From cf89086dff4180b050a63243563c303bcb2766f3 Mon Sep 17 00:00:00 2001 From: larssand Date: Thu, 10 Sep 2026 22:33:57 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 10 ++++++ ROADMAP.md | 17 ++++++++++ package-lock.json | 4 +-- package.json | 2 +- src/app.js | 26 +++++++++++++-- src/event_manager_controller.js | 27 ++++++++++++++++ src/event_manager_view.js | 45 ++++++++++++++++++++++++++ src/event_race_logic.js | 51 ++++++++++++++++++++++++++++++ src/race_render_helpers.js | 15 +++++++-- src/styles.css | 5 +++ tests/race_timing_logic.test.js | 56 +++++++++++++++++++++++++++++++++ 11 files changed, 250 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d629459..8f48de1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ The application version is maintained in `package.json` and displayed in the adm 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 ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index 151d345..2bacb7f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 ` + +
+ + ${event.raceConfig?.scheduleStartAt ? `` : ""} +
+ + ${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 ` + + ${escapeHtml(session.name)} + ${escapeHtml(getSessionTypeLabel(session.type))} + ${escapeHtml(formatClockTime(row.plannedStart))} - ${escapeHtml(formatClockTime(row.plannedEnd))} + ${row.actualStart ? escapeHtml(formatClockTime(row.actualStart)) + (row.actualEnd ? ` - ${escapeHtml(formatClockTime(row.actualEnd))}` : "") : "-"} + ${row.delayed ? `${t("events.schedule_delayed")}` : ""} + + `; + }) + ) + : `

${t("events.schedule_not_set")}

`} + + +

${t("events.final_matrix")}

diff --git a/src/event_race_logic.js b/src/event_race_logic.js index 04928f9..24cf529 100644 --- a/src/event_race_logic.js +++ b/src/event_race_logic.js @@ -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), + }; + }); +} diff --git a/src/race_render_helpers.js b/src/race_render_helpers.js index f18eb06..a4e3067 100644 --- a/src/race_render_helpers.js +++ b/src/race_render_helpers.js @@ -159,7 +159,11 @@ export function getSessionSortWeight(session) { final: 5, 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 }) { @@ -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 sessions = getSessionsForEvent(event.id) .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 || "")); }); + 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 `