ReviewOS

also looking at this

stacks/lastsoulultra

feat(results): put the live scoreboard back on the page

#15
Merged glennmichael123 wants to merge feat/live-scoreboard into main
6 files +419 -0

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

public/assets/styles/production/results.cssmodified+47-0
Changes to public/assets/styles/production/results.css
@@ -1,1 +1,48 @@
11.ed-section{padding:clamp(70px,10vw,140px) var(--lsu-pad-x);border-bottom:1px solid var(--lsu-line-soft)}.ed-section:first-child{padding-top:var(--lsu-head-gap)}.ed-head{display:flex;justify-content:space-between;align-items:end;gap:24px;margin-bottom:38px}.ed-link{color:var(--lsu-red);font-size:14px;letter-spacing:.12em;text-transform:uppercase}.ed-stats{margin-bottom:58px}.podium{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}.card{padding:28px;border:1px solid rgba(255,255,255,.14)}.card-win{border-color:var(--lsu-red)}.card-kicker,.kicker-sm{font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--lsu-dim-2)}.card-name{margin-top:14px;font-family:var(--lsu-display);font-size:clamp(25px,3vw,42px);line-height:.95;text-transform:uppercase}.card-num{margin-top:22px;font-family:var(--lsu-display);font-size:56px;color:var(--lsu-red)}.card-num span{font-size:18px}.card-body{margin-top:12px;color:var(--lsu-dim-1);line-height:1.55;font-size:16px}.event-details{margin-top:54px}.event-meta{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;margin:0;background:var(--lsu-line-soft);border:1px solid var(--lsu-line-soft)}.event-meta>div{padding:20px;background:var(--lsu-black)}.event-meta dt{font:12px var(--lsu-mono);letter-spacing:.12em;text-transform:uppercase;color:var(--lsu-dim-2)}.event-meta dd{margin:10px 0 0;line-height:1.55;font-size:17px}.tables{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:48px;margin-top:64px}.table-title{margin-bottom:20px}.rows{border-top:1px solid var(--lsu-line)}.row{display:grid;grid-template-columns:82px 1fr auto;gap:18px;padding:22px 0;border-bottom:1px solid var(--lsu-line-soft)}.row-n{font-family:var(--lsu-display);font-size:32px;color:var(--lsu-red)}.row-n span,.row-n small{display:block;font:12px var(--lsu-mono);letter-spacing:.08em;color:var(--lsu-dim-2)}.row-name{font-size:18px;font-weight:600}.row-note,.row-km,.table-note{font-size:15px;color:var(--lsu-dim-2)}.runner-facts{display:flex;flex-wrap:wrap;gap:8px 16px;margin:10px 0 0}.runner-facts div{display:flex;gap:6px;font:12px var(--lsu-mono);letter-spacing:.04em}.runner-facts dt{color:var(--lsu-dim-2)}.runner-facts dd{margin:0}.runner-detail{margin-top:16px}.runner-detail summary{width:max-content;cursor:pointer;color:var(--lsu-red);font:12px var(--lsu-mono);letter-spacing:.1em;text-transform:uppercase}.runner-detail summary:hover{color:#fff}.runner-stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1px;margin:16px 0 0;background:var(--lsu-line-soft);border:1px solid var(--lsu-line-soft)}.runner-stats>div{padding:14px;background:var(--lsu-black)}.runner-stats dt{font:11px var(--lsu-mono);letter-spacing:.06em;text-transform:uppercase;color:var(--lsu-dim-2)}.runner-stats dd{margin:7px 0 0;font:14px var(--lsu-mono)}.edon{display:grid;grid-template-columns:repeat(3,1fr);gap:24px;margin-top:46px;padding:28px;border:1px solid rgba(255,255,255,.14)}.edon-title{font-family:var(--lsu-display);font-size:28px;text-transform:uppercase}.edon-v{font-family:var(--lsu-display);font-size:38px;color:var(--lsu-red)}@media(max-width:760px){.podium,.tables,.edon,.event-meta{grid-template-columns:1fr}.runner-stats{grid-template-columns:repeat(2,minmax(0,1fr))}.ed-head{align-items:start;flex-direction:column}.row{grid-template-columns:42px 1fr}.row-km{grid-column:2}}
2
3/* The live board, mirrored from the race's own scoreboard.
4
5 Only ever on screen while there is a race to show: the section carries
6 `:if` on the payload, so off-season it is absent rather than an error box
7 telling a reader something is broken when nothing is.
8
9 Built on .statgrid, .stat-v and .stat-l from the edition sections above,
10 so a lap count during the race and a lap count in the archive are the same
11 object at the same size. Red is the live state and nothing else. */
12
13.live{padding:clamp(60px,8vw,110px) var(--lsu-pad-x) clamp(50px,7vw,90px);border-bottom:1px solid var(--lsu-line-soft);background:radial-gradient(ellipse 70% 60% at 50% 0,#140305,#000)}
14.live-head{display:flex;flex-direction:column;gap:14px;margin-bottom:clamp(28px,3.4vw,44px)}
15.live-head .h2{max-width:18ch}
16.live-head .kicker{display:flex;align-items:center;gap:10px}
17/* The pulse is the one thing on the page that says "now". */
18.live-head .kicker:before{content:"";width:10px;height:10px;background:var(--lsu-red);box-shadow:0 0 12px var(--lsu-red);animation:lsu-live-pulse 2s ease-in-out infinite}
19@keyframes lsu-live-pulse{50%{opacity:.25}}
20.live-bell{margin:0;color:var(--lsu-dim-1);font-size:15px}
21.live-bell-n{font-family:var(--lsu-seg);font-size:clamp(22px,2.4vw,30px);color:var(--lsu-white);letter-spacing:.04em}
22
23.live-stats{margin-bottom:clamp(28px,3.4vw,44px)}
24.live-stats>div{padding:clamp(20px,2.4vw,30px);background:var(--lsu-black)}
25
26.live-winner{padding:clamp(22px,2.6vw,32px);border-left:3px solid var(--lsu-red);background:var(--lsu-card);margin-bottom:clamp(24px,3vw,38px)}
27.live-winner-name{margin-top:10px;font:400 clamp(28px,3.4vw,46px)/1.05 var(--lsu-display);text-transform:uppercase}
28.live-winner-yards{margin-top:8px;color:var(--lsu-dim-1);font-size:15px}
29
30.live-rows{display:grid;margin:0;padding:0;list-style:none;border-top:1px solid var(--lsu-line)}
31.live-row{display:grid;grid-template-columns:3.5rem minmax(0,1fr) auto auto auto;gap:clamp(10px,2vw,26px);align-items:baseline;padding:14px 0;border-bottom:1px solid var(--lsu-line-faint)}
32.live-rank{font-family:var(--lsu-display);font-size:20px;color:var(--lsu-red)}
33.live-name{display:flex;align-items:baseline;gap:10px;min-width:0;font-size:17px}
34.live-bib,.live-km{color:var(--lsu-dim-2);font-size:13px}
35.live-laps{font-family:var(--lsu-display);font-size:19px}
36/* Beside the name, because it is the runner's tracker that is quiet,
37 not the row. As a sixth child it fell to a line of its own. */
38.live-stale{flex:none;color:var(--lsu-dim-2);font-size:11px;letter-spacing:.14em;text-transform:uppercase}
39.live-note{margin:clamp(20px,2.4vw,30px) 0 0;max-width:68ch;color:var(--lsu-dim-2);font-size:13px;line-height:1.6}
40.live-note a{color:var(--lsu-white);text-decoration:underline;text-underline-offset:4px}
41.live-note a:hover{color:var(--lsu-red)}
42
43@media(max-width:700px){
44.live-row{grid-template-columns:2.6rem minmax(0,1fr) auto;row-gap:4px}
45.live-bib{grid-column:2}
46.live-km{grid-column:3}
47}
48@media(prefers-reduced-motion:reduce){.live-head .kicker:before{animation:none}}
resources/assets/styles/site/results.cssmodified+47-0
Changes to resources/assets/styles/site/results.css
@@ -1,1 +1,48 @@
11.ed-section{padding:clamp(70px,10vw,140px) var(--lsu-pad-x);border-bottom:1px solid var(--lsu-line-soft)}.ed-section:first-child{padding-top:var(--lsu-head-gap)}.ed-head{display:flex;justify-content:space-between;align-items:end;gap:24px;margin-bottom:38px}.ed-link{color:var(--lsu-red);font-size:14px;letter-spacing:.12em;text-transform:uppercase}.ed-stats{margin-bottom:58px}.podium{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}.card{padding:28px;border:1px solid rgba(255,255,255,.14)}.card-win{border-color:var(--lsu-red)}.card-kicker,.kicker-sm{font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--lsu-dim-2)}.card-name{margin-top:14px;font-family:var(--lsu-display);font-size:clamp(25px,3vw,42px);line-height:.95;text-transform:uppercase}.card-num{margin-top:22px;font-family:var(--lsu-display);font-size:56px;color:var(--lsu-red)}.card-num span{font-size:18px}.card-body{margin-top:12px;color:var(--lsu-dim-1);line-height:1.55;font-size:16px}.event-details{margin-top:54px}.event-meta{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;margin:0;background:var(--lsu-line-soft);border:1px solid var(--lsu-line-soft)}.event-meta>div{padding:20px;background:var(--lsu-black)}.event-meta dt{font:12px var(--lsu-mono);letter-spacing:.12em;text-transform:uppercase;color:var(--lsu-dim-2)}.event-meta dd{margin:10px 0 0;line-height:1.55;font-size:17px}.tables{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:48px;margin-top:64px}.table-title{margin-bottom:20px}.rows{border-top:1px solid var(--lsu-line)}.row{display:grid;grid-template-columns:82px 1fr auto;gap:18px;padding:22px 0;border-bottom:1px solid var(--lsu-line-soft)}.row-n{font-family:var(--lsu-display);font-size:32px;color:var(--lsu-red)}.row-n span,.row-n small{display:block;font:12px var(--lsu-mono);letter-spacing:.08em;color:var(--lsu-dim-2)}.row-name{font-size:18px;font-weight:600}.row-note,.row-km,.table-note{font-size:15px;color:var(--lsu-dim-2)}.runner-facts{display:flex;flex-wrap:wrap;gap:8px 16px;margin:10px 0 0}.runner-facts div{display:flex;gap:6px;font:12px var(--lsu-mono);letter-spacing:.04em}.runner-facts dt{color:var(--lsu-dim-2)}.runner-facts dd{margin:0}.runner-detail{margin-top:16px}.runner-detail summary{width:max-content;cursor:pointer;color:var(--lsu-red);font:12px var(--lsu-mono);letter-spacing:.1em;text-transform:uppercase}.runner-detail summary:hover{color:#fff}.runner-stats{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1px;margin:16px 0 0;background:var(--lsu-line-soft);border:1px solid var(--lsu-line-soft)}.runner-stats>div{padding:14px;background:var(--lsu-black)}.runner-stats dt{font:11px var(--lsu-mono);letter-spacing:.06em;text-transform:uppercase;color:var(--lsu-dim-2)}.runner-stats dd{margin:7px 0 0;font:14px var(--lsu-mono)}.edon{display:grid;grid-template-columns:repeat(3,1fr);gap:24px;margin-top:46px;padding:28px;border:1px solid rgba(255,255,255,.14)}.edon-title{font-family:var(--lsu-display);font-size:28px;text-transform:uppercase}.edon-v{font-family:var(--lsu-display);font-size:38px;color:var(--lsu-red)}@media(max-width:760px){.podium,.tables,.edon,.event-meta{grid-template-columns:1fr}.runner-stats{grid-template-columns:repeat(2,minmax(0,1fr))}.ed-head{align-items:start;flex-direction:column}.row{grid-template-columns:42px 1fr}.row-km{grid-column:2}}
2
3/* The live board, mirrored from the race's own scoreboard.
4
5 Only ever on screen while there is a race to show: the section carries
6 `:if` on the payload, so off-season it is absent rather than an error box
7 telling a reader something is broken when nothing is.
8
9 Built on .statgrid, .stat-v and .stat-l from the edition sections above,
10 so a lap count during the race and a lap count in the archive are the same
11 object at the same size. Red is the live state and nothing else. */
12
13.live{padding:clamp(60px,8vw,110px) var(--lsu-pad-x) clamp(50px,7vw,90px);border-bottom:1px solid var(--lsu-line-soft);background:radial-gradient(ellipse 70% 60% at 50% 0,#140305,#000)}
14.live-head{display:flex;flex-direction:column;gap:14px;margin-bottom:clamp(28px,3.4vw,44px)}
15.live-head .h2{max-width:18ch}
16.live-head .kicker{display:flex;align-items:center;gap:10px}
17/* The pulse is the one thing on the page that says "now". */
18.live-head .kicker:before{content:"";width:10px;height:10px;background:var(--lsu-red);box-shadow:0 0 12px var(--lsu-red);animation:lsu-live-pulse 2s ease-in-out infinite}
19@keyframes lsu-live-pulse{50%{opacity:.25}}
20.live-bell{margin:0;color:var(--lsu-dim-1);font-size:15px}
21.live-bell-n{font-family:var(--lsu-seg);font-size:clamp(22px,2.4vw,30px);color:var(--lsu-white);letter-spacing:.04em}
22
23.live-stats{margin-bottom:clamp(28px,3.4vw,44px)}
24.live-stats>div{padding:clamp(20px,2.4vw,30px);background:var(--lsu-black)}
25
26.live-winner{padding:clamp(22px,2.6vw,32px);border-left:3px solid var(--lsu-red);background:var(--lsu-card);margin-bottom:clamp(24px,3vw,38px)}
27.live-winner-name{margin-top:10px;font:400 clamp(28px,3.4vw,46px)/1.05 var(--lsu-display);text-transform:uppercase}
28.live-winner-yards{margin-top:8px;color:var(--lsu-dim-1);font-size:15px}
29
30.live-rows{display:grid;margin:0;padding:0;list-style:none;border-top:1px solid var(--lsu-line)}
31.live-row{display:grid;grid-template-columns:3.5rem minmax(0,1fr) auto auto auto;gap:clamp(10px,2vw,26px);align-items:baseline;padding:14px 0;border-bottom:1px solid var(--lsu-line-faint)}
32.live-rank{font-family:var(--lsu-display);font-size:20px;color:var(--lsu-red)}
33.live-name{display:flex;align-items:baseline;gap:10px;min-width:0;font-size:17px}
34.live-bib,.live-km{color:var(--lsu-dim-2);font-size:13px}
35.live-laps{font-family:var(--lsu-display);font-size:19px}
36/* Beside the name, because it is the runner's tracker that is quiet,
37 not the row. As a sixth child it fell to a line of its own. */
38.live-stale{flex:none;color:var(--lsu-dim-2);font-size:11px;letter-spacing:.14em;text-transform:uppercase}
39.live-note{margin:clamp(20px,2.4vw,30px) 0 0;max-width:68ch;color:var(--lsu-dim-2);font-size:13px;line-height:1.6}
40.live-note a{color:var(--lsu-white);text-decoration:underline;text-underline-offset:4px}
41.live-note a:hover{color:var(--lsu-red)}
42
43@media(max-width:700px){
44.live-row{grid-template-columns:2.6rem minmax(0,1fr) auto;row-gap:4px}
45.live-bib{grid-column:2}
46.live-km{grid-column:3}
47}
48@media(prefers-reduced-motion:reduce){.live-head .kicker:before{animation:none}}
resources/components/LiveBoard.stxadded+118-0
Changes to resources/components/LiveBoard.stx
@@ -0,0 +1,118 @@
1<script server>
2import { liveSnapshot, livePayload } from '../data/live'
3import { race } from '../data/race'
4
5/* A best-effort snapshot at render time.
6 *
7 * On a server render this is current. In the static build it is whatever the
8 * board said when the build ran, which during a race is hours old - so the
9 * client below refuses a stale seed rather than painting standings that are
10 * several laps behind. Same LivePayload shape either way, which is the point:
11 * the table cannot drift between the server copy and the polled one. */
12const boardSeed = livePayload(await liveSnapshot(), race.loopMetres)
13</script>
14<script client>
15/* Older than this and the seed is a build artefact, not the board. */
16const LSU_SEED_MAX_AGE = 120000
17/* The board regenerates about once a minute; a lap lasts an hour. */
18const LSU_POLL_MS = 30000
19
20const lsuSeedFresh = Boolean(boardSeed.ok && boardSeed.fetchedAt && Date.now() - boardSeed.fetchedAt < LSU_SEED_MAX_AGE)
21const lsuLive = state(lsuSeedFresh ? boardSeed : null)
22const lsuTick = state(Date.now())
23
24const lsuShown = derived(() => lsuLive())
25const lsuRunning = derived(() => Boolean(lsuShown() && !lsuShown().raceOver))
26const lsuStillIn = derived(() => (lsuShown()?.runners || []).filter(row => row.status === 'active'))
27const lsuOut = derived(() => (lsuShown()?.runners || []).length - lsuStillIn().length)
28
29/** Seconds to the next bell, recomputed locally between polls. */
30const lsuToBell = derived(() => {
31 const board = lsuShown()
32 if (!board || board.raceOver || board.secondsToNextLap === null) return null
33 const elapsed = Math.floor((lsuTick() - (board.fetchedAt || lsuTick())) / 1000)
34 const left = board.secondsToNextLap - elapsed
35 return left > 0 ? left : 0
36})
37const lsuBell = derived(() => {
38 const left = lsuToBell()
39 if (left === null) return ''
40 return `${String(Math.floor(left / 60)).padStart(2, '0')}:${String(left % 60).padStart(2, '0')}`
41})
42
43async function lsuReadBoard() {
44 try {
45 const response = await fetch('/api/live', { headers: { accept: 'application/json' } })
46 // 503 carries ok:false rather than a body of zeroes, so read it either way.
47 const payload = await response.json()
48 lsuLive.set(payload && payload.ok ? payload : null)
49 }
50 catch {
51 // Leave the last good board up rather than blanking it on one blip.
52 }
53}
54
55onMount(() => { lsuReadBoard() })
56useInterval(() => { lsuTick.set(Date.now()) }, 1000)
57useInterval(() => { if (lsuRunning() || !lsuShown()) lsuReadBoard() }, LSU_POLL_MS)
58</script>
59<section class="live" :if="lsuShown()" x-cloak>
60 <div class="shell">
61 <div class="live-head">
62 <div class="kicker">
63 <span :if="!lsuRunning()">Final standing</span>
64 <span :if="lsuRunning()" :text="'Live · lap ' + lsuShown().currentLap"></span>
65 </div>
66 <h2 class="h2" :if="lsuRunning()">The race is on.</h2>
67 <h2 class="h2" :if="!lsuRunning()">The last soul.</h2>
68 <p :if="lsuRunning() && lsuBell()" class="live-bell">
69 <span class="live-bell-n" :text="lsuBell()"></span> to the next bell
70 </p>
71 </div>
72
73 <div class="live-stats statgrid">
74 <div :if="lsuRunning()">
75 <div class="stat-v" :text="lsuShown().remaining"></div>
76 <div class="stat-l">still in</div>
77 </div>
78 <div>
79 <div class="stat-v" :text="lsuRunning() ? lsuShown().currentLap : lsuShown().finalLap || lsuShown().currentLap"></div>
80 <div class="stat-l" :text="lsuRunning() ? 'lap now running' : 'laps in the end'"></div>
81 </div>
82 <div>
83 <div class="stat-v" :text="lsuShown().totalKm"></div>
84 <div class="stat-l">kilometres run between them</div>
85 </div>
86 <div>
87 <div class="stat-v" :text="lsuShown().starters"></div>
88 <div class="stat-l">on the start line</div>
89 </div>
90 </div>
91
92 <div :if="lsuShown().winner" class="live-winner">
93 <div class="kicker">Winner · last soul</div>
94 <div class="live-winner-name" :text="lsuShown().winner.name"></div>
95 <div class="live-winner-yards"><span :text="lsuShown().winner.laps"></span> yards · <span :text="lsuShown().winner.km"></span> km</div>
96 </div>
97
98 <ol class="live-rows" :if="lsuRunning() && lsuStillIn().length">
99 <li :for="row in lsuStillIn()" class="live-row">
100 <span class="live-rank" :text="row.rank"></span>
101 <span class="live-name">
102 <span :text="row.name"></span>
103 <span class="live-stale" :if="row.stale" title="The tracker has not reported a position recently">no fix</span>
104 </span>
105 <span class="live-bib" :text="'bib ' + row.bib"></span>
106 <span class="live-laps"><span :text="row.laps"></span> yards</span>
107 <span class="live-km" :text="row.km + ' km'"></span>
108 </li>
109 </ol>
110
111 <p class="live-note">
112 <span :if="lsuRunning() && lsuOut() > 0" :text="lsuOut() + ' eliminated so far. '"></span>
113 <span :if="lsuShown().provisional">Provisional until the organiser confirms it. </span>
114 <span :if="!lsuShown().sourcesOk">Some of the board's own feeds are down, so a position may lag. </span>
115 <a href="https://lsu-live.bspmedia.workers.dev/" rel="noopener">The board itself</a>
116 </p>
117 </div>
118</section>
resources/data/live.tsmodified+13-0
Changes to resources/data/live.ts
@@ -227,6 +227,19 @@ export async function liveSnapshot(): Promise<LiveSnapshot> {
227227 return inFlight
228228}
229229
230/**
231 * Drop the cached board and any backoff.
232 *
233 * For tests, which need each case to start from nothing, and for a dev
234 * server that should not keep serving a board from before a restart.
235 */
236export function forgetBoard(): void {
237 cache = null
238 inFlight = null
239 failedAt = 0
240 lastError = null
241}
242
230243/* ---------------------------------------------------------------------- */
231244/* Derived views */
232245/* ---------------------------------------------------------------------- */
resources/views/results.stxmodified+1-0
Changes to resources/views/results.stx
@@ -12,6 +12,7 @@ const canonicalPath = "/results"
1212<p>Every edition, every runner, every yard, straight from the race database. Season 3 is 2027: <a href="/world-series">request a race entry</a> for an open event. Places are allocated through a combination of LSU team selection and a random draw.</p>
1313</div>
1414</section>
15<LiveBoard />
1516<section id="2026" class="ed-section">
1617<div class="shell">
1718<div class="ed-head">
tests/live-board.test.tsadded+193-0
Changes to tests/live-board.test.ts
@@ -0,0 +1,193 @@
1import type { LiveSnapshot, LiveState } from '../resources/data/live'
2import { afterEach, describe, expect, it } from 'bun:test'
3import { resolve } from 'node:path'
4import { processDirectives } from '@stacksjs/stx'
5import { currentLap, forgetBoard, livePayload, secondsToNextLap, standings, stillIn, totalLaps, winner } from '../resources/data/live'
6
7/**
8 * The live board, and the shaping behind it.
9 *
10 * `livePayload` is the one shape the server render and the browser poll both
11 * use, so it is the thing that decides what a reader is told during a race.
12 * It had no tests. The rules it encodes are not obvious - a backyard ultra
13 * ranks on laps completed, two runners on the same lap genuinely share a
14 * position, and there is no winner until the board says the race is over.
15 */
16
17const LOOP = 6706
18const GUN = Date.UTC(2026, 7, 14, 12, 0, 0)
19
20function runner(name: string, laps: number, over: Partial<LiveState['runners'][number]> = {}) {
21 return {
22 bib: name.slice(0, 2).toUpperCase(),
23 name,
24 status: 'active' as const,
25 lapsCompleted: laps,
26 progress: 0.5,
27 lastFixAt: GUN,
28 progressStale: false,
29 ...over,
30 }
31}
32
33function board(over: Partial<LiveState> = {}): LiveState {
34 return {
35 generatedAt: GUN,
36 raceStartUtc: GUN,
37 remaining: 3,
38 provisional: false,
39 raceOver: false,
40 finalLap: null,
41 runners: [runner('Ada', 10), runner('Bo', 12), runner('Cy', 10)],
42 weatherCelsius: 14,
43 sourcesOk: true,
44 ...over,
45 }
46}
47
48function snapshot(state: LiveState | null, over: Partial<LiveSnapshot> = {}): LiveSnapshot {
49 return { ok: state !== null, state, fetchedAt: GUN, stale: false, error: null, ...over }
50}
51
52describe('an unreachable board', () => {
53 it('invents no numbers', () => {
54 const payload = livePayload(snapshot(null, { error: 'timeout' }), LOOP)
55
56 expect(payload.ok).toBe(false)
57 expect(payload.runners).toEqual([])
58 expect(payload.winner).toBeNull()
59 expect(payload.starters).toBe(0)
60 expect(payload.totalKm).toBe(0)
61 expect(payload.error).toBe('timeout')
62 })
63
64 it('reports an outage rather than an empty field', () => {
65 // A page of zeroes and a field of nobody look the same to a reader; only
66 // `ok` tells them apart, so it must survive the shaping.
67 expect(livePayload(snapshot(null), LOOP).ok).toBe(false)
68 expect(livePayload(snapshot(board({ runners: [runner('Ada', 1)] })), LOOP).ok).toBe(true)
69 })
70})
71
72describe('ranking the field', () => {
73 it('orders on laps, then on progress through the current one', () => {
74 const state = board({ runners: [runner('Ada', 10, { progress: 0.1 }), runner('Bo', 10, { progress: 0.9 })] })
75 expect(standings(state).map(row => row.name)).toEqual(['Bo', 'Ada'])
76 })
77
78 it('gives two runners on the same lap the same position', () => {
79 const rows = livePayload(snapshot(board()), LOOP).runners
80
81 expect(rows.map(row => `${row.name} ${row.rank}`)).toEqual(['Bo 1', 'Ada 2', 'Cy 2'])
82 })
83
84 it('converts laps to kilometres with the loop it was given', () => {
85 const rows = livePayload(snapshot(board()), LOOP).runners
86 expect(rows.find(row => row.name === 'Bo')?.km).toBe(80.5)
87 expect(livePayload(snapshot(board()), LOOP).totalKm).toBe(Math.round(32 * LOOP / 100) / 10)
88 })
89
90 it('counts everyone who is still out there', () => {
91 const state = board({ runners: [runner('Ada', 10), runner('Bo', 9, { status: 'out' })] })
92
93 expect(stillIn(state).map(row => row.name)).toEqual(['Ada'])
94 expect(totalLaps(state)).toBe(19)
95 })
96})
97
98describe('calling a winner', () => {
99 it('names nobody while the race is running', () => {
100 expect(winner(board())).toBeNull()
101 expect(livePayload(snapshot(board()), LOOP).winner).toBeNull()
102 })
103
104 it('names the last one standing once the board says it is over', () => {
105 const state = board({ raceOver: true, finalLap: 12 })
106 const payload = livePayload(snapshot(state), LOOP)
107
108 expect(winner(state)?.name).toBe('Bo')
109 expect(payload.winner?.name).toBe('Bo')
110 expect(payload.finalLap).toBe(12)
111 })
112})
113
114describe('where the race is up to', () => {
115 it('counts the lap from the gun, one an hour', () => {
116 expect(currentLap(board(), GUN)).toBe(1)
117 expect(currentLap(board(), GUN + 3 * 3600_000 + 60_000)).toBe(4)
118 expect(currentLap(board(), GUN - 60_000)).toBe(0)
119 })
120
121 it('reports the recorded final lap once it is over, not the clock', () => {
122 const over = board({ raceOver: true, finalLap: 94 })
123 expect(currentLap(over, GUN + 500 * 3600_000)).toBe(94)
124 })
125
126 it('counts down to the next bell, and stops at the end', () => {
127 expect(secondsToNextLap(board(), GUN + 59 * 60_000)).toBe(60)
128 expect(secondsToNextLap(board({ raceOver: true }), GUN + 60_000)).toBeNull()
129 expect(secondsToNextLap(board(), GUN - 60_000)).toBeNull()
130 })
131})
132
133/**
134 * The wiring: /results carries the section, and the server's snapshot is
135 * serialised into the client bundle so the board paints on hydration rather
136 * than after the first poll.
137 */
138describe('the board on the results page', () => {
139 const realFetch = globalThis.fetch
140
141 /** The worker serves its state as a `window.__LSU__` assignment in HTML. */
142 function boardPage(state: Record<string, unknown>): string {
143 return `<!doctype html><html><body><script>window.__LSU__ = ${JSON.stringify({ state })};</script></body></html>`
144 }
145
146 function stub(body: string | null): void {
147 globalThis.fetch = (async () => body === null
148 ? new Response('down', { status: 503 })
149 : new Response(body, { headers: { 'content-type': 'text/html' } })) as typeof fetch
150 }
151
152 async function results(): Promise<string> {
153 forgetBoard()
154 const path = resolve('resources/views/results.stx')
155 return processDirectives(await Bun.file(path).text(), {}, path, {
156 enabled: true,
157 cache: false,
158 componentsDir: resolve('resources/components'),
159 layoutsDir: resolve('resources/layouts'),
160 partialsDir: resolve('resources/partials'),
161 }, new Set())
162 }
163
164 afterEach(() => { globalThis.fetch = realFetch; forgetBoard() })
165
166 it('seeds the client with a board that is up', async () => {
167 stub(boardPage({ raceStartUtc: GUN, generatedAt: GUN, remaining: 2, runners: [runner('Ada', 10), runner('Bo', 12)] }))
168 const html = await results()
169
170 expect(html).toContain('class="live"')
171 expect(/boardSeed = \{"ok":true/.test(html)).toBe(true)
172 expect(html).toContain('"name":"Bo"')
173 })
174
175 it('still carries the section when the board is down, for the poll to fill', async () => {
176 // A static build made off-season must not omit the markup, or the board
177 // can never appear without a rebuild.
178 stub(null)
179 const html = await results()
180
181 expect(html).toContain('class="live"')
182 expect(/boardSeed = \{"ok":false/.test(html)).toBe(true)
183 expect(html).toContain('/api/live')
184 })
185
186 it('shows nothing of the board until there is a payload', async () => {
187 stub(null)
188 const html = await results()
189
190 // The section is gated on the payload, so an outage renders no box.
191 expect(html).toMatch(/<section class="live"[^>]*:if="lsuShown\(\)"/)
192 })
193})