Add track layout tracking so lap-time stats stay comparable

Clubs that reconfigure their track (e.g. running indoors in the same
building) can now add named track layouts under Settings, each with
just a start date/time - layouts are contiguous, so a layout's end is
implicitly the next one's start, with no separate field to keep in
sync. Every session freezes which layout was active when it started,
so the "track record" audio cue and the Results Archive (which now
shows each session's layout) only compare laps run on the same
physical track. With no layouts configured, everything behaves
exactly as before - the feature is fully inert until a club uses it.

Design settled through conversation with the user: global scope (not
per class), start-date-only (no redundant stop date), and no changes
needed to driver/transponder mapping, since that's already fully
independent of track geometry in this app.

Deliberately out of scope for this pass: the mobile/driver training
profile's own cross-session personal-best trend stats still compare
across a driver's whole lap ledger regardless of layout - logged in
ROADMAP.md as a real remaining gap for a later pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-10 14:17:06 +02:00
parent 90df3378a6
commit fd6efe2c7e
13 changed files with 328 additions and 27 deletions

View File

@@ -13,7 +13,8 @@ import { renderRaceFormatField, renderRaceFormatContextCard, getRaceSummaryItems
import { normalizeRaceTeam as normalizeRaceTeamLogic, normalizeStoredRacePreset as normalizeStoredRacePresetLogic, getRaceFormatPresets as getRaceFormatPresetsLogic, applyRaceFormatPreset as applyRaceFormatPresetLogic, buildRaceFormatConfigFromForm as buildRaceFormatConfigFromFormLogic, normalizeEvent as normalizeEventLogic, normalizeBrandingConfig as normalizeBrandingConfigLogic, resolveEventBranding as resolveEventBrandingLogic, getRaceManageStatuses as getRaceManageStatusesLogic, buildDefaultRaceWizardDraft as buildDefaultRaceWizardDraftLogic, getRaceWizardPreset as getRaceWizardPresetLogic, applyRaceWizardPresetDefaults as applyRaceWizardPresetDefaultsLogic, ensureRaceParticipantsConfigured as ensureRaceParticipantsConfiguredLogic, buildRaceSession as buildRaceSessionLogic, buildOpenPracticeBundle as buildOpenPracticeBundleLogic, buildTrackSession as buildTrackSessionLogic, createSponsorRounds as createSponsorRoundsLogic, buildRaceSessionsFromWizard as buildRaceSessionsFromWizardLogic, getRaceWizardSessionPlan as getRaceWizardSessionPlanLogic, getEventDrivers as getEventDriversLogic, getEventTeams as getEventTeamsLogic, getTeamDriverPool as getTeamDriverPoolLogic, findEventTeamForPassing as findEventTeamForPassingLogic, generateQualifyingForRace as generateQualifyingForRaceLogic, reseedUpcomingQualifying as reseedUpcomingQualifyingLogic, generateFinalsForRace as generateFinalsForRaceLogic, applyBumpsForRace as applyBumpsForRaceLogic } from "./event_race_logic.js";
import { getSessionTypeLabel as getSessionTypeLabelLogic, getStatusLabel as getStatusLabelLogic, isUntimedSession as isUntimedSessionLogic, getActiveSession as getActiveSessionLogic, getSessionTargetMs as getSessionTargetMsLogic, getSessionLapWindow as getSessionLapWindowLogic, isCountedPassing as isCountedPassingLogic, getVisiblePassings as getVisiblePassingsLogic, getPassingValidationLabel as getPassingValidationLabelLogic, getSessionTiming as getSessionTimingLogic, ensureSessionResult as ensureSessionResultLogic, buildLeaderboard as buildLeaderboardLogic, formatLapDelta as formatLapDeltaLogic, formatLeaderboardGap as formatLeaderboardGapLogic, getCompetitorElapsedMs as getCompetitorElapsedMsLogic, getCompetitorPassings as getCompetitorPassingsLogic, getCompetitorSeedMetric as getCompetitorSeedMetricLogic, getSessionEntrants as getSessionEntrantsLogic, buildPracticeStandings as buildPracticeStandingsLogic, getQualifyingPointsValue as getQualifyingPointsValueLogic, isHighPointsTable as isHighPointsTableLogic, compareNumberSet as compareNumberSetLogic, buildQualifyingTieBreakNote as buildQualifyingTieBreakNoteLogic, hasQualifyingPrimaryTie as hasQualifyingPrimaryTieLogic, buildQualifyingStandings as buildQualifyingStandingsLogic, formatTeamActiveMemberLabel as formatTeamActiveMemberLabelLogic, buildTeamRaceStandings as buildTeamRaceStandingsLogic, buildSessionChannelStats as buildSessionChannelStatsLogic, buildTeamStintLog as buildTeamStintLogLogic, getSessionGridEntries as getSessionGridEntriesLogic, getSessionGridOrder as getSessionGridOrderLogic, ensureSessionDriverOrder as ensureSessionDriverOrderLogic, buildFinalStandings as buildFinalStandingsLogic } from "./timing_logic.js";
import { getSessionTypeLabel as getSessionTypeLabelLogic, getStatusLabel as getStatusLabelLogic, isUntimedSession as isUntimedSessionLogic, getActiveSession as getActiveSessionLogic, getSessionTargetMs as getSessionTargetMsLogic, getSessionLapWindow as getSessionLapWindowLogic, isCountedPassing as isCountedPassingLogic, getVisiblePassings as getVisiblePassingsLogic, getPassingValidationLabel as getPassingValidationLabelLogic, getSessionTiming as getSessionTimingLogic, ensureSessionResult as ensureSessionResultLogic, buildLeaderboard as buildLeaderboardLogic, formatLapDelta as formatLapDeltaLogic, formatLeaderboardGap as formatLeaderboardGapLogic, getCompetitorElapsedMs as getCompetitorElapsedMsLogic, getCompetitorPassings as getCompetitorPassingsLogic, getCompetitorSeedMetric as getCompetitorSeedMetricLogic, getSessionEntrants as getSessionEntrantsLogic, buildPracticeStandings as buildPracticeStandingsLogic, getQualifyingPointsValue as getQualifyingPointsValueLogic, isHighPointsTable as isHighPointsTableLogic, compareNumberSet as compareNumberSetLogic, buildQualifyingTieBreakNote as buildQualifyingTieBreakNoteLogic, hasQualifyingPrimaryTie as hasQualifyingPrimaryTieLogic, buildQualifyingStandings as buildQualifyingStandingsLogic, formatTeamActiveMemberLabel as formatTeamActiveMemberLabelLogic, buildTeamRaceStandings as buildTeamRaceStandingsLogic, buildSessionChannelStats as buildSessionChannelStatsLogic, buildTeamStintLog as buildTeamStintLogLogic, getSessionGridEntries as getSessionGridEntriesLogic, getSessionGridOrder as getSessionGridOrderLogic, ensureSessionDriverOrder as ensureSessionDriverOrderLogic, buildFinalStandings as buildFinalStandingsLogic, getTrackRecordMs as getTrackRecordMsLogic } from "./timing_logic.js";
import { normalizeTrackLayouts, getTrackLayoutIdAt, getTrackLayoutLabel, nextTrackLayoutLabel } from "./track_layouts_logic.js";
import { renderDashboardView, renderClassesView, renderDriversView, renderCarsView } from "./core_views.js";
@@ -139,7 +140,7 @@ const saveAmmcConfigToBackend = (config) => saveAmmcConfigToBackendHelper(config
const refreshAmmcStatus = () => refreshAmmcStatusHelper({ ammc, t, getBackendUrl });
const startManagedAmmc = () => startManagedAmmcHelper({ ammc, t, getBackendUrl });
const stopManagedAmmc = () => stopManagedAmmcHelper({ ammc, t, getBackendUrl });
const applyPersistedState = (persisted) => applyPersistedStateHelper(persisted, { state, backend, t, getDefaultBackendUrl, DEFAULT_LANGUAGE, DEFAULT_THEME, AVAILABLE_THEMES, normalizeDriver, normalizeCar, normalizeEvent, normalizeSession, normalizeStoredRacePreset, normalizeObsOverlaySettings, normalizeTrainingState, normalizeRaceAudioSettings, applyTheme });
const applyPersistedState = (persisted) => applyPersistedStateHelper(persisted, { state, backend, t, getDefaultBackendUrl, DEFAULT_LANGUAGE, DEFAULT_THEME, AVAILABLE_THEMES, normalizeDriver, normalizeCar, normalizeEvent, normalizeSession, normalizeStoredRacePreset, normalizeObsOverlaySettings, normalizeTrainingState, normalizeRaceAudioSettings, normalizeTrackLayouts: (value) => normalizeTrackLayouts(value, { uid }), applyTheme });
const hydrateFromBackend = () => hydrateFromBackendHelper({ backend, t, getBackendUrl, applyPersistedState, saveState });
const scheduleBackendSync = () => scheduleBackendSyncHelper({ getBackendSyncTimer: () => backendSyncTimer, setBackendSyncTimer: (value) => { backendSyncTimer = value; }, syncStateToBackend });
const syncStateToBackend = () => syncStateToBackendHelper({ backend, t, getBackendUrl, buildPersistableState, getLocalStateVersion: () => localStateVersion, getSyncedStateVersion: () => syncedStateVersion, setSyncedStateVersion: (value) => { syncedStateVersion = value; }, getLocalStateDirty: () => localStateDirty, setLocalStateDirty: (value) => { localStateDirty = value; } });
@@ -223,13 +224,7 @@ const disconnectDecoder = (options = {}) => disconnectDecoderHelper(options, {
setReconnectTimer: (role, value) => { reconnectTimers[role] = value; },
updateConnectionBadge,
});
const getTrackRecordMs = (session) => {
const event = state.events.find((item) => item.id === session?.eventId);
if (!event?.classId) return null;
const candidates = state.sessions.filter((item) => item.id !== session?.id && state.events.find((race) => race.id === item.eventId)?.classId === event?.classId);
const values = candidates.flatMap((item) => Object.values(state.resultsBySession[item.id]?.competitors || {}).map((row) => Number(row.bestLapMs || 0))).filter((value) => value > 500);
return values.length ? Math.min(...values) : null;
};
const getTrackRecordMs = (session) => getTrackRecordMsLogic(session, { state });
const processDecoderMessage = (msg, decoderRole = "main") => processDecoderMessageHelper(msg, {
state,
t,
@@ -332,6 +327,7 @@ const renderArchive = () => renderResultsArchiveView({
exportRaceResultsPdf, buildRacePackagePayload, downloadJsonFile, sanitizeFilenameSegment,
getArchiveFilters: () => archiveFilters, setArchiveFilters: (value) => { archiveFilters = value; },
getSelectedSessionId: () => archiveSelectedSessionId, setSelectedSessionId: (value) => { archiveSelectedSessionId = value; },
getTrackLayoutLabel,
});
const renderTraining = () => renderTrainingView({ state, dom, t, escapeHtml, formatLap, formatRaceClock, uid, saveState, syncStateToBackend, renderView, normalizeTrainingState, syncTrainingLedger, buildDriverTrainingStats, buildDriverTimingChannelStats: buildDriverTimingChannelStatsLogic, buildDriverActivePracticeStats, buildEvenLapsProgress, buildStepTrainingProgress, buildTrainingRecommendation, getTrainingGoalProgress, getNextTrainingMilestone, buildTrainingMissions: buildTrainingMissionsLogic, buildTrainingHighlights: buildTrainingHighlightsLogic, getClubTrainingGoalProgress: getClubTrainingGoalProgressLogic, countDriverExerciseSessions: countDriverExerciseSessionsLogic, countExerciseSessions: countExerciseSessionsLogic, buildTrainingPaceBuckets: buildTrainingPaceBucketsLogic, buildTrainingWeekdayStats: buildTrainingWeekdayStatsLogic, buildTrainingStintAnalysis: buildTrainingStintAnalysisLogic, buildTrainingMarkerComparisons: buildTrainingMarkerComparisonsLogic, getSelectedDriverId: () => selectedTrainingDriverId, setSelectedDriverId: (value) => { selectedTrainingDriverId = value; updateViewUrl("training", value, selectedTrainingClassId); }, getSelectedClassId: () => selectedTrainingClassId, setSelectedClassId: (value) => { selectedTrainingClassId = value; updateViewUrl("training", selectedTrainingDriverId, value); } });
const applyCompetitorCorrection = (session, row, options = {}) => {
@@ -472,6 +468,7 @@ const TRANSLATIONS = {
"archive.date": "Datum",
"archive.event": "Event",
"archive.session": "Session",
"archive.layout": "Layout",
"archive.drivers": "Förare",
"archive.winner": "Vinnare",
"archive.penalties_short": "Straff",
@@ -1476,6 +1473,17 @@ const TRANSLATIONS = {
"settings.logo_upload": "Ladda logo",
"settings.logo_clear": "Rensa logo",
"settings.logo_note": "Logotypen visas i huvudmenyn, overlay och publik träningsprofil. PDF-export försöker bädda in den via backend.",
"settings.track_layouts": "Banlayouter",
"settings.track_layouts_hint": "Lägg till en ny banlayout varje gång banan byggs om (t.ex. vid inomhuskörning i samma lokal), så jämförs personbästa, banrekord och \"jämn fart\"-ljudet bara mot pass körda på samma layout. En session räknas till den layout som gällde när den startades. Om ingen layout läggs till fungerar allt som idag.",
"settings.track_layout_label": "Namn (valfritt)",
"settings.track_layout_started_at": "Gäller från",
"settings.track_layout_add": "Lägg till layout",
"settings.track_layout_none": "Inga banlayouter tillagda än.",
"settings.track_layout_unnamed": "Namnlös layout",
"settings.track_layout_invalid_date": "Ogiltigt datum.",
"settings.track_layout_date_error": "Ny layout måste börja gälla efter den senaste layoutens startdatum ({date}).",
"settings.track_layout_delete_confirm": "Ta bort banlayouten \"{label}\"?",
"settings.track_layout_delete_blocked": "Kan inte tas bort - en eller flera sessioner är redan kopplade till den här layouten.",
"settings.update_title": "Uppdateringar",
"settings.update_current_version": "Installerad version",
"settings.update_checking": "Kontrollerar om det finns en ny version...",
@@ -1772,6 +1780,7 @@ const TRANSLATIONS = {
"guide.archive_3": "3. Klicka Visa på en rad för att se resultat, straff/korrigeringar och varvhistorik per förare för den sessionen.",
"guide.archive_4": "4. Exportera samma PDF- och racepaket (JSON)-filer som redan finns i Manage Event, direkt från arkivet.",
"guide.archive_5": "5. Att radera ett event eller en session tar bort racets data överallt, inklusive de råa varvraderna i databasen - men påverkar aldrig förarens beständiga träningsstatistik, som redan sparas separat.",
"guide.archive_6": "6. Kör klubben på flera banlayouter (t.ex. inomhus i samma lokal) - lägg till varje ny layout under Inställningar. Arkivet visar vilken layout varje session kördes på, och personbästa/banrekord jämförs bara mot pass på samma layout.",
"overlay.title": "Overlay",
"overlay.subtitle": "Extern leaderboard-skärm",
"overlay.no_active": "Ingen aktiv session vald.",
@@ -1962,6 +1971,7 @@ const TRANSLATIONS = {
"archive.date": "Date",
"archive.event": "Event",
"archive.session": "Session",
"archive.layout": "Layout",
"archive.drivers": "Drivers",
"archive.winner": "Winner",
"archive.penalties_short": "Penalties",
@@ -2966,6 +2976,17 @@ const TRANSLATIONS = {
"settings.logo_upload": "Upload logo",
"settings.logo_clear": "Clear logo",
"settings.logo_note": "The logo is shown in the main menu, overlays, and public training profiles. PDF export attempts to embed it via the backend.",
"settings.track_layouts": "Track layouts",
"settings.track_layouts_hint": "Add a new track layout every time the track gets rebuilt (e.g. running indoors in the same building), so personal bests, the track record and the \"stable pace\" cue only compare against laps run on the same layout. A session is attributed to whichever layout was active when it started. With no layouts added, everything behaves exactly as it does today.",
"settings.track_layout_label": "Name (optional)",
"settings.track_layout_started_at": "Active from",
"settings.track_layout_add": "Add layout",
"settings.track_layout_none": "No track layouts added yet.",
"settings.track_layout_unnamed": "Unnamed layout",
"settings.track_layout_invalid_date": "Invalid date.",
"settings.track_layout_date_error": "A new layout must start after the latest layout's start date ({date}).",
"settings.track_layout_delete_confirm": "Delete the track layout \"{label}\"?",
"settings.track_layout_delete_blocked": "Can't delete - one or more sessions are already linked to this layout.",
"settings.update_title": "Updates",
"settings.update_current_version": "Installed version",
"settings.update_checking": "Checking for a new version...",
@@ -3262,6 +3283,7 @@ const TRANSLATIONS = {
"guide.archive_3": "3. Click View on a row to see that session's results, penalties/corrections and per-driver lap history.",
"guide.archive_4": "4. Export the same PDF and race-package (JSON) files already available in Manage Event, directly from the archive.",
"guide.archive_5": "5. Deleting an event or session removes the race's data everywhere, including the raw lap rows in the database - but never affects a driver's durable training statistics, which are already stored separately.",
"guide.archive_6": "6. Running your club across several track layouts (e.g. indoors in the same building) - add each new layout under Settings. The archive shows which layout each session ran on, and personal bests/track records only compare against laps on the same layout.",
"overlay.title": "Overlay",
"overlay.subtitle": "External leaderboard screen",
"overlay.no_active": "No active session selected.",
@@ -3526,6 +3548,7 @@ function normalizeSession(session = {}) {
manualGridIds: Array.isArray(session?.manualGridIds) ? session.manualGridIds.map(String).filter(Boolean) : [],
gridCustomized: session?.gridCustomized === true,
generated: session?.generated === true,
trackLayoutId: String(session?.trackLayoutId || "").trim(),
};
}
@@ -3834,6 +3857,7 @@ function seedDefaultData() {
state.events = state.events.map((event) => normalizeEvent(event));
state.sessions = state.sessions.map((session) => normalizeSession(session));
state.training = normalizeTrainingState(state.training);
state.trackLayouts = normalizeTrackLayouts(state.trackLayouts, { uid });
saveState({ skipBackend: true });
}
@@ -3853,6 +3877,7 @@ function loadState() {
audioEventLog: Array.isArray(parsed.audioEventLog) ? parsed.audioEventLog.slice(0, 500) : [],
training: normalizeTrainingState(parsed.training),
activeSessionId: parsed.activeSessionId || null,
trackLayouts: normalizeTrackLayouts(parsed.trackLayouts, { uid }),
settings: {
wsUrl: parsed.settings?.wsUrl || "ws://127.0.0.1:9000",
splitDecoderEnabled: parsed.settings?.splitDecoderEnabled === true,
@@ -3920,6 +3945,7 @@ function loadState() {
audioEventLog: [],
training: normalizeTrainingState(),
activeSessionId: null,
trackLayouts: [],
settings: {
wsUrl: "ws://127.0.0.1:9000",
splitDecoderEnabled: false,
@@ -4012,6 +4038,7 @@ async function startOperationalSession(session, callbacks = {}) {
committed = true;
state.activeSessionId = session.id;
beginSessionControl(session);
session.trackLayoutId = getTrackLayoutIdAt(state.trackLayouts);
lastFinishAnnouncementSessionId = null;
lastOverlayLeaderKeyBySession[session.id] = null;
lastOverlayTop3BySession[session.id] = [];
@@ -4172,6 +4199,7 @@ function buildPersistableState() {
training: normalizeTrainingState(state.training),
activeSessionId: state.activeSessionId,
settings: state.settings,
trackLayouts: normalizeTrackLayouts(state.trackLayouts, { uid }),
};
}
@@ -4537,6 +4565,27 @@ async function refreshServerBackups({ render = true } = {}) {
return serverBackups;
}
function addTrackLayout(label, startedAtMs) {
const layouts = normalizeTrackLayouts(state.trackLayouts, { uid });
const latest = layouts.at(-1);
if (latest && startedAtMs <= latest.startedAt) {
return { ok: false, message: t("settings.track_layout_date_error", { date: new Date(latest.startedAt).toLocaleString() }) };
}
layouts.push({ id: uid("layout"), label: String(label || "").trim() || nextTrackLayoutLabel(layouts), startedAt: startedAtMs });
state.trackLayouts = normalizeTrackLayouts(layouts, { uid });
saveState();
return { ok: true };
}
function deleteTrackLayout(id) {
if (state.sessions.some((session) => session.trackLayoutId === id)) {
return { ok: false, message: t("settings.track_layout_delete_blocked") };
}
state.trackLayouts = normalizeTrackLayouts(state.trackLayouts, { uid }).filter((layout) => layout.id !== id);
saveState();
return { ok: true };
}
async function refreshUpdateStatus({ render = true, force = false } = {}) {
updateCheckPending = true;
try {
@@ -4760,6 +4809,10 @@ function renderView() {
createManualServerBackup: () => createServerBackup("manual"),
createSafetyServerBackup: () => createServerBackup("pre_change"),
refreshServerBackups,
trackLayouts: state.trackLayouts,
nextTrackLayoutLabel: () => nextTrackLayoutLabel(state.trackLayouts),
addTrackLayout,
deleteTrackLayout,
updateStatus,
updateStatusLoaded,
updateCheckPending,