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

@@ -77,7 +77,17 @@ function filterPublicTrainingStats(stats, visibility) {
return filtered;
}
function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp = 0, windowMs = 0, evenLapsSince = 0, stepTrainingSince = 0) {
// Which layout the pace/record/consistency numbers are for - mirrors
// getDriverTrainingLapsForLayout in src/training_logic.js: "current", "all", "none"
// (laps from before any layout existed) or a known layout id. Anything else -> "current".
function normalizePublicTrainingLayout(value, state) {
const layout = String(value || "current");
if (["current", "all", "none"].includes(layout)) return layout;
const layouts = Array.isArray(state?.trackLayouts) ? state.trackLayouts : [];
return layouts.some((item) => String(item?.id || "") === layout) ? layout : "current";
}
function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp = 0, windowMs = 0, evenLapsSince = 0, stepTrainingSince = 0, layout = "current") {
const share = findPublicTrainingShare(state, shareToken);
if (!share) return null;
const visibility = normalizePublicTrainingVisibility(share.visibility);
@@ -90,6 +100,8 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp
const classes = Array.isArray(state?.classes) ? state.classes : [];
const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : [];
const currentLayoutId = getCurrentTrackLayoutId(state);
const selectedLayout = normalizePublicTrainingLayout(layout, state);
const layoutFilterId = selectedLayout === "current" ? currentLayoutId : selectedLayout === "none" ? "" : selectedLayout;
const profileReferences = new Set([driverProfile.id, ...driverProfile.drivers.map((item) => String(item.id || ""))]);
const profileClassIds = [...new Set(driverProfile.drivers.map((item) => String(item.classId || "")).filter(Boolean))];
const classProfiles = profileClassIds.map((classId) => {
@@ -111,7 +123,7 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp
// track rebuild doesn't erase past practice. Pace/record/consistency numbers instead
// read only laps tagged with today's active layout, so a personal best from a track
// that no longer exists can't be shown as current. See getCurrentTrackLayoutId above.
const classLayoutLaps = classLaps.filter((lap) => lap.trackLayoutId === currentLayoutId);
const classLayoutLaps = selectedLayout === "all" ? classLaps : classLaps.filter((lap) => lap.trackLayoutId === layoutFilterId);
const stats = buildPublicTrainingStats(classLaps, classLayoutLaps);
const classMarkers = (Array.isArray(state?.training?.markers) ? state.training.markers : []).filter((marker) => classDriverIds.has(String(marker?.driverId || "")) && String(marker?.classId || "") === classId);
stats.paceBuckets = buildPublicTrainingPaceBuckets(classLaps, classMarkers);
@@ -176,6 +188,15 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp
logoDataUrl: normalizePublicLogoDataUrl(settings.logoDataUrl),
},
classProfiles,
trackLayouts: {
selected: selectedLayout,
currentId: currentLayoutId,
options: (Array.isArray(state?.trackLayouts) ? state.trackLayouts : [])
.slice()
.sort((a, b) => Number(a?.startedAt || 0) - Number(b?.startedAt || 0))
.map((item) => ({ id: String(item?.id || ""), label: String(item?.label || "").trim() })),
hasUntaggedLaps: ledger.some((entry) => profileReferences.has(String(entry?.driverId || "")) && !String(entry?.trackLayoutId || "")),
},
stats: activeClass.stats,
channelStats: activeClass.channelStats,
nextMilestone: activeClass.nextMilestone,
@@ -827,6 +848,7 @@ function getPublicTrainingRecommendation(stats) {
}
module.exports = {
normalizePublicTrainingLayout,
buildPublicTrainingProfile,
findPublicTrainingShare,
hasPublicTrainingShare,