From f561dd8feb0bdea8e0c5bc6fa327730a4a0b15d3 Mon Sep 17 00:00:00 2001 From: larssand Date: Tue, 1 Sep 2026 07:07:23 +0200 Subject: [PATCH] new raceday and backup --- README.md | 37 +++ README.sv.md | 37 +++ docs/javascript-architecture.md | 57 ++++ package.json | 1 + scripts/restore-backup.js | 84 ++++++ server.js | 24 ++ server/backup_service.js | 100 +++++++ src/app.js | 292 ++++++++++++++++++- src/core_views.js | 4 + src/misc_views.js | 5 + src/race_day_logic.js | 5 +- src/runtime_services.js | 4 +- src/settings.js | 71 ++++- src/styles.css | 87 ++++++ src/timing_views.js | 58 +--- tests/backup_service.test.js | 108 +++++++ tests/race_day_logic.test.js | 68 +++++ tests/run.js | 2 + tests/runtime_persistence.test.js | 23 ++ tests/server_persistence.integration.test.js | 18 ++ windows/README.md | 17 ++ 21 files changed, 1042 insertions(+), 60 deletions(-) create mode 100644 scripts/restore-backup.js create mode 100644 server/backup_service.js create mode 100644 tests/backup_service.test.js create mode 100644 tests/race_day_logic.test.js diff --git a/README.md b/README.md index f694f32..057e337 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,12 @@ Recommended deployment: - `Race Setup` = untimed Open Practice or competition races with personal driver transponders - Grouped navigation follows the operating flow: `Start`, `Create`, `Run and follow`, `Directory`, and `System and help`. - `Dashboard -> Getting started` links directly to system setup, driver registration, race/practice creation, Timing, and Overlay. +- Race Day operation: + - focused current/next/after-next heat queue + - preflight for decoder, participants, transponders, duplicate IDs, lap window, grid and audio + - shared start/finish/pause logic with the existing Timing view + - automatic consistent SQLite backup before every session start, full data import and data clear operation + - latest 30 server backups listed under `Settings -> Storage` - Storage/import tools: - full race-data export/import for backup or moving to another machine - directory export/import for only classes, drivers and cars @@ -631,6 +637,37 @@ npm run start:fg - `windows\start_all.bat`: start AMMC and backend, then open the browser - `windows\stop_all.bat`: stop the backend and `ammc-amb.exe` +### Restore a server backup + +Backups are stored in `data/backups/`. Restore is intentionally a local server command and is not exposed through the unauthenticated web API. + +1. Stop RC RaceCore. +2. Copy the backup filename from `Settings -> Storage` or list the files in `data/backups/`. +3. Run the restore command with the explicit confirmation flag. +4. Start RC RaceCore again. + +Windows PowerShell: + +```powershell +npm.cmd stop +$backup = Get-ChildItem .\data\backups\*.sqlite | Sort-Object LastWriteTime -Descending | Select-Object -First 1 +$backup.Name +npm.cmd run backup:restore -- $backup.Name --confirm +npm.cmd start +``` + +Linux: + +```bash +npm stop +backup=$(find data/backups -maxdepth 1 -name '*.sqlite' -printf '%T@ %f\n' | sort -nr | head -n 1 | cut -d' ' -f2-) +printf 'Restoring %s\n' "$backup" +npm run backup:restore -- "$backup" --confirm +npm start +``` + +The restore command validates the selected SQLite file and creates a `pre_restore` backup of the current database before replacing it. + ### Update an existing installation without losing data The SQLite database and local configuration are in `data/`. Do not delete or replace this directory during an update. diff --git a/README.sv.md b/README.sv.md index 55fd758..ff417e1 100644 --- a/README.sv.md +++ b/README.sv.md @@ -114,6 +114,12 @@ Rekommenderad deploy: - `Race Setup` = otidsstyrd Open Practice eller riktiga race med personlig transponder per förare - Grupperad meny följer arbetsordningen: `Start`, `Skapa`, `Kör och följ`, `Register` samt `System och hjälp`. - `Översikt -> Kom igång` länkar direkt till systeminställningar, förarregister, race/practice, Tidtagning och Overlay. +- Race Day-drift: + - fokuserad heat-kö med Pågår, Nästa och Därefter + - förkontroll av decoder, deltagare, transpondrar, dubbla ID, varvfönster, grid och ljud + - samma start-, stopp- och pauslogik som den befintliga Tidtagning-vyn + - automatisk konsistent SQLite-backup före varje sessionstart, full dataimport och datarensning + - de 30 senaste serverbackuperna visas under `Inställningar -> Lagring` - Lagring/import: - full export/import av race-data för backup eller flytt till annan maskin - register-export/import för bara klasser, förare och bilar @@ -620,6 +626,37 @@ npm run start:fg - `windows\start_all.bat`: startar AMMC och backend samt öppnar webbläsaren - `windows\stop_all.bat`: stoppar backend och `ammc-amb.exe` +### Återställ en serverbackup + +Backuper lagras i `data/backups/`. Återställning är avsiktligt ett lokalt serverkommando och exponeras inte via det oautentiserade webb-API:t. + +1. Stoppa RC RaceCore. +2. Kopiera backupfilens namn från `Inställningar -> Lagring` eller lista filerna i `data/backups/`. +3. Kör restore-kommandot med den uttryckliga bekräftelseflaggan. +4. Starta RC RaceCore igen. + +Windows PowerShell: + +```powershell +npm.cmd stop +$backup = Get-ChildItem .\data\backups\*.sqlite | Sort-Object LastWriteTime -Descending | Select-Object -First 1 +$backup.Name +npm.cmd run backup:restore -- $backup.Name --confirm +npm.cmd start +``` + +Linux: + +```bash +npm stop +backup=$(find data/backups -maxdepth 1 -name '*.sqlite' -printf '%T@ %f\n' | sort -nr | head -n 1 | cut -d' ' -f2-) +printf 'Återställer %s\n' "$backup" +npm run backup:restore -- "$backup" --confirm +npm start +``` + +Restore-kommandot validerar vald SQLite-fil och skapar en `pre_restore`-backup av nuvarande databas innan den ersätts. + ### Uppdatera utan att förlora data SQLite-databasen och lokal konfiguration ligger i `data/`. Radera eller ersätt aldrig den katalogen vid en uppdatering. diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 46f7d1d..6e261ac 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -33,11 +33,13 @@ The system has four main runtime parts: - AMMC process control - PDF export endpoint - app version endpoint +- bounded SQLite backup API and local-only restore CLI 3. Local persistence - SQLite via `better-sqlite3` - main file: `data/rc_timing.sqlite` - AMMC config JSON: `data/ammc_config.json` +- rotating SQLite snapshots: `data/backups/*.sqlite` plus metadata sidecars 4. Decoder / AMMC runtime - AMMC process started by backend or manually elsewhere @@ -52,8 +54,10 @@ flowchart LR Browser -->|GET/POST /api/ammc/*| Server Browser -->|POST /api/passings| Server Browser -->|GET /api/app-version| Server + Browser -->|GET/POST /api/backups| Server Server -->|persist app state| SQLite[(SQLite)] Server -->|persist passings| SQLite + Server -->|online backup API| Backups[(data/backups)] Server -->|read/write config| AMMCConfig[(ammc_config.json)] Server -->|spawn/stop| AMMC[AMMC process] Browser -->|WebSocket| DecoderWS[AMMC websocket / decoder feed] @@ -75,6 +79,7 @@ Backend owns: - AMMC process management - PDF generation endpoint - file-watch based app version bumping +- SQLite online backup creation and bounded retention through `server/backup_service.js` ## 2. Repository JavaScript Inventory @@ -105,6 +110,7 @@ Scripts: - `npm restart` - `npm run verify` - `npm test` +- `npm run backup:restore -- --confirm` - `npm run setup:windows` ### `server.js` @@ -119,6 +125,7 @@ Uses: - `express` - `better-sqlite3` - `server/public_training_profile.js` +- `server/backup_service.js` Owns: - HTTP server @@ -136,6 +143,7 @@ Delegates public training token validation and response shaping to `server/publi Main API groups: - `/api/health` - `/api/app-version` +- `/api/backups` (`GET` list, `POST` create only) - `/api/state` - `/api/passings` - `/api/ammc/config` @@ -159,6 +167,23 @@ Runtime environment variables: - `PUBLIC_OVERLAY_TOKEN`: optional shared token for public overlay routes - `RC_ALLOWED_ORIGINS`: optional comma-separated cross-origin UI origins; same-origin is always allowed +### `server/backup_service.js` +Role: +- server-side SQLite snapshot service + +Owns: +- `data/backups` creation +- online `better-sqlite3.backup(...)` snapshots that remain consistent while WAL mode is active +- sanitized backup names and JSON metadata sidecars +- newest-first backup listing +- bounded retention of the latest 30 snapshots + +Security boundary: +- the HTTP API can create and list metadata but cannot download or restore a database +- destructive restore is available only through `scripts/restore-backup.js` on the backend host +- the restore CLI requires the managed server to be stopped and an explicit `--confirm` flag +- before replacement, restore validates SQLite/application state and creates a `pre_restore` safety snapshot + ### `server/public_training_profile.js` Role: - pure CommonJS backend helper for tokenized personal training profiles @@ -244,6 +269,38 @@ Owns: This is the only file that should know about almost every other frontend module. +### `src/race_day_logic.js` +Role: +- pure Race Day queue and preflight calculations + +Owns: +- current/next/after-next ordering within one event +- blocking checks for Main decoder, participants, transponders, duplicates, lap window, and position grid +- warnings for backend backup availability, optional decoder roles, and audio + +### `src/race_day_view.js` +Role: +- focused Race Day operator UI + +Dependencies injected by `app.js`: +- existing timing/session selectors and formatters +- `buildRaceDayQueue` and `buildRaceDayPreflight` +- centralized start, finish, pause, and resume controllers +- navigation shortcuts to Timing, Judging, and Overlay + +The view does not own timing state and does not implement a second race clock. + +### `src/session_control.js` +Role: +- small pure mutation helpers for begin, finish, pause, and resume + +The same helpers are used by the centralized operational start path so Race Day and Timing cannot drift into separate lifecycle behavior. + +Safety backup contract: +- session start synchronizes state and creates a `pre_start` snapshot before the existing audio/start sequence +- full data import and every clear-data scope synchronize state and create a `pre_change` snapshot before mutation +- if backup is unavailable, the operator must explicitly confirm before the existing operation continues + ### `src/runtime_services.js` Type: - frontend runtime helpers diff --git a/package.json b/package.json index e78ffbf..de7af82 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "status": "node scripts/serverctl.js status", "restart": "node scripts/serverctl.js restart", "verify": "node scripts/verify-install.js", + "backup:restore": "node scripts/restore-backup.js", "audit:prod": "npm audit --omit=dev", "test": "node --experimental-default-type=module tests/run.js", "setup:windows": "powershell -NoProfile -ExecutionPolicy Bypass -File windows/setup.ps1" diff --git a/scripts/restore-backup.js b/scripts/restore-backup.js new file mode 100644 index 0000000..98321ef --- /dev/null +++ b/scripts/restore-backup.js @@ -0,0 +1,84 @@ +const fs = require("fs"); +const path = require("path"); +const Database = require("better-sqlite3"); + +const root = path.resolve(__dirname, ".."); +const dataDir = process.env.RC_TIMING_DATA_DIR ? path.resolve(process.env.RC_TIMING_DATA_DIR) : path.join(root, "data"); +const backupDir = path.join(dataDir, "backups"); +const dbPath = path.join(dataDir, "rc_timing.sqlite"); +const pidFile = process.env.RC_TIMING_PID_FILE || path.join(dataDir, "server.pid"); +const fileName = String(process.argv[2] || ""); +const confirmed = process.argv.includes("--confirm"); + +function fail(message) { + console.error(`[restore] ${message}`); + process.exit(1); +} + +function isProcessRunning(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} + +async function main() { + if (!fileName || path.basename(fileName) !== fileName || !/^[a-zA-Z0-9_.-]+\.sqlite$/.test(fileName)) { + fail("Usage: npm run backup:restore -- --confirm"); + } + if (!confirmed) fail("Add --confirm after the backup file to confirm the restore."); + if (fs.existsSync(pidFile)) { + const pid = Number(fs.readFileSync(pidFile, "utf8").trim()); + if (isProcessRunning(pid)) fail(`RC RaceCore is running with PID ${pid}. Run npm stop first.`); + } + + const sourcePath = path.join(backupDir, fileName); + if (!fs.existsSync(sourcePath)) fail(`Backup not found: ${sourcePath}`); + const sourceDb = new Database(sourcePath, { readonly: true, fileMustExist: true }); + try { + const quickCheck = sourceDb.pragma("quick_check", { simple: true }); + if (quickCheck !== "ok") fail(`Backup failed SQLite quick_check: ${quickCheck}`); + const state = sourceDb.prepare("SELECT state_json FROM app_state WHERE id = 1").get(); + if (!state?.state_json) fail("Backup does not contain RC RaceCore application state."); + JSON.parse(state.state_json); + } finally { + sourceDb.close(); + } + + fs.mkdirSync(backupDir, { recursive: true }); + let safetyPath = ""; + if (fs.existsSync(dbPath)) { + const currentDb = new Database(dbPath); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + safetyPath = path.join(backupDir, `${timestamp}__pre_restore__current.sqlite`); + try { + currentDb.pragma("wal_checkpoint(TRUNCATE)"); + await currentDb.backup(safetyPath); + } finally { + currentDb.close(); + } + fs.writeFileSync(safetyPath.replace(/\.sqlite$/, ".json"), JSON.stringify({ + fileName: path.basename(safetyPath), + createdAt: new Date().toISOString(), + reason: "pre_restore", + sessionId: "", + eventId: "", + }, null, 2)); + } + + const temporaryPath = `${dbPath}.restore.tmp`; + fs.copyFileSync(sourcePath, temporaryPath); + fs.rmSync(dbPath, { force: true }); + fs.renameSync(temporaryPath, dbPath); + fs.rmSync(`${dbPath}-wal`, { force: true }); + fs.rmSync(`${dbPath}-shm`, { force: true }); + + console.log(`[restore] Restored: ${fileName}`); + if (safetyPath) console.log(`[restore] Previous database backup: ${safetyPath}`); + console.log("[restore] Start RC RaceCore with: npm start"); +} + +main().catch((error) => fail(error instanceof Error ? error.message : String(error))); diff --git a/server.js b/server.js index e63c054..2a6e5b5 100644 --- a/server.js +++ b/server.js @@ -5,6 +5,7 @@ const { spawn } = require("child_process"); const express = require("express"); const Database = require("better-sqlite3"); const publicTrainingProfile = require("./server/public_training_profile"); +const { createBackupService } = require("./server/backup_service"); const PORT = Number(process.env.PORT || 8081); const HOST = process.env.HOST || "0.0.0.0"; @@ -30,6 +31,7 @@ db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); initSchema(); +const backupService = createBackupService({ db, dataDir: DB_DIR }); const appVersion = { revision: 1, updatedAt: new Date().toISOString(), @@ -99,6 +101,28 @@ app.get("/api/app-version", (_req, res) => { res.json(appVersion); }); +app.get("/api/backups", (_req, res) => { + try { + res.setHeader("Cache-Control", "no-store"); + res.json({ backups: backupService.listBackups() }); + } catch (error) { + res.status(500).json({ error: error instanceof Error ? error.message : String(error) }); + } +}); + +app.post("/api/backups", async (req, res) => { + try { + const backup = await backupService.createBackup({ + reason: req.body?.reason || "manual", + sessionId: req.body?.sessionId || "", + eventId: req.body?.eventId || "", + }); + res.status(201).json({ ok: true, backup }); + } catch (error) { + res.status(500).json({ error: error instanceof Error ? error.message : String(error) }); + } +}); + app.get("/api/ammc/config", (_req, res) => { res.json({ config: ammcState.config, diff --git a/server/backup_service.js b/server/backup_service.js new file mode 100644 index 0000000..d5f768f --- /dev/null +++ b/server/backup_service.js @@ -0,0 +1,100 @@ +const fs = require("fs"); +const path = require("path"); + +function safeSegment(value, fallback = "backup") { + const normalized = String(value || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48); + return normalized || fallback; +} + +function createBackupService({ db, dataDir, maxBackups = 30, now = () => new Date() }) { + if (!db || !dataDir) throw new Error("Backup service requires db and dataDir"); + const backupDir = path.join(dataDir, "backups"); + fs.mkdirSync(backupDir, { recursive: true }); + + function metadataPath(fileName) { + return path.join(backupDir, fileName.replace(/\.sqlite$/i, ".json")); + } + + function readMetadata(fileName) { + try { + return JSON.parse(fs.readFileSync(metadataPath(fileName), "utf8")); + } catch { + return {}; + } + } + + function listBackups() { + if (!fs.existsSync(backupDir)) return []; + return fs.readdirSync(backupDir) + .filter((fileName) => fileName.endsWith(".sqlite")) + .map((fileName) => { + const filePath = path.join(backupDir, fileName); + const stat = fs.statSync(filePath); + const metadata = readMetadata(fileName); + return { + fileName, + createdAt: metadata.createdAt || stat.mtime.toISOString(), + reason: metadata.reason || "unknown", + sessionId: metadata.sessionId || "", + eventId: metadata.eventId || "", + sizeBytes: stat.size, + }; + }) + .sort((left, right) => String(right.createdAt).localeCompare(String(left.createdAt))); + } + + function pruneBackups() { + listBackups().slice(Math.max(1, maxBackups)).forEach((backup) => { + fs.rmSync(path.join(backupDir, backup.fileName), { force: true }); + fs.rmSync(metadataPath(backup.fileName), { force: true }); + }); + } + + async function createBackupNow({ reason = "manual", sessionId = "", eventId = "" } = {}) { + const createdAt = now().toISOString(); + const timestamp = createdAt.replace(/[:.]/g, "-"); + const stem = `${timestamp}__${safeSegment(reason)}__${safeSegment(sessionId, "all")}`; + let fileName = `${stem}.sqlite`; + let sequence = 1; + while (fs.existsSync(path.join(backupDir, fileName))) { + fileName = `${stem}-${sequence}.sqlite`; + sequence += 1; + } + const finalPath = path.join(backupDir, fileName); + const temporaryPath = `${finalPath}.tmp`; + try { + await db.backup(temporaryPath); + fs.renameSync(temporaryPath, finalPath); + const metadata = { + fileName, + createdAt, + reason: safeSegment(reason), + sessionId: String(sessionId || ""), + eventId: String(eventId || ""), + }; + fs.writeFileSync(metadataPath(fileName), JSON.stringify(metadata, null, 2), "utf8"); + pruneBackups(); + return { ...metadata, sizeBytes: fs.statSync(finalPath).size }; + } catch (error) { + fs.rmSync(temporaryPath, { force: true }); + fs.rmSync(finalPath, { force: true }); + throw error; + } + } + + let backupQueue = Promise.resolve(); + function createBackup(options = {}) { + const pending = backupQueue.then(() => createBackupNow(options)); + backupQueue = pending.catch(() => {}); + return pending; + } + + return { backupDir, createBackup, listBackups }; +} + +module.exports = { createBackupService, safeSegment }; diff --git a/src/app.js b/src/app.js index cf61762..836971c 100644 --- a/src/app.js +++ b/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 -- --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 -- --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": diff --git a/src/core_views.js b/src/core_views.js index 3d57220..3882c68 100644 --- a/src/core_views.js +++ b/src/core_views.js @@ -105,6 +105,7 @@ export function renderDashboardView(deps) { 4
${t("dashboard.step_run")}

${t("dashboard.step_run_hint")}

+
@@ -176,6 +177,7 @@ export function renderDashboardView(deps) {
+ @@ -206,11 +208,13 @@ export function renderDashboardView(deps) { document.getElementById("workflowSettings")?.addEventListener("click", () => navigateTo("settings")); document.getElementById("workflowDrivers")?.addEventListener("click", () => navigateTo("drivers")); document.getElementById("workflowRaceSetup")?.addEventListener("click", () => navigateTo("race_setup")); + document.getElementById("workflowRaceDay")?.addEventListener("click", () => navigateTo("race_day")); document.getElementById("workflowTiming")?.addEventListener("click", () => navigateTo("timing")); document.getElementById("workflowOverlay")?.addEventListener("click", () => navigateTo("overlay")); document.getElementById("goEvents")?.addEventListener("click", () => navigateTo("events")); + document.getElementById("goRaceDay")?.addEventListener("click", () => navigateTo("race_day")); document.getElementById("goTiming")?.addEventListener("click", () => navigateTo("timing")); document.getElementById("connectNow")?.addEventListener("click", connectDecoder); diff --git a/src/misc_views.js b/src/misc_views.js index d74549c..b8eb3dc 100644 --- a/src/misc_views.js +++ b/src/misc_views.js @@ -30,6 +30,7 @@ export function renderGuideView({ dom, t }) {
${renderGuideOverviewCard("guide.sponsor_title", "guide.card_sponsor_blurb", { t })} ${renderGuideOverviewCard("guide.race_title", "guide.card_race_blurb", { t })} + ${renderGuideOverviewCard("guide.race_day_title", "guide.card_race_day_blurb", { t })} ${renderGuideOverviewCard("guide.team_title", "guide.card_team_blurb", { t })} ${renderGuideOverviewCard("guide.training_title", "guide.card_training_blurb", { t })} ${renderGuideOverviewCard("guide.windows_title", "guide.card_decoder_blurb", { t })} @@ -37,6 +38,10 @@ export function renderGuideView({ dom, t }) {
+
+ ${renderGuidePanel("guide.race_day_title", ["guide.race_day_1", "guide.race_day_2", "guide.race_day_3", "guide.race_day_4", "guide.race_day_5", "guide.race_day_6"], "", { t })} +
+
${renderGuidePanel("guide.sponsor_title", ["guide.sponsor_1", "guide.sponsor_2", "guide.sponsor_3", "guide.sponsor_4", "guide.sponsor_5", "guide.sponsor_6"], "", { t })} ${renderGuidePanel("guide.race_wizard_title", ["guide.race_wizard_1", "guide.race_wizard_2", "guide.race_wizard_3", "guide.race_wizard_4", "guide.race_wizard_5", "guide.race_wizard_6", "guide.race_wizard_7"], "", { t })} diff --git a/src/race_day_logic.js b/src/race_day_logic.js index 820022c..9d954c0 100644 --- a/src/race_day_logic.js +++ b/src/race_day_logic.js @@ -70,7 +70,10 @@ export function buildRaceDayPreflight(state, session, { backendAvailable = false const minLapMs = Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0); const maxLapMs = Math.max(0, Number(event?.raceConfig?.maxLapMs || 0) || 0); - checks.push(check("lap_window", event?.mode !== "race" || (maxLapMs > minLapMs && maxLapMs > 0) ? "ok" : "error", { minLapMs, maxLapMs })); + const validLapWindow = maxLapMs > minLapMs && minLapMs > 0; + const openPractice = session.type === "open_practice"; + const lapWindowLevel = event?.mode !== "race" || validLapWindow ? "ok" : openPractice ? "warning" : "error"; + checks.push(check("lap_window", lapWindowLevel, { minLapMs, maxLapMs })); let entrantIds = []; let transponders = []; diff --git a/src/runtime_services.js b/src/runtime_services.js index cfab9ab..8f88939 100644 --- a/src/runtime_services.js +++ b/src/runtime_services.js @@ -423,9 +423,9 @@ export function tickClockHelper({ dom, currentLanguage, handleSessionTimerTick, dom.clock.textContent = new Date().toLocaleString(currentLanguage() === "sv" ? "sv-SE" : "en-US"); const timerState = handleSessionTimerTick(); const active = getActiveSession(); - if (getCurrentView() === "timing" && active) { + if (["timing", "race_day"].includes(getCurrentView()) && active) { if (timerState.changed) renderView(); - else if (active.status === "running") refreshTimingDynamics(); + else if (["running", "paused"].includes(active.status)) refreshTimingDynamics(); } if (getCurrentView() === "dashboard" && timerState.changed) renderView(); if (getOverlayMode() && getCurrentView() === "overlay" && active && timerState.changed) renderView(); diff --git a/src/settings.js b/src/settings.js index 0b05bbd..900f74f 100644 --- a/src/settings.js +++ b/src/settings.js @@ -1,3 +1,10 @@ +function formatBackupSize(bytes) { + const value = Math.max(0, Number(bytes || 0)); + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / (1024 * 1024)).toFixed(1)} MB`; +} + export function renderSettings(deps) { const { dom, @@ -45,7 +52,22 @@ export function renderSettings(deps) { normalizeRaceAudioSettings, raceAudioEventDefinitions, triggerRaceAudio, + serverBackups, + serverBackupsLoaded, + createManualServerBackup, + createSafetyServerBackup, + refreshServerBackups, } = deps; + const runWithSafetyBackup = async (action) => { + try { + await createSafetyServerBackup(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!window.confirm(t("settings.safety_backup_failed_confirm", { msg: message }))) return false; + } + await action(); + return true; + }; const ammcStatus = ammc.status || {}; const ammcOutput = Array.isArray(ammcStatus.lastOutput) ? ammcStatus.lastOutput : []; const suggestedWsUrl = getManagedWsUrl("main"); @@ -415,6 +437,31 @@ export function renderSettings(deps) {
+
+
+
+ ${t("settings.server_backups")} +

${t("settings.server_backups_hint")}

+
+
+ + +
+
+
+ ${!serverBackupsLoaded + ? `

${t("settings.loading_backups")}

` + : serverBackups.length + ? serverBackups.slice(0, 10).map((backup) => ` +
+
${escapeHtml(new Date(backup.createdAt).toLocaleString())}${escapeHtml(backup.fileName)}
+ ${escapeHtml(t(`settings.backup_reason_${backup.reason}`))} + ${formatBackupSize(backup.sizeBytes)} +
`).join("") + : `

${t("settings.no_backups")}

`} +
+

${t("settings.restore_backup_hint")}

+

${t("settings.storage_note_full")}

@@ -673,6 +720,19 @@ Erik Eriksson;654321;4WD;Associated;; await syncStateToBackend(); renderView(); }); + document.getElementById("createServerBackup")?.addEventListener("click", async (event) => { + const button = event.currentTarget; + if (button instanceof HTMLButtonElement) button.disabled = true; + try { + await createManualServerBackup(); + await refreshServerBackups({ render: false }); + alert(t("settings.backup_created")); + } catch (error) { + alert(t("settings.backup_failed", { msg: error instanceof Error ? error.message : String(error) })); + } + renderView(); + }); + document.getElementById("refreshServerBackups")?.addEventListener("click", () => refreshServerBackups()); document.getElementById("logoUpload")?.addEventListener("change", (event) => { const input = event.currentTarget; @@ -707,10 +767,11 @@ Erik Eriksson;654321;4WD;Associated;; return; } const reader = new FileReader(); - reader.onload = () => { + reader.onload = async () => { try { const parsed = JSON.parse(String(reader.result || "{}")); - importDataPayload(parsed, "full"); + const completed = await runWithSafetyBackup(() => importDataPayload(parsed, "full")); + if (!completed && input instanceof HTMLInputElement) input.value = ""; } catch (error) { settingsStorageNotice = t("settings.import_failed", { msg: error instanceof Error ? error.message : String(error) }); settingsStorageNoticeIsError = true; @@ -756,10 +817,12 @@ Erik Eriksson;654321;4WD;Associated;; bindCsvImport("selectCarsCsv", "importCarsCsv", "cars"); document.querySelectorAll("[data-clear-scope]").forEach((button) => { - button.addEventListener("click", () => { + button.addEventListener("click", async () => { const scope = String(button.dataset.clearScope || ""); if (!scope || !window.confirm(t(`settings.clear_${scope}_confirm`))) return; - clearDataScope(scope); + button.disabled = true; + const completed = await runWithSafetyBackup(() => clearDataScope(scope)); + if (!completed) button.disabled = false; }); }); diff --git a/src/styles.css b/src/styles.css index 36a3a27..03c41f3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3842,3 +3842,90 @@ select:focus { grid-row: 2; } } + +/* Race Day keeps the operator focused on session safety and the next heats. */ +.race-day-hero { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + padding: 22px 24px; + border: 1px solid var(--line); + border-left: 5px solid var(--danger); + border-radius: 18px; + background: linear-gradient(110deg, color-mix(in srgb, var(--danger) 15%, var(--surface-card)), var(--surface-card) 54%); +} + +.race-day-hero.is-ready { + border-left-color: var(--success); + background: linear-gradient(110deg, color-mix(in srgb, var(--success) 13%, var(--surface-card)), var(--surface-card) 54%); +} + +.race-day-hero h2, +.race-day-hero p { margin: 0; } +.race-day-hero h2 { margin-top: 4px; font-family: Orbitron, sans-serif; } +.race-day-hero p { margin-top: 5px; color: var(--muted); } +.race-day-kicker { color: var(--muted); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; } +.race-day-clock { text-align: right; } +.race-day-clock strong { display: block; font: 700 clamp(1.8rem, 4vw, 3.4rem)/1 Orbitron, sans-serif; } +.race-day-clock span { color: var(--muted); font-size: 0.72rem; text-transform: uppercase; } + +.race-day-layout { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.8fr); gap: 16px; margin-top: 16px; } +.race-day-main { display: grid; gap: 16px; min-width: 0; } +.race-day-side { min-width: 0; } +.race-day-control-panel .panel-header { align-items: flex-start; } +.race-day-actions, +.race-day-shortcuts { display: flex; flex-wrap: wrap; gap: 8px; } +.race-day-actions .btn { min-width: 126px; } +.race-day-shortcuts { margin-top: 14px; } + +.race-day-checks { display: grid; gap: 7px; } +.race-day-check { display: grid; grid-template-columns: 12px minmax(0, 1fr); gap: 9px; align-items: center; padding: 8px 9px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-card); } +.race-day-check > span { width: 10px; height: 10px; border-radius: 50%; background: var(--muted); } +.race-day-check.is-ok > span { background: var(--success); box-shadow: 0 0 10px color-mix(in srgb, var(--success) 65%, transparent); } +.race-day-check.is-warning > span { background: #e6a93c; } +.race-day-check.is-error > span { background: var(--danger); box-shadow: 0 0 10px color-mix(in srgb, var(--danger) 65%, transparent); } +.race-day-check strong, +.race-day-check small { display: block; } +.race-day-check strong { font-size: 0.78rem; } +.race-day-check small { margin-top: 1px; color: var(--muted); font-size: 0.68rem; } + +.race-day-grid-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 8px; } +.race-day-grid-list article { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 2px 8px; align-items: center; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-card); } +.race-day-grid-list article > span { grid-row: 1 / 3; font: 700 1rem Orbitron, sans-serif; color: var(--accent); } +.race-day-grid-list strong, +.race-day-grid-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.race-day-grid-list small { color: var(--muted); } + +.race-day-queue { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } +.race-day-queue-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 5px 8px; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-card); } +.race-day-queue-card > span:first-child { grid-column: 1 / -1; color: var(--muted); font-size: 0.65rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; } +.race-day-queue-card > strong, +.race-day-queue-card > small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.race-day-queue-card > small { color: var(--muted); } +.race-day-queue-card .btn { justify-self: end; } +.race-day-queue-card.is-empty { opacity: 0.58; } + +.backup-manager { padding: 14px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-card); } +.backup-manager-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } +.backup-manager-header p { margin: 4px 0 0; } +.backup-list { display: grid; gap: 6px; margin: 12px 0; } +.backup-row { display: grid; grid-template-columns: minmax(0, 1fr) 110px 74px; gap: 10px; align-items: center; padding: 8px 10px; border: 1px solid var(--line); border-radius: 9px; } +.backup-row strong, +.backup-row small { display: block; } +.backup-row small { overflow: hidden; color: var(--muted); font-size: 0.66rem; text-overflow: ellipsis; white-space: nowrap; } +.backup-row > span { color: var(--muted); font-size: 0.72rem; text-align: right; } + +@media (max-width: 980px) { + .race-day-layout { grid-template-columns: 1fr; } + .race-day-queue { grid-template-columns: 1fr; } +} + +@media (max-width: 620px) { + .race-day-hero, + .backup-manager-header { align-items: stretch; flex-direction: column; } + .race-day-clock { text-align: left; } + .race-day-actions .btn { flex: 1 1 120px; } + .backup-row { grid-template-columns: 1fr auto; } + .backup-row > span:first-of-type { display: none; } +} diff --git a/src/timing_views.js b/src/timing_views.js index 0f88b64..a69c361 100644 --- a/src/timing_views.js +++ b/src/timing_views.js @@ -263,7 +263,7 @@ export function renderTimingView(deps) { getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings, getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid, connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, - ensureAudioContext, validateTrackSessionForStart, announceRaceStarted, announceRaceFinished, startSessionWithAudio, cancelRaceStart, triggerRaceAudio, getStartLightState, saveState, updateHeaderState, renderView, + startOperationalSession, finishOperationalSession, cancelRaceStart, triggerRaceAudio, getStartLightState, saveState, updateHeaderState, renderView, normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey, setSelectedLeaderboardKey, getQuickAddDraft, setQuickAddDraft, getPassingValidationLabel, isCountedPassing, getCompetitorPassings, getManualCorrectionSummary, formatTeamActiveMemberLabel, formatRaceClock, renderSessionChannelAnalysis, @@ -601,15 +601,10 @@ export function renderTimingView(deps) { renderView(); }); - document.getElementById("startSession")?.addEventListener("click", () => { + document.getElementById("startSession")?.addEventListener("click", async () => { const session = getActiveSession(); const startButton = document.getElementById("startSession"); if (!session || startButton?.disabled) return; - ensureAudioContext(); - if (session.mode === "track") { - const trackValidation = validateTrackSessionForStart(session); - if (!trackValidation.ok) { alert(trackValidation.message); return; } - } if (startButton) startButton.disabled = true; const updateStartDisplay = (label, lights = 0) => { if (startButton && label) startButton.textContent = label; @@ -619,54 +614,21 @@ export function renderTimingView(deps) { panel.querySelector("strong")?.replaceChildren(document.createTextNode(label)); panel.querySelectorAll("[data-start-light]").forEach((node) => node.classList.toggle("is-on", Number(node.dataset.startLight) <= lights)); }; - let committed = false; - const commitStart = () => { - if (committed) return; - committed = true; - session.status = "running"; - session.startedAt = Date.now(); - session.endedAt = null; - session.finishedByTimer = false; - session.followUpStartedAt = null; - session.pausedAt = null; - session.totalPausedMs = 0; - session.followUpPausedMs = 0; - session.audioAnnouncements = []; - setLastFinishAnnouncementSessionId(null); - lastOverlayLeaderKeyBySession[session.id] = null; - lastOverlayTop3BySession[session.id] = []; - setOverlayEvents([]); - ensureSessionResult(session.id); - announceRaceStarted(session); - saveState(); - updateHeaderState(); - renderView(); - }; - startSessionWithAudio(session, { + const result = await startOperationalSession(session, { onCountdown: (seconds) => updateStartDisplay(String(seconds), 0), onLight: (light) => updateStartDisplay("LIGHT_" + light, light), onArmed: () => updateStartDisplay("WAIT_RANDOM", 5), - onTone: commitStart, - }).then((started) => { - if (started === false && startButton) { startButton.disabled = false; updateStartDisplay(t("timing.start"), 0); } - }).catch(() => commitStart()); + }); + if (!result.ok) { + if (result.message) alert(result.message); + if (startButton) startButton.disabled = false; + updateStartDisplay(t("timing.start"), 0); + } }); document.getElementById("stopSession")?.addEventListener("click", () => { - cancelRaceStart(); const session = getActiveSession(); - if (!session) { - return; - } - session.status = "finished"; - session.endedAt = Date.now(); - session.finishedByTimer = false; - session.followUpStartedAt = null; - setLastFinishAnnouncementSessionId(session.id); - announceRaceFinished(); - saveState(); - updateHeaderState(); - renderView(); + if (session) finishOperationalSession(session); }); document.getElementById("resetSession")?.addEventListener("click", () => { diff --git a/tests/backup_service.test.js b/tests/backup_service.test.js new file mode 100644 index 0000000..c4a5aba --- /dev/null +++ b/tests/backup_service.test.js @@ -0,0 +1,108 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { tmpdir } from "node:os"; + +const require = createRequire(import.meta.url); +const Database = require("better-sqlite3"); +const { createBackupService, safeSegment } = require("../server/backup_service.js"); +const projectRoot = dirname(dirname(fileURLToPath(import.meta.url))); + +function createTestDb(dataDir) { + const db = new Database(join(dataDir, "rc_timing.sqlite")); + db.exec(` + CREATE TABLE app_state (id INTEGER PRIMARY KEY, state_json TEXT NOT NULL, updated_at TEXT NOT NULL); + CREATE TABLE passings (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL); + `); + db.prepare("INSERT INTO app_state (id, state_json, updated_at) VALUES (1, ?, ?)").run(JSON.stringify({ events: [{ id: "event_1" }] }), "2026-08-31T10:00:00.000Z"); + db.prepare("INSERT INTO passings (session_id) VALUES (?)").run("session_1"); + return db; +} + +test("backup service creates consistent SQLite snapshots and metadata", async () => { + const dataDir = mkdtempSync(join(tmpdir(), "racecore-backup-")); + const db = createTestDb(dataDir); + try { + const service = createBackupService({ db, dataDir, maxBackups: 3, now: () => new Date("2026-08-31T10:15:30.123Z") }); + const backup = await service.createBackup({ reason: "pre_start", sessionId: "session_1", eventId: "event_1" }); + assert.match(backup.fileName, /pre_start__session_1\.sqlite$/); + assert.equal(backup.reason, "pre_start"); + assert.ok(backup.sizeBytes > 0); + + db.prepare("UPDATE app_state SET state_json = ? WHERE id = 1").run(JSON.stringify({ changed: true })); + const snapshot = new Database(join(dataDir, "backups", backup.fileName), { readonly: true }); + try { + assert.deepEqual(JSON.parse(snapshot.prepare("SELECT state_json FROM app_state WHERE id = 1").get().state_json), { events: [{ id: "event_1" }] }); + assert.equal(snapshot.prepare("SELECT COUNT(*) AS count FROM passings").get().count, 1); + } finally { + snapshot.close(); + } + + const listed = service.listBackups(); + assert.equal(listed.length, 1); + assert.equal(listed[0].sessionId, "session_1"); + const metadata = JSON.parse(readFileSync(join(dataDir, "backups", backup.fileName.replace(/\.sqlite$/, ".json")), "utf8")); + assert.equal(metadata.eventId, "event_1"); + } finally { + db.close(); + rmSync(dataDir, { recursive: true, force: true }); + } +}); + +test("backup service keeps only the configured number of snapshots", async () => { + const dataDir = mkdtempSync(join(tmpdir(), "racecore-backup-retention-")); + const db = createTestDb(dataDir); + let index = 0; + try { + const service = createBackupService({ db, dataDir, maxBackups: 2, now: () => new Date(1_800_000_000_000 + index++ * 1000) }); + await service.createBackup({ reason: "manual", sessionId: "one" }); + await service.createBackup({ reason: "manual", sessionId: "two" }); + await service.createBackup({ reason: "manual", sessionId: "three" }); + const listed = service.listBackups(); + assert.equal(listed.length, 2); + assert.ok(listed.some((item) => item.sessionId === "three")); + assert.ok(!listed.some((item) => item.sessionId === "one")); + } finally { + db.close(); + rmSync(dataDir, { recursive: true, force: true }); + } +}); + +test("local restore validates and replaces a stopped test database with a safety backup", async () => { + const dataDir = mkdtempSync(join(tmpdir(), "racecore-restore-")); + let db = createTestDb(dataDir); + try { + const service = createBackupService({ db, dataDir, maxBackups: 5 }); + const backup = await service.createBackup({ reason: "manual", sessionId: "session_1" }); + db.prepare("UPDATE app_state SET state_json = ? WHERE id = 1").run(JSON.stringify({ changed: true })); + db.close(); + db = null; + + const result = spawnSync(process.execPath, [join(projectRoot, "scripts", "restore-backup.js"), backup.fileName, "--confirm"], { + cwd: projectRoot, + env: { ...process.env, RC_TIMING_DATA_DIR: dataDir, RC_TIMING_PID_FILE: join(dataDir, "missing.pid") }, + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + const restored = new Database(join(dataDir, "rc_timing.sqlite"), { readonly: true }); + try { + assert.deepEqual(JSON.parse(restored.prepare("SELECT state_json FROM app_state WHERE id = 1").get().state_json), { events: [{ id: "event_1" }] }); + } finally { + restored.close(); + } + const safetyFiles = service.listBackups().filter((item) => item.reason === "pre_restore"); + assert.equal(safetyFiles.length, 1); + } finally { + if (db) db.close(); + rmSync(dataDir, { recursive: true, force: true }); + } +}); + +test("backup file segments are bounded and filesystem safe", () => { + assert.equal(safeSegment("A final / ../ unsafe"), "a-final-unsafe"); + assert.equal(safeSegment(""), "backup"); +}); diff --git a/tests/race_day_logic.test.js b/tests/race_day_logic.test.js new file mode 100644 index 0000000..8abe982 --- /dev/null +++ b/tests/race_day_logic.test.js @@ -0,0 +1,68 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildRaceDayPreflight, buildRaceDayQueue } from "../src/race_day_logic.js"; + +function createState() { + return { + activeSessionId: "session_1", + decoder: { connected: true, splitConnected: true, pitEntryConnected: true, pitExitConnected: true }, + settings: { splitDecoderEnabled: false, pitDecoderEnabled: false, raceAudio: { enabled: true } }, + classes: [{ id: "class_1", name: "2WD" }], + drivers: [ + { id: "driver_1", classId: "class_1", name: "One", transponder: "111" }, + { id: "driver_2", classId: "class_1", name: "Two", transponder: "222" }, + ], + cars: [], + events: [{ + id: "event_1", + mode: "race", + classId: "class_1", + raceConfig: { participantsConfigured: true, driverIds: ["driver_1", "driver_2"], minLapMs: 10000, maxLapMs: 60000 }, + }], + sessions: [ + { id: "session_1", eventId: "event_1", name: "Q1", mode: "race", type: "qualification", status: "ready", startMode: "mass", driverIds: ["driver_1", "driver_2"] }, + { id: "session_2", eventId: "event_1", name: "Q2", mode: "race", type: "qualification", status: "ready", startMode: "mass", driverIds: ["driver_1", "driver_2"] }, + { id: "session_3", eventId: "event_1", name: "A final", mode: "race", type: "final", status: "ready", startMode: "position", driverIds: ["driver_1", "driver_2"], manualGridIds: ["driver_1", "driver_2"] }, + ], + }; +} + +test("Race Day queue follows the selected event session order", () => { + const queue = buildRaceDayQueue(createState()); + assert.equal(queue.current.id, "session_1"); + assert.equal(queue.next.id, "session_2"); + assert.equal(queue.afterNext.id, "session_3"); +}); + +test("Race Day preflight accepts a complete race session", () => { + const state = createState(); + const result = buildRaceDayPreflight(state, state.sessions[0], { backendAvailable: true }); + assert.equal(result.ready, true); + assert.ok(result.checks.every((item) => item.level === "ok")); +}); + +test("Race Day preflight blocks decoder, transponder, duplicate and grid errors", () => { + const state = createState(); + state.decoder.connected = false; + state.drivers[1].transponder = "111"; + state.sessions[2].manualGridIds = ["driver_1"]; + const result = buildRaceDayPreflight(state, state.sessions[2], { backendAvailable: true }); + assert.equal(result.ready, false); + const levels = new Map(result.checks.map((item) => [item.id, item.level])); + assert.equal(levels.get("decoder"), "error"); + assert.equal(levels.get("duplicate_transponders"), "error"); + assert.equal(levels.get("grid"), "error"); +}); + +test("open practice allows an unset lap window but still requires the main decoder", () => { + const state = createState(); + state.events[0].raceConfig.minLapMs = 0; + state.events[0].raceConfig.maxLapMs = 0; + const practice = { ...state.sessions[0], type: "open_practice", driverIds: [] }; + let result = buildRaceDayPreflight(state, practice, { backendAvailable: true }); + assert.equal(result.ready, true); + assert.equal(result.checks.find((item) => item.id === "lap_window").level, "warning"); + state.decoder.connected = false; + result = buildRaceDayPreflight(state, practice, { backendAvailable: true }); + assert.equal(result.ready, false); +}); diff --git a/tests/run.js b/tests/run.js index 35b2930..0bbd2e8 100644 --- a/tests/run.js +++ b/tests/run.js @@ -1,6 +1,8 @@ import "./training_logic.test.js"; import "./public_training_profile.test.js"; import "./race_timing_logic.test.js"; +import "./race_day_logic.test.js"; +import "./backup_service.test.js"; import "./judging_logic.test.js"; import "./runtime_persistence.test.js"; import "./audio_runtime.test.js"; diff --git a/tests/runtime_persistence.test.js b/tests/runtime_persistence.test.js index a7f15d8..c24ffb9 100644 --- a/tests/runtime_persistence.test.js +++ b/tests/runtime_persistence.test.js @@ -311,3 +311,26 @@ test("running timing clock updates without rebuilding the leaderboard details mo assert.equal(renders, 1); assert.equal(headerRefreshes, 1); }); + + +test("Race Day clock refreshes in place while running or paused", () => { + let renders = 0; + let dynamicRefreshes = 0; + const base = { + dom: { clock: { textContent: "" } }, + currentLanguage: () => "en", + getActiveSession: () => ({ status: "running" }), + getCurrentView: () => "race_day", + getOverlayMode: () => false, + handleSessionTimerTick: () => ({ changed: false }), + renderView: () => { renders += 1; }, + refreshTimingDynamics: () => { dynamicRefreshes += 1; }, + updateHeaderState: () => {}, + }; + + tickClockHelper(base); + tickClockHelper({ ...base, getActiveSession: () => ({ status: "paused" }) }); + + assert.equal(dynamicRefreshes, 2); + assert.equal(renders, 0); +}); diff --git a/tests/server_persistence.integration.test.js b/tests/server_persistence.integration.test.js index e450ca9..e3a015e 100644 --- a/tests/server_persistence.integration.test.js +++ b/tests/server_persistence.integration.test.js @@ -160,6 +160,24 @@ test("SQLite API persists state, passings, and overlay session results", { timeo assert.equal(savePayload.ok, true); assert.equal(typeof savePayload.updatedAt, "string"); + const backupResponse = await fetch(`${baseUrl}/api/backups`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reason: "pre_start", sessionId: "session_1", eventId: "event_1" }), + }); + const backupPayload = await backupResponse.json(); + assert.equal(backupResponse.status, 201); + assert.equal(backupPayload.ok, true); + assert.equal(backupPayload.backup.reason, "pre_start"); + assert.equal(backupPayload.backup.sessionId, "session_1"); + assert.ok(backupPayload.backup.sizeBytes > 0); + + const backupsResponse = await fetch(`${baseUrl}/api/backups`); + const backupsPayload = await backupsResponse.json(); + assert.equal(backupsResponse.status, 200); + assert.equal(backupsPayload.backups.length, 1); + assert.equal(backupsPayload.backups[0].fileName, backupPayload.backup.fileName); + const storedResponse = await fetch(`${baseUrl}/api/state`); const storedPayload = await storedResponse.json(); assert.deepEqual(storedPayload.state, initialState); diff --git a/windows/README.md b/windows/README.md index 8f13c04..31e953f 100644 --- a/windows/README.md +++ b/windows/README.md @@ -209,6 +209,23 @@ npm.cmd ci npm.cmd run verify ``` +## Automatic backup and local restore + +RC RaceCore creates a consistent SQLite snapshot before every session start, full data import, and data clear operation. The latest 30 files are retained in `data\backups` and listed under `Settings -> Storage`. + +Restore is local-only because the administration UI does not yet have authentication. Run it from PowerShell on the Windows server: + +```powershell +npm.cmd stop +$backup = Get-ChildItem .\data\backups\*.sqlite | Sort-Object LastWriteTime -Descending | Select-Object -First 1 +$backup.Name +npm.cmd run backup:restore -- $backup.Name --confirm +npm.cmd start +npm.cmd run status +``` + +The restore script refuses to run while the managed server PID is active, validates the backup, checkpoints SQLite, and creates another `pre_restore` snapshot before replacement. + ## Update an existing installation Stop the service, update the files, reinstall locked dependencies, verify, and restart: