feat(layouts): switch track layout mid-practice and view stats per layout

A club can run one Open Practice all season and re-lay the track now and then.
Previously a session's layout was frozen at start and every training lap inherited
it, so a season-long practice stayed on its first layout forever, and changing layout
was hidden under Settings -> Appearance.

- Each practice lap now counts toward the layout active when it was driven; a running
  practice session moves onto a new layout when one is added
- "New layout" strip on Training stats, Race Day and the Dashboard, offered only around
  practice; a running race session blocks layout changes everywhere
- "Stats for" selector (current / older layout / before layouts / all layouts) on
  Training stats and in the driver's mobile profile (?layout= on the public API)
- A layout that has practice laps on it can no longer be deleted
- Fix: the buildDriverTrainingStats wrapper in app.js dropped extra arguments

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-27 21:24:44 +02:00
parent 196437d25b
commit 627179b95a
22 changed files with 454 additions and 44 deletions

View File

@@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { normalizeTrackLayout, normalizeTrackLayouts, getTrackLayoutIdAt, getTrackLayoutLabel, nextTrackLayoutLabel } from "../src/track_layouts_logic.js";
import { normalizeTrackLayout, normalizeTrackLayouts, getTrackLayoutIdAt, getTrackLayoutLabel, nextTrackLayoutLabel, isPracticeSessionType, findRaceSessionBlockingLayoutChange, isTrackLayoutInUse } from "../src/track_layouts_logic.js";
import { getTrackRecordMs } from "../src/timing_logic.js";
test("normalizeTrackLayout fills in an id via uid() when missing and coerces startedAt", () => {
@@ -98,3 +98,18 @@ test("getTrackRecordMs does not conflate a layout-tagged session with an untagge
});
assert.equal(getTrackRecordMs(state.sessions[0], { state }), null, "the legacy session (no trackLayoutId) is a different bucket from layout_1");
});
test("layouts change only around practice: a running or paused race session blocks it", () => {
assert.equal(isPracticeSessionType("open_practice"), true);
assert.equal(isPracticeSessionType("free_practice"), true);
assert.equal(isPracticeSessionType("qualification"), false);
assert.equal(findRaceSessionBlockingLayoutChange([{ type: "open_practice", status: "running" }, { type: "final", status: "ready" }]), null, "running practice and a not-yet-started final don't block");
assert.equal(findRaceSessionBlockingLayoutChange([{ name: "Q1", type: "qualification", status: "paused" }])?.name, "Q1");
assert.equal(findRaceSessionBlockingLayoutChange([{ name: "Heat", type: "heat", status: "running" }])?.name, "Heat");
});
test("a layout is in use when a session started on it or a practice lap was driven on it", () => {
assert.equal(isTrackLayoutInUse("layout_2", { sessions: [{ trackLayoutId: "layout_1" }], ledger: [] }), false);
assert.equal(isTrackLayoutInUse("layout_2", { sessions: [{ trackLayoutId: "layout_2" }] }), true);
assert.equal(isTrackLayoutInUse("layout_2", { sessions: [{ trackLayoutId: "layout_1" }], ledger: [{ trackLayoutId: "layout_2" }] }), true, "a mid-session switch leaves laps on layout_2 even though no session started on it");
});