From 94794350e5cc16fe336530903ed3b1b1153a9a37 Mon Sep 17 00:00:00 2001 From: larssand Date: Wed, 9 Sep 2026 15:49:10 +0200 Subject: [PATCH] Add a results archive and purge orphaned passing rows on delete Until now there was no way to find a completed session without already knowing which event it belonged to - navigation was strictly Events -> one event -> its sessions. A new "Resultatarkiv" view lists every finished session across all events, searchable by event/session name, class, driver and date range. Opening a session reuses the existing single-session leaderboard builder for standings and the existing per-competitor lap lookup for lap history - no new results logic - plus a session-wide penalties/ corrections list (resultsBySession[id].adjustments), previously only visible in the live judging action log. Export buttons reuse the existing per-event PDF and race-package JSON export functions rather than adding a new format. Investigated what should happen to raw timing data when a race is deleted. Training statistics and session replay already read from their own independent, deliberately-retained copies that survive deletion by existing design - unaffected either way. The one thing that outlived deletion with no purpose was the raw `passings` SQLite rows (write-only; nothing in the app reads them), growing forever with driver IDs never purged. A new POST /api/passings/purge endpoint now removes exactly the deleted session(s)' rows, called as a best-effort side call from both delete-event and delete-session. Also fixes a real bug found while building this: ensureSessionResult guarded adjustments/splits/pitEvents/pitStops against being missing on an already-existing result, but not passings itself, so viewing lap history for such a result (e.g. an older/imported race package) crashed outright. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 10 + ROADMAP.md | 12 +- package-lock.json | 4 +- package.json | 2 +- server.js | 18 ++ src/app.js | 100 +++++++++ src/event_manager_controller.js | 2 + src/event_workspace_controller.js | 2 + src/results_archive_view.js | 214 +++++++++++++++++++ src/styles.css | 16 ++ src/timing_logic.js | 3 + tests/race_timing_logic.test.js | 17 ++ tests/server_persistence.integration.test.js | 28 +++ 13 files changed, 421 insertions(+), 7 deletions(-) create mode 100644 src/results_archive_view.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e9ecd69..1b2130b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.6.0 - 2026-09-09 + +### Added + +- A new "Resultatarkiv" (results archive) view lists every completed session across all events, searchable by event/session name, class, driver and date - no more needing to already know which event a session belonged to. Opening a session shows its standings, penalties/corrections, and a per-driver lap history, and offers the same PDF and race-package (JSON) export already available from Event Manager. Deleting an event or session now also removes its raw timing rows from the database instead of leaving them behind indefinitely - driver-visible training statistics and session replay are unaffected, since they already keep their own independent copy. + +### Fixed + +- Opening a session's lap history in the archive (or anywhere reusing the same lookup) crashed if that session's result was missing its passings list - e.g. an older or hand-edited/imported race package. It's now backfilled the same way other missing result fields already were. + ## 1.5.0 - 2026-09-09 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index e480b28..3a4e2f0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -119,10 +119,14 @@ Verified 2026-09-09: unit tests in `tests/public_training_profile.test.js` cover ## 4. Results archive -- [ ] Search completed sessions by date, class and driver without Race Setup. -- [ ] Show standings, lap history and penalties together. -- [ ] Reuse existing PDF/export functions from the archive. -- [ ] Define what remains available after deleting a race. +Implemented 2026-09-09. + +- [x] Search completed sessions by date, class and driver without Race Setup. New "Resultatarkiv" nav item lists every session with `status: "finished"` across all events, filterable by free text (event/session name), class, driver and a date range - none of which required knowing which event a session belonged to first, unlike every existing navigation path. +- [x] Show standings, lap history and penalties together. Reuses the existing single-session leaderboard builder (`buildLeaderboard`) for standings and `getCompetitorPassings` for a per-driver lap-by-lap breakdown (the same functions the live Timing view uses) rather than new logic; penalties/corrections (`resultsBySession[id].adjustments`) are shown in a session-wide list - previously visible nowhere outside the live judging action log. +- [x] Reuse existing PDF/export functions from the archive. No new export format was built: the archive's "Export PDF"/"Export race package (JSON)" buttons call the same per-event `exportRaceResultsPdf`/`buildRacePackagePayload` functions already used from Event Manager, since no "PDF of one session" export existed or was needed - the roadmap explicitly asked to reuse, not add a new format. +- [x] Define what remains available after deleting a race. Decided 2026-09-09, after confirming a real gap: training statistics and session replay both already read from their own independent, deliberately-retained copies (`state.training.ledger`, `resultsBySession[id].passings`) that survive event/session deletion by existing design - deleting a race does not and did not affect them. The one thing that previously outlived deletion with no purpose was the raw `passings` SQLite rows (write-only; nothing in the app reads them), which would otherwise grow forever, unreachable, still holding driver IDs indefinitely. A new `POST /api/passings/purge` endpoint removes exactly the deleted session(s)' rows, called from both delete-event and delete-session as a best-effort side call alongside the existing local state removal. + +Verified 2026-09-09: unit tests (`tests/server_persistence.integration.test.js`) cover the purge endpoint (scoped deletion, an unrelated session's rows untouched, empty-input no-op) and (`tests/race_timing_logic.test.js`) a real bug found while building this: `ensureSessionResult` guarded `adjustments`/`splits`/`pitEvents`/`pitStops` against being missing on an already-existing result (e.g. an older/imported race package) but not `passings` itself, so opening a session's lap-history detail crashed outright for any such result - now guarded the same way as the other four. A full headless-browser run against an isolated temp SQLite instance seeded a finished session with two competitors and a penalty, confirmed all four filters (search, class, driver, date) correctly include/exclude it, confirmed the detail panel shows standings/penalties/lap-history correctly, and confirmed deleting the event through the real Events UI actually purges its session's raw passing rows from SQLite end to end. ## 5. More natural race speech diff --git a/package-lock.json b/package-lock.json index 76f217c..f16e144 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.5.0", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.5.0", + "version": "1.6.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index eb717c6..0ea63bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.5.0", + "version": "1.6.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 3161666..e01120b 100644 --- a/server.js +++ b/server.js @@ -363,6 +363,24 @@ app.get("/api/passings", (req, res) => { res.json({ rows }); }); +// Deleting an event/session already removes it from every part of the UI (the event list, +// resultsBySession, training aggregates all preserve their own independent copy of what they +// need). The raw passing rows behind it are the one thing that isn't reachable through +// /api/state at all, so a dedicated purge is how the client asks for them to be cleaned up too +// instead of lingering in SQLite indefinitely with no UI ever pointing at them again. +app.post("/api/passings/purge", (req, res) => { + const sessionIds = Array.isArray(req.body?.sessionIds) + ? [...new Set(req.body.sessionIds.map((id) => String(id || "")).filter(Boolean))] + : []; + if (!sessionIds.length) { + res.json({ ok: true, deleted: 0 }); + return; + } + const placeholders = sessionIds.map(() => "?").join(","); + const result = db.prepare(`DELETE FROM passings WHERE session_id IN (${placeholders})`).run(...sessionIds); + res.json({ ok: true, deleted: result.changes }); +}); + app.post("/api/export/pdf", (req, res) => { try { const payload = normalizePdfExportPayload(req.body || {}); diff --git a/src/app.js b/src/app.js index bbfdf88..16acd65 100644 --- a/src/app.js +++ b/src/app.js @@ -26,6 +26,7 @@ import { createDefaultAmmcConfigHelper, getManagedWsUrlHelper, loadAmmcConfigFro import { connectDecoderHelper, disconnectDecoderHelper, processDecoderMessageHelper } from "./decoder_runtime.js"; import { normalizeTrainingState as normalizeTrainingStateLogic, syncTrainingLedger as syncTrainingLedgerLogic, buildDriverTrainingStats as buildDriverTrainingStatsLogic, buildDriverTimingChannelStats as buildDriverTimingChannelStatsLogic, buildDriverActivePracticeStats as buildDriverActivePracticeStatsLogic, buildEvenLapsProgress as buildEvenLapsProgressLogic, buildStepTrainingProgress as buildStepTrainingProgressLogic, buildTrainingRecommendation as buildTrainingRecommendationLogic, getTrainingGoalProgress as getTrainingGoalProgressLogic, getNextTrainingMilestone as getNextTrainingMilestoneLogic, buildTrainingMissions as buildTrainingMissionsLogic, buildTrainingHighlights as buildTrainingHighlightsLogic, getClubTrainingGoalProgress as getClubTrainingGoalProgressLogic, buildTrainingPaceBuckets as buildTrainingPaceBucketsLogic, buildTrainingWeekdayStats as buildTrainingWeekdayStatsLogic, buildTrainingStintAnalysis as buildTrainingStintAnalysisLogic, buildTrainingMarkerComparisons as buildTrainingMarkerComparisonsLogic } from "./training_logic.js"; import { renderTrainingView } from "./training_views.js"; +import { renderResultsArchiveView } from "./results_archive_view.js"; import { importDriversCsv, importCarsCsv } from "./csv_import.js"; @@ -325,6 +326,13 @@ const buildStepTrainingProgress = (trainingState, driverId, classId, config, now const buildTrainingRecommendation = (stats) => buildTrainingRecommendationLogic(stats); const getTrainingGoalProgress = (goal, stats) => getTrainingGoalProgressLogic(goal, stats); const getNextTrainingMilestone = (stats) => getNextTrainingMilestoneLogic(stats); +const renderArchive = () => renderResultsArchiveView({ + state, dom, t, escapeHtml, formatLap, formatRaceClock, renderTable, + getClassName, getEventName, buildLeaderboard, getCompetitorPassings, + exportRaceResultsPdf, buildRacePackagePayload, downloadJsonFile, sanitizeFilenameSegment, + getArchiveFilters: () => archiveFilters, setArchiveFilters: (value) => { archiveFilters = value; }, + getSelectedSessionId: () => archiveSelectedSessionId, setSelectedSessionId: (value) => { archiveSelectedSessionId = value; }, +}); const renderTraining = () => renderTrainingView({ state, dom, t, escapeHtml, formatLap, formatRaceClock, uid, saveState, syncStateToBackend, renderView, normalizeTrainingState, syncTrainingLedger, buildDriverTrainingStats, buildDriverTimingChannelStats: buildDriverTimingChannelStatsLogic, buildDriverActivePracticeStats, buildEvenLapsProgress, buildStepTrainingProgress, buildTrainingRecommendation, getTrainingGoalProgress, getNextTrainingMilestone, buildTrainingMissions: buildTrainingMissionsLogic, buildTrainingHighlights: buildTrainingHighlightsLogic, getClubTrainingGoalProgress: getClubTrainingGoalProgressLogic, buildTrainingPaceBuckets: buildTrainingPaceBucketsLogic, buildTrainingWeekdayStats: buildTrainingWeekdayStatsLogic, buildTrainingStintAnalysis: buildTrainingStintAnalysisLogic, buildTrainingMarkerComparisons: buildTrainingMarkerComparisonsLogic, getSelectedDriverId: () => selectedTrainingDriverId, setSelectedDriverId: (value) => { selectedTrainingDriverId = value; updateViewUrl("training", value, selectedTrainingClassId); }, getSelectedClassId: () => selectedTrainingClassId, setSelectedClassId: (value) => { selectedTrainingClassId = value; updateViewUrl("training", selectedTrainingDriverId, value); } }); const applyCompetitorCorrection = (session, row, options = {}) => { const result = applyCompetitorCorrectionLogic(session, row, options, { ensureSessionResult, uid, t, formatLap, saveState }); @@ -350,6 +358,7 @@ const NAV_ITEMS = [ { id: "judging", titleKey: "nav.judging", subtitleKey: "nav.judging_sub", groupKey: "nav.group_run" }, { id: "overlay", titleKey: "nav.overlay", subtitleKey: "nav.overlay_sub", groupKey: "nav.group_run" }, { id: "training", titleKey: "nav.training", subtitleKey: "nav.training_sub", groupKey: "nav.group_run" }, + { id: "archive", titleKey: "nav.archive", subtitleKey: "nav.archive_sub", groupKey: "nav.group_run" }, { id: "classes", titleKey: "nav.classes", subtitleKey: "nav.classes_sub", groupKey: "nav.group_register" }, { id: "drivers", titleKey: "nav.drivers", subtitleKey: "nav.drivers_sub", groupKey: "nav.group_register" }, { id: "cars", titleKey: "nav.cars", subtitleKey: "nav.cars_sub", groupKey: "nav.group_register" }, @@ -448,6 +457,39 @@ const TRANSLATIONS = { "nav.cars_sub": "Bilar med fasta transpondrar", "nav.training": "Träningsstatistik", "nav.training_sub": "Utveckling, delmål och belöningar per förare", + "nav.archive": "Resultatarkiv", + "nav.archive_sub": "Sök avslutade race utan att gå via Race Setup", + "archive.title": "Resultatarkiv", + "archive.hint": "Sök bland alla avslutade sessioner efter datum, klass eller förare.", + "archive.search": "Sök", + "archive.search_placeholder": "Event- eller sessionsnamn", + "archive.class": "Klass", + "archive.all_classes": "Alla klasser", + "archive.driver": "Förare", + "archive.all_drivers": "Alla förare", + "archive.date_from": "Från datum", + "archive.date_to": "Till datum", + "archive.date": "Datum", + "archive.event": "Event", + "archive.session": "Session", + "archive.drivers": "Förare", + "archive.winner": "Vinnare", + "archive.penalties_short": "Straff", + "archive.view": "Visa", + "archive.no_results": "Inga avslutade sessioner matchar filtren.", + "archive.standings": "Resultat", + "archive.no_standings": "Inga resultat registrerade.", + "archive.penalties": "Straff och korrigeringar", + "archive.action": "Åtgärd", + "archive.detail": "Detalj", + "archive.no_penalties": "Inga straff eller korrigeringar registrerade för den här sessionen.", + "archive.adjustment_correction": "Korrigering", + "archive.adjustment_invalid": "Ogiltigförklaring", + "archive.adjustment_undo": "Ångrad", + "archive.lap_history": "Varvhistorik per förare", + "archive.close": "Stäng", + "archive.export_pdf": "Exportera PDF", + "archive.export_json": "Exportera racepaket (JSON)", "nav.timing": "Tidtagning", "nav.timing_sub": "Live timing-board", "nav.judging": "Domare", @@ -1862,6 +1904,39 @@ const TRANSLATIONS = { "nav.cars_sub": "Track cars with fixed transponders", "nav.training": "Training stats", "nav.training_sub": "Development, milestones and rewards per driver", + "nav.archive": "Results archive", + "nav.archive_sub": "Search completed races without going through Race Setup", + "archive.title": "Results archive", + "archive.hint": "Search all completed sessions by date, class or driver.", + "archive.search": "Search", + "archive.search_placeholder": "Event or session name", + "archive.class": "Class", + "archive.all_classes": "All classes", + "archive.driver": "Driver", + "archive.all_drivers": "All drivers", + "archive.date_from": "From date", + "archive.date_to": "To date", + "archive.date": "Date", + "archive.event": "Event", + "archive.session": "Session", + "archive.drivers": "Drivers", + "archive.winner": "Winner", + "archive.penalties_short": "Penalties", + "archive.view": "View", + "archive.no_results": "No completed sessions match the filters.", + "archive.standings": "Results", + "archive.no_standings": "No results recorded.", + "archive.penalties": "Penalties and corrections", + "archive.action": "Action", + "archive.detail": "Detail", + "archive.no_penalties": "No penalties or corrections recorded for this session.", + "archive.adjustment_correction": "Correction", + "archive.adjustment_invalid": "Invalidation", + "archive.adjustment_undo": "Undone", + "archive.lap_history": "Lap history per driver", + "archive.close": "Close", + "archive.export_pdf": "Export PDF", + "archive.export_json": "Export race package (JSON)", "nav.timing": "Timing", "nav.timing_sub": "Live timing board", "nav.judging": "Judging", @@ -3412,6 +3487,8 @@ function writeObsOverlayParams(url, config) { } let raceWizardStep = 1; let raceWizardDraft = null; +let archiveFilters = { search: "", classId: "", driverId: "", dateFrom: "", dateTo: "" }; +let archiveSelectedSessionId = ""; let overlaySyncTimer = null; let overlayRotationTimer = null; let overlayLiveRefreshTimer = null; @@ -4379,6 +4456,24 @@ async function createServerBackup(reason = "manual", session = null) { return payload.backup || null; } +// The raw passing rows behind a deleted event/session live only in SQLite, outside +// buildPersistableState()/POST /api/state entirely, so deleting the event/session locally +// can't reach them - this is a best-effort side call to ask the server to clean them up too. +// A failure here (offline, etc.) is not worth blocking or rolling back the deletion itself. +async function purgeSessionPassings(sessionIds) { + const ids = (Array.isArray(sessionIds) ? sessionIds : [sessionIds]).filter(Boolean); + if (!ids.length) return; + try { + await fetch(`${getBackendUrl()}/api/passings/purge`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionIds: ids }), + }); + } catch { + // best-effort cleanup only + } +} + function navigateToView(view) { currentView = view; updateViewUrl(view); @@ -4482,6 +4577,9 @@ function renderView() { case "training": renderTraining(); break; + case "archive": + renderArchive(); + break; case "timing": renderTiming(); break; @@ -4679,6 +4777,7 @@ function renderEventWorkspace(mode) { bindModalShell, isValidIsoDate, saveState, + purgeSessionPassings, openTimingSession: (sessionId) => { state.activeSessionId = sessionId; saveState(); @@ -4740,6 +4839,7 @@ function renderEventManager(eventId) { bindModalShell, setFormError, saveState, + purgeSessionPassings, renderView, rerenderEventManager: renderEventManager, updateHeaderState, diff --git a/src/event_manager_controller.js b/src/event_manager_controller.js index 4b844be..90f723a 100644 --- a/src/event_manager_controller.js +++ b/src/event_manager_controller.js @@ -47,6 +47,7 @@ export function renderEventManagerView(context) { bindModalShell, setFormError, saveState, + purgeSessionPassings, renderView, rerenderEventManager, updateHeaderState, @@ -328,6 +329,7 @@ export function renderEventManagerView(context) { state.activeSessionId = null; } saveState(); + void purgeSessionPassings([s.id]); refreshManager(); updateHeaderState(); }); diff --git a/src/event_workspace_controller.js b/src/event_workspace_controller.js index de6d7cc..67d033b 100644 --- a/src/event_workspace_controller.js +++ b/src/event_workspace_controller.js @@ -37,6 +37,7 @@ export function renderEventWorkspaceView(context) { bindModalShell, isValidIsoDate, saveState, + purgeSessionPassings, openTimingSession, } = context; @@ -281,6 +282,7 @@ export function renderEventWorkspaceView(context) { state.activeSessionId = null; } saveState(); + void purgeSessionPassings(sessionIds); renderView(); }); diff --git a/src/results_archive_view.js b/src/results_archive_view.js new file mode 100644 index 0000000..eda72fb --- /dev/null +++ b/src/results_archive_view.js @@ -0,0 +1,214 @@ +import { sortableTable, sortValuesAttribute } from "./list_sorting.js"; + +function buildArchiveRows(state, { getEventName, getClassName }) { + return (state.sessions || []) + .filter((session) => session.status === "finished") + .map((session) => { + const event = state.events.find((item) => item.id === session.eventId) || null; + const result = state.resultsBySession?.[session.id] || null; + const competitors = result ? Object.values(result.competitors || {}) : []; + const winner = competitors + .slice() + .sort((left, right) => (Number(right.laps || 0) - Number(left.laps || 0)) || (Number(left.totalElapsedMs || 0) - Number(right.totalElapsedMs || 0)))[0] || null; + return { + session, + event, + result, + competitors, + eventName: event ? getEventName(event.id) : session.name, + className: event ? getClassName(event.classId) : "", + date: event?.date || (session.endedAt ? new Date(session.endedAt).toISOString().slice(0, 10) : ""), + winnerName: winner?.displayName || winner?.driverName || "", + adjustmentCount: (result?.adjustments || []).length, + }; + }) + .sort((left, right) => Number(right.session.endedAt || 0) - Number(left.session.endedAt || 0)); +} + +function matchesArchiveFilters(row, filters) { + const search = filters.search.trim().toLowerCase(); + if (search && !`${row.eventName} ${row.session.name}`.toLowerCase().includes(search)) return false; + if (filters.classId && row.event?.classId !== filters.classId) return false; + if (filters.driverId && !row.competitors.some((competitor) => String(competitor.driverId || "") === filters.driverId)) return false; + if (filters.dateFrom && row.date && row.date < filters.dateFrom) return false; + if (filters.dateTo && row.date && row.date > filters.dateTo) return false; + return true; +} + +function renderArchiveDetail(row, deps) { + const { t, escapeHtml, formatLap, formatRaceClock, renderTable, buildLeaderboard, getCompetitorPassings } = deps; + const { session, event, result } = row; + const leaderboard = buildLeaderboard(session); + const adjustments = result?.adjustments || []; + return ` +
+
+

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

+
+ ${event ? `` : ""} + ${event ? `` : ""} + +
+
+
+

${t("archive.standings")}

+ ${leaderboard.length + ? renderTable( + [t("table.pos"), t("table.driver"), t("table.laps"), t("table.best_lap"), t("timing.total_time")], + leaderboard.map((rowItem, index) => ` + + ${index + 1} + ${escapeHtml(rowItem.displayName || rowItem.driverName || t("common.unknown_driver"))} + ${rowItem.laps} + ${formatLap(rowItem.bestLapMs)} + ${escapeHtml(rowItem.resultDisplay || "-")} + + `) + ) + : `

${t("archive.no_standings")}

`} +
+
+

${t("archive.penalties")}

+ ${adjustments.length + ? renderTable( + [t("table.time"), t("table.driver"), t("archive.action"), t("archive.detail")], + adjustments.slice().reverse().map((adjustment) => ` + + ${escapeHtml(new Date(adjustment.ts || 0).toLocaleTimeString())} + ${escapeHtml(adjustment.displayName || "-")} + ${escapeHtml(t(`archive.adjustment_${adjustment.category}`) || adjustment.category || "-")} + ${escapeHtml(adjustment.detail || "-")} + + `) + ) + : `

${t("archive.no_penalties")}

`} +
+
+

${t("archive.lap_history")}

+ ${leaderboard.map((rowItem) => { + const passings = getCompetitorPassings(session, rowItem); + return ` +
+ ${escapeHtml(rowItem.displayName || rowItem.driverName || t("common.unknown_driver"))} - ${rowItem.laps} ${t("table.laps")} + ${passings.length + ? renderTable( + [t("table.lap"), t("table.time"), t("table.last_lap")], + passings.map((passing, index) => ` + + ${index + 1} + ${formatRaceClock(Math.max(0, passing.timestamp - (session.startedAt || passing.timestamp)))} + ${formatLap(passing.lapMs)} + + `) + ) + : `

${t("timing.no_lap_history")}

`} +
+ `; + }).join("") || `

${t("archive.no_standings")}

`} +
+
+ `; +} + +export function renderResultsArchiveView(deps) { + const { + state, dom, t, escapeHtml, formatLap, formatRaceClock, renderTable, + getClassName, getEventName, buildLeaderboard, getCompetitorPassings, + exportRaceResultsPdf, buildRacePackagePayload, downloadJsonFile, sanitizeFilenameSegment, + getArchiveFilters, setArchiveFilters, getSelectedSessionId, setSelectedSessionId, + } = deps; + + const filters = getArchiveFilters(); + const allRows = buildArchiveRows(state, { getEventName, getClassName }); + const filteredRows = allRows.filter((row) => matchesArchiveFilters(row, filters)); + const selectedSessionId = getSelectedSessionId(); + const selectedRow = selectedSessionId ? allRows.find((row) => row.session.id === selectedSessionId) : null; + + const classOptions = state.classes.map((c) => ``).join(""); + const driverOptions = state.drivers.map((d) => ``).join(""); + + const table = sortableTable( + renderTable( + [t("archive.date"), t("archive.event"), t("archive.session"), t("archive.class"), t("archive.drivers"), t("archive.winner"), t("archive.penalties_short"), ""], + filteredRows.map((row) => ` + + ${escapeHtml(row.date || "-")} + ${escapeHtml(row.eventName)} + ${escapeHtml(row.session.name)} + ${escapeHtml(row.className || "-")} + ${row.competitors.length} + ${escapeHtml(row.winnerName || "-")} + ${row.adjustmentCount || "-"} + + + `) + ), + "archive-sessions", + [1, 2, 3, 5] + ); + + dom.view.innerHTML = ` +
+

${t("archive.title")}

+
+

${t("archive.hint")}

+
+ + + + + +
+
+
+ ${filteredRows.length ? table : `

${t("archive.no_results")}

`} +
+
+ ${selectedRow ? renderArchiveDetail(selectedRow, { t, escapeHtml, formatLap, formatRaceClock, renderTable, buildLeaderboard, getCompetitorPassings }) : ""} + `; + + const rerender = () => renderResultsArchiveView(deps); + document.getElementById("archiveSearch")?.addEventListener("input", (event) => { + const input = event.currentTarget; + if (!(input instanceof HTMLInputElement)) return; + // A full re-render replaces this input's DOM node, which would otherwise drop focus and + // cursor position on every keystroke - restore both immediately after. + const caret = input.selectionStart; + setArchiveFilters({ ...filters, search: input.value }); + rerender(); + const restored = document.getElementById("archiveSearch"); + if (restored instanceof HTMLInputElement) { + restored.focus(); + restored.setSelectionRange(caret, caret); + } + }); + document.getElementById("archiveClassFilter")?.addEventListener("change", (event) => { + setArchiveFilters({ ...filters, classId: event.currentTarget.value }); + rerender(); + }); + document.getElementById("archiveDriverFilter")?.addEventListener("change", (event) => { + setArchiveFilters({ ...filters, driverId: event.currentTarget.value }); + rerender(); + }); + document.getElementById("archiveDateFrom")?.addEventListener("change", (event) => { + setArchiveFilters({ ...filters, dateFrom: event.currentTarget.value }); + rerender(); + }); + document.getElementById("archiveDateTo")?.addEventListener("change", (event) => { + setArchiveFilters({ ...filters, dateTo: event.currentTarget.value }); + rerender(); + }); + document.querySelectorAll("[data-archive-open]").forEach((button) => { + button.addEventListener("click", () => { setSelectedSessionId(button.dataset.archiveOpen); rerender(); }); + }); + document.getElementById("archiveDetailClose")?.addEventListener("click", () => { setSelectedSessionId(""); rerender(); }); + if (selectedRow?.event) { + document.getElementById("archiveExportPdf")?.addEventListener("click", () => { + exportRaceResultsPdf(selectedRow.event); + }); + document.getElementById("archiveExportJson")?.addEventListener("click", () => { + const payload = buildRacePackagePayload(selectedRow.event.id); + downloadJsonFile(`${sanitizeFilenameSegment(selectedRow.event.name)}_race_package.json`, payload); + }); + } +} diff --git a/src/styles.css b/src/styles.css index 0f62b66..ed687cb 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2188,6 +2188,22 @@ select:focus { font-size: 0.95rem; } +.archive-lap-history { + margin-bottom: 8px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 8px; +} + +.archive-lap-history summary { + cursor: pointer; + font-weight: 700; +} + +.archive-lap-history .data-table { + margin-top: 8px; +} + .data-table tr.passing-invalid td { background: var(--danger-soft); color: var(--danger-text); diff --git a/src/timing_logic.js b/src/timing_logic.js index dd81fe1..aacf0f7 100644 --- a/src/timing_logic.js +++ b/src/timing_logic.js @@ -113,6 +113,9 @@ export function ensureSessionResult(sessionId, { state }) { adjustments: [], }; } + if (!Array.isArray(state.resultsBySession[sessionId].passings)) { + state.resultsBySession[sessionId].passings = []; + } if (!Array.isArray(state.resultsBySession[sessionId].adjustments)) { state.resultsBySession[sessionId].adjustments = []; } diff --git a/tests/race_timing_logic.test.js b/tests/race_timing_logic.test.js index 799cd2b..8e10e10 100644 --- a/tests/race_timing_logic.test.js +++ b/tests/race_timing_logic.test.js @@ -23,6 +23,7 @@ import { getSessionTiming, isHighPointsTable, isUntimedSession, + ensureSessionResult, } from "../src/timing_logic.js"; function createUid() { @@ -457,3 +458,19 @@ test("paused session timing freezes and resumes without counting the pause", () assert.equal(resumed.elapsedMs, 60_000); assert.equal(resumed.remainingMs, 240_000); }); + +test("ensureSessionResult backfills a missing passings array on an already-existing result", () => { + // A result can already exist (loaded from an older export, or a hand-edited/imported race + // package) without ever going through the brand-new-session initialization branch, the same + // way it can be missing adjustments/splits/pitEvents/pitStops - passings needs the same guard. + const state = { resultsBySession: { session_1: { competitors: {} } } }; + const result = ensureSessionResult("session_1", { state }); + assert.deepEqual(result.passings, []); + assert.deepEqual(result.adjustments, []); + assert.deepEqual(result.splits, []); + assert.deepEqual(result.pitEvents, []); + assert.deepEqual(result.pitStops, []); + + const brandNew = ensureSessionResult("session_2", { state }); + assert.deepEqual(brandNew, { passings: [], splits: [], pitEvents: [], pitStops: [], competitors: {}, adjustments: [] }); +}); diff --git a/tests/server_persistence.integration.test.js b/tests/server_persistence.integration.test.js index 04b0c13..dec3c05 100644 --- a/tests/server_persistence.integration.test.js +++ b/tests/server_persistence.integration.test.js @@ -282,6 +282,34 @@ test("SQLite API persists state, passings, and overlay session results", { timeo assert.equal(rowsPayload.rows[0].car_id, "car_1"); assert.equal(rowsPayload.rows[0].strength, -52.5); assert.deepEqual(JSON.parse(rowsPayload.rows[0].raw_json), passing); + + // A second, unrelated session's passing must survive purging session_1's rows. + await fetch(`${baseUrl}/api/passings`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId: "session_other", passing: { ...passing, timestamp: 18000 } }), + }); + const purgeResponse = await fetch(`${baseUrl}/api/passings/purge`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionIds: ["session_1"] }), + }); + const purgePayload = await purgeResponse.json(); + assert.equal(purgeResponse.status, 200); + assert.equal(purgePayload.ok, true); + assert.equal(purgePayload.deleted, 1); + + const afterPurgeSession1 = await (await fetch(`${baseUrl}/api/passings?sessionId=session_1&limit=10`)).json(); + assert.equal(afterPurgeSession1.rows.length, 0, "the deleted session's rows are gone"); + const afterPurgeOtherSession = await (await fetch(`${baseUrl}/api/passings?sessionId=session_other&limit=10`)).json(); + assert.equal(afterPurgeOtherSession.rows.length, 1, "an unrelated session's rows must be untouched"); + + const emptyPurgeResponse = await fetch(`${baseUrl}/api/passings/purge`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionIds: [] }), + }); + assert.deepEqual(await emptyPurgeResponse.json(), { ok: true, deleted: 0 }); } finally { await stopServer(child); rmSync(dataDir, { recursive: true, force: true });