Clubs that reconfigure their track (e.g. running indoors in the same building) can now add named track layouts under Settings, each with just a start date/time - layouts are contiguous, so a layout's end is implicitly the next one's start, with no separate field to keep in sync. Every session freezes which layout was active when it started, so the "track record" audio cue and the Results Archive (which now shows each session's layout) only compare laps run on the same physical track. With no layouts configured, everything behaves exactly as before - the feature is fully inert until a club uses it. Design settled through conversation with the user: global scope (not per class), start-date-only (no redundant stop date), and no changes needed to driver/transponder mapping, since that's already fully independent of track geometry in this app. Deliberately out of scope for this pass: the mobile/driver training profile's own cross-session personal-best trend stats still compare across a driver's whole lap ledger regardless of layout - logged in ROADMAP.md as a real remaining gap for a later pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
218 lines
11 KiB
JavaScript
218 lines
11 KiB
JavaScript
import { sortableTable, sortValuesAttribute } from "./list_sorting.js";
|
|
|
|
function buildArchiveRows(state, { getEventName, getClassName, getTrackLayoutLabel }) {
|
|
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,
|
|
trackLayoutLabel: getTrackLayoutLabel(state.trackLayouts, session.trackLayoutId),
|
|
};
|
|
})
|
|
.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)}${row.trackLayoutLabel ? ` <span class="badge">${escapeHtml(row.trackLayoutLabel)}</span>` : ""}</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,
|
|
getTrackLayoutLabel,
|
|
} = deps;
|
|
|
|
const filters = getArchiveFilters();
|
|
const allRows = buildArchiveRows(state, { getEventName, getClassName, getTrackLayoutLabel });
|
|
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.layout"), 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.trackLayoutLabel, 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>${escapeHtml(row.trackLayoutLabel || "-")}</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, 4, 6]
|
|
);
|
|
|
|
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);
|
|
});
|
|
}
|
|
}
|