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 dbEventTypes = await db.select().from(schema.eventTypes);
|
||||||
const eventTypeMap = new Map(dbEventTypes.map((et) => [et.name, et.id]));
|
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 eventNameMap = new Map<string, number>();
|
||||||
const registeredEventsCSV = readCSV('registeredEvents.csv');
|
|
||||||
|
|
||||||
for (const row of registeredEventsCSV) {
|
for (const division of dbDivisions) {
|
||||||
const eventTypeId = eventTypeMap.get(row.event_type);
|
for (const eventType of dbEventTypes) {
|
||||||
const teamId = teamMap.get(row.winner);
|
const [inserted] = await db
|
||||||
const divisionId = divisionMap.get(row.division);
|
.insert(schema.registeredEvents)
|
||||||
|
.values({
|
||||||
|
eventType: eventType.id,
|
||||||
|
division: division.id,
|
||||||
|
state: 0,
|
||||||
|
timeCompleted: null,
|
||||||
|
teamWinner: null
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
console.log(
|
||||||
|
` → Registered Event [id:${inserted.id}]: ${eventType.name} | ${division.name}`
|
||||||
|
);
|
||||||
|
|
||||||
if (!eventTypeId) throw new Error(`Event Type "${row.event_type}" not found`);
|
eventNameMap.set(`${eventType.name}|${division.name}`, inserted.id);
|
||||||
if (!divisionId) throw new Error(`Division "${row.division}" not found`);
|
}
|
||||||
|
|
||||||
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
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
console.log(
|
|
||||||
` → Registered Event [id:${inserted.id}]: ${row.event_type} | ${row.division}, winner: ${teamId}, ${row.winner}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Map event name|division to the generated event ID
|
|
||||||
eventNameMap.set(`${row.event_type}|${row.division}`, inserted.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed registered players (linking players to events)
|
// Seed registered players (linking players to events)
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export async function validateSessionToken(token: string) {
|
|||||||
row.session.expiresAt = newExpiresAt;
|
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) {
|
export async function invalidateSession(sessionId: string) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from '$lib/server/db';
|
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 * as schema from '$lib/server/db/schema';
|
||||||
import { globalEmitter } from './globalEmitter';
|
import { globalEmitter } from './globalEmitter';
|
||||||
|
|
||||||
@@ -214,3 +214,61 @@ export async function getRegisteredEventsWithPlayers(eventId?: number) {
|
|||||||
}
|
}
|
||||||
return fullEventList;
|
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 rowOffsetTop = targetRow.offsetTop;
|
||||||
const rowHeight = targetRow.offsetHeight;
|
const rowHeight = targetRow.offsetHeight;
|
||||||
|
const containerHeight = containerRef.offsetHeight;
|
||||||
|
|
||||||
const centerScrollTarget = rowOffsetTop - containerHeight / 2 + rowHeight / 2;
|
const centerScrollTarget = rowOffsetTop - containerHeight / 2 + rowHeight / 2;
|
||||||
|
|
||||||
@@ -32,7 +33,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={containerRef} class="table-container" style="max-height: {maxHeight};">
|
<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 { error, redirect } from '@sveltejs/kit';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { db } from '$lib/server/db';
|
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';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
@@ -19,5 +19,8 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
throw error(403, 'Forbidden');
|
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