add sorting

This commit is contained in:
larssand
2026-09-05 13:50:11 +02:00
parent 9c74c1f256
commit a2e29b89dd
6 changed files with 193 additions and 22 deletions

View File

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

138
src/list_sorting.js Normal file
View File

@@ -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("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
export function sortableTable(markup, id) {
return markup.replace("<table", `<table data-sort-id="${id}"`);
}
export function installListSorting(root) {
loadPreferences();
const initialized = new WeakSet();
const originalRows = new WeakMap();
const selector = "[data-sort-id], .training-history-table";
function rowsFor(list) {
return list.tagName === "TABLE" ? [...(list.tBodies[0]?.rows || [])] : [...list.children].filter((child) => 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();
}

View File

@@ -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
? `<div data-sort-id="overlay-passings" data-sort-labels="${sortValuesAttribute([t("table.time"), t("table.driver"), t("table.last_lap")])}">${recent
.map(
(passing) => `
<div class="overlay-passing">
<div class="overlay-passing" data-sort-values="${sortValuesAttribute([passing.timestamp, passing.displayName || passing.teamName || passing.driverName || passing.transponder, passing.lapMs])}">
<strong>${escapeHtmlLocal(passing.displayName || passing.teamName || passing.driverName || passing.transponder || t("common.unknown_driver"))}</strong>
<span>${passing.lapMs == null ? "-" : formatLapLocal(passing.lapMs)}</span>
</div>
`
)
.join("")
.join("")}</div>`
: `<p>${t("timing.no_passings")}</p>`,
},
{
@@ -60,12 +61,12 @@ export function renderOverlayLeaderboard(rows, { t, escapeHtml, formatTeamActive
}
return `
<div class="overlay-race-list">
<div class="overlay-race-list" data-sort-id="overlay-leaderboard" data-sort-labels="${sortValuesAttribute([t("table.pos"), t("table.driver"), t("table.laps"), t("table.result"), t("table.gap"), t("table.ahead_gap"), t("table.own_delta"), t("table.best_lap")])}">
${rows
.map((row, idx) => {
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
return `
<article class="overlay-race-row ${idx === 0 ? "overlay-race-row-leader" : ""} ${row.invalidPending ? "overlay-race-row-invalid" : ""}" data-overlay-competitor="${escapeHtml(row.key)}">
<article class="overlay-race-row ${idx === 0 ? "overlay-race-row-leader" : ""} ${row.invalidPending ? "overlay-race-row-invalid" : ""}" data-sort-values="${sortValuesAttribute([idx + 1, row.displayName || row.driverName, row.laps ?? 0, row.resultDisplay, row.gapDisplay || row.gapAhead || null, row.gapAhead || null, row.lapDelta || null, row.bestLapMs || null])}" data-overlay-competitor="${escapeHtml(row.key)}">
<div class="overlay-race-pos">
<span class="pos-pill ${posClass}">${idx + 1}</span>
</div>

View File

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

View File

@@ -1,3 +1,4 @@
import { sortableTable, sortValuesAttribute } from "./list_sorting.js";
import {
advanceSessionReplay,
buildSessionReplaySnapshot,
@@ -267,7 +268,7 @@ function renderLeaderboard(rows, deps) {
return `<p>${t("timing.no_laps")}</p>`;
}
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 `
<tr class="${row.invalidPending ? "leaderboard-invalid" : ""}">
<tr data-sort-values="${sortValuesAttribute([idx + 1, row.displayName || row.driverName, row.subLabel || row.carName, row.transponder, row.laps, row.resultDisplay, row.lastLapMs || null, row.bestLapMs || null, row.leaderGap || row.gap || null, row.gapAhead || null, row.lapDelta || null, row.lastSplitMs || null, [Number(row.pitStops || 0), Number(row.totalPitMs || 0)]])}" class="${row.invalidPending ? "leaderboard-invalid" : ""}">
<td><span class="pos-pill ${posClass}">${idx + 1}</span></td>
<td>
<div class="table-primary">${escapeHtml(row.displayName || row.driverName)}</div>
@@ -310,7 +311,7 @@ function renderLeaderboard(rows, deps) {
</tr>
`;
})
);
), "timing-leaderboard");
}
function renderRecentPassings(session, deps) {
@@ -324,11 +325,11 @@ function renderRecentPassings(session, deps) {
return `<p>${t("timing.no_passings")}</p>`;
}
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 `
<tr class="${isCountedPassing(p) ? "" : "passing-invalid"}">
<tr data-sort-values="${sortValuesAttribute([p.timestamp, p.transponder, p.teamName || p.driverName, p.carName || p.subLabel, p.lapMs, getPassingValidationLabel(p)])}" class="${isCountedPassing(p) ? "" : "passing-invalid"}">
<td>${new Date(p.timestamp).toLocaleTimeString()}</td>
<td>${escapeHtml(p.transponder)}</td>
<td>${escapeHtml(p.teamName ? `${p.teamName} • ${p.driverName || t("common.unknown_driver")}` : p.driverName || t("common.unknown_driver"))}</td>
@@ -339,7 +340,7 @@ function renderRecentPassings(session, deps) {
</tr>
`;
})
);
), "timing-passings");
}
export function renderTimingView(deps) {

View File

@@ -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 `
<tr class="${row.insufficient ? "is-muted" : ""}">
<tr data-sort-values="${sortValuesAttribute([row.number, row.startedAt, row.lapCount, row.bestLapMs || null, row.paceMs || null, row.racePace10Ms || null, row.mistakes, row.endChangeMs, row.consistencyPct, row.fiveMinuteResult ? [row.fiveMinuteResult.lapCount, row.fiveMinuteResult.totalMs] : null, row.durationMs])}" class="${row.insufficient ? "is-muted" : ""}">
<td>${row.number}</td>
<td>${escapeHtml(new Date(row.startedAt).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" }))}</td>
<td>${row.lapCount}</td>
@@ -182,7 +183,7 @@ export function renderTrainingView(deps) {
const dailyRows = stats.dailySummaries.length
? stats.dailySummaries.slice(0, 10).map((day) => `
<tr>
<td>${escapeHtml(new Date(day.latestTimestamp).toLocaleDateString(undefined, { dateStyle: "medium" }))}</td>
<td data-sort-value="${day.latestTimestamp}">${escapeHtml(new Date(day.latestTimestamp).toLocaleDateString(undefined, { dateStyle: "medium" }))}</td>
<td>${day.laps}</td>
<td>${formatLap(day.bestLapMs)}</td>
<td>${formatLap(day.averageLapMs)}</td>
@@ -237,7 +238,7 @@ export function renderTrainingView(deps) {
.map((entry) => {
const milestone = getNextTrainingMilestone(entry.stats);
return `
<tr class="${entry.profile.id === selectedProfileId ? "is-selected" : ""}">
<tr data-sort-values="${sortValuesAttribute([entry.driver?.name, entry.stats.totalLaps, entry.stats.trainingDays, entry.stats.bestLapMs || null, milestone.targetLaps])}" class="${entry.profile.id === selectedProfileId ? "is-selected" : ""}">
<td><button class="training-club-driver" data-driver-id="${escapeHtml(entry.profile.id)}" data-class-id="${escapeHtml(selectedClassId)}" type="button">${escapeHtml(entry.driver?.name || "-")}</button></td>
<td>${entry.stats.totalLaps}</td>
<td>${entry.stats.trainingDays}</td>
@@ -546,13 +547,13 @@ export function renderTrainingView(deps) {
${stintAnalysis.best ? `<span class="badge success">${escapeHtml(t("training.best_stint", { stint: stintAnalysis.best.number, laps: stintAnalysis.best.lapCount, pace: formatLap(stintAnalysis.best.paceMs) }))}</span>` : ""}
</div>
<div class="table-wrap training-history-wrap">
<table class="training-history-table training-stint-table"><thead>${stintColumns}</thead><tbody>${featuredStintRows}</tbody></table>
<table data-sort-id="training-stints-featured" class="training-history-table training-stint-table"><thead>${stintColumns}</thead><tbody>${featuredStintRows}</tbody></table>
</div>
${stintAnalysis.rows.length > featuredStints.length ? `
<details class="training-stint-all">
<summary>${escapeHtml(t("training.show_all_stints", { count: stintAnalysis.rows.length }))}</summary>
<div class="table-wrap training-history-wrap">
<table class="training-history-table training-stint-table"><thead>${stintColumns}</thead><tbody>${allStintRows}</tbody></table>
<table data-sort-id="training-stints-all" class="training-history-table training-stint-table"><thead>${stintColumns}</thead><tbody>${allStintRows}</tbody></table>
</div>
</details>` : ""}
<p class="hint training-method-note">${escapeHtml(t("training.stats_method"))}</p>
@@ -580,11 +581,11 @@ export function renderTrainingView(deps) {
${channelStats.recentPitStops.length ? `
<div class="training-section-title"><h3>${escapeHtml(t("training.recent_pit_stops"))}</h3></div>
<div class="table-wrap training-history-wrap">
<table class="training-history-table">
<table data-sort-id="training-pit-stops" class="training-history-table">
<thead><tr><th>${escapeHtml(t("training.date"))}</th><th>${escapeHtml(t("table.event"))}</th><th>${escapeHtml(t("table.session"))}</th><th>${escapeHtml(t("table.car"))}</th><th>${escapeHtml(t("table.duration"))}</th></tr></thead>
<tbody>${channelStats.recentPitStops.map((stop) => `
<tr>
<td>${escapeHtml(new Date(stop.enteredAt).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }))}</td>
<td data-sort-value="${stop.enteredAt}">${escapeHtml(new Date(stop.enteredAt).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }))}</td>
<td>${escapeHtml(stop.eventName || "-")}</td>
<td>${escapeHtml(stop.sessionName || "-")}</td>
<td>${escapeHtml(stop.carName || "-")}</td>
@@ -599,7 +600,7 @@ export function renderTrainingView(deps) {
${stats.personalBestGainMs ? `<span class="badge success">${escapeHtml(t("training.pb_improvement", { lap: formatLap(stats.personalBestGainMs) }))}</span>` : ""}
</div>
<div class="table-wrap training-history-wrap">
<table class="training-history-table">
<table data-sort-id="training-history" class="training-history-table">
<thead><tr><th>${escapeHtml(t("training.date"))}</th><th>${escapeHtml(t("training.laps"))}</th><th>${escapeHtml(t("training.best_lap"))}</th><th>${escapeHtml(t("training.average_lap"))}</th></tr></thead>
<tbody>${dailyRows}</tbody>
</table>
@@ -613,7 +614,7 @@ export function renderTrainingView(deps) {
<span class="training-disclosure-arrow">+</span>
</summary>
<div class="training-disclosure-body table-wrap training-history-wrap">
<table class="training-history-table training-club-table">
<table data-sort-id="training-class-drivers" class="training-history-table training-club-table">
<thead><tr><th>${escapeHtml(t("training.driver"))}</th><th>${escapeHtml(t("training.laps"))}</th><th>${escapeHtml(t("training.training_days"))}</th><th>${escapeHtml(t("training.best_lap"))}</th><th>${escapeHtml(t("training.next_target"))}</th></tr></thead>
<tbody>${clubRows}</tbody>
</table>
@@ -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 `
<tr>
<tr data-sort-values="${sortValuesAttribute([row.driver?.name, row.className, row.stats.totalLaps, row.stats.trainingDays, row.stats.bestLapMs || null, row.stats.lastLapAt || null, milestone.targetLaps])}">
<td><button class="training-club-driver" data-driver-id="${escapeHtml(row.profile.id)}" data-class-id="${escapeHtml(row.classId)}" type="button">${escapeHtml(row.driver?.name || "-")}</button></td>
<td>${escapeHtml(row.className)}</td>
<td>${row.stats.totalLaps}</td>
@@ -941,7 +942,7 @@ function renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, uid, sav
<div class="panel-body">
<p class="hint">${escapeHtml(t("training.overview_class_hint"))}</p>
<div class="table-wrap training-history-wrap">
<table class="training-history-table training-club-table">
<table data-sort-id="training-overview" class="training-history-table training-club-table">
<thead><tr><th>${escapeHtml(t("training.driver"))}</th><th>${escapeHtml(t("training.class"))}</th><th>${escapeHtml(t("training.laps"))}</th><th>${escapeHtml(t("training.training_days"))}</th><th>${escapeHtml(t("training.best_lap"))}</th><th>${escapeHtml(t("training.last_activity"))}</th><th>${escapeHtml(t("training.next_target"))}</th></tr></thead>
<tbody>${tableRows}</tbody>
</table>