added ledger view (thanks opencode)

This commit is contained in:
2026-07-04 15:51:49 +01:00
parent bfa49cfa91
commit ec56e3794e
7 changed files with 266 additions and 32 deletions

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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) || {}
}));
}

View File

@@ -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};">

View 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');
}

View File

@@ -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 })) };
};

View File

@@ -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>