diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a8b3f3..7ed3775 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.35.1 - 2026-10-10 + +### Fixed + +- A direct link to a driver's Training stats (`?view=training&driver=...`) could lose the driver and show the club overview. The page first renders from the browser's local copy, and a driver not in that copy yet was treated as unknown and the selection cleared. The selection is now kept until the first load from the server has finished, and then the driver opens. + ## 1.35.0 - 2026-10-10 ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index 2610f63..354befd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -829,7 +829,11 @@ Verified 2026-10-10: - `tests/browser_training_source.js` picks a driver with practice and qualifying laps and checks that practice shows stints and no race marks, and that Races shows the race-session table with "🏁 TS Winter #3 TS 2WD · Kval 2 · TS Winter Cup", 3 laps and no stint table. - 66 browser scenarios pass. -Noticed while testing, not fixed: opening Training stats with `?driver=` in the URL can lose the driver if the page renders before the backend data has loaded. The selection is reset because the driver isn't known yet. Choosing the driver on the page works. +Noticed while testing: opening Training stats with `?driver=` in the URL could lose the driver if the page rendered before the backend data had loaded. Fixed in 1.35.1: +- `initialLoadDone` in `app.js` is set when the first `hydrateFromBackend` round settles. +- Until then, the training view shows the overview without clearing the selection or the URL. +- The re-render after the load opens the driver. +- The browser test opens the link first, before the page has ever seen the driver. It fails with the fix disabled and passes with it. 261 unit and 67 browser scenarios pass. ## Documentation maintenance diff --git a/package-lock.json b/package-lock.json index 0e1ff8b..a0d52c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.35.0", + "version": "1.35.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.35.0", + "version": "1.35.1", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index 27fea9d..a9d20f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.35.0", + "version": "1.35.1", "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 70c15e4..bdf8384 100644 --- a/src/app.js +++ b/src/app.js @@ -578,7 +578,7 @@ const renderArchive = () => renderResultsArchiveView({ getSelectedSessionId: () => archiveSelectedSessionId, setSelectedSessionId: (value) => { archiveSelectedSessionId = value; }, getTrackLayoutLabel, }); -const renderTraining = () => renderTrainingView({ addTrackLayout, 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 renderTraining = () => renderTrainingView({ addTrackLayout, 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, isInitialLoadDone: () => initialLoadDone, getSelectedDriverId: () => selectedTrainingDriverId, setSelectedDriverId: (value) => { selectedTrainingDriverId = value; updateViewUrl("training", value, selectedTrainingClassId); }, getSelectedClassId: () => selectedTrainingClassId, setSelectedClassId: (value) => { selectedTrainingClassId = value; updateViewUrl("training", selectedTrainingDriverId, value); } }); // Judge corrections are saved as usual and also pushed as this session's result, so the // server's "hot results" protection (live passings in the last 15 s) can't hold them back. const saveCorrectionFor = (session) => () => { @@ -4313,6 +4313,10 @@ let updateStatusLoaded = false; let updateCheckPending = false; let updateNotice = ""; let updateNoticeIsError = false; +// Set once the first load from the server has finished (or failed): until then the state is +// only this browser's local copy, so a driver from a link (?driver=) may simply not be known +// yet - views must not treat that as "doesn't exist" and clear the selection. +let initialLoadDone = false; const backend = { available: false, lastSyncAt: null, @@ -4401,6 +4405,7 @@ async function init() { setInterval(() => refreshUpdateStatus({ render: currentView === "settings" }), 20 * 60 * 1000); Promise.allSettled([hydrateFromBackend(), loadAmmcConfigFromBackend(), refreshServerBackups({ render: false }), refreshUpdateStatus({ render: false })]).finally(() => { + initialLoadDone = true; applyTheme(); renderNav(); renderViewFromBackground(); diff --git a/src/training_views.js b/src/training_views.js index d48735c..a50d6cd 100644 --- a/src/training_views.js +++ b/src/training_views.js @@ -148,6 +148,7 @@ export function renderTrainingView(deps) { setSelectedDriverId, getSelectedClassId, setSelectedClassId, + isInitialLoadDone = () => true, } = deps; state.training = normalizeTrainingState(state.training); @@ -156,7 +157,12 @@ export function renderTrainingView(deps) { .sort((left, right) => String(left.representative?.name || "").localeCompare(String(right.representative?.name || ""))); let selectedProfileId = String(getSelectedDriverId() || ""); let selectedProfile = resolveTrainingProfile(profiles, selectedProfileId); - if (selectedProfileId && !selectedProfile) { + if (selectedProfileId && !selectedProfile && !isInitialLoadDone()) { + // A driver from a link (?driver=) that isn't known yet because the server data is still + // loading: show the overview for now but keep the selection, so the re-render after the + // load opens that driver instead of losing it. + selectedProfileId = ""; + } else if (selectedProfileId && !selectedProfile) { selectedProfileId = ""; setSelectedDriverId(""); setSelectedClassId(""); diff --git a/tests/browser_training_source.js b/tests/browser_training_source.js index 34dc1ac..b41005e 100644 --- a/tests/browser_training_source.js +++ b/tests/browser_training_source.js @@ -26,6 +26,14 @@ export async function runTrainingSourceSmoke({ evaluate, navigate, savedState, w }); try { + // A direct link to the driver: the page first renders from this browser's local copy, which + // doesn't know the driver (created on the server) - the selection must survive until the + // server data has loaded, then open that driver. + await evaluate(`location.href = ${JSON.stringify(`${base}/?view=training&driver=ts_profile&class=ts_class`)}`); + await waitFor(async () => (await evaluate(`document.getElementById("training-driver")?.value || ""`)) === "ts_profile", "the linked driver opens once the data has loaded"); + assert.match(await evaluate(`location.search`), /driver=ts_profile/, "the link is kept in the address"); + console.log("PASS a direct link to a driver's training stats opens that driver even before the data has loaded"); + await navigate("training", "view"); // The overview has its own driver picker; the driver page has another one. await waitFor(async () => Boolean(await evaluate(`[...document.querySelectorAll("#training-overview-driver option, #training-driver option")].some((option) => option.value === "ts_profile")`)), "driver listed");