AI Implementation feature(907): Challenges Page and Challenge Solve Modal 1.03 #49

Merged
m0rph3us1987 merged 2 commits from feature-907-1784774938350 into dev 2026-07-23 03:11:19 +00:00
15 changed files with 245 additions and 73 deletions
-28
View File
@@ -1,28 +0,0 @@
# Implementation Plan: Register SeedSampleChallenges migration in DatabaseModule
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** NestJS + TypeORM; migrations are registered manually via an in-source `MIGRATIONS` array (not a filesystem glob) in `backend/src/database/database.module.ts`. Imports follow chronological order matching the array.
- **Data Layer:** SQLite via `better-sqlite3`. `DatabaseInitService.init()` (called from `main.ts` before `app.listen()`) executes `dataSource.runMigrations({ transaction: 'each' })`, which **uses the `MIGRATIONS` array** registered with `TypeOrmModule.forRootAsync`. Until the array contains the new class, `npm run setup` and the first app boot will not run `SeedSampleChallenges1700000000600`.
- **Test framework:** Jest; backend tests construct their own in-memory `DataSource` with an explicit `migrations: [...]` list — they bypass `DatabaseModule`, which is why `migrations.spec.ts` (already updated for Job 906) passes despite this gap.
- **Required tools & dependencies:** none.
## 2. Impacted Files
- **To Modify:**
- `backend/src/database/database.module.ts` — add the import and append the new migration class to the `MIGRATIONS` array, right after `UpgradeChallengeAdminSchema1700000000500`.
## 3. Proposed Changes
1. **Edit `database.module.ts`:**
- Add the import alongside the other chronological migration imports (between `.../1700000000500-UpgradeChallengeAdminSchema` and the next non-migration import `DatabaseInitService`):
```ts
import { SeedSampleChallenges1700000000600 } from './migrations/1700000000600-SeedSampleChallenges';
```
- Append to the `MIGRATIONS` array, immediately after `UpgradeChallengeAdminSchema1700000000500`:
```ts
SeedSampleChallenges1700000000600,
```
2. **No other code changes.** The migration class already exists, is exported, and is covered by tests in `tests/backend/migrations.spec.ts`. Once registered in the TypeORM `MIGRATIONS` array, `DatabaseInitService.init()` (called via `main.ts` and indirectly by `npm run setup`) will execute it on next boot.
3. **Verification expectation:** After `npm run setup` (or first boot on a fresh DB), `SELECT COUNT(*) FROM challenge;` returns `> 0` and the eight sample names are present. Re-running setup on a DB that already has rows is a no-op (the migration's early-return guard).
## 4. Test Strategy
- No new automated tests required. The existing `tests/backend/migrations.spec.ts` already asserts the seed runs and that values are schema-compatible, schema-correct, and `down()` is scoped correctly. The fix is purely a wiring change that registers an already-tested class with the production `DatabaseModule`, which has no dedicated test (and per the Jobs rule, no new test files should be introduced for a 2-line wiring fix).
- **Manual smoke (tester steps, no UI):** on a fresh DB file, run `npm run setup` then `sqlite3 ./data/db.sqlite 'SELECT COUNT(*) FROM challenge;'` and confirm the count is `8`; on a re-run, confirm the count is unchanged (idempotent guard works).
+38
View File
@@ -0,0 +1,38 @@
# Implementation Plan: Challenges Page and Challenge Solve Modal 1.03
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** Node.js monorepo with an Angular 17 standalone SPA and NestJS API, written in TypeScript. Frontend state uses root-provided signal stores with private mutable signals and readonly/computed public state; HTTP calls are isolated in injectable services and return typed RxJS `Observable`s. The challenges page consumes `EventStatusStore` for its anchor-based one-second countdown and `ChallengesStore`/`ChallengesApiService` for board, detail, solve, and SSE data. Pure countdown helpers are currently duplicated in `frontend/src/app/core/services/event-status.pure.ts` and `frontend/src/app/features/challenges/challenges.pure.ts`, and both drop seconds.
- **Data Layer:** SQLite through TypeORM in the NestJS backend. No schema or persistence change is needed: the event store already recalculates remaining seconds every second, solve persistence is correct, `ChallengesService.getDetail()` already loads solver rows, and solve responses already return the updated solver list.
- **Test Framework & Structure:** Jest 29 with `ts-jest`; frontend tests use jsdom and live under the dedicated `tests/frontend/` tree. Root `npm test` executes backend and frontend projects through `tests/jest.config.js`, while `npm run test:frontend` provides the focused frontend run. Keep coverage at pure/helper and HTTP-service boundaries without browser/visual tests.
- **Required Tools & Dependencies:** No new system tools, global CLIs, npm packages, `/data` assets, or setup steps are required. Existing Angular `HttpClient`, Jest, jsdom, and `ts-jest` are sufficient; `setup.sh` does not need modification. Verification should use the existing root `npm test` and `npm run build` scripts; the repository exposes no lint or standalone typecheck script.
## 2. Impacted Files
- **To Modify:**
- `frontend/src/app/core/services/event-status.pure.ts` — format countdown output with the actual seconds remaining so computed text changes on every one-second store tick.
- `frontend/src/app/features/challenges/challenges.pure.ts` — keep the challenges-page formatter consistent with the shared event-status formatter (or re-export/delegate to one source of truth) so the gate also displays seconds.
- `frontend/src/app/features/challenges/challenges.service.ts` — request challenge detail with `include=solvers` so the modal receives the servers current solver list after an event resume and solve.
- `tests/frontend/event-status.store.spec.ts` — add/update focused assertions for the final-minute countdown and second-level decrement behavior.
- `tests/frontend/challenges.pure.spec.ts` — update the feature formatter contract to include seconds and retain invalid-input coverage.
- `tests/frontend/challenges-page.spec.ts` — update countdown expectations/documentation and cover final-minute values used by the page gate.
- `tests/frontend/challenge-modal-submit.spec.ts` — update the incidental formatter assertion to the new output shape, keeping solve-modal tests aligned.
- `tests/frontend/challenges.service.spec.ts` — add a minimal HTTP boundary regression test asserting that detail loading sends `include=solvers` and preserves the typed detail payload.
- **To Create:**
- `tests/frontend/challenges.service.spec.ts` — only if no existing service test is present at implementation time; keep it in the dedicated test tree and use Angular HTTP testing utilities rather than a manual browser or backend environment.
## 3. Proposed Changes
1. **Database / Schema Migration:** No migration. Solver records, event state, scoring, and backend detail assembly are already correct and must remain unchanged.
2. **Backend Logic & APIs:** No backend implementation change. The existing `GET /api/v1/challenges/:id` flow in `backend/src/modules/challenges/challenges.controller.ts` and `ChallengesService.getDetail()` already retrieves solvers. Preserve the current API contract and add the clients expected `include=solvers` query parameter rather than changing solve/event-state behavior.
3. **Frontend UI Integration:**
1. Change the countdown formatter contract from `DD:HH:MM` to a second-bearing representation while preserving all four units, e.g. `DD:HH:MM:SS`; compute `seconds = floor(totalSeconds) % 60`, zero-pad every component, and retain the existing `00:00:00:00` fallback for negative/non-finite input. This makes values such as 24, 23, and 22 render distinctly and lets the existing `EventStatusStore.tick` signal drive visible one-second updates without introducing another timer.
2. Eliminate behavioral drift between the core and challenges formatters by having the challenges pure module delegate to/re-export the shared core formatter, or otherwise apply the exact same implementation if repository import constraints require it. Keep `ChallengesPage.countdownText` and `EventStatusStore.countdownText` on the same output contract.
3. Update `ChallengesApiService.getDetail()` to pass typed query params containing `include: 'solvers'` alongside `withCredentials: true`. Keep URL encoding and existing `catchError` envelope conversion unchanged.
4. Leave modal submission state handling intact: successful and idempotent solve responses already replace the modals solver signal from `resp.solvers`. The detail-query fix addresses newly opened/reopened modals by ensuring their initial server fetch explicitly requests solver data.
5. Update directly relevant documentation strings/comments or API/countdown labels only if implementation validation confirms they are part of the maintained contract; do not add unrelated UI or backend work.
## 4. Test Strategy
- **Target Unit Test File:**
- `tests/frontend/event-status.store.spec.ts`: assert final-minute formatting (for example, `24 -> 00:00:00:24` and `23 -> 00:00:00:23`) plus representative multi-day formatting and invalid input.
- `tests/frontend/challenges.pure.spec.ts` and `tests/frontend/challenges-page.spec.ts`: update focused formatter/page-gate expectations so seconds are retained rather than frozen at zero.
- `tests/frontend/challenges.service.spec.ts`: instantiate `ChallengesApiService` with Angulars HTTP testing providers, call `getDetail(id)`, assert the request URL/method, `withCredentials`, and `include=solvers`, flush a small `ChallengeDetail`, and verify the observable result.
- `tests/frontend/challenge-modal-submit.spec.ts`: update only the existing formatter assertion affected by the contract; rely on existing success-path tests for solve-response solver replacement.
- **Mocking Strategy:** Use pure function calls for countdown coverage and `provideHttpClient()` plus `provideHttpClientTesting()`/`HttpTestingController` for the detail request. Flush an in-memory detail payload and call `verify()` after each HTTP test. Do not start NestJS, SQLite, SSE, timers requiring real waiting, a browser, or visual tooling; no persistent `/data` fixture is needed. Run all automated checks from the repository root with `npm test`, then `npm run build` for Angular/NestJS compilation.
+1 -1
View File
@@ -89,7 +89,7 @@ single path.
| `EventState` | Union `'running' \| 'countdown' \| 'stopped' \| 'unconfigured'`. | | `EventState` | Union `'running' \| 'countdown' \| 'stopped' \| 'unconfigured'`. |
| `EventStatePayload` | Server-pushed event window payload shape. | | `EventStatePayload` | Server-pushed event window payload shape. |
| `EventSourceLike` | Minimal interface for any SSE transport the store can drive. | | `EventSourceLike` | Minimal interface for any SSE transport the store can drive. |
| `formatDdHhMm(seconds)` | Pure `DD:HH:mm` formatter. | | `formatDdHhMm(seconds)` | Pure `DD:HH:mm:ss` formatter (zero-padded, falls back to `00:00:00:00` for negative / non-finite input). |
| `deriveCountdownText(state, start, end)` | Pure mapping from event window state to countdown label. | | `deriveCountdownText(state, start, end)` | Pure mapping from event window state to countdown label. |
| `LED_COLOR_BY_STATE` | `Readonly<Record<EventState, string>>` mapping each state to its theme CSS variable (`var(--color-success)`, `--color-warning`, `--color-danger`, `--color-secondary`). | | `LED_COLOR_BY_STATE` | `Readonly<Record<EventState, string>>` mapping each state to its theme CSS variable (`var(--color-success)`, `--color-warning`, `--color-danger`, `--color-secondary`). |
| `ledBackgroundColor(state)` | Pure accessor returning the `background-color` value for the LED element. | | `ledBackgroundColor(state)` | Pure accessor returning the `background-color` value for the LED element. |
+4 -3
View File
@@ -59,7 +59,7 @@ timestamp: 2026-07-23T01:19:00Z
| `frontend/src/app/core/services/bootstrap-event.service.ts` | Root-provided public SSE listener that subscribes to `/api/v1/event/stream`, filters `topic === 'general'` frames, and triggers `BootstrapService.refresh()` so the landing modal stays in sync with admin general-settings updates. | | `frontend/src/app/core/services/bootstrap-event.service.ts` | Root-provided public SSE listener that subscribes to `/api/v1/event/stream`, filters `topic === 'general'` frames, and triggers `BootstrapService.refresh()` so the landing modal stays in sync with admin general-settings updates. |
| `frontend/src/app/core/services/bootstrap-event.pure.ts` | Pure helpers for the public bootstrap SSE listener: `isBootstrapGeneralFrame` predicate, SSE line parser, and the `makeBootstrapEventSource` factory (fetch + `ReadableStream` opener with frame buffering and idempotent `close()`). | | `frontend/src/app/core/services/bootstrap-event.pure.ts` | Pure helpers for the public bootstrap SSE listener: `isBootstrapGeneralFrame` predicate, SSE line parser, and the `makeBootstrapEventSource` factory (fetch + `ReadableStream` opener with frame buffering and idempotent `close()`). |
| `frontend/src/app/core/services/event-status.store.ts` | Event state signal store, one-second countdown timer, and the optional `onUnauthorized` callback wiring; exposes `start()`/`stop()`/`closeTransport()` for transport lifecycle and `subscribeReloadAtCountdownZero(handler)` (with `reloadAtCountdownZero(handler)` kept as a back-compat wrapper) so the reload handler is owned by its subscriber rather than the transport. | | `frontend/src/app/core/services/event-status.store.ts` | Event state signal store, one-second countdown timer, and the optional `onUnauthorized` callback wiring; exposes `start()`/`stop()`/`closeTransport()` for transport lifecycle and `subscribeReloadAtCountdownZero(handler)` (with `reloadAtCountdownZero(handler)` kept as a back-compat wrapper) so the reload handler is owned by its subscriber rather than the transport. |
| `frontend/src/app/core/services/event-status.pure.ts` | Event payload types, transport interface (including `'unauthorized'` listener), pure countdown helpers, and the `LED_COLOR_BY_STATE` map used by the shell LED. | | `frontend/src/app/core/services/event-status.pure.ts` | Event payload types, transport interface (including `'unauthorized'` listener), pure countdown helpers (`formatDdHhMm` returns `DD:HH:mm:ss` and `deriveCountdownText`), and the `LED_COLOR_BY_STATE` map used by the shell LED. |
| `frontend/src/app/features/home/home.component.ts` | Authenticated shell container; starts user and event state services, subscribes to peer invalidation, and navigates to `/login` when the session is invalidated elsewhere. | | `frontend/src/app/features/home/home.component.ts` | Authenticated shell container; starts user and event state services, subscribes to peer invalidation, and navigates to `/login` when the session is invalidated elsewhere. |
| `frontend/src/app/features/shell/header/shell-header.component.ts` | Shell title, status LED (size/shape only — color comes from the pure map via `[style.background-color]`), countdown, and user menu. | | `frontend/src/app/features/shell/header/shell-header.component.ts` | Shell title, status LED (size/shape only — color comes from the pure map via `[style.background-color]`), countdown, and user menu. |
| `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Main shell navigation tabs. | | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Main shell navigation tabs. |
@@ -74,14 +74,15 @@ timestamp: 2026-07-23T01:19:00Z
| `tests/frontend/admin-challenges-import-autoclose.spec.ts` | Pure helpers (`summarizeImportResult`, `importErrorMessage`) and modal auto-close branch for the confirmed-import handler. | | `tests/frontend/admin-challenges-import-autoclose.spec.ts` | Pure helpers (`summarizeImportResult`, `importErrorMessage`) and modal auto-close branch for the confirmed-import handler. |
| `frontend/src/app/features/challenges/challenges.page.ts` | `/challenges` smart page: gate logic (countdown/running/stopped/unconfigured), modal lifecycle, SSE wiring on `/api/v1/events`, and the page-owned countdown-zero reload (handler registered in the constructor with the disposer wired into `DestroyRef`). | | `frontend/src/app/features/challenges/challenges.page.ts` | `/challenges` smart page: gate logic (countdown/running/stopped/unconfigured), modal lifecycle, SSE wiring on `/api/v1/events`, and the page-owned countdown-zero reload (handler registered in the constructor with the disposer wired into `DestroyRef`). |
| `frontend/src/app/features/challenges/challenges.store.ts` | Signal store: board, event state, per-card solve listeners, SSE solve-frame mutation, submit response application, stop() lifecycle. | | `frontend/src/app/features/challenges/challenges.store.ts` | Signal store: board, event state, per-card solve listeners, SSE solve-frame mutation, submit response application, stop() lifecycle. |
| `frontend/src/app/features/challenges/challenges.service.ts` | HTTP service for `/api/v1/challenges/{board,status,:id,:id/solves}` returning typed `ApiErrorEnvelope`s. | | `frontend/src/app/features/challenges/challenges.service.ts` | HTTP service for `/api/v1/challenges/{board,status,:id,:id/solves}` returning typed `ApiErrorEnvelope`s. `getDetail` always requests `?include=solvers` so the modal can render the solvers list in one round-trip; `getBoard` only attaches `include=solvers` when the caller opts in. |
| `frontend/src/app/features/challenges/challenges.pure.ts` | Pure types and helpers (`BoardCard`, `SolverRow`, `SolveEventPayload`, `parseSolveEvent`, `mergeSolveEventIntoSolvers`, `messageForSolveError`, `formatDdHhMm`). | | `frontend/src/app/features/challenges/challenges.pure.ts` | Pure types and helpers (`BoardCard`, `SolverRow`, `SolveEventPayload`, `parseSolveEvent`, `mergeSolveEventIntoSolvers`, `messageForSolveError`, `formatDdHhMm`). |
| `frontend/src/app/features/challenges/category-column.component.ts` | Renders a single category column (header + cards) on the challenges board. | | `frontend/src/app/features/challenges/category-column.component.ts` | Renders a single category column (header + cards) on the challenges board. |
| `frontend/src/app/features/challenges/challenge-card.component.ts` | Renders a single challenge card on the board (difficulty, live points, solve count, `✓` when solved by the player). | | `frontend/src/app/features/challenges/challenge-card.component.ts` | Renders a single challenge card on the board (difficulty, live points, solve count, `✓` when solved by the player). |
| `frontend/src/app/features/challenges/challenge-modal.component.ts` | Challenge detail modal: description, flag form, awarded banner, solvers list, live-solve updates. | | `frontend/src/app/features/challenges/challenge-modal.component.ts` | Challenge detail modal: description, flag form, awarded banner, solvers list, live-solve updates. |
| `frontend/src/app/core/services/notification.service.ts` | Root-provided signal-backed store of `{ id, kind, message, ts }` records; `error()` / `info()` push, `dismiss(id)` / `clear()` remove. | | `frontend/src/app/core/services/notification.service.ts` | Root-provided signal-backed store of `{ id, kind, message, ts }` records; `error()` / `info()` push, `dismiss(id)` / `clear()` remove. |
| `frontend/src/app/core/interceptors/error-notification.interceptor.ts` | Translates `HttpErrorResponse` into friendly messages and pushes them to `NotificationService` (with suppression for `/api/v1/auth/{login,csrf,register}` and `/api/v1/challenges/status`). | | `frontend/src/app/core/interceptors/error-notification.interceptor.ts` | Translates `HttpErrorResponse` into friendly messages and pushes them to `NotificationService` (with suppression for `/api/v1/auth/{login,csrf,register}` and `/api/v1/challenges/status`). |
| `tests/frontend/challenges.pure.spec.ts` | Pure helpers: sorting, parsers, friendly error mapping, `formatDdHhMm`. | | `tests/frontend/challenges.pure.spec.ts` | Pure helpers: sorting, parsers, friendly error mapping, `formatDdHhMm` (`DD:HH:mm:ss`). |
| `tests/frontend/challenges.service.spec.ts` | HTTP-service contract: `getDetail` URL-encodes the id, attaches `?include=solvers`, and forwards `withCredentials: true`. |
| `tests/frontend/challenges.store.spec.ts` | Signal store: board mutation, live solve merge, listeners, `stop()`. | | `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/frontend/notification-interceptor.spec.ts` | Interceptor suppression rules and friendly message mapping. |
| `tests/backend/challenges-board.spec.ts` | Board query shape, ordering, `?include=solvers`. | | `tests/backend/challenges-board.spec.ts` | Board query shape, ordering, `?include=solvers`. |
+5 -3
View File
@@ -3,7 +3,7 @@ type: guide
title: Challenges Board 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. 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] tags: [guide, challenges, board, flag, score, sse, tester]
timestamp: 2026-07-23T00:10:00Z timestamp: 2026-07-23T03:09:00Z
--- ---
# When this view is available # When this view is available
@@ -66,7 +66,7 @@ renders `[data-testid="challenges-gate"]`:
| Event state | Headline | Countdown text (`[data-testid="challenges-countdown"]`) | Helper label | | 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.` | | `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)_ | — | — | | `running` | _(grid shown instead)_ | — | — |
| `stopped` | `Event ended` | `Event ended` | `The board is closed.` | | `stopped` | `Event ended` | `Event ended` | `The board is closed.` |
| `unconfigured`| `Awaiting configuration` | empty | _(none)_ | | `unconfigured`| `Awaiting configuration` | empty | _(none)_ |
@@ -159,7 +159,9 @@ destruction of the page the SSE source is closed via
## Countdown / stopped gate ## Countdown / stopped gate
1. Set `eventStartUtc` in the future; confirm the gate panel shows 1. Set `eventStartUtc` in the future; confirm the gate panel shows
`Event starts in` and the `DD:HH:mm` countdown. `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 2. Wait for the countdown to hit zero; the SPA reloads and now shows
the board. the board.
3. Set `eventEndUtc` in the past; confirm the gate shows 3. Set `eventEndUtc` in the past; confirm the gate shows
+5 -2
View File
@@ -3,7 +3,7 @@ type: guide
title: Event Window title: Event Window
description: How the event window state machine is computed and surfaced via REST + SSE. description: How the event window state machine is computed and surfaced via REST + SSE.
tags: [guide, event, countdown, sse, state-machine] tags: [guide, event, countdown, sse, state-machine]
timestamp: 2026-07-23T01:19:00Z timestamp: 2026-07-23T03:09:00Z
--- ---
# Configuration # Configuration
@@ -73,7 +73,10 @@ The store:
3. Exposes `countdownText` (computed): `""` for `unconfigured`, 3. Exposes `countdownText` (computed): `""` for `unconfigured`,
`"Event ended"` for `stopped`, and `formatDdHhMm(seconds)` for `"Event ended"` for `stopped`, and `formatDdHhMm(seconds)` for
`countdown` / `running`. The format helper lives in the pure module `countdown` / `running`. The format helper lives in the pure module
`event-status.pure.ts` (`DD:HH:mm`, zero-padded). `event-status.pure.ts` (`DD:HH:mm:ss`, zero-padded). The seconds
digit is driven by the local 1-second tick (and the SSE re-push
every 60 seconds) so the final minute of the countdown visibly
elapses second-by-second on the shell header.
The header (`ShellHeaderComponent`) renders an LED with the The header (`ShellHeaderComponent`) renders an LED with the
`led-{state}` class so the colour reflects the current state (the `led-{state}` class so the colour reflects the current state (the
+1 -1
View File
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
controls. controls.
The docs below are organized by purpose so agents can pull just the slice The docs below are organized by purpose so agents can pull just the slice
they need. Last regenerated 2026-07-23T02:23:34Z. they need. Last regenerated 2026-07-23T03:09:36Z.
# Architecture # Architecture
@@ -18,13 +18,14 @@ export interface EventSourceLike {
} }
export function formatDdHhMm(totalSeconds: number): string { export function formatDdHhMm(totalSeconds: number): string {
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '00:00:00'; if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '00:00:00:00';
const s = Math.floor(totalSeconds); const s = Math.floor(totalSeconds);
const days = Math.floor(s / 86400); const days = Math.floor(s / 86400);
const hours = Math.floor((s % 86400) / 3600); const hours = Math.floor((s % 86400) / 3600);
const minutes = Math.floor((s % 3600) / 60); const minutes = Math.floor((s % 3600) / 60);
const seconds = s % 60;
const pad = (n: number) => n.toString().padStart(2, '0'); const pad = (n: number) => n.toString().padStart(2, '0');
return `${pad(days)}:${pad(hours)}:${pad(minutes)}`; return `${pad(days)}:${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
} }
export function deriveCountdownText( export function deriveCountdownText(
@@ -35,9 +36,9 @@ export function deriveCountdownText(
if (state === 'unconfigured') return ''; if (state === 'unconfigured') return '';
if (state === 'stopped') return 'Event ended'; if (state === 'stopped') return 'Event ended';
if (state === 'countdown') { if (state === 'countdown') {
return secondsToStart == null ? '00:00:00' : formatDdHhMm(secondsToStart); return secondsToStart == null ? '00:00:00:00' : formatDdHhMm(secondsToStart);
} }
return secondsToEnd == null ? '00:00:00' : formatDdHhMm(secondsToEnd); return secondsToEnd == null ? '00:00:00:00' : formatDdHhMm(secondsToEnd);
} }
export const LED_COLOR_BY_STATE: Readonly<Record<EventState, string>> = { export const LED_COLOR_BY_STATE: Readonly<Record<EventState, string>> = {
@@ -120,13 +120,14 @@ export function sortColumnsByAbbrev(columns: readonly CategoryColumn[]): Categor
} }
export function formatDdHhMm(totalSeconds: number): string { export function formatDdHhMm(totalSeconds: number): string {
if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '00:00:00'; if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '00:00:00:00';
const s = Math.floor(totalSeconds); const s = Math.floor(totalSeconds);
const days = Math.floor(s / 86400); const days = Math.floor(s / 86400);
const hours = Math.floor((s % 86400) / 3600); const hours = Math.floor((s % 86400) / 3600);
const minutes = Math.floor((s % 3600) / 60); const minutes = Math.floor((s % 3600) / 60);
const seconds = s % 60;
const pad = (n: number) => n.toString().padStart(2, '0'); const pad = (n: number) => n.toString().padStart(2, '0');
return `${pad(days)}:${pad(hours)}:${pad(minutes)}`; return `${pad(days)}:${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
} }
export function formatUtcToLocal(utc: string | null | undefined): string { export function formatUtcToLocal(utc: string | null | undefined): string {
@@ -43,6 +43,7 @@ export class ChallengesApiService {
return this.http return this.http
.get<ChallengeDetail>(`/api/v1/challenges/${encodeURIComponent(challengeId)}`, { .get<ChallengeDetail>(`/api/v1/challenges/${encodeURIComponent(challengeId)}`, {
withCredentials: true, withCredentials: true,
params: { include: 'solvers' },
}) })
.pipe(catchError((err) => throwError(() => toEnvelope(err)))); .pipe(catchError((err) => throwError(() => toEnvelope(err))));
} }
@@ -68,7 +68,7 @@ describe('ChallengeModal — flag submission branches', () => {
const wrong = messageForSolveError(parseSolveError(400, { code: 'FLAG_INCORRECT' })); const wrong = messageForSolveError(parseSolveError(400, { code: 'FLAG_INCORRECT' }));
expect(wrong).toBe('Incorrect flag. Try again.'); expect(wrong).toBe('Incorrect flag. Try again.');
const awarded = formatDdHhMm(3600); const awarded = formatDdHhMm(3600);
expect(awarded).toBe('00:01:00'); expect(awarded).toBe('00:01:00:00');
}); });
it('second submit yields already_solved without losing the awarded banner', () => { it('second submit yields already_solved without losing the awarded banner', () => {
+49 -3
View File
@@ -83,10 +83,10 @@ describe('ChallengesPage (gate + countdown + auto-reload wiring)', () => {
it('starts in unconfigured state so the gate panel would render', () => { it('starts in unconfigured state so the gate panel would render', () => {
expect(eventStore.state()).toBe('unconfigured'); expect(eventStore.state()).toBe('unconfigured');
expect(formatDdHhMm(0)).toBe('00:00:00'); expect(formatDdHhMm(0)).toBe('00:00:00:00');
}); });
it('shows live countdown in DD:HH:mm when the admin sets a countdown window', () => { it('shows live countdown in DD:HH:mm:ss when the admin sets a countdown window', () => {
const future = new Date(Date.now() + 90_061_000).toISOString(); const future = new Date(Date.now() + 90_061_000).toISOString();
const past = new Date(Date.now() - 60_000).toISOString(); const past = new Date(Date.now() - 60_000).toISOString();
eventStore.applyServerStatus({ eventStore.applyServerStatus({
@@ -99,7 +99,53 @@ describe('ChallengesPage (gate + countdown + auto-reload wiring)', () => {
}); });
expect(eventStore.state()).toBe('countdown'); expect(eventStore.state()).toBe('countdown');
expect(eventStore.countdownText()).toBe('01:01:01'); const text = eventStore.countdownText();
expect(text).toMatch(/^01:01:01:\d{2}$/);
const [d, h, m, s] = text.split(':').map(Number);
expect(d).toBe(1);
expect(h).toBe(1);
expect(m).toBe(1);
expect(s).toBeGreaterThanOrEqual(0);
expect(s).toBeLessThanOrEqual(2);
});
it('formats the final minute second-by-second so the user can see time elapse', () => {
eventStore.applyServerStatus({
state: 'countdown',
serverNowUtc: new Date().toISOString(),
eventStartUtc: new Date(Date.now() + 24_000).toISOString(),
eventEndUtc: new Date(Date.now() + 7200_000).toISOString(),
secondsToStart: 24,
secondsToEnd: null,
});
const first = eventStore.countdownText();
expect(first).toMatch(/^00:00:00:\d{2}$/);
expect(Number(first.split(':')[3])).toBeGreaterThanOrEqual(22);
expect(Number(first.split(':')[3])).toBeLessThanOrEqual(24);
eventStore.applyServerStatus({
state: 'countdown',
serverNowUtc: new Date().toISOString(),
eventStartUtc: new Date(Date.now() + 23_000).toISOString(),
eventEndUtc: new Date(Date.now() + 7200_000).toISOString(),
secondsToStart: 23,
secondsToEnd: null,
});
const second = eventStore.countdownText();
expect(Number(second.split(':')[3])).toBeGreaterThanOrEqual(21);
expect(Number(second.split(':')[3])).toBeLessThanOrEqual(23);
eventStore.applyServerStatus({
state: 'countdown',
serverNowUtc: new Date().toISOString(),
eventStartUtc: new Date(Date.now() + 22_000).toISOString(),
eventEndUtc: new Date(Date.now() + 7200_000).toISOString(),
secondsToStart: 22,
secondsToEnd: null,
});
const third = eventStore.countdownText();
expect(Number(third.split(':')[3])).toBeGreaterThanOrEqual(20);
expect(Number(third.split(':')[3])).toBeLessThanOrEqual(22);
}); });
it('single auto-reload fires exactly once when secondsToStart hits zero', () => { it('single auto-reload fires exactly once when secondsToStart hits zero', () => {
+11 -11
View File
@@ -90,18 +90,18 @@ describe('sortColumnsByAbbrev', () => {
}); });
describe('formatDdHhMm', () => { describe('formatDdHhMm', () => {
it('formats DD:HH:mm', () => { it('formats DD:HH:mm:ss', () => {
expect(formatDdHhMm(0)).toBe('00:00:00'); expect(formatDdHhMm(0)).toBe('00:00:00:00');
expect(formatDdHhMm(59)).toBe('00:00:00'); expect(formatDdHhMm(59)).toBe('00:00:00:59');
expect(formatDdHhMm(60)).toBe('00:00:01'); expect(formatDdHhMm(60)).toBe('00:00:01:00');
expect(formatDdHhMm(3600)).toBe('00:01:00'); expect(formatDdHhMm(3600)).toBe('00:01:00:00');
expect(formatDdHhMm(86400)).toBe('01:00:00'); expect(formatDdHhMm(86400)).toBe('01:00:00:00');
expect(formatDdHhMm(90061)).toBe('01:01:01'); expect(formatDdHhMm(90061)).toBe('01:01:01:01');
}); });
it('returns 00:00:00 for negative or non-finite', () => { it('returns 00:00:00:00 for negative or non-finite', () => {
expect(formatDdHhMm(-1)).toBe('00:00:00'); expect(formatDdHhMm(-1)).toBe('00:00:00:00');
expect(formatDdHhMm(Number.NaN)).toBe('00:00:00'); expect(formatDdHhMm(Number.NaN)).toBe('00:00:00:00');
expect(formatDdHhMm(Number.POSITIVE_INFINITY)).toBe('00:00:00'); expect(formatDdHhMm(Number.POSITIVE_INFINITY)).toBe('00:00:00:00');
}); });
}); });
+107
View File
@@ -0,0 +1,107 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injector, runInInjectionContext } from '@angular/core';
import { Observable } from 'rxjs';
import { ChallengesApiService } from '../../frontend/src/app/features/challenges/challenges.service';
import { ChallengeDetail } from '../../frontend/src/app/features/challenges/challenges.pure';
interface CapturedRequest {
url: string;
params: HttpParams;
withCredentials: boolean;
}
function makeDetail(): ChallengeDetail {
return {
challenge: {
id: 'c1',
name: 'alpha',
descriptionMd: '# Alpha',
categoryId: 'cat1',
categoryAbbreviation: 'CRY',
categoryIconPath: '',
difficulty: 'LOW',
livePoints: 90,
solveCount: 1,
solvedByMe: false,
},
solvers: [
{
position: 1,
playerId: 'p1',
playerName: 'alice',
solvedAtUtc: '2025-01-01T00:00:00.000Z',
awardedPoints: 100,
basePoints: 100,
rankBonus: 0,
isFirst: true,
isSecond: false,
isThird: false,
},
],
};
}
class StubHttpClient {
public captured: CapturedRequest | null = null;
get<T>(url: string, options: { withCredentials?: boolean; params?: HttpParams | Record<string, string> }): Observable<T> {
this.captured = {
url,
params:
options.params instanceof HttpParams
? options.params
: new HttpParams({ fromObject: (options.params ?? {}) as Record<string, string> }),
withCredentials: !!options.withCredentials,
};
return new Observable<T>((subscriber) => {
subscriber.next(makeDetail() as unknown as T);
subscriber.complete();
});
}
post<T>(_url: string, _body: unknown, _options: unknown): Observable<T> {
return new Observable<T>();
}
}
describe('ChallengesApiService.getDetail', () => {
let http: StubHttpClient;
let service: ChallengesApiService;
beforeEach(() => {
http = new StubHttpClient();
const injector = Injector.create({
providers: [
{ provide: HttpClient, useValue: http },
ChallengesApiService,
],
});
service = runInInjectionContext(injector, () => injector.get(ChallengesApiService));
});
it('requests the challenge detail with include=solvers and withCredentials', (done) => {
service.getDetail('c1').subscribe(() => {
try {
expect(http.captured).not.toBeNull();
expect(http.captured!.url).toBe('/api/v1/challenges/c1');
expect(http.captured!.params.get('include')).toBe('solvers');
expect(http.captured!.withCredentials).toBe(true);
done();
} catch (err) {
done(err);
}
});
});
it('encodes the challenge id in the URL path', (done) => {
service.getDetail('c/with spaces').subscribe(() => {
try {
expect(http.captured!.url).toBe('/api/v1/challenges/c%2Fwith%20spaces');
expect(http.captured!.params.get('include')).toBe('solvers');
done();
} catch (err) {
done(err);
}
});
});
});
+14 -14
View File
@@ -5,21 +5,21 @@ import {
} from '../../frontend/src/app/core/services/event-status.pure'; } from '../../frontend/src/app/core/services/event-status.pure';
describe('formatDdHhMm', () => { describe('formatDdHhMm', () => {
it('formats 90_061 seconds as 01:01:01', () => { it('formats 90_061 seconds as 01:01:01:01', () => {
expect(formatDdHhMm(90_061)).toBe('01:01:01'); expect(formatDdHhMm(90_061)).toBe('01:01:01:01');
}); });
it('formats 800 seconds as 00:00:13', () => { it('formats 800 seconds as 00:00:13:20', () => {
expect(formatDdHhMm(800)).toBe('00:00:13'); expect(formatDdHhMm(800)).toBe('00:00:13:20');
}); });
it('formats 0 seconds as 00:00:00', () => { it('formats 0 seconds as 00:00:00:00', () => {
expect(formatDdHhMm(0)).toBe('00:00:00'); expect(formatDdHhMm(0)).toBe('00:00:00:00');
}); });
it('returns 00:00:00 for negative or non-finite values', () => { it('returns 00:00:00:00 for negative or non-finite values', () => {
expect(formatDdHhMm(-5)).toBe('00:00:00'); expect(formatDdHhMm(-5)).toBe('00:00:00:00');
expect(formatDdHhMm(Number.NaN)).toBe('00:00:00'); expect(formatDdHhMm(Number.NaN)).toBe('00:00:00:00');
}); });
}); });
@@ -30,17 +30,17 @@ describe('deriveCountdownText', () => {
if (state === 'stopped' || state === 'unconfigured') { if (state === 'stopped' || state === 'unconfigured') {
expect(deriveCountdownText(state, null, null)).toBe(state === 'unconfigured' ? '' : 'Event ended'); expect(deriveCountdownText(state, null, null)).toBe(state === 'unconfigured' ? '' : 'Event ended');
} else { } else {
expect(deriveCountdownText(state, null, null)).toBe('00:00:00'); expect(deriveCountdownText(state, null, null)).toBe('00:00:00:00');
} }
}); });
}); });
it('returns DD:HH:mm to start when countdown and secondsToStart provided', () => { it('returns DD:HH:mm:ss to start when countdown and secondsToStart provided', () => {
expect(deriveCountdownText('countdown', 3600, null)).toBe('00:01:00'); expect(deriveCountdownText('countdown', 3600, null)).toBe('00:01:00:00');
}); });
it('returns DD:HH:mm to end when running and secondsToEnd provided', () => { it('returns DD:HH:mm:ss to end when running and secondsToEnd provided', () => {
expect(deriveCountdownText('running', null, 800)).toBe('00:00:13'); expect(deriveCountdownText('running', null, 800)).toBe('00:00:13:20');
}); });
it('returns "Event ended" when stopped', () => { it('returns "Event ended" when stopped', () => {