Skip to content

feat(public): spectator insights + points timeline - #35

Merged
tbcsec merged 1 commit into
mainfrom
feat/public-scoreboard-insights
Jul 25, 2026
Merged

feat(public): spectator insights + points timeline#35
tbcsec merged 1 commit into
mainfrom
feat/public-scoreboard-insights

Conversation

@tbcsec

@tbcsec tbcsec commented Jul 25, 2026

Copy link
Copy Markdown
Owner

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)

  • Challenge-level insights included — challenge names + solve/attempt counts are acceptable publicly (authenticated competitors already see per-challenge solve counts in-app).
  • Top 10 on the timeline — CTFtime convention, the readability limit, and it bounds the work an unauthenticated endpoint does.

The two invariants this rests on

1. Freeze parity. The spectator board is always computed as of freeze_cutoff (never live=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_scoreboard already 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-aware utils/analytics.py so 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_competition helper (opted-in + not archived, else an indistinguishable 404).
  • Short TTL memo (settings.public_insights_cache_seconds, 15s) — unauthenticated and fans out to many spectators while the page polls every 30s; tests disable it.
  • Only published and released challenges are counted, so drafts and unreleased waves aren't disclosed.
  • Known simplification (documented in-module): dynamic solves use current values applied backwards — value-at-time isn't stored. It converges exactly on the board total, which is what matters here.

Frontend

  • max-w-3xlmax-w-7xl; standings and highlights sit side by side on large screens.
  • Hand-rolled inline-SVG chart, no library — same call as the scoreboard bar chart, survey histograms and the [Feature]: replace browser default scrollbars #22 scrollbars. Geometry is pure and unit-tested (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--chart-10 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 — what makes ten overlapping series readable. role="img" + an aria-label summary, with the standings table as the text alternative.

Verification

  • ✅ Backend 385 passed (8 new, incl. freeze parity, series-equals-board-total, top-10 cap, draft/unreleased non-disclosure, archived 404); frontend 98 passed (13 new geometry tests), tsc + eslint clean
  • Live, logged out, demo backend + simulator: series finals matched board totals exactly (1574 / 1414 / 1339); legend names matched the table
  • Freeze test: after freezing and 20s more play, public stats and the timeline held at 100 solves / 1574 while the live staff board moved to 1569 (dynamic decay) — proving the frozen figures are genuinely computed as-of the cutoff, not cached. Timeline end snapped to frozen_at
  • ✅ Dark and light palettes checked; a desktop-width layout bug (highlight cards stretching to the table's height) was caught and fixed

Checklist

  • Backend + frontend checks pass
  • Verified in the browser, unauthenticated
  • Design tokens only, no raw hex (§9)
  • No migration, no model change, no new event; existing /scoreboard and /ctftime responses untouched

🤖 Generated with Claude Code

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>
@tbcsec
tbcsec merged commit b7231e7 into main Jul 25, 2026
3 checks passed
@tbcsec
tbcsec deleted the feat/public-scoreboard-insights branch July 26, 2026 09:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: public scoreboard improvements

1 participant