Skip to content

feat(realtime): live updates across the site — the activity room - #36

Merged
tbcsec merged 2 commits into
mainfrom
feat/activity-live-updates
Jul 25, 2026
Merged

feat(realtime): live updates across the site — the activity room#36
tbcsec merged 2 commits into
mainfrom
feat/activity-live-updates

Conversation

@tbcsec

@tbcsec tbcsec commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Fixes #18

What

One new mechanism instead of five bespoke fixes: a per-competition activity/<competition_id> WebSocket room that fans out tiny event-name pings, and a frontend map from event names → TanStack Query invalidations. Every surface the issue names now updates live:

  • Dashboard widgets — stats tiles, recent activity, challenge health, your standing/solves
  • Challenge cards — solve counts, dynamic point values, the dialog's solver list
  • Participants / teams roster — joins, standings, overtakes
  • Support — thread replies now bump the staff queue; a competitor's ticket list refreshes when staff reply/resolve (rides their existing notification frames — no new socket)
  • Analytics — attempts, solves, completion rates

Bonus liveness for free (one map line each): the pause banner appears live (competition.updated), module toggles re-gate the nav live, and category/hint/vocab edits propagate.

Design

  • Ping → refetch, not snapshot-push. These surfaces are all per-user filtered (solved state, locks, MC attempts, role-scoped stats), so a shared snapshot can't exist. Frames carry the event name + ids only, never bodies — nothing can leak, because the refetch is permission-filtered server-side. §4.1 now documents the two room idioms (snapshot vs ping).
  • One shell-level socket (mounted beside the notification bell), authorized like the scoreboard room (challenge_view), broadcast skipped when nobody's watching. invalidateQueries only refetches mounted queries, so background pages are marked stale and fetch fresh on navigation.
  • Curated allowlists on both ends: the backend fans out an explicit event list (ticket.*/announcement.* excluded — they have tighter-scoped rooms; user.*/role.* are global-admin domains); the frontend map treats unmapped events as a no-op.
  • Per-key leading+trailing throttle (2.5s) in lib/live.ts: a lone event lands instantly, a burst collapses to at most one refetch per key per window, the last event is never lost.

New §3.2 event: challenge.attempted (owner-approved)

Every graded submission, right or wrong — the event half of §13.2's "every attempt is logged". Wrong guesses previously emitted nothing, so attempt counters could not go live. Automation trigger gated view_competition_analytics (others' attempts are staff analytics data); volume bounded by the submission rate limit. Deliberately not wired to the challenge-list refetch (heaviest query, most frequent event).

Tests

  • Backend 391 passed (+6): room auth (member/outsider/bogus id), wrong submit → attempted ping, correct submit → attempted then solved, ticket events don't reach the room, team-created ping, thread-reply → support bump, notification frame carries competition_id.
  • Frontend 109 passed (+11): map coverage incl. the layout-key exclusion, throttle timing (leading/trailing/steady-state/dispose) under fake timers. tsc + eslint clean.

Live verification (demo backend + simulator)

Logged in as admin with the simulator generating traffic — no reloads anywhere:

  • Dashboard tiles climbed 16→20 solves / 23→29 submissions in 30s; recent-activity feed streamed.
  • Challenge cards: page-wide solve total read exactly 31 when the DB held 31.
  • Analytics: attempts 52→63, solves 36→47 in place.
  • Roster: a bot overtook rank 2 in place.
  • API-pausing the competition surfaced the paused banner on the challenges page within the throttle window.
  • One activity socket total, no reconnect churn, zero console errors.

🤖 Generated with Claude Code

tbcsec and others added 2 commits July 25, 2026 23:17
…vent

The backend half of live-updates-everywhere (#18). A new broadcast-only
`activity/<competition_id>` WS room, owned by the competitions module
(the tenancy root): a curated allowlist of §3.2 events fans out as tiny
`{type: "activity", event}` pings — ids only, never payload bodies — so
clients refetch their own permission-filtered REST slices. Authorized
like the scoreboard room (challenge_view), recompute skipped when nobody
is watching. ticket.*/announcement.* stay off the allowlist (they have
tighter-scoped rooms of their own); user.*/role.*/site.* are global-admin
domains, not competition activity.

New §3.2 event `challenge.attempted`: every *graded* submission, right
or wrong (the event half of "every attempt is logged"). Wrong guesses
previously emitted nothing, leaving attempt counters (dashboard stats,
challenge health, analytics) stale until the next solve. Automation
trigger gated view_competition_analytics — others' attempts are staff
analytics data. Volume bounded by the submission rate limit.

Two ticket-liveness gaps fixed while in there: a thread reply now bumps
the staff support room (ticket_updated, no audio cue), and notification
frames carry competition_id so the bell can refresh that competition's
ticket caches client-side.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The frontend half of #18. `lib/live.ts` (pure, unit-tested — mirroring
the data-table split) maps each activity event to the query keys it
stales: challenge cards/dialog (solve counts, dynamic value, solver
list), the dashboard data widgets (never the layout key), rosters,
analytics, module nav gating, and the competition detail (pause banner
goes live). Unmapped events are a deliberate no-op — the map is the
frontend's allowlist.

Invalidations run through a per-key leading+trailing throttle (2.5s):
a lone event lands instantly, a burst collapses to at most one refetch
per key per window, and the last event is never lost. attempted events
deliberately skip the challenge list — heaviest refetch, most frequent
event, and someone else's wrong guess doesn't change your cards.

`useActivityLive` (use-activity.ts) owns the socket lifecycle and is
mounted once in the app shell beside the notification bell — pages never
open their own. Since invalidateQueries only refetches *mounted*
queries, background pages are just marked stale and fetch fresh on
navigation. The bell hook additionally refreshes the competition's
ticket caches on ticket-type notification frames, closing the
competitor-side support gap.

Docs: §3.2 gains challenge.attempted; §4.1 now names the two room
idioms (snapshot vs ping) and the activity room's place in them.

Verified live against a demo-mode backend + simulator: dashboard tiles,
challenge-card solve counts (page total matched the DB exactly),
analytics attempts/solves, and the participants roster all moved
without a reload; pausing via the API surfaced the banner live; one
shell socket, no reconnect churn, no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tbcsec
tbcsec merged commit 25051c1 into main Jul 25, 2026
3 checks passed
@tbcsec
tbcsec deleted the feat/activity-live-updates branch July 25, 2026 22:22
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]: websockets/live updates across the site

1 participant