diff --git a/CHANGELOG.md b/CHANGELOG.md
index c225d18..a4b5747 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.27.0 - 2026-09-30
+
+### Changed
+
+- Race Day starts a race-format heat the EFRA/IFMAR way: Start begins a count-down (Go, 30 s, 1, 1:30, 2, 3 or 5 min; the default is set per event for qualifying and finals in Race Setup) instead of the separate warm-up button from 1.26.0. The track is open and the announcer calls "Heat 3 starts in 2 minutes", every minute, 30 s and 10 s (EFRA 9.2b). For a grid start it also calls "cars to the grid" at 30 s and counts 9..3 second by second, then the start lights and a random 0-5 s signal follow (IFMAR 4.2.5-4.2.8). The count-down can be held while cars are placed on the grid, resumed, extended by a minute, jumped to 30 s or 10 s, started at once ("Go now", which warns about drivers not seen) or cancelled. When it runs out the heat starts by itself. The transponder check (green ticks, quick transponder fix) now runs during the count-down. A reload keeps the count-down but puts it on hold, and it only runs in the browser tab that started it.
+
+### Added
+
+- Flying start mode for qualifying (EFRA 9.2b), now the default for new events and the built-in presets: no start procedure, the start signal is followed by "Clock is running", and each car's clock starts at its own first crossing.
+- Latest personal clock start (EFRA 9.3, IFMAR 4.2.3): a car that hasn't crossed in time gets its clock started anyway. For a flying start this happens at the latest 150 % of the first lap anyone completes; for a staggered start, when the first car completes a lap or at the latest 1 minute after the start. That car's first crossing then counts as a lap.
+
## 1.26.0 - 2026-09-30
### Added
diff --git a/ROADMAP.md b/ROADMAP.md
index fc41b29..ff5f885 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -573,6 +573,47 @@ Asked by the user 2026-09-30: what happens if a driver in a running heat registe
Verified 2026-09-30: unit tests (`tests/warmup_logic.test.js`) cover seen-since-start, a passing before the warm-up not counting, drivers without a transponder, unknown transponders newest-first with owner names, repeat counting and the size bound, and the countdown. `tests/browser_warmup.js` drives it with real decoder frames: Anna and Bo go green, Cia (registered wrong) stays missing until the unknown transponder is linked from the panel - then green and saved - preflight shows 3/3, and after a restarted warm-up Start warns naming the two not seen and starts the heat on confirm. While writing that test it first failed because a passing sent "before" the warm-up reached the page after the click and was (correctly) counted - a test timing issue, fixed by waiting for the frame to arrive. After that fix the same assertion still failed rarely (twice in about ten full-suite runs). Investigated with temporary logging: every run had exactly one decoder connection and the passing was processed before the start (`no_running_session` in the diagnostics), so it is not a duplicate-connection bug - but the flake could not be reproduced while logging (4/4 passed), so the root cause is unknown. The browser test now waits 750 ms between that passing and the start; the before/after rule itself is covered deterministically by the unit tests. 230 unit/integration tests pass, and the 46 browser scenarios passed three runs in a row.
+### Follow-up: the warm-up becomes the start count-down (EFRA / IFMAR)
+
+The user's feedback on the same day: the warm-up should not be a separate part. It belongs in the heat start itself, as "heat starts in 2 min, 1 min". It must be possible to hold the start so the cars can be put on the grid. Qualifying uses a flying start, where each driver's 5 minutes start at their own crossing. Lengths should be 30 s, 1, 1:30 and 2 min. The goal is to follow EFRA and IFMAR. Checked first:
+- EVERLAPS (formerly RCM), which does exactly this: a "prologue" after Start, with calls every minute and at 30/20/10 s, and green ticks for drivers who have crossed. It also offers 30 s / 10 s shortcuts and Go.
+- EFRA General Rules 2024 §9.2-9.3.
+- IFMAR 1:10 IC Track 2024 §4.2.
+
+- [x] `src/warmup_logic.js` became `src/prologue_logic.js`. It adds the prologue state (hold/resume/set remaining) and the call schedule:
+ - EFRA 9.2b for a flying start: 1 min, 30 s, 10 s; with a longer count-down, every whole minute ("N minutes to the start").
+ - IFMAR 4.2.4-4.2.6 for grid and staggered starts: "30 seconds, cars to the grid", then 9..3 second by second.
+ - The start procedure takes over at 3 s (IFMAR 4.2.8: the signal comes 0-5 s later, which is our random delay). A flying start takes over at 0.
+- [x] Race Day's Start on a race-format heat now starts the count-down. Choices are Go, 30 s, 1, 1:30, 2, 3 and 5 min. The default is set per event (`qualPrologueSec` / `finalPrologueSec`, 2 min) and comes from new fields in Race Setup.
+ - While counting down: Hold / Resume, +1 min, To 30 s (IFMAR 4.2.8 grid restart from 30 s), To 10 s, Go now (warns about drivers not seen) and Cancel.
+ - The hero clock shows the time to the start.
+ - When the count-down runs out the heat starts by itself, with no dialog.
+ - The global 10/30/60 s start count-down in Settings is skipped on Race Day. It still applies to Start on the Timing page.
+- [x] The count-down lives only in the browser tab that started it (module state plus `sessionStorage`). A second browser on the same event therefore never runs its own count-down and doubles the calls. A reload restores it on hold, so a start is never fired by a reload.
+- [x] `runStartSequence` takes `skipCountdown` and `skipIntro`:
+ - "Go" still says "drivers, stand by" and runs the lights, random delay and horn.
+ - After a full count-down only the lights, random delay and horn are left.
+- [x] New start mode `flying` (EFRA 9.2b): no start procedure, just the horn and "Clock is running" (new voice event `clock_running`). Each car gets its own clock from its first crossing, like staggered.
+ - It is the default for qualifying in new events and in the three built-in presets. Existing events keep the mode they already have saved.
+ - The shared session clock gets one extra minute, so the last car to start still has its full window.
+- [x] Latest personal clock start (EFRA 9.3, IFMAR 4.2.3) in `decoder_runtime.js`. A car first seen after the latest start gets that moment as its start, and its first crossing counts as a lap.
+ - Flying start: 150 % of a standard lap after the horn. We take the first lap anyone completes in the heat as the standard lap, since there is no configured standard lap time.
+ - Staggered start: when the first car completes a lap, or 1 minute after the start, whichever comes first.
+- [x] Voice events `warmup_*` were replaced by `prologue_start`, `prologue_minutes`, `prologue_grid`, `prologue_count`, `prologue_hold`, `prologue_resumed`, `prologue_cancelled` and `clock_running`, in sv and en. The existing `race_1_minute`, `race_30_seconds` and `race_10_seconds` are reused.
+
+Verified 2026-09-30:
+- 239 unit/integration tests pass. They include new tests for the call schedule per start mode, hold/resume, jumping to 10 s (skipped calls marked done, only the latest one spoken, calls come back after +1 min), length choices, the latest clock start rules, a flying start in the decoder (own clock, and a late car getting the latest start with a counted lap), and the audio sequence for flying and post-count-down grid starts.
+- `tests/browser_prologue.js` drives real decoder frames through the whole flow:
+ - the default length comes from the event, and a passing before the count-down doesn't count;
+ - Anna goes green and Cia is fixed by linking her unknown transponder;
+ - hold keeps the clock still for 1.5 s, and it moves again after resume; +1 min works;
+ - "Go now" warns about Bo only and starts on confirm;
+ - a second heat started with 30 s and jumped to 10 s starts by itself with no dialog.
+- `tests/browser_race_day_start_sequences.js` was updated to the new flow: the heat counts down and never gets a second count-down, and the other types use Go.
+- The 48 browser scenarios passed three runs in a row.
+
+Not done: a configurable standard lap time per event (EVERLAPS has one). Until then, the first completed lap stands in for it.
+
## Documentation maintenance
- [x] Update the architecture's module/dependency inventory as modules change.
diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md
index 15956e5..c496a91 100644
--- a/docs/javascript-architecture.md
+++ b/docs/javascript-architecture.md
@@ -576,13 +576,17 @@ Type:
Owns:
- "Track layout:
@@ -525,8 +527,14 @@ export function renderEventManagerMarkup(context) {
+
`
) : ""}
+ ${showBasicQualifyingFields ? renderRaceFormatFieldView(
+ "events.qual_prologue",
+ "events.prologue_hint",
+ ``
+ ) : ""}
${raceFormatAdvanced
? renderRaceFormatFieldView(
"events.qual_seed_laps",
@@ -610,8 +618,14 @@ export function renderEventManagerMarkup(context) {
+
`
) : ""}
+ ${showBasicFinalFields ? renderRaceFormatFieldView(
+ "events.final_prologue",
+ "events.prologue_hint",
+ ``
+ ) : ""}
${showBasicFinalFields ? renderRaceFormatFieldView(
"events.bump_count",
"events.bump_count_hint",
@@ -915,6 +929,7 @@ export function renderEventManagerMarkup(context) {
+
${t("events.session_start_mode_hint")}
diff --git a/src/event_race_logic.js b/src/event_race_logic.js
index 24cf529..c9872c7 100644
--- a/src/event_race_logic.js
+++ b/src/event_race_logic.js
@@ -1,3 +1,4 @@
+import { normalizePrologueSec } from "./prologue_logic.js";
export function normalizeRaceTeam(team, { uid }) {
return {
id: String(team?.id || uid("team")),
@@ -34,7 +35,7 @@ export function getRaceFormatPresets({ state, t, uid, normalizeStoredRacePreset
qualifyingRounds: 3,
carsPerHeat: 6,
qualDurationMin: 5,
- qualStartMode: "staggered",
+ qualStartMode: "flying",
qualSeedLapCount: 3,
qualSeedMethod: "best_sum",
countedQualRounds: 2,
@@ -59,7 +60,7 @@ export function getRaceFormatPresets({ state, t, uid, normalizeStoredRacePreset
qualifyingRounds: 4,
carsPerHeat: 8,
qualDurationMin: 5,
- qualStartMode: "staggered",
+ qualStartMode: "flying",
qualSeedLapCount: 3,
qualSeedMethod: "best_sum",
countedQualRounds: 2,
@@ -84,7 +85,7 @@ export function getRaceFormatPresets({ state, t, uid, normalizeStoredRacePreset
qualifyingRounds: 5,
carsPerHeat: 10,
qualDurationMin: 5,
- qualStartMode: "staggered",
+ qualStartMode: "flying",
qualSeedLapCount: 3,
qualSeedMethod: "best_sum",
countedQualRounds: 3,
@@ -162,7 +163,7 @@ export function buildRaceFormatConfigFromForm(form, event, { normalizeStartMode,
qualifyingRounds: Math.max(1, Number(form.get("qualifyingRounds") || 3) || 3),
carsPerHeat: Math.max(2, Number(form.get("carsPerHeat") || 8) || 8),
qualDurationMin: Math.max(1, Number(form.get("qualDurationMin") || 5) || 5),
- qualStartMode: normalizeStartMode(String(form.get("qualStartMode") || "staggered")),
+ qualStartMode: normalizeStartMode(String(form.get("qualStartMode") || "flying")),
qualSeedLapCount: Math.max(0, Number(form.get("qualSeedLapCount") || 2) || 0),
qualSeedMethod: ["best_sum", "average", "consecutive"].includes(String(form.get("qualSeedMethod") || "").toLowerCase())
? String(form.get("qualSeedMethod")).toLowerCase()
@@ -179,6 +180,8 @@ export function buildRaceFormatConfigFromForm(form, event, { normalizeStartMode,
countedFinalLegs: Math.max(1, Number(form.get("countedFinalLegs") || 1) || 1),
finalDurationMin: Math.max(1, Number(form.get("finalDurationMin") || 5) || 5),
finalStartMode: normalizeStartMode(String(form.get("finalStartMode") || "position")),
+ qualPrologueSec: normalizePrologueSec(form.get("qualPrologueSec")),
+ finalPrologueSec: normalizePrologueSec(form.get("finalPrologueSec")),
followUpSec: Math.max(0, Number(form.get("followUpSec") || 0) || 0),
minLapMs: Math.max(0, Math.round((Number(form.get("minLapSec") || 0) || 0) * 1000)),
maxLapMs: Math.max(1000, Math.round((Number(form.get("maxLapSec") || 60) || 60) * 1000)),
@@ -204,7 +207,7 @@ export function normalizeEvent(event, { normalizeBrandingConfig, normalizeStartM
qualifyingRounds: Math.max(1, Number(event?.raceConfig?.qualifyingRounds || 3) || 3),
carsPerHeat: Math.max(2, Number(event?.raceConfig?.carsPerHeat || 8) || 8),
qualDurationMin: Math.max(1, Number(event?.raceConfig?.qualDurationMin || 5) || 5),
- qualStartMode: normalizeStartMode(event?.raceConfig?.qualStartMode || "staggered"),
+ qualStartMode: normalizeStartMode(event?.raceConfig?.qualStartMode || "flying"),
qualSeedLapCount: Math.max(0, Number(event?.raceConfig?.qualSeedLapCount || 2) || 2),
qualSeedMethod: ["best_sum", "average", "consecutive"].includes(String(event?.raceConfig?.qualSeedMethod || "").toLowerCase())
? String(event.raceConfig.qualSeedMethod).toLowerCase()
@@ -221,6 +224,8 @@ export function normalizeEvent(event, { normalizeBrandingConfig, normalizeStartM
countedFinalLegs: Math.max(1, Number(event?.raceConfig?.countedFinalLegs || 1) || 1),
finalDurationMin: Math.max(1, Number(event?.raceConfig?.finalDurationMin || 5) || 5),
finalStartMode: normalizeStartMode(event?.raceConfig?.finalStartMode || "position"),
+ qualPrologueSec: normalizePrologueSec(event?.raceConfig?.qualPrologueSec),
+ finalPrologueSec: normalizePrologueSec(event?.raceConfig?.finalPrologueSec),
followUpSec: Math.max(0, Number(event?.raceConfig?.followUpSec || 0) || 0),
minLapMs: Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0),
maxLapMs: Math.max(0, Number(event?.raceConfig?.maxLapMs || 60000) || 60000),
@@ -637,7 +642,7 @@ export function generateQualifyingForRace(event, { buildPracticeStandings, getEv
const qualifyingRounds = Math.max(1, Number(event.raceConfig?.qualifyingRounds || 1) || 1);
const carsPerHeat = Math.max(2, Number(event.raceConfig?.carsPerHeat || 8) || 8);
const qualDurationMin = Math.max(1, Number(event.raceConfig?.qualDurationMin || 5) || 5);
- const qualStartMode = normalizeStartMode(event.raceConfig?.qualStartMode || "staggered");
+ const qualStartMode = normalizeStartMode(event.raceConfig?.qualStartMode || "flying");
const qualSeedLapCount = Math.max(0, Number(event.raceConfig?.qualSeedLapCount || 2) || 0);
const qualSeedMethod = ["best_sum", "average", "consecutive"].includes(String(event.raceConfig?.qualSeedMethod || "").toLowerCase())
? String(event.raceConfig.qualSeedMethod).toLowerCase()
diff --git a/src/prologue_logic.js b/src/prologue_logic.js
new file mode 100644
index 0000000..3633e5e
--- /dev/null
+++ b/src/prologue_logic.js
@@ -0,0 +1,166 @@
+// Start of a heat per EFRA / IFMAR: the count-down before it ("prologue"), the transponder
+// check during that count-down, and when a car's own clock starts at the latest.
+//
+// Transponder check: every passing the main decoder sees is remembered (transponder -> when),
+// independent of whether a session is running, so the Race Day panel can show which drivers
+// in the selected heat have been seen since the count-down began and which unknown
+// transponders crossed the line.
+const MAX_SIGHTINGS = 500;
+
+export function recordTransponderSighting(sightings, transponder, at = Date.now()) {
+ const key = String(transponder || "").trim();
+ if (!key) return sightings;
+ const previous = sightings.get(key);
+ sightings.delete(key); // re-insert so Map order stays oldest -> newest
+ sightings.set(key, { transponder: key, lastSeenAt: at, count: (previous?.count || 0) + 1 });
+ while (sightings.size > MAX_SIGHTINGS) sightings.delete(sightings.keys().next().value);
+ return sightings;
+}
+
+// entrants: drivers in the heat, in start order. drivers: every registered driver, used to
+// name the owner of a transponder that crossed the line but doesn't belong to this heat.
+export function buildWarmupCheck({ entrants = [], drivers = [], sightings = new Map(), since = 0 }) {
+ const seenSince = (transponder) => {
+ const sighting = transponder ? sightings.get(String(transponder).trim()) : null;
+ return sighting && sighting.lastSeenAt >= since ? sighting : null;
+ };
+ const rows = entrants.map((driver) => {
+ const sighting = seenSince(driver.transponder);
+ return { driverId: driver.id, name: driver.name, transponder: String(driver.transponder || ""), seen: Boolean(sighting), lastSeenAt: sighting?.lastSeenAt || null };
+ });
+ const entrantTransponders = new Set(rows.map((row) => row.transponder).filter(Boolean));
+ const unknownTransponders = [...sightings.values()]
+ .filter((sighting) => sighting.lastSeenAt >= since && !entrantTransponders.has(sighting.transponder))
+ .map((sighting) => ({ ...sighting, ownerName: drivers.find((driver) => String(driver.transponder || "") === sighting.transponder)?.name || "" }))
+ .sort((a, b) => b.lastSeenAt - a.lastSeenAt);
+ return {
+ rows,
+ total: rows.length,
+ seenCount: rows.filter((row) => row.seen).length,
+ missing: rows.filter((row) => !row.seen),
+ unknownTransponders,
+ };
+}
+
+// --- Prologue: the count-down before a heat --------------------------------------------
+// Race Day's Start runs a prologue first (EVERLAPS' term): the track is open, the announcer
+// counts down (EFRA 9.2b: 2 min, 1 min, 30 s, 10 s) and the warm-up check above shows who
+// has crossed the line. The race director can hold it (cars being put on the grid), add time
+// or jump ahead. State is a plain object stored on the session so a reload keeps it.
+export const PROLOGUE_OPTIONS_SEC = Object.freeze([0, 30, 60, 90, 120, 180, 300]);
+export const DEFAULT_PROLOGUE_SEC = 120;
+// Grid starts (mass/position) and staggered releases hand over to the start procedure at
+// 3 s: IFMAR 4.2.8 - "from 3 seconds the counting stops and the start signal is given
+// between 0 and 5 seconds" (our random delay). A flying start ends at 0 with the horn.
+const GRID_HANDOVER_MS = 3000;
+
+export function normalizePrologueSec(value, fallback = DEFAULT_PROLOGUE_SEC) {
+ const seconds = Number(value);
+ return PROLOGUE_OPTIONS_SEC.includes(seconds) ? seconds : fallback;
+}
+
+// Staggered and flying starts give every car its own clock from its own first crossing.
+export function usesPersonalClock(startMode) {
+ return startMode === "staggered" || startMode === "flying";
+}
+
+export function getPrologueHandoverMs(startMode) {
+ return startMode === "flying" ? 0 : GRID_HANDOVER_MS;
+}
+
+export function createPrologue(sessionId, totalMs, now = Date.now()) {
+ return { sessionId, totalMs, startedAt: now, endsAt: now + totalMs, heldRemainingMs: null, announced: [] };
+}
+
+export function getPrologueRemainingMs(prologue, now = Date.now()) {
+ if (!prologue) return 0;
+ if (prologue.heldRemainingMs !== null && prologue.heldRemainingMs !== undefined) return Math.max(0, prologue.heldRemainingMs);
+ return Math.max(0, Number(prologue.endsAt || 0) - now);
+}
+
+export function holdPrologue(prologue, now = Date.now()) {
+ if (!prologue || prologue.heldRemainingMs !== null) return prologue;
+ return { ...prologue, heldRemainingMs: getPrologueRemainingMs(prologue, now) };
+}
+
+export function resumePrologue(prologue, now = Date.now()) {
+ if (!prologue || prologue.heldRemainingMs === null) return prologue;
+ return { ...prologue, endsAt: now + prologue.heldRemainingMs, heldRemainingMs: null };
+}
+
+// Sets a new remaining time (+1 min, "to 10 s", IFMAR 4.2.8 "re-start from 30 s"). Calls
+// for marks above the new time are forgotten so they are made again on the way down.
+export function setPrologueRemaining(prologue, remainingMs, now = Date.now()) {
+ if (!prologue) return prologue;
+ const value = Math.max(0, Math.round(remainingMs));
+ const announced = prologue.announced.filter((marker) => getMarkMs(marker) > value);
+ const totalMs = Math.max(prologue.totalMs, value);
+ return prologue.heldRemainingMs !== null
+ ? { ...prologue, totalMs, announced, heldRemainingMs: value }
+ : { ...prologue, totalMs, announced, endsAt: now + value };
+}
+
+function getMarkMs(marker) {
+ const [, unit, amount] = /^([ms])(\d+)$/.exec(marker) || [];
+ return unit === "m" ? Number(amount) * 60000 : Number(amount) * 1000;
+}
+
+function getPrologueMarks(startMode) {
+ const marks = [5, 4, 3, 2].map((minutes) => ({ marker: `m${minutes}`, event: "prologue_minutes", variables: { minutes } }));
+ marks.push({ marker: "m1", event: "race_1_minute" });
+ // IFMAR 4.2.5: from 30 s the cars may be placed in the starting boxes.
+ marks.push({ marker: "s30", event: startMode === "flying" ? "race_30_seconds" : "prologue_grid" });
+ marks.push({ marker: "s10", event: "race_10_seconds" });
+ // IFMAR 4.2.6: from 10 s to 3 s the time is counted down second by second.
+ if (startMode !== "flying") [9, 8, 7, 6, 5, 4, 3].forEach((seconds) => marks.push({ marker: `s${seconds}`, event: "prologue_count", variables: { seconds } }));
+ return marks.map((mark) => ({ ...mark, ms: getMarkMs(mark.marker) }));
+}
+
+// Which call is due now. Marks at or above the prologue's own length are skipped (the
+// opening call already says the time), and when several are passed at once (a jump to
+// 10 s) they are all marked done but only the latest one is spoken.
+export function takeDuePrologueCall(prologue, startMode, now = Date.now()) {
+ if (!prologue) return { prologue, call: null };
+ const remaining = getPrologueRemainingMs(prologue, now);
+ const due = getPrologueMarks(startMode)
+ .filter((mark) => mark.ms < prologue.totalMs - 500 && remaining <= mark.ms && !prologue.announced.includes(mark.marker));
+ if (!due.length) return { prologue, call: null };
+ return {
+ prologue: { ...prologue, announced: [...prologue.announced, ...due.map((mark) => mark.marker)] },
+ call: due[due.length - 1],
+ };
+}
+
+// Label for a length choice in the event form and on Race Day: "Go", "30 s", "1:30 min".
+export function formatPrologueOption(seconds, t) {
+ if (!seconds) return t("prologue.option_go");
+ if (seconds < 60) return `${seconds} s`;
+ return seconds % 60 ? `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")} min` : `${seconds / 60} min`;
+}
+
+export function formatPrologueTime(ms, language = "sv") {
+ const total = Math.round(ms / 1000);
+ const minutes = Math.floor(total / 60);
+ const seconds = total % 60;
+ const sv = language === "sv";
+ const minuteText = minutes ? (sv ? `${minutes} ${minutes === 1 ? "minut" : "minuter"}` : `${minutes} ${minutes === 1 ? "minute" : "minutes"}`) : "";
+ const secondText = seconds || !minutes ? (sv ? `${seconds} sekunder` : `${seconds} seconds`) : "";
+ return [minuteText, secondText].filter(Boolean).join(sv ? " och " : " ");
+}
+
+// --- Latest personal clock start -------------------------------------------------------
+// A car that doesn't cross the line in time doesn't get a later clock of its own:
+// - staggered (EFRA 9.3, IFMAR 4.2.3): the clock starts at the latest when any car completes
+// a full lap, or 1 minute after the start, whichever comes first;
+// - flying (EFRA 9.3): at the latest 150 % of a standard lap after "clock is running". The
+// standard lap is the first lap anyone completes in the heat.
+// Returns null while that moment isn't known yet.
+export function getLatestClockStartTs(session, result) {
+ const goTs = Number(session?.startedAt || 0);
+ if (!goTs || !usesPersonalClock(session?.startMode)) return null;
+ const firstLap = (result?.passings || [])
+ .filter((passing) => passing.validLap !== false && Number(passing.lapMs) > 0)
+ .sort((a, b) => a.timestamp - b.timestamp)[0] || null;
+ if (session.startMode === "staggered") return Math.min(goTs + 60000, firstLap ? firstLap.timestamp : Number.POSITIVE_INFINITY);
+ return firstLap ? goTs + Math.round(firstLap.lapMs * 1.5) : null;
+}
diff --git a/src/race_audio.js b/src/race_audio.js
index 5450355..fb4bf23 100644
--- a/src/race_audio.js
+++ b/src/race_audio.js
@@ -10,7 +10,10 @@ export const RACE_AUDIO_EVENT_DEFINITIONS = Object.freeze([
event("start_tone", "start", "high", false, "effects/start_beep.wav"),
event("false_start", "start", "critical", true, "effects/warning.wav", 1000), event("restart_race", "start", "high"),
event("staggered_release", "start", "normal"),
- event("warmup_start", "start", "normal"), event("warmup_1_minute", "start", "normal"), event("warmup_end", "start", "high"),
+ event("prologue_start", "start", "normal"), event("prologue_minutes", "start", "normal"),
+ event("prologue_grid", "start", "high"), event("prologue_count", "start", "high"),
+ event("prologue_hold", "start", "high", true, "effects/warning.wav"), event("prologue_resumed", "start", "normal"),
+ event("prologue_cancelled", "start", "normal"), event("clock_running", "start", "high"),
event("transponder_pass", "race", "pass", false, "effects/transponder_pass.wav"), event("lap_completed", "race", "pass", false),
event("personal_best", "race", "low", true, "effects/personal_best.wav", 1500),
event("fastest_lap", "race", "normal", true, "effects/fastest_lap_trophy.wav", 1500),
@@ -251,16 +254,33 @@ export function createRaceAudioEngine(options = {}) {
return;
}
emitLightState("GRID", { sessionId: session?.id });
- if (settings.startCountdownSeconds >= 60) { trigger("race_1_minute", { interrupt: true }); callbacks.onCountdown?.(60); await wait(30000); if (cancelled()) return false; }
- if (settings.startCountdownSeconds >= 30) { trigger("race_30_seconds", { interrupt: true }); callbacks.onCountdown?.(30); await wait(20000); if (cancelled()) return false; }
- if (settings.startCountdownSeconds >= 10) {
- trigger("race_10_seconds", { interrupt: true });
- for (let seconds = 10; seconds > 0; seconds -= 1) { callbacks.onCountdown?.(seconds); await wait(1000); if (cancelled()) return false; }
+ const flying = session?.startMode === "flying";
+ // Race Day's prologue replaces this count-down (skipCountdown); when the prologue ran all
+ // the way down it has also called 10..3, so the intro calls are skipped too (skipIntro).
+ if (!callbacks.skipCountdown) {
+ if (settings.startCountdownSeconds >= 60) { trigger("race_1_minute", { interrupt: true }); callbacks.onCountdown?.(60); await wait(30000); if (cancelled()) return false; }
+ if (settings.startCountdownSeconds >= 30) { trigger("race_30_seconds", { interrupt: true }); callbacks.onCountdown?.(30); await wait(20000); if (cancelled()) return false; }
+ if (settings.startCountdownSeconds >= 10) {
+ trigger("race_10_seconds", { interrupt: true });
+ for (let seconds = 10; seconds > 0; seconds -= 1) { callbacks.onCountdown?.(seconds); await wait(1000); if (cancelled()) return false; }
+ }
+ }
+ // A flying start has no start procedure (EFRA 9.2b): the horn and "clock is running" -
+ // each car's own clock then starts at its own first crossing.
+ if (flying) {
+ emitLightState("GO", { sessionId: session?.id });
+ await playEffect("start_tone", { force: true });
+ if (cancelled()) return false;
+ callbacks.onTone?.();
+ trigger("clock_running", { interrupt: true, logData: { sessionId: session?.id } });
+ return true;
}
emitLightState("READY", { sessionId: session?.id });
- await trigger("drivers_ready", { interrupt: true }).voiceDone; if (cancelled()) return false;
- if (session?.type === "qualification") await trigger("qualifying_start").voiceDone; if (cancelled()) return false;
- await trigger("start_sequence").voiceDone; if (cancelled()) return false;
+ if (!callbacks.skipIntro) {
+ await trigger("drivers_ready", { interrupt: true }).voiceDone; if (cancelled()) return false;
+ if (session?.type === "qualification") await trigger("qualifying_start").voiceDone; if (cancelled()) return false;
+ await trigger("start_sequence").voiceDone; if (cancelled()) return false;
+ }
if (settings.startLights) {
for (let light = 1; light <= 5; light += 1) {
emitLightState(`LIGHT_${light}`, { sessionId: session?.id, lights: light });
diff --git a/src/race_day_view.js b/src/race_day_view.js
index 534b208..c6f2e73 100644
--- a/src/race_day_view.js
+++ b/src/race_day_view.js
@@ -1,5 +1,6 @@
import { isPracticeSessionType } from "./track_layouts_logic.js";
import { renderTrackLayoutBar, bindTrackLayoutBar } from "./track_layout_bar.js";
+import { PROLOGUE_OPTIONS_SEC, formatPrologueOption } from "./prologue_logic.js";
export function getRaceDayCheckAction(item, session) {
if (item.level === "ok") return null;
if (["backend", "decoder", "split_decoder", "pit_decoders", "audio"].includes(item.id)) {
@@ -54,11 +55,11 @@ function renderQueueSession(session, positionKey, deps) {
}
-// --- Warm-up / transponder check -------------------------------------------------------
-// Shown for a selected, not-yet-started race-format heat with driver transponders. The panel
-// re-renders on its own (refreshRaceDayWarmup) on every passing, without rebuilding the page.
+// --- Prologue (count-down) and transponder check ---------------------------------------
+// A race-format heat is started through a prologue (see prologue_logic.js): Start begins the
+// count-down, and the check panel shows who has crossed the line since. The panel re-renders
+// on its own (refreshRaceDayWarmup) on every passing, without rebuilding the page.
let lastWarmupDeps = null;
-let warmupClockTimer = null;
function showsWarmupPanel(session) {
return Boolean(session && session.status === "ready" && session.mode !== "track" && session.type !== "team_race" && !isPracticeSessionType(session.type));
@@ -69,10 +70,31 @@ function formatMmSs(ms) {
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
}
+function renderPrologueControls({ t, escapeHtml, getDefaultPrologueSec }, session, check, canStart) {
+ const prologue = check?.prologue;
+ if (!prologue) {
+ const defaultSec = getDefaultPrologueSec(session);
+ return `
+
+ `;
+ }
+ const held = prologue.heldRemainingMs !== null;
+ return `
+ ${formatMmSs(check.remainingMs)}
+ ${t(held ? "prologue.held" : "prologue.running")}
+
+
+
+
+
+ `;
+}
+
function renderWarmupPanelInner({ t, escapeHtml, getRaceDayWarmupCheck }, session) {
const check = getRaceDayWarmupCheck(session);
- const warmup = check.warmup;
- const remaining = warmup && !warmup.stopped ? Math.max(0, warmup.startedAt + warmup.durationMs - Date.now()) : 0;
+ const warmup = check.prologue;
const unknownOptions = check.unknownTransponders.map((item) => ``).join("");
const status = (row) => (!warmup ? "–" : row.seen ? "✅" : "⏳");
return `
@@ -81,14 +103,7 @@ function renderWarmupPanelInner({ t, escapeHtml, getRaceDayWarmupCheck }, sessio
${warmup ? `${t("warmup.seen_count", { seen: check.seenCount, total: check.total })}` : ""}