Compare commits

..

6 Commits

9 changed files with 548 additions and 157 deletions

View File

@@ -2,7 +2,7 @@ event_type,division,event_state,time_completed
100m Sprint,Year 7,0, 100m Sprint,Year 7,0,
100m Sprint,Year 8,0, 100m Sprint,Year 8,0,
100m Sprint,Year 9,0, 100m Sprint,Year 9,0,
100m Sprint,Year 10,0, 100m Sprint,Year 10,1,
200m Sprint,Year 7,0, 200m Sprint,Year 7,0,
200m Sprint,Year 8,0, 200m Sprint,Year 8,0,
200m Sprint,Year 9,0, 200m Sprint,Year 9,0,
1 event_type division event_state time_completed
2 100m Sprint Year 7 0
3 100m Sprint Year 8 0
4 100m Sprint Year 9 0
5 100m Sprint Year 10 0 1
6 200m Sprint Year 7 0
7 200m Sprint Year 8 0
8 200m Sprint Year 9 0

View File

@@ -1,24 +0,0 @@
import * as database from './databaseManager.ts';
import { globalEmitter } from './databaseManager.ts';
// TODO Implement caching of info at some point
// structure:
// databaseManager gets raw data from backend
// stored in variables in cacheManager
// sent to frontend using same function names in dataManager (not made)
let teamsCache;
export async function updateTeamsCache() {
teamsCache = database.getTeams();
}
globalEmitter.on('invalTeamsCache', updateTeamsCache);
let eventsCache;
export async function updateEventsCache() {
eventsCache = database.getRegisteredEvents();
}
globalEmitter.on('invalEventsCache', updateEventsCache);

View File

@@ -1,21 +1,7 @@
import { EventEmitter } from 'node:events';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import * as schema from '$lib/server/db/schema'; import * as schema from '$lib/server/db/schema';
// Emitter that emits
export const globalEmitter = new EventEmitter();
//// REFERENCE CODE
// Increment score for testing (remove ts)
// const increment = () => {
// testScore++;
// console.log('score incremented', testScore);
// globalEmitter.emit('scoreUpdate');
// };
// Increment scores when there is an emit
// globalEmitter.on('incrementScores', increment);
// For page.server.ts so that it doesnt look weird before loading // For page.server.ts so that it doesnt look weird before loading
export async function getAllInitialInfo() { export async function getAllInitialInfo() {
return { return {
@@ -44,7 +30,6 @@ export async function getTeams() {
// Get all registered events from database // Get all registered events from database
export async function getRegisteredEvents(eventId?: number) { export async function getRegisteredEvents(eventId?: number) {
console.log('eventId: ', eventId);
const allEvents = await db const allEvents = await db
.select() .select()
.from(schema.registeredEventsView) .from(schema.registeredEventsView)
@@ -98,11 +83,11 @@ export async function getAllBrackets() {
}; };
} }
export async function getResultPreset(presetId: number) { export async function getResultPreset(presetId?: number) {
const resultPresets = await db const resultPresets = await db
.select() .select()
.from(schema.resultPresets) .from(schema.resultPresets)
.where(eq(schema.resultPresets.id, presetId)); .where(presetId ? eq(schema.resultPresets.id, presetId) : undefined);
return { return {
resultPresets: resultPresets resultPresets: resultPresets

View File

@@ -0,0 +1,8 @@
import { EventEmitter } from 'node:events';
// Main emitter for everything
export const globalEmitter = new EventEmitter();
// Emitter for cache events
export const cacheEmitter = new EventEmitter();

View File

@@ -1,92 +1,102 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy, tick } from 'svelte';
// import { enhance } from '$app/forms';
import Table from '$lib/ui/Table.svelte';
// Get initial data from the load thing (innacurate lol) let {
let { data }: { data: import('./$types').PageData } = $props(); data
}: {
data: import('./$types').PageData;
focusEventId?: number | null;
} = $props();
// Derived unordered
let teams = $derived(data.teams.teams); let teams = $derived(data.teams.teams);
let eventTable = $derived(data.events.events); let eventTable = $derived(data.events.events);
//// Leaderboard Database logic
// new event source for websocket
let scoreEndpoint: EventSource; let scoreEndpoint: EventSource;
let eventEndpoint: EventSource; let eventEndpoint: EventSource;
onMount(() => { // Ref map for event card DOM nodes
// get endpoint let eventRefs = $state<Record<number, HTMLElement>>({});
scoreEndpoint = new EventSource('/api/teams');
// when you get a message do something onMount(() => {
scoreEndpoint = new EventSource('/api/teams');
scoreEndpoint.onmessage = (e) => { scoreEndpoint.onmessage = (e) => {
const teamsData = JSON.parse(e.data); const teamsData = JSON.parse(e.data);
// If the message has a teams object update the score thing if (teamsData['teams']) teams = teamsData['teams'];
if (teamsData['teams']) {
teams = teamsData['teams'];
console.log('teams updated');
}
}; };
// Player endpoint
eventEndpoint = new EventSource('/api/registeredEvents'); eventEndpoint = new EventSource('/api/registeredEvents');
eventEndpoint.onmessage = (e) => { eventEndpoint.onmessage = (e) => {
const eventData = JSON.parse(e.data); eventTable = JSON.parse(e.data);
console.log(eventData); console.log(eventTable);
eventTable = eventData;
}; };
}); });
// When window destroyed close the websocket connection onDestroy(() => {
onDestroy(() => scoreEndpoint?.close()); scoreEndpoint?.close();
eventEndpoint?.close();
});
// Order leaderboard so that its displayed correctly
let leaderboard = $derived([...teams].sort((a, b) => b.points - a.points)); let leaderboard = $derived([...teams].sort((a, b) => b.points - a.points));
let focusEventId = $derived([...eventTable].reverse().find((e) => e.state == 1)?.id ?? null);
function ordinal(n: number) {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] ?? s[v] ?? s[0]);
}
// When focusEventId changes, scroll to and highlight that event card
// TODO make this scrolling shit work idk
$effect(() => {
if (focusEventId == null) return;
tick().then(() => {
const el = eventRefs[focusEventId!];
const container = el?.closest('.events-scroll') as HTMLElement;
if (el && container) {
const elMid = el.offsetTop + el.offsetHeight / 2;
const targetScroll = elMid - container.clientHeight / 2 - 60;
container.scrollTo({ top: targetScroll, behavior: 'smooth' });
el.classList.remove('highlight-pulse');
void el.offsetWidth;
el.classList.add('highlight-pulse');
}
});
});
// Svelte action: measures text overflow and drives CSS marquee
function marquee(node: HTMLElement) {
function measure() {
const inner = node.querySelector<HTMLElement>('.marquee-inner');
if (!inner) return;
const overflow = inner.scrollWidth - node.clientWidth;
if (overflow > 2) {
node.style.setProperty('--scroll-dist', `-${overflow + 6}px`);
inner.classList.add('scrolling');
} else {
inner.classList.remove('scrolling');
}
}
measure();
const ro = new ResizeObserver(measure);
ro.observe(node);
return { destroy: () => ro.disconnect() };
}
</script> </script>
{#snippet header()}
<th>Event</th>
<th>Division</th>
<th>Players</th>
{/snippet}
{#snippet row(d: any)}
<td>{d.name}</td>
<td>{d.division}</td>
<td>
<div class="">
{#each d.registeredPlayers as bracket}
<div class="flex justify-center">
{#each bracket.items as player}
<div
style="--theme-color: {player.teamColor};"
class="player-box w-min-0 m-1 flex-1 flex-col rounded-md border-2"
>
<div>{player.firstName} {player.lastName}</div>
<div></div>
{#if player.placement != 0}
<div>{player.placement}</div>
{/if}
</div>
{/each}
</div>
{/each}
</div>
</td>
{/snippet}
<svelte:window onbeforeunload={() => scoreEndpoint?.close()} /> <svelte:window onbeforeunload={() => scoreEndpoint?.close()} />
<div class="flex max-h-[150vh] flex-col object-contain p-[2vw]"> <div class="page">
{#each leaderboard as team (team.name)} <!-- ═══════════ LEADERBOARD ═══════════ -->
<div <section class="leaderboard">
style="--theme-color: {team.color};" <!-- Winner — always full-width -->
class="score-box mx-[10vw] mb-2 grid aspect-3/1 min-h-0 min-w-[70vw] flex-1 grid-cols-1 grid-rows-1 overflow-hidden rounded-2xl border-5 *:col-span-full *:row-end-[-1] *:flex *:items-center *:justify-center first:aspect-2/1" {#if leaderboard[0]}
{@const team = leaderboard[0]}
<a
href="/team/{team.id}"
class="score-box winner"
style="--c:{team.color}"
aria-label="{team.name}, 1st place, {team.points} points"
> >
<div class="black-ops-one-regular @container uppercase opacity-60"> <div class="score-ghost" aria-hidden="true">
<svg viewBox="0 0.1 100 0.6" preserveAspectRatio="none" class="h-full w-full fill-current"> <svg viewBox="0 0.1 100 0.6" preserveAspectRatio="none" class="ghost-svg">
<text <text
x="0" x="0"
y="0.7" y="0.7"
@@ -94,49 +104,442 @@
dominant-baseline="auto" dominant-baseline="auto"
textLength="100" textLength="100"
lengthAdjust="spacingAndGlyphs" lengthAdjust="spacingAndGlyphs"
class="goldman-bold" font-family="'Black Ops One',system-ui">{team.name}</text
> >
{team.name}
</text>
</svg> </svg>
</div> </div>
<div class="text-[20cqh]"> <div class="score-fg">
<p>{team.points.toString().padStart(3, '0')}</p> <div class="score-meta">
<span class="score-rank">1st place</span>
<span class="score-name goldman">{team.name}</span>
</div>
<span class="score-pts goldman">{team.points.toString().padStart(3, '0')}</span>
</div>
</a>
{/if}
<!-- Runners-up: stretch to fill row, max 5 wide, 1 col on small -->
{#if leaderboard.length > 1}
<div class="runners-grid" style="--runner-count:{Math.min(leaderboard.length - 1, 5)}">
{#each leaderboard.slice(1) as team, i (team.name)}
<a
href="/team/{team.id}"
class="score-box runner"
style="--c:{team.color}"
aria-label="{team.name}, {ordinal(i + 2)} place, {team.points} points"
>
<div class="score-ghost" aria-hidden="true">
<svg viewBox="0 0.1 100 0.6" preserveAspectRatio="none" class="ghost-svg">
<text
x="0"
y="0.7"
font-size="1"
dominant-baseline="auto"
textLength="100"
lengthAdjust="spacingAndGlyphs"
font-family="'Black Ops One',system-ui">{team.name}</text
>
</svg>
</div>
<div class="score-fg">
<div class="score-meta">
<span class="score-rank">{ordinal(i + 2)}</span>
<span class="score-name goldman">{team.name}</span>
</div>
<span class="score-pts goldman">{team.points.toString().padStart(3, '0')}</span>
</div>
</a>
{/each}
</div>
{/if}
</section>
<!-- ═══════════ EVENTS TABLE ═══════════ -->
<p class="section-label">Events</p>
<section class="events-scroll">
<div class="events">
{#each eventTable as event (event.id)}
<div class="event-card" bind:this={eventRefs[event.id]}>
<div class="event-header">
<a href="/event/{event.id}" class="event-name goldman">{event.name}</a>
<span class="event-division">{event.division}</span>
</div>
<div class="brackets">
{#each event.registeredPlayers as bracket, bi}
{#if bi > 0}
<div class="bracket-sep" aria-hidden="true"></div>
{/if}
<div class="bracket-row">
{#each bracket.items as player}
<div class="player-box" style="--c:{player.teamColor}">
<div class="player-ghost" aria-hidden="true">
<svg viewBox="0 0.1 100 0.6" preserveAspectRatio="none" class="ghost-svg">
<text
x="0"
y="0.7"
font-size="1"
dominant-baseline="auto"
textLength="100"
lengthAdjust="spacingAndGlyphs"
font-family="'Black Ops One',system-ui">{player.firstName}</text
>
</svg>
</div>
<div class="player-name-wrap" use:marquee>
<span class="marquee-inner">
{player.firstName}
{player.lastName}
</span>
</div>
{#if player.placement !== 0}
<div class="player-placement goldman">{ordinal(player.placement)}</div>
{:else}
<div class="player-placement-gap"></div>
{/if}
</div>
{/each}
</div>
{/each}
</div> </div>
</div> </div>
{/each} {/each}
</div> </div>
</section>
<button </div>
onclick={() =>
// Onclick send a request to the post endpoint
fetch('/api/teams', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
})}
>
Send update
</button>
<Table data={eventTable} maxHeight="500px" focusId="20" {header} {row} />
<style> <style>
@import url('https://cdn.jsdelivr.net/npm/@catppuccin/palette/css/catppuccin.css');
@import url('https://fonts.googleapis.com/css2?family=Black+Ops+One&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Black+Ops+One&display=swap');
.black-ops-one-regular { :global(html),
:global(body) {
min-height: 100vh;
}
:global(body > div), /* SvelteKit's root wrapper */
:global(#svelte) {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.page {
display: flex;
flex-direction: column;
max-height: 150vh;
max-width: 1000px;
margin: 0 auto;
width: 100%;
}
.goldman {
font-family: 'Black Ops One', system-ui; font-family: 'Black Ops One', system-ui;
font-weight: 400; font-weight: 400;
font-style: normal;
} }
/* ── Leaderboard ── */
.leaderboard {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 14px 6px;
}
.score-box { .score-box {
color: var(--theme-color); position: relative;
border-color: var(--theme-color); overflow: hidden;
background-color: color-mix(in srgb, var(--theme-color), transparent 90%); border-radius: 14px;
border: 2px solid var(--c);
color: var(--c);
background: color-mix(in srgb, var(--c) 10%, transparent);
/* Grid stacking: ghost + fg share the same cell */
display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
text-decoration: none;
transition: filter 0.15s ease;
}
.score-box:hover {
filter: brightness(1.15);
}
.score-box > * {
grid-column: 1;
grid-row: 1;
}
.winner {
aspect-ratio: 2 / 1;
border-width: 3px;
min-height: 80px;
}
.runner {
aspect-ratio: 4 / 1;
min-height: 56px;
}
/* Ghost SVG: fill the cell edge-to-edge, text flush to bottom */
.score-ghost {
width: 100%;
height: 100%;
opacity: 0.18;
fill: currentColor;
}
.ghost-svg {
width: 100%;
height: 100%;
display: block;
}
/* Foreground */
.score-fg {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px;
gap: 8px;
}
.score-meta {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.score-rank {
font-size: 10px;
letter-spacing: 1.5px;
text-transform: uppercase;
opacity: 0.5;
line-height: 1;
margin-bottom: 3px;
}
.score-name {
font-size: clamp(11px, 3vw, 20px);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.score-pts {
font-size: clamp(20px, 5.5vw, 40px);
letter-spacing: 2px;
flex-shrink: 0;
}
.winner .score-name {
font-size: clamp(16px, 5vw, 32px);
}
.winner .score-pts {
font-size: clamp(26px, 7.5vw, 52px);
}
/*
* Runners-up grid:
* - 1 column on small screens (<480px)
* - 2 columns on medium (480699px)
* - 4 columns on large (≥700px), max 5
* --runner-count drives the actual column count on large screens
* so fewer than 5 teams still fill the whole row.
*/
.runners-grid {
display: grid;
gap: 10px;
grid-template-columns: 1fr;
}
@media (min-width: 480px) {
.runners-grid {
grid-template-columns: repeat(2, 1fr);
}
.winner {
aspect-ratio: 2 / 1;
}
.runner {
aspect-ratio: 2 / 1;
}
}
@media (min-width: 700px) {
.runners-grid {
/* clamp actual count to 4 on large, but use --runner-count
so e.g. 2 teams still fill 2 equal columns not 2 of 4 */
grid-template-columns: repeat(min(var(--runner-count), 4), 1fr);
}
.winner {
aspect-ratio: 3 / 1;
}
.runner {
aspect-ratio: 3 / 2;
}
}
/* ── Section label ── */
.section-label {
font-size: 10px;
letter-spacing: 2.5px;
text-transform: uppercase;
opacity: 0.4;
padding: 12px 16px 4px;
margin: 0;
}
/* ── Events scrollable container ── */
.events-scroll {
flex: 1;
overflow-y: auto;
/* max-height: 900px; */
min-height: 0;
padding: 0 14px 24px;
/* Thin custom scrollbar */
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, currentColor 30%, transparent) transparent;
}
.events {
display: flex;
flex-direction: column;
gap: 10px;
}
/* ── Event card ── */
.event-card {
border-radius: 12px;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
overflow: hidden;
transition: box-shadow 0.3s ease;
}
/* Focus highlight pulse — added/removed by $effect */
.event-card.highlight-pulse {
animation: card-pulse 1.2s ease-out forwards;
}
@keyframes card-pulse {
0% {
box-shadow: 0 0 0 3px currentColor;
}
100% {
box-shadow: 0 0 0 0px currentColor;
}
}
.event-header {
display: flex;
align-items: baseline;
gap: 10px;
padding: 9px 13px 7px;
border-bottom: 1px solid color-mix(in srgb, currentColor 10%, transparent);
flex-wrap: wrap;
}
.event-name {
font-size: 15px;
text-decoration: none;
color: inherit;
}
.event-name:hover {
text-decoration: underline;
}
.event-division {
font-size: 10px;
letter-spacing: 1.2px;
text-transform: uppercase;
opacity: 0.4;
}
/* ── Brackets ── */
.brackets {
display: flex;
flex-direction: column;
}
.bracket-sep {
height: 1px;
background: color-mix(in srgb, currentColor 10%, transparent);
margin: 0 10px;
}
.bracket-row {
display: flex;
gap: 6px;
padding: 7px 9px;
flex-wrap: wrap;
}
/* ── Player boxes ── */
.player-box {
flex: 1 1 0; /* equal widths, no min-content bias */
max-width: 160px;
position: relative;
overflow: hidden;
border-radius: 8px;
border: 1.5px solid var(--c);
color: var(--c);
background: color-mix(in srgb, var(--c) 10%, transparent);
padding: 5px 7px 5px;
display: flex;
flex-direction: column;
min-height: 52px;
}
/* 1 col on small screens, 4 across on large */
@media (max-width: 479px) {
.bracket-row {
flex-direction: column;
} }
.player-box { .player-box {
color: var(--theme-color); max-width: 100%;
border-color: var(--theme-color); }
background-color: color-mix(in srgb, var(--theme-color), transparent 90%); }
@media (min-width: 700px) {
.player-box {
max-width: calc(25% - 6px);
} /* 4 per row */
}
.player-ghost {
position: absolute;
inset: 0;
opacity: 0.15;
fill: currentColor;
pointer-events: none;
}
.ghost-svg {
width: 100%;
height: 100%;
display: block;
}
.player-name-wrap {
position: relative;
z-index: 1;
overflow: hidden;
white-space: nowrap;
}
.marquee-inner {
display: inline-block;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.marquee-inner.scrolling {
animation: marquee-scroll 7s ease-in-out infinite;
}
@keyframes marquee-scroll {
0%,
20% {
transform: translateX(0);
}
70%,
90% {
transform: translateX(var(--scroll-dist, 0px));
}
100% {
transform: translateX(0);
}
}
.player-placement {
position: relative;
z-index: 1;
font-size: 17px;
line-height: 1.1;
margin-top: 2px;
}
.player-placement-gap {
height: 20px;
} }
</style> </style>

View File

@@ -1,8 +1,5 @@
import { import { getRegisteredEventsWithPlayers } from '$lib/server/databaseManager';
globalEmitter, import { globalEmitter } from '$lib/server/globalEmitter';
getRegisteredEvents,
getRegisteredEventsWithPlayers
} from '$lib/server/databaseManager';
import { generateEndpoint } from '$lib/server/endpoint'; import { generateEndpoint } from '$lib/server/endpoint';
export async function GET() { export async function GET() {
@@ -10,16 +7,20 @@ export async function GET() {
const endpoint = generateEndpoint(async (enqueue) => { const endpoint = generateEndpoint(async (enqueue) => {
// Get the all the events with the players seperated into brackets // Get the all the events with the players seperated into brackets
let eventList = async () => { let eventList = async () => {
// Get eventList with structure from database
let newEventList = await getRegisteredEventsWithPlayers(); let newEventList = await getRegisteredEventsWithPlayers();
console.log(newEventList);
// send to client
enqueue(newEventList); enqueue(newEventList);
}; };
// Send the eventList to the client when a connection is made // Send the eventList to the client when a connection is made
// TODO make it so that this only happens on an initial post request // TODO make it so that this only happens on an initial post request
eventList(); eventList();
// When the data changes send an update to the client
globalEmitter.on('eventUpdate', eventList); globalEmitter.on('eventUpdate', eventList);
// Simply return the cleanup function here // Return cleanup function to remove listener when it closes
return () => { return () => {
globalEmitter.off('eventUpdate', eventList); globalEmitter.off('eventUpdate', eventList);
}; };
@@ -28,10 +29,18 @@ export async function GET() {
} }
export async function POST({ request }: any) { export async function POST({ request }: any) {
// Decode body
let responseBody = await request.json(); let responseBody = await request.json();
// If there is no request then dont respond
if (!responseBody) {
return new Response('nuh uh');
} else {
// Get requested event
let eventRequested = responseBody.eventId; let eventRequested = responseBody.eventId;
// request eventList from database
let eventList = await getRegisteredEventsWithPlayers(eventRequested); let eventList = await getRegisteredEventsWithPlayers(eventRequested);
// return eventList to client
return new Response(JSON.stringify(eventList)); return new Response(JSON.stringify(eventList));
} }
}

View File

@@ -1,4 +1,5 @@
import { globalEmitter, getAllRegisteredEventPlayers } from '$lib/server/databaseManager'; import { getAllRegisteredEventPlayers } from '$lib/server/databaseManager';
import { globalEmitter } from '$lib/server/globalEmitter';
import { generateEndpoint } from '$lib/server/endpoint'; import { generateEndpoint } from '$lib/server/endpoint';
// Expose post request // Expose post request

View File

@@ -1,4 +1,5 @@
import { globalEmitter, getTeams } from '$lib/server/databaseManager'; import { getTeams } from '$lib/server/databaseManager';
import { globalEmitter } from '$lib/server/globalEmitter';
import { generateEndpoint } from '$lib/server/endpoint'; import { generateEndpoint } from '$lib/server/endpoint';
// Expose post request // Expose post request

View File

@@ -36,5 +36,13 @@
<div>loading</div> <div>loading</div>
{:then eventData} {:then eventData}
{@const event = eventData[0]} {@const event = eventData[0]}
<div>{console.log(event)} {event.name}</div> <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}</div>
{#each event.registeredPlayers as division}
<div>{division.name}</div>
{/each}
</div>
</div>
{/await} {/await}