added ledger view (thanks opencode)
This commit is contained in:
@@ -155,34 +155,27 @@ async function seed() {
|
||||
const dbEventTypes = await db.select().from(schema.eventTypes);
|
||||
const eventTypeMap = new Map(dbEventTypes.map((et) => [et.name, et.id]));
|
||||
|
||||
// Seed registered events
|
||||
// Seed registered events (auto-generated from event types × divisions, ordered by division then event type)
|
||||
const eventNameMap = new Map<string, number>();
|
||||
const registeredEventsCSV = readCSV('registeredEvents.csv');
|
||||
|
||||
for (const row of registeredEventsCSV) {
|
||||
const eventTypeId = eventTypeMap.get(row.event_type);
|
||||
const teamId = teamMap.get(row.winner);
|
||||
const divisionId = divisionMap.get(row.division);
|
||||
|
||||
if (!eventTypeId) throw new Error(`Event Type "${row.event_type}" not found`);
|
||||
if (!divisionId) throw new Error(`Division "${row.division}" not found`);
|
||||
|
||||
for (const division of dbDivisions) {
|
||||
for (const eventType of dbEventTypes) {
|
||||
const [inserted] = await db
|
||||
.insert(schema.registeredEvents)
|
||||
.values({
|
||||
eventType: eventTypeId,
|
||||
division: divisionId,
|
||||
state: row.event_state || 0,
|
||||
timeCompleted: row.time_completed || null,
|
||||
teamWinner: teamId || null
|
||||
eventType: eventType.id,
|
||||
division: division.id,
|
||||
state: 0,
|
||||
timeCompleted: null,
|
||||
teamWinner: null
|
||||
})
|
||||
.returning();
|
||||
console.log(
|
||||
` → Registered Event [id:${inserted.id}]: ${row.event_type} | ${row.division}, winner: ${teamId}, ${row.winner}`
|
||||
` → Registered Event [id:${inserted.id}]: ${eventType.name} | ${division.name}`
|
||||
);
|
||||
|
||||
// Map event name|division to the generated event ID
|
||||
eventNameMap.set(`${row.event_type}|${row.division}`, inserted.id);
|
||||
eventNameMap.set(`${eventType.name}|${division.name}`, inserted.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Seed registered players (linking players to events)
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function validateSessionToken(token: string) {
|
||||
row.session.expiresAt = newExpiresAt;
|
||||
}
|
||||
|
||||
return { session: row.session, user: { id: row.user.id, username: row.user.username } };
|
||||
return { session: row.session, user: { id: row.user.id, username: row.user.username, role: row.user.role } };
|
||||
}
|
||||
|
||||
export async function invalidateSession(sessionId: string) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { sql, eq, and } from 'drizzle-orm';
|
||||
import { sql, eq, and, desc, inArray } from 'drizzle-orm';
|
||||
import * as schema from '$lib/server/db/schema';
|
||||
import { globalEmitter } from './globalEmitter';
|
||||
|
||||
@@ -214,3 +214,61 @@ export async function getRegisteredEventsWithPlayers(eventId?: number) {
|
||||
}
|
||||
return fullEventList;
|
||||
}
|
||||
|
||||
export async function getLedgerEntries(limit: number | 'all' = 100) {
|
||||
let query = db
|
||||
.select({
|
||||
id: schema.mainLedger.id,
|
||||
timestamp: schema.mainLedger.timestamp,
|
||||
updateType: schema.mainLedger.updateType,
|
||||
eventName: schema.eventTypes.name
|
||||
})
|
||||
.from(schema.mainLedger)
|
||||
.leftJoin(
|
||||
schema.registeredEvents,
|
||||
eq(schema.mainLedger.registeredEvent, schema.registeredEvents.id)
|
||||
)
|
||||
.leftJoin(
|
||||
schema.eventTypes,
|
||||
eq(schema.registeredEvents.eventType, schema.eventTypes.id)
|
||||
)
|
||||
.orderBy(desc(schema.mainLedger.timestamp)) as any;
|
||||
|
||||
if (limit !== 'all') {
|
||||
query = query.limit(limit);
|
||||
}
|
||||
|
||||
const entries = await query;
|
||||
|
||||
if (entries.length === 0) return [];
|
||||
|
||||
const ids = entries.map((e: any) => e.id);
|
||||
|
||||
const scores = await db
|
||||
.select({
|
||||
ledgerID: schema.scoreLedger.ledgerID,
|
||||
teamName: schema.teams.name,
|
||||
points: schema.scoreLedger.points
|
||||
})
|
||||
.from(schema.scoreLedger)
|
||||
.leftJoin(schema.teams, eq(schema.scoreLedger.teamID, schema.teams.id))
|
||||
.where(inArray(schema.scoreLedger.ledgerID, ids));
|
||||
|
||||
const scoresByLedger = new Map<number, Record<string, number>>();
|
||||
for (const s of scores) {
|
||||
if (s.ledgerID == null || s.teamName == null) continue;
|
||||
if (!scoresByLedger.has(s.ledgerID)) {
|
||||
scoresByLedger.set(s.ledgerID, {});
|
||||
}
|
||||
const map = scoresByLedger.get(s.ledgerID)!;
|
||||
map[s.teamName] = (map[s.teamName] || 0) + s.points;
|
||||
}
|
||||
|
||||
return entries.map((e: any) => ({
|
||||
id: e.id,
|
||||
type: e.updateType,
|
||||
eventName: e.eventName || '',
|
||||
timestamp: e.timestamp,
|
||||
teamPoints: scoresByLedger.get(e.id) || {}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
const rowOffsetTop = targetRow.offsetTop;
|
||||
const rowHeight = targetRow.offsetHeight;
|
||||
const containerHeight = containerRef.offsetHeight;
|
||||
|
||||
const centerScrollTarget = rowOffsetTop - containerHeight / 2 + rowHeight / 2;
|
||||
|
||||
@@ -32,7 +33,6 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={containerRef} class="table-container" style="max-height: {maxHeight};">
|
||||
|
||||
47
src/routes/api/ledger/+server.ts
Normal file
47
src/routes/api/ledger/+server.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { getLedgerEntries } from '$lib/server/databaseManager';
|
||||
import { globalEmitter } from '$lib/server/globalEmitter';
|
||||
import { db } from '$lib/server/db';
|
||||
import * as schema from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET({ url }: any) {
|
||||
const limit = url.searchParams.get('limit') || '100';
|
||||
const entries = await getLedgerEntries(limit === 'all' ? 'all' : parseInt(limit));
|
||||
return new Response(JSON.stringify(entries), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST({ request, locals }: any) {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { eventId, teamPoints: teamPointsByName } = body;
|
||||
|
||||
const teams = await db.select().from(schema.teams);
|
||||
const teamNameToId = new Map(teams.map((t) => [t.name, t.id]));
|
||||
|
||||
const newLedgerEntry = await db
|
||||
.insert(schema.mainLedger)
|
||||
.values({ registeredEvent: eventId, updateType: 'admin' })
|
||||
.returning();
|
||||
|
||||
const ledgerEntryId = newLedgerEntry[0].id;
|
||||
|
||||
const scoreEntries: { ledgerID: number; teamID: number; points: number }[] = [];
|
||||
for (const [name, points] of Object.entries(teamPointsByName as Record<string, number>)) {
|
||||
if (points === 0) continue;
|
||||
const teamID = teamNameToId.get(name);
|
||||
if (teamID === undefined) continue;
|
||||
scoreEntries.push({ ledgerID: ledgerEntryId, teamID, points });
|
||||
}
|
||||
|
||||
if (scoreEntries.length > 0) {
|
||||
await db.insert(schema.scoreLedger).values(scoreEntries);
|
||||
}
|
||||
|
||||
globalEmitter.emit('scoreUpdate');
|
||||
return new Response('ok');
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// src/routes/admin/+page.server.ts
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db';
|
||||
import { scorers } from '$lib/server/db/schema';
|
||||
import { scorers, teams } from '$lib/server/db/schema';
|
||||
import { getRegisteredEvents } from '$lib/server/databaseManager';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
@@ -19,5 +19,8 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
return { user: locals.user };
|
||||
const allTeams = await db.select({ id: teams.id, name: teams.name }).from(teams).orderBy(teams.name);
|
||||
const events = await getRegisteredEvents();
|
||||
|
||||
return { teams: allTeams, events: events.events.map((e) => ({ id: e.id, name: e.name, division: e.division })) };
|
||||
};
|
||||
|
||||
@@ -1 +1,134 @@
|
||||
<div>some</div>
|
||||
<script lang="ts">
|
||||
import Table from '$lib/ui/Table.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
type LedgerEntry = {
|
||||
id: number;
|
||||
type: string;
|
||||
eventName: string;
|
||||
timestamp: string;
|
||||
teamPoints: Record<string, number>;
|
||||
};
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let entries = $state<LedgerEntry[]>([]);
|
||||
let showAll = $state(false);
|
||||
let selectedEventId = $state<number | ''>('');
|
||||
let inputPoints = $state<Record<string, string>>({});
|
||||
let submitting = $state(false);
|
||||
|
||||
let teamNames = $derived(data.teams.map((t) => t.name).sort());
|
||||
|
||||
function formatTime(ts: string) {
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
}
|
||||
|
||||
function formatPoints(n: number) {
|
||||
return n >= 0 ? `+${n}` : `${n}`;
|
||||
}
|
||||
|
||||
async function loadEntries(all: boolean = false) {
|
||||
const res = await fetch(`/api/ledger?limit=${all ? 'all' : '100'}`);
|
||||
entries = await res.json();
|
||||
showAll = all;
|
||||
}
|
||||
|
||||
async function submitEntry() {
|
||||
const event = data.events.find((e) => e.id === selectedEventId);
|
||||
if (!event) return;
|
||||
|
||||
submitting = true;
|
||||
try {
|
||||
const teamPoints: Record<string, number> = {};
|
||||
for (const [team, val] of Object.entries(inputPoints)) {
|
||||
const num = parseInt(val);
|
||||
if (!isNaN(num) && num !== 0) {
|
||||
teamPoints[team] = num;
|
||||
}
|
||||
}
|
||||
|
||||
await fetch('/api/ledger', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ eventId: event.id, teamPoints })
|
||||
});
|
||||
|
||||
selectedEventId = '';
|
||||
inputPoints = {};
|
||||
await loadEntries(showAll);
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadEntries();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="p-4">
|
||||
<h2 class="mb-4 text-lg font-bold">Manual Ledger Entry</h2>
|
||||
<div class="mb-4 flex flex-wrap gap-4 items-end">
|
||||
<div>
|
||||
<label class="block text-sm mb-1">Event</label>
|
||||
<select bind:value={selectedEventId} class="border rounded px-2 py-1">
|
||||
<option value="">Select event...</option>
|
||||
{#each data.events as event (event.id)}
|
||||
<option value={event.id}>{event.name} — {event.division}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{#each teamNames as team}
|
||||
<div>
|
||||
<label class="block text-sm mb-1">{team}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={inputPoints[team] ?? '0'}
|
||||
oninput={(e) => { inputPoints[team] = (e.target as HTMLInputElement).value; }}
|
||||
class="border rounded px-2 py-1 w-20"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
<button
|
||||
onclick={submitEntry}
|
||||
disabled={!selectedEventId || submitting}
|
||||
class="border rounded px-4 py-1 bg-green-700 text-white disabled:opacity-50"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Table data={entries} maxHeight="none">
|
||||
{#snippet header()}
|
||||
<th class="px-2">ID</th>
|
||||
<th class="px-2">Type</th>
|
||||
<th class="px-2">Event</th>
|
||||
{#each teamNames as team}
|
||||
<th class="px-2">{team}</th>
|
||||
{/each}
|
||||
<th class="px-2">Time</th>
|
||||
{/snippet}
|
||||
{#snippet row(entry: LedgerEntry)}
|
||||
<td class="px-2">{entry.id}</td>
|
||||
<td class="px-2">{entry.type}</td>
|
||||
<td class="px-2">{entry.eventName}</td>
|
||||
{#each teamNames as team}
|
||||
<td class="px-2">
|
||||
{entry.teamPoints[team] !== undefined ? formatPoints(entry.teamPoints[team]) : ''}
|
||||
</td>
|
||||
{/each}
|
||||
<td class="px-2">{formatTime(entry.timestamp)}</td>
|
||||
{/snippet}
|
||||
</Table>
|
||||
|
||||
<div class="mt-4">
|
||||
<button
|
||||
onclick={() => loadEntries(!showAll)}
|
||||
class="border rounded px-4 py-1"
|
||||
>
|
||||
{showAll ? 'Show 100 most recent' : 'Load all entries'}
|
||||
</button>
|
||||
<span class="ml-2 text-sm">{entries.length} entries shown</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user