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

@@ -464,3 +464,29 @@ test("a goal targeting completed sessions computes progress from real exerciseSe
assert.ok(!serialized.includes("driver_1"), "exercise session history must never expose driverId");
assert.ok(!serialized.includes("class_1"), "exercise session history must never expose classId");
});
test("public profile lets the driver pick a layout, an older one, laps from before layouts, or all of them", () => {
const now = Date.now();
const oldLayoutDay = now - 60 * 24 * 60 * 60 * 1000;
const state = buildState(now);
state.trackLayouts = [
{ id: "layout_2", label: "Layout 2", startedAt: now - 24 * 60 * 60 * 1000 },
{ id: "layout_1", label: "Layout 1", startedAt: oldLayoutDay - 1000 },
];
state.training.ledger.push(
{ driverId: "driver_1", sessionId: "old_track", timestamp: oldLayoutDay, lapMs: 9000, trackLayoutId: "layout_1" },
{ driverId: "driver_1", sessionId: "old_track", timestamp: oldLayoutDay + 10000, lapMs: 9200, trackLayoutId: "layout_1" },
);
const best = (layout) => buildPublicTrainingProfile(state, DRIVER_ONE_TOKEN, "", 0, 0, 0, 0, layout).classProfiles.find((item) => item.classId === "class_1").stats.bestLapMs;
assert.equal(best("layout_1"), 9000, "an older layout");
assert.equal(best("all"), 9000, "all layouts = overall best");
assert.ok(best("none") > 9000, "laps from before any layout existed, without the layout_1 laps");
assert.equal(best("current"), 0, "nothing driven on the current layout yet");
assert.equal(best("layout_that_does_not_exist"), 0, "an unknown choice falls back to the current layout");
const layouts = buildPublicTrainingProfile(state, DRIVER_ONE_TOKEN, "", 0, 0, 0, 0, "all").trackLayouts;
assert.equal(layouts.selected, "all");
assert.equal(layouts.currentId, "layout_2");
assert.deepEqual(layouts.options, [{ id: "layout_1", label: "Layout 1" }, { id: "layout_2", label: "Layout 2" }], "oldest first, only id and label are exposed");
assert.equal(layouts.hasUntaggedLaps, true);
});