--- type: guide title: Challenges Board description: How a signed-in player navigates the `/challenges` page, opens a challenge modal, submits a flag, and watches live solve updates. tags: [guide, challenges, board, flag, score, sse, tester] timestamp: 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. 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. # How to access (tester steps) 1. Initialize the instance and sign in (`/login`). 2. Confirm the SPA routes to `/` and redirects to `/challenges`. 3. Configure an event window (`/admin/general`) whose `eventStartUtc` is in the past and `eventEndUtc` is in the future so the event state is `running`. # Expected behavior ## Board grid (state = `running`) The page renders an `

Challenges

` 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-"]` | 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-"]` | `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.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](/guides/event-window.md#auto-reload-at-the-transition-boundary)). ## Challenge modal Clicking a card opens `ChallengeModalComponent` (`[data-testid="challenge-modal-"]`). The modal has a dark backdrop; clicking the backdrop or pressing `Esc` closes it. | Section | Selector / element | Expected | |------------------------|----------------------------------------------------------|------------------------------------------------------------------------------------------| | Header | `

` + 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 | `No solvers yet.` | Shown when the challenge has no solves yet. | ## Submitting a flag 1. Type the flag into `[data-testid="flag-input"]`. 2. Click `[data-testid="solve-button"]` (or press Enter inside the form). 3. The modal calls `ChallengesStore.submit(challengeId, flag)` which POSTs `{ flag }` to `/api/v1/challenges/:id/solves`. 4. 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 `✓`. 5. On `200 already_solved` (idempotent re-submit): * Same banner + solvers list, but the inline error area shows `You already solved this challenge.` 6. On `400 FLAG_INCORRECT`: * Inline error: `Incorrect flag. Try again.` * Flag input is re-enabled so the player can retry. 7. On `409 EVENT_NOT_RUNNING`: * Inline error: `Submissions are only accepted while the event is running.` * The notice banner also reflects the event state. 8. On any other error (network, 5xx): * The `errorNotificationInterceptor` also pushes a top-level notification in the SPA's notification store (see [Notifications](/guides/notifications.md)). ## 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.livePoints` and `card.solveCount` on the modal. * Merges the new solver into the existing `solvers` array (sorted by `solvedAtUtc` ASC) via `mergeSolveEventIntoSolvers`. * 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 1. Configure the event window as running; confirm the board renders the columns/cards described above. 2. Open and close a card — the modal should mount and unmount cleanly with no console errors; the SSE source should remain connected. 3. Solve a flag and confirm the awarded banner, the new solver row, the updated board card (`✓` and new live points), and the new `livePoints` on the same card across all open clients. ## Countdown / stopped gate 1. Set `eventStartUtc` in the future; confirm the gate panel shows `Event starts in` and the `DD:HH:mm` countdown. 2. Wait for the countdown to hit zero; the SPA reloads and now shows the board. 3. Set `eventEndUtc` in the past; confirm the gate shows `Event ended`. 4. Delete one of the event-window settings; confirm `Awaiting configuration` and an empty countdown. ## Submission errors 1. Submit an empty string — the modal's local guard skips the call. 2. Submit a wrong flag — expect `Incorrect flag. Try again.`. 3. Submit a correct flag twice — second call returns `already_solved` and shows both the awarded banner and the `You already solved this challenge.` inline error. 4. Try to submit while the event is `stopped` — expect `Submissions 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](/api/challenges.md) - [Challenge Tables](/database/challenges.md) - [System Endpoints](/api/system.md) (legacy `/events/status`) - [Scoreboard Stream](/guides/scoreboard-stream.md) - [Authenticated Shell](/guides/authenticated-shell.md) (SSE transport) - [Notifications](/guides/notifications.md) (error interceptor + service)