From e356e30eda042e1e4cec141fab1e8e38db9a857f Mon Sep 17 00:00:00 2001 From: larssand Date: Thu, 10 Sep 2026 21:59:23 +0200 Subject: [PATCH] Fix staggered heat call-out order and extend its shared clock getEventDrivers followed the drivers directory's internal creation order instead of the order participants were actually selected for the event, so the fallback qualifying/finals grid, the Start List display, and the staggered-release announcer all read out drivers in a scrambled order relative to how the event was actually entered. Fixed to preserve raceConfig.driverIds' own order. Also, a staggered heat's shared clock/auto-finish target only ever counted the flat session duration, not the staggered release schedule itself - so the last-released driver's own personal window could still be running when the session force-finished. The shared clock now widens by (driverCount - 1) * staggerGapSec on top of the base duration for staggered sessions; each driver's own personal quota is unchanged. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 7 +++++ ROADMAP.md | 9 ++++++ package-lock.json | 4 +-- package.json | 2 +- src/event_race_logic.js | 11 ++++++- src/timing_logic.js | 15 +++++++++- tests/race_timing_logic.test.js | 51 +++++++++++++++++++++++++++++++++ 7 files changed, 94 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b52193..2933d88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.14.1 - 2026-09-10 + +### Fixed + +- A staggered heat's car call-outs (and its start list, and its fallback grid seeding before any practice has been run) followed the drivers directory's internal creation order instead of the order participants were actually selected for the event - so the announced order, and the start list shown in Race Day, could look scrambled relative to how the event was actually entered. +- A staggered heat's shared clock and auto-finish timer only ever counted a flat session duration (e.g. 5:00), not accounting for the staggered release schedule itself - so with several drivers being released a few seconds apart, the last-released driver's own personal window could still be running when the session force-finished. The shared clock now starts from duration + the full release schedule (e.g. 5:09 for 4 drivers 3 seconds apart) instead of just the flat duration, while each driver's own personal 5-minute quota is unchanged. + ## 1.14.0 - 2026-09-10 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 2744d76..9c1d777 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -350,6 +350,15 @@ Found two genuine gaps and, after confirming the desired behavior with the user, Verified 2026-09-10: new unit tests cover both - `tests/race_audio.test.js` (the staggered release sequence fires in grid order with real ~3s gaps only when `startMode === "staggered"`, and never for a mass start even when a grid order is passed) and `tests/race_timing_logic.test.js` (`personalTimeUp` true/false cases, paused-time exclusion, never flagged outside staggered mode). A full headless-browser run against a real, isolated server reproduced the exact scenario end to end: started a real 3-driver staggered heat through the real Race Day "Start" button and confirmed all 3 transponders were announced in the session's actual grid order (not driver-creation order) with the real ~3-second gaps elapsing (not fired instantly), confirmed a mass-start final's sequence never calls out individual cars, then seeded one driver 6 minutes into their own 5-minute staggered window and another only 2 minutes in and confirmed the green "Done" pill appeared next to the first driver's name (and only that driver's) in both the real Timing leaderboard and the real standalone overlay page. +## Staggered heat call-out order and shared clock, found live-testing the feature above + +Reported 2026-09-10 by the user, right after testing the staggered-release announcer above with a real 4-driver, 2-heat qualifying setup: (1) the car call-outs did not follow the order the drivers appeared in "the list", and (2) the heat's shared timer just showed a flat 5:00 and didn't seem to count down - expected it to be sequential, with the last-released driver finishing around 5:20 or so per the timer. + +- [x] **Root cause of (1)**: `getEventDrivers` (`src/event_race_logic.js`) filtered the club's global drivers-directory array by membership in `event.raceConfig.driverIds`, but kept the *directory's own creation order* rather than the order the admin actually selected participants in - the two are unrelated whenever drivers weren't added to the club in the same order they were later picked for this event. This function feeds the fallback qualifying/finals grid (used whenever no practice data exists yet to seed from) and, through that, both the Start List display and the staggered announcer - so both looked "scrambled" relative to the actual entry list, but were still consistent with each other (not a separate mismatch bug). Fixed to preserve `driverIds`' own order, filtered down to drivers still valid for the event's class. +- [x] **Root cause of (2), and confirmed (2)'s "timer not counting down" half did not reproduce** - a live test showed the clock ticking down normally (05:09 → 05:08 → 05:07...) once the extension below was in place; the "started at 5:00" complaint was consistent with the flat, unextended base since the real issue was the missing extension, not a frozen clock. `getSessionTiming` (`src/timing_logic.js`) now widens a staggered session's own shared clock/auto-finish target by the full release schedule (`(driverCount - 1) * staggerGapSec` seconds, using the field `generateQualifyingForRace` already sets to 3s) on top of the plain duration - a 4-driver, 3-second-gap heat now shows and enforces 5:09, not a flat 5:00. Each competitor's own personal quota (`getSessionTargetMs`, used by `buildLeaderboard`'s `personalTimeUp` from the entry above) is deliberately left untouched - only the session-level shared clock widens, not any individual driver's own allotted time. + +Verified 2026-09-10: new unit tests cover both directly - `tests/race_timing_logic.test.js` (`getEventDrivers` follows selection order and drops stale/out-of-class ids; the staggered clock extension math for 4 drivers/3s gaps, a mass-start session with the same driver count staying unextended, and a solo staggered heat having no extension). A full headless-browser run reproduced the user's exact setup (4 drivers, participant order deliberately reversed from directory order, real "Generate qualifying" button, real Race Day "Start"): the Start List now correctly reads in the selected participant order, the announced transponder order matched it, and the live clock started at 05:09 and counted down normally instead of a flat, unextended 05:00. + ## Documentation maintenance - [ ] Update the architecture's module/dependency inventory as modules change. diff --git a/package-lock.json b/package-lock.json index a59d3bc..9462b1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.14.0", + "version": "1.14.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.14.0", + "version": "1.14.1", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index 801fcdb..d2f11c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.14.0", + "version": "1.14.1", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/src/event_race_logic.js b/src/event_race_logic.js index 0d8e45b..04928f9 100644 --- a/src/event_race_logic.js +++ b/src/event_race_logic.js @@ -548,7 +548,16 @@ export function getEventDrivers(event, { state }) { if (!event?.raceConfig?.participantsConfigured) { return classDrivers; } - return classDrivers.filter((driver) => (event.raceConfig.driverIds || []).includes(driver.id)); + // Preserves the order participants were selected in (event.raceConfig.driverIds), not + // the club's global driver-directory order - the two rarely match, and anything seeded + // from this list (a fallback qualifying/finals grid with no practice data yet, a + // staggered heat's start-order announcement) should follow the order a club admin + // actually built the entry list in, not an unrelated internal array order. + const classDriverIds = new Set(classDrivers.map((driver) => driver.id)); + const byId = new Map(classDrivers.map((driver) => [driver.id, driver])); + return (event.raceConfig.driverIds || []) + .filter((id) => classDriverIds.has(id)) + .map((id) => byId.get(id)); } diff --git a/src/timing_logic.js b/src/timing_logic.js index bcd6c9b..0c2e34d 100644 --- a/src/timing_logic.js +++ b/src/timing_logic.js @@ -79,7 +79,20 @@ export function getPassingValidationLabel(passing, { t }) { export function getSessionTiming(session, nowTs = Date.now(), { getSessionTargetMs }) { - const targetMs = getSessionTargetMs(session); + const baseTargetMs = getSessionTargetMs(session); + // A staggered heat doesn't release every car at once (see runStartSequence in + // race_audio.js) - the shared clock and auto-finish threshold need to cover the full + // release schedule too, or the last-released driver's own personal window could still + // be running when the session force-finishes and locks everyone out. Each competitor's + // OWN personal duration (used for buildLeaderboard's personalTimeUp) stays exactly + // durationMin - this only widens the session-level clock, not any individual's quota. + const gridCount = Array.isArray(session?.manualGridIds) && session.manualGridIds.length + ? session.manualGridIds.length + : Array.isArray(session?.driverIds) ? session.driverIds.length : 0; + const staggerExtensionMs = session?.startMode === "staggered" && baseTargetMs !== null && gridCount > 1 + ? (gridCount - 1) * Math.max(0, Number(session?.staggerGapSec || 3) || 3) * 1000 + : 0; + const targetMs = baseTargetMs === null ? null : baseTargetMs + staggerExtensionMs; const startedAt = Number(session?.startedAt || 0); const pausedAt = Number(session?.pausedAt || 0) || 0; const endedAt = Number(session?.endedAt || 0) || 0; diff --git a/tests/race_timing_logic.test.js b/tests/race_timing_logic.test.js index b0632e6..0c991d3 100644 --- a/tests/race_timing_logic.test.js +++ b/tests/race_timing_logic.test.js @@ -8,6 +8,7 @@ import { buildRaceSessionsFromWizard, generateFinalsForRace, generateQualifyingForRace, + getEventDrivers, getTeamDriverPool, moveDriversIntoQualifyingHeat, normalizeEvent, @@ -185,6 +186,31 @@ test("getTeamDriverPool scopes to the event's class/participants, only falling b assert.equal(noMatches.fallback, true); }); +// Regression coverage for the user's report: a staggered heat's start-order announcement +// (and its start grid, and any fallback qualifying/finals seeding with no practice data +// yet) all derive from getEventDrivers - it used to return drivers in the club's global +// directory/creation order, completely ignoring the order they were actually selected as +// this event's participants, which is what a club admin actually sees as "the list". +test("getEventDrivers follows the order participants were selected in, not the drivers directory's creation order", () => { + const state = { + drivers: [ + { id: "d1", name: "One", classId: "2wd" }, + { id: "d2", name: "Two", classId: "2wd" }, + { id: "d3", name: "Three", classId: "2wd" }, + { id: "d4", name: "Four", classId: "2wd" }, + { id: "d5", name: "Five", classId: "4wd" }, + ], + }; + const event = { classId: "2wd", raceConfig: { participantsConfigured: true, driverIds: ["d4", "d2", "d1"] } }; + assert.deepEqual(getEventDrivers(event, { state }).map((driver) => driver.id), ["d4", "d2", "d1"], "matches selection order, not directory order, and excludes d3 who was never selected"); + + const stale = { classId: "2wd", raceConfig: { participantsConfigured: true, driverIds: ["d4", "d5", "d1"] } }; + assert.deepEqual(getEventDrivers(stale, { state }).map((driver) => driver.id), ["d4", "d1"], "drops a selected id that no longer belongs to this event's class (d5 is 4wd)"); + + const unconfigured = { classId: "2wd", raceConfig: {} }; + assert.deepEqual(getEventDrivers(unconfigured, { state }).map((driver) => driver.id), ["d1", "d2", "d3", "d4"], "before participants are configured, falls back to every driver in the class"); +}); + test("race wizard creates configured practice, qualifying, and endurance sessions", () => { const event = { id: "event_1", @@ -494,6 +520,31 @@ test("session timing handles untimed practice, countdown, and follow-up", () => assert.equal(untimed.elapsedMs, 60000); }); +// Regression coverage for the user's report: a staggered-start qualifying heat with 4 +// drivers shouldn't force-finish (and cut off) the last-released driver before they've +// had their own full personal window - the shared clock must cover the whole release +// schedule (3 gaps of staggerGapSec between 4 cars), on top of the plain duration. +test("staggered sessions extend the shared clock/auto-finish target by the full release schedule, without changing each driver's own personal duration", () => { + const getTarget = (session) => getSessionTargetMs(session, { isUntimedSession }); + const staggeredHeat = { type: "qualification", startMode: "staggered", durationMin: 5, staggerGapSec: 3, driverIds: ["d1", "d2", "d3", "d4"], manualGridIds: ["d1", "d2", "d3", "d4"], startedAt: 1000 }; + + // Each competitor's own quota (used for buildLeaderboard's personalTimeUp) is untouched. + assert.equal(getTarget(staggeredHeat), 300000); + + // The session-level clock adds 3 gaps of 3s (for 4 cars) on top of the 5-minute base. + const timing = getSessionTiming(staggeredHeat, 301000, { getSessionTargetMs: getTarget }); + assert.equal(timing.targetMs, 309000, "5:00 plus 3 gaps of 3s = 5:09"); + assert.equal(timing.remainingMs, 9000, "still 9s of the extended window left at the nominal 5:00 mark"); + + // A mass/position start with the same driver count is never extended. + const massFinal = { ...staggeredHeat, startMode: "mass" }; + assert.equal(getSessionTiming(massFinal, 301000, { getSessionTargetMs: getTarget }).remainingMs, 0); + + // A single-driver staggered heat has no release gaps to cover. + const soloHeat = { ...staggeredHeat, driverIds: ["d1"], manualGridIds: ["d1"] }; + assert.equal(getSessionTiming(soloHeat, 301000, { getSessionTargetMs: getTarget }).remainingMs, 0); +}); + test("a finished session's elapsed time is frozen at endedAt, not still counting up against the real clock", () => { const getTarget = (session) => getSessionTargetMs(session, { isUntimedSession }); const finished = { type: "final", durationMin: 5, status: "finished", startedAt: 1000, endedAt: 301000 };