Add a time-of-day race day schedule to Event Manager

Events can now be given a start time and a gap between sessions on a
new "Schedule" panel; every session's planned clock time cascades
automatically in running order (practice, qualifying, finals), and
once a session actually starts or finishes, everything after it
re-anchors to that real time - a late start or a session that ran
long (including any pause) pushes the rest of the day forward with
no manual reschedule step. The scheduled time now also shows on the
printed/PDF start lists.

Printable start lists and manually moving a driver to start last in
their own heat (for a mechanic-time request) already existed, so
this only builds the missing piece: the schedule itself.

Also fixes a real pre-existing bug in the same session-ordering logic
this feature relies on: getSessionSortWeight used `order[type] || 99`,
which silently treated open_practice's own weight of 0 as falsy and
sorted practice sessions dead last instead of first in print/PDF
output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-10 22:33:57 +02:00
parent a449bec07d
commit cf89086dff
11 changed files with 250 additions and 8 deletions

View File

@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import {
applyBumpsForRace,
buildEventSchedule,
buildOpenPracticeBundle,
buildRaceFormatConfigFromForm,
buildRaceSessionsFromWizard,
@@ -35,6 +36,7 @@ import {
isUntimedSession,
ensureSessionResult,
} from "../src/timing_logic.js";
import { getSessionSortWeight } from "../src/race_render_helpers.js";
const testFormatLap = (ms) => (Number.isFinite(ms) ? `${ms}ms` : "-");
const testFormatRaceClock = (ms) => `${ms}ms`;
@@ -211,6 +213,60 @@ test("getEventDrivers follows the order participants were selected in, not the d
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");
});
// Found while building buildEventSchedule below (which relies on this for running order):
// open_practice's own weight is 0, which `order[key] || 99` treated as falsy and silently
// replaced with the 99 "unknown type" fallback - sorting practice sessions dead last
// instead of first everywhere this drives ordering (print/PDF start lists and results,
// and now the schedule).
test("getSessionSortWeight puts open_practice first, not last (0 is a valid weight, not a missing one)", () => {
assert.equal(getSessionSortWeight({ type: "open_practice" }), 0);
assert.ok(getSessionSortWeight({ type: "open_practice" }) < getSessionSortWeight({ type: "qualification" }));
assert.ok(getSessionSortWeight({ type: "qualification" }) < getSessionSortWeight({ type: "final" }));
assert.equal(getSessionSortWeight({ type: "something_unknown" }), 99, "a genuinely unknown type still falls back to last");
});
// Regression coverage for the user's feature request: a time-of-day schedule for the
// whole race day, with each session's planned clock time worked out automatically, that
// shifts forward on its own once reality (a late start, a session running long or being
// paused) diverges from the plan - no separate "reschedule" step needed.
test("buildEventSchedule cascades planned times in running order and re-anchors to reality once a session actually happens", () => {
const event = { id: "ev1", mode: "race", raceConfig: { scheduleGapMin: 3 } };
const practice = { id: "s_practice", eventId: "ev1", mode: "race", type: "open_practice", name: "Open Practice", durationMin: 5 };
const qual = { id: "s_qual", eventId: "ev1", mode: "race", type: "qualification", name: "Q1 H1", durationMin: 5 };
const final = { id: "s_final", eventId: "ev1", mode: "race", type: "final", name: "A Final 1", durationMin: 8 };
const trackSession = { id: "s_track", eventId: "ev1", mode: "track", type: "heat", name: "Track Heat", durationMin: 5 };
const sessions = [practice, qual, final, trackSession];
assert.deepEqual(buildEventSchedule({ ...event, raceConfig: { ...event.raceConfig, scheduleStartAt: null } }, sessions, { getSessionSortWeight }), [], "no schedule at all until a start time is set");
const scheduleStartAt = 1_000_000;
const plan = buildEventSchedule({ ...event, raceConfig: { ...event.raceConfig, scheduleStartAt } }, sessions, { getSessionSortWeight });
assert.equal(plan.length, 3, "track-mode sessions are excluded from the race-day schedule");
assert.deepEqual(plan.map((row) => row.sessionId), ["s_practice", "s_qual", "s_final"], "running order: practice, then qualifying, then finals");
assert.equal(plan[0].plannedStart, 1_000_000);
assert.equal(plan[0].plannedEnd, 1_300_000, "5 minutes after the day's start time");
assert.equal(plan[1].plannedStart, 1_480_000, "starts 3 minutes (the gap) after practice's planned end");
assert.equal(plan[1].plannedEnd, 1_780_000);
assert.equal(plan[2].plannedStart, 1_960_000, "again 3 minutes after qualifying's planned end");
assert.equal(plan[2].plannedEnd, 2_440_000, "8 minutes for the final");
assert.ok(plan.every((row) => row.delayed === false), "nothing has actually started yet, so nothing is flagged delayed");
// Practice started 2 minutes late (well past the 1-minute delay threshold) and is still
// running - everything after it must re-anchor to this real start, not the original plan.
const lateStart = { ...event, raceConfig: { ...event.raceConfig, scheduleStartAt } };
const lateSessions = [{ ...practice, startedAt: 1_120_000 }, qual, final];
const latePlan = buildEventSchedule(lateStart, lateSessions, { getSessionSortWeight });
assert.equal(latePlan[0].delayed, true, "started more than a minute after its own planned time");
assert.equal(latePlan[1].plannedStart, 1_600_000, "qualifying's plan shifts forward by practice's own 2-minute delay");
// Practice actually finished, having run 50s over (e.g. a track call) - the real endedAt
// (which already reflects any pause) is what the rest of the day anchors to, not the
// nominal 5-minute duration.
const finishedSessions = [{ ...practice, startedAt: 1_000_000, status: "finished", endedAt: 1_350_000 }, qual, final];
const finishedPlan = buildEventSchedule(lateStart, finishedSessions, { getSessionSortWeight });
assert.equal(finishedPlan[1].plannedStart, 1_530_000, "1,350,000 (real end) + 180,000 (3-minute gap)");
});
test("race wizard creates configured practice, qualifying, and endurance sessions", () => {
const event = {
id: "event_1",