From fd6efe2c7e6e2d164b1e85261697b1a041a8c6dd Mon Sep 17 00:00:00 2001 From: larssand Date: Thu, 10 Sep 2026 14:17:06 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 6 ++ ROADMAP.md | 22 ++++--- package-lock.json | 4 +- package.json | 2 +- src/app.js | 71 ++++++++++++++++++--- src/misc_views.js | 2 +- src/results_archive_view.js | 15 +++-- src/runtime_services.js | 3 +- src/settings.js | 63 +++++++++++++++++++ src/timing_logic.js | 22 +++++++ src/track_layouts_logic.js | 44 +++++++++++++ tests/run.js | 1 + tests/track_layouts_logic.test.js | 100 ++++++++++++++++++++++++++++++ 13 files changed, 328 insertions(+), 27 deletions(-) create mode 100644 src/track_layouts_logic.js create mode 100644 tests/track_layouts_logic.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 968fa43..b94ee82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.9.0 - 2026-09-10 + +### Added + +- Clubs that reconfigure their track layout (common when running indoors in the same building) can now add track layouts under Settings, each with a name and a start date/time. Every session is tagged with whichever 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 lap times run on the same physical track. With no layouts added, everything behaves exactly as before. + ## 1.8.0 - 2026-09-09 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 95802f7..4e1323a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -163,15 +163,23 @@ Deferred 2026-09-09 at the user's request - the largest remaining item, on par w ## 7. Track layout changes and stat continuity -Reported 2026-09-10 by the user: an indoor club running in the same physical building often reconfigures the track layout (different wall/gate placement) while everything else - the venue, the class, the drivers - stays the same. Nothing in the app currently knows a layout change happened, so every lap-time comparison silently keeps treating before-the-change and after-the-change laps as if they were run on the same track: personal bests and the "track record" audio cue (`RACE_AUDIO_EVENT_DEFINITIONS` in `src/race_audio.js`) would announce a "record" that's actually just a different, possibly easier, layout; the training pace baseline and "stable pace" cue would judge consistency against a lap time that no longer means anything; and the Results Archive would let someone compare two sessions across a layout change as if they were comparable, with no indication they aren't. +Reported 2026-09-10 by the user: an indoor club running in the same physical building often reconfigures the track layout (different wall/gate placement) while everything else - the venue, the class, the drivers - stays the same. Nothing in the app knew a layout change happened, so lap-time comparisons silently treated before- and after-the-change laps as if they were run on the same track. -No design decided yet - open questions before this can be built: -- Scope: is a layout global to the installation, or per class (a club might run different classes on different sub-layouts at once)? -- How a change is recorded: an explicit admin action ("mark a new layout as of now", maybe with a name/date) versus something automatic - automatic detection isn't realistic, lap times alone don't reveal *why* they changed. -- What happens to stats spanning the change: hide cross-layout comparisons entirely, keep them but label which layout each figure belongs to, or reset PBs/records at the boundary while keeping session history intact for the record? -- Whether this becomes a new stored entity (a `trackLayouts` list with an id/name/startedAt) or a simpler incrementing counter on club settings that every PB/record/baseline calculation is scoped by. +Design settled 2026-09-10 through a short back-and-forth with the user: +- Scope: global to the installation (a layout change is a whole-venue event), not per class. +- Layouts are contiguous - a club changes maybe every ~4 weeks and never switches back and forth - so only a start date/time is stored per layout (`{ id, label, startedAt }`); a layout's end is implicitly the next one's start, with no separate stop field to keep in sync. The label defaults to "Layout N" but can be renamed. +- A session freezes which layout was active at the moment it *started* (not when it was created, and never recomputed later), so editing the layout list afterwards can't silently reclassify old results. +- Driver/transponder mapping needs no changes at all when a layout changes - unlike an earlier system the user described, transponder-to-driver assignment in RC RaceCore was already fully independent of track geometry. -Revisit as its own design conversation once the scope question above is answered. +Implemented 2026-09-10 (`src/track_layouts_logic.js`, new): +- [x] Settings has a new "Banlayouter" panel to add a layout (name + start date/time, validated to be after the latest existing layout's start) and list/delete existing ones - deleting is blocked if any session already references it. +- [x] Every session is stamped with `trackLayoutId` when it starts (`commitStart()` in `src/app.js`), covering every session type including Open Practice, since practice sessions feed the track-record calculation too. +- [x] The "track_record" audio cue (`getTrackRecordMs` in `src/timing_logic.js`) now only compares a live lap against other sessions tagged with the *same* layout. With no layouts configured, every session's `trackLayoutId` is `""`, so the comparison is unscoped exactly as before - the feature is fully inert until a club actually adds a layout. +- [x] The Results Archive shows which layout each session ran on, both in the session list and the detail view's header badge. + +Deliberately not touched in this pass - a real remaining gap, not an oversight: the mobile/driver training profile's own cross-session personal-best trend and pace-baseline statistics (`buildDriverTrainingStats` and related functions in `src/training_logic.js`) still compare across a driver's entire lap ledger regardless of layout. Only the live-race "personal_best" cue is already session-scoped by design (it resets every session) and needed no change; only the cross-session "track_record" cue needed - and got - the fix above. Revisit the training-ledger side as a separate, later pass if it turns out to matter in practice. + +Verified 2026-09-10: unit tests (`tests/track_layouts_logic.test.js`) cover normalization/sorting, the contiguous "layout active at time X" lookup at its boundaries, and `getTrackRecordMs`'s layout scoping (including that a layout-tagged session is never conflated with an untagged legacy one). A full headless-browser run against an isolated temp SQLite instance added two layouts through the real Settings UI (confirming the default "Layout 1" label, the custom-label case, and that a backdated second layout is rejected with a clear message), started a session and confirmed it was stamped with whichever layout was active at that moment, confirmed the Results Archive shows the correct layout in both the list and the detail header, and confirmed deleting a layout still in use is blocked while deleting an unused one succeeds. ## PDF export character encoding diff --git a/package-lock.json b/package-lock.json index 0b31e0d..123f389 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.8.0", + "version": "1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.8.0", + "version": "1.9.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index b719bdb..5f133cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.8.0", + "version": "1.9.0", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/src/app.js b/src/app.js index 9fbcf73..30de99a 100644 --- a/src/app.js +++ b/src/app.js @@ -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, diff --git a/src/misc_views.js b/src/misc_views.js index b45009d..f100a61 100644 --- a/src/misc_views.js +++ b/src/misc_views.js @@ -74,7 +74,7 @@ export function renderGuideView({ dom, t }) {
- ${renderGuidePanel("guide.archive_title", ["guide.archive_1", "guide.archive_2", "guide.archive_3", "guide.archive_4", "guide.archive_5"], "", { t })} + ${renderGuidePanel("guide.archive_title", ["guide.archive_1", "guide.archive_2", "guide.archive_3", "guide.archive_4", "guide.archive_5", "guide.archive_6"], "", { t })}
diff --git a/src/results_archive_view.js b/src/results_archive_view.js index eda72fb..3258256 100644 --- a/src/results_archive_view.js +++ b/src/results_archive_view.js @@ -1,6 +1,6 @@ import { sortableTable, sortValuesAttribute } from "./list_sorting.js"; -function buildArchiveRows(state, { getEventName, getClassName }) { +function buildArchiveRows(state, { getEventName, getClassName, getTrackLayoutLabel }) { return (state.sessions || []) .filter((session) => session.status === "finished") .map((session) => { @@ -20,6 +20,7 @@ function buildArchiveRows(state, { getEventName, getClassName }) { date: event?.date || (session.endedAt ? new Date(session.endedAt).toISOString().slice(0, 10) : ""), winnerName: winner?.displayName || winner?.driverName || "", adjustmentCount: (result?.adjustments || []).length, + trackLayoutLabel: getTrackLayoutLabel(state.trackLayouts, session.trackLayoutId), }; }) .sort((left, right) => Number(right.session.endedAt || 0) - Number(left.session.endedAt || 0)); @@ -43,7 +44,7 @@ function renderArchiveDetail(row, deps) { return `
-

${escapeHtml(row.eventName)} - ${escapeHtml(session.name)}

+

${escapeHtml(row.eventName)} - ${escapeHtml(session.name)}${row.trackLayoutLabel ? ` ${escapeHtml(row.trackLayoutLabel)}` : ""}

${event ? `` : ""} ${event ? `` : ""} @@ -116,10 +117,11 @@ export function renderResultsArchiveView(deps) { getClassName, getEventName, buildLeaderboard, getCompetitorPassings, exportRaceResultsPdf, buildRacePackagePayload, downloadJsonFile, sanitizeFilenameSegment, getArchiveFilters, setArchiveFilters, getSelectedSessionId, setSelectedSessionId, + getTrackLayoutLabel, } = deps; const filters = getArchiveFilters(); - const allRows = buildArchiveRows(state, { getEventName, getClassName }); + const allRows = buildArchiveRows(state, { getEventName, getClassName, getTrackLayoutLabel }); const filteredRows = allRows.filter((row) => matchesArchiveFilters(row, filters)); const selectedSessionId = getSelectedSessionId(); const selectedRow = selectedSessionId ? allRows.find((row) => row.session.id === selectedSessionId) : null; @@ -129,13 +131,14 @@ export function renderResultsArchiveView(deps) { const table = sortableTable( renderTable( - [t("archive.date"), t("archive.event"), t("archive.session"), t("archive.class"), t("archive.drivers"), t("archive.winner"), t("archive.penalties_short"), ""], + [t("archive.date"), t("archive.event"), t("archive.session"), t("archive.class"), t("archive.layout"), t("archive.drivers"), t("archive.winner"), t("archive.penalties_short"), ""], filteredRows.map((row) => ` - + ${escapeHtml(row.date || "-")} ${escapeHtml(row.eventName)} ${escapeHtml(row.session.name)} ${escapeHtml(row.className || "-")} + ${escapeHtml(row.trackLayoutLabel || "-")} ${row.competitors.length} ${escapeHtml(row.winnerName || "-")} ${row.adjustmentCount || "-"} @@ -144,7 +147,7 @@ export function renderResultsArchiveView(deps) { `) ), "archive-sessions", - [1, 2, 3, 5] + [1, 2, 3, 4, 6] ); dom.view.innerHTML = ` diff --git a/src/runtime_services.js b/src/runtime_services.js index 2b93efc..8e89ffc 100644 --- a/src/runtime_services.js +++ b/src/runtime_services.js @@ -101,7 +101,7 @@ export async function stopManagedAmmcHelper({ ammc, t, getBackendUrl }) { } export function applyPersistedStateHelper(persisted, deps) { - const { state, getDefaultBackendUrl, DEFAULT_LANGUAGE, DEFAULT_THEME, AVAILABLE_THEMES, normalizeDriver, normalizeCar, normalizeEvent, normalizeSession, normalizeStoredRacePreset, normalizeObsOverlaySettings, normalizeTrainingState, normalizeRaceAudioSettings = (value = {}) => value || {}, applyTheme } = deps; + const { state, getDefaultBackendUrl, DEFAULT_LANGUAGE, DEFAULT_THEME, AVAILABLE_THEMES, normalizeDriver, normalizeCar, normalizeEvent, normalizeSession, normalizeStoredRacePreset, normalizeObsOverlaySettings, normalizeTrainingState, normalizeRaceAudioSettings = (value = {}) => value || {}, normalizeTrackLayouts = (value) => (Array.isArray(value) ? value : []), applyTheme } = deps; state.classes = persisted.classes || []; state.drivers = (persisted.drivers || []).map((driver) => normalizeDriver(driver)).filter((driver) => driver.name); state.cars = (persisted.cars || []).map((car) => normalizeCar(car)).filter((car) => car.name); @@ -111,6 +111,7 @@ export function applyPersistedStateHelper(persisted, deps) { state.audioEventLog = Array.isArray(persisted.audioEventLog) ? persisted.audioEventLog.slice(0, 500) : []; state.training = normalizeTrainingState(persisted.training); state.activeSessionId = persisted.activeSessionId || null; + state.trackLayouts = normalizeTrackLayouts(persisted.trackLayouts); state.settings = { wsUrl: persisted.settings?.wsUrl || state.settings.wsUrl || "ws://127.0.0.1:9000", splitDecoderEnabled: persisted.settings?.splitDecoderEnabled === true, diff --git a/src/settings.js b/src/settings.js index c8a47e2..64249e7 100644 --- a/src/settings.js +++ b/src/settings.js @@ -59,6 +59,10 @@ export function renderSettings(deps) { createManualServerBackup, createSafetyServerBackup, refreshServerBackups, + trackLayouts, + nextTrackLayoutLabel, + addTrackLayout, + deleteTrackLayout, updateStatus, updateStatusLoaded, updateCheckPending, @@ -343,6 +347,33 @@ export function renderSettings(deps) {
+
+

${t("settings.track_layouts")}

+
+

${t("settings.track_layouts_hint")}

+
+ + +
+
+
+ ${(trackLayouts || []).length + ? trackLayouts.slice().reverse().map((layout) => ` +
+
${escapeHtml(layout.label || t("settings.track_layout_unnamed"))}${escapeHtml(new Date(layout.startedAt).toLocaleString())}
+ + +
`).join("") + : `

${t("settings.track_layout_none")}

`} +
+
+
+

${t("overlay.obs_config")}

@@ -779,6 +810,38 @@ Erik Eriksson;654321;4WD;Associated;; }); document.getElementById("refreshServerBackups")?.addEventListener("click", () => refreshServerBackups()); + document.getElementById("trackLayoutForm")?.addEventListener("submit", (event) => { + event.preventDefault(); + const form = event.currentTarget; + if (!(form instanceof HTMLFormElement)) return; + const label = String(form.elements.namedItem("label")?.value || "").trim(); + const startedAtRaw = String(form.elements.namedItem("startedAt")?.value || ""); + const startedAtMs = startedAtRaw ? new Date(startedAtRaw).getTime() : Date.now(); + if (!Number.isFinite(startedAtMs)) { + alert(t("settings.track_layout_invalid_date")); + return; + } + const result = addTrackLayout(label, startedAtMs); + if (!result.ok) { + alert(result.message); + return; + } + renderView(); + }); + document.querySelectorAll("[data-delete-track-layout]").forEach((button) => { + button.addEventListener("click", () => { + const id = button.getAttribute("data-delete-track-layout"); + const layout = (trackLayouts || []).find((item) => item.id === id); + if (!window.confirm(t("settings.track_layout_delete_confirm", { label: layout?.label || t("settings.track_layout_unnamed") }))) return; + const result = deleteTrackLayout(id); + if (!result.ok) { + alert(result.message); + return; + } + renderView(); + }); + }); + document.getElementById("settingsCheckUpdate")?.addEventListener("click", async () => { setUpdateNotice(""); await refreshUpdateStatus({ render: false, force: true }); diff --git a/src/timing_logic.js b/src/timing_logic.js index aacf0f7..d6a804b 100644 --- a/src/timing_logic.js +++ b/src/timing_logic.js @@ -133,6 +133,28 @@ export function ensureSessionResult(sessionId, { state }) { +// The all-time fastest lap among every other session in the same class, used to decide +// whether a live lap deserves the "track_record" audio cue. Scoped to sessions run on the +// same track layout as `session` (see track_layouts_logic.js): once a club starts using +// layouts, a lap from a previous, different layout can no longer "beat" today's record. +// Sessions with no trackLayoutId (i.e. every session recorded before this feature existed, +// or an installation that has never configured a layout) compare only against each other - +// this keeps the feature fully inert until a club actually adds a layout. +export function getTrackRecordMs(session, { state }) { + 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 + && String(item.trackLayoutId || "") === String(session?.trackLayoutId || "") + && 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; +} + + + export function buildLeaderboard(session, { ensureSessionResult, getSessionTargetMs, getCompetitorPassings, isCountedPassing, getCompetitorElapsedMs, getCompetitorSeedMetric, getSessionLapWindow, getPassingValidationLabel, formatSeedMetric, formatRaceClock, formatLeaderboardGap, formatLapDelta, formatLap, t }) { const result = ensureSessionResult(session.id); const sessionType = String(session.type || "").toLowerCase(); diff --git a/src/track_layouts_logic.js b/src/track_layouts_logic.js new file mode 100644 index 0000000..568d37b --- /dev/null +++ b/src/track_layouts_logic.js @@ -0,0 +1,44 @@ +// A club that reconfigures its track (common indoors, in the same building) needs a way +// to say "everything from this point on is a different layout" so lap-time comparisons +// don't silently mix incomparable tracks. Layouts are assumed contiguous - a club changes +// layout roughly every few weeks and never switches back and forth - so only a start +// date/time is stored per layout; a layout's end is implicitly the next one's start (see +// ROADMAP.md "Track layout changes and stat continuity" for the fuller reasoning). +export function normalizeTrackLayout(layout, { uid } = {}) { + return { + id: String(layout?.id || (uid ? uid("layout") : "")), + label: String(layout?.label || "").trim(), + startedAt: Number.isFinite(Number(layout?.startedAt)) ? Number(layout.startedAt) : Date.now(), + }; +} + +export function normalizeTrackLayouts(layouts, { uid } = {}) { + const list = (Array.isArray(layouts) ? layouts : []) + .map((layout) => normalizeTrackLayout(layout, { uid })) + .filter((layout) => layout.id); + return list.slice().sort((a, b) => a.startedAt - b.startedAt); +} + +// The layout active at `atMs` (default: now) - the latest one whose startedAt has already +// passed. Returns "" if no layout has been configured yet (the feature is simply unused, +// and every comparison that scopes by this id degrades back to today's unscoped behavior). +export function getTrackLayoutIdAt(layouts, atMs = Date.now()) { + let current = ""; + for (const layout of normalizeTrackLayouts(layouts)) { + if (layout.startedAt > atMs) break; + current = layout.id; + } + return current; +} + +export function getTrackLayoutLabel(layouts, layoutId) { + if (!layoutId) return ""; + const layout = (Array.isArray(layouts) ? layouts : []).find((item) => item.id === layoutId); + return layout?.label || ""; +} + +// The default label offered when adding a layout without typing a custom name, numbered +// by how many layouts already exist (1-based, so the very first is "Layout 1"). +export function nextTrackLayoutLabel(layouts) { + return `Layout ${(Array.isArray(layouts) ? layouts.length : 0) + 1}`; +} diff --git a/tests/run.js b/tests/run.js index 16a652a..1b9f018 100644 --- a/tests/run.js +++ b/tests/run.js @@ -1,6 +1,7 @@ import "./training_logic.test.js"; import "./public_training_profile.test.js"; import "./race_timing_logic.test.js"; +import "./track_layouts_logic.test.js"; import "./race_day_logic.test.js"; import "./backup_service.test.js"; import "./judging_logic.test.js"; diff --git a/tests/track_layouts_logic.test.js b/tests/track_layouts_logic.test.js new file mode 100644 index 0000000..a062dbd --- /dev/null +++ b/tests/track_layouts_logic.test.js @@ -0,0 +1,100 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { normalizeTrackLayout, normalizeTrackLayouts, getTrackLayoutIdAt, getTrackLayoutLabel, nextTrackLayoutLabel } 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", () => { + const withoutId = normalizeTrackLayout({ label: "Layout 1", startedAt: 1000 }, { uid: (prefix) => `${prefix}_generated` }); + assert.equal(withoutId.id, "layout_generated"); + assert.equal(withoutId.startedAt, 1000); + + const malformed = normalizeTrackLayout({ id: "x", label: " Layout 2 ", startedAt: "not-a-number" }); + assert.equal(malformed.label, "Layout 2"); + assert.ok(Number.isFinite(malformed.startedAt), "falls back to Date.now() rather than NaN"); +}); + +test("normalizeTrackLayouts sorts chronologically and drops entries with no id", () => { + const layouts = normalizeTrackLayouts([ + { id: "b", label: "Later", startedAt: 2000 }, + { id: "a", label: "Earlier", startedAt: 1000 }, + { label: "" }, + ], { uid: () => "" }); + assert.deepEqual(layouts.map((layout) => layout.id), ["a", "b"]); +}); + +test("getTrackLayoutIdAt returns the latest layout that has already started, treating layouts as contiguous", () => { + const layouts = [ + { id: "layout_1", label: "Layout 1", startedAt: 1000 }, + { id: "layout_2", label: "Layout 2", startedAt: 2000 }, + { id: "layout_3", label: "Layout 3", startedAt: 3000 }, + ]; + assert.equal(getTrackLayoutIdAt(layouts, 500), "", "before any layout existed"); + assert.equal(getTrackLayoutIdAt(layouts, 1000), "layout_1", "exactly at a layout's start"); + assert.equal(getTrackLayoutIdAt(layouts, 2500), "layout_2", "between two layouts belongs to the earlier one"); + assert.equal(getTrackLayoutIdAt(layouts, 9999), "layout_3", "after the last change, still the latest layout"); +}); + +test("getTrackLayoutIdAt and getTrackLayoutLabel are inert with no layouts configured", () => { + assert.equal(getTrackLayoutIdAt([], Date.now()), ""); + assert.equal(getTrackLayoutLabel([], ""), ""); + assert.equal(getTrackLayoutLabel([{ id: "layout_1", label: "Layout 1" }], ""), "", "an empty id never matches a real layout"); +}); + +test("getTrackLayoutLabel resolves a known id and nextTrackLayoutLabel numbers sequentially", () => { + const layouts = [{ id: "layout_1", label: "Layout 1", startedAt: 1000 }]; + assert.equal(getTrackLayoutLabel(layouts, "layout_1"), "Layout 1"); + assert.equal(getTrackLayoutLabel(layouts, "missing"), ""); + assert.equal(nextTrackLayoutLabel([]), "Layout 1"); + assert.equal(nextTrackLayoutLabel(layouts), "Layout 2"); +}); + +function createTrackRecordState({ sessions, results }) { + return { + events: [{ id: "event_1", classId: "class_1" }], + sessions, + resultsBySession: results, + }; +} + +test("getTrackRecordMs finds the fastest lap among other sessions in the same class when no layout is set", () => { + const state = createTrackRecordState({ + sessions: [ + { id: "session_1", eventId: "event_1" }, + { id: "session_2", eventId: "event_1" }, + ], + results: { + session_2: { competitors: { a: { bestLapMs: 12345 } } }, + }, + }); + assert.equal(getTrackRecordMs(state.sessions[0], { state }), 12345); +}); + +test("getTrackRecordMs only compares sessions run on the same track layout once layouts are in use", () => { + const state = createTrackRecordState({ + sessions: [ + { id: "current", eventId: "event_1", trackLayoutId: "layout_2" }, + { id: "same_layout", eventId: "event_1", trackLayoutId: "layout_2" }, + { id: "old_layout", eventId: "event_1", trackLayoutId: "layout_1" }, + ], + results: { + same_layout: { competitors: { a: { bestLapMs: 15000 } } }, + old_layout: { competitors: { a: { bestLapMs: 9000 } } }, + }, + }); + // The 9000ms lap belongs to a different, earlier layout - it must not count as the record + // for a lap set on the current layout, even though it is numerically faster. + assert.equal(getTrackRecordMs(state.sessions[0], { state }), 15000); +}); + +test("getTrackRecordMs does not conflate a layout-tagged session with an untagged legacy one", () => { + const state = createTrackRecordState({ + sessions: [ + { id: "current", eventId: "event_1", trackLayoutId: "layout_1" }, + { id: "legacy", eventId: "event_1" }, + ], + results: { + legacy: { competitors: { a: { bestLapMs: 5000 } } }, + }, + }); + assert.equal(getTrackRecordMs(state.sessions[0], { state }), null, "the legacy session (no trackLayoutId) is a different bucket from layout_1"); +});