From 1576d663d3275ccb0d31df834a0458dba72db827 Mon Sep 17 00:00:00 2001 From: OpenVelo Agent Date: Thu, 23 Jul 2026 05:13:20 +0000 Subject: [PATCH] docs: update documentation to OKF v0.1 format --- docs/api/challenges.md | 172 +++++++++++++++- docs/architecture/frontend-structure.md | 13 +- docs/architecture/key-files.md | 20 +- docs/guides/scoreboard-page.md | 262 ++++++++++++++++++++++++ docs/guides/scoreboard-stream.md | 89 ++++++-- docs/index.md | 8 +- 6 files changed, 536 insertions(+), 28 deletions(-) create mode 100644 docs/guides/scoreboard-page.md diff --git a/docs/api/challenges.md b/docs/api/challenges.md index f904bf8..14ef80d 100644 --- a/docs/api/challenges.md +++ b/docs/api/challenges.md @@ -2,8 +2,8 @@ type: api title: Challenges Endpoints description: Authenticated challenge board, single-challenge detail, flag submission, event-state snapshot, and the combined authenticated SSE stream that pushes status + solve frames. -tags: [api, challenges, board, score, sse, submit] -timestamp: 2026-07-23T00:10:00Z +tags: [api, challenges, board, score, scoreboard, sse, submit] +timestamp: 2026-07-23T05:10:00Z --- # Endpoints @@ -21,6 +21,10 @@ are not `@Public()`). The DTOs and zod schemas live in | `GET` | `/api/v1/challenges/:id` | Authenticated | Same. | | `POST` | `/api/v1/challenges/:id/solves` | Authenticated | Same. | | `SSE` | `/api/v1/events` | Authenticated (SSE) | `backend/src/modules/challenges/events.controller.ts` | +| `GET` | `/api/v1/scoreboard/ranking` | Authenticated | `backend/src/modules/challenges/scoreboard.controller.ts` | +| `GET` | `/api/v1/scoreboard/matrix` | Authenticated | Same. | +| `GET` | `/api/v1/scoreboard/event-log` | Authenticated | Same. | +| `GET` | `/api/v1/scoreboard/graph` | Authenticated | Same. | > The combined `/api/v1/events` SSE is **in addition to** the existing > `/api/v1/events/status` status-only stream. The two share the same @@ -202,6 +206,169 @@ downstream consumer can read either shape (for example `challenge_id` or `challengeId`, `player_id` or `userId`, `awarded_points` or `pointsAwarded`). +# Scoreboard endpoints + +The `ScoreboardController` mounts the four authenticated read-only +projections that drive the `/scoreboard` page (Ranking, Matrix, +Event Log, Score Graph tabs). All four live under +`/api/v1/scoreboard/*` and are served by +`backend/src/modules/challenges/scoreboard.controller.ts` via +`ScoreboardService` +(`backend/src/modules/challenges/scoreboard.service.ts`). The DTO +contracts live in +`backend/src/modules/challenges/dto/scoreboard.dto.ts` (zod +`EventLogQuerySchema` plus TypeScript interfaces for the response +shapes). + +All four endpoints: + +* Require a valid JWT (the global `JwtAuthGuard` is active; no + `@Public()` is used on the scoreboard controller). +* Read exclusively — no mutation — and never expose the `challenge.flag` + field or any admin-only data. +* Compute `awardedPoints` per row using + `computeAwardedPoints(initial, minimum, decaySolves, solveCountBefore)` + from `backend/src/modules/challenges/scoring.util.ts`, which is the + same `basePoints + rankBonus` arithmetic that `submitFlag` performs + when persisting the `solve` row. +* Color each player via `stablePlayerColorIndex(playerId)` (FNV-1a + hash → 10-entry `PLAYER_COLOR_PALETTE`) so the Ranking, Matrix, and + Score Graph share a deterministic swatch per `playerId`. + +## `GET /api/v1/scoreboard/ranking` + +Returns every user (including zero-solve players) ranked by total +points, with competition-rank numbering (`1, 2, 2, 4, …` — players with +equal `points` share a rank). + +Response (`RankingRowDto[]`): + +```json +[ + { + "rank": 1, + "playerId": "", + "playerName": "alice", + "solvedCount": 7, + "points": 3120, + "colorIndex": 4 + } +] +``` + +`colorIndex` is the same value the frontend uses to tint the +per-player swatch in the Ranking, Matrix, and Score Graph tabs so a +player keeps their color across all three views. + +## `GET /api/v1/scoreboard/matrix` + +Returns a players × challenges grid for the live scoreboard view. + +Response (`MatrixViewDto`): + +```json +{ + "players": [ /* RankingRowDto[] in ranking order */ ], + "challenges": [ + { + "id": "", + "name": "RSA Rollers", + "solveCount": 4, + "categoryAbbreviation": "CRY" + } + ], + "cells": { + "": { + "": 1, + "": 2, + "": 3, + "": "solved", + "": null + } + } +} +``` + +`cells[playerId][challengeId]` is one of `1` (gold ★, 1st solver), +`2` (silver ★, 2nd solver), `3` (bronze ★, 3rd solver), `'solved'` +(✓, 4th+ solver), or `null` (not solved). Challenges are sorted by +`solveCount` DESC, then by `name` ASC. The service walks the `solve` +rows in `solved_at ASC` order per challenge to assign each cell its +first solver rank. + +## `GET /api/v1/scoreboard/event-log` + +Recent solves, newest first, joined with the user, challenge, and +category so the frontend never needs a second round-trip to render +labels. + +Query string (`EventLogQuerySchema`, validated by `ZodValidationPipe`): + +| Param | Type | Default | Description | +|---------|------|---------|----------------------------------------------| +| `limit` | int | `50` | `1 <= limit <= 200`, clamped server-side too. | + +Response (`EventLogRowDto[]`): + +```json +[ + { + "solveId": "", + "playerId": "", + "playerName": "bob", + "challengeId": "", + "challengeName": "Forensic Trail", + "categoryAbbreviation": "FOR", + "position": 1, + "awardedPoints": 515, + "awardedAtUtc": "2026-07-23T05:01:30.000Z" + } +] +``` + +## `GET /api/v1/scoreboard/graph` + +Time-series of cumulative points for the top 10 players over the +configured event window, used to drive the Score Graph tab. + +Response (`GraphViewDto`): + +```json +{ + "startUtc": "2026-07-23T05:00:00.000Z", + "endUtc": "2026-07-23T09:00:00.000Z", + "serverNowUtc": "2026-07-23T05:10:00.000Z", + "state": "running", + "series": [ + { + "playerId": "", + "playerName": "alice", + "colorIndex": 4, + "points": [ + { "tUtc": "2026-07-23T05:00:00.000Z", "value": 0 }, + { "tUtc": "2026-07-23T05:01:30.000Z", "value": 515 }, + { "tUtc": "2026-07-23T05:09:50.000Z", "value": 980 }, + { "tUtc": "2026-07-23T09:00:00.000Z", "value": 980 } + ] + } + ] +} +``` + +`state` mirrors the 4-state event window (`running` / `countdown` / +`stopped` / `unconfigured`) and `startUtc` / `endUtc` come from +`SettingsService` via the admin general-settings pipeline. When the +event has not yet started (`countdown`) or has no window configured +(`unconfigured`), the endpoint still returns `200` with empty +`series` and `null` boundaries so the frontend can render the +"Awaiting event configuration" / "Not started" empty states. + +Series are sorted by their final cumulative value DESC and trimmed to +the top 10. Each series starts with a `{tUtc: startUtc, value: 0}` +anchor and (when `endUtc` is known) ends with a +`{tUtc: endUtc, value: finalValue}` step so the line plot shows a +flat plateau between the last solve and the event end. + # Errors The challenges endpoints use three additional error codes from @@ -221,5 +388,6 @@ The full error envelope and middleware are documented in - [Challenge Tables](/database/challenges.md) - [System Endpoints](/api/system.md) (legacy `/events/status` and `/event/stream`) - [Scoreboard Stream](/guides/scoreboard-stream.md) +- [Scoreboard Page Guide](/guides/scoreboard-page.md) - [Challenges Board Guide](/guides/challenges-board.md) - [Backend Module Map](/architecture/backend-modules.md) \ No newline at end of file diff --git a/docs/architecture/frontend-structure.md b/docs/architecture/frontend-structure.md index a7b6f4e..898a870 100644 --- a/docs/architecture/frontend-structure.md +++ b/docs/architecture/frontend-structure.md @@ -2,8 +2,8 @@ type: architecture title: Frontend Structure description: Angular routes, components, services, guards, interceptors, and authenticated SSE transport. -tags: [architecture, frontend, angular, shell, sse, challenges, notifications] -timestamp: 2026-07-23T00:10:00Z +tags: [architecture, frontend, angular, shell, sse, challenges, scoreboard, notifications] +timestamp: 2026-07-23T05:10:00Z --- # Routes @@ -16,7 +16,7 @@ Routes live in `frontend/src/app/app.routes.ts`: | `/login` | `LandingComponent` | `landingGuard` | Public landing and authentication modal. | | `/` | `HomeComponent` | `authGuard` | Authenticated shell and child outlet. | | `/challenges` | `ChallengesPage` | inherited | Full challenges board (category columns, modal, flag submit, live SSE). | -| `/scoreboard` | `ScoreboardPage` | inherited | Placeholder scoreboard page. | +| `/scoreboard` | `ScoreboardPage` | inherited | Live scoreboard page with 4 tabs (Ranking / Matrix / Event Log / Score Graph), live SSE `solve` updates. | | `/blog` | `BlogPage` | inherited | Placeholder blog page. | | `/admin` | `AdminUsersComponent` | `adminGuard` | Admin-only child route. | | `/admin/challenges` | `AdminChallengesComponent` | inherited | Sortable/searchable admin Challenges list, Add/Edit modal, import/export. | @@ -37,6 +37,12 @@ Routes live in `frontend/src/app/app.routes.ts`: | `ChallengesApiService` | `frontend/src/app/features/challenges/challenges.service.ts` | HTTP wrapper around `/api/v1/challenges/{board,status,:id,:id/solves}` returning `Observable<...>` and translating `HttpErrorResponse` into a typed `ApiErrorEnvelope`. | | `challenges.pure.ts` | `frontend/src/app/features/challenges/challenges.pure.ts` | Pure types + helpers for the challenges feature (`BoardCard`, `SolverRow`, `SolveEventPayload`, sorting, parsers, friendly error mapping, `formatDdHhMm`). | | `ChallengeCardComponent` / `CategoryColumnComponent` / `ChallengeModalComponent` | `frontend/src/app/features/challenges/` | Presentational components for the board grid, column, and detail modal. | +| `ScoreboardPage` | `frontend/src/app/features/scoreboard/scoreboard.page.ts` | Smart page for `/scoreboard`: holds a `ScoreboardStore`, calls `store.loadAll()` + `store.wireSse(...)` on init, tears them down on destroy, and switches between the 4 tab components via `*ngIf` on `store.activeTab()`. | +| `ScoreboardStore` | `frontend/src/app/features/scoreboard/scoreboard.store.ts` | Root-provided signal store backing `/scoreboard`: holds the four projections, active tab, loading + error state, and the SSE lifecycle (`wireSse` / `stop` / exponential reconnect on transport error). Mutates all four tabs from each `solve` frame via pure helpers (`parseSolveEventIntoRanking`, `mutateMatrixFromSolve`, `applySolveToGraph`, `dedupEventLogBySolveId`). | +| `ScoreboardApiService` | `frontend/src/app/features/scoreboard/scoreboard.service.ts` | HTTP wrapper around `/api/v1/scoreboard/{ranking,matrix,event-log,graph}` returning typed `ApiErrorEnvelope`s. | +| `scoreboard.pure.ts` | `frontend/src/app/features/scoreboard/scoreboard.pure.ts` | Pure types + helpers shared between the store and the four tab components (`RankingRow`, `MatrixView`, `EventLogRow`, `GraphView`, `SolveLivePayload`, `PLAYER_COLOR_PALETTE`, `stablePlayerColorIndex`, `applyRankingSort`, `parseSolveEventIntoRanking`, `dedupEventLogBySolveId`, `applySolveToGraph`, `mutateMatrixFromSolve`, `formatSolveDateTime`). | +| `ScoreboardTabsComponent` | `frontend/src/app/features/scoreboard/tabs.component.ts` | Horizontal tab strip bound to `store.activeTab()`; emits `(select)` to switch tabs. | +| `RankingComponent` / `MatrixComponent` / `EventLogComponent` / `ScoreGraphComponent` | `frontend/src/app/features/scoreboard/` | Presentational components for the 4 tabs; each binds a single `@Input()` from `ScoreboardStore` and uses `PLAYER_COLOR_PALETTE` + `stablePlayerColorIndex` to color each player consistently. | | `NotificationService` | `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. | | `errorNotificationInterceptor` | `frontend/src/app/core/interceptors/error-notification.interceptor.ts` | Functional HTTP interceptor that pushes a friendly message into `NotificationService` for every `HttpErrorResponse`; suppresses duplicate toasts for endpoints with their own error UI (login form, `/challenges/status` snapshot). | @@ -121,5 +127,6 @@ for the full contract and tester matrix. - [Authenticated Shell](/guides/authenticated-shell.md) - [Event Window](/guides/event-window.md) - [Challenges Board](/guides/challenges-board.md) +- [Scoreboard Page](/guides/scoreboard-page.md) - [Notifications](/guides/notifications.md) - [System Overview](/architecture/overview.md) diff --git a/docs/architecture/key-files.md b/docs/architecture/key-files.md index 72df473..1378131 100644 --- a/docs/architecture/key-files.md +++ b/docs/architecture/key-files.md @@ -35,8 +35,11 @@ timestamp: 2026-07-23T04:05:00Z | `backend/src/modules/challenges/challenges.controller.ts` | Registers `/api/v1/challenges/{board,status,:id,:id/solves}` (all JWT-protected). | | `backend/src/modules/challenges/events.controller.ts` | Authenticated SSE `/api/v1/events` merging status ticks + hub `general` pushes + `scoreboard$` solve frames. | | `backend/src/modules/challenges/challenges.service.ts` | `getBoard` / `getDetail` / `submitFlag` + `dataSource.transaction` for race-safe idempotent solves; publishes `solve` SSE frames on success. | +| `backend/src/modules/challenges/scoreboard.controller.ts` | Registers the four authenticated scoreboard read-only endpoints under `/api/v1/scoreboard/{ranking,matrix,event-log,graph}`. | +| `backend/src/modules/challenges/scoreboard.service.ts` | Builds the `RankingRowDto[]`, `MatrixViewDto`, `EventLogRowDto[]`, and `GraphViewDto` projections used by the `/scoreboard` page; consumes `SettingsService` for the event window and `EventStatusService` for `state`. | +| `backend/src/modules/challenges/dto/scoreboard.dto.ts` | `EventLogQuerySchema` (zod) + TypeScript DTO interfaces (`RankingRowDto`, `MatrixViewDto`, `EventLogRowDto`, `GraphViewDto`, `GraphSeriesDto`, `GraphPointDto`) for the four scoreboard endpoints. | | `backend/src/modules/challenges/dto/challenges.dto.ts` | zod contracts (`SolveSubmitBodySchema`, `BoardQuerySchema`, `ChallengeIdParamSchema`) + DTO types for the player-facing board. | -| `backend/src/modules/challenges/scoring.util.ts` | Pure `computeLivePoints`, `rankBonusForPosition`, `compareDifficulty`, and constant-time `safeEqualString` for flag comparison. | +| `backend/src/modules/challenges/scoring.util.ts` | Pure `computeLivePoints`, `rankBonusForPosition`, `compareDifficulty`, `computeAwardedPoints` (base + rank bonus), `PLAYER_COLOR_PALETTE` + `stablePlayerColorIndex` (FNV-1a → palette index), and constant-time `safeEqualString` for flag comparison. | | `backend/src/common/errors/error-codes.ts` | Canonical error code map (now includes `EVENT_NOT_RUNNING`, `FLAG_INCORRECT`, `CHALLENGE_DISABLED`). | # Frontend @@ -79,6 +82,16 @@ timestamp: 2026-07-23T04:05:00Z | `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 as a semantic `