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 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-09 15:49:10 +02:00
parent 643902ff15
commit 94794350e5
13 changed files with 421 additions and 7 deletions

View File

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

View File

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

4
package-lock.json generated
View File

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

View File

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

View File

@@ -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 || {});

View File

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

View File

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

View File

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

214
src/results_archive_view.js Normal file
View File

@@ -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 `
<section class="panel archive-detail" style="margin-top:14px">
<div class="panel-header">
<h3>${escapeHtml(row.eventName)} - ${escapeHtml(session.name)}</h3>
<div class="actions-inline">
${event ? `<button class="btn" id="archiveExportPdf" type="button">${t("archive.export_pdf")}</button>` : ""}
${event ? `<button class="btn" id="archiveExportJson" type="button">${t("archive.export_json")}</button>` : ""}
<button class="btn" id="archiveDetailClose" type="button">${t("archive.close")}</button>
</div>
</div>
<div class="panel-body">
<h4>${t("archive.standings")}</h4>
${leaderboard.length
? renderTable(
[t("table.pos"), t("table.driver"), t("table.laps"), t("table.best_lap"), t("timing.total_time")],
leaderboard.map((rowItem, index) => `
<tr>
<td>${index + 1}</td>
<td>${escapeHtml(rowItem.displayName || rowItem.driverName || t("common.unknown_driver"))}</td>
<td>${rowItem.laps}</td>
<td>${formatLap(rowItem.bestLapMs)}</td>
<td>${escapeHtml(rowItem.resultDisplay || "-")}</td>
</tr>
`)
)
: `<p>${t("archive.no_standings")}</p>`}
</div>
<div class="panel-body">
<h4>${t("archive.penalties")}</h4>
${adjustments.length
? renderTable(
[t("table.time"), t("table.driver"), t("archive.action"), t("archive.detail")],
adjustments.slice().reverse().map((adjustment) => `
<tr>
<td>${escapeHtml(new Date(adjustment.ts || 0).toLocaleTimeString())}</td>
<td>${escapeHtml(adjustment.displayName || "-")}</td>
<td>${escapeHtml(t(`archive.adjustment_${adjustment.category}`) || adjustment.category || "-")}</td>
<td>${escapeHtml(adjustment.detail || "-")}</td>
</tr>
`)
)
: `<p>${t("archive.no_penalties")}</p>`}
</div>
<div class="panel-body">
<h4>${t("archive.lap_history")}</h4>
${leaderboard.map((rowItem) => {
const passings = getCompetitorPassings(session, rowItem);
return `
<details class="archive-lap-history">
<summary>${escapeHtml(rowItem.displayName || rowItem.driverName || t("common.unknown_driver"))} - ${rowItem.laps} ${t("table.laps")}</summary>
${passings.length
? renderTable(
[t("table.lap"), t("table.time"), t("table.last_lap")],
passings.map((passing, index) => `
<tr>
<td>${index + 1}</td>
<td>${formatRaceClock(Math.max(0, passing.timestamp - (session.startedAt || passing.timestamp)))}</td>
<td>${formatLap(passing.lapMs)}</td>
</tr>
`)
)
: `<p>${t("timing.no_lap_history")}</p>`}
</details>
`;
}).join("") || `<p>${t("archive.no_standings")}</p>`}
</div>
</section>
`;
}
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) => `<option value="${escapeHtml(c.id)}" ${c.id === filters.classId ? "selected" : ""}>${escapeHtml(c.name)}</option>`).join("");
const driverOptions = state.drivers.map((d) => `<option value="${escapeHtml(d.id)}" ${d.id === filters.driverId ? "selected" : ""}>${escapeHtml(d.name)}</option>`).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) => `
<tr data-sort-values="${sortValuesAttribute([row.date, row.eventName, row.session.name, row.className, row.competitors.length, row.winnerName, row.adjustmentCount, null])}">
<td>${escapeHtml(row.date || "-")}</td>
<td>${escapeHtml(row.eventName)}</td>
<td>${escapeHtml(row.session.name)}</td>
<td>${escapeHtml(row.className || "-")}</td>
<td>${row.competitors.length}</td>
<td>${escapeHtml(row.winnerName || "-")}</td>
<td>${row.adjustmentCount || "-"}</td>
<td><button class="btn btn-mini" data-archive-open="${escapeHtml(row.session.id)}" type="button">${t("archive.view")}</button></td>
</tr>
`)
),
"archive-sessions",
[1, 2, 3, 5]
);
dom.view.innerHTML = `
<section class="panel">
<div class="panel-header"><h3>${t("archive.title")}</h3></div>
<div class="panel-body">
<p class="hint">${t("archive.hint")}</p>
<div class="form-grid cols-5">
<label><span>${t("archive.search")}</span><input id="archiveSearch" value="${escapeHtml(filters.search)}" placeholder="${t("archive.search_placeholder")}" /></label>
<label><span>${t("archive.class")}</span><select id="archiveClassFilter"><option value="">${t("archive.all_classes")}</option>${classOptions}</select></label>
<label><span>${t("archive.driver")}</span><select id="archiveDriverFilter"><option value="">${t("archive.all_drivers")}</option>${driverOptions}</select></label>
<label><span>${t("archive.date_from")}</span><input id="archiveDateFrom" type="date" value="${escapeHtml(filters.dateFrom)}" /></label>
<label><span>${t("archive.date_to")}</span><input id="archiveDateTo" type="date" value="${escapeHtml(filters.dateTo)}" /></label>
</div>
</div>
<div class="panel-body" id="archiveResultsBody">
${filteredRows.length ? table : `<p>${t("archive.no_results")}</p>`}
</div>
</section>
${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);
});
}
}

View File

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

View File

@@ -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 = [];
}

View File

@@ -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: [] });
});

View File

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