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

@@ -8,6 +8,16 @@ The application version is maintained in `package.json` and displayed in the adm
No changes recorded yet. No changes recorded yet.
## 1.16.0 - 2026-09-10
### Added
- Events can now have a time-of-day schedule: set a start time and a gap between sessions on the "Schedule" panel in Event Manager, and every session's planned clock time is worked out automatically in running order (practice, then qualifying, then finals). Once a session actually starts or finishes, everything after it shifts forward to match reality - a late start or a session that runs long (including any pause) automatically pushes the rest of the day's plan, no manual "reschedule" step needed. The scheduled time is now also shown on the printed/PDF start lists.
### Fixed
- `getSessionSortWeight`, which decides run order for printed start lists/results and the new schedule above, silently sorted `open_practice` sessions (weight 0) dead last instead of first, because `0 || 99` treats a valid zero weight as missing and falls back to the "unknown type" bucket. Practice sessions now correctly sort first, matching how a race day actually runs.
## 1.15.0 - 2026-09-10 ## 1.15.0 - 2026-09-10
### Changed ### Changed

View File

@@ -367,6 +367,23 @@ Reported 2026-09-10 by the user: the "select session" dropdown (Timing) and the
Verified 2026-09-10 with a live headless-browser check against a real, isolated server: seeded 3 events with a mix of practice/qualifying/final sessions and confirmed the rendered `<select>` groups them into 3 correctly-labeled, newest-first `<optgroup>`s with each event's own sessions nested inside. Verified 2026-09-10 with a live headless-browser check against a real, isolated server: seeded 3 events with a mix of practice/qualifying/final sessions and confirmed the rendered `<select>` groups them into 3 correctly-labeled, newest-first `<optgroup>`s with each event's own sessions nested inside.
## Time-of-day race day schedule
Requested 2026-09-10 by the user: when running a race day, should be able to set it up as time-scheduled - start Practice 1 at 12:34, and once it's done the next session (with drivers) is automatically set to start e.g. 3 minutes later at 12:42, and so on; must be pausable if something goes wrong, printable start lists, and a driver who's TQ in a qualifying heat but needs to request work time on their car should be able to start last in that heat instead.
Investigated first and confirmed two of the three pieces already existed, before building the missing one:
- Printable/PDF start lists already existed (`events.print_startlists`/`events.pdf_startlists` in Event Manager).
- Manually moving a driver to start last in their own heat already existed via the drag-and-drop grid editor (`gridDragList` in `event_manager_controller.js`, reordering `session.manualGridIds`) - already covers the "request mech time, start last" scenario without any new work.
Built the genuinely missing piece, confirmed with the user before starting given its size:
- [x] `buildEventSchedule` (`src/event_race_logic.js`, new) computes each race-mode session's planned clock time in running order (reusing the same `getSessionSortWeight` ordering the print/PDF output already uses: practice, then qualifying, then finals), cascading from a configured day-start time plus each session's own duration/follow-up plus a configurable gap between sessions. Once a session has actually started or finished, everything scheduled after it re-anchors to that real time instead of the original plan - a late start or a session that ran long (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" action, which is what covers "must be able to pause" without any extra pause-specific schedule logic.
- [x] New "Schedule" panel in Event Manager (`src/event_manager_view.js`/`event_manager_controller.js`): a start-time + gap-between-sessions form, and (once set) a table of every session with its planned time range, its real start/end once it has one, and a "Delayed" flag for a session that started more than a minute late. New `event.raceConfig.scheduleStartAt`/`scheduleGapMin` fields, added to `normalizeEvent`'s explicit field allowlist (an event's `raceConfig` is fully rebuilt from that allowlist on every save - any field not listed there is silently dropped, caught during this work before it shipped).
- [x] The scheduled time is now also shown on the printed/PDF start lists (`buildRaceStartListsHtml` in `src/race_render_helpers.js`) next to each session's name.
Found and fixed a real, previously-existing bug while building this, unrelated to the new feature but affecting the same ordering logic: `getSessionSortWeight` (`src/race_render_helpers.js`) used `order[type] || 99` - since `open_practice`'s own weight is `0`, a falsy value, `||` silently replaced it with the 99 "unknown type" fallback, sorting practice sessions dead last (tied with genuinely unrecognized types) in the print/PDF start lists and results, instead of first. Fixed to `??`.
Verified 2026-09-10: new unit tests cover `getSessionSortWeight`'s fix directly, and `buildEventSchedule`'s cascading math, the running-order sort, track-mode sessions being excluded, and the re-anchoring behavior for both a late-but-still-running session and a finished one whose real duration (including a pause) differed from the plan. A full headless-browser run against a real, isolated server reproduced the exact reported workflow: set a 12:34 start time with a 3-minute gap on a real Open Practice + Q1 H1, confirmed the schedule panel showed practice at 12:34-12:39 and qualifying at 12:42-12:47, confirmed the printed start list included "12:34" next to Open Practice, then made practice actually start 2 minutes late and confirmed the panel flagged it "Delayed" and qualifying's planned time shifted forward to 12:44 automatically.
## Documentation maintenance ## Documentation maintenance
- [ ] Update the architecture's module/dependency inventory as modules change. - [ ] Update the architecture's module/dependency inventory as modules change.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "rc-racecore", "name": "rc-racecore",
"version": "1.15.0", "version": "1.16.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "rc-racecore", "name": "rc-racecore",
"version": "1.15.0", "version": "1.16.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"better-sqlite3": "^12.11.1", "better-sqlite3": "^12.11.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "rc-racecore", "name": "rc-racecore",
"version": "1.15.0", "version": "1.16.0",
"private": true, "private": true,
"description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence",
"engines": { "engines": {

View File

@@ -11,7 +11,7 @@ import { RACE_AUDIO_EVENT_DEFINITIONS, createRaceAudioEngine, normalizeRaceAudio
import { renderRaceFormatField, renderRaceFormatContextCard, getRaceSummaryItems, getRaceSummaryWarnings, renderManageStatusBadge, renderRaceWizardSteps, renderRaceWizardContent, renderRaceStandingsTable } from "./race_setup_ui.js"; import { renderRaceFormatField, renderRaceFormatContextCard, getRaceSummaryItems, getRaceSummaryWarnings, renderManageStatusBadge, renderRaceWizardSteps, renderRaceWizardContent, renderRaceStandingsTable } from "./race_setup_ui.js";
import { normalizeRaceTeam as normalizeRaceTeamLogic, normalizeStoredRacePreset as normalizeStoredRacePresetLogic, getRaceFormatPresets as getRaceFormatPresetsLogic, applyRaceFormatPreset as applyRaceFormatPresetLogic, buildRaceFormatConfigFromForm as buildRaceFormatConfigFromFormLogic, normalizeEvent as normalizeEventLogic, normalizeBrandingConfig as normalizeBrandingConfigLogic, resolveEventBranding as resolveEventBrandingLogic, getRaceManageStatuses as getRaceManageStatusesLogic, buildDefaultRaceWizardDraft as buildDefaultRaceWizardDraftLogic, getRaceWizardPreset as getRaceWizardPresetLogic, applyRaceWizardPresetDefaults as applyRaceWizardPresetDefaultsLogic, ensureRaceParticipantsConfigured as ensureRaceParticipantsConfiguredLogic, buildRaceSession as buildRaceSessionLogic, buildOpenPracticeBundle as buildOpenPracticeBundleLogic, buildTrackSession as buildTrackSessionLogic, createSponsorRounds as createSponsorRoundsLogic, buildRaceSessionsFromWizard as buildRaceSessionsFromWizardLogic, getRaceWizardSessionPlan as getRaceWizardSessionPlanLogic, getEventDrivers as getEventDriversLogic, getEventTeams as getEventTeamsLogic, getTeamDriverPool as getTeamDriverPoolLogic, reassignTeamMembers as reassignTeamMembersLogic, findEventTeamForPassing as findEventTeamForPassingLogic, generateQualifyingForRace as generateQualifyingForRaceLogic, reseedUpcomingQualifying as reseedUpcomingQualifyingLogic, moveDriversIntoQualifyingHeat as moveDriversIntoQualifyingHeatLogic, parseQualifyingHeatName as parseQualifyingHeatNameLogic, generateFinalsForRace as generateFinalsForRaceLogic, applyBumpsForRace as applyBumpsForRaceLogic } from "./event_race_logic.js"; import { normalizeRaceTeam as normalizeRaceTeamLogic, normalizeStoredRacePreset as normalizeStoredRacePresetLogic, getRaceFormatPresets as getRaceFormatPresetsLogic, applyRaceFormatPreset as applyRaceFormatPresetLogic, buildRaceFormatConfigFromForm as buildRaceFormatConfigFromFormLogic, normalizeEvent as normalizeEventLogic, normalizeBrandingConfig as normalizeBrandingConfigLogic, resolveEventBranding as resolveEventBrandingLogic, getRaceManageStatuses as getRaceManageStatusesLogic, buildDefaultRaceWizardDraft as buildDefaultRaceWizardDraftLogic, getRaceWizardPreset as getRaceWizardPresetLogic, applyRaceWizardPresetDefaults as applyRaceWizardPresetDefaultsLogic, ensureRaceParticipantsConfigured as ensureRaceParticipantsConfiguredLogic, buildRaceSession as buildRaceSessionLogic, buildOpenPracticeBundle as buildOpenPracticeBundleLogic, buildTrackSession as buildTrackSessionLogic, createSponsorRounds as createSponsorRoundsLogic, buildRaceSessionsFromWizard as buildRaceSessionsFromWizardLogic, getRaceWizardSessionPlan as getRaceWizardSessionPlanLogic, getEventDrivers as getEventDriversLogic, getEventTeams as getEventTeamsLogic, getTeamDriverPool as getTeamDriverPoolLogic, reassignTeamMembers as reassignTeamMembersLogic, findEventTeamForPassing as findEventTeamForPassingLogic, generateQualifyingForRace as generateQualifyingForRaceLogic, reseedUpcomingQualifying as reseedUpcomingQualifyingLogic, moveDriversIntoQualifyingHeat as moveDriversIntoQualifyingHeatLogic, parseQualifyingHeatName as parseQualifyingHeatNameLogic, generateFinalsForRace as generateFinalsForRaceLogic, applyBumpsForRace as applyBumpsForRaceLogic, buildEventSchedule as buildEventScheduleLogic } from "./event_race_logic.js";
import { getSessionTypeLabel as getSessionTypeLabelLogic, getStatusLabel as getStatusLabelLogic, isUntimedSession as isUntimedSessionLogic, getActiveSession as getActiveSessionLogic, getSessionTargetMs as getSessionTargetMsLogic, getSessionLapWindow as getSessionLapWindowLogic, isCountedPassing as isCountedPassingLogic, getVisiblePassings as getVisiblePassingsLogic, getPassingValidationLabel as getPassingValidationLabelLogic, getSessionTiming as getSessionTimingLogic, ensureSessionResult as ensureSessionResultLogic, buildLeaderboard as buildLeaderboardLogic, formatLapDelta as formatLapDeltaLogic, formatLeaderboardGap as formatLeaderboardGapLogic, getCompetitorElapsedMs as getCompetitorElapsedMsLogic, getCompetitorPassings as getCompetitorPassingsLogic, getCompetitorSeedMetric as getCompetitorSeedMetricLogic, getSessionEntrants as getSessionEntrantsLogic, buildPracticeStandings as buildPracticeStandingsLogic, getQualifyingPointsValue as getQualifyingPointsValueLogic, isHighPointsTable as isHighPointsTableLogic, compareNumberSet as compareNumberSetLogic, buildQualifyingTieBreakNote as buildQualifyingTieBreakNoteLogic, hasQualifyingPrimaryTie as hasQualifyingPrimaryTieLogic, buildQualifyingStandings as buildQualifyingStandingsLogic, formatTeamActiveMemberLabel as formatTeamActiveMemberLabelLogic, buildTeamRaceStandings as buildTeamRaceStandingsLogic, buildSessionChannelStats as buildSessionChannelStatsLogic, buildTeamStintLog as buildTeamStintLogLogic, getSessionGridEntries as getSessionGridEntriesLogic, getSessionGridOrder as getSessionGridOrderLogic, ensureSessionDriverOrder as ensureSessionDriverOrderLogic, buildFinalStandings as buildFinalStandingsLogic, getTrackRecordMs as getTrackRecordMsLogic } from "./timing_logic.js"; import { getSessionTypeLabel as getSessionTypeLabelLogic, getStatusLabel as getStatusLabelLogic, isUntimedSession as isUntimedSessionLogic, getActiveSession as getActiveSessionLogic, getSessionTargetMs as getSessionTargetMsLogic, getSessionLapWindow as getSessionLapWindowLogic, isCountedPassing as isCountedPassingLogic, getVisiblePassings as getVisiblePassingsLogic, getPassingValidationLabel as getPassingValidationLabelLogic, getSessionTiming as getSessionTimingLogic, ensureSessionResult as ensureSessionResultLogic, buildLeaderboard as buildLeaderboardLogic, formatLapDelta as formatLapDeltaLogic, formatLeaderboardGap as formatLeaderboardGapLogic, getCompetitorElapsedMs as getCompetitorElapsedMsLogic, getCompetitorPassings as getCompetitorPassingsLogic, getCompetitorSeedMetric as getCompetitorSeedMetricLogic, getSessionEntrants as getSessionEntrantsLogic, buildPracticeStandings as buildPracticeStandingsLogic, getQualifyingPointsValue as getQualifyingPointsValueLogic, isHighPointsTable as isHighPointsTableLogic, compareNumberSet as compareNumberSetLogic, buildQualifyingTieBreakNote as buildQualifyingTieBreakNoteLogic, hasQualifyingPrimaryTie as hasQualifyingPrimaryTieLogic, buildQualifyingStandings as buildQualifyingStandingsLogic, formatTeamActiveMemberLabel as formatTeamActiveMemberLabelLogic, buildTeamRaceStandings as buildTeamRaceStandingsLogic, buildSessionChannelStats as buildSessionChannelStatsLogic, buildTeamStintLog as buildTeamStintLogLogic, getSessionGridEntries as getSessionGridEntriesLogic, getSessionGridOrder as getSessionGridOrderLogic, ensureSessionDriverOrder as ensureSessionDriverOrderLogic, buildFinalStandings as buildFinalStandingsLogic, getTrackRecordMs as getTrackRecordMsLogic } from "./timing_logic.js";
import { normalizeTrackLayouts, getTrackLayoutIdAt, getTrackLayoutLabel, nextTrackLayoutLabel } from "./track_layouts_logic.js"; import { normalizeTrackLayouts, getTrackLayoutIdAt, getTrackLayoutLabel, nextTrackLayoutLabel } from "./track_layouts_logic.js";
@@ -120,13 +120,14 @@ const getManualCorrectionSummary = (row) => getManualCorrectionSummaryLogic(row,
const renderTeamStintLog = (session, rows) => renderTeamStintLogHelper(session, rows, { t, escapeHtml, buildTeamStintLog, formatTeamActiveMemberLabel, renderTable, formatRaceClock }); const renderTeamStintLog = (session, rows) => renderTeamStintLogHelper(session, rows, { t, escapeHtml, buildTeamStintLog, formatTeamActiveMemberLabel, renderTable, formatRaceClock });
const renderTeamRaceStandings = (event) => renderTeamRaceStandingsHelper(event, { t, escapeHtml, buildTeamRaceStandings, getSessionTypeLabel, renderTable, formatTeamActiveMemberLabel, formatLap, formatRaceClock, renderTeamStintLog, buildSessionChannelStats }); const renderTeamRaceStandings = (event) => renderTeamRaceStandingsHelper(event, { t, escapeHtml, buildTeamRaceStandings, getSessionTypeLabel, renderTable, formatTeamActiveMemberLabel, formatLap, formatRaceClock, renderTeamStintLog, buildSessionChannelStats });
const getSessionSortWeight = (session) => getSessionSortWeightHelper(session); const getSessionSortWeight = (session) => getSessionSortWeightHelper(session);
const buildEventSchedule = (event) => buildEventScheduleLogic(event, getSessionsForEvent(event.id), { getSessionSortWeight });
const getDriverDisplayById = (driverId) => getDriverDisplayByIdHelper(driverId, { state, t }); const getDriverDisplayById = (driverId) => getDriverDisplayByIdHelper(driverId, { state, t });
const renderPositionGrid = (session) => renderPositionGridHelper(session, { t, escapeHtml, getSessionGridEntries }); const renderPositionGrid = (session) => renderPositionGridHelper(session, { t, escapeHtml, getSessionGridEntries });
const renderGridEditor = (session) => renderGridEditorHelper(session, { t, escapeHtml, ensureSessionDriverOrder, state }); const renderGridEditor = (session) => renderGridEditorHelper(session, { t, escapeHtml, ensureSessionDriverOrder, state });
const getFinalMainLayouts = (event) => getFinalMainLayoutsHelper(event, { getSessionsForEvent, getSessionGridOrder, getDriverDisplayById, t }); const getFinalMainLayouts = (event) => getFinalMainLayoutsHelper(event, { getSessionsForEvent, getSessionGridOrder, getDriverDisplayById, t });
const renderFinalMatrix = (event) => renderFinalMatrixHelper(event, { t, escapeHtml, getFinalMainLayouts, getStatusLabel }); const renderFinalMatrix = (event) => renderFinalMatrixHelper(event, { t, escapeHtml, getFinalMainLayouts, getStatusLabel });
const buildPrintBrandBlock = (branding) => buildPrintBrandBlockHelper(branding, { escapeHtml }); const buildPrintBrandBlock = (branding) => buildPrintBrandBlockHelper(branding, { escapeHtml });
const buildRaceStartListsHtml = (event) => buildRaceStartListsHtmlHelper(event, { t, state, escapeHtml, resolveEventBranding, getSessionsForEvent, getSessionSortWeight, getClassName, buildPrintBrandBlock, getSessionGridEntries, getSessionTypeLabel, getStartModeLabel, renderTable }); const buildRaceStartListsHtml = (event) => buildRaceStartListsHtmlHelper(event, { t, state, escapeHtml, resolveEventBranding, getSessionsForEvent, getSessionSortWeight, getClassName, buildPrintBrandBlock, getSessionGridEntries, getSessionTypeLabel, getStartModeLabel, renderTable, buildEventSchedule });
const buildRaceResultsHtml = (event) => buildRaceResultsHtmlHelper(event, { t, escapeHtml, resolveEventBranding, getClassName, buildPrintBrandBlock, renderRaceStandingsTableView, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings, renderTeamRaceStandings, getSessionsForEvent, renderSessionChannelAnalysis }); const buildRaceResultsHtml = (event) => buildRaceResultsHtmlHelper(event, { t, escapeHtml, resolveEventBranding, getClassName, buildPrintBrandBlock, renderRaceStandingsTableView, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings, renderTeamRaceStandings, getSessionsForEvent, renderSessionChannelAnalysis });
const getSessionsForEvent = (eventId) => getSessionsForEventHelper(state, eventId); const getSessionsForEvent = (eventId) => getSessionsForEventHelper(state, eventId);
const getModeLabel = (mode) => getModeLabelHelper(mode, { t }); const getModeLabel = (mode) => getModeLabelHelper(mode, { t });
@@ -1137,6 +1138,16 @@ const TRANSLATIONS = {
"events.no_practice_results": "Inga practice-resultat ännu.", "events.no_practice_results": "Inga practice-resultat ännu.",
"events.no_qualifying_results": "Inga kval-resultat ännu.", "events.no_qualifying_results": "Inga kval-resultat ännu.",
"events.no_final_results": "Inga final-resultat ännu.", "events.no_final_results": "Inga final-resultat ännu.",
"events.schedule_title": "Tidsschema",
"events.schedule_hint": "Sätt en starttid för dagen så räknas varje sessions planerade klockslag ut automatiskt utifrån längd, efterlöp och paus mellan sessioner. Skjuter en session upp sig (körs över tiden eller pausas) flyttas resten av schemat fram automatiskt.",
"events.schedule_start_time": "Starttid",
"events.schedule_gap": "Paus mellan sessioner (min)",
"events.schedule_save": "Spara schema",
"events.schedule_clear": "Rensa schema",
"events.schedule_not_set": "Inget schema satt ännu. Sätt en starttid ovan.",
"events.schedule_planned": "Planerad tid",
"events.schedule_actual": "Faktisk tid",
"events.schedule_delayed": "Försenad",
"events.final_matrix": "Finalmatris", "events.final_matrix": "Finalmatris",
"events.print_startlists": "Skriv ut startlistor", "events.print_startlists": "Skriv ut startlistor",
"events.print_results": "Skriv ut resultat", "events.print_results": "Skriv ut resultat",
@@ -2655,6 +2666,16 @@ const TRANSLATIONS = {
"events.no_practice_results": "No practice results yet.", "events.no_practice_results": "No practice results yet.",
"events.no_qualifying_results": "No qualifying results yet.", "events.no_qualifying_results": "No qualifying results yet.",
"events.no_final_results": "No final results yet.", "events.no_final_results": "No final results yet.",
"events.schedule_title": "Schedule",
"events.schedule_hint": "Set a start time for the day and each session's planned clock time is worked out automatically from its duration, follow-up, and the gap between sessions. If a session runs long or gets paused, the rest of the schedule shifts forward automatically.",
"events.schedule_start_time": "Start time",
"events.schedule_gap": "Gap between sessions (min)",
"events.schedule_save": "Save schedule",
"events.schedule_clear": "Clear schedule",
"events.schedule_not_set": "No schedule set yet. Set a start time above.",
"events.schedule_planned": "Planned time",
"events.schedule_actual": "Actual time",
"events.schedule_delayed": "Delayed",
"events.final_matrix": "Final matrix", "events.final_matrix": "Final matrix",
"events.print_startlists": "Print start lists", "events.print_startlists": "Print start lists",
"events.print_results": "Print results", "events.print_results": "Print results",
@@ -5035,6 +5056,7 @@ function renderEventManager(eventId) {
normalizeEvent, normalizeEvent,
ensureRaceParticipantsConfigured, ensureRaceParticipantsConfigured,
getSessionsForEvent, getSessionsForEvent,
buildEventSchedule,
getSelectedTeamEditId: () => selectedTeamEditId, getSelectedTeamEditId: () => selectedTeamEditId,
setSelectedTeamEditId: (value) => { selectedTeamEditId = value; }, setSelectedTeamEditId: (value) => { selectedTeamEditId = value; },
getSelectedSessionEditId: () => selectedSessionEditId, getSelectedSessionEditId: () => selectedSessionEditId,

View File

@@ -86,6 +86,7 @@ export function renderEventManagerView(context) {
ensureSessionDriverOrder, ensureSessionDriverOrder,
reorderList, reorderList,
getEventName, getEventName,
buildEventSchedule,
} = context; } = context;
const selectedTeamEditId = getSelectedTeamEditId(); const selectedTeamEditId = getSelectedTeamEditId();
@@ -147,12 +148,14 @@ export function renderEventManagerView(context) {
if (!selectedGridSessionId && selectedGridSession) { if (!selectedGridSessionId && selectedGridSession) {
setSelectedGridSessionId(selectedGridSession.id); setSelectedGridSessionId(selectedGridSession.id);
} }
const eventSchedule = event.mode === "race" ? buildEventSchedule(event) : [];
eventManageArea.innerHTML = renderEventManagerMarkup({ eventManageArea.innerHTML = renderEventManagerMarkup({
event, event,
t, t,
escapeHtml, escapeHtml,
sessions, sessions,
eventSchedule,
sessionTypeChoices, sessionTypeChoices,
sessionTypeHintKey, sessionTypeHintKey,
raceTeams, raceTeams,
@@ -692,6 +695,30 @@ export function renderEventManagerView(context) {
refreshManager(); refreshManager();
}); });
document.getElementById("scheduleForm")?.addEventListener("submit", (e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
const startValue = String(form.get("scheduleStartAt") || "").trim();
const parsedStart = startValue ? new Date(startValue).getTime() : NaN;
const gapMin = Math.max(0, Number(form.get("scheduleGapMin")) || 0);
updateEvent((item) => ({
...item,
raceConfig: {
...item.raceConfig,
scheduleStartAt: Number.isFinite(parsedStart) ? parsedStart : null,
scheduleGapMin: gapMin,
},
}));
saveState();
refreshManager();
});
document.getElementById("scheduleClear")?.addEventListener("click", () => {
updateEvent((item) => ({ ...item, raceConfig: { ...item.raceConfig, scheduleStartAt: null } }));
saveState();
refreshManager();
});
document.getElementById("applyRacePreset")?.addEventListener("click", () => { document.getElementById("applyRacePreset")?.addEventListener("click", () => {
const formElement = document.getElementById("raceFormatForm"); const formElement = document.getElementById("raceFormatForm");
if (!(formElement instanceof HTMLFormElement)) { if (!(formElement instanceof HTMLFormElement)) {

View File

@@ -1,9 +1,21 @@
function formatDateTimeLocalValue(ms) {
const date = new Date(ms);
date.setSeconds(0, 0);
date.setMinutes(date.getMinutes() - date.getTimezoneOffset());
return date.toISOString().slice(0, 16);
}
function formatClockTime(ms) {
return new Date(ms).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
}
export function renderEventManagerMarkup(context) { export function renderEventManagerMarkup(context) {
const { const {
event, event,
t, t,
escapeHtml, escapeHtml,
sessions, sessions,
eventSchedule,
sessionTypeChoices, sessionTypeChoices,
sessionTypeHintKey, sessionTypeHintKey,
raceTeams, raceTeams,
@@ -742,6 +754,39 @@ export function renderEventManagerMarkup(context) {
</section> </section>
` : ""} ` : ""}
<section class="panel mt-16">
<div class="panel-header"><h3>${t("events.schedule_title")}</h3></div>
<div class="panel-body">
<p class="hint">${t("events.schedule_hint")}</p>
<form id="scheduleForm" class="form-grid cols-3">
<label>${t("events.schedule_start_time")}<input type="datetime-local" name="scheduleStartAt" value="${event.raceConfig?.scheduleStartAt ? escapeHtml(formatDateTimeLocalValue(event.raceConfig.scheduleStartAt)) : ""}" /></label>
<label>${t("events.schedule_gap")}<input type="number" min="0" step="1" name="scheduleGapMin" value="${Number(event.raceConfig?.scheduleGapMin ?? 3)}" /></label>
<div class="actions">
<button class="btn btn-primary" type="submit">${t("events.schedule_save")}</button>
${event.raceConfig?.scheduleStartAt ? `<button id="scheduleClear" class="btn btn-danger" type="button">${t("events.schedule_clear")}</button>` : ""}
</div>
</form>
${eventSchedule && eventSchedule.length
? renderTable(
[t("table.session"), t("table.type"), t("events.schedule_planned"), t("events.schedule_actual"), ""],
eventSchedule.map((row) => {
const session = sessions.find((item) => item.id === row.sessionId);
if (!session) return "";
return `
<tr>
<td>${escapeHtml(session.name)}</td>
<td>${escapeHtml(getSessionTypeLabel(session.type))}</td>
<td>${escapeHtml(formatClockTime(row.plannedStart))} - ${escapeHtml(formatClockTime(row.plannedEnd))}</td>
<td>${row.actualStart ? escapeHtml(formatClockTime(row.actualStart)) + (row.actualEnd ? ` - ${escapeHtml(formatClockTime(row.actualEnd))}` : "") : "-"}</td>
<td>${row.delayed ? `<span class="pill pill-warn">${t("events.schedule_delayed")}</span>` : ""}</td>
</tr>
`;
})
)
: `<p class="hint">${t("events.schedule_not_set")}</p>`}
</div>
</section>
<section class="panel mt-16"> <section class="panel mt-16">
<div class="panel-header"><h3>${t("events.final_matrix")}</h3></div> <div class="panel-header"><h3>${t("events.final_matrix")}</h3></div>
<div class="panel-body"> <div class="panel-body">

View File

@@ -231,6 +231,10 @@ export function normalizeEvent(event, { normalizeBrandingConfig, normalizeStartM
finalsSource: event?.raceConfig?.finalsSource === "practice" ? "practice" : "qualifying", finalsSource: event?.raceConfig?.finalsSource === "practice" ? "practice" : "qualifying",
teams: Array.isArray(event?.raceConfig?.teams) ? event.raceConfig.teams.map((team) => normalizeRaceTeam(team)).filter((team) => team.name) : [], teams: Array.isArray(event?.raceConfig?.teams) ? event.raceConfig.teams.map((team) => normalizeRaceTeam(team)).filter((team) => team.name) : [],
practiceOnly: event?.raceConfig?.practiceOnly === true, 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; 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),
};
});
}

View File

@@ -159,7 +159,11 @@ export function getSessionSortWeight(session) {
final: 5, final: 5,
team_race: 6, team_race: 6,
}; };
return order[String(session?.type || "").toLowerCase()] || 99; // Nullish coalescing, not ||: open_practice's own weight is 0, which || would treat as
// falsy and collapse to the 99 fallback - sorting practice sessions dead last (tied with
// genuinely unknown types) everywhere this drives print/PDF/schedule ordering, instead
// of first.
return order[String(session?.type || "").toLowerCase()] ?? 99;
} }
export function getDriverDisplayById(driverId, { state, t }) { export function getDriverDisplayById(driverId, { state, t }) {
@@ -359,7 +363,7 @@ export function buildPrintBrandBlock(branding, { escapeHtml }) {
`; `;
} }
export function buildRaceStartListsHtml(event, { t, state, escapeHtml, resolveEventBranding, getSessionsForEvent, getSessionSortWeight, getClassName, buildPrintBrandBlock, getSessionGridEntries, getSessionTypeLabel, getStartModeLabel, renderTable }) { export function buildRaceStartListsHtml(event, { t, state, escapeHtml, resolveEventBranding, getSessionsForEvent, getSessionSortWeight, getClassName, buildPrintBrandBlock, getSessionGridEntries, getSessionTypeLabel, getStartModeLabel, renderTable, buildEventSchedule }) {
const branding = resolveEventBranding(event); const branding = resolveEventBranding(event);
const sessions = getSessionsForEvent(event.id) const sessions = getSessionsForEvent(event.id)
.filter((session) => session.mode === "race") .filter((session) => session.mode === "race")
@@ -370,6 +374,11 @@ export function buildRaceStartListsHtml(event, { t, state, escapeHtml, resolveEv
} }
return String(left.name || "").localeCompare(String(right.name || "")); return String(left.name || "").localeCompare(String(right.name || ""));
}); });
const schedule = typeof buildEventSchedule === "function" ? buildEventSchedule(event) : [];
const plannedTimeFor = (sessionId) => {
const row = schedule.find((item) => item.sessionId === sessionId);
return row ? new Date(row.plannedStart).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false }) : "";
};
return ` return `
<header class="print-header"> <header class="print-header">
@@ -388,7 +397,7 @@ export function buildRaceStartListsHtml(event, { t, state, escapeHtml, resolveEv
const entries = getSessionGridEntries(session); const entries = getSessionGridEntries(session);
return ` return `
<section class="print-block"> <section class="print-block">
<h3>${escapeHtml(session.name)} • ${escapeHtml(getSessionTypeLabel(session.type))}</h3> <h3>${escapeHtml(session.name)} • ${escapeHtml(getSessionTypeLabel(session.type))}${plannedTimeFor(session.id) ? ` • ${escapeHtml(plannedTimeFor(session.id))}` : ""}</h3>
<p>${t("table.start_mode")}: ${escapeHtml(getStartModeLabel(session.startMode))} • ${t("table.duration")}: ${session.durationMin} min</p> <p>${t("table.start_mode")}: ${escapeHtml(getStartModeLabel(session.startMode))} • ${t("table.duration")}: ${session.durationMin} min</p>
${ ${
entries.length entries.length

View File

@@ -3585,6 +3585,11 @@ select:focus {
color: #b7ffd4; color: #b7ffd4;
} }
.pill-warn {
border-color: rgba(245, 166, 35, 0.6);
color: #f5a623;
}
.pos-pill { .pos-pill {
display: inline-grid; display: inline-grid;
place-items: center; place-items: center;

View File

@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import { import {
applyBumpsForRace, applyBumpsForRace,
buildEventSchedule,
buildOpenPracticeBundle, buildOpenPracticeBundle,
buildRaceFormatConfigFromForm, buildRaceFormatConfigFromForm,
buildRaceSessionsFromWizard, buildRaceSessionsFromWizard,
@@ -35,6 +36,7 @@ import {
isUntimedSession, isUntimedSession,
ensureSessionResult, ensureSessionResult,
} from "../src/timing_logic.js"; } from "../src/timing_logic.js";
import { getSessionSortWeight } from "../src/race_render_helpers.js";
const testFormatLap = (ms) => (Number.isFinite(ms) ? `${ms}ms` : "-"); const testFormatLap = (ms) => (Number.isFinite(ms) ? `${ms}ms` : "-");
const testFormatRaceClock = (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"); 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", () => { test("race wizard creates configured practice, qualifying, and endurance sessions", () => {
const event = { const event = {
id: "event_1", id: "event_1",