Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
node_modules
node_modules/

# Output
.output
.vercel
/.svelte-kit
/build
.output/
.vercel/
.svelte-kit/
build/

# OS
.DS_Store
Expand All @@ -20,4 +20,6 @@ Thumbs.db
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

.notes
# Local files
.notes
*.log
100 changes: 73 additions & 27 deletions src/lib/components/Podium.svelte
Original file line number Diff line number Diff line change
@@ -1,22 +1,58 @@
<script lang="ts">
import { onMount } from 'svelte';

export let first: string | undefined;
export let second: string | undefined;
export let third: string | undefined;

import type { Place } from '$lib/types';

/**
* Preferred API: an arbitrary number of places, so a division with only a
* first and second can render without an empty third plinth.
*/
export let places: Place[] = [];

/** Legacy three-slot API, kept for events stored before divisions existed. */
export let first: string | undefined = undefined;
export let second: string | undefined = undefined;
export let third: string | undefined = undefined;

const MEDAL_CLASSES = ['gold', 'silver', 'bronze'];

// Falling back to the three fixed slots preserves the old behaviour of
// showing three 'TBD' plinths for an event whose results aren't in yet.
$: resolved =
places.length > 0
? [...places].sort((a, b) => a.rank - b.rank)
: [
{ rank: 1, team: first || 'TBD' },
{ rank: 2, team: second || 'TBD' },
{ rank: 3, team: third || 'TBD' }
];

// Tallest in the middle: second on the left, first centre, third on the
// right. With only two places that naturally becomes [second, first].
$: ordered = (() => {
const byRank = new Map(resolved.map((p) => [p.rank, p]));
const podiumOrder = [2, 1, 3].map((r) => byRank.get(r)).filter(Boolean) as Place[];
const remaining = resolved.filter((p) => p.rank > 3);
return [...podiumOrder, ...remaining];
})();

let podiumContainer: HTMLElement;
let uniformWidth = 160; // Default width

onMount(() => {
if (podiumContainer) {
calculateUniformWidth();
}
});


// Re-measure when the teams change, otherwise a division rendered after the
// first paint keeps the previous division's tile width.
$: if (podiumContainer && ordered) {
calculateUniformWidth();
}

function calculateUniformWidth() {
if (!podiumContainer) return;

// Create a temporary element to measure text width
const measurer = document.createElement('div');
measurer.style.position = 'absolute';
Expand All @@ -26,21 +62,21 @@
measurer.style.fontWeight = '600';
measurer.style.fontFamily = getComputedStyle(document.body).fontFamily;
document.body.appendChild(measurer);
const names = [first, second, third].filter(Boolean) as string[];

const names = ordered.map((p) => p.team).filter(Boolean) as string[];
let maxWidth = 120; // Minimum width

names.forEach(name => {
measurer.textContent = name;
const textWidth = measurer.offsetWidth;
// Add padding (32px) and some extra space (16px) for wrapping
const neededWidth = Math.min(textWidth + 48, 200);
maxWidth = Math.max(maxWidth, neededWidth);
});

document.body.removeChild(measurer);
uniformWidth = maxWidth;

// Apply the uniform width to all podiums
const podiums = podiumContainer.querySelectorAll('.podium') as NodeListOf<HTMLElement>;
podiums.forEach(podium => {
Expand All @@ -50,20 +86,15 @@
</script>

<div class="podium-container" bind:this={podiumContainer}>
<div class="podium" data-place="2">
<div class="medal silver">2</div>
<div class="team-name">{second || 'TBD'}</div>
</div>

<div class="podium" data-place="1">
<div class="medal gold">1</div>
<div class="team-name">{first || 'TBD'}</div>
</div>

<div class="podium" data-place="3">
<div class="medal bronze">3</div>
<div class="team-name">{third || 'TBD'}</div>
</div>
{#each ordered as place (place.rank)}
<div class="podium" data-place={Math.min(place.rank, 3)}>
<div class="medal {MEDAL_CLASSES[place.rank - 1] ?? 'bronze'}">{place.rank}</div>
<div class="team-name">{place.team || 'TBD'}</div>
{#if place.award}
<div class="award">${place.award}</div>
{/if}
</div>
{/each}
</div>

<style lang="scss">
Expand Down Expand Up @@ -195,6 +226,21 @@
}
}

.award {
margin-top: $spacing-xs;
font-size: $font-size-sm;
font-weight: 700;
text-align: center;
color: rgba(255, 255, 255, 0.9);
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
opacity: 0;
animation: fade-in-up 0.8s ease-out 2.4s forwards;

@media (max-width: $mobile-width) {
font-size: $font-size-xs;
}
}

@keyframes fade-in-up {
from {
opacity: 0;
Expand Down
26 changes: 26 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,37 @@ export interface EventData {
contact?: string;
results?: {
eliminationBracket?: string;
/**
* Legacy single-ladder results, used by the 2024 and 2025 events.
* Still rendered when `divisions` is absent.
*/
winners?: {
first?: string;
second?: string;
third?: string;
};
/**
* Results split by bracket. From 2026 the competition runs separate
* High School and Middle School divisions (plus a mock round), which
* the three flat `winners` slots cannot represent.
*
* Takes precedence over `winners` when present.
*/
divisions?: Division[];
description?: string;
};
}

export interface Place {
/** 1 = first, 2 = second, and so on. */
rank: number;
team: string;
/** Prize in whole US dollars. */
award?: number;
}

export interface Division {
/** e.g. "High School", "Middle School", "Mock Competition" */
name: string;
places: Place[];
}
38 changes: 33 additions & 5 deletions src/routes/results/[eventID]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,21 @@
<div class="results-content">
<section class="winners-section">
<h2 class="section-title">🏆 Congratulations to our winners</h2>
<Podium
first={eventData.results?.winners?.first}
second={eventData.results?.winners?.second}
third={eventData.results?.winners?.third}
/>

{#if eventData.results?.divisions?.length}
{#each eventData.results.divisions as division (division.name)}
<div class="division">
<h3 class="division-title">{division.name}</h3>
<Podium places={division.places} />
</div>
{/each}
{:else}
<Podium
first={eventData.results?.winners?.first}
second={eventData.results?.winners?.second}
third={eventData.results?.winners?.third}
/>
{/if}

<div class="thank-you-message">
<h2>Thank you to everyone for participating</h2>
Expand Down Expand Up @@ -122,6 +132,24 @@
}

.winners-section {
.division {
& + .division {
margin-top: $spacing-2xl;
}

.division-title {
font-size: $font-size-2xl;
font-weight: 600;
text-align: center;
color: $text-secondary;
margin-bottom: 0;

@media (max-width: $mobile-width) {
font-size: $font-size-xl;
}
}
}

.thank-you-message {
text-align: center;
margin-top: $spacing-3xl;
Expand Down