Compare commits

..

3 Commits

Author SHA1 Message Date
d1abc83074 wow a scoring page! sure hope that the endpoint exists... oh 2026-05-30 17:29:18 +01:00
10010631f5 added a header so i can navigate 2026-05-30 17:29:02 +01:00
a44ee668c8 i cant read, and it punishes me 2026-05-30 17:28:46 +01:00
5 changed files with 232 additions and 34 deletions

View File

@@ -132,7 +132,7 @@ async function seed() {
if (!presetId) throw new Error(`Team "${row.resultPreset}" not found`);
await db.insert(schema.eventTypes).values({
name: row.event_name,
preset: row.preset,
scoringPreset: row.preset,
resultPreset: presetId
});
console.log(

View File

@@ -38,6 +38,14 @@ export async function getRegisteredEvents(eventId?: number) {
return teamInfo.teams[0];
}
async function getScoringPreset(presetId: number) {
const presets = await db
.select()
.from(schema.scoringPresets)
.where(presetId ? eq(schema.scoringPresets.presetID, presetId) : undefined);
return presets;
}
const allEvents = await db
.select()
.from(schema.registeredEventsView)
@@ -51,6 +59,7 @@ export async function getRegisteredEvents(eventId?: number) {
state: events.state,
completed: events.timeCompleted || 0,
resultPreset: events.resultPreset,
scoringPreset: events.scorePreset ? await getScoringPreset(events.scorePreset) : 'UNDECIDED',
winner: events.winner ? await getWinnerInfo(events.winner) : 'UNDECIDED'
}))
);

View File

@@ -46,7 +46,7 @@ export const resultPresets = sqliteTable('resultPresets', {
export const eventTypes = sqliteTable('eventTypes', {
id: integer('eventTypes_id').primaryKey({ autoIncrement: true }),
name: text('event_name').notNull(),
preset: integer('preset')
scoringPreset: integer('preset')
.references(() => scoringPresets.presetID)
.notNull(),
resultPreset: integer('result_preset')
@@ -133,6 +133,7 @@ export const registeredEventsView = sqliteView('registeredEventsView').as((qb) =
state: registeredEvents.state,
timeCompleted: registeredEvents.timeCompleted,
winner: registeredEvents.teamWinner,
scorePreset: eventTypes.scoringPreset,
resultPreset: eventTypes.resultPreset
})
.from(registeredEvents)

View File

@@ -7,4 +7,17 @@
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
<div class="header goldman align-center flex h-15 w-full">
<a
class="align-text-middle mx-3 my-1 h-auto content-center rounded-sm border-2 border-solid border-red-500 px-2"
href="/">home</a
>
</div>
{@render children()}
<style>
.header {
background-color: var(--ctp-mocha-crust);
}
</style>

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import type { PageProps } from './$types';
import { flip } from 'svelte/animate';
let { params }: PageProps = $props();
function ordinal(n: number) {
@@ -28,65 +30,238 @@
}
let eventId = params.eventId;
let eventEndpoint: EventSource;
async function getEventData() {
let response = await fetch('/api/registeredEvents', {
method: 'POST',
body: JSON.stringify({
eventId: eventId
}),
headers: {
'Content-type': 'application/json; charset=UTF-8'
}
});
return response.json();
type Player = { firstName: string; lastName: string; teamColor: string; [key: string]: any };
type Bracket = { name: string; items: Player[]; [key: string]: any };
let event = $state<any>(null);
let brackets = $state<Bracket[]>([]);
let loading = $state(true);
let dropTarget = $state<{ bi: number; pi: number } | null>(null);
let submitStatus = $state<'idle' | 'submitting' | 'done' | 'error'>('idle');
// Drag state
let dragSrc = $state<{ bi: number; pi: number } | null>(null);
let sortByScore = $state(true);
let numResults = $derived(event?.resultPresets[0]?.numberOfResults ?? 2);
let useAverage = $derived(event?.resultPresets[0]?.averageResults ?? true);
let pendingScores = $state<Record<string, string[]>>({});
let committedScores = $state<Record<string, (number | null)[]>>({});
function average(scores: (number | null)[]): number {
const valid = scores.filter((s): s is number => s !== null);
if (valid.length === 0) return Infinity;
return valid.reduce((a, b) => a + b, 0) / valid.length;
}
let eventDataPromise = getEventData();
function best(scores: (number | null)[]): number {
const valid = scores.filter((s): s is number => s !== null);
if (valid.length === 0) return Infinity;
return Math.min(...valid);
}
let displayBrackets = $derived(
brackets.map((bracket) => ({
...bracket,
items: sortByScore
? [...bracket.items].sort((a, b) => {
const sa = useAverage
? average(committedScores[a.id] ?? [])
: best(committedScores[a.id] ?? []);
const sb = useAverage
? average(committedScores[b.id] ?? [])
: best(committedScores[b.id] ?? []);
return sa - sb;
})
: bracket.items
}))
);
onMount(async () => {
const response = await fetch('/api/registeredEvents', {
method: 'POST',
body: JSON.stringify({ eventId }),
headers: { 'Content-type': 'application/json; charset=UTF-8' }
});
const data = await response.json();
console.log(data);
event = data[0];
brackets = data[0].registeredPlayers.map((b: any) => ({
...b,
items: [...b.items]
}));
loading = false;
onMount(() => {
eventEndpoint = new EventSource('/api/registeredEvents');
// eventEndpoint.onmessage = (e) => {
// const eventData = JSON.parse(e.data);
// console.log(eventData);
// };
});
onDestroy(() => {
eventEndpoint?.close();
});
function onDragStart(bi: number, pi: number) {
dragSrc = { bi, pi };
}
function onDragOver(e: DragEvent, bi: number, pi: number) {
if (dragSrc?.bi === bi) {
e.preventDefault();
dropTarget = { bi, pi };
}
}
function onDrop(bi: number, pi: number) {
if (!dragSrc || dragSrc.bi !== bi) return;
const [src] = brackets[dragSrc.bi].items.splice(dragSrc.pi, 1);
brackets[bi].items.splice(pi, 0, src);
brackets = [...brackets];
dragSrc = null;
dropTarget = null;
}
function onDragEnd() {
dragSrc = null;
dropTarget = null;
}
async function submitResults() {
submitStatus = 'submitting';
try {
const res = await fetch('/api/eventResults', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
eventId,
brackets: brackets.map((b) => ({
name: b.name,
players: b.items.map((p, i) => ({ ...p, position: i + 1 }))
}))
})
});
submitStatus = res.ok ? 'done' : 'error';
} catch {
submitStatus = 'error';
}
}
</script>
{#await eventDataPromise}
{#if loading}
<div>loading</div>
{:then eventData}
{@const event = eventData[0]}
{:else}
<div class="flex justify-center">
<div class="w-full flex-col px-[5vw] text-center">
{console.log(event)}
<div class="align-text-middle h-10 w-full bg-red-500">
{event.name} - {event.division} - scoring
</div>
<button onclick={() => (sortByScore = !sortByScore)}>
{sortByScore ? 'Sort: Score' : 'Sort: Manual'}
</button>
<div class="flex flex-row justify-center">
{#each event.registeredPlayers as bracket, bi}
<!-- {#each some as some} -->
<!-- {/each} -->
{#each displayBrackets as bracket, bi}
<div class="w-full min-w-0">
<div class="brackets-name text-bold">
<span class="brackets-name-text">{bracket.name}</span>
<div class=""></div>
<span class="brackets-name-text" role="listbox" aria-label={bracket.name}
>{bracket.name}</span
>
</div>
{#each bracket.items as player}
<div class="" style="color:{player.teamColor}">
<div class="player-name-wrap" use:marquee>
{player.firstName}
{player.lastName}
{#each bracket.items as player, pi (player.id)}
{@const playerScores = committedScores[player.id] ?? []}
{@const validScores = playerScores.filter((s): s is number => s !== null)}
{@const avgScore =
validScores.length > 0
? validScores.reduce((a, b) => a + b, 0) / validScores.length
: 0}
<div
animate:flip={{ duration: 300 }}
class="scoring-player-card"
style="--player-color:{player.teamColor}"
>
<div
class="player-name-wrap cursor-grab active:cursor-grabbing"
class:opacity-50={dragSrc?.bi === bi && dragSrc?.pi === pi}
style="color:{player.teamColor}"
role="option"
aria-selected={false}
draggable="true"
tabindex="0"
ondragstart={() => onDragStart(bi, pi)}
class:drop-target={dropTarget?.bi === bi && dropTarget?.pi === pi}
ondragover={(e) => onDragOver(e, bi, pi)}
ondrop={() => onDrop(bi, pi)}
ondragend={onDragEnd}
>
<div use:marquee>
{player.firstName}
{player.lastName}
</div>
<div class="result-input-containers flex flex-col">
{#each Array.from({ length: numResults }, (_, i) => i) as run}
<input
type="number"
placeholder="run {run + 1}"
value={pendingScores[player.id]?.[run] ?? ''}
oninput={(e) => {
const current = [
...(pendingScores[player.id] ?? Array(numResults).fill(''))
];
current[run] = e.currentTarget.value;
pendingScores[player.id] = current;
}}
onblur={(e) => {
const val = parseFloat(e.currentTarget.value);
const current = [
...(committedScores[player.id] ?? Array(numResults).fill(null))
];
current[run] = isNaN(val) ? null : val;
committedScores[player.id] = current;
}}
/>
{/each}
{#if event.resultPresets[0].averageResults == 1}{/if}
</div>
</div>
<div class="text-sm opacity-60">
avg: {avgScore.toFixed(2)}
</div>
</div>
{/each}
</div>
{/each}
</div>
<button
class="mt-4 rounded px-6 py-2 text-white"
class:bg-green-600={submitStatus === 'idle'}
class:bg-gray-400={submitStatus === 'submitting'}
class:bg-blue-600={submitStatus === 'done'}
class:bg-red-600={submitStatus === 'error'}
disabled={submitStatus === 'submitting' || submitStatus === 'done'}
onclick={submitResults}
>
{#if submitStatus === 'idle'}Submit Results
{:else if submitStatus === 'submitting'}Submitting…
{:else if submitStatus === 'done'}Submitted ✓
{:else}Error — Retry{/if}
</button>
</div>
</div>
{/await}
{/if}
<style>
.drop-target {
outline: 2px dotted currentColor;
}
.scoring-player-card {
margin: 5px;
background: color-mix(in srgb, var(--player-color) 10%, transparent);
}
</style>