220 lines
15 KiB
Markdown
220 lines
15 KiB
Markdown
---
|
|
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-23T03:09: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](/guides/challenges-per-user-state.md).
|
|
|
|
# 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 `<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: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](/guides/event-window.md#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
|
|
|
|
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:ss` countdown. 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.
|
|
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)
|
|
- [Per-User `solvedByMe` Across the Session Boundary](/guides/challenges-per-user-state.md) (logout/login, peer-tab, SSE-unauthorized reset)
|
|
- [Notifications](/guides/notifications.md) (error interceptor + service) |