16 KiB
type, title, description, tags, timestamp
| type | title | description | tags | timestamp | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| guide | Challenges Board | How a signed-in player navigates the `/challenges` page, opens a challenge modal, submits a flag, and watches live solve updates. |
|
2026-07-23T16:10:00Z |
When this view is available
/challenges is rendered by ChallengesPage
(frontend/src/app/features/challenges/challenges.page.ts) inside the
authenticated shell.
On a fresh database the SeedSampleChallenges1700000000600
migration loads a representative sample set of challenges so the
board has cards the moment an admin configures the event window; an
empty board is only possible when the admin deleted every challenge.
The page is gated by:
| Gate | Where |
|---|---|
| Signed in (access token + refresh) | authGuard in app.routes.ts. |
| Event window state | ChallengesPage.isRunning() derived from EventStatusStore. The board is only shown when state === 'running'; otherwise the page renders a gate panel (see below). |
The first time the page mounts it triggers ChallengesStore.load()
which calls GET /api/v1/challenges/board. It also calls
GET /api/v1/challenges/status once to populate the event state
synchronously, then opens the authenticated SSE stream on
/api/v1/events for live solve frames`.
The mount order is significant: ChallengesPage.ngOnInit() calls
store.setMyUserId(user?.id ?? null) before store.load(). If the
singleton's cache belongs to an unknown or different user, setMyUserId
calls reset() first so the new board is never blended with a previous
user's solvedByMe. See
Per-User solvedByMe Across the Session Boundary.
How to access (tester steps)
- Initialize the instance and sign in (
/login). - Confirm the SPA routes to
/and redirects to/challenges. - Configure an event window (
/admin/general) whoseeventStartUtcis in the past andeventEndUtcis in the future so the event state isrunning.
Expected behavior
Board grid (state = running)
The page renders an <h2>Challenges</h2> followed by a horizontal
flex container ([data-testid="challenges-grid"]) of one column per
category.
| Element | Selector | Expected |
|---|---|---|
| Category column | [data-testid="category-column-CR"] (or any abbreviation) |
Header shows the category icon, uppercase abbreviation, and full name; below it is a vertical list of cards. The icon's src is /uploads/icons/<KEY>.png and is served by the backend's static middleware; on a freshly bootstrapped instance these PNGs are generated at startup by seedSystemCategoryIcons so the column headers never show broken images. |
| Category column with no challenges | Same | The header renders but the body shows No challenges yet. |
| Challenge card | [data-testid="challenge-card-<uuid>"] |
Rendered as a real <button type="button">. Keyboard activatable (Tab + Enter/Space), exposes aria-pressed and an accessible name Challenge <name>, solved / Challenge <name>, not solved, and data-solved="true"|"false". Click anywhere (or press Enter/Space) to open the modal. |
| Difficulty pill | .diff.diff-LOW / .diff-MEDIUM / .diff-HIGH |
Green / yellow / red pill matching the challenge difficulty. |
| Live points | [data-testid="points-<uuid>"] |
livePoints from the board payload; updates live as solve frames arrive. |
| Solve count | text N solve / N solves |
Number of distinct players who have solved this challenge. |
| Solved-by-me check | [data-testid="challenge-check-<uuid>"] (the ✓ glyph, aria-hidden="true") |
Appears on the card once the player has solved it; the card border also flips to the success color. The button's aria-pressed flips to true and the accessible name gains the trailing , solved. |
Columns are sorted alphabetically by abbreviation; cards within a
column are sorted LOW → MEDIUM → HIGH then by name (lowercased).
Gate panel (state != running)
When the event is in any other state, the grid is hidden and the page
renders [data-testid="challenges-gate"]:
| Event state | Headline | Countdown text ([data-testid="challenges-countdown"]) |
Helper label |
|---|---|---|---|
countdown |
Event starts in |
DD:HH:mm:ss until eventStartUtc (ticks each second during the final minute so the player sees time elapse) |
The board will be available once the event is running. |
running |
(grid shown instead) | — | — |
stopped |
Event ended |
Event ended |
The board is closed. |
unconfigured |
Awaiting configuration |
empty | (none) |
When secondsToStart or secondsToEnd reach zero, the
ChallengesPage triggers a full window.location.reload() via
EventStatusStore.subscribeReloadAtCountdownZero(...) so the SPA
re-evaluates the board visibility from the server snapshot. The
subscription is page-owned: ChallengesPage registers the handler in
its constructor and wires the returned disposer into its DestroyRef
so the reload listener is cleared as soon as the page is destroyed,
independent of the store's start()/stop() transport lifecycle (see
Event Window — Auto-reload at the transition boundary).
Challenge modal
Clicking a card opens ChallengeModalComponent
([data-testid="challenge-modal-<uuid>"]). The modal has a dark
backdrop; clicking the backdrop or pressing Esc closes it.
| Section | Selector / element | Expected |
|---|---|---|
| Header | <h3> + pills |
Challenge name, category abbreviation pill, difficulty pill (LOW/MEDIUM/HIGH), live points pill, and Close button. |
| Solved banner | [data-testid="modal-solved-banner"] |
Renders You solved this challenge. when card.solvedByMe is true. |
| Awarded banner | [data-testid="modal-awarded-banner"] |
Awarded X pts (base Y + rank bonus Z) after a successful submit. |
| Notice banner | .notice |
When the event is not running, shows Submissions are only accepted while the event is running. and disables the input. |
| Description | [innerHTML] from MarkdownService.render(...) |
Markdown body, links, code, etc. rendered from card.descriptionMd. |
| Flag input | [data-testid="flag-input"] |
Free text input; disabled while submitting or while the event is not running. |
| Solve button | [data-testid="solve-button"] |
Disabled while submitting. Submits via ChallengesStore.submit(...). |
| Inline error | [data-testid="modal-error"] |
Renders the friendly message from messageForSolveError(parseSolveError(...)). |
| Solvers list | [data-testid="modal-solvers"] |
Header Solvers (N) then a row per solver (#position, name, local-time, awarded points). Top three ranks are coloured gold / silver / bronze; subsequent ranks use the success color. |
| Empty solvers state | <em>No solvers yet.</em> |
Shown when the challenge has no solves yet. |
Submitting a flag
- Type the flag into
[data-testid="flag-input"]. - Click
[data-testid="solve-button"](or press Enter inside the form). - The modal calls
ChallengesStore.submit(challengeId, flag)which POSTs{ flag }to/api/v1/challenges/:id/solves. - On
200 solved:- The flag input is cleared.
- The
Awarded X pts (base Y + rank bonus Z)banner appears. - The solvers list re-renders with the player now at position
N. - The corresponding card on the board gets the success border and
✓.
- On
200 already_solved(idempotent re-submit):- Same banner + solvers list, but the inline error area shows
You already solved this challenge.
- Same banner + solvers list, but the inline error area shows
- On
400 FLAG_INCORRECT:- Inline error:
Incorrect flag. Try again. - Flag input is re-enabled so the player can retry.
- Inline error:
- On
409 EVENT_NOT_RUNNING:- Inline error:
Submissions are only accepted while the event is running. - The notice banner also reflects the event state.
- Inline error:
- On any other error (network, 5xx):
- The
errorNotificationInterceptoralso pushes a top-level notification in the SPA's notification store (see Notifications).
- The
Live solve updates
While the modal is open, the page subscribes to live solve frames
for the selected challenge via
ChallengesStore.registerSolveListener(id, payload => modal.applyLiveSolve(payload)).
Each solve SSE frame:
- Updates
card.livePointsandcard.solveCounton the modal. - Merges the new solver into the existing
solversarray (sorted bysolvedAtUtcASC) viamergeSolveEventIntoSolvers. - Updates the matching board card (point value + solve count + the
✓if the player is the solver).
When the user closes the modal, the listener is unregistered; on
destruction of the page the SSE source is closed via
ChallengesStore.stop().
Tester flows
Running event board
- Configure the event window as running; confirm the board renders the columns/cards described above.
- Open and close a card — the modal should mount and unmount cleanly with no console errors; the SSE source should remain connected.
- Solve a flag and confirm the awarded banner, the new solver row,
the updated board card (
✓and new live points), and the newlivePointson the same card across all open clients.
Countdown / stopped gate
- Set
eventStartUtcin the future; confirm the gate panel showsEvent starts inand theDD:HH:mm:sscountdown. Within the final minute the seconds digit visibly ticks down (e.g.00:00:00:24→00:00:00:23→ …) so the player can see time elapse. - Wait for the countdown to hit zero; the SPA reloads and now shows the board.
- Set
eventEndUtcin the past; confirm the gate showsEvent ended. - Delete one of the event-window settings; confirm
Awaiting configurationand an empty countdown.
Submission errors
- Submit an empty string — the modal's local guard skips the call.
- Submit a wrong flag — expect
Incorrect flag. Try again.. - Submit a correct flag twice — second call returns
already_solvedand shows both the awarded banner and theYou already solved this challenge.inline error. - Try to submit while the event is
stopped— expectSubmissions are only accepted while the event is running.and a matching notice banner.
Architecture map
| File | Responsibility |
|---|---|
frontend/src/app/features/challenges/challenges.page.ts |
Smart page; owns the modal lifecycle, gate logic, SSE wiring, and the countdown-zero reload. |
frontend/src/app/features/challenges/challenges.store.ts |
Signal store: board, event state, solve listeners, per-card mutation on submit + SSE solve. |
frontend/src/app/features/challenges/challenges.service.ts |
HTTP service: getBoard, getDetail, getEventState, submit (returns ApiErrorEnvelope). |
frontend/src/app/features/challenges/challenges.pure.ts |
Pure types (BoardCard, SolverRow, SolveEventPayload, etc.) + helpers (sortColumnsByAbbrev, formatDdHhMm, parseSolveEvent, messageForSolveError). |
frontend/src/app/features/challenges/category-column.component.ts |
Renders one category column (header + cards). |
frontend/src/app/features/challenges/challenge-card.component.ts |
Renders one challenge card on the board as a semantic <button> with aria-pressed, aria-label, data-testid="challenge-card-<id>", data-solved, and an inner data-testid="challenge-check-<id>" for the solved glyph. |
tests/frontend/challenge-card-accessibility.spec.ts |
Keyboard activation (Enter/Space), aria-pressed toggling, accessible-name composition, and the data-solved/challenge-check-* selectors. |
frontend/src/app/features/challenges/challenge-modal.component.ts |
Renders the challenge detail modal, flag form, solvers list, and live-solve updates. |
frontend/src/app/core/services/event-status.store.ts |
Anchor-based clock + 4-state event store; reused here for the gate logic. |
frontend/src/app/core/services/authenticated-event-source.service.ts |
Fetch-based SSE transport used to open /api/v1/events. |
frontend/src/app/core/services/notification.service.ts |
Signal-backed notification store used by errorNotificationInterceptor. |
tests/frontend/challenges.pure.spec.ts |
Pure helpers (sorting, parsers, friendly error mapping). |
tests/frontend/challenges.store.spec.ts |
Signal store: board mutation, live solve merge, listeners, stop(). |
tests/frontend/notification-interceptor.spec.ts |
Interceptor suppression rules and friendly message mapping. |
tests/backend/challenges-board.spec.ts |
Board query shape, ordering, and ?include=solvers flag. |
tests/backend/challenges-status-rest.spec.ts |
/api/v1/challenges/status snapshot shape. |
tests/backend/challenges-events-sse.spec.ts |
/api/v1/events SSE: status + solve frames, dedup, ordering. |
tests/backend/challenges-submit-flag.spec.ts |
Submit flow: correct/incorrect flag, idempotent re-submit, event-state guard, race handling. |
See also
- Challenges Endpoints
- Challenge Tables
- System Endpoints (legacy
/events/status) - System Category Icon Seed (how the column-header icon PNGs are guaranteed to exist on a fresh clone)
- Scoreboard Stream
- Authenticated Shell (SSE transport)
- Per-User
solvedByMeAcross the Session Boundary (logout/login, peer-tab, SSE-unauthorized reset) - Notifications (error interceptor + service)