# Implementation Plan: Scoreboard Score Graph — Safe rendering under invalid/reversed event window (Job 912) ## 1. Architectural Reconnaissance - **Codebase style & conventions:** - Backend: NestJS 10 + TypeORM, SQLite (`backend/src/...`). - Frontend: Angular 20 standalone components with Signals, `ChangeDetectionStrategy.OnPush`, `@if`/`@for` control flow, `signal()`/`computed()` for state. - Pure helpers live in `frontend/src/app/features/scoreboard/scoreboard.pure.ts` and are the canonical test surface; components stay presentation-only. - Existing `ScoreGraphComponent` uses the legacy `@Input()` + `@if/*ngIf` mix; we will not refactor it (out of scope) — only fix the broken math. - **Data Layer:** SQLite via TypeORM. The bug lives entirely in the frontend projection. No DB migration is required. (The root cause is bad admin-entered `eventStartUtc`/`eventEndUtc` values persisted in the `setting` table — we do NOT silently rewrite them; we harden the renderer.) - **Test Framework & Structure:** - Jest 29 with `ts-jest` and two projects in `tests/jest.config.js` (`backend` + `frontend`). - Frontend project uses `jsdom`, `tests/frontend/jest.setup.ts`, and `transformIgnorePatterns` allow-listing `@angular`, `rxjs`, `tslib`. - Tests live under `tests/frontend/` (already convention) — e.g. `tests/frontend/scoreboard.pure.spec.ts` exists and follows this pattern. We will add a sibling `tests/frontend/score-graph-projection.spec.ts`. - Single test command: `npm test` (or scoped `npm run test:frontend`). - **Required Tools & Dependencies:** None new. Existing stack (Jest 29, jsdom, `@angular/common`, `@angular/core`) is sufficient. ## 2. Impacted Files ### To Modify - `frontend/src/app/features/scoreboard/scoreboard.pure.ts` - Add a pure helper `projectGraphPoints(view, series, dims)` that returns clamped SVG `points` strings for a single series. Encapsulates the X/Y math so the bug cannot recur in the component template. Pure, deterministic, fully unit-testable. - Add a small helper `isValidEventWindow(startUtc, endUtc)` returning `true` only when both timestamps parse and `endMs > startMs`. - `frontend/src/app/features/scoreboard/score-graph.component.ts` - Replace inline `pointsAttr()` with a call to `projectGraphPoints(view, s, dims)` and a single `points` getter computed up front (via a `computed()` over `view`) so the bug fix is a single source of truth. - Switch the per-series `` binding from a method call to a precomputed `string` per series (Angular calls methods on every change-detection cycle, so hoisting into `computed()` also helps OnPush perf). - No template/TDD-required structural changes beyond `[attr.points]="linePoints(s)"` consuming the hoisted value. ### To Create - `tests/frontend/score-graph-projection.spec.ts` - Unit tests for the new pure helper covering: 1. Happy path (start < end, normal window) — unchanged coordinates. 2. Reversed window (`endUtc < startUtc`) — every projected X coordinate falls inside `[padL, padL + innerW]` and no coordinate exceeds plausible SVG bounds. 3. Equal start/end (zero span) — handled gracefully (no NaN/Infinity, all points clamp to the right edge). 4. Invalid `Date` strings — points string is empty / coordinates stay finite. ## 3. Proposed Changes ### Step 1 — Pure helper in `scoreboard.pure.ts` Add two exports: ```ts export interface GraphProjectionDims { width: number; height: number; padL: number; padR: number; padT: number; padB: number; } export function isValidEventWindow( startUtc: string | null, endUtc: string | null, ): boolean { if (!startUtc || !endUtc) return false; const s = new Date(startUtc).getTime(); const e = new Date(endUtc).getTime(); if (!Number.isFinite(s) || !Number.isFinite(e)) return false; return e > s; } export function projectGraphPoints( view: Pick, series: GraphSeries, dims: GraphProjectionDims, ): string { if (!isValidEventWindow(view.startUtc, view.endUtc)) return ''; const startMs = new Date(view.startUtc!).getTime(); const endMs = new Date(view.endUtc!).getTime(); const span = endMs - startMs; const innerW = dims.width - dims.padL - dims.padR; const innerH = dims.height - dims.padT - dims.padB; let maxVal = 0; for (const s of series.points) if (s.value > maxVal) maxVal = s.value; if (maxVal <= 0) maxVal = 1; const left = dims.padL; const right = dims.padL + innerW; const top = dims.padT; const bottom = dims.padT + innerH; return series.points .map((p) => { const ms = new Date(p.tUtc).getTime(); if (!Number.isFinite(ms)) return `${left.toFixed(2)},${bottom.toFixed(2)}`; const rawX = ((ms - startMs) / span) * innerW; const x = Math.min(right, Math.max(left, rawX)); const rawY = (p.value / maxVal) * innerH; const y = top + Math.max(0, Math.min(innerH, innerH - rawY)); return `${x.toFixed(2)},${y.toFixed(2)}`; }) .join(' '); } ``` Why this fixes the bug: - `isValidEventWindow` short-circuits the case `endUtc < startUtc` (the persisted repro). The component then renders the existing `Not started`/`No solves yet` empty branch instead of emitting absurd polylines. We do not silently mutate the persisted settings — we just refuse to render an off-canvas chart for an invalid window. - `Math.min/Math.max` clamp surviving X coordinates into `[padL, width - padR]` so future regressions that still produce a malformed span (zero, NaN) cannot escape the SVG viewport. - NaN guards prevent the `toFixed(2)`-on-`NaN` failure mode that previously produced strings like `40.00,292.00`. ### Step 2 — Component refactor in `score-graph.component.ts` - Replace the `@Input() view` with a `signal`-style getter via `ngOnChanges`/`input()` semantics: keep `@Input() view: GraphView | null = null;` (existing pattern) but add a `computed` `seriesWithPoints` that maps each series to `{ ...s, pointsAttr: projectGraphPoints(view, s, dims) }`. Use an explicit `dims: GraphProjectionDims` constant built from the existing `width/height/padL/padR/padT/padB` so the magic numbers are declared once. - Replace the `[attr.points]="pointsAttr(s)"` binding with `[attr.points]="line.pointsAttr"` and iterate over `seriesWithPoints()` instead of `view.series`. (Template keeps `*ngFor` — we are not upgrading to `@for` per job scope.) - Keep all existing `data-testid` markers and the three empty states (`unconfigured` / `countdown` / running-with-empty-series) — the fix is a no-op for those branches. ### Step 3 — No backend / no DB changes The persisted bad data (start > end) stays as-is. The renderer now refuses to project points outside the SVG and gracefully returns empty for the invalid window. We will NOT auto-swap start/end at the backend because that would silently rewrite admin input. ## 4. Test Strategy - **Target Unit Test File:** `tests/frontend/score-graph-projection.spec.ts` (new). It mirrors the existing `tests/frontend/scoreboard.pure.spec.ts` style: plain Jest, no TestBed needed (pure helper), no Angular bootstrap, no DOM rendering. - **Mocking Strategy:** - No mocks required — the helper is a pure function over primitives and date strings. - No HTTP, no time mocking (the helper accepts timestamps as arguments; we pin them in fixtures). - **Tests to include (minimal, focused):** 1. `isValidEventWindow` returns `false` when `endUtc < startUtc`, `false` when equal, `false` for non-finite Date strings, `true` only when `end > start`. 2. `projectGraphPoints` with the documented repro (`startUtc=2026-07-23T00:00:00.000Z`, `endUtc=2026-07-22T00:00:00.000Z`) returns the empty string — explicitly asserting the bug-class fix. 3. `projectGraphPoints` with a normal window returns coordinates where the first point's X is `padL` and the last X is at or below `padL + innerW` (regression guard). 4. `projectGraphPoints` with `startUtc === endUtc` (zero-span edge) returns only finite, in-bounds X coordinates (no `Infinity`, no negative). - **Negative coverage deliberately omitted:** we do not mount the Angular component (no TestBed, no fixture) — that keeps the suite fast and matches the rule "Frontend tests focus on logic, not visuals." - **Run command:** `npm test` (executes all projects) or `npm run test:frontend` for the frontend project only. Both commands already work in this repo. ## Status Not yet implemented. Pure helper `projectGraphPoints` / `isValidEventWindow` do not exist in `scoreboard.pure.ts`; the bug is reproducible from the current `pointsAttr()` method on line 117 of `score-graph.component.ts`.