From 9e6fe2386d34a718f98948ede9b1aa3e5081fbd0 Mon Sep 17 00:00:00 2001 From: larssand Date: Tue, 8 Sep 2026 13:21:29 +0200 Subject: [PATCH] fix sort and obs panels --- CHANGELOG.md | 5 ++++- src/list_sorting.js | 21 ++++++++++++--------- src/overlays.js | 4 ++-- src/styles.css | 11 ++++++++--- src/timing_views.js | 4 ++-- src/training_views.js | 6 +++--- tests/list_sorting.test.js | 33 +++++++++++++++++++++++++++++++++ tests/run.js | 1 + 8 files changed, 65 insertions(+), 20 deletions(-) create mode 100644 tests/list_sorting.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 51c8a72..1521aad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,10 @@ The application version is maintained in `package.json` and displayed in the adm ## Unreleased -No changes recorded yet. +### Fixed + +- Correct chronological sorting of zero-padded times and deterministic ordering of distinct numeric-looking driver/car names. Name and identifier columns are explicitly treated as text. +- OBS race-info and standings panels wrap on narrow sources. Driver names retain space and timing fields wrap instead of overflowing. ## 1.1.1 - 2026-09-07 diff --git a/src/list_sorting.js b/src/list_sorting.js index e42f9c0..fbcaab9 100644 --- a/src/list_sorting.js +++ b/src/list_sorting.js @@ -19,12 +19,12 @@ 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 (/^0\d/.test(normalized)) return text; // zero-padded identifiers (e.g. car number "09") are names, not quantities - keep as text so "09" and "9" don't collide - 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); } + if (/^0\d+$/.test(normalized)) return text; + if (/^[+-]?\d+(?:\.\d+)?\s*(?:%|s|L)?$/.test(normalized)) return parseFloat(normalized); return text; } @@ -40,7 +40,8 @@ export function compareSortValues(left, right, direction = "asc") { } const comparison = typeof left === "number" && typeof right === "number" ? left - right - : String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: "base" }); + : String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: "base" }) + || String(left).localeCompare(String(right), undefined, { numeric: false, sensitivity: "base" }); return direction === "desc" ? -comparison : comparison; } @@ -48,8 +49,8 @@ export function sortValuesAttribute(values) { return escapeHtml(JSON.stringify(values)); } -export function sortableTable(markup, id) { - return markup.replace(" child.hasAttribute("data-sort-values")); } - function valueFor(row, column) { + function valueFor(row, column, list) { + const isText = (list.dataset.sortTextColumns || "").split(",").includes(String(column)); + const convert = (value) => isText ? (value == null ? null : String(value)) : typeof value === "string" ? parseSortValue(value) : value; if (row.dataset.sortValues) { try { const value = JSON.parse(row.dataset.sortValues)[column] ?? null; - return typeof value === "string" ? parseSortValue(value) : value; + return convert(value); } catch {} } const cell = row.cells?.[column]; - return parseSortValue(cell?.dataset.sortValue ?? cell?.querySelector(".table-primary")?.textContent ?? cell?.textContent); + return convert(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 rows = order ? [...original].sort((a, b) => compareSortValues(valueFor(a, order.column, list), valueFor(b, order.column, list), 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); diff --git a/src/overlays.js b/src/overlays.js index bc23697..82a45cf 100644 --- a/src/overlays.js +++ b/src/overlays.js @@ -9,7 +9,7 @@ export function buildOverlayPanels(active, recent, { t, overlayEvents, normalize { title: t("overlay.last_passings"), content: recent.length - ? `
${recent + ? `
${recent .map( (passing) => `
@@ -62,7 +62,7 @@ export function renderOverlayLeaderboard(rows, { t, escapeHtml, formatTeamActive } const sortAttrs = sortable - ? ` 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")])}"` + ? ` data-sort-id="overlay-leaderboard" data-sort-text-columns="1" 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")])}"` : ""; return `
diff --git a/src/styles.css b/src/styles.css index c372a20..4f3d287 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3666,15 +3666,17 @@ select:focus { .overlay-obs-tower { display: flex; + flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 10px; } .overlay-obs-tower-left { + flex: 0 1 320px; display: grid; gap: 6px; - max-width: 320px; + max-width: 100%; min-width: 0; } @@ -3688,7 +3690,8 @@ select:focus { } .overlay-obs-tower-standings { - max-width: 340px; + flex: 0 1 340px; + max-width: 100%; min-width: 0; } @@ -3740,7 +3743,7 @@ select:focus { .overlay-obs-tower-row { display: grid; - grid-template-columns: 24px minmax(0, 1fr) auto; + grid-template-columns: 24px minmax(72px, 1fr) minmax(0, auto); gap: 6px; align-items: center; padding: 5px 6px; @@ -3774,6 +3777,8 @@ select:focus { .overlay-obs-tower-trail { display: flex; + flex-wrap: wrap; + min-width: 0; align-items: center; gap: 6px; justify-content: flex-end; diff --git a/src/timing_views.js b/src/timing_views.js index 63466d8..f382afa 100644 --- a/src/timing_views.js +++ b/src/timing_views.js @@ -311,7 +311,7 @@ function renderLeaderboard(rows, deps) { `; }) - ), "timing-leaderboard"); + ), "timing-leaderboard", [1, 2, 3]); } function renderRecentPassings(session, deps) { @@ -340,7 +340,7 @@ function renderRecentPassings(session, deps) { `; }) - ), "timing-passings"); + ), "timing-passings", [1, 2, 3, 5]); } export function renderTimingView(deps) { diff --git a/src/training_views.js b/src/training_views.js index 87c4c63..cb7f6c2 100644 --- a/src/training_views.js +++ b/src/training_views.js @@ -581,7 +581,7 @@ export function renderTrainingView(deps) { ${channelStats.recentPitStops.length ? `

${escapeHtml(t("training.recent_pit_stops"))}

- +
${channelStats.recentPitStops.map((stop) => ` @@ -614,7 +614,7 @@ export function renderTrainingView(deps) { +
-
${escapeHtml(t("training.date"))}${escapeHtml(t("table.event"))}${escapeHtml(t("table.session"))}${escapeHtml(t("table.car"))}${escapeHtml(t("table.duration"))}
+
${clubRows}
${escapeHtml(t("training.driver"))}${escapeHtml(t("training.laps"))}${escapeHtml(t("training.training_days"))}${escapeHtml(t("training.best_lap"))}${escapeHtml(t("training.next_target"))}
@@ -942,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"))}
diff --git a/tests/list_sorting.test.js b/tests/list_sorting.test.js new file mode 100644 index 0000000..7a87e60 --- /dev/null +++ b/tests/list_sorting.test.js @@ -0,0 +1,33 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseSortValue, compareSortValues, sortableTable } from "../src/list_sorting.js"; + +test("zero-padded and ordinary lap times sort chronologically", () => { + const times = ["00:59.000", "0:10.000", "01:00.000", "00:09,500", "1:02:03.000"]; + assert.deepEqual(times.map(parseSortValue), [59, 10, 60, 9.5, 3723]); + assert.deepEqual([...times].sort((a, b) => compareSortValues(parseSortValue(a), parseSortValue(b))), ["00:09,500", "0:10.000", "00:59.000", "01:00.000", "1:02:03.000"]); +}); + +test("distinct numeric-looking names have deterministic ascending and descending order", () => { + for (const [left, right] of [["09", "9"], ["Car 09", "Car 9"], ["009", "09"]]) { + const comparison = compareSortValues(left, right); + assert.notEqual(comparison, 0); + assert.equal(Math.sign(comparison), -Math.sign(compareSortValues(right, left))); + assert.equal(Math.sign(comparison), -Math.sign(compareSortValues(left, right, "desc"))); + } + assert.ok(compareSortValues("Car 2", "Car 10") < 0); +}); + +test("missing times stay last in both sort directions", () => { + for (const direction of ["asc", "desc"]) { + assert.ok(compareSortValues(null, 20, direction) > 0); + assert.ok(compareSortValues(20, NaN, direction) < 0); + } + assert.equal(parseSortValue("-"), null); + assert.equal(parseSortValue("-00:01.500"), -1.5); + assert.equal(parseSortValue("+0.250s"), 0.25); +}); + +test("tables can declare names and identifiers as text columns", () => { + assert.match(sortableTable('
', 'timing-passings', [1, 2, 3]), /data-sort-text-columns="1,2,3"/); +}); diff --git a/tests/run.js b/tests/run.js index 36a2f74..92b7bcc 100644 --- a/tests/run.js +++ b/tests/run.js @@ -15,3 +15,4 @@ import "./training_views.test.js"; import "./session_replay.test.js"; import "./public_training_refresh.test.js"; +import "./list_sorting.test.js";