feat(public): spectator insights + points timeline - #35
Merged
Conversation
The public scoreboard showed points at one instant and nothing else. Adds the three improvements from #24: a full-width layout, competition insight cards, and a live cumulative-points timeline so spectators can watch competitors climb and overtake each other. Backend — utils/public_insights.py (new), deliberately separate from the staff-only, non-freeze-aware utils/analytics.py so what we disclose publicly is auditable in one file: - stats (participants / solves / challenges / unsolved), highlights (most solved, most attempted, first-blood leader, fastest solve), and a top-10 cumulative timeline, behind GET /api/public/competitions/{id}/insights. - **Freeze parity is the invariant**: the spectator board is always computed as of freeze_cutoff, so every score-derived figure here applies the same cutoff — otherwise the page would leak exactly what the board beside it hides. The challenge inventory and participant count are deliberately exempt (neither is score movement, and the board already behaves that way). - **The timeline agrees with the table**: all four point sources reach it — awarded solves, hint costs, score adjustments and award points — so each series ends exactly on that subject's board total. Dynamic solves use current values applied backwards (value-at-time isn't stored); documented in-module. - Same opt-in gating as the board, via a shared _load_public_competition helper (public_scoreboard + not archived, else an indistinguishable 404). Short TTL memo (settings.public_insights_cache_seconds) since this is unauthenticated and fans out to many spectators. Frontend: - Page widened max-w-3xl → max-w-7xl; standings and highlights sit side by side on large screens. - Hand-rolled inline-SVG chart, no library (as with the scoreboard bar chart, survey histograms and the #22 scrollbars). Geometry is pure and unit-tested in lib/timeline-chart.ts; scores are step functions, so lines hold then jump rather than implying a continuous trickle. - Series colours are new --chart-1…10 design tokens (§9 bans raw hex), declared once and palette-independent so a series keeps its identity across themes. - Legend hover/focus dims the other lines, which is what makes ten overlapping series readable; role="img" + aria-label summary, with the table as the text alternative. Verified live against a demo-mode backend + simulator: series finals matched board totals exactly (1574/1414/1339); after freezing, public stats and the timeline held at 100 solves / 1574 while the live staff board moved to 1569 (dynamic decay), and the timeline end snapped to frozen_at. Checked on dark and light palettes. Backend 385 passed; frontend 98 passed, tsc + eslint clean. Fixes #24 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #24
All three improvements from the issue: full-width layout, insight cards, and a live points-over-time timeline so spectators can watch competitors climb and overtake each other.
Owner decisions (asked during planning)
The two invariants this rests on
1. Freeze parity. The spectator board is always computed as of
freeze_cutoff(neverlive=True), so every score-derived figure here applies the same cutoff — otherwise the page would leak exactly what the board beside it is hiding. Two figures are deliberately exempt and documented as such: the challenge inventory (a freeze doesn't un-release a challenge) and the participant count (compute_scoreboardalready lists late joiners at zero points while frozen).2. The chart can't contradict the table. Points come from four sources — awarded solves, hint-cost deductions, score adjustments and award points — so all four become timeline events and every series ends exactly on that subject's board total. A submissions-only timeline would have visibly disagreed with the numbers beside it.
Backend
utils/public_insights.py(new) — kept separate from the staff-only, non-freeze-awareutils/analytics.pyso what we disclose publicly is auditable in one file.GET /api/public/competitions/{id}/insights, gated identically to the board via a new shared_load_public_competitionhelper (opted-in + not archived, else an indistinguishable 404).settings.public_insights_cache_seconds, 15s) — unauthenticated and fans out to many spectators while the page polls every 30s; tests disable it.Frontend
max-w-3xl→max-w-7xl; standings and highlights sit side by side on large screens.lib/timeline-chart.ts); scores are step functions, so lines hold then jump rather than implying a continuous trickle.--chart-1…--chart-10tokens (§9 bans raw hex), declared once and palette-independent so a series keeps its identity across themes.role="img"+ anaria-labelsummary, with the standings table as the text alternative.Verification
endsnapped tofrozen_atChecklist
/scoreboardand/ctftimeresponses untouched🤖 Generated with Claude Code