diff --git a/src/app.js b/src/app.js
index f3c7102..3a7c378 100644
--- a/src/app.js
+++ b/src/app.js
@@ -1,3 +1,4 @@
+import { installListSorting } from "./list_sorting.js";
import { getOverlayModeLabel, buildOverlayPanels, renderOverlaySidePanel, renderOverlayLeaderboard, renderTeamOverlay, renderObsOverlay } from "./overlays.js";
import { renderSettings } from "./settings.js";
@@ -3377,6 +3378,7 @@ const dom = {
init();
async function init() {
+ installListSorting(document.body);
document.body.classList.toggle("overlay-mode", overlayMode);
["focusin", "input", "change"].forEach((eventName) => {
dom.view.addEventListener(eventName, markSettingsDraftActive);
diff --git a/src/list_sorting.js b/src/list_sorting.js
new file mode 100644
index 0000000..ea032ce
--- /dev/null
+++ b/src/list_sorting.js
@@ -0,0 +1,138 @@
+const storageKey = "racecore.listSorting.v1";
+const preferences = new Map();
+let loaded = false;
+
+function loadPreferences() {
+ if (loaded) return;
+ loaded = true;
+ try {
+ const saved = JSON.parse(localStorage.getItem(storageKey) || "{}");
+ for (const [key, value] of Object.entries(saved)) {
+ if (Number.isInteger(value?.column) && ["asc", "desc"].includes(value.direction)) preferences.set(key, value);
+ }
+ } catch {}
+}
+
+export function parseSortValue(value) {
+ const text = String(value ?? "").trim();
+ if (!text || /^[\s—–-]+$/.test(text)) return null;
+ const normalized = text.replace(/\u2212/g, "-").replace(/,/g, ".");
+ if (/^[+-]?\d+(?:\.\d+)?\s*(?:%|s|L)?$/.test(normalized)) return parseFloat(normalized);
+ if (/^[+-]?\d+(?::\d+){1,2}(?:\.\d+)?$/.test(normalized)) {
+ const sign = normalized.startsWith("-") ? -1 : 1;
+ return sign * normalized.replace(/^[+-]/, "").split(":").reduce((total, part) => total * 60 + Number(part), 0);
+ }
+ return text;
+}
+
+export function compareSortValues(left, right, direction = "asc") {
+ if (left == null || left === "" || (typeof left === "number" && !Number.isFinite(left))) return right == null || right === "" || (typeof right === "number" && !Number.isFinite(right)) ? 0 : 1;
+ if (right == null || right === "" || (typeof right === "number" && !Number.isFinite(right))) return -1;
+ if (Array.isArray(left) && Array.isArray(right)) {
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
+ const result = compareSortValues(left[i], right[i], direction);
+ if (result) return result;
+ }
+ return 0;
+ }
+ const comparison = typeof left === "number" && typeof right === "number"
+ ? left - right
+ : String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: "base" });
+ return direction === "desc" ? -comparison : comparison;
+}
+
+export function sortValuesAttribute(values) {
+ return JSON.stringify(values).replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">");
+}
+
+export function sortableTable(markup, id) {
+ return markup.replace("
child.hasAttribute("data-sort-values"));
+ }
+
+ function valueFor(row, column) {
+ if (row.dataset.sortValues) {
+ try {
+ const value = JSON.parse(row.dataset.sortValues)[column] ?? null;
+ return typeof value === "string" ? parseSortValue(value) : value;
+ } catch {}
+ }
+ const cell = row.cells?.[column];
+ return parseSortValue(cell?.dataset.sortValue ?? cell?.querySelector(".table-primary")?.textContent ?? cell?.textContent);
+ }
+
+ function apply(list) {
+ const order = preferences.get(list.dataset.sortId);
+ const original = originalRows.get(list) || [];
+ const rows = order ? [...original].sort((a, b) => compareSortValues(valueFor(a, order.column), valueFor(b, order.column), order.direction)) : original;
+ const parent = list.tagName === "TABLE" ? list.tBodies[0] : list;
+ const currentRows = rowsFor(list);
+ if (parent && rows.some((row, index) => currentRows[index] !== row)) parent.append(...rows);
+ list.querySelectorAll("[data-sort-column]").forEach((button) => {
+ const active = order?.column === Number(button.dataset.sortColumn);
+ button.querySelector(".list-sort-arrow").textContent = active ? (order.direction === "asc" ? " ▲" : " ▼") : " ↕";
+ if (button.parentElement.tagName === "TH") button.parentElement.setAttribute("aria-sort", active ? (order.direction === "asc" ? "ascending" : "descending") : "none");
+ else button.setAttribute("aria-pressed", String(active));
+ });
+ }
+
+ function enhance(list) {
+ if (initialized.has(list)) return;
+ initialized.add(list);
+ if (!list.dataset.sortId) {
+ const tables = [...root.querySelectorAll(".training-history-table")];
+ const view = new URLSearchParams(location.search).get("view") || "training";
+ list.dataset.sortId = `${view}:${list.className}:${tables.indexOf(list)}`;
+ }
+ originalRows.set(list, rowsFor(list));
+ const headers = list.tagName === "TABLE" ? [...(list.tHead?.rows[0]?.cells || [])] : [];
+ let labels = [];
+ try { labels = JSON.parse(list.dataset.sortLabels || "[]"); } catch {}
+ const toolbar = headers.length ? null : document.createElement("div");
+ if (toolbar) { toolbar.className = "list-sort-toolbar"; list.prepend(toolbar); }
+ (headers.length ? headers.map((header) => header.textContent.trim()) : labels).forEach((label, column) => {
+ if (!label) return;
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "list-sort-button";
+ button.dataset.sortColumn = column;
+ button.append(document.createTextNode(label));
+ const arrow = document.createElement("span");
+ arrow.className = "list-sort-arrow";
+ arrow.setAttribute("aria-hidden", "true");
+ button.append(arrow);
+ if (headers[column]) headers[column].replaceChildren(button);
+ else toolbar.append(button);
+ button.addEventListener("click", () => {
+ const id = list.dataset.sortId;
+ const previous = preferences.get(id);
+ if (previous?.column === column && previous.direction === "desc") preferences.delete(id);
+ else preferences.set(id, { column, direction: previous?.column === column ? "desc" : "asc" });
+ try { localStorage.setItem(storageKey, JSON.stringify(Object.fromEntries(preferences))); } catch {}
+ apply(list);
+ });
+ });
+ apply(list);
+ }
+
+ function scan(node) {
+ if (node.nodeType !== 1) return;
+ if (node.matches(selector)) enhance(node);
+ node.querySelectorAll(selector).forEach(enhance);
+ }
+ scan(root);
+ const observer = new MutationObserver((records) => {
+ for (const record of records) for (const node of record.addedNodes) scan(node);
+ });
+ observer.observe(root, { childList: true, subtree: true });
+ return () => observer.disconnect();
+}
diff --git a/src/overlays.js b/src/overlays.js
index cc04909..c0dd6d9 100644
--- a/src/overlays.js
+++ b/src/overlays.js
@@ -1,3 +1,4 @@
+import { sortValuesAttribute } from "./list_sorting.js";
export function getOverlayModeLabel(mode, { t }) {
return t(`overlay.mode_${String(mode || "leaderboard").toLowerCase()}`);
}
@@ -7,16 +8,16 @@ export function buildOverlayPanels(active, recent, { t, overlayEvents, normalize
{
title: t("overlay.last_passings"),
content: recent.length
- ? recent
+ ? `${recent
.map(
(passing) => `
-
+
${escapeHtmlLocal(passing.displayName || passing.teamName || passing.driverName || passing.transponder || t("common.unknown_driver"))}
${passing.lapMs == null ? "-" : formatLapLocal(passing.lapMs)}
`
)
- .join("")
+ .join("")}
`
: `
${t("timing.no_passings")}
`,
},
{
@@ -60,12 +61,12 @@ export function renderOverlayLeaderboard(rows, { t, escapeHtml, formatTeamActive
}
return `
-
+
${rows
.map((row, idx) => {
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
return `
-
+
${idx + 1}
diff --git a/src/styles.css b/src/styles.css
index 473373d..57fa8af 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -3987,3 +3987,31 @@ select:focus {
.training-share-sections legend { padding: 0 6px; font-weight: 700; }
.training-share-sections .hint { grid-column: 1 / -1; margin: 0 0 4px; }
.training-share-sections label { display: flex; align-items: center; gap: 8px; }
+
+.list-sort-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ min-height: 36px;
+ padding: 3px 0;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ text-align: inherit;
+ cursor: pointer;
+}
+.list-sort-button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.list-sort-arrow { opacity: 0.65; white-space: nowrap; }
+[aria-sort="ascending"] .list-sort-button,
+[aria-sort="descending"] .list-sort-button,
+.list-sort-button[aria-pressed="true"] { color: var(--accent); }
+.list-sort-toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 2px 12px;
+ color: var(--muted);
+ font-size: 0.7rem;
+}
+.overlay-shell-obs .list-sort-toolbar { display: none; }
+@media print { .list-sort-toolbar, .list-sort-arrow { display: none; } }
diff --git a/src/timing_views.js b/src/timing_views.js
index 1d5145c..63466d8 100644
--- a/src/timing_views.js
+++ b/src/timing_views.js
@@ -1,3 +1,4 @@
+import { sortableTable, sortValuesAttribute } from "./list_sorting.js";
import {
advanceSessionReplay,
buildSessionReplaySnapshot,
@@ -267,7 +268,7 @@ function renderLeaderboard(rows, deps) {
return `${t("timing.no_laps")}
`;
}
- return renderTable(
+ return sortableTable(renderTable(
[
t("table.pos"),
t("table.driver"),
@@ -287,7 +288,7 @@ function renderLeaderboard(rows, deps) {
rows.map((row, idx) => {
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
return `
-
+
| ${idx + 1} |
${escapeHtml(row.displayName || row.driverName)}
@@ -310,7 +311,7 @@ function renderLeaderboard(rows, deps) {
|
`;
})
- );
+ ), "timing-leaderboard");
}
function renderRecentPassings(session, deps) {
@@ -324,11 +325,11 @@ function renderRecentPassings(session, deps) {
return `${t("timing.no_passings")}
`;
}
- return renderTable(
+ return sortableTable(renderTable(
[t("table.time"), t("table.transponder"), t("table.driver"), t("table.car"), t("table.last_lap"), t("table.status"), ""],
items.map((p, index) => {
return `
-
+
| ${new Date(p.timestamp).toLocaleTimeString()} |
${escapeHtml(p.transponder)} |
${escapeHtml(p.teamName ? `${p.teamName} • ${p.driverName || t("common.unknown_driver")}` : p.driverName || t("common.unknown_driver"))} |
@@ -339,7 +340,7 @@ function renderRecentPassings(session, deps) {
`;
})
- );
+ ), "timing-passings");
}
export function renderTimingView(deps) {
diff --git a/src/training_views.js b/src/training_views.js
index 05ecff8..87c4c63 100644
--- a/src/training_views.js
+++ b/src/training_views.js
@@ -1,3 +1,4 @@
+import { sortValuesAttribute } from "./list_sorting.js";
import { getDriverProfileGroups, getDriverProfileReferences } from "./driver_profile_logic.js";
function renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock }) {
@@ -12,7 +13,7 @@ function renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock }) {
? `${paceRetention} · ${t("training.strong_finish")}`
: paceRetention;
return `
-
+
| ${row.number} |
${escapeHtml(new Date(row.startedAt).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" }))} |
${row.lapCount} |
@@ -182,7 +183,7 @@ export function renderTrainingView(deps) {
const dailyRows = stats.dailySummaries.length
? stats.dailySummaries.slice(0, 10).map((day) => `
- | ${escapeHtml(new Date(day.latestTimestamp).toLocaleDateString(undefined, { dateStyle: "medium" }))} |
+ ${escapeHtml(new Date(day.latestTimestamp).toLocaleDateString(undefined, { dateStyle: "medium" }))} |
${day.laps} |
${formatLap(day.bestLapMs)} |
${formatLap(day.averageLapMs)} |
@@ -237,7 +238,7 @@ export function renderTrainingView(deps) {
.map((entry) => {
const milestone = getNextTrainingMilestone(entry.stats);
return `
-
+
|
${entry.stats.totalLaps} |
${entry.stats.trainingDays} |
@@ -546,13 +547,13 @@ export function renderTrainingView(deps) {
${stintAnalysis.best ? `${escapeHtml(t("training.best_stint", { stint: stintAnalysis.best.number, laps: stintAnalysis.best.lapCount, pace: formatLap(stintAnalysis.best.paceMs) }))}` : ""}
-
${stintColumns}${featuredStintRows}
+
${stintColumns}${featuredStintRows}
${stintAnalysis.rows.length > featuredStints.length ? `
${escapeHtml(t("training.show_all_stints", { count: stintAnalysis.rows.length }))}
-
${stintColumns}${allStintRows}
+
${stintColumns}${allStintRows}
` : ""}
${escapeHtml(t("training.stats_method"))}
@@ -580,11 +581,11 @@ export function renderTrainingView(deps) {
${channelStats.recentPitStops.length ? `
${escapeHtml(t("training.recent_pit_stops"))}
-
+
| ${escapeHtml(t("training.date"))} | ${escapeHtml(t("table.event"))} | ${escapeHtml(t("table.session"))} | ${escapeHtml(t("table.car"))} | ${escapeHtml(t("table.duration"))} |
${channelStats.recentPitStops.map((stop) => `
- | ${escapeHtml(new Date(stop.enteredAt).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }))} |
+ ${escapeHtml(new Date(stop.enteredAt).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }))} |
${escapeHtml(stop.eventName || "-")} |
${escapeHtml(stop.sessionName || "-")} |
${escapeHtml(stop.carName || "-")} |
@@ -599,7 +600,7 @@ export function renderTrainingView(deps) {
${stats.personalBestGainMs ? `${escapeHtml(t("training.pb_improvement", { lap: formatLap(stats.personalBestGainMs) }))}` : ""}
-
+
| ${escapeHtml(t("training.date"))} | ${escapeHtml(t("training.laps"))} | ${escapeHtml(t("training.best_lap"))} | ${escapeHtml(t("training.average_lap"))} |
${dailyRows}
@@ -613,7 +614,7 @@ export function renderTrainingView(deps) {
+
-
+
| ${escapeHtml(t("training.driver"))} | ${escapeHtml(t("training.laps"))} | ${escapeHtml(t("training.training_days"))} | ${escapeHtml(t("training.best_lap"))} | ${escapeHtml(t("training.next_target"))} |
${clubRows}
@@ -859,7 +860,7 @@ function renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, uid, sav
? new Date(row.stats.lastLapAt).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })
: "-";
return `
-
+
|
${escapeHtml(row.className)} |
${row.stats.totalLaps} |
@@ -941,7 +942,7 @@ function renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, uid, sav
${escapeHtml(t("training.overview_class_hint"))}
-
+
| ${escapeHtml(t("training.driver"))} | ${escapeHtml(t("training.class"))} | ${escapeHtml(t("training.laps"))} | ${escapeHtml(t("training.training_days"))} | ${escapeHtml(t("training.best_lap"))} | ${escapeHtml(t("training.last_activity"))} | ${escapeHtml(t("training.next_target"))} |
${tableRows}