Files
HIPCTF2/.kilo/plans/912.md
T

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/@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 <polyline> 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:

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:

  • 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.