From 8601954ea24a1051b037a7bd899c16b93ab51179 Mon Sep 17 00:00:00 2001 From: larssand Date: Wed, 7 Oct 2026 19:53:23 +0200 Subject: [PATCH] feat(practice): flag suspiciously short laps and remove any lap in one click Practice laps under 85% of the driver's own median (a likely shortcut) are flagged: counted, but never a best lap, record or training stat until approved. Timing and Race Day get a one-click Approve/Remove panel, overlays show a warning mark, and any lap can be removed or restored from Timing and Judging with undo. Also sends judge corrections straight to the server so its hot-results protection can no longer hold back a correction made right after a passing. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 14 ++++ ROADMAP.md | 31 +++++++ docs/javascript-architecture.md | 9 +++ package-lock.json | 4 +- package.json | 2 +- server.js | 67 +++++++++------ src/app.js | 53 ++++++++++-- src/decoder_runtime.js | 16 +++- src/judging_logic.js | 42 +++++++++- src/overlays.js | 6 +- src/race_day_view.js | 6 +- src/styles.css | 43 ++++++++++ src/suspect_laps_logic.js | 45 +++++++++++ src/timing_logic.js | 2 + src/timing_views.js | 69 +++++++++++++--- src/training_logic.js | 3 +- tests/browser_fake_decoder.js | 5 +- tests/browser_smoke.js | 2 + tests/browser_suspect_laps.js | 85 ++++++++++++++++++++ tests/decoder_runtime.test.js | 27 +++++++ tests/judging_logic.test.js | 29 +++++++ tests/run.js | 1 + tests/server_persistence.integration.test.js | 11 +++ tests/suspect_laps_logic.test.js | 44 ++++++++++ 24 files changed, 556 insertions(+), 60 deletions(-) create mode 100644 src/suspect_laps_logic.js create mode 100644 tests/browser_suspect_laps.js create mode 100644 tests/suspect_laps_logic.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index dd03d42..66cd32d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.30.0 - 2026-10-07 + +### Added + +- Suspiciously short practice laps are flagged. In practice, a lap under 85 % of the driver's own median on the current layout (after 5 normal laps) is marked ⚠, e.g. one lap of 15 s from someone who usually laps 20-25 s. It still counts as a lap, but not as a best lap, personal best, session fastest, track record or in the training stats until someone approves it. Two shortcuts in a row are both flagged. Races are not affected; the minimum lap time covers those. +- Where it shows: + - A "Suspiciously short laps" panel on Timing and Race Day, with **Approve** / **Remove** per lap. + - ⚠ next to the driver in the Timing leaderboard and in every overlay. The overlay event feed lists the lap. +- **Remove / Restore any lap**, not just the last one, from a driver's lap list on Timing (driver details) and in Judging. Every action is logged and can be undone like other judge corrections. + +### Fixed + +- A judge correction made within 15 s of a live passing (e.g. removing a lap right after it was driven) could be held back by the server's protection against stale browsers. It then reached the server only with the next passing, and was lost if someone reloaded first. Corrections are now also sent straight to the server as that session's result. + ## 1.29.2 - 2026-10-03 ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index de356f5..1cdae6a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -685,6 +685,37 @@ Verified 2026-10-03: - 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. +## Suspiciously short practice laps, remove any lap + +Asked by the user 2026-10-07: in Open Practice, new drivers and kids sometimes cut across the track, so a lap lands just above the minimum lap time. Example: someone usually laps 20-25 s and then does one or two laps of 15 s. Those laps should be flagged, visible (also in the overlay) and easy to remove. + +Before this, only the *last* lap could be invalidated, in Timing or Judging. Training stats flag only slow outliers. + +- [x] `src/suspect_laps_logic.js`: a lap is suspect when it is under 85 % of the median of the driver's last 20 normal laps on the same layout, with at least 5 such laps. Normal laps are counted, not suspect, and not the first lap of a stint. The driver is compared with themselves, so kids and fast drivers each get their own baseline. +- [x] `decoder_runtime.js` flags suspect laps in practice sessions only. A suspect lap is counted, but it is not used as a best lap and triggers no personal best, session fastest or record call. It adds a "suspect" overlay event. +- [x] `judging_logic.js`: `setCompetitorLapStatus(session, rowKey, timestamp, "remove" | "restore" | "approve")` works on any lap. Each action is logged with an undo (new undo type `set_passing_suspect`). The best-lap recalculation ignores suspect laps. +- [x] `syncTrainingLedger` leaves suspect laps out until they are approved. +- [x] UI: + - The "Suspiciously short laps" panel on Timing and Race Day, with Approve / Remove. + - ⚠ in the Timing leaderboard and in the overlays (live, team and OBS). + - The driver's lap list (Timing driver details and Judging) shows every lap, including removed ones, with Remove / Restore / Approve. +- [x] Found while testing: a correction within `RESULT_PROTECTION_WINDOW_MS` (15 s) of a live passing was reverted by the server's stale-browser protection. It survived only if another passing followed before a reload. This also affected the existing "invalidate last lap". New `POST /api/session-results` (`storeSessionResult`, shared with `/api/passings`), and judge actions in `app.js` save through `saveCorrectionFor(session)`, which also pushes the result there. + +Verified 2026-10-07: +- 251 unit and integration tests pass. New tests cover: + - the threshold and minimum reference; + - which laps form the reference; + - best-lap and pending rules; + - training stats excluding a suspect lap until approved; + - the decoder flagging a 15 s lap among 20 s laps, keeping the best lap with no personal-best call, and flagging two shortcuts in a row; + - races never flagging; + - remove / restore / approve on any lap with undo; + - `/api/session-results` winning over the hot-results protection. +- `tests/browser_suspect_laps.js` sends a real decoder passing exactly 14 s after six laps around 20 s. It checks that the lap is flagged, not used as the best lap, shows ⚠ in the Timing leaderboard and the overlay, and appears in the Race Day panel, and that one click on Remove takes it away, logs it and clears the panel. +- 56 browser scenarios pass. + +Possible next step: a per-layout minimum lap time (option 3 from the proposal), not built. + ## Documentation maintenance - [x] Update the architecture's module/dependency inventory as modules change. diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 0dd9cda..55fa8c3 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -588,6 +588,15 @@ Owns: The prologue controller lives in `app.js` (`startRacePrologue`, `tickRacePrologue`, hold/resume/+1 min/to 30 s/to 10 s/Go/Cancel). It is per browser tab (module state plus `sessionStorage`, restored on hold after a reload), so a second browser on the same event never runs its own count-down. When it runs out it calls `startOperationalSession` with `skipCountdown` and `skipIntro`; `runStartSequence` in `race_audio.js` then only runs lights, random delay and horn, or for a flying start the horn plus `clock_running`. +### `src/suspect_laps_logic.js` +Type: +- pure practice lap plausibility check + +Owns: +- `isSuspectLap` / `getSuspectReferenceLaps` (85 % of the driver's own median of up to 20 normal laps on the layout, at least 5), used by `decoder_runtime.js` to set `passing.suspect` in practice sessions +- `countsForBestLap`, `listPendingSuspectLaps` (Timing / Race Day panel via `renderSuspectLapsPanel` in `timing_views.js`) +- decisions go through `setCompetitorLapStatus` in `judging_logic.js` (remove / restore / approve, logged and undoable); `app.js` saves judge corrections with `saveCorrectionFor`, which also posts the session result to `POST /api/session-results` + ### `src/misc_views.js` Type: - misc page renderers diff --git a/package-lock.json b/package-lock.json index 2dd286d..5090283 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.29.2", + "version": "1.30.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.29.2", + "version": "1.30.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index acb20ed..9b46c74 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.29.2", + "version": "1.30.0", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/server.js b/server.js index 638609c..5ab3cf9 100644 --- a/server.js +++ b/server.js @@ -431,6 +431,47 @@ app.post("/api/state", (req, res) => { res.json({ ok: true, updatedAt: nowIso }); }); +// Writes one session's results into app_state and marks them "hot" (see +// protectRecentSessionResults), so a stale full-state save can't revert them. +function storeSessionResult(sessionId, sessionResult) { + if (!sessionResult || typeof sessionResult !== "object") return false; + const row = db.prepare("SELECT state_json FROM app_state WHERE id = 1").get(); + if (!row?.state_json) return false; + try { + const parsed = JSON.parse(row.state_json); + if (!parsed.resultsBySession || typeof parsed.resultsBySession !== "object") { + parsed.resultsBySession = {}; + } + parsed.resultsBySession[String(sessionId)] = sessionResult; + recentSessionResultWrites.set(String(sessionId), Date.now()); + db.prepare( + ` + INSERT INTO app_state (id, state_json, updated_at) + VALUES (1, ?, ?) + ON CONFLICT(id) DO UPDATE SET + state_json = excluded.state_json, + updated_at = excluded.updated_at + ` + ).run(JSON.stringify(parsed), new Date().toISOString()); + return true; + } catch { + // leave app_state untouched if the stored JSON is invalid + return false; + } +} + +// A deliberate correction (a lap removed, restored or approved, a penalty) on a session that +// is getting live passings: the full-state save would be held back by the "hot results" +// protection for up to RESULT_PROTECTION_WINDOW_MS, so the corrected result is sent here. +app.post("/api/session-results", (req, res) => { + const { sessionId, sessionResult } = req.body || {}; + if (!sessionId || !sessionResult || typeof sessionResult !== "object" || !Array.isArray(sessionResult.passings)) { + res.status(400).json({ error: "Expected { sessionId, sessionResult }" }); + return; + } + res.json({ ok: storeSessionResult(sessionId, sessionResult) }); +}); + app.post("/api/passings", (req, res) => { const { sessionId, passing, sessionResult } = req.body || {}; if (!sessionId || !passing || typeof passing !== "object") { @@ -474,31 +515,7 @@ app.post("/api/passings", (req, res) => { // Keep app_state hot for overlay clients that hydrate from backend instead of // having their own direct decoder socket. - if (sessionResult && typeof sessionResult === "object") { - const nowIso = new Date().toISOString(); - const row = db.prepare("SELECT state_json FROM app_state WHERE id = 1").get(); - if (row?.state_json) { - try { - const parsed = JSON.parse(row.state_json); - if (!parsed.resultsBySession || typeof parsed.resultsBySession !== "object") { - parsed.resultsBySession = {}; - } - parsed.resultsBySession[String(sessionId)] = sessionResult; - recentSessionResultWrites.set(String(sessionId), Date.now()); - db.prepare( - ` - INSERT INTO app_state (id, state_json, updated_at) - VALUES (1, ?, ?) - ON CONFLICT(id) DO UPDATE SET - state_json = excluded.state_json, - updated_at = excluded.updated_at - ` - ).run(JSON.stringify(parsed), nowIso); - } catch { - // leave app_state untouched if the stored JSON is invalid - } - } - } + storeSessionResult(sessionId, sessionResult); res.json({ ok: true }); }); diff --git a/src/app.js b/src/app.js index cb1d8c1..7295e0e 100644 --- a/src/app.js +++ b/src/app.js @@ -34,13 +34,13 @@ import { renderResultsArchiveView } from "./results_archive_view.js"; import { importDriversCsv, importCarsCsv } from "./csv_import.js"; -import { renderTimingView, renderJudgingView } from "./timing_views.js"; +import { renderTimingView, renderJudgingView, renderSuspectLapsPanel as renderSuspectLapsPanelView } from "./timing_views.js"; import { createSessionReplayRuntime } from "./session_replay.js"; import { renderTeamStintLog as renderTeamStintLogHelper, renderSessionChannelAnalysis as renderSessionChannelAnalysisHelper, renderTeamRaceStandings as renderTeamRaceStandingsHelper, getSessionSortWeight as getSessionSortWeightHelper, getDriverDisplayById as getDriverDisplayByIdHelper, renderPositionGrid as renderPositionGridHelper, renderGridEditor as renderGridEditorHelper, getFinalMainLayouts as getFinalMainLayoutsHelper, renderFinalMatrix as renderFinalMatrixHelper, buildPrintBrandBlock as buildPrintBrandBlockHelper, buildRaceStartListsHtml as buildRaceStartListsHtmlHelper, buildRaceResultsHtml as buildRaceResultsHtmlHelper, buildTeamRaceResultsHtml as buildTeamRaceResultsHtmlHelper } from "./race_render_helpers.js"; -import { getManualCorrectionSummary as getManualCorrectionSummaryLogic, applyCompetitorCorrection as applyCompetitorCorrectionLogic, recalculateCompetitorFromPassings as recalculateCompetitorFromPassingsLogic, invalidateCompetitorLastLap as invalidateCompetitorLastLapLogic, restoreCompetitorLastInvalidLap as restoreCompetitorLastInvalidLapLogic, findPassingByUndoMarker as findPassingByUndoMarkerLogic, undoJudgingAdjustment as undoJudgingAdjustmentLogic, getJudgeFilteredRows as getJudgeFilteredRowsLogic, getJudgeFilteredLog as getJudgeFilteredLogLogic } from "./judging_logic.js"; +import { setCompetitorLapStatus as setCompetitorLapStatusLogic, getManualCorrectionSummary as getManualCorrectionSummaryLogic, applyCompetitorCorrection as applyCompetitorCorrectionLogic, recalculateCompetitorFromPassings as recalculateCompetitorFromPassingsLogic, invalidateCompetitorLastLap as invalidateCompetitorLastLapLogic, restoreCompetitorLastInvalidLap as restoreCompetitorLastInvalidLapLogic, findPassingByUndoMarker as findPassingByUndoMarkerLogic, undoJudgingAdjustment as undoJudgingAdjustmentLogic, getJudgeFilteredRows as getJudgeFilteredRowsLogic, getJudgeFilteredLog as getJudgeFilteredLogLogic } from "./judging_logic.js"; const PRODUCT_NAME = "RC RaceCore"; @@ -407,6 +407,8 @@ const renderRaceDay = () => renderRaceDayView({ getRaceDayWarmupCheck, setDriverTransponder, getDefaultPrologueSec, startRacePrologue, holdRacePrologue, resumeRacePrologue, setRacePrologueRemaining, cancelRacePrologue, goRacePrologue, isStartPending: (session) => Boolean(session && pendingStartSessionId === session.id), + renderSuspectLapsPanel: (session) => renderSuspectLapsPanelView(session, { t, escapeHtml, formatLap, ensureSessionResult }), + setCompetitorLapStatus, addTrackLayout, renderView, state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, getEventName, getSessionTypeLabel, getStatusLabel, getStartModeLabel, getSessionTiming, getSessionGridEntries, buildRaceDayQueue, buildRaceDayPreflight, @@ -463,8 +465,8 @@ 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, 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 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, setCompetitorLapStatus }); +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; } , setCompetitorLapStatus }); const normalizeTrainingState = (training) => normalizeTrainingStateLogic(training, { uid }); const syncTrainingLedger = () => { state.training = syncTrainingLedgerLogic(state.training, state, { uid }); @@ -486,18 +488,25 @@ const renderArchive = () => renderResultsArchiveView({ 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); } }); +// 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) => () => { + saveState(); + persistSessionResultToBackend(session?.id); +}; const applyCompetitorCorrection = (session, row, options = {}) => { - const result = applyCompetitorCorrectionLogic(session, row, options, { ensureSessionResult, uid, t, formatLap, saveState }); + const result = applyCompetitorCorrectionLogic(session, row, options, { ensureSessionResult, uid, t, formatLap, saveState: saveCorrectionFor(session) }); if (!options.reset && ((Number(options.timeMsDelta || 0) > 0) || (Number(options.lapDelta || 0) < 0))) { triggerRaceAudio("penalty", { variables: { driverName: row?.displayName || row?.driverName || "" }, driverId: row?.driverId, logData: { raceId: session?.eventId, driverId: row?.driverId } }); } return result; }; const recalculateCompetitorFromPassings = (session, rowKey) => recalculateCompetitorFromPassingsLogic(session, rowKey, { ensureSessionResult, getCompetitorPassings, isCountedPassing }); -const invalidateCompetitorLastLap = (session, row) => invalidateCompetitorLastLapLogic(session, row, { ensureSessionResult, getCompetitorPassings, isCountedPassing, recalculateCompetitorFromPassings, uid, t, formatLap, saveState }); -const restoreCompetitorLastInvalidLap = (session, row) => restoreCompetitorLastInvalidLapLogic(session, row, { ensureSessionResult, getCompetitorPassings, recalculateCompetitorFromPassings, uid, t, formatLap, saveState }); +const invalidateCompetitorLastLap = (session, row) => invalidateCompetitorLastLapLogic(session, row, { ensureSessionResult, getCompetitorPassings, isCountedPassing, recalculateCompetitorFromPassings, uid, t, formatLap, saveState: saveCorrectionFor(session) }); +const setCompetitorLapStatus = (session, rowKey, passingTimestamp, action) => setCompetitorLapStatusLogic(session, rowKey, passingTimestamp, action, { ensureSessionResult, recalculateCompetitorFromPassings, uid, t, formatLap, saveState: saveCorrectionFor(session) }); +const restoreCompetitorLastInvalidLap = (session, row) => restoreCompetitorLastInvalidLapLogic(session, row, { ensureSessionResult, getCompetitorPassings, recalculateCompetitorFromPassings, uid, t, formatLap, saveState: saveCorrectionFor(session) }); const findPassingByUndoMarker = (session, rowKey, passingTimestamp) => findPassingByUndoMarkerLogic(session, rowKey, passingTimestamp, { ensureSessionResult }); -const undoJudgingAdjustment = (session, adjustmentId) => undoJudgingAdjustmentLogic(session, adjustmentId, { ensureSessionResult, findPassingByUndoMarker, recalculateCompetitorFromPassings, uid, t, saveState }); +const undoJudgingAdjustment = (session, adjustmentId) => undoJudgingAdjustmentLogic(session, adjustmentId, { ensureSessionResult, findPassingByUndoMarker, recalculateCompetitorFromPassings, uid, t, saveState: saveCorrectionFor(session) }); const getJudgeFilteredRows = (rows, filterValue) => getJudgeFilteredRowsLogic(rows, filterValue); const getJudgeFilteredLog = (adjustments, filterValue) => getJudgeFilteredLogLogic(adjustments, filterValue); @@ -1548,6 +1557,13 @@ const TRANSLATIONS = { "timing.start": "Starta", "timing.stop": "Stoppa", "timing.reset": "Nollställ data", + "timing.lap_remove": "Ta bort", + "timing.lap_restore": "Återställ", + "timing.lap_removed": "borttaget", + "suspect.title": "Misstänkt korta varv ({count})", + "suspect.hint": "Mycket snabbare än förarens egen nivå - ofta en genväg. Räknas som varv men inte som bästa varv, rekord eller i statistiken förrän det godkänns.", + "suspect.approve": "Godkänn", + "suspect.flag_title": "Misstänkt kort varv väntar på granskning", "timing.total_passings": "Totala passeringar", "timing.started": "Startad", "timing.remaining": "Nedräkning", @@ -3198,6 +3214,13 @@ const TRANSLATIONS = { "timing.start": "Start", "timing.stop": "Stop", "timing.reset": "Reset Data", + "timing.lap_remove": "Remove", + "timing.lap_restore": "Restore", + "timing.lap_removed": "removed", + "suspect.title": "Suspiciously short laps ({count})", + "suspect.hint": "Much quicker than the driver's own pace - often a shortcut. Counts as a lap, but not as a best lap, record or in the stats until approved.", + "suspect.approve": "Approve", + "suspect.flag_title": "Suspiciously short lap waiting for review", "timing.total_passings": "Total passings", "timing.started": "Started", "timing.remaining": "Countdown", @@ -6289,6 +6312,20 @@ function findDuplicateSessionTransponders(session) { return Object.keys(counts).filter((tp) => counts[tp] > 1); } +async function persistSessionResultToBackend(sessionId) { + if (!sessionId || !state.resultsBySession[sessionId]) return; + try { + const res = await fetch(`${getBackendUrl()}/api/session-results`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId, sessionResult: state.resultsBySession[sessionId] }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch { + // The regular state save still carries the correction once the session is no longer hot. + } +} + async function persistPassingToBackend(sessionId, passing) { backend.pendingWrites = (backend.pendingWrites || 0) + 1; try { diff --git a/src/decoder_runtime.js b/src/decoder_runtime.js index 22f89fc..8fe7f1d 100644 --- a/src/decoder_runtime.js +++ b/src/decoder_runtime.js @@ -1,6 +1,7 @@ import { decoderDiagnostics } from "./decoder_diagnostics.js"; import { getControlledLapStreak } from "./training_logic.js"; import { usesPersonalClock, getLatestClockStartTs } from "./prologue_logic.js"; +import { isSuspectLap, getSuspectReferenceLaps } from "./suspect_laps_logic.js"; const PRACTICE_SESSION_TYPES = new Set(["open_practice", "free_practice", "practice"]); @@ -513,6 +514,12 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { const layoutId = String(session.trackLayoutId || ""); if (entry.bestLapLayoutId === undefined) entry.bestLapLayoutId = layoutId; else if (entry.bestLapLayoutId !== layoutId) { entry.bestLapMs = null; entry.bestLapLayoutId = layoutId; } + // Practice only: a lap far quicker than this driver's own pace (a shortcut) counts as a lap + // but not as a best lap, record or training stat until approved (suspect_laps_logic.js). + const suspect = PRACTICE_SESSION_TYPES.has(String(session.type || "").toLowerCase()) + && !validationWarning + && isSuspectLap(lapMs, getSuspectReferenceLaps(result.passings, key, layoutId)); + if (suspect) passing.suspect = true; const previousPersonalBest = Number(entry.bestLapMs || 0) || null; const leaderboardBefore = buildLeaderboard(session); const previousPosition = leaderboardBefore.findIndex((row) => row.key === key); @@ -524,7 +531,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { entry.lastLapMs = lapMs; entry.lastTimestamp = timestamp; - if (lapMs > 500 && (!entry.bestLapMs || lapMs < entry.bestLapMs)) { + if (lapMs > 500 && !suspect && (!entry.bestLapMs || lapMs < entry.bestLapMs)) { entry.bestLapMs = lapMs; } @@ -547,9 +554,10 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { }); } } - if (previousPersonalBest && lapMs < previousPersonalBest) triggerRaceAudio("personal_best", { variables: audioVariables, driverId: entry.driverId, competitorKey: key, logData: { raceId: session.eventId, driverId: entry.driverId, lapNumber: entry.laps, lapTime: lapMs } }); - if (previousSessionFastest && lapMs < previousSessionFastest) triggerRaceAudio("fastest_lap", { variables: audioVariables, driverId: entry.driverId, competitorKey: key, logData: { raceId: session.eventId, driverId: entry.driverId, lapNumber: entry.laps, lapTime: lapMs } }); - if (previousTrackRecord && lapMs < previousTrackRecord) triggerRaceAudio("track_record", { variables: audioVariables, driverId: entry.driverId, competitorKey: key, logData: { raceId: session.eventId, driverId: entry.driverId, lapNumber: entry.laps, lapTime: lapMs } }); + if (suspect) pushOverlayEvent("suspect", `${entry.displayName || entry.driverName} • ${formatLap(lapMs)} ⚠`); + if (!suspect && previousPersonalBest && lapMs < previousPersonalBest) triggerRaceAudio("personal_best", { variables: audioVariables, driverId: entry.driverId, competitorKey: key, logData: { raceId: session.eventId, driverId: entry.driverId, lapNumber: entry.laps, lapTime: lapMs } }); + if (!suspect && previousSessionFastest && lapMs < previousSessionFastest) triggerRaceAudio("fastest_lap", { variables: audioVariables, driverId: entry.driverId, competitorKey: key, logData: { raceId: session.eventId, driverId: entry.driverId, lapNumber: entry.laps, lapTime: lapMs } }); + if (!suspect && previousTrackRecord && lapMs < previousTrackRecord) triggerRaceAudio("track_record", { variables: audioVariables, driverId: entry.driverId, competitorKey: key, logData: { raceId: session.eventId, driverId: entry.driverId, lapNumber: entry.laps, lapTime: lapMs } }); if (previousPosition >= 0 && currentPosition >= 0 && currentPosition < previousPosition) triggerRaceAudio("position_gain", { driverId: entry.driverId, competitorKey: key }); if (previousPosition >= 0 && currentPosition > previousPosition) triggerRaceAudio("position_loss", { driverId: entry.driverId, competitorKey: key }); const leader = leaderboard[0]; diff --git a/src/judging_logic.js b/src/judging_logic.js index bbccdad..055a720 100644 --- a/src/judging_logic.js +++ b/src/judging_logic.js @@ -87,7 +87,7 @@ export function recalculateCompetitorFromPassings(session, rowKey, { ensureSessi // before layouts were tagged have no trackLayoutId and still count). const layoutId = String(session.trackLayoutId || ""); const bestLapCandidates = validPassings - .filter((passing) => passing.trackLayoutId === undefined || String(passing.trackLayoutId) === layoutId) + .filter((passing) => !passing.suspect && (passing.trackLayoutId === undefined || String(passing.trackLayoutId) === layoutId)) .map((passing) => Number(passing.lapMs || 0)).filter((lapMs) => lapMs > 500); entry.bestLapMs = bestLapCandidates.length ? Math.min(...bestLapCandidates) : null; } @@ -156,6 +156,38 @@ export function restoreCompetitorLastInvalidLap(session, row, { ensureSessionRes return true; } +// One lap, picked by its timestamp: "remove" (invalidate), "restore" (a removed lap counts +// again) or "approve" (a suspect practice lap may count as a best lap). Logged and undoable +// like every other judge correction. +const LAP_ACTIONS = { + remove: { applies: (passing) => passing.validLap !== false, apply: (passing) => { passing.validLap = false; passing.invalidReason = "manual_invalid"; }, label: "timing.lap_remove", undo: { type: "set_passing_validity", validLap: true, invalidReason: "" } }, + restore: { applies: (passing) => passing.validLap === false && passing.invalidReason === "manual_invalid", apply: (passing) => { passing.validLap = true; passing.invalidReason = ""; }, label: "timing.lap_restore", undo: { type: "set_passing_validity", validLap: false, invalidReason: "manual_invalid" } }, + approve: { applies: (passing) => Boolean(passing.suspect), apply: (passing) => { passing.suspect = false; passing.suspectApproved = true; }, label: "suspect.approve", undo: { type: "set_passing_suspect", suspect: true } }, +}; + +export function setCompetitorLapStatus(session, rowKey, passingTimestamp, action, { ensureSessionResult, recalculateCompetitorFromPassings, uid, t, formatLap, saveState }) { + const spec = LAP_ACTIONS[action]; + const result = ensureSessionResult(session.id); + const passing = findPassingByUndoMarker(session, rowKey, passingTimestamp, { ensureSessionResult }); + const entry = result.competitors[rowKey]; + if (!spec || !passing || !entry || !spec.applies(passing)) return false; + spec.apply(passing); + recalculateCompetitorFromPassings(session, rowKey); + if (!Array.isArray(result.adjustments)) result.adjustments = []; + result.adjustments.push({ + id: uid("judge"), + ts: Date.now(), + competitorKey: rowKey, + displayName: entry.displayName || entry.driverName || t("common.unknown_driver"), + action: t(spec.label), + detail: formatLap(Number(passing.lapMs || 0) || 0), + category: "invalid", + undo: { ...spec.undo, passingTimestamp: Number(passing.timestamp || 0) || 0 }, + }); + saveState(); + return true; +} + export function findPassingByUndoMarker(session, rowKey, passingTimestamp, { ensureSessionResult }) { const result = ensureSessionResult(session.id); return result.passings.find((passing) => passing.competitorKey === rowKey && Number(passing.timestamp || 0) === Number(passingTimestamp || 0)) || null; @@ -188,6 +220,14 @@ export function undoJudgingAdjustment(session, adjustmentId, { ensureSessionResu passing.validLap = adjustment.undo.validLap !== false; passing.invalidReason = adjustment.undo.validLap === false ? adjustment.undo.invalidReason || "manual_invalid" : ""; recalculateCompetitorFromPassings(session, adjustment.competitorKey); + } else if (adjustment.undo.type === "set_passing_suspect") { + const passing = findPassingByUndoMarker(session, adjustment.competitorKey, adjustment.undo.passingTimestamp); + if (!passing) { + return false; + } + passing.suspect = adjustment.undo.suspect === true; + if (passing.suspect) delete passing.suspectApproved; + recalculateCompetitorFromPassings(session, adjustment.competitorKey); } else { return false; } diff --git a/src/overlays.js b/src/overlays.js index 4f4db13..905aa73 100644 --- a/src/overlays.js +++ b/src/overlays.js @@ -76,7 +76,7 @@ export function renderOverlayLeaderboard(rows, { t, escapeHtml, formatTeamActive ${idx + 1}
- ${escapeHtml(row.displayName || row.driverName)}${row.personalTimeUp ? ` 🏁 ${t("timing.personal_time_up")}` : ""} + ${escapeHtml(row.displayName || row.driverName)}${row.personalTimeUp ? ` 🏁 ${t("timing.personal_time_up")}` : ""}${row.suspectPending ? ` ⚠` : ""} ${escapeHtml(row.teamId ? `${t("overlay.active_member")}: ${formatTeamActiveMemberLabel(row)}` : row.subLabel || row.transponder || "-")} ${row.lastSplitMs || row.pitActive || row.pitStops ? `${row.lastSplitMs ? `${t("table.split")}: ${formatLap(row.lastSplitMs)}` : ""}${row.lastSplitMs && (row.pitActive || row.pitStops) ? " • " : ""}${row.pitActive ? t("timing.pit_active") : row.pitStops ? `${t("table.pit")}: ${row.pitStops} / ${formatLap(row.totalPitMs)}` : ""}` : ""} ${row.invalidPending ? `${escapeHtml(row.invalidLabel)}${row.invalidLapMs ? ` • ${formatLap(row.invalidLapMs)}` : ""}` : ""} @@ -261,7 +261,7 @@ export function renderObsOverlay(active, leaderboard, result, sessionTiming, bra return `
${idx + 1} - ${escapeHtml(row.displayName || row.driverName)}${row.personalTimeUp ? " 🏁" : ""} + ${escapeHtml(row.displayName || row.driverName)}${row.personalTimeUp ? " 🏁" : ""}${row.suspectPending ? ` ⚠` : ""} ${obsConfig.showLaps ? `${t("table.laps")}: ${row.laps ?? 0}` : ""} ${obsConfig.showResult ? `${t("table.result")}: ${escapeHtml(row.resultDisplay)}` : ""} ${obsConfig.showGap ? `${t("table.gap")}: ${escapeHtml(row.gapDisplay || row.gapAhead || "-")}` : ""} @@ -304,7 +304,7 @@ export function renderObsOverlay(active, leaderboard, result, sessionTiming, bra
${idx + 1}
- ${escapeHtml(row.displayName || row.driverName)}${row.personalTimeUp ? " 🏁" : ""} + ${escapeHtml(row.displayName || row.driverName)}${row.personalTimeUp ? " 🏁" : ""}${row.suspectPending ? ` ⚠` : ""} ${escapeHtml(row.teamId ? formatTeamActiveMemberLabel(row) : row.subLabel || row.transponder || "-")}
${trail ? `
${trail}
` : ""} diff --git a/src/race_day_view.js b/src/race_day_view.js index c6f2e73..2384471 100644 --- a/src/race_day_view.js +++ b/src/race_day_view.js @@ -174,7 +174,7 @@ export function renderRaceDayView(deps) { saveLapWindow, navigateTo, getStartLightState, addTrackLayout, renderView, getRaceDayWarmupCheck, setDriverTransponder, getDefaultPrologueSec, startRacePrologue, holdRacePrologue, resumeRacePrologue, setRacePrologueRemaining, cancelRacePrologue, goRacePrologue, - isStartPending, + isStartPending, renderSuspectLapsPanel = () => "", setCompetitorLapStatus = () => false, } = deps; const queue = buildRaceDayQueue(state); const session = queue.current; @@ -241,6 +241,7 @@ export function renderRaceDayView(deps) { ${showsWarmupPanel(session) ? `
` : ""} + ${session ? renderSuspectLapsPanel(session) : ""}

${t("race_day.start_list")}

${grid.length}
@@ -333,6 +334,9 @@ export function renderRaceDayView(deps) { if (!result.ok && result.message) alert(result.message); if (!result.ok && button) button.disabled = false; }); + dom.view.querySelectorAll("[data-lap-action]").forEach((button) => button.addEventListener("click", () => { + if (session && setCompetitorLapStatus(session, button.dataset.lapRow, Number(button.dataset.lapTs), button.dataset.lapAction)) renderView(); + })); dom.view.querySelectorAll("[data-prologue]").forEach((button) => button.addEventListener("click", () => { const action = button.dataset.prologue; if (action === "hold") holdRacePrologue(); diff --git a/src/styles.css b/src/styles.css index f3f4971..8264b7d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4517,3 +4517,46 @@ select:focus { #raceDayWarmup .panel-header .pill { white-space: nowrap; } + +/* Suspect (likely shortcut) practice laps */ +.suspect-flag { + color: #f5a623; + font-weight: 700; +} + +.suspect-laps-panel { + border-color: rgba(245, 166, 35, 0.6); +} + +.suspect-laps-list { + display: grid; + gap: 6px; +} + +.suspect-lap-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.lap-actions { + display: inline-flex; + gap: 6px; + white-space: nowrap; +} + +tr.is-suspect td { + background: rgba(245, 166, 35, 0.08); +} + +tr.is-removed td { + opacity: 0.55; + text-decoration: line-through; +} + +tr.is-removed td.lap-actions { + opacity: 1; + text-decoration: none; +} diff --git a/src/suspect_laps_logic.js b/src/suspect_laps_logic.js new file mode 100644 index 0000000..f654bc4 --- /dev/null +++ b/src/suspect_laps_logic.js @@ -0,0 +1,45 @@ +// Suspiciously short laps in practice: new drivers and kids sometimes cut across the track, +// and the lap lands just above the minimum lap time. Such a lap is far quicker than the +// driver's own pace on the same layout, so it is flagged as suspect: it still counts as a +// lap, but never as a best lap, personal best, track record or training stat until someone +// approves it - or removes it with one click. +export const SUSPECT_LAP_RATIO = 0.85; +export const MIN_REFERENCE_LAPS = 5; +const REFERENCE_WINDOW = 20; + +function median(values) { + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; +} + +// The driver's recent normal laps on this layout: counted, not suspect, and not the first lap +// of a stint (those span a break and say nothing about pace). +export function getSuspectReferenceLaps(passings, competitorKey, trackLayoutId = "") { + return (Array.isArray(passings) ? passings : []) + .filter((passing) => passing.competitorKey === competitorKey + && passing.validLap !== false + && !passing.suspect + && !passing.stintBoundary + && String(passing.trackLayoutId ?? trackLayoutId) === String(trackLayoutId)) + .slice(-REFERENCE_WINDOW) + .map((passing) => Number(passing.lapMs || 0)) + .filter((lapMs) => lapMs > 500); +} + +export function isSuspectLap(lapMs, referenceLaps) { + if (!Array.isArray(referenceLaps) || referenceLaps.length < MIN_REFERENCE_LAPS) return false; + return Number(lapMs) > 0 && Number(lapMs) < median(referenceLaps) * SUSPECT_LAP_RATIO; +} + +// A lap that may count for best lap / records / training stats. +export function countsForBestLap(passing) { + return passing?.validLap !== false && !passing?.suspect; +} + +// Suspect laps still waiting for a decision, newest first. +export function listPendingSuspectLaps(result) { + return (Array.isArray(result?.passings) ? result.passings : []) + .filter((passing) => passing.suspect && passing.validLap !== false) + .sort((a, b) => Number(b.timestamp || 0) - Number(a.timestamp || 0)); +} diff --git a/src/timing_logic.js b/src/timing_logic.js index ee088a8..6a847c7 100644 --- a/src/timing_logic.js +++ b/src/timing_logic.js @@ -252,6 +252,8 @@ export function buildLeaderboard(session, { ensureSessionResult, getSessionTarge return { ...row, personalTimeUp, + // Practice laps far quicker than the driver's pace, still waiting for approve/remove. + suspectPending: passings.filter((passing) => passing.suspect).length, laps: Math.max(0, Number(row.laps || 0) + manualLapAdjustment), lastLapMs, bestLapMs, diff --git a/src/timing_views.js b/src/timing_views.js index d669563..f29764b 100644 --- a/src/timing_views.js +++ b/src/timing_views.js @@ -1,4 +1,5 @@ import { sortableTable, sortValuesAttribute } from "./list_sorting.js"; +import { listPendingSuspectLaps } from "./suspect_laps_logic.js"; import { advanceSessionReplay, buildSessionReplaySnapshot, @@ -226,9 +227,36 @@ function renderQuickAddPanel(session, deps) { `; } +function renderLapActions(passing, rowKey, { t, escapeHtml }) { + const button = (action, label, extra = "") => ``; + if (passing.validLap === false) return passing.invalidReason === "manual_invalid" ? button("restore", "timing.lap_restore") : ""; + return `${passing.suspect ? button("approve", "suspect.approve") : ""}${button("remove", "timing.lap_remove", "btn-danger")}`; +} + +// Practice: suspect laps waiting for a decision, one click each, right above the leaderboard. +export function renderSuspectLapsPanel(session, deps) { + const { t, escapeHtml, formatLap, ensureSessionResult } = deps; + const pending = listPendingSuspectLaps(ensureSessionResult(session.id)); + if (!pending.length) return ""; + return ` +
+

⚠ ${escapeHtml(t("suspect.title", { count: pending.length }))}

${escapeHtml(t("suspect.hint"))}

+
+ ${pending.map((passing) => ` +
+ ${escapeHtml(passing.displayName || passing.driverName || passing.transponder || t("common.unknown_driver"))} · ${formatLap(passing.lapMs)} · ${new Date(passing.timestamp).toLocaleTimeString()} + ${renderLapActions(passing, passing.competitorKey, { t, escapeHtml })} +
`).join("")} +
+
`; +} + function renderLeaderboardModal(session, row, deps) { const { t, escapeHtml, formatLap, formatRaceClock, renderTable, getCompetitorPassings, renderQuickAddActions } = deps; - const passings = getCompetitorPassings(session, row); + // Every lap, removed ones included, so any lap - not just the last - can be removed, + // restored or (a suspect practice lap) approved. + const passings = getCompetitorPassings(session, row, { includeInvalid: true }); + let lapNumber = 0; return `
+ ${active ? renderSuspectLapsPanel(active, { t, escapeHtml, formatLap, ensureSessionResult }) : ""}

${t("timing.leaderboard")}

@@ -780,6 +814,12 @@ export function renderTimingView(deps) { if (session) finishOperationalSession(session); }); + // Remove / restore / approve one lap - from the suspect panel or a driver's lap list. + dom.view.querySelectorAll("[data-lap-action]").forEach((button) => button.addEventListener("click", () => { + const session = getActiveSession(); + if (session && setCompetitorLapStatus(session, button.dataset.lapRow, Number(button.dataset.lapTs), button.dataset.lapAction)) renderView(); + })); + document.getElementById("resetSession")?.addEventListener("click", () => { const session = getActiveSession(); if (!session) { @@ -848,6 +888,7 @@ export function renderJudgingView(deps) { getPassingValidationLabel, undoJudgingAdjustment, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, renderView, getSelectedJudgeKey, setSelectedJudgeKey, getJudgingCompetitorFilter, setJudgingCompetitorFilter, getJudgingLogFilter, setJudgingLogFilter, + setCompetitorLapStatus = () => false, } = deps; const active = getActiveSession(); @@ -943,13 +984,14 @@ export function renderJudgingView(deps) { ${ selectedPassings.length ? renderTable( - [t("table.lap"), t("table.last_lap"), t("table.status")], + [t("table.lap"), t("table.last_lap"), t("table.status"), ""], selectedPassings.map( (passing, index) => ` - + ${index + 1} - ${formatLap(passing.lapMs)} + ${formatLap(passing.lapMs)}${passing.suspect && isCountedPassing(passing) ? ` ⚠` : ""} ${escapeHtml(getPassingValidationLabel(passing))} + ${renderLapActions(passing, selectedRow.key, { t, escapeHtml })} ` ) @@ -1091,6 +1133,9 @@ export function renderJudgingView(deps) { invalidateCompetitorLastLap(active, selectedRow); renderJudgingView(deps); }); + dom.view.querySelectorAll("[data-lap-action]").forEach((button) => button.addEventListener("click", () => { + if (setCompetitorLapStatus(active, button.dataset.lapRow, Number(button.dataset.lapTs), button.dataset.lapAction)) renderJudgingView(deps); + })); document.getElementById("judgeRestore")?.addEventListener("click", () => { restoreCompetitorLastInvalidLap(active, selectedRow); renderJudgingView(deps); diff --git a/src/training_logic.js b/src/training_logic.js index 2ad1afc..327f46b 100644 --- a/src/training_logic.js +++ b/src/training_logic.js @@ -226,7 +226,8 @@ export function syncTrainingLedger(training, state, { uid }) { (Array.isArray(result?.passings) ? result.passings : []).forEach((passing, index) => { const driverId = String(passing?.driverId || ""); const lapMs = Math.round(Number(passing?.lapMs || 0) || 0); - if (!driverId || passing?.validLap === false || lapMs <= 500) return; + // A suspect practice lap (a likely shortcut) joins the stats only once it is approved. + if (!driverId || passing?.validLap === false || passing?.suspect || lapMs <= 500) return; const timestamp = Math.max(0, Number(passing?.timestamp || 0) || 0); const sourceKey = getPassingSourceKey(sessionId, passing, index); current.push({ diff --git a/tests/browser_fake_decoder.js b/tests/browser_fake_decoder.js index ba64573..0b86a05 100644 --- a/tests/browser_fake_decoder.js +++ b/tests/browser_fake_decoder.js @@ -40,8 +40,9 @@ export async function startFakeDecoder() { // listening so the frontend's own reconnect logic has somewhere to reconnect to. dropConnections: () => { for (const socket of sockets) socket.destroy(); }, // Sends an AMMC-style PASSING message as an unmasked server->client WebSocket text frame. - sendPassing: (transponder) => { - const payload = Buffer.from(JSON.stringify({ msg: "PASSING", transponder: String(transponder), rtc_time: new Date().toISOString() })); + // `at` (ms) sets the decoder's own passing time, for exact lap times in a test. + sendPassing: (transponder, at = Date.now()) => { + const payload = Buffer.from(JSON.stringify({ msg: "PASSING", transponder: String(transponder), rtc_time: new Date(at).toISOString() })); const header = payload.length < 126 ? Buffer.from([0x81, payload.length]) : Buffer.from([0x81, 126, payload.length >> 8, payload.length & 0xff]); diff --git a/tests/browser_smoke.js b/tests/browser_smoke.js index 719c0ea..8db7cab 100644 --- a/tests/browser_smoke.js +++ b/tests/browser_smoke.js @@ -12,6 +12,7 @@ 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 { runSuspectLapsSmoke } from "./browser_suspect_laps.js"; import { runObsPopupSmoke } from "./browser_obs_popup.js"; import { runDecoderReconnectSmoke } from "./browser_decoder_reconnect.js"; import assert from "node:assert/strict"; @@ -154,6 +155,7 @@ try { 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 runSuspectLapsSmoke({ 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/browser_suspect_laps.js b/tests/browser_suspect_laps.js new file mode 100644 index 0000000..df73373 --- /dev/null +++ b/tests/browser_suspect_laps.js @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { startFakeDecoder } from "./browser_fake_decoder.js"; + +// Open Practice: a driver who usually laps around 20 s suddenly does one lap of 14 s (cut +// across the track). A real decoder passing must flag it: the "suspiciously short laps" panel +// on Timing, a ⚠ next to the driver in the leaderboard and the overlay, not used as the best +// lap - and one click on Remove takes it away. +export async function runSuspectLapsSmoke({ 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 results = async () => (await savedState()).resultsBySession?.sl_practice; + + const dialogsAtStart = dialogs.length; + const decoder = await startFakeDecoder(); + const seeded = await savedState(); + const event = { id: "sl_event", name: "SL Practice", classId: "sl_class", mode: "race", raceConfig: { practiceOnly: true, minLapMs: 3000, maxLapMs: 60000 } }; + // Six normal laps around 20 s; the decoder then reports the next crossing 14 s later. + const lastTs = Date.now() - 1000; + const laps = [20400, 19800, 20200, 20600, 20100, 20300]; + let ts = lastTs - laps.reduce((sum, value) => sum + value, 0); + const startedAt = ts; + const passings = laps.map((lapMs) => { ts += lapMs; return { driverId: "sl_driver", driverName: "SL Kid", displayName: "SL Kid", competitorKey: "driver_sl_driver", transponder: "8330001", lapMs, timestamp: ts, validLap: true }; }); + const practice = { id: "sl_practice", eventId: event.id, name: "SL Open Practice", type: "open_practice", durationMin: 600, status: "running", startedAt }; + await patchState({ + classes: [...seeded.classes, { id: "sl_class", name: "SL Class" }], + drivers: [...seeded.drivers, { id: "sl_driver", name: "SL Kid", classId: "sl_class", transponder: "8330001" }], + 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, competitors: { driver_sl_driver: { key: "driver_sl_driver", driverId: "sl_driver", driverName: "SL Kid", displayName: "SL Kid", transponder: "8330001", laps: 6, lastLapMs: 20300, bestLapMs: 19800, startTimestamp: startedAt, lastTimestamp: lastTs } } }, + }, + activeSessionId: practice.id, + settings: { ...seeded.settings, autoReconnect: true, wsUrl: `ws://127.0.0.1:${decoder.port}` }, + }); + + try { + await navigate("timing", "resetSession"); + await waitFor(async () => evaluate('document.getElementById("connectionBadge")?.classList.contains("badge-online")'), "decoder connected"); + decoder.sendPassing("8330001", lastTs + 14000); + await waitFor(async () => (await results())?.passings?.length === 7, "the short lap arrived"); + const shortLap = (await results()).passings.at(-1); + assert.equal(shortLap.lapMs, 14000); + assert.equal(shortLap.suspect, true, "flagged as suspect"); + assert.equal((await results()).competitors.driver_sl_driver.bestLapMs, 19800, "not used as the best lap"); + + await waitFor(async () => Boolean(await evaluate(`document.getElementById("suspectLapsPanel")`)), "suspect panel on Timing"); + assert.match(await evaluate(`document.getElementById("suspectLapsPanel").innerText`), /SL Kid/); + assert.equal(await evaluate(`Boolean([...document.querySelectorAll(".table-primary")].find((node) => node.textContent.includes("SL Kid"))?.querySelector(".suspect-flag"))`), true, "⚠ in the leaderboard"); + console.log("PASS a lap far quicker than the driver's own pace is flagged on Timing and not used as the best lap"); + + await navigate("overlay", "view"); + await waitFor(async () => evaluate(`document.body.innerText.includes("SL Kid")`), "overlay shows the driver"); + assert.equal(await evaluate(`[...document.querySelectorAll(".suspect-flag")].length > 0`), true, "⚠ in the overlay"); + console.log("PASS the overlay marks the driver with a suspect lap"); + + await navigate("race_day", "raceDayStart"); + await waitFor(async () => Boolean(await evaluate(`document.querySelector('#suspectLapsPanel [data-lap-action="approve"]')`)), "the same panel on Race Day"); + console.log("PASS Race Day shows the suspect lap with Approve / Remove"); + + await navigate("timing", "resetSession"); + await waitFor(async () => Boolean(await evaluate(`document.querySelector('#suspectLapsPanel [data-lap-action="remove"]')`)), "Remove button"); + await evaluate(`document.querySelector('#suspectLapsPanel [data-lap-action="remove"]').click()`); + await waitFor(async () => (await results())?.passings?.at(-1)?.validLap === false, "the short lap removed"); + const after = await results(); + assert.equal(after.passings.at(-1).invalidReason, "manual_invalid"); + assert.equal(after.competitors.driver_sl_driver.laps, 6, "no longer counted"); + assert.ok(after.adjustments?.some((adjustment) => adjustment.category === "invalid"), "logged as a judge correction (undoable)"); + await waitFor(async () => !(await evaluate(`Boolean(document.getElementById("suspectLapsPanel"))`)), "panel gone once nothing is pending"); + console.log("PASS one click removes the suspect lap and it is logged as an undoable correction"); + } 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/decoder_runtime.test.js b/tests/decoder_runtime.test.js index fab8d8d..11f971a 100644 --- a/tests/decoder_runtime.test.js +++ b/tests/decoder_runtime.test.js @@ -157,6 +157,33 @@ test("personal best is per session and track layout - not per stint, and an old assert.equal(harness.state.resultsBySession.session_1.passings.at(-1).trackLayoutId, "layout_b", "each lap remembers its layout"); }); +test("practice: a shortcut lap is flagged suspect - counted, but no best lap, personal best or record", () => { + const harness = createHarness({ sessionType: "open_practice" }); + const entry = () => Object.values(harness.state.resultsBySession.session_1.competitors)[0]; + let ts = 1_000; + [20_000, 20_400, 19_800, 20_200, 20_600, 20_100].forEach((lapMs) => { ts += lapMs; harness.passing(ts, "main"); }); + assert.equal(entry().bestLapMs, 19_800); + const pbsBefore = harness.audioEvents.filter((event) => event === "personal_best").length; + ts += 15_000; // usually ~20 s, then one lap of 15 s: cut across the track + harness.passing(ts, "main"); + const last = harness.state.resultsBySession.session_1.passings.at(-1); + assert.equal(last.suspect, true); + assert.equal(entry().laps, 7, "still counted as a lap"); + assert.equal(entry().bestLapMs, 19_800, "not a best lap"); + assert.equal(harness.audioEvents.filter((event) => event === "personal_best").length, pbsBefore, "no personal best call for it"); + ts += 15_100; // a second shortcut in a row is flagged too (suspect laps aren't the reference) + harness.passing(ts, "main"); + assert.equal(harness.state.resultsBySession.session_1.passings.at(-1).suspect, true); + assert.ok(harness.overlayEvents.some((event) => event.type === "suspect"), "the overlay event feed shows it"); +}); + +test("races never flag suspect laps - that's what the minimum lap time is for", () => { + const harness = createHarness({ sessionType: "heat" }); + let ts = 1_000; + [20_000, 20_400, 19_800, 20_200, 20_600, 12_000].forEach((lapMs) => { ts += lapMs; harness.passing(ts, "main"); }); + assert.equal(harness.state.resultsBySession.session_1.passings.some((passing) => passing.suspect), false); +}); + test("pit entry and exit create one timed stop without adding laps", () => { const harness = createHarness(); harness.passing(17_000, "main"); diff --git a/tests/judging_logic.test.js b/tests/judging_logic.test.js index b44dd03..bd4bef2 100644 --- a/tests/judging_logic.test.js +++ b/tests/judging_logic.test.js @@ -9,6 +9,7 @@ import { getManualCorrectionSummary, invalidateCompetitorLastLap, recalculateCompetitorFromPassings, + setCompetitorLapStatus, restoreCompetitorLastInvalidLap, undoJudgingAdjustment, } from "../src/judging_logic.js"; @@ -86,6 +87,34 @@ test("recalculating after a judge correction keeps the best lap on the current t assert.equal(harness.row.bestLapMs, 16000); }); +test("any lap can be removed, restored or (if suspect) approved - each logged and undoable", () => { + const harness = createHarness(); + const act = (timestamp, action) => setCompetitorLapStatus(harness.session, "driver_1", timestamp, action, harness.deps); + // Remove the FIRST lap, not just the last one. + assert.equal(act(17000, "remove"), true); + assert.equal(harness.row.laps, 2); + assert.equal(harness.row.bestLapMs, 16500); + assert.equal(act(17000, "remove"), false, "already removed"); + assert.equal(act(17000, "restore"), true); + assert.equal(harness.row.laps, 3); + assert.equal(harness.row.bestLapMs, 16000); + + // A suspect lap doesn't count for best lap until approved; undo makes it suspect again. + harness.result.passings[2].lapMs = 12000; + harness.result.passings[2].suspect = true; + harness.deps.recalculateCompetitorFromPassings(harness.session, "driver_1"); + assert.equal(harness.row.bestLapMs, 16000); + assert.equal(act(50500, "approve"), true); + assert.equal(harness.row.bestLapMs, 12000); + const approval = harness.result.adjustments.at(-1); + assert.equal(approval.category, "invalid"); + assert.equal(undoJudgingAdjustment(harness.session, approval.id, { ...harness.deps, findPassingByUndoMarker: (session, key, ts) => harness.result.passings.find((passing) => passing.competitorKey === key && passing.timestamp === ts) }), true); + assert.equal(harness.result.passings[2].suspect, true); + assert.equal(harness.row.bestLapMs, 16000); + assert.equal(act(99999, "remove"), false, "unknown lap"); + assert.equal(act(17000, "explode"), false, "unknown action"); +}); + test("manual corrections accumulate, reset, and undo to the prior state", () => { const harness = createHarness(); diff --git a/tests/run.js b/tests/run.js index 682b89f..e8eec1a 100644 --- a/tests/run.js +++ b/tests/run.js @@ -1,5 +1,6 @@ import "./auth.test.js"; import "./prologue_logic.test.js"; +import "./suspect_laps_logic.test.js"; import "./tts.test.js"; import "./piper_speech.test.js"; import "./driver_profile_logic.test.js"; diff --git a/tests/server_persistence.integration.test.js b/tests/server_persistence.integration.test.js index 0c84a0d..a82a314 100644 --- a/tests/server_persistence.integration.test.js +++ b/tests/server_persistence.integration.test.js @@ -388,6 +388,17 @@ test("a stale full-state save from a second browser cannot revert a session's ju // ...but the live race result survived the stale overwrite instead of reverting to empty. assert.deepEqual(afterStaleSave.state.resultsBySession.session_1, liveSessionResult); + // A judge removing that lap moments later must not be held back by the same protection: + // the corrected result goes through /api/session-results and wins straight away. + const correctedResult = { + ...liveSessionResult, + competitors: { driver_1: { ...liveSessionResult.competitors.driver_1, laps: 0, bestLapMs: null } }, + passings: [{ ...liveSessionResult.passings[0], validLap: false, invalidReason: "manual_invalid" }], + }; + assert.equal((await fetch(`${baseUrl}/api/session-results`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: "session_1", sessionResult: correctedResult }) })).status, 200); + assert.deepEqual((await (await fetch(`${baseUrl}/api/state`)).json()).state.resultsBySession.session_1, correctedResult); + assert.equal((await fetch(`${baseUrl}/api/session-results`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: "session_1" }) })).status, 400); + // The protection is time-bounded, not permanent: once the window passes, a save is trusted // again, so a legitimate later change to that same session's results (e.g. an admin // deleting the session) still takes effect. diff --git a/tests/suspect_laps_logic.test.js b/tests/suspect_laps_logic.test.js new file mode 100644 index 0000000..73d0c5c --- /dev/null +++ b/tests/suspect_laps_logic.test.js @@ -0,0 +1,44 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { isSuspectLap, getSuspectReferenceLaps, countsForBestLap, listPendingSuspectLaps, SUSPECT_LAP_RATIO, MIN_REFERENCE_LAPS } from "../src/suspect_laps_logic.js"; +import { syncTrainingLedger } from "../src/training_logic.js"; + +const lap = (lapMs, extra = {}) => ({ competitorKey: "d1", lapMs, timestamp: lapMs, validLap: true, ...extra }); + +test("a lap well under 85 % of the driver's own median is suspect - only once there are 5 normal laps", () => { + assert.equal(SUSPECT_LAP_RATIO, 0.85); + assert.equal(MIN_REFERENCE_LAPS, 5); + const reference = [20000, 20500, 19800, 20200, 21000]; // median 20 200 -> limit 17 170 + assert.equal(isSuspectLap(17000, reference), true, "a shortcut"); + assert.equal(isSuspectLap(17500, reference), false, "a genuinely good lap"); + assert.equal(isSuspectLap(12000, reference.slice(0, 4)), false, "too little to compare with yet"); +}); + +test("the reference is the driver's own recent normal laps on the same layout", () => { + const passings = [ + lap(20000), lap(20000), lap(30000, { stintBoundary: true }), lap(9000, { validLap: false }), + lap(15000, { suspect: true }), lap(25000, { competitorKey: "other" }), lap(40000, { trackLayoutId: "old" }), lap(21000), + ]; + assert.deepEqual(getSuspectReferenceLaps(passings, "d1", ""), [20000, 20000, 21000]); +}); + +test("suspect and removed laps never count for best lap; pending ones are listed newest first", () => { + assert.equal(countsForBestLap(lap(15000, { suspect: true })), false); + assert.equal(countsForBestLap(lap(15000, { validLap: false })), false); + assert.equal(countsForBestLap(lap(15000, { suspectApproved: true })), true); + const result = { passings: [lap(15000, { suspect: true, timestamp: 1 }), lap(16000, { suspect: true, validLap: false, timestamp: 2 }), lap(14000, { suspect: true, timestamp: 3 })] }; + assert.deepEqual(listPendingSuspectLaps(result).map((passing) => passing.lapMs), [14000, 15000], "a removed suspect lap is no longer pending"); +}); + +test("a suspect lap stays out of the training stats until it is approved", () => { + let n = 0; + const uid = (prefix) => `${prefix}_${++n}`; + const state = { + sessions: [{ id: "op", name: "Open Practice", eventId: "e" }], + events: [{ id: "e", name: "Club" }], + resultsBySession: { op: { passings: [{ ...lap(20000), driverId: "d1", timestamp: 1000 }, { ...lap(15000, { suspect: true }), driverId: "d1", timestamp: 2000 }] } }, + }; + assert.deepEqual(syncTrainingLedger({}, state, { uid }).ledger.map((entry) => entry.lapMs), [20000]); + Object.assign(state.resultsBySession.op.passings[1], { suspect: false, suspectApproved: true }); + assert.deepEqual(syncTrainingLedger({}, state, { uid }).ledger.map((entry) => entry.lapMs), [20000, 15000]); +});