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 `
+ ${t("archive.no_standings")} ${t("archive.no_penalties")} ${t("timing.no_lap_history")} ${t("archive.no_standings")}${escapeHtml(row.eventName)} - ${escapeHtml(session.name)}
+ ${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.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.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("archive.hint")}
+${t("archive.no_results")}
`} +