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"))}

- +
${channelStats.recentPitStops.map((stop) => ` - + @@ -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("table.event"))}${escapeHtml(t("table.session"))}${escapeHtml(t("table.car"))}${escapeHtml(t("table.duration"))}
${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 || "-")}
+
${dailyRows}
${escapeHtml(t("training.date"))}${escapeHtml(t("training.laps"))}${escapeHtml(t("training.best_lap"))}${escapeHtml(t("training.average_lap"))}
@@ -613,7 +614,7 @@ export function renderTrainingView(deps) { +
- +
${clubRows}
${escapeHtml(t("training.driver"))}${escapeHtml(t("training.laps"))}${escapeHtml(t("training.training_days"))}${escapeHtml(t("training.best_lap"))}${escapeHtml(t("training.next_target"))}
@@ -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"))}

- +
${tableRows}
${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"))}