new raceday and backup

This commit is contained in:
larssand
2026-09-01 07:07:23 +02:00
parent c2bfa97d30
commit f561dd8feb
21 changed files with 1042 additions and 60 deletions

View File

@@ -1,6 +1,9 @@
import { getOverlayModeLabel, buildOverlayPanels, renderOverlaySidePanel, renderOverlayLeaderboard, renderTeamOverlay, renderObsOverlay } from "./overlays.js";
import { renderSettings } from "./settings.js";
import { buildRaceDayQueue, buildRaceDayPreflight } from "./race_day_logic.js";
import { renderRaceDayView } from "./race_day_view.js";
import { beginSession as beginSessionControl, finishSession as finishSessionControl, pauseSession as pauseSessionControl, resumeSession as resumeSessionControl } from "./session_control.js";
import { RACE_AUDIO_EVENT_DEFINITIONS, createRaceAudioEngine, normalizeRaceAudioSettings } from "./race_audio.js";
import { renderRaceFormatField, renderRaceFormatContextCard, getRaceSummaryItems, getRaceSummaryWarnings, renderManageStatusBadge, renderRaceWizardSteps, renderRaceWizardContent, renderRaceStandingsTable } from "./race_setup_ui.js";
@@ -238,6 +241,19 @@ const renderClasses = () => renderClassesView({ state, dom, t, selectedClassEdit
const renderDrivers = () => renderDriversView({ state, dom, t, driverBrandFilter: () => driverBrandFilter, setDriverBrandFilter: (value) => { driverBrandFilter = value; }, selectedDriverEditId: () => selectedDriverEditId, setSelectedDriverEditId: (value) => { selectedDriverEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell, normalizeDriver, getClassName });
const renderCars = () => renderCarsView({ state, dom, t, carBrandFilter: () => carBrandFilter, setCarBrandFilter: (value) => { carBrandFilter = value; }, selectedCarEditId: () => selectedCarEditId, setSelectedCarEditId: (value) => { selectedCarEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell, normalizeCar });
const renderGuide = () => renderGuideView({ dom, t });
const renderRaceDay = () => renderRaceDayView({
state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock,
getEventName, getSessionTypeLabel, getStatusLabel, getStartModeLabel,
getSessionTiming, getSessionGridEntries, buildRaceDayQueue, buildRaceDayPreflight,
backendAvailable: backend.available,
selectSession: selectOperationalSession,
startSession: (session, callbacks) => startOperationalSession(session, { ...callbacks, requirePreflight: true }),
finishSession: finishOperationalSession,
pauseSession: pauseOperationalSession,
resumeSession: resumeOperationalSession,
navigateTo: navigateToView,
getStartLightState: () => raceAudioEngine.getStartLightState(),
});
const renderOverlay = () => renderOverlayPageView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatPredictedLapDelta, getActiveSession, buildLeaderboard, ensureSessionResult, getSessionTiming, getVisiblePassings, resolveEventBranding, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings, getOverlayModeLabel, getStatusLabel, getObsOverlayConfig, buildOverlayPanels, normalizeStartMode, renderPositionGrid, getEventName, getSessionTypeLabel, getStartModeLabel, renderRaceStandingsTableView, renderTeamOverlay, renderObsOverlay, renderOverlayLeaderboard, renderOverlaySidePanel, formatTeamActiveMemberLabel, getSessionGridEntries, overlayViewMode, overlayMode, publicOverlayMode, overlayEvents, overlayRotationIndex, openOverlayWindow, buildOverlayUrl });
const refreshOverlayDynamics = () => {
const active = getActiveSession();
@@ -277,11 +293,11 @@ const refreshTimingDynamics = () => {
: timing.untimed
? formatElapsedClock(timing.elapsedMs)
: formatCountdown(timing.remainingMs);
document.querySelectorAll("[data-timing-clock]").forEach((node) => {
document.querySelectorAll("[data-timing-clock], [data-race-day-clock]").forEach((node) => {
node.textContent = clock;
});
};
const renderTiming = () => renderTimingView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatRaceClock, renderTable, uid, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings, getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid, renderSessionChannelAnalysis, connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, ensureAudioContext, validateTrackSessionForStart, announceRaceStarted, announceRaceFinished, startSessionWithAudio, cancelRaceStart, triggerRaceAudio, getStartLightState: () => raceAudioEngine.getStartLightState(), saveState, updateHeaderState, renderView, normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey: () => selectedLeaderboardKey, setSelectedLeaderboardKey: (value) => { selectedLeaderboardKey = value; }, getQuickAddDraft: () => quickAddDraft, setQuickAddDraft: (value) => { quickAddDraft = value; }, getPassingValidationLabel, isCountedPassing, getCompetitorPassings, getManualCorrectionSummary, formatTeamActiveMemberLabel, lastFinishAnnouncementSessionId: () => lastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId: (value) => { lastFinishAnnouncementSessionId = value; }, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, overlayEvents: () => overlayEvents, setOverlayEvents: (value) => { overlayEvents = value; } });
const renderTiming = () => renderTimingView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatRaceClock, renderTable, uid, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings, getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid, renderSessionChannelAnalysis, connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, startOperationalSession, finishOperationalSession, cancelRaceStart, triggerRaceAudio, getStartLightState: () => raceAudioEngine.getStartLightState(), saveState, updateHeaderState, renderView, normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey: () => selectedLeaderboardKey, setSelectedLeaderboardKey: (value) => { selectedLeaderboardKey = value; }, getQuickAddDraft: () => quickAddDraft, setQuickAddDraft: (value) => { quickAddDraft = value; }, getPassingValidationLabel, isCountedPassing, getCompetitorPassings, getManualCorrectionSummary, formatTeamActiveMemberLabel, lastFinishAnnouncementSessionId: () => lastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId: (value) => { lastFinishAnnouncementSessionId = value; }, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, overlayEvents: () => overlayEvents, setOverlayEvents: (value) => { overlayEvents = value; } });
const renderJudging = () => renderJudgingView({ state, dom, t, escapeHtml, formatLap, renderTable, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTypeLabel, getJudgeFilteredRows, getJudgeFilteredLog, getCompetitorPassings, isCountedPassing, getPassingValidationLabel, undoJudgingAdjustment, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, renderView, getSelectedJudgeKey: () => selectedJudgeKey, setSelectedJudgeKey: (value) => { selectedJudgeKey = value; }, getJudgingCompetitorFilter: () => judgingCompetitorFilter, setJudgingCompetitorFilter: (value) => { judgingCompetitorFilter = value; }, getJudgingLogFilter: () => judgingLogFilter, setJudgingLogFilter: (value) => { judgingLogFilter = value; } });
const normalizeTrainingState = (training) => normalizeTrainingStateLogic(training, { uid });
const syncTrainingLedger = () => {
@@ -311,6 +327,7 @@ const getJudgeFilteredLog = (adjustments, filterValue) => getJudgeFilteredLogLog
const NAV_ITEMS = [
{ id: "dashboard", titleKey: "nav.dashboard", subtitleKey: "nav.dashboard_sub", groupKey: "nav.group_start" },
{ id: "race_day", titleKey: "nav.race_day", subtitleKey: "nav.race_day_sub", groupKey: "nav.group_start" },
{ id: "race_setup", titleKey: "nav.race_setup", subtitleKey: "nav.race_setup_sub", groupKey: "nav.group_create" },
{ id: "events", titleKey: "nav.events", subtitleKey: "nav.events_sub", groupKey: "nav.group_create" },
{ id: "timing", titleKey: "nav.timing", subtitleKey: "nav.timing_sub", groupKey: "nav.group_run" },
@@ -339,6 +356,45 @@ const TRANSLATIONS = {
sv: {
"nav.dashboard": "Översikt",
"nav.dashboard_sub": "Status och liveinformation",
"nav.race_day": "Race Day",
"nav.race_day_sub": "Förkontroll, heat-kö och racedrift",
"race_day.current": "Pågår / vald",
"race_day.next": "Nästa",
"race_day.after_next": "Därefter",
"race_day.none": "Ingen",
"race_day.no_session": "Ingen session vald",
"race_day.no_session_hint": "Skapa sessioner i Race Setup och välj sedan heat här.",
"race_day.control": "Race Control",
"race_day.control_hint": "Starta endast när förkontrollen är grön. Avancerade inställningar ligger kvar i Race Setup.",
"race_day.ready": "Klar för start",
"race_day.not_ready": "Start blockerad",
"race_day.activate": "Välj session",
"race_day.finish": "Avsluta heat",
"race_day.start_list": "Startlista",
"race_day.no_start_list": "Ingen startlista finns för sessionen.",
"race_day.preflight": "Förkontroll",
"race_day.heat_queue": "Heat-kö",
"race_day.select": "Välj",
"race_day.open_practice_any": "Alla inkommande transpondrar tillåts",
"race_day.count": "{count} registrerade",
"race_day.missing_count": "{count} saknar transponder",
"race_day.not_required": "Krävs inte",
"race_day.check_ok": "OK",
"race_day.check_warning": "Kontrollera före start",
"race_day.check_error": "Måste åtgärdas",
"race_day.check_session": "Session vald",
"race_day.check_backend": "Backend och automatisk backup",
"race_day.check_decoder": "Main decoder",
"race_day.check_split_decoder": "Mellantidsdecoder",
"race_day.check_pit_decoders": "Depådecoders",
"race_day.check_lap_window": "Min/max varvtid",
"race_day.check_participants": "Deltagare",
"race_day.check_transponders": "Transpondrar",
"race_day.check_duplicate_transponders": "Dubbla transpondrar",
"race_day.check_grid": "Startgrid",
"race_day.check_audio": "Raceljud",
"race_day.backup_failed_confirm": "Automatisk backup misslyckades: {msg}\n\nStarta ändå?",
"race_day.start_failed": "Sessionen kunde inte startas.",
"nav.group_start": "Start",
"nav.group_create": "Skapa",
"nav.group_run": "Kör och följ",
@@ -614,6 +670,7 @@ const TRANSLATIONS = {
"dashboard.no_session": "Ingen session är aktiv. Gå till Event eller Tidtagning för att starta.",
"dashboard.quick_actions": "Snabbval",
"dashboard.create_event": "Skapa Event",
"dashboard.open_race_day": "Öppna Race Day",
"dashboard.open_timing": "Öppna Tidtagning",
"dashboard.connect_decoder": "Anslut Decoder",
"dashboard.recent_sessions": "Senaste sessioner",
@@ -1246,6 +1303,21 @@ const TRANSLATIONS = {
"settings.logo_clear": "Rensa logo",
"settings.logo_note": "Logotypen visas i huvudmenyn, overlay och publik träningsprofil. PDF-export försöker bädda in den via backend.",
"settings.storage": "Lagring",
"settings.server_backups": "SQLite-backuper på servern",
"settings.server_backups_hint": "En backup skapas automatiskt före varje sessionstart. De 30 senaste sparas i data/backups.",
"settings.create_backup": "Skapa backup nu",
"settings.refresh_backups": "Uppdatera lista",
"settings.loading_backups": "Läser backuphistorik...",
"settings.no_backups": "Inga serverbackuper har skapats ännu.",
"settings.backup_reason_pre_start": "Före start",
"settings.backup_reason_pre_change": "Före större ändring",
"settings.backup_reason_pre_restore": "Före återställning",
"settings.backup_reason_manual": "Manuell",
"settings.backup_reason_unknown": "Okänd",
"settings.backup_created": "SQLite-backup skapades.",
"settings.backup_failed": "Backup misslyckades: {msg}",
"settings.safety_backup_failed_confirm": "Säkerhetsbackup misslyckades: {msg}\n\nFortsätt ändå?",
"settings.restore_backup_hint": "Återställning görs lokalt på servern medan RC RaceCore är stoppad. Webbåterställning aktiveras först när administrationsbehörigheter finns.",
"settings.race_presets": "Klubb-presetar",
"settings.race_presets_note": "Exportera eller importera lokala klubb-presetar mellan installationer.",
"settings.export_presets": "Exportera presets",
@@ -1279,7 +1351,7 @@ const TRANSLATIONS = {
"settings.storage_note_directory": "Registerpaket innehåller bara klasser, förare och bilar och mergeas in i befintlig data.",
"settings.storage_note_csv": "CSV kan exporteras eller importeras från Excel och LibreOffice. Komma, semikolon och tabb stöds. Importen slår ihop på ID, transponder eller namn i stället för att radera registret.",
"settings.clear_data_title": "Rensa data",
"settings.clear_data_note": "Exportera gärna en backup först. Rensningen kan inte ångras, men system-, decoder-, tema- och brandinginställningar behålls. Råa passeringar i SQLite behålls som revisionslogg.",
"settings.clear_data_note": "En SQLite-backup skapas automatiskt före rensning. Rensningen kan inte ångras, men system-, decoder-, tema- och brandinginställningar behålls. Råa passeringar i SQLite behålls som revisionslogg.",
"settings.clear_drivers": "Rensa förare",
"settings.clear_cars": "Rensa bilar",
"settings.clear_classes": "Rensa klasser",
@@ -1461,6 +1533,14 @@ const TRANSLATIONS = {
"guide.qualifying_3": "Kval-scoring kan kombinera poängläge med poängtabell: placeringstal, fallande efter fältstorlek eller IFMAR 10-9-8-7-6-5-4-3-2-1.",
"guide.qualifying_4": "Tie-break i kvalrankingen kan avgöras på räknade rundor, bästa enskilda varv eller bästa runda/heatresultat.",
"guide.qualifying_5": "Leaderboarden visar seedningsresultat i rätt format, till exempel 3/00:48.321, 3 avg 16.107 eller 3 con 00:49.005.",
"guide.race_day_title": "Race Day och säker start",
"guide.card_race_day_blurb": "Förkontroll, startlista, heat-kö, automatisk backup och fokuserad racedrift.",
"guide.race_day_1": "1. Skapa race och sessioner i Race Setup. Öppna sedan Race Day från gruppen Start.",
"guide.race_day_2": "2. Välj aktuellt heat. Pågår, Nästa och Därefter följer sessionernas ordning i samma event.",
"guide.race_day_3": "3. Förkontrollen blockerar start när Main decoder är offline, deltagare eller transpondrar saknas, transpondrar är dubbla, varvfönstret är ogiltigt eller en positionsgrid är ofullständig.",
"guide.race_day_4": "4. Gula kontroller är varningar. Valfria split-/depådecoders, backend och ljud visas tydligt men ändrar inte vanlig varvräkning.",
"guide.race_day_5": "5. Vid start synkas state till backend och en konsistent SQLite-backup skapas i data/backups innan den befintliga audio- och starttonssekvensen körs. De 30 senaste sparas.",
"guide.race_day_6": "6. Använd Race Day för start, paus, fortsätt och avslut. Tidtagning, Domare och Overlay finns som direkta genvägar. Lokal restore kräver stoppad server och npm run backup:restore -- <fil.sqlite> --confirm.",
"guide.dashboard_title": "Schemaavvikelse på Översikt",
"guide.dashboard_1": "Översikt visar nu skillnaden mellan planerad tid och faktisk körtid för alla startade sessioner.",
"guide.dashboard_2": "Planerad tid räknar sessionstid plus follow-up. Faktisk tid räknar verklig tid från start till stopp eller nuvarande tid om heatet fortfarande kör.",
@@ -1589,6 +1669,45 @@ const TRANSLATIONS = {
en: {
"nav.dashboard": "Dashboard",
"nav.dashboard_sub": "Overview and live status",
"nav.race_day": "Race Day",
"nav.race_day_sub": "Preflight, heat queue, and race operation",
"race_day.current": "Running / selected",
"race_day.next": "Next",
"race_day.after_next": "After next",
"race_day.none": "None",
"race_day.no_session": "No session selected",
"race_day.no_session_hint": "Create sessions in Race Setup, then select a heat here.",
"race_day.control": "Race Control",
"race_day.control_hint": "Start only when preflight is green. Advanced configuration remains in Race Setup.",
"race_day.ready": "Ready to start",
"race_day.not_ready": "Start blocked",
"race_day.activate": "Select session",
"race_day.finish": "Finish heat",
"race_day.start_list": "Start list",
"race_day.no_start_list": "No start list is available for this session.",
"race_day.preflight": "Preflight",
"race_day.heat_queue": "Heat queue",
"race_day.select": "Select",
"race_day.open_practice_any": "All incoming transponders are allowed",
"race_day.count": "{count} registered",
"race_day.missing_count": "{count} missing transponder",
"race_day.not_required": "Not required",
"race_day.check_ok": "OK",
"race_day.check_warning": "Check before start",
"race_day.check_error": "Must be fixed",
"race_day.check_session": "Session selected",
"race_day.check_backend": "Backend and automatic backup",
"race_day.check_decoder": "Main decoder",
"race_day.check_split_decoder": "Split decoder",
"race_day.check_pit_decoders": "Pit decoders",
"race_day.check_lap_window": "Min/max lap time",
"race_day.check_participants": "Participants",
"race_day.check_transponders": "Transponders",
"race_day.check_duplicate_transponders": "Duplicate transponders",
"race_day.check_grid": "Start grid",
"race_day.check_audio": "Race audio",
"race_day.backup_failed_confirm": "Automatic backup failed: {msg}\n\nStart anyway?",
"race_day.start_failed": "The session could not be started.",
"nav.group_start": "Start",
"nav.group_create": "Create",
"nav.group_run": "Run and follow",
@@ -1864,6 +1983,7 @@ const TRANSLATIONS = {
"dashboard.no_session": "No session is active. Go to Events or Timing to create/start one.",
"dashboard.quick_actions": "Quick Actions",
"dashboard.create_event": "Create Event",
"dashboard.open_race_day": "Open Race Day",
"dashboard.open_timing": "Open Timing Board",
"dashboard.connect_decoder": "Connect Decoder",
"dashboard.recent_sessions": "Recent Sessions",
@@ -2496,6 +2616,21 @@ const TRANSLATIONS = {
"settings.logo_clear": "Clear logo",
"settings.logo_note": "The logo is shown in the main menu, overlays, and public training profiles. PDF export attempts to embed it via the backend.",
"settings.storage": "Storage",
"settings.server_backups": "Server SQLite backups",
"settings.server_backups_hint": "A backup is created automatically before every session start. The latest 30 are retained in data/backups.",
"settings.create_backup": "Create backup now",
"settings.refresh_backups": "Refresh list",
"settings.loading_backups": "Loading backup history...",
"settings.no_backups": "No server backups have been created yet.",
"settings.backup_reason_pre_start": "Before start",
"settings.backup_reason_pre_change": "Before major change",
"settings.backup_reason_pre_restore": "Before restore",
"settings.backup_reason_manual": "Manual",
"settings.backup_reason_unknown": "Unknown",
"settings.backup_created": "SQLite backup created.",
"settings.backup_failed": "Backup failed: {msg}",
"settings.safety_backup_failed_confirm": "Safety backup failed: {msg}\n\nContinue anyway?",
"settings.restore_backup_hint": "Restore locally on the server while RC RaceCore is stopped. Web restore remains disabled until administration access control is available.",
"settings.race_presets": "Club presets",
"settings.race_presets_note": "Export or import local club presets between installations.",
"settings.export_presets": "Export presets",
@@ -2529,7 +2664,7 @@ const TRANSLATIONS = {
"settings.storage_note_directory": "Directory package only contains classes, drivers and cars and merges into existing data.",
"settings.storage_note_csv": "CSV can be exported or imported from Excel and LibreOffice. Comma, semicolon, and tab delimiters are supported. Import merges by ID, transponder, or name instead of replacing the directory.",
"settings.clear_data_title": "Clear data",
"settings.clear_data_note": "Export a backup first. Clearing cannot be undone, but system, decoder, theme, and branding settings are retained. Raw SQLite passings remain as an audit log.",
"settings.clear_data_note": "A SQLite backup is created automatically before clearing. Clearing cannot be undone, but system, decoder, theme, and branding settings are retained. Raw SQLite passings remain as an audit log.",
"settings.clear_drivers": "Clear drivers",
"settings.clear_cars": "Clear cars",
"settings.clear_classes": "Clear classes",
@@ -2711,6 +2846,14 @@ const TRANSLATIONS = {
"guide.qualifying_3": "Qualifying scoring can combine points mode with a points table: placement values, descending by field size or IFMAR 10-9-8-7-6-5-4-3-2-1.",
"guide.qualifying_4": "Qualifying tie-break can now be resolved by counted rounds, best single lap or best round / heat result.",
"guide.qualifying_5": "The leaderboard now shows seeded results in the correct format, for example 3/00:48.321, 3 avg 16.107 or 3 con 00:49.005.",
"guide.race_day_title": "Race Day and safe start",
"guide.card_race_day_blurb": "Preflight, start list, heat queue, automatic backup, and focused race operation.",
"guide.race_day_1": "1. Create the race and sessions in Race Setup, then open Race Day from the Start group.",
"guide.race_day_2": "2. Select the current heat. Running, Next, and After next follow session order within the same event.",
"guide.race_day_3": "3. Preflight blocks start when the Main decoder is offline, participants or transponders are missing, transponders are duplicated, the lap window is invalid, or a position grid is incomplete.",
"guide.race_day_4": "4. Yellow checks are warnings. Optional split/pit decoders, backend, and audio are visible but do not change normal lap counting.",
"guide.race_day_5": "5. Starting synchronizes state to the backend and creates a consistent SQLite backup in data/backups before the existing audio and start-tone sequence runs. The latest 30 are retained.",
"guide.race_day_6": "6. Use Race Day for start, pause, resume, and finish. Timing, Judging, and Overlay remain one click away. Local restore requires a stopped server and npm run backup:restore -- <file.sqlite> --confirm.",
"guide.dashboard_title": "Schedule drift on overview",
"guide.dashboard_1": "Overview now shows the difference between planned time and actual elapsed time for all started sessions.",
"guide.dashboard_2": "Planned time includes session duration plus follow-up. Actual time uses real time from start to stop, or the current time if the heat is still running.",
@@ -3062,6 +3205,8 @@ let activeModalEscapeHandler = null;
let settingsStorageNotice = "";
let settingsStorageNoticeIsError = false;
let settingsDraftActive = false;
let serverBackups = [];
let serverBackupsLoaded = false;
const backend = {
available: false,
lastSyncAt: null,
@@ -3124,7 +3269,7 @@ async function init() {
setInterval(tickClock, 1000);
startAppVersionPolling();
Promise.allSettled([hydrateFromBackend(), loadAmmcConfigFromBackend()]).finally(() => {
Promise.allSettled([hydrateFromBackend(), loadAmmcConfigFromBackend(), refreshServerBackups({ render: false })]).finally(() => {
applyTheme();
renderNav();
renderViewFromBackground();
@@ -3405,6 +3550,87 @@ function saveState(options = {}) {
}
}
async function startOperationalSession(session, callbacks = {}) {
if (!session) return { ok: false, message: t("race_day.start_failed") };
const { requirePreflight = false, onTone, ...sequenceCallbacks } = callbacks;
if (session.mode === "track") {
const validation = validateTrackSessionForStart(session);
if (!validation.ok) return { ok: false, message: validation.message };
}
if (requirePreflight) {
const preflight = buildRaceDayPreflight(state, session, { backendAvailable: backend.available });
if (!preflight.ready) return { ok: false, message: t("race_day.not_ready") };
}
ensureAudioContext();
let backup = null;
try {
backup = await createServerBackup("pre_start", session);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!window.confirm(t("race_day.backup_failed_confirm", { msg: message }))) {
return { ok: false, message };
}
}
let committed = false;
const commitStart = () => {
if (committed) return;
committed = true;
state.activeSessionId = session.id;
beginSessionControl(session);
lastFinishAnnouncementSessionId = null;
lastOverlayLeaderKeyBySession[session.id] = null;
lastOverlayTop3BySession[session.id] = [];
overlayEvents = [];
ensureSessionResult(session.id);
announceRaceStarted(session);
saveState();
updateHeaderState();
onTone?.();
renderView();
};
try {
const started = await startSessionWithAudio(session, { ...sequenceCallbacks, onTone: commitStart });
if (started === false) return { ok: false, message: t("race_day.start_failed"), backup };
if (!committed) commitStart();
return { ok: true, backup };
} catch (error) {
commitStart();
return { ok: true, backup, audioWarning: error instanceof Error ? error.message : String(error) };
}
}
function finishOperationalSession(session) {
if (!session) return false;
cancelRaceStart();
state.activeSessionId = session.id;
finishSessionControl(session);
lastFinishAnnouncementSessionId = session.id;
announceRaceFinished();
saveState();
updateHeaderState();
renderView();
return true;
}
function pauseOperationalSession(session) {
if (!pauseSessionControl(session)) return false;
triggerRaceAudio("race_paused", { interrupt: true, force: true, logData: { raceId: session.eventId, sessionId: session.id } });
saveState();
renderView();
return true;
}
function resumeOperationalSession(session) {
if (!resumeSessionControl(session)) return false;
triggerRaceAudio("race_resumed", { interrupt: true, force: true, logData: { raceId: session.eventId, sessionId: session.id } });
saveState();
renderView();
return true;
}
function uid(prefix) {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
}
@@ -3847,6 +4073,54 @@ function getBackendUrl() {
return String(state.settings.backendUrl || getDefaultBackendUrl()).replace(/\/+$/, "");
}
async function refreshServerBackups({ render = true } = {}) {
try {
const response = await fetch(`${getBackendUrl()}/api/backups`, { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
serverBackups = Array.isArray(payload.backups) ? payload.backups : [];
serverBackupsLoaded = true;
} catch (error) {
serverBackupsLoaded = true;
backend.lastError = error instanceof Error ? error.message : String(error);
}
if (render && currentView === "settings") renderView();
return serverBackups;
}
async function createServerBackup(reason = "manual", session = null) {
await syncStateToBackend();
if (!backend.available) throw new Error(backend.lastError || "Backend offline");
const response = await fetch(`${getBackendUrl()}/api/backups`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason, sessionId: session?.id || "", eventId: session?.eventId || "" }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`);
if (payload.backup) {
serverBackups = [payload.backup, ...serverBackups.filter((item) => item.fileName !== payload.backup.fileName)].slice(0, 30);
}
return payload.backup || null;
}
function navigateToView(view) {
currentView = view;
updateViewUrl(view);
renderNav();
renderView();
}
function selectOperationalSession(sessionId, options = {}) {
const session = state.sessions.find((item) => item.id === sessionId);
if (!session) return false;
state.activeSessionId = session.id;
saveState();
updateHeaderState();
if (options.render !== false) renderView();
return true;
}
function renderNav() {
if (overlayMode) {
dom.nav.innerHTML = "";
@@ -3912,6 +4186,9 @@ function renderView() {
case "dashboard":
renderDashboard();
break;
case "race_day":
renderRaceDay();
break;
case "events":
renderEvents();
break;
@@ -3986,6 +4263,11 @@ function renderView() {
normalizeRaceAudioSettings,
raceAudioEventDefinitions: RACE_AUDIO_EVENT_DEFINITIONS,
triggerRaceAudio,
serverBackups,
serverBackupsLoaded,
createManualServerBackup: () => createServerBackup("manual"),
createSafetyServerBackup: () => createServerBackup("pre_change"),
refreshServerBackups,
});
break;
case "guide":