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 8,0,
100m Sprint,Year 9,0,
100m Sprint,Year 10,0,
100m Sprint,Year 10,1,
200m Sprint,Year 7,0,
200m Sprint,Year 8,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 { eq } from 'drizzle-orm';
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
export async function getAllInitialInfo() {
return {
@@ -44,7 +30,6 @@ export async function getTeams() {
// Get all registered events from database
export async function getRegisteredEvents(eventId?: number) {
console.log('eventId: ', eventId);
const allEvents = await db
.select()
.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
.select()
.from(schema.resultPresets)
.where(eq(schema.resultPresets.id, presetId));
.where(presetId ? eq(schema.resultPresets.id, presetId) : undefined);
return {
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">
import { onMount, onDestroy } from 'svelte';
// import { enhance } from '$app/forms';
import Table from '$lib/ui/Table.svelte';
import { onMount, onDestroy, tick } from 'svelte';
// Get initial data from the load thing (innacurate lol)
let { data }: { data: import('./$types').PageData } = $props();
let {
data
}: {
data: import('./$types').PageData;
focusEventId?: number | null;
} = $props();
// Derived unordered
let teams = $derived(data.teams.teams);
let eventTable = $derived(data.events.events);
//// Leaderboard Database logic
// new event source for websocket
let scoreEndpoint: EventSource;
let eventEndpoint: EventSource;
onMount(() => {
// get endpoint
scoreEndpoint = new EventSource('/api/teams');
// Ref map for event card DOM nodes
let eventRefs = $state<Record<number, HTMLElement>>({});
// when you get a message do something
onMount(() => {
scoreEndpoint = new EventSource('/api/teams');
scoreEndpoint.onmessage = (e) => {
const teamsData = JSON.parse(e.data);
// If the message has a teams object update the score thing
if (teamsData['teams']) {
teams = teamsData['teams'];
console.log('teams updated');
}
if (teamsData['teams']) teams = teamsData['teams'];
};
// Player endpoint
eventEndpoint = new EventSource('/api/registeredEvents');
eventEndpoint.onmessage = (e) => {
const eventData = JSON.parse(e.data);
console.log(eventData);
eventTable = eventData;
eventTable = JSON.parse(e.data);
console.log(eventTable);
};
});
// When window destroyed close the websocket connection
onDestroy(() => scoreEndpoint?.close());
onDestroy(() => {
scoreEndpoint?.close();
eventEndpoint?.close();
});
// Order leaderboard so that its displayed correctly
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>
{#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()} />
<div class="flex max-h-[150vh] flex-col object-contain p-[2vw]">
{#each leaderboard as team (team.name)}
<div
style="--theme-color: {team.color};"
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"
<div class="page">
<!-- ═══════════ LEADERBOARD ═══════════ -->
<section class="leaderboard">
<!-- Winner — always full-width -->
{#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">
<svg viewBox="0 0.1 100 0.6" preserveAspectRatio="none" class="h-full w-full fill-current">
<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"
@@ -94,49 +104,442 @@
dominant-baseline="auto"
textLength="100"
lengthAdjust="spacingAndGlyphs"
class="goldman-bold"
font-family="'Black Ops One',system-ui">{team.name}</text
>
{team.name}
</text>
</svg>
</div>
<div class="text-[20cqh]">
<p>{team.points.toString().padStart(3, '0')}</p>
<div class="score-fg">
<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>
{/each}
</div>
<button
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} />
</section>
</div>
<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');
.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-weight: 400;
font-style: normal;
}
/* ── Leaderboard ── */
.leaderboard {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 14px 6px;
}
.score-box {
color: var(--theme-color);
border-color: var(--theme-color);
background-color: color-mix(in srgb, var(--theme-color), transparent 90%);
position: relative;
overflow: hidden;
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 {
color: var(--theme-color);
border-color: var(--theme-color);
background-color: color-mix(in srgb, var(--theme-color), transparent 90%);
max-width: 100%;
}
}
@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>

View File

@@ -1,8 +1,5 @@
import {
globalEmitter,
getRegisteredEvents,
getRegisteredEventsWithPlayers
} from '$lib/server/databaseManager';
import { getRegisteredEventsWithPlayers } from '$lib/server/databaseManager';
import { globalEmitter } from '$lib/server/globalEmitter';
import { generateEndpoint } from '$lib/server/endpoint';
export async function GET() {
@@ -10,16 +7,20 @@ export async function GET() {
const endpoint = generateEndpoint(async (enqueue) => {
// Get the all the events with the players seperated into brackets
let eventList = async () => {
// Get eventList with structure from database
let newEventList = await getRegisteredEventsWithPlayers();
console.log(newEventList);
// send to client
enqueue(newEventList);
};
// Send the eventList to the client when a connection is made
// TODO make it so that this only happens on an initial post request
eventList();
// When the data changes send an update to the client
globalEmitter.on('eventUpdate', eventList);
// Simply return the cleanup function here
// Return cleanup function to remove listener when it closes
return () => {
globalEmitter.off('eventUpdate', eventList);
};
@@ -28,10 +29,18 @@ export async function GET() {
}
export async function POST({ request }: any) {
// Decode body
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;
// request eventList from database
let eventList = await getRegisteredEventsWithPlayers(eventRequested);
// return eventList to client
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';
// 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';
// Expose post request

View File

@@ -36,5 +36,13 @@
<div>loading</div>
{:then eventData}
{@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}