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

@@ -21,6 +21,7 @@ import {
getBestTrainingDurationResult,
getDriverTrainingLaps,
getDriverTrainingLapsForCurrentLayout,
getDriverTrainingLapsForLayout,
getNextTrainingMilestone,
getClubTrainingGoalProgress,
getTrainingGoalProgress,
@@ -908,3 +909,49 @@ test("stint analysis hides race pace 10 for short stints and flags slow laps", (
assert.equal(shortStint.consistencyPct, null);
assert.equal(shortStint.insufficient, true);
});
test("one Open Practice run across a layout switch: each lap lands on the layout active when it was driven", () => {
const switchAt = 10_000;
const state = {
trackLayouts: [{ id: "layout_1", label: "Layout 1", startedAt: 1 }, { id: "layout_2", label: "Layout 2", startedAt: switchAt }],
// The session itself was stamped with layout_1 when it started - that must not decide the laps.
sessions: [{ id: "season", name: "Open Practice", eventId: "event_1", type: "open_practice", trackLayoutId: "layout_1" }],
events: [{ id: "event_1", name: "Season practice" }],
resultsBySession: {
season: {
passings: [
{ driverId: "driver_1", competitorKey: "driver_1", lapMs: 12_000, timestamp: 5_000, validLap: true },
{ driverId: "driver_1", competitorKey: "driver_1", lapMs: 11_800, timestamp: 8_000, validLap: true },
{ driverId: "driver_1", competitorKey: "driver_1", lapMs: 15_000, timestamp: 20_000, validLap: true },
{ driverId: "driver_1", competitorKey: "driver_1", lapMs: 14_600, timestamp: 35_000, validLap: true },
],
},
},
};
const training = syncTrainingLedger({}, state, { uid: createUid() });
assert.deepEqual(training.ledger.map((lap) => lap.trackLayoutId), ["layout_1", "layout_1", "layout_2", "layout_2"]);
const withLedger = { ...state, training };
assert.equal(buildDriverTrainingStats(withLedger, "driver_1").bestLapMs, 14_600, "default = current layout (layout_2)");
assert.equal(buildDriverTrainingStats(withLedger, "driver_1", "", { layout: "layout_1" }).bestLapMs, 11_800, "an older layout can be picked");
const overall = buildDriverTrainingStats(withLedger, "driver_1", "", { layout: "all" });
assert.equal(overall.bestLapMs, 11_800, "all layouts = overall best");
assert.equal(overall.totalLaps, 4);
assert.equal(buildDriverTrainingStats(withLedger, "driver_1", "", { layout: "layout_1" }).totalLaps, 4, "lifetime totals never depend on the layout choice");
assert.equal(buildTrainingStintAnalysis(withLedger, "driver_1", "", { layout: "layout_2" }).rows.reduce((sum, row) => sum + row.lapCount, 0), 2, "stint analysis follows the layout choice");
});
test("layout choice \"none\" picks the laps driven before any layout existed", () => {
const state = {
trackLayouts: [{ id: "layout_1", label: "Layout 1", startedAt: 50_000 }],
training: {
ledger: [
{ driverId: "driver_1", sessionId: "s0", timestamp: 1000, lapMs: 13_000, trackLayoutId: "" },
{ driverId: "driver_1", sessionId: "s1", timestamp: 60_000, lapMs: 16_000, trackLayoutId: "layout_1" },
],
},
};
assert.deepEqual(getDriverTrainingLapsForLayout(state, "driver_1", "", "none").map((lap) => lap.lapMs), [13_000]);
assert.deepEqual(getDriverTrainingLapsForLayout(state, "driver_1", "", "current").map((lap) => lap.lapMs), [16_000]);
assert.equal(getDriverTrainingLapsForLayout(state, "driver_1", "", "all").length, 2);
});