new raceday and backup
This commit is contained in:
292
src/app.js
292
src/app.js
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user