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

@@ -231,6 +231,10 @@ export function normalizeEvent(event, { normalizeBrandingConfig, normalizeStartM
finalsSource: event?.raceConfig?.finalsSource === "practice" ? "practice" : "qualifying",
teams: Array.isArray(event?.raceConfig?.teams) ? event.raceConfig.teams.map((team) => normalizeRaceTeam(team)).filter((team) => team.name) : [],
practiceOnly: event?.raceConfig?.practiceOnly === true,
scheduleStartAt: Number(event?.raceConfig?.scheduleStartAt) > 0 ? Number(event.raceConfig.scheduleStartAt) : null,
// 0 is a valid gap (back-to-back sessions), so this can't use || - only a genuinely
// non-numeric/missing value should fall back to the 3-minute default.
scheduleGapMin: Math.max(0, Number.isFinite(Number(event?.raceConfig?.scheduleGapMin)) ? Number(event.raceConfig.scheduleGapMin) : 3),
},
};
}
@@ -950,3 +954,50 @@ export function applyBumpsForRace(event, { buildFinalStandings, getSessionsForEv
return applied;
}
// A planned run-of-day schedule: given a start time and a gap between sessions, each
// session gets a planned clock time in running order (practice, then qualifying, then
// finals - the same order the printed start lists already use). Once a session has
// actually started or finished, everything after it re-anchors to that real time instead
// of the original plan - a heat that started late or ran long (including any pause; a
// paused session's real endedAt already reflects the full wall-clock time it took)
// automatically pushes the rest of the day forward, with no separate "reschedule" step.
export function buildEventSchedule(event, sessions, { getSessionSortWeight }) {
const scheduleStartAt = Number(event?.raceConfig?.scheduleStartAt || 0) || null;
if (!scheduleStartAt) {
return [];
}
const gapMs = Math.max(0, Number(event?.raceConfig?.scheduleGapMin ?? 3) || 0) * 60000;
const ordered = [...sessions]
.filter((session) => session.mode === "race")
.sort((left, right) => {
const weightDiff = getSessionSortWeight(left) - getSessionSortWeight(right);
if (weightDiff !== 0) {
return weightDiff;
}
return String(left.name || "").localeCompare(String(right.name || ""));
});
let cursor = scheduleStartAt;
return ordered.map((session) => {
const plannedStart = cursor;
const durationMs = Math.max(1, Number(session.durationMin || 0)) * 60000 + Math.max(0, Number(session.followUpSec || 0)) * 1000;
const plannedEnd = plannedStart + durationMs;
const actualStart = Number(session.startedAt || 0) || null;
const actualEnd = session.status === "finished" ? Number(session.endedAt || 0) || null : null;
const effectiveEnd = actualEnd || (actualStart ? actualStart + durationMs : plannedEnd);
cursor = effectiveEnd + gapMs;
return {
sessionId: session.id,
plannedStart,
plannedEnd,
actualStart,
actualEnd,
// A session that actually started more than a minute after its own planned time is
// worth flagging - small drift is normal and not worth calling out row by row.
delayed: Boolean(actualStart && actualStart > plannedStart + 60000),
};
});
}