fix sort and obs panels
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
33
tests/list_sorting.test.js
Normal file
33
tests/list_sorting.test.js
Normal 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"/);
|
||||||
|
});
|
||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user