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);
})())}" />
+
+