From 3cc4fa0845aa2bd47b9e2a4531f48a52fce89069 Mon Sep 17 00:00:00 2001 From: larssand Date: Sat, 3 Oct 2026 23:13:58 +0200 Subject: [PATCH] fix(training): keep practice laps in training stats after Reset data Resetting a running Open Practice deleted its results, the Timing view recreated an empty result at once, and the next save rebuilt that session's training laps from nothing - wiping every earlier lap from the drivers' stats. Reset now syncs first and records resultsResetAt; laps from before it are kept. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +++ ROADMAP.md | 17 ++++++++ package-lock.json | 4 +- package.json | 2 +- src/app.js | 6 +-- src/timing_views.js | 5 +++ src/training_logic.js | 9 ++++- tests/browser_practice_reset.js | 69 +++++++++++++++++++++++++++++++++ tests/browser_smoke.js | 2 + tests/training_logic.test.js | 28 +++++++++++++ 10 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 tests/browser_practice_reset.js diff --git a/CHANGELOG.md b/CHANGELOG.md index d076277..dd03d42 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.29.2 - 2026-10-03 + +### Fixed + +- "Reset data" on Timing no longer deletes a session's laps from the training stats. The laps survived the reset itself, but the Timing view recreates an empty result for the active session straight away. The next save then rebuilt that session's training laps from the now-empty results, so a running Open Practice cleared for a new day lost everyone's earlier laps from their stats. Reset now records when it happened (`resultsResetAt`) and syncs the latest laps first, so every lap from before it stays in the training stats while Timing starts empty. The confirm dialog says so. + ## 1.29.1 - 2026-10-03 ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index 6d03c42..de356f5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -668,6 +668,23 @@ Verified 2026-10-03: - 243 unit tests pass. New tests cover: a personal best survives a new stint; after a layout switch the old 18 s no longer counts and a personal best is called on the new layout; passings carry their layout; the record includes the current session; judge recalculation is per layout. - The 51 browser scenarios pass. A new one checks that "New layout" during a running open practice moves it and resets the best lap while keeping the laps. +## Reset an Open Practice without losing training stats + +Asked by the user 2026-10-03: if a running Open Practice is reset to show only today's laps, do the drivers lose those laps from their training stats? Checked first: +- `syncTrainingLedger` rebuilds every session that has results, and keeps ledger laps only for sessions without results. +- "Reset data" deleted the session's results, but the Timing view's `ensureSessionResult` recreates an empty result for the active session at once. The next save therefore dropped all of that session's laps from the training stats. +- A scratch script against `syncTrainingLedger` confirmed it: 3 laps → reset → 3 → first new lap → only the new one. + +- [x] Reset sets `session.resultsResetAt` and calls `syncTrainingLedger` before clearing, so laps driven since the last save are captured. Ledger laps from before `resultsResetAt` are kept, even though the session has results again. +- [x] A judge invalidating a lap after the reset still removes it from the stats: only laps from before the reset are pinned. +- [x] The confirm text says the laps stay in the training stats. + +Verified 2026-10-03: +- 244 unit tests pass. A new test covers reset, the next-day lap, and that invalidation still applies after a reset. +- `tests/browser_practice_reset.js` runs a running Open Practice with three laps through the real Reset button. It checks the confirm text, that Timing is cleared, that the practice is still running, that the three laps are still in the stats, and that a real decoder passing afterwards adds the fourth lap without removing the three. +- With the old `training_logic.js` put back temporarily, that browser test fails ("laps kept right after the reset"), so it catches the bug. +- 52 browser scenarios pass. + ## Documentation maintenance - [x] Update the architecture's module/dependency inventory as modules change. diff --git a/package-lock.json b/package-lock.json index b60cb3f..2dd286d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.29.1", + "version": "1.29.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.29.1", + "version": "1.29.2", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index c2e95d6..acb20ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.29.1", + "version": "1.29.2", "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 8b4490e..cb1d8c1 100644 --- a/src/app.js +++ b/src/app.js @@ -463,7 +463,7 @@ const refreshTimingDynamics = () => { node.textContent = clock; }); }; -const renderTiming = () => renderTimingView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatRaceClock, renderTable, uid, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings, getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid, renderSessionChannelAnalysis, connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, startOperationalSession, finishOperationalSession, cancelRaceStart, triggerRaceAudio, getStartLightState: () => raceAudioEngine.getStartLightState(), saveState, updateHeaderState, renderView, normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey: () => selectedLeaderboardKey, setSelectedLeaderboardKey: (value) => { selectedLeaderboardKey = value; }, getQuickAddDraft: () => quickAddDraft, setQuickAddDraft: (value) => { quickAddDraft = value; }, getPassingValidationLabel, isCountedPassing, getCompetitorPassings, getManualCorrectionSummary, formatTeamActiveMemberLabel, lastFinishAnnouncementSessionId: () => lastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId: (value) => { lastFinishAnnouncementSessionId = value; }, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, overlayEvents: () => overlayEvents, setOverlayEvents: (value) => { overlayEvents = value; }, sessionReplay }); +const renderTiming = () => renderTimingView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatRaceClock, renderTable, uid, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings, getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid, renderSessionChannelAnalysis, connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, startOperationalSession, finishOperationalSession, cancelRaceStart, triggerRaceAudio, getStartLightState: () => raceAudioEngine.getStartLightState(), saveState, updateHeaderState, renderView, normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey: () => selectedLeaderboardKey, setSelectedLeaderboardKey: (value) => { selectedLeaderboardKey = value; }, getQuickAddDraft: () => quickAddDraft, setQuickAddDraft: (value) => { quickAddDraft = value; }, getPassingValidationLabel, isCountedPassing, getCompetitorPassings, getManualCorrectionSummary, formatTeamActiveMemberLabel, lastFinishAnnouncementSessionId: () => lastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId: (value) => { lastFinishAnnouncementSessionId = value; }, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, overlayEvents: () => overlayEvents, setOverlayEvents: (value) => { overlayEvents = value; }, sessionReplay, syncTrainingLedger }); const renderJudging = () => renderJudgingView({ state, dom, t, escapeHtml, formatLap, renderTable, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTypeLabel, getJudgeFilteredRows, getJudgeFilteredLog, getCompetitorPassings, isCountedPassing, getPassingValidationLabel, undoJudgingAdjustment, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, renderView, getSelectedJudgeKey: () => selectedJudgeKey, setSelectedJudgeKey: (value) => { selectedJudgeKey = value; }, getJudgingCompetitorFilter: () => judgingCompetitorFilter, setJudgingCompetitorFilter: (value) => { judgingCompetitorFilter = value; }, getJudgingLogFilter: () => judgingLogFilter, setJudgingLogFilter: (value) => { judgingLogFilter = value; } }); const normalizeTrainingState = (training) => normalizeTrainingStateLogic(training, { uid }); const syncTrainingLedger = () => { @@ -1621,7 +1621,7 @@ const TRANSLATIONS = { "judging.undo_done": "Senaste manuella åtgärden ångrades.", "judging.no_undo": "Ingen åtgärd att ångra.", "timing.total_time": "Total tid", - "timing.clear_confirm": "Rensa all tiddata för denna session?", + "timing.clear_confirm": "Rensa all tiddata för denna session? Varven finns kvar i träningsstatistiken.", "timing.prompt_transponder": "Transponder", "timing.first_crossing_start": "Första crossing satte personlig start", "timing.seeding_mode": "Seedning", @@ -3271,7 +3271,7 @@ const TRANSLATIONS = { "judging.undo_done": "The latest manual action was undone.", "judging.no_undo": "No action to undo.", "timing.total_time": "Total time", - "timing.clear_confirm": "Clear all timing data for this session?", + "timing.clear_confirm": "Clear all timing data for this session? The laps stay in the training stats.", "timing.prompt_transponder": "Transponder", "timing.first_crossing_start": "First crossing set personal start", "timing.seeding_mode": "Seeding", diff --git a/src/timing_views.js b/src/timing_views.js index e8fd6f7..d669563 100644 --- a/src/timing_views.js +++ b/src/timing_views.js @@ -380,6 +380,7 @@ export function renderTimingView(deps) { getQuickAddDraft, setQuickAddDraft, getPassingValidationLabel, isCountedPassing, getCompetitorPassings, getManualCorrectionSummary, formatTeamActiveMemberLabel, formatRaceClock, renderSessionChannelAnalysis, setLastFinishAnnouncementSessionId, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, overlayEvents, setOverlayEvents, sessionReplay, + syncTrainingLedger = () => {}, } = deps; const helperDeps = { @@ -787,6 +788,10 @@ export function renderTimingView(deps) { if (!confirm(t("timing.clear_confirm"))) { return; } + // Every lap so far goes into the training stats first; resultsResetAt then keeps them + // there although they leave this session's results (see syncTrainingLedger). + syncTrainingLedger(); + session.resultsResetAt = Date.now(); delete state.resultsBySession[session.id]; session.followUpStartedAt = null; setLastFinishAnnouncementSessionId(null); diff --git a/src/training_logic.js b/src/training_logic.js index cce0e1f..2ad1afc 100644 --- a/src/training_logic.js +++ b/src/training_logic.js @@ -208,8 +208,15 @@ export function syncTrainingLedger(training, state, { uid }) { const normalized = normalizeTrainingState(training, { uid }); const results = state?.resultsBySession && typeof state.resultsBySession === "object" ? state.resultsBySession : {}; const currentSessionIds = new Set(Object.keys(results)); - const retained = normalized.ledger.filter((entry) => !currentSessionIds.has(entry.sessionId)); const sessions = new Map((Array.isArray(state?.sessions) ? state.sessions : []).map((session) => [String(session.id), session])); + // A session's laps are rebuilt from its results - except laps from before a "Reset data" + // (session.resultsResetAt): they're gone from the results but stay training history, so an + // Open Practice can be cleared for a new day without losing anyone's stats. + const retained = normalized.ledger.filter((entry) => { + if (!currentSessionIds.has(entry.sessionId)) return true; + const resetAt = Number(sessions.get(entry.sessionId)?.resultsResetAt || 0); + return resetAt > 0 && entry.timestamp < resetAt; + }); const events = new Map((Array.isArray(state?.events) ? state.events : []).map((event) => [String(event.id), event])); const current = []; diff --git a/tests/browser_practice_reset.js b/tests/browser_practice_reset.js new file mode 100644 index 0000000..8a641f5 --- /dev/null +++ b/tests/browser_practice_reset.js @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { startFakeDecoder } from "./browser_fake_decoder.js"; + +// "Reset data" on Timing for an Open Practice that keeps running (cleared for a new day): +// the timing view starts empty, but every lap from before stays in the training stats - also +// after the next lap brings the session back into the results, which used to wipe them. +export async function runPracticeResetSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs }) { + const patchState = async (patch) => { + const current = await savedState(); + Object.assign(current, patch); + const response = await fetch(base + "/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(current) }); + assert.equal(response.ok, true); + }; + const driverLaps = async () => ((await savedState()).training?.ledger || []).filter((lap) => lap.driverId === "pr_driver").map((lap) => lap.lapMs); + + const dialogsAtStart = dialogs.length; + const decoder = await startFakeDecoder(); + const seeded = await savedState(); + const event = { id: "pr_event", name: "PR Practice", classId: "pr_class", mode: "race", raceConfig: { practiceOnly: true } }; + const startedAt = Date.now() - 600000; + const practice = { id: "pr_practice", eventId: event.id, name: "PR Open Practice", type: "open_practice", durationMin: 600, status: "running", startedAt }; + const lap = (lapMs, timestamp) => ({ driverId: "pr_driver", driverName: "PR Driver", competitorKey: "pr_driver", transponder: "8220001", lapMs, timestamp, validLap: true }); + await patchState({ + classes: [...seeded.classes, { id: "pr_class", name: "PR Class" }], + drivers: [...seeded.drivers, { id: "pr_driver", name: "PR Driver", classId: "pr_class", transponder: "8220001" }], + events: [...seeded.events, event], + sessions: [...seeded.sessions.map((session) => (["running", "paused"].includes(session.status) ? { ...session, status: "finished", endedAt: Date.now() } : session)), practice], + resultsBySession: { + ...seeded.resultsBySession, + [practice.id]: { + passings: [lap(20000, startedAt + 20000), lap(19500, startedAt + 39500), lap(19000, startedAt + 58500)], + competitors: { pr_driver: { key: "pr_driver", driverId: "pr_driver", driverName: "PR Driver", transponder: "8220001", laps: 3, lastLapMs: 19000, bestLapMs: 19000, startTimestamp: startedAt, lastTimestamp: startedAt + 58500 } }, + }, + }, + activeSessionId: practice.id, + settings: { ...seeded.settings, autoReconnect: true, wsUrl: `ws://127.0.0.1:${decoder.port}` }, + }); + + try { + await navigate("timing", "resetSession"); + // The laps were seeded straight into the results and haven't been synced into the training + // stats yet - Reset itself must sync them before clearing (the case of laps driven since + // the last save). + const before = dialogs.length; + await evaluate(`document.getElementById("resetSession").click()`); + await waitFor(async () => dialogs.slice(before).some((dialog) => dialog.type === "confirm" && /training|tränings/i.test(dialog.message)), "confirm says the laps stay in the training stats"); + // The Timing view recreates an empty result for the active session straight away - which + // is why the old code lost the laps already on the next save, not only on the next lap. + await waitFor(async () => ((await savedState()).resultsBySession?.[practice.id]?.passings || []).length === 0, "timing data cleared"); + assert.deepEqual((await driverLaps()).sort(), [19000, 19500, 20000], "laps kept right after the reset"); + assert.equal((await savedState()).sessions.find((session) => session.id === practice.id).status, "running", "the practice keeps running"); + + await waitFor(async () => evaluate('document.getElementById("connectionBadge")?.classList.contains("badge-online")'), "decoder connected"); + decoder.sendPassing("8220001"); + await waitFor(async () => ((await savedState()).resultsBySession?.[practice.id]?.passings || []).length >= 1, "a new lap after the reset"); + await waitFor(async () => (await driverLaps()).length === 4, "the new lap joins the training stats"); + assert.deepEqual((await driverLaps()).slice(0, 3), [20000, 19500, 19000], "the laps from before the reset are still there"); + console.log("PASS resetting a running Open Practice clears timing but keeps every lap in the training stats"); + } finally { + dialogs.splice(dialogsAtStart); + const current = await savedState(); + await patchState({ + settings: { ...current.settings, autoReconnect: false }, + sessions: current.sessions.map((session) => (session.id === practice.id ? { ...session, status: "finished", endedAt: Date.now() } : session)), + activeSessionId: "", + }); + await decoder.close(); + } +} diff --git a/tests/browser_smoke.js b/tests/browser_smoke.js index f6e4ce1..719c0ea 100644 --- a/tests/browser_smoke.js +++ b/tests/browser_smoke.js @@ -11,6 +11,7 @@ import { runAuthSmoke } from "./browser_auth.js"; import { runTtsSettingsSmoke } from "./browser_tts_settings.js"; import { runTrackLayoutsSmoke } from "./browser_track_layouts.js"; import { runPrologueSmoke } from "./browser_prologue.js"; +import { runPracticeResetSmoke } from "./browser_practice_reset.js"; import { runObsPopupSmoke } from "./browser_obs_popup.js"; import { runDecoderReconnectSmoke } from "./browser_decoder_reconnect.js"; import assert from "node:assert/strict"; @@ -152,6 +153,7 @@ try { await runTtsSettingsSmoke({ evaluate, navigate, savedState, waitFor }); await runTrackLayoutsSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs }); await runPrologueSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs }); + await runPracticeResetSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs }); await runRaceDaySmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, dialogs }); await runRaceDayStartSequencesSmoke({ evaluate, navigate, waitFor, pause, base, dialogs }); await runObsPopupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, debugPort, dialogs, realClick }); diff --git a/tests/training_logic.test.js b/tests/training_logic.test.js index 039852f..d558470 100644 --- a/tests/training_logic.test.js +++ b/tests/training_logic.test.js @@ -151,6 +151,34 @@ test("ledger sync retains archived laps and rebuilds current session laps", () = assert.equal(synced.markers.length, 1); }); +test("resetting a running Open Practice keeps its earlier laps in the training stats", () => { + const lap = (lapMs, timestamp) => ({ driverId: "driver_1", competitorKey: "driver_1", lapMs, timestamp, validLap: true }); + const state = { + sessions: [{ id: "op", name: "Open Practice", type: "open_practice", eventId: "event_1" }], + events: [{ id: "event_1", name: "Club" }], + resultsBySession: { op: { passings: [lap(20000, 1000), lap(19500, 2000), lap(19000, 3000)] } }, + }; + let training = syncTrainingLedger({}, state, { uid: createUid() }); + assert.equal(training.ledger.length, 3); + + // "Reset data" on Timing: the session keeps running, its results are cleared. + state.sessions[0].resultsResetAt = 5000; + delete state.resultsBySession.op; + training = syncTrainingLedger(training, state, { uid: createUid() }); + assert.equal(training.ledger.length, 3, "nothing is lost right after the reset"); + + // The next day's first lap brings the session back into the results. This used to wipe + // every lap from before the reset out of the training stats. + state.resultsBySession.op = { passings: [lap(18500, 90000)] }; + training = syncTrainingLedger(training, state, { uid: createUid() }); + assert.deepEqual(training.ledger.map((entry) => entry.lapMs), [20000, 19500, 19000, 18500]); + + // A lap removed from the current results (judge invalidated it) still leaves the stats. + state.resultsBySession.op.passings[0].validLap = false; + training = syncTrainingLedger(training, state, { uid: createUid() }); + assert.deepEqual(training.ledger.map((entry) => entry.lapMs), [20000, 19500, 19000]); +}); + test("driver statistics remain isolated and aggregate days and sessions", () => { const firstDay = new Date(2026, 0, 10, 12, 0, 0).getTime(); const secondDay = new Date(2026, 0, 11, 12, 0, 0).getTime();