fix sort and obs panels

This commit is contained in:
larssand
2026-09-08 13:21:29 +02:00
parent 4b5f3422dd
commit 9e6fe2386d
8 changed files with 65 additions and 20 deletions

View File

@@ -6,7 +6,10 @@ The application version is maintained in `package.json` and displayed in the adm
## Unreleased ## 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 ## 1.1.1 - 2026-09-07

View File

@@ -19,12 +19,12 @@ export function parseSortValue(value) {
const text = String(value ?? "").trim(); const text = String(value ?? "").trim();
if (!text || /^[\s—–-]+$/.test(text)) return null; if (!text || /^[\s—–-]+$/.test(text)) return null;
const normalized = text.replace(/\u2212/g, "-").replace(/,/g, "."); 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)) { if (/^[+-]?\d+(?::\d+){1,2}(?:\.\d+)?$/.test(normalized)) {
const sign = normalized.startsWith("-") ? -1 : 1; const sign = normalized.startsWith("-") ? -1 : 1;
return sign * normalized.replace(/^[+-]/, "").split(":").reduce((total, part) => total * 60 + Number(part), 0); 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; return text;
} }
@@ -40,7 +40,8 @@ export function compareSortValues(left, right, direction = "asc") {
} }
const comparison = typeof left === "number" && typeof right === "number" const comparison = typeof left === "number" && typeof right === "number"
? left - right ? 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; return direction === "desc" ? -comparison : comparison;
} }
@@ -48,8 +49,8 @@ export function sortValuesAttribute(values) {
return escapeHtml(JSON.stringify(values)); return escapeHtml(JSON.stringify(values));
} }
export function sortableTable(markup, id) { export function sortableTable(markup, id, textColumns = []) {
return markup.replace("<table", `<table data-sort-id="${id}"`); return markup.replace("<table", `<table data-sort-id="${id}" data-sort-text-columns="${textColumns.join(",")}"`);
} }
export function installListSorting(root) { export function installListSorting(root) {
@@ -62,21 +63,23 @@ export function installListSorting(root) {
return list.tagName === "TABLE" ? [...(list.tBodies[0]?.rows || [])] : [...list.children].filter((child) => child.hasAttribute("data-sort-values")); return list.tagName === "TABLE" ? [...(list.tBodies[0]?.rows || [])] : [...list.children].filter((child) => 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) { if (row.dataset.sortValues) {
try { try {
const value = JSON.parse(row.dataset.sortValues)[column] ?? null; const value = JSON.parse(row.dataset.sortValues)[column] ?? null;
return typeof value === "string" ? parseSortValue(value) : value; return convert(value);
} catch {} } catch {}
} }
const cell = row.cells?.[column]; 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) { function apply(list) {
const order = preferences.get(list.dataset.sortId); const order = preferences.get(list.dataset.sortId);
const original = originalRows.get(list) || []; 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 parent = list.tagName === "TABLE" ? list.tBodies[0] : list;
const currentRows = rowsFor(list); const currentRows = rowsFor(list);
if (parent && rows.some((row, index) => currentRows[index] !== row)) parent.append(...rows); if (parent && rows.some((row, index) => currentRows[index] !== row)) parent.append(...rows);

View File

@@ -9,7 +9,7 @@ export function buildOverlayPanels(active, recent, { t, overlayEvents, normalize
{ {
title: t("overlay.last_passings"), title: t("overlay.last_passings"),
content: recent.length content: recent.length
? `<div data-sort-id="overlay-passings" data-sort-labels="${sortValuesAttribute([t("table.time"), t("table.driver"), t("table.last_lap")])}">${recent ? `<div data-sort-id="overlay-passings" data-sort-text-columns="1" data-sort-labels="${sortValuesAttribute([t("table.time"), t("table.driver"), t("table.last_lap")])}">${recent
.map( .map(
(passing) => ` (passing) => `
<div class="overlay-passing" data-sort-values="${sortValuesAttribute([passing.timestamp, passing.displayName || passing.teamName || passing.driverName || passing.transponder, passing.lapMs])}"> <div class="overlay-passing" data-sort-values="${sortValuesAttribute([passing.timestamp, passing.displayName || passing.teamName || passing.driverName || passing.transponder, passing.lapMs])}">
@@ -62,7 +62,7 @@ export function renderOverlayLeaderboard(rows, { t, escapeHtml, formatTeamActive
} }
const sortAttrs = sortable 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 ` return `
<div class="overlay-race-list"${sortAttrs}> <div class="overlay-race-list"${sortAttrs}>

View File

@@ -3666,15 +3666,17 @@ select:focus {
.overlay-obs-tower { .overlay-obs-tower {
display: flex; display: flex;
flex-wrap: wrap;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
} }
.overlay-obs-tower-left { .overlay-obs-tower-left {
flex: 0 1 320px;
display: grid; display: grid;
gap: 6px; gap: 6px;
max-width: 320px; max-width: 100%;
min-width: 0; min-width: 0;
} }
@@ -3688,7 +3690,8 @@ select:focus {
} }
.overlay-obs-tower-standings { .overlay-obs-tower-standings {
max-width: 340px; flex: 0 1 340px;
max-width: 100%;
min-width: 0; min-width: 0;
} }
@@ -3740,7 +3743,7 @@ select:focus {
.overlay-obs-tower-row { .overlay-obs-tower-row {
display: grid; display: grid;
grid-template-columns: 24px minmax(0, 1fr) auto; grid-template-columns: 24px minmax(72px, 1fr) minmax(0, auto);
gap: 6px; gap: 6px;
align-items: center; align-items: center;
padding: 5px 6px; padding: 5px 6px;
@@ -3774,6 +3777,8 @@ select:focus {
.overlay-obs-tower-trail { .overlay-obs-tower-trail {
display: flex; display: flex;
flex-wrap: wrap;
min-width: 0;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
justify-content: flex-end; justify-content: flex-end;

View File

@@ -311,7 +311,7 @@ function renderLeaderboard(rows, deps) {
</tr> </tr>
`; `;
}) })
), "timing-leaderboard"); ), "timing-leaderboard", [1, 2, 3]);
} }
function renderRecentPassings(session, deps) { function renderRecentPassings(session, deps) {
@@ -340,7 +340,7 @@ function renderRecentPassings(session, deps) {
</tr> </tr>
`; `;
}) })
), "timing-passings"); ), "timing-passings", [1, 2, 3, 5]);
} }
export function renderTimingView(deps) { export function renderTimingView(deps) {

View File

@@ -581,7 +581,7 @@ export function renderTrainingView(deps) {
${channelStats.recentPitStops.length ? ` ${channelStats.recentPitStops.length ? `
<div class="training-section-title"><h3>${escapeHtml(t("training.recent_pit_stops"))}</h3></div> <div class="training-section-title"><h3>${escapeHtml(t("training.recent_pit_stops"))}</h3></div>
<div class="table-wrap training-history-wrap"> <div class="table-wrap training-history-wrap">
<table data-sort-id="training-pit-stops" class="training-history-table"> <table data-sort-id="training-pit-stops" data-sort-text-columns="1,2,3" 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> <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) => ` <tbody>${channelStats.recentPitStops.map((stop) => `
<tr> <tr>
@@ -614,7 +614,7 @@ export function renderTrainingView(deps) {
<span class="training-disclosure-arrow">+</span> <span class="training-disclosure-arrow">+</span>
</summary> </summary>
<div class="training-disclosure-body table-wrap training-history-wrap"> <div class="training-disclosure-body table-wrap training-history-wrap">
<table data-sort-id="training-class-drivers" class="training-history-table training-club-table"> <table data-sort-id="training-class-drivers" data-sort-text-columns="0" 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> <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> <tbody>${clubRows}</tbody>
</table> </table>
@@ -942,7 +942,7 @@ function renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, uid, sav
<div class="panel-body"> <div class="panel-body">
<p class="hint">${escapeHtml(t("training.overview_class_hint"))}</p> <p class="hint">${escapeHtml(t("training.overview_class_hint"))}</p>
<div class="table-wrap training-history-wrap"> <div class="table-wrap training-history-wrap">
<table data-sort-id="training-overview" class="training-history-table training-club-table"> <table data-sort-id="training-overview" data-sort-text-columns="0,1" 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> <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> <tbody>${tableRows}</tbody>
</table> </table>

View File

@@ -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('<table><tbody></tbody></table>', 'timing-passings', [1, 2, 3]), /data-sort-text-columns="1,2,3"/);
});

View File

@@ -15,3 +15,4 @@ import "./training_views.test.js";
import "./session_replay.test.js"; import "./session_replay.test.js";
import "./public_training_refresh.test.js"; import "./public_training_refresh.test.js";
import "./list_sorting.test.js";