ADR-0019 — Age-banded speed zones via a per-session config store¶
Status: Implemented (FE Phase 4, 2026-06-15) · Date: 2026-06-15 · See phase-4-contract.md
(sessionConfig.ts store + session-config.ts CLI + GET /sessions/:id/config; client useSessionConfig + zones.ts).
Context¶
Phase 4 adds coaching metrics: speed-zone colouring (live), distance + distance/min (live), an isolated-player cue (live), and post-match aggregates — zone breakdown, sprint efforts, accel/decel efforts (review). The governing rule from metric-definitions §0/§3: adult speed thresholds do not transfer to children — a U13 records zero sprint distance against an adult 25.2 km/h cut-off. The high-intensity (HSR) and sprint cut-offs must be age-banded, and the threshold set used must be stored with each session so a later comparison knows which numbers produced the result (§3.2).
Decision¶
- Age bands U12/U14/U16/U19 with the §3.2 default thresholds: zones 1–3 keep the adult walking/jogging/ running breaks (0 / 2.0 / 4.0 m/s); only HSR and Sprint scale by band — U12 4.44/5.28, U14 4.86/5.83, U16 5.28/6.39, U19 5.50/6.94 (m/s). Individualized MSS/MAS thresholds (§3.3) are deferred (need per-player testing).
- Per-session age band lives in a server-side config store — a separate, fail-closed JSON file
(
SESSION_CONFIG_FILE), modelled exactly on the Phase-3 roster store (async load, size-cap, periodic reload, 0o600, a provisioning CLI). An unconfigured session resolves to a documented default band (U14), so zones always render. This literally satisfies "store the threshold set with each session." - Single source of truth for the band→threshold mapping is the server.
GET /sessions/:id/configreturns{ageBand, thresholds}; the client uses the returned thresholds for live zone colouring, and the server reads the same band for the review aggregates — so live and review never disagree, and the client never re-implements the table. - Scope = GPS-derivable coaching metrics only (zones, distance, distance/min, sprint efforts §3.4, accel/decel efforts §4). Deferred: IMU PlayerLoad (§5.1, no hardware), metabolic power/HMLD (§5.2), sRPE (§5.3), ACWR/monotony (§6, cross-session daily-load tracking) — all out of Phase 4.
- Load metrics stay off the live path (§3/plan): sprint/accel/decel counts are review-only; live carries only zone colour + distance/min + the isolated cue.
Consequences¶
- + Youth-correct zones; provenance per session; live and review consistent by construction.
- + The config store reuses the proven roster pattern (fail-closed, 0o600, reload, CLI) — low novelty risk.
- − A new provisioning surface (store + endpoint + CLI + reload + tests) — chosen over a client-picked band for cross-device/coach consistency (product-owner decision).
- − The age band is not an identity/secret, but the config file + endpoint inherit the session-scoped
authz posture anyway for uniformity (it is NOT name/location data, so no
no-store/rate-limit is required — see the Phase-4 contract).
Amendment — the PITCH lives here too (audit Phase 5, 2026-08-26)¶
The 2026-08-03 audit (§6 "Client") found PITCH_CORNERS compiled into the client bundle — and the committed
value pointing at a bench in Belgrade, so every real pitch mapped to the wrong box and fixing it meant
editing source and rebuilding. The corners are per-session configuration in exactly the sense this ADR already
established for the age band, so they join the same store rather than getting a second one:
session-config.jsonentries gain an optionalpitch: { corners: [TL, TR, BR, BL] }, set withbun run session-config.ts set-pitch <session> <TL> <TR> <BR> <BL>(each corner"lat,lon"), cleared withclear-pitch, and shown bylist. Setting the BAND merges rather than replaces, so a routine band correction can't silently discard four coordinates someone walked the pitch to collect.GET /sessions/:id/configreturnspitch:{corners}when one is configured and omits the key entirely otherwise — absent means "keep your built-in corners", which needs no special case at any reader.- The geometry is validated on BOTH sides (
validatePitchCornersserver-side,parsePitchCornersclient-side — deliberately duplicated, marked as such in both files). The client SOLVES a homography from these four points: coincident or collinear corners throw inside that solve, and a self-crossing order solves fine but folds the pitch over itself and maps players to mirrored nonsense. Rejecting a bad quad costs the session its pitch (fall back to the built-in corners); it must never cost the coach their view mid-match, and an unusable pitch never costs the session its band. - Same posture as the band: a pitch corner is a place, not a person — no child's position, so no
no-storeand no rate-limit beyond the existing session gate.
Alternatives considered¶
- Client-picked band + query param (echoed for provenance) — rejected by the owner: simplest, but two coaches could pick different bands for the same session; no server-stored provenance.
- Single global env default band — rejected: a club with multiple age groups can't differentiate sessions.
- Individualized MSS/MAS thresholds now — rejected: requires per-player sprint testing the project lacks; the age-band defaults are the documented "start here," with individualization a later upgrade.