fix(training): keep a linked driver until the first server load finishes

A ?driver= link to Training stats was cleared when the page rendered
from the local copy before the server data arrived. The selection now
survives until the first load settles, then the driver opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-10-10 20:21:19 +02:00
parent af79b610db
commit efa082c2b5
7 changed files with 35 additions and 6 deletions

View File

@@ -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

View File

@@ -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

4
package-lock.json generated
View File

@@ -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",

View File

@@ -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": {

View File

@@ -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();

View File

@@ -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("");

View File

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