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

@@ -9,6 +9,7 @@ import { runSettingsTabsSmoke } from "./browser_settings_tabs.js";
import { runGuideTabsSmoke } from "./browser_guide_tabs.js";
import { runAuthSmoke } from "./browser_auth.js";
import { runTtsSettingsSmoke } from "./browser_tts_settings.js";
import { runTrackLayoutsSmoke } from "./browser_track_layouts.js";
import { runObsPopupSmoke } from "./browser_obs_popup.js";
import { runDecoderReconnectSmoke } from "./browser_decoder_reconnect.js";
import assert from "node:assert/strict";
@@ -148,6 +149,7 @@ try {
await runGuideTabsSmoke({ evaluate, navigate });
await runAuthSmoke({ evaluate, navigate, fill, savedState, waitFor });
await runTtsSettingsSmoke({ evaluate, navigate, savedState, waitFor });
await runTrackLayoutsSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs });
await runRaceDaySmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, dialogs });
await runRaceDayStartSequencesSmoke({ evaluate, navigate, waitFor, pause, base, dialogs });
await runObsPopupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, debugPort, dialogs, realClick });

View File

@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
// Track layouts from the operator's side: create one from Training stats with the
// "New layout" button, see the per-layout stats selector appear, and confirm layout
// changes stay a practice-only action - hidden on Race Day while a race session is in
// focus, and refused outright while a race session is running.
export async function runTrackLayoutsSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs }) {
const patchState = async (patch) => {
const current = await savedState();
Object.assign(current, patch);
const response = await fetch(base + "/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(current) });
assert.equal(response.ok, true);
};
const layoutButtonVisible = () => evaluate(`Boolean(document.querySelector("[data-track-layout-new]"))`);
// This scenario triggers prompts/alerts on purpose; drop them afterwards so the shared
// "no unexpected native dialog" checks in later scenarios stay meaningful.
const dialogsAtStart = dialogs.length;
const seeded = await savedState();
const practiceEvent = { id: "tl_practice_event", name: "TL Practice", classId: "test_class", mode: "race", raceConfig: { practiceOnly: true } };
const raceEvent = { id: "tl_race_event", name: "TL Race", classId: "test_class", mode: "race", raceConfig: { minLapMs: 3000, maxLapMs: 60000 } };
const practice = { id: "tl_practice", eventId: practiceEvent.id, name: "TL Open Practice", type: "open_practice", durationMin: 60, status: "ready" };
const quali = { id: "tl_quali", eventId: raceEvent.id, name: "TL Q1", type: "qualification", durationMin: 5, status: "ready" };
// Earlier scenarios in the shared suite can leave a race-format session running, which
// would (correctly) block every layout change - finish those first.
const settled = seeded.sessions.map((session) => (["running", "paused"].includes(session.status) ? { ...session, status: "finished", endedAt: Date.now() } : session));
await patchState({ trackLayouts: [], events: [...seeded.events, practiceEvent, raceEvent], sessions: [...settled, practice, quali] });
try {
await navigate("training", "view");
await waitFor(async () => layoutButtonVisible(), "New layout button on Training stats");
assert.equal(await evaluate(`Boolean(document.getElementById("training-layout"))`), false, "no per-layout selector before any layout exists");
await evaluate(`document.querySelector("[data-track-layout-new]").click()`);
await waitFor(async () => (await savedState()).trackLayouts?.length === 1, "layout created from Training stats");
assert.equal((await savedState()).trackLayouts[0].label, "Layout 1", "an empty prompt answer gets the next default name");
await waitFor(async () => Boolean(await evaluate(`document.getElementById("training-layout")`)), "per-layout selector appears");
const options = await evaluate(`[...document.querySelectorAll("#training-layout option")].map((option) => option.value)`);
assert.deepEqual(options.filter((value) => ["current", "all"].includes(value)), ["current", "all"]);
assert.ok(options.includes((await savedState()).trackLayouts[0].id));
console.log("PASS a track layout can be created from Training stats and the per-layout stats selector appears");
await patchState({ activeSessionId: quali.id });
await navigate("race_day", "raceDayStart");
assert.equal(await layoutButtonVisible(), false, "no New layout button while a race session is in focus");
await patchState({ activeSessionId: practice.id });
await navigate("race_day", "raceDayStart");
assert.equal(await layoutButtonVisible(), true, "New layout is offered when Open Practice is in focus");
console.log("PASS Race Day only offers a new layout when a practice session is in focus");
await patchState({ sessions: (await savedState()).sessions.map((session) => (session.id === quali.id ? { ...session, status: "running", startedAt: Date.now() } : session)) });
await navigate("training", "view");
const dialogCount = dialogs.length;
await evaluate(`document.querySelector("[data-track-layout-new]").click()`);
await waitFor(async () => dialogs.slice(dialogCount).some((dialog) => dialog.type === "alert" && dialog.message.includes("TL Q1")), "blocked with a message naming the running race");
assert.equal((await savedState()).trackLayouts.length, 1, "no layout was added while the race ran");
console.log("PASS a new track layout is refused while a race session is running");
} finally {
dialogs.splice(dialogsAtStart);
await patchState({
trackLayouts: [],
activeSessionId: "",
sessions: (await savedState()).sessions.filter((session) => ![practice.id, quali.id].includes(session.id)),
events: (await savedState()).events.filter((event) => ![practiceEvent.id, raceEvent.id].includes(event.id)),
});
}
}

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);
});

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");
});

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);
});