14 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-23T00:10:00Z |
When this view is available
/challenges is rendered by ChallengesPage
(frontend/src/app/features/challenges/challenges.page.ts) inside the
authenticated shell. 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.
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. |
| Category column with no challenges | Same | The header renders but the body shows No challenges yet. |
| Challenge card | [data-testid="challenge-card-<uuid>"] |
Click anywhere on the card 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 | .check (the ✓ glyph) |
Appears on the card once the player has solved it; the card border also flips to the success color. |
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 until eventStartUtc |
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.reloadAtCountdownZero(...) so the SPA re-evaluates
the board visibility from the server snapshot.
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:mmcountdown. - 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. |
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) - Scoreboard Stream
- Authenticated Shell (SSE transport)
- Notifications (error interceptor + service)