Frontend improvement plan — coach live view (client/)¶
Status: Plan (design) · Date: 2026-06-15 · Source: six-expert FE panel (rendering, live-ops UX, frontend security, React/TS architecture, accessibility, football-coaching) + chair synthesis + adversarial verification.
Takes the coach SPA from "working prototype" to a production-grade, secure, glanceable tool across a pitch-side tablet (primary, outdoor, always-on, touch), a phone (portrait), and a desktop (review, mouse). Drivers, in order: security (the payload is children's live location) → performance (multi-player real-time) → cost (self-hosted, minimal dependencies).
Starting state (pre-Phase-1 baseline, for context)¶
React 19 + Vite 8 + TS, ~451 lines. Plain WS to /live?sessionId&token; latest-fix-per-player in a useRef
Map; rAF canvas with a 4-corner GPS→pitch homography. The useRef-Map + rAF model and the homography are
correct — preserve them. Gaps: bundled LIVE_TOKEN + token-in-URL; canvas not DPR-scaled (blurry on the
target devices); dots teleport at 10 Hz (spd/hdg unused); ids-only (no names); no device-health surfacing;
silent infinite reconnect on auth failure; build-time-only config; no outdoor mode / wake-lock; canvas-only
a11y; only the homography is tested.
What the adversarial pass changed (carried into the plan)¶
- DPR is mandatory but costs budget (4–9× fill) — it is the budget's main threat on a sun-throttled tablet, not its guardian. Pair it with decoupling render from 60 fps (dirty-flag / cap ~30 fps; data is only 10 Hz) and measure at 50 players before claiming "within budget." Clamp DPR ≤2 if a device fails.
- Interpolation must be guarded, not absolute — see ADR-0018: interpolate only across <~200 ms gaps, snap otherwise, never extrapolate.
- Cookie-on-upgrade is same-origin-only — pin the topology + Vite proxy (ADR-0015).
- Names preserve pseudonymity of the stores, not the screen (ADR-0016).
- History reads must run off the live loop and be verified as an SLO (ADR-0017).
- Domain: speed/sprint zones must be age-banded for children (adult thresholds are a measurement artefact — see metric-definitions); keep load metrics (sprint counts, accel/decel, ACWR) off the live path — they are review-only.
Phased plan¶
Items are tagged by driver and note server dependencies. Phase 1 is the Minimum Safe Increment and is entirely client-side (no server changes, no firmware).
Phase 1 — MSI · client-only · shipped 2026-06-15¶
Built via five parallel agents over disjoint files (rendering, connection/data-integrity, CSP,
a11y/resilience, testing) + integration. Verified green: tsc, ESLint, Bun unit tests (homography +
interpolation honesty + inbound-WS-frame validation), vite build, and the simulator-driven
Playwright gate — 4 live/a11y/failure-state specs + the 50-player frame-budget gate (p95 9.4 ms,
DPR-crisp checked separately). The strict CSP's connect-src is derived from VITE_WS_URL at
build/dev time (a hardcoded host silently kills the feed). All items below landed:
| Item | Driver | Notes |
|---|---|---|
DPR-crisp canvas, ResizeObserver-driven backing store, homography dst/pxPerM recomputed from the live CSS box |
perf/usability | clamp DPR ≤2; decouple render from 60 fps (dirty-flag/cap) |
Static-pitch layer separated from the dynamic player layer; data-gated rAF; pause on visibilitychange |
perf | battery on the always-on tablet |
| Guarded two-fix interpolation + heading arrow + live speed + short trail | perf/usability | ADR-0018 honesty rule |
| Explicit failure/empty states — stop the silent infinite reconnect; distinguish unauthorized / wrong-session / no-players / disconnected | security/usability | interpret the server's 1008 close |
| React error boundary; wrap the homography solve so a degenerate-corner config shows UI, not a white screen | usability | |
Strict CSP + self-hosted assets (SRI) on index.html |
security | script-injection / exfiltration backstop |
Accessible DOM mirror (role=img label + ~1 Hz player/health table) + ARIA live region for status; status by shape + text, not colour alone; honour prefers-reduced-motion |
a11y | |
| Outdoor high-contrast mode + screen wake-lock | usability | primary tablet |
| Input hardening on inbound WS frames; bound the player Map | security | |
Playwright e2e driven by the simulator (--record/--replay = deterministic visual checks) + a 50-player frame-budget measurement gate; client lint + CI |
cost/quality |
Phase 2 — auth & security core · shipped 2026-06-15 (ADR-0015, implements ADR-0008)¶
Named login (argon2id + HTTP-only cookie) → cookie on the WS upgrade; bundled token killed; Vite
same-origin dev proxy; principal-bound session authz in server.ts open() (ft_ws_rejected{reason="not_authorized_for_session"}); the Phase-1 failure states wired to real auth outcomes (unauthorized → re-check the cookie, forbidden session → terminal "not authorized for this session").
Built via parallel streams over disjoint files (server auth core + routes, client useAuth/Login,
App/useLiveTelemetry, CSP/Vite proxy, server + client tests, simulator, docs) against a frozen contract
that a 4-lens adversarial security pre-mortem hardened before any code was written — every must-fix (server-side
logout revocation, strict Origin, anon scoped to ANON_SESSIONS, argon2id login DoS controls + constant-work
dummy hash, accounts-reload revocation path, CSRF synchronizer token) folded in up front. The server auth core
was smoke-verified first as the sequential dependency; the full path is verified by the server auth e2e + client
Playwright auth specs through the simulator (--secure provisions a default coach account; anonymous standalone
exercises the isolated-LAN bypass), with the standing guard that no child name reaches a metric label or log line.
New auth metrics (ft_auth_logins_total{result}, ft_auth_sessions_active, ft_anon_mode_active) and the
not_authorized_for_session reject reason are in observability.
Phase 3 — identity, health, review · shipped 2026-06-15 (ADR-0016, ADR-0017)¶
- Player names via an authenticated per-session roster (ADR-0016):
GET /sessions/:id/rosteroff a fail-closed roster store (server/src/roster.ts) + aroster-user.tsCLI +roster.json(0o600);purge-playernow erases the roster entry too. The client (useRoster) joinsplayerId → displayNamerender-only — names never enter the telemetry store, any DB/history row, a metric label/HELP line, a log line, or client persistence. - Device health (battery / GPS fix / backlog) surfaced per player — a second
/liveenvelope{event:'status', data: DeviceHealth}from the existing.../statusdata, minimised to coach-relevant fields; shown as A11yMirror columns + a small canvas health cue. A coach can finally tell a stationary player from a dropped tracker. - Review/replay mode (ADR-0017) — a Live⇄Review shell;
GET /sessions/:id/history(aggregate default + raw keyset-paged) reads off the live loop (single-threaded paging + yields, worker deferred) under per-principal rate-limit + inflight cap + audit log; the client (useHistory+ReviewView) renders an aggregate table + occupancy heatmap + raw scrub on the live homography.
Built the same way as Phase 1/2: a frozen contract (phase-3-contract) hardened by an
adversarial pre-mortem (5 lenses, names-never-leak + bulk-export-DoS + event-loop-SLO the headline risks) →
parallel slices over disjoint files → integration of the shared files → a post-build security review.
Verified green: tsc/ESLint (server + client), all server tests (incl. roster-e2e, erasure-e2e, the history
SLO case over ≥270k pre-seeded rows, device-health-e2e), client units, and the Playwright live/review specs —
with the standing guard that no child name reaches a store, label, log line, or localStorage/sessionStorage.
Phase 4 — coaching polish · shipped 2026-06-15 (ADR-0019, phase-4-contract.md)¶
Age-banded speed-zone colouring + live distance/min; a positions-only "isolated player" cue; post-match
aggregates (zone breakdown, sprint + accel/decel efforts, distance/min) computed server-side, off the live
path. The youth thresholds (U12–U19) are transcribed verbatim from metric-definitions
§3.2 — adult zones do not transfer to children; the per-session age band lives in a server config store + CLI
and is served (resolved) from GET /sessions/:id/config, so live colour and the review breakdown use the same
band. Deferred (need IMU or cross-session data, not GPS-v1): PlayerLoad, metabolic power/HMLD, sRPE, ACWR,
individualized MSS/MAS. Built via the same frozen-contract → pre-mortem → parallel slices → integration →
post-build review flow (pre-mortem fixed the pre-existing ungated-distance phantom-distance bug; post-build
caught a sprint-effort merge/off-by-one). That completes the four-phase roadmap.
Beyond the roadmap — tactical event detection (Track A) · shipped 2026-06-16 (ADR-0020, event-detection-contract)¶
A NEW initiative past the four-phase plan. Off-loop GET /sessions/:id/events reconstructs time-bucketed
team-shape snapshots (centroid, compactness/stretch, convex-hull surface area, spread, mean speed, HSR
fraction) from the stored trace and detects three heuristic phases — high-tempo, transition, stoppage —
rendered as a review-mode timeline (EventTimeline). Team-aggregate (no playerId/name, ever), honesty-labelled
(confidence + provenance; movement-derived, not confirmed ball events), with the entire history security
posture and a shared off-loop inflight cap (scanLoad.ts). The naive reading of "football events"
(passes/shots) is Track B — not derivable from one-team 10 Hz GPS; it needs new sensing (IMU+ML or camera+CV)
and is deferred (ADR-0020 §6). Same flow as the phases: frozen contract → 5-lens pre-mortem (6 must-fix) →
parallel slices → post-build review (3 confirmed + 5 nits, all test-coverage/env-hardening).
Beyond the roadmap — coach-view reliability · shipped 2026-08-26 (audit Phase 5, ADR-0024)¶
Not a feature phase: the 2026-08-03 production-readiness audit
found that several things this plan assumed were true only on a desk. C-1 — a match-day LAN has no NTP, so
every freshness comparison mixed the TABLET's clock with a SERVER-stamped serverTs; a tablet 10 s fast
rendered an EMPTY PITCH over a healthy 10 Hz feed, and a slow one kept a dead tracker alive forever, defeating
ADR-0018's honesty rule outright. Fixed with
serverClock.ts (a running minimum of Date.now() - serverTs) feeding every age computation — fed ONLY
by the new {event:'hello'} envelope (the server's clock, first frame on every socket) and by .../status
frames, never by telemetry: a Phase-4 replayed fix carries its GPS time, and an adversarial review showed a
page loading during a backlog drain would otherwise infer an offset of hours and draw stale fixes as live
dots — the honesty rule failing in its dangerous direction. C-2 — the
reconnect give-up was terminal for the rest of the match, with no button and no online listener; recovery
existed only by accident (toggle to Review and back). Now conn.retryable + reconnectNow() + a "Reconnect
now" control — plus a stall watchdog, because the same review found the recovery unreachable in the
commonest field failure of all: a transport that stops carrying bytes without ever closing (the tablet
behind the clubhouse, the AP dropping the flow), where the socket stays OPEN and the phase stayed 'live'
while the pitch quietly emptied. And from §6: the pitch's four corners moved out of the bundle into per-session config (they were
pointing at a bench in Belgrade); error boundaries scoped so a Review crash no longer white-screens the shell;
a deadline on every fetch; retries that actually re-fetch (re-pressing Apply was a no-op); off-pitch players
pinned to the canvas edge instead of silently clipped while still being counted; 44 px touch targets; and the
first client→server write — a four-value beacon so a dark tablet is visible from /metrics (ADR-0024). Gated
by a Playwright reliability project that kills and restarts a real server, skews the browser clock 30 s, and
induces a real Review crash.
Server contracts the FE depends on¶
| Endpoint | Purpose | Auth / shape |
|---|---|---|
POST /auth/login (+ session cookie) |
named login | argon2id; HTTP-only/Secure/SameSite=Lax |
GET /sessions |
principal-scoped list (no-rebuild switcher) | principal-authed |
GET /sessions/:id/roster |
playerId → displayName |
principal-authed, session-scoped, in-memory only |
GET /sessions/:id/config |
age band → youth zone thresholds (Phase 4) + the session's four pitch corners (Phase 5) | principal-authed, session-scoped; pitch omitted when unset |
GET /sessions/:id/history?… |
review/replay source (+ Phase-4 zone/sprint/effort aggregates) | principal-authed, session-scoped, off-loop/paged |
GET /sessions/:id/events?… |
tactical events (ADR-0020): team-shape series + heuristic phases | principal-authed, session-scoped, off-loop, inflight cap shared with history |
device-health on /live |
per-player battery/GPS/backlog | second envelope from .../status |
POST /sessions/:id/client-beacon |
the coach view reports its OWN failures (ADR-0024) | principal-authed, session-scoped, strict Origin, closed four-value body, rate-limited |
Testing & verification¶
Drive everything from the simulator (server/test/simulate.ts):
--record/--replay for deterministic visual/e2e checks; the 50-player ramp for the frame-budget gate
(crisp and within-budget verified separately); the secured-broker mode for auth-path tests; a guard that
no name reaches a metric label or log line.
Success criteria¶
Crisp on a retina tablet in sunlight; smooth, honest motion (no fabricated positions); real names without weakening store-level pseudonymity; no bundle-baked shared token; runtime session switching; a working review mode; accessible status/alerts; minimal new dependencies; all exercisable via the simulator.
Decisions¶
ADR-0015 auth transport · ADR-0016 roster / names · ADR-0017 review data source · ADR-0018 smoothing honesty rule.