8.5 KiB
8.5 KiB
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/@forcontrol flow,signal()/computed()for state. - Pure helpers live in
frontend/src/app/features/scoreboard/scoreboard.pure.tsand are the canonical test surface; components stay presentation-only. - Existing
ScoreGraphComponentuses the legacy@Input()+@if/*ngIfmix; we will not refactor it (out of scope) — only fix the broken math.
- Backend: NestJS 10 + TypeORM, SQLite (
- 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/eventEndUtcvalues persisted in thesettingtable — we do NOT silently rewrite them; we harden the renderer.) - Test Framework & Structure:
- Jest 29 with
ts-jestand two projects intests/jest.config.js(backend+frontend). - Frontend project uses
jsdom,tests/frontend/jest.setup.ts, andtransformIgnorePatternsallow-listing@angular,rxjs,tslib. - Tests live under
tests/frontend/(already convention) — e.g.tests/frontend/scoreboard.pure.spec.tsexists and follows this pattern. We will add a siblingtests/frontend/score-graph-projection.spec.ts. - Single test command:
npm test(or scopednpm run test:frontend).
- Jest 29 with
- 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 SVGpointsstrings 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)returningtrueonly when both timestamps parse andendMs > startMs.
- Add a pure helper
frontend/src/app/features/scoreboard/score-graph.component.ts- Replace inline
pointsAttr()with a call toprojectGraphPoints(view, s, dims)and a singlepointsgetter computed up front (via acomputed()overview) so the bug fix is a single source of truth. - Switch the per-series
<polyline>binding from a method call to a precomputedstringper series (Angular calls methods on every change-detection cycle, so hoisting intocomputed()also helps OnPush perf). - No template/TDD-required structural changes beyond
[attr.points]="linePoints(s)"consuming the hoisted value.
- Replace inline
To Create
tests/frontend/score-graph-projection.spec.ts- Unit tests for the new pure helper covering:
- Happy path (start < end, normal window) — unchanged coordinates.
- Reversed window (
endUtc < startUtc) — every projected X coordinate falls inside[padL, padL + innerW]and no coordinate exceeds plausible SVG bounds. - Equal start/end (zero span) — handled gracefully (no NaN/Infinity, all points clamp to the right edge).
- Invalid
Datestrings — points string is empty / coordinates stay finite.
- Unit tests for the new pure helper covering:
3. Proposed Changes
Step 1 — Pure helper in scoreboard.pure.ts
Add two exports:
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<GraphView, 'startUtc' | 'endUtc'>,
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:
isValidEventWindowshort-circuits the caseendUtc < startUtc(the persisted repro). The component then renders the existingNot started/No solves yetempty 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.maxclamp 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-NaNfailure mode that previously produced strings like40.00,292.00.
Step 2 — Component refactor in score-graph.component.ts
- Replace the
@Input() viewwith asignal-style getter viangOnChanges/input()semantics: keep@Input() view: GraphView | null = null;(existing pattern) but add acomputedseriesWithPointsthat maps each series to{ ...s, pointsAttr: projectGraphPoints(view, s, dims) }. Use an explicitdims: GraphProjectionDimsconstant built from the existingwidth/height/padL/padR/padT/padBso the magic numbers are declared once. - Replace the
[attr.points]="pointsAttr(s)"binding with[attr.points]="line.pointsAttr"and iterate overseriesWithPoints()instead ofview.series. (Template keeps*ngFor— we are not upgrading to@forper job scope.) - Keep all existing
data-testidmarkers 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 existingtests/frontend/scoreboard.pure.spec.tsstyle: 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):
isValidEventWindowreturnsfalsewhenendUtc < startUtc,falsewhen equal,falsefor non-finite Date strings,trueonly whenend > start.projectGraphPointswith 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.projectGraphPointswith a normal window returns coordinates where the first point's X ispadLand the last X is at or belowpadL + innerW(regression guard).projectGraphPointswithstartUtc === endUtc(zero-span edge) returns only finite, in-bounds X coordinates (noInfinity, 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) ornpm run test:frontendfor 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.