diff --git a/CHANGELOG.md b/CHANGELOG.md index 66cd32d..84a4452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.31.0 - 2026-10-07 + +### Added + +- Min and max lap time per track layout, for practice. A rebuilt track is often shorter or longer, so each layout can have its own lap window: + - set when the layout is created (Settings, or the "New layout" button, which now also asks for a minimum lap time); + - edited later in the layout list under Settings; + - saved to the layout when edited from Race Day's lap window form while a practice session on a layout is in focus. + + Empty means the event's value applies, as before. Open and Free practice on that layout use it, including a long-running Open Practice that is moved to a new layout. Qualifying, heats and finals always use the event's values, so a layout can never change the limits of a race. + ## 1.30.0 - 2026-10-07 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 1cdae6a..9ae0ead 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -716,6 +716,19 @@ Verified 2026-10-07: Possible next step: a per-layout minimum lap time (option 3 from the proposal), not built. +## Min/max lap time per track layout + +Asked by the user 2026-10-07. The minimum and maximum lap time came only from the event (`raceConfig`), so Layout 2 had the same limits as Layout 1. A shorter rebuilt track would then reject real laps, and a longer one would let shortcuts through. + +- [x] Layouts store optional `minLapMs` and `maxLapMs` (0 = not set). `getLayoutLapWindowOverride` in `track_layouts_logic.js` applies them to Open and Free practice on that layout only. It is used by both `getSessionLapWindow`, which decides whether a lap is too short or too long while timing, and the Race Day preflight. Races always keep the event's values. +- [x] Set when a layout is created: in Settings (min and max fields), or with the "New layout" button (asks for the min lap; empty means the event's). Edited per layout in the Settings list. Max ≤ min is refused. Race Day's lap window form saves to the layout when a practice session on a layout is in focus. + +Verified 2026-10-07: +- 253 unit tests pass. They check that practice uses the layout's values (both set, only min set, none set), that a heat on the same layout ignores them, the normalization, and that the Race Day preflight shows the layout's window. +- The browser test saves 8.5 s / 40 s on a layout in Settings and checks that 30 / 20 is refused with an alert. +- 57 browser scenarios pass. +- Not covered by an automated test: the Race Day form saving to the layout during practice. + ## Documentation maintenance - [x] Update the architecture's module/dependency inventory as modules change. diff --git a/package-lock.json b/package-lock.json index 5090283..b4f922e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.30.0", + "version": "1.31.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.30.0", + "version": "1.31.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index 9b46c74..4ce83d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.30.0", + "version": "1.31.0", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/src/app.js b/src/app.js index 7295e0e..12ea834 100644 --- a/src/app.js +++ b/src/app.js @@ -1246,6 +1246,11 @@ const TRANSLATIONS = { "layouts.bar_current": "Banlayout: {label} - sedan {date}", "layouts.bar_none": "Ingen banlayout satt ännu", "layouts.new": "Ny layout", + "layouts.min_lap": "Minsta varvtid (s)", + "layouts.new_min_lap_prompt": "Minsta varvtid i sekunder för den nya layouten (tomt = som tävlingen, kan ändras under Inställningar):", + "layouts.max_lap": "Längsta varvtid (s)", + "layouts.lap_from_event": "som tävlingen", + "layouts.lap_window_hint": "Valfritt per layout: gäller träning (Open/Free practice) på den layouten. Tomt = tävlingens min/max. Kval, heat och final använder alltid tävlingens värden.", "layouts.new_prompt": "Namn på den nya banlayouten (gäller från nu). Varv som körs efter bytet räknas till den nya layouten - även i ett Open Practice som redan pågår.", "layouts.stats_for": "Statistik för", "layouts.option_current": "Aktiv layout", @@ -2903,6 +2908,11 @@ const TRANSLATIONS = { "layouts.bar_current": "Track layout: {label} - since {date}", "layouts.bar_none": "No track layout set yet", "layouts.new": "New layout", + "layouts.min_lap": "Min lap time (s)", + "layouts.new_min_lap_prompt": "Minimum lap time in seconds for the new layout (empty = from the event, can be changed in Settings):", + "layouts.max_lap": "Max lap time (s)", + "layouts.lap_from_event": "from the event", + "layouts.lap_window_hint": "Optional per layout: applies to practice (Open/Free practice) on that layout. Empty = the event's min/max. Qualifying, heats and finals always use the event's values.", "layouts.new_prompt": "Name for the new track layout (active from now). Laps driven after the switch count toward the new layout - even in an Open Practice that is already running.", "layouts.stats_for": "Stats for", "layouts.option_current": "Current layout", @@ -4557,6 +4567,13 @@ function saveOperationalLapWindow(session, minLapSec, maxLapSec) { const event = state.events.find((item) => item.id === session?.eventId); const minLapMs = Math.round(Number(minLapSec) * 1000); const maxLapMs = Math.round(Number(maxLapSec) * 1000); + // Practice on a layout: the lap window belongs to that layout, not to the event. + if (isPracticeSessionType(session?.type) && session.trackLayoutId && state.trackLayouts?.some((layout) => layout.id === session.trackLayoutId)) { + if (!Number.isFinite(minLapMs) || !Number.isFinite(maxLapMs) || minLapMs <= 0 || maxLapMs <= minLapMs) return false; + const result = updateTrackLayoutLapWindow(session.trackLayoutId, minLapMs, maxLapMs); + if (result.ok) renderView(); + return result.ok; + } if (!event || !Number.isFinite(minLapMs) || !Number.isFinite(maxLapMs) || minLapMs <= 0 || maxLapMs <= minLapMs) return false; event.raceConfig = { ...(event.raceConfig || {}), minLapMs, maxLapMs }; saveState(); @@ -5056,7 +5073,7 @@ async function refreshServerBackups({ render = true } = {}) { return serverBackups; } -function addTrackLayout(label, startedAtMs = Date.now()) { +function addTrackLayout(label, startedAtMs = Date.now(), { minLapMs = 0, maxLapMs = 0 } = {}) { const blocking = findRaceSessionBlockingLayoutChange(state.sessions); if (blocking) return { ok: false, message: t("settings.track_layout_race_running", { name: blocking.name }) }; const layouts = normalizeTrackLayouts(state.trackLayouts, { uid }); @@ -5064,7 +5081,9 @@ function addTrackLayout(label, startedAtMs = Date.now()) { if (latest && startedAtMs <= latest.startedAt) { return { ok: false, message: t("settings.track_layout_date_error", { date: new Date(latest.startedAt).toLocaleString() }) }; } - const layout = { id: uid("layout"), label: String(label || "").trim() || nextTrackLayoutLabel(layouts), startedAt: startedAtMs }; + const lapWindow = validateLayoutLapWindow(minLapMs, maxLapMs); + if (!lapWindow.ok) return lapWindow; + const layout = { id: uid("layout"), label: String(label || "").trim() || nextTrackLayoutLabel(layouts), startedAt: startedAtMs, minLapMs: lapWindow.minLapMs, maxLapMs: lapWindow.maxLapMs }; layouts.push(layout); state.trackLayouts = normalizeTrackLayouts(layouts, { uid }); // A practice session that keeps running across the switch moves onto the new layout; @@ -5086,6 +5105,26 @@ function addTrackLayout(label, startedAtMs = Date.now()) { return { ok: true, layout }; } +// 0 / empty = not set (the event's value applies); when both are set, max must exceed min. +function validateLayoutLapWindow(minLapMs, maxLapMs) { + const min = Math.max(0, Math.round(Number(minLapMs) || 0)); + const max = Math.max(0, Math.round(Number(maxLapMs) || 0)); + if (min && max && max <= min) return { ok: false, message: t("race_day.lap_window_invalid") }; + return { ok: true, minLapMs: min, maxLapMs: max }; +} + +function updateTrackLayoutLapWindow(id, minLapMs, maxLapMs) { + const lapWindow = validateLayoutLapWindow(minLapMs, maxLapMs); + if (!lapWindow.ok) return lapWindow; + const layouts = normalizeTrackLayouts(state.trackLayouts, { uid }); + const layout = layouts.find((item) => item.id === id); + if (!layout) return { ok: false, message: t("common.unknown") }; + Object.assign(layout, { minLapMs: lapWindow.minLapMs, maxLapMs: lapWindow.maxLapMs }); + state.trackLayouts = layouts; + saveState(); + return { ok: true, layout }; +} + function deleteTrackLayout(id) { if (isTrackLayoutInUse(id, { sessions: state.sessions, ledger: state.training?.ledger || [] })) { return { ok: false, message: t("settings.track_layout_delete_blocked") }; @@ -5341,6 +5380,7 @@ function renderView() { nextTrackLayoutLabel: () => nextTrackLayoutLabel(state.trackLayouts), addTrackLayout, deleteTrackLayout, + updateTrackLayoutLapWindow, updateStatus, updateStatusLoaded, updateCheckPending, diff --git a/src/race_day_logic.js b/src/race_day_logic.js index 885007c..9aecfb6 100644 --- a/src/race_day_logic.js +++ b/src/race_day_logic.js @@ -1,3 +1,4 @@ +import { getLayoutLapWindowOverride } from "./track_layouts_logic.js"; function unique(values) { return [...new Set(values.map((value) => String(value || "").trim()).filter(Boolean))]; } @@ -68,8 +69,9 @@ export function buildRaceDayPreflight(state, session, { backendAvailable = false checks.push(check("pit_decoders", pitConnected ? "ok" : "warning")); } - const minLapMs = Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0); - const maxLapMs = Math.max(0, Number(event?.raceConfig?.maxLapMs || 0) || 0); + const layoutOverride = getLayoutLapWindowOverride(state?.trackLayouts, session); + const minLapMs = layoutOverride.minLapMs ?? Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0); + const maxLapMs = layoutOverride.maxLapMs ?? Math.max(0, Number(event?.raceConfig?.maxLapMs || 0) || 0); const validLapWindow = maxLapMs > minLapMs && minLapMs > 0; const openPractice = session.type === "open_practice"; const lapWindowLevel = event?.mode !== "race" || validLapWindow ? "ok" : openPractice ? "warning" : "error"; diff --git a/src/settings.js b/src/settings.js index a30a64f..bb2d495 100644 --- a/src/settings.js +++ b/src/settings.js @@ -67,6 +67,7 @@ export function renderSettings(deps) { nextTrackLayoutLabel, addTrackLayout, deleteTrackLayout, + updateTrackLayoutLapWindow = () => ({ ok: false }), updateStatus, updateStatusLoaded, updateCheckPending, @@ -306,14 +307,21 @@ export function renderSettings(deps) { now.setMinutes(now.getMinutes() - now.getTimezoneOffset()); return now.toISOString().slice(0, 16); })())}" /> + +
+

${t("layouts.lap_window_hint")}

${(trackLayouts || []).length ? trackLayouts.slice().reverse().map((layout) => ` -
+
${escapeHtml(layout.label || t("settings.track_layout_unnamed"))}${escapeHtml(new Date(layout.startedAt).toLocaleString())}
- +
+ + + +
`).join("") : `

${t("settings.track_layout_none")}

`} @@ -760,13 +768,26 @@ Erik Eriksson;654321;4WD;Associated;; alert(t("settings.track_layout_invalid_date")); return; } - const result = addTrackLayout(label, startedAtMs); + const seconds = (name) => Math.round((Number(form.elements.namedItem(name)?.value) || 0) * 1000); + const result = addTrackLayout(label, startedAtMs, { minLapMs: seconds("minLapSec"), maxLapMs: seconds("maxLapSec") }); if (!result.ok) { alert(result.message); return; } renderView(); }); + document.querySelectorAll("[data-layout-lap-window]").forEach((form) => { + form.addEventListener("submit", (event) => { + event.preventDefault(); + const seconds = (name) => Math.round((Number(form.elements.namedItem(name)?.value) || 0) * 1000); + const result = updateTrackLayoutLapWindow(form.dataset.layoutLapWindow, seconds("minLapSec"), seconds("maxLapSec")); + if (!result.ok) { + alert(result.message); + return; + } + renderView(); + }); + }); document.querySelectorAll("[data-delete-track-layout]").forEach((button) => { button.addEventListener("click", () => { const id = button.getAttribute("data-delete-track-layout"); diff --git a/src/styles.css b/src/styles.css index 8264b7d..3002f60 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4560,3 +4560,12 @@ tr.is-removed td.lap-actions { opacity: 1; text-decoration: none; } + +/* Settings -> track layouts: optional min/max lap time per layout */ +.backup-row.track-layout-row { grid-template-columns: minmax(0, 1fr) auto 74px; } +.layout-lap-window { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; } +.layout-lap-window label { display: grid; gap: 2px; font-size: 0.7rem; color: var(--muted); } +.layout-lap-window input { width: 110px; } +@media (max-width: 720px) { + .backup-row.track-layout-row { grid-template-columns: 1fr; } +} diff --git a/src/timing_logic.js b/src/timing_logic.js index 6a847c7..53ac9a0 100644 --- a/src/timing_logic.js +++ b/src/timing_logic.js @@ -1,3 +1,4 @@ +import { getLayoutLapWindowOverride } from "./track_layouts_logic.js"; export function getSessionTypeLabel(type, { t }) { const key = `session.${String(type || "").toLowerCase()}`; const translated = t(key); @@ -40,8 +41,9 @@ export function getSessionLapWindow(session, { state }) { if (event?.mode !== "race") { return { minLapMs: 0, maxLapMs: Number.POSITIVE_INFINITY }; } - const minLapMs = Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0); - const configuredMaxLapMs = Math.max(0, Number(event?.raceConfig?.maxLapMs || 60000) || 60000); + const override = getLayoutLapWindowOverride(state.trackLayouts, session); + const minLapMs = override.minLapMs ?? Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0); + const configuredMaxLapMs = override.maxLapMs ?? Math.max(0, Number(event?.raceConfig?.maxLapMs || 60000) || 60000); const maxLapMs = configuredMaxLapMs > 0 ? Math.max(configuredMaxLapMs, minLapMs || 0) : Number.POSITIVE_INFINITY; return { minLapMs, maxLapMs }; } diff --git a/src/track_layout_bar.js b/src/track_layout_bar.js index 94c13f2..3f1dc04 100644 --- a/src/track_layout_bar.js +++ b/src/track_layout_bar.js @@ -24,7 +24,9 @@ export function bindTrackLayoutBar(root, { state, t, addTrackLayout, renderView const suggested = nextTrackLayoutLabel(normalizeTrackLayouts(state.trackLayouts)); const label = window.prompt(t("layouts.new_prompt"), suggested); if (label === null) return; - const result = addTrackLayout(label.trim() || suggested, Date.now()); + // Optional: a rebuilt track often needs its own minimum lap time (empty = the event's). + const minLapSec = Number(String(window.prompt(t("layouts.new_min_lap_prompt"), "") || "").replace(",", ".")); + const result = addTrackLayout(label.trim() || suggested, Date.now(), { minLapMs: Number.isFinite(minLapSec) && minLapSec > 0 ? Math.round(minLapSec * 1000) : 0 }); if (!result.ok) { window.alert(result.message); return; diff --git a/src/track_layouts_logic.js b/src/track_layouts_logic.js index c6a952f..f79212f 100644 --- a/src/track_layouts_logic.js +++ b/src/track_layouts_logic.js @@ -9,9 +9,24 @@ export function normalizeTrackLayout(layout, { uid } = {}) { id: String(layout?.id || (uid ? uid("layout") : "")), label: String(layout?.label || "").trim(), startedAt: Number.isFinite(Number(layout?.startedAt)) ? Number(layout.startedAt) : Date.now(), + // Optional lap window for practice on this layout (a rebuilt track is often shorter or + // longer); 0 = not set, the event's own min/max lap time applies. + minLapMs: Math.max(0, Math.round(Number(layout?.minLapMs) || 0)), + maxLapMs: Math.max(0, Math.round(Number(layout?.maxLapMs) || 0)), }; } +// Practice sessions use their layout's min/max lap time where it is set; race sessions +// always keep the event's (set by race control, never changed mid-race by a layout). +export function getLayoutLapWindowOverride(layouts, session) { + if (!session || !isPracticeSessionType(session.type) || !session.trackLayoutId) return {}; + const layout = (Array.isArray(layouts) ? layouts : []).find((item) => item?.id === session.trackLayoutId); + const override = {}; + if (Number(layout?.minLapMs) > 0) override.minLapMs = Number(layout.minLapMs); + if (Number(layout?.maxLapMs) > 0) override.maxLapMs = Number(layout.maxLapMs); + return override; +} + export function normalizeTrackLayouts(layouts, { uid } = {}) { const list = (Array.isArray(layouts) ? layouts : []) .map((layout) => normalizeTrackLayout(layout, { uid })) diff --git a/tests/browser_track_layouts.js b/tests/browser_track_layouts.js index 36fa5d4..a24de11 100644 --- a/tests/browser_track_layouts.js +++ b/tests/browser_track_layouts.js @@ -40,6 +40,19 @@ export async function runTrackLayoutsSmoke({ evaluate, navigate, savedState, wai assert.ok(options.includes((await savedState()).trackLayouts[0].id)); console.log("PASS a track layout can be created from Training stats and the per-layout stats selector appears"); + // Settings: the layout gets its own min/max lap time; max <= min is refused. + await navigate("settings", "settingsForm"); + const layoutId = (await savedState()).trackLayouts[0].id; + const submitLapWindow = (min, max) => evaluate(`(() => { const form = document.querySelector('[data-layout-lap-window="${layoutId}"]'); form.elements.minLapSec.value = "${min}"; form.elements.maxLapSec.value = "${max}"; form.requestSubmit(); })()`); + await submitLapWindow("8.5", "40"); + await waitFor(async () => (await savedState()).trackLayouts[0].minLapMs === 8500, "layout min lap time saved"); + assert.equal((await savedState()).trackLayouts[0].maxLapMs, 40000); + const alertsBefore = dialogs.length; + await submitLapWindow("30", "20"); + await waitFor(async () => dialogs.slice(alertsBefore).some((dialog) => dialog.type === "alert"), "an impossible lap window is refused"); + assert.equal((await savedState()).trackLayouts[0].minLapMs, 8500, "unchanged"); + console.log("PASS each track layout can have its own min/max lap time"); + await patchState({ activeSessionId: quali.id }); await navigate("race_day", "raceDayStart"); assert.equal(await layoutButtonVisible(), false, "no New layout button while a race session is in focus"); diff --git a/tests/track_layouts_logic.test.js b/tests/track_layouts_logic.test.js index f482205..a8f8973 100644 --- a/tests/track_layouts_logic.test.js +++ b/tests/track_layouts_logic.test.js @@ -1,7 +1,8 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { normalizeTrackLayout, normalizeTrackLayouts, getTrackLayoutIdAt, getTrackLayoutLabel, nextTrackLayoutLabel, isPracticeSessionType, findRaceSessionBlockingLayoutChange, isTrackLayoutInUse } from "../src/track_layouts_logic.js"; -import { getTrackRecordMs } from "../src/timing_logic.js"; +import { normalizeTrackLayout, normalizeTrackLayouts, getTrackLayoutIdAt, getTrackLayoutLabel, nextTrackLayoutLabel, isPracticeSessionType, findRaceSessionBlockingLayoutChange, isTrackLayoutInUse, getLayoutLapWindowOverride } from "../src/track_layouts_logic.js"; +import { getTrackRecordMs, getSessionLapWindow } from "../src/timing_logic.js"; +import { buildRaceDayPreflight } from "../src/race_day_logic.js"; test("normalizeTrackLayout fills in an id via uid() when missing and coerces startedAt", () => { const withoutId = normalizeTrackLayout({ label: "Layout 1", startedAt: 1000 }, { uid: (prefix) => `${prefix}_generated` }); @@ -128,3 +129,33 @@ test("a layout is in use when a session started on it or a practice lap was driv assert.equal(isTrackLayoutInUse("layout_2", { sessions: [{ trackLayoutId: "layout_2" }] }), true); assert.equal(isTrackLayoutInUse("layout_2", { sessions: [{ trackLayoutId: "layout_1" }], ledger: [{ trackLayoutId: "layout_2" }] }), true, "a mid-session switch leaves laps on layout_2 even though no session started on it"); }); + +test("practice uses its layout's min/max lap time where set; races always keep the event's", () => { + const state = { + events: [{ id: "event_1", mode: "race", raceConfig: { minLapMs: 12000, maxLapMs: 60000 } }], + trackLayouts: [ + { id: "layout_1", label: "Layout 1", startedAt: 1000 }, + { id: "layout_2", label: "Layout 2 (short)", startedAt: 2000, minLapMs: 8000, maxLapMs: 40000 }, + { id: "layout_3", label: "Layout 3 (min only)", startedAt: 3000, minLapMs: 15000 }, + ], + }; + const practice = (trackLayoutId) => ({ eventId: "event_1", type: "open_practice", trackLayoutId }); + assert.deepEqual(getSessionLapWindow(practice("layout_1"), { state }), { minLapMs: 12000, maxLapMs: 60000 }, "no layout values -> the event's"); + assert.deepEqual(getSessionLapWindow(practice("layout_2"), { state }), { minLapMs: 8000, maxLapMs: 40000 }); + assert.deepEqual(getSessionLapWindow(practice("layout_3"), { state }), { minLapMs: 15000, maxLapMs: 60000 }, "only min set -> the event's max stays"); + assert.deepEqual(getSessionLapWindow({ eventId: "event_1", type: "heat", trackLayoutId: "layout_2" }, { state }), { minLapMs: 12000, maxLapMs: 60000 }, "a heat ignores the layout"); + assert.deepEqual(getLayoutLapWindowOverride(state.trackLayouts, { type: "free_practice", trackLayoutId: "layout_2" }), { minLapMs: 8000, maxLapMs: 40000 }); + assert.deepEqual(normalizeTrackLayout({ id: "x", minLapMs: -5, maxLapMs: "40000" }), { ...normalizeTrackLayout({ id: "x" }), minLapMs: 0, maxLapMs: 40000, startedAt: normalizeTrackLayout({ id: "x", minLapMs: -5, maxLapMs: "40000" }).startedAt }); +}); + +test("Race Day preflight shows the layout's lap window for practice", () => { + const state = { + events: [{ id: "event_1", mode: "race", raceConfig: { minLapMs: 12000, maxLapMs: 60000, practiceOnly: true } }], + trackLayouts: [{ id: "layout_2", startedAt: 2000, minLapMs: 8000, maxLapMs: 40000 }], + sessions: [], drivers: [], classes: [], resultsBySession: {}, settings: {}, decoder: {}, + }; + const session = { id: "op", eventId: "event_1", type: "open_practice", trackLayoutId: "layout_2", status: "ready", mode: "race" }; + const lapWindow = buildRaceDayPreflight(state, session, { backendAvailable: true }).checks.find((check) => check.id === "lap_window"); + assert.equal(lapWindow.minLapMs, 8000); + assert.equal(lapWindow.maxLapMs, 40000); +});