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:
214
src/results_archive_view.js
Normal file
214
src/results_archive_view.js
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user