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

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