also looking at this
feat(results): put the live scoreboard back on the page
#15
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.
| @@ -1,1 +1,48 @@ | ||
| 1 | 1 | .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}} | |
| @@ -1,1 +1,48 @@ | ||
| 1 | 1 | .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}} | |
| @@ -0,0 +1,118 @@ | ||
| 1 | <script server> | |
| 2 | import { liveSnapshot, livePayload } from '../data/live' | |
| 3 | import { 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. */ | |
| 12 | const 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. */ | |
| 16 | const LSU_SEED_MAX_AGE = 120000 | |
| 17 | /* The board regenerates about once a minute; a lap lasts an hour. */ | |
| 18 | const LSU_POLL_MS = 30000 | |
| 19 | ||
| 20 | const lsuSeedFresh = Boolean(boardSeed.ok && boardSeed.fetchedAt && Date.now() - boardSeed.fetchedAt < LSU_SEED_MAX_AGE) | |
| 21 | const lsuLive = state(lsuSeedFresh ? boardSeed : null) | |
| 22 | const lsuTick = state(Date.now()) | |
| 23 | ||
| 24 | const lsuShown = derived(() => lsuLive()) | |
| 25 | const lsuRunning = derived(() => Boolean(lsuShown() && !lsuShown().raceOver)) | |
| 26 | const lsuStillIn = derived(() => (lsuShown()?.runners || []).filter(row => row.status === 'active')) | |
| 27 | const lsuOut = derived(() => (lsuShown()?.runners || []).length - lsuStillIn().length) | |
| 28 | ||
| 29 | /** Seconds to the next bell, recomputed locally between polls. */ | |
| 30 | const 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 | }) | |
| 37 | const 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 | ||
| 43 | async 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 | ||
| 55 | onMount(() => { lsuReadBoard() }) | |
| 56 | useInterval(() => { lsuTick.set(Date.now()) }, 1000) | |
| 57 | useInterval(() => { 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> | |
| @@ -227,6 +227,19 @@ export async function liveSnapshot(): Promise<LiveSnapshot> { | ||
| 227 | 227 | return inFlight |
| 228 | 228 | } |
| 229 | 229 | |
| 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 | */ | |
| 236 | export function forgetBoard(): void { | |
| 237 | cache = null | |
| 238 | inFlight = null | |
| 239 | failedAt = 0 | |
| 240 | lastError = null | |
| 241 | } | |
| 242 | ||
| 230 | 243 | /* ---------------------------------------------------------------------- */ |
| 231 | 244 | /* Derived views */ |
| 232 | 245 | /* ---------------------------------------------------------------------- */ |
| @@ -12,6 +12,7 @@ const canonicalPath = "/results" | ||
| 12 | 12 | <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> |
| 13 | 13 | </div> |
| 14 | 14 | </section> |
| 15 | <LiveBoard /> | |
| 15 | 16 | <section id="2026" class="ed-section"> |
| 16 | 17 | <div class="shell"> |
| 17 | 18 | <div class="ed-head"> |
| @@ -0,0 +1,193 @@ | ||
| 1 | import type { LiveSnapshot, LiveState } from '../resources/data/live' | |
| 2 | import { afterEach, describe, expect, it } from 'bun:test' | |
| 3 | import { resolve } from 'node:path' | |
| 4 | import { processDirectives } from '@stacksjs/stx' | |
| 5 | import { 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 | ||
| 17 | const LOOP = 6706 | |
| 18 | const GUN = Date.UTC(2026, 7, 14, 12, 0, 0) | |
| 19 | ||
| 20 | function 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 | ||
| 33 | function 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 | ||
| 48 | function snapshot(state: LiveState | null, over: Partial<LiveSnapshot> = {}): LiveSnapshot { | |
| 49 | return { ok: state !== null, state, fetchedAt: GUN, stale: false, error: null, ...over } | |
| 50 | } | |
| 51 | ||
| 52 | describe('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 | ||
| 72 | describe('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 | ||
| 98 | describe('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 | ||
| 114 | describe('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 | */ | |
| 138 | describe('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 | }) | |