fix live leaderbord details

This commit is contained in:
larssand
2026-08-28 21:12:43 +02:00
parent 3cf68cf34a
commit c6262aceab
15 changed files with 293 additions and 34 deletions

View File

@@ -7,6 +7,7 @@ RC RaceCore is an RC timing and race-control system with support for sponsor eve
## Architecture
- JavaScript module overview: `docs/javascript-architecture.md`
- Security and performance audit: `docs/security-performance-audit.md`
- Swedish operational guide: `README.sv.md`
## Screenshots
@@ -51,6 +52,8 @@ If you want simple protection for public overlays, set this environment variable
PUBLIC_OVERLAY_TOKEN=your-secret-token
```
This token gates the overlay route only. Do not expose port `8081` directly to the internet because administration APIs are not authenticated. Use a trusted race LAN, VPN, or authenticated reverse proxy. If the UI is intentionally served from another origin, list it in `RC_ALLOWED_ORIGINS` (comma-separated). See [`docs/security-performance-audit.md`](docs/security-performance-audit.md).
Then the public overlay URL must include:
```text

View File

@@ -5,6 +5,7 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he
## Arkitektur
- JavaScript-modulöversikt: `docs/javascript-architecture.md`
- Säkerhets- och prestandaaudit: `docs/security-performance-audit.md`
- Primär engelsk README: `README.md`
## Skärmbilder
@@ -49,6 +50,8 @@ Om du vill ha ett enkelt skydd för publika overlays, sätt denna miljövariabel
PUBLIC_OVERLAY_TOKEN=din-hemliga-token
```
Tokenen skyddar bara overlay-routen. Exponera inte port `8081` direkt mot internet eftersom administrations-API:erna saknar autentisering. Använd betrott race-LAN, VPN eller en autentiserad reverse proxy. Om webbgränssnittet avsiktligt körs från en annan origin anges den i `RC_ALLOWED_ORIGINS` (kommaseparerat). Se [`docs/security-performance-audit.md`](docs/security-performance-audit.md).
Då måste den publika overlay-länken innehålla:
```text

View File

@@ -157,6 +157,7 @@ Runtime environment variables:
- `RC_TIMING_DATA_DIR`: optional absolute/relative data directory override; defaults to `<repo>/data`
- `RC_TIMING_PID_FILE`: optional PID-file path used by process control
- `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/public_training_profile.js`
Role:

View File

@@ -0,0 +1,128 @@
# Security and Performance Audit
Audit date: 2026-08-28
## Scope
This review covers the Node/Express backend, SQLite persistence, browser runtime, decoder WebSocket processing, overlay refresh path, dependency tree, and bundled runtime assets. It is a source-level review plus the repository test and npm vulnerability scans. It is not a penetration test of a deployed network.
## Verification baseline
- `npm audit --omit=dev`: 0 known vulnerabilities across 105 production dependencies
- `npm test`: 74 tests passing after the changes in this audit
- `npm run verify`: Express and SQLite runtime verified
- Runtime target: Node.js 22-26
## Changes applied during this audit
### Overlay rendering
The overlay previously rebuilt its complete HTML tree from three independent loops: backend hydration every 800 ms, live refresh every 250 ms, and the one-second session timer. This caused repeated leaderboard calculation, HTML generation, DOM replacement, image handling, and event-listener registration.
The live 250 ms path now updates only:
- elapsed/remaining clock nodes
- predicted-lap text
- predicted-lap progress width and status color
Full overlay rendering remains event-driven for passings and state changes, and follows backend hydration for remote overlay clients.
### Backend synchronization
The backend state poll now runs only while the current view is `Overlay`. It no longer hydrates the full application state every 800 ms while an operator edits Settings, Race Setup, Drivers, Cars, or other administration views.
### Decoder messages
Only recognized decoder passings that mutate race state now trigger state persistence and a view render. Status, keepalive, malformed, ignored, and duplicate channel messages no longer serialize the complete state or rebuild the active page.
### Cross-origin requests
The backend no longer returns wildcard CORS headers. Requests with an `Origin` header are accepted from the server's own origin or from origins explicitly listed in `RC_ALLOWED_ORIGINS`. Other origins receive `403`, including simple cross-origin POST requests that do not use a browser preflight.
Responses also include:
- `X-Content-Type-Options: nosniff`
- `Referrer-Policy: no-referrer`
## Security findings
### Critical: administration API has no authentication
All users that can reach port 8081 can currently read or replace application state and operate managed AMMC. The most sensitive endpoints are:
- `GET/POST /api/state`
- `GET/POST /api/ammc/config`
- `POST /api/ammc/start`
- `POST /api/ammc/stop`
- `POST /api/passings`
- `POST /api/export/pdf`
AMMC configuration includes an executable path and arguments. Because the backend can start that executable, access to these endpoints must be treated as administrative host access.
Required deployment rule: do not expose port 8081 directly to the public internet. Keep it on a trusted race LAN, behind a VPN, or behind an authenticated reverse proxy.
Recommended code change: add authenticated operator sessions or an admin API token, protect every mutating/admin endpoint, and keep public timing endpoints separate.
### High: public overlay token does not protect the state API
`PUBLIC_OVERLAY_TOKEN` gates the public overlay HTML route, but the overlay still hydrates through the general `/api/state` endpoint. That endpoint contains full operational state and is not token-protected or sanitized.
Therefore `PUBLIC_OVERLAY_TOKEN` is only a route gate, not complete data/API security. Before publishing an overlay outside the race LAN, add a read-only, sanitized public-overlay state endpoint and make public overlays use only that endpoint.
### Medium: monolithic state import increases stored-XSS impact
Most dynamic UI values are escaped before insertion into HTML. However, imported JSON and unrestricted `/api/state` replacement can place attacker-controlled identifiers and configuration into many UI paths. Authentication and strict import schema validation should be the primary controls. A Content Security Policy should be added after inline styles have been removed or explicitly accounted for.
### Medium: large request bodies and no rate limiting
The global JSON limit is 20 MB and administrative endpoints have no request-rate control. This is acceptable only on a trusted LAN. Add per-route limits and rate limiting before any internet-facing deployment.
### Medium: bundled AMMC binaries are trusted artifacts
The repository includes large platform binaries and an archive. Installation currently verifies presence, not cryptographic hashes or signatures. Record vendor versions and expected SHA-256 hashes, then verify them during installation or release packaging.
### Low: health endpoint exposes the database path
`/api/health` returns the absolute SQLite path. This is useful for local diagnostics but unnecessary on a public deployment. Remove it or expose detailed health data only to authenticated operators.
## Performance findings
### High: monolithic state serialization and SQLite rewrite
`saveState()` serializes the complete application state into localStorage. Backend synchronization stores the same state as one large JSON document. `/api/passings` also parses and rewrites the state document to keep remote overlays current.
Cost grows with all historical sessions, results, passings, training records, and audio events. This is the main long-term scaling limit.
Recommended design:
1. Keep configuration and small UI state in `app_state`.
2. Store sessions, competitors, results, passings, training laps, and audit events in normalized SQLite tables.
3. Update only the active session result on a passing.
4. Publish active-session deltas through WebSocket or Server-Sent Events instead of polling full state.
### Medium: leaderboard and track-record calculations repeat on each passing
A valid passing builds the leaderboard before and after mutation, scans prior class results for the track record, and can build it again during rendering. This is acceptable for roughly 10-12 cars but will become visible with larger endurance histories.
Recommended design: cache active-session leaderboard inputs and per-class track records; invalidate only the affected competitor and class.
### Medium: full view replacement on each valid passing
Timing and overlay views still replace substantial HTML after each valid passing. The 250 ms overlay animation path is now incremental, but passing-driven rendering remains full-page. Move leaderboard rows to keyed incremental updates if profiling shows frame drops during larger races.
### Low: source delivery is unbundled and unminified
ES modules are appropriate for the current local deployment. The size of `app.js` affects maintainability more than runtime by itself, but production releases would benefit from bundling, minification, hashed assets, and long-lived cache headers. Development currently uses `no-store` intentionally to support live file reload.
### Distribution size
The AMMC archive and multi-platform binaries dominate repository and deployment size. Build platform-specific release archives instead of syncing all AMMC targets to each timing computer.
## Recommended implementation order
1. Add admin authentication and a sanitized public overlay API.
2. Replace full-state overlay polling with server-pushed active-session deltas.
3. Normalize active race results and training history into SQLite tables.
4. Add request limits, rate limiting, import schema validation, and CSP.
5. Add AMMC checksum verification and platform-specific release packaging.
6. Profile a simulated 12-car race and an endurance dataset before deeper leaderboard optimization.

View File

@@ -19,6 +19,7 @@
"status": "node scripts/serverctl.js status",
"restart": "node scripts/serverctl.js restart",
"verify": "node scripts/verify-install.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"
},

View File

@@ -59,13 +59,33 @@ const ammcState = {
};
const app = express();
const allowedCorsOrigins = new Set(
String(process.env.RC_ALLOWED_ORIGINS || "")
.split(",")
.map((origin) => origin.trim().replace(/\/$/, ""))
.filter(Boolean)
);
app.use(express.json({ limit: "20mb" }));
app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Referrer-Policy", "no-referrer");
const requestOrigin = `${req.protocol}://${req.get("host")}`;
const origin = String(req.get("origin") || "").replace(/\/$/, "");
const corsAllowed = !origin || origin === requestOrigin || allowedCorsOrigins.has(origin);
if (!corsAllowed) {
res.status(403).end();
return;
}
if (origin) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
}
if (req.method === "OPTIONS") {
res.status(204).end();
res.status(corsAllowed ? 204 : 403).end();
return;
}
next();

View File

@@ -140,7 +140,7 @@ const checkAppVersion = () => checkAppVersionHelper({ getBackendUrl, getBaseline
const startAppVersionPolling = () => startAppVersionPollingHelper({ checkAppVersion, getAppVersionPollTimer: () => appVersionPollTimer, setAppVersionPollTimer: (value) => { appVersionPollTimer = value; } });
const startOverlaySync = () => startOverlaySyncHelper({ getOverlaySyncTimer: () => overlaySyncTimer, setOverlaySyncTimer: (value) => { overlaySyncTimer = value; }, getLocalStateDirty: () => localStateDirty, hydrateFromBackend, getCurrentView: () => currentView, renderView });
const startOverlayRotation = () => startOverlayRotationHelper({ getOverlayRotationTimer: () => overlayRotationTimer, setOverlayRotationTimer: (value) => { overlayRotationTimer = value; }, getOverlayRotationIndex: () => overlayRotationIndex, setOverlayRotationIndex: (value) => { overlayRotationIndex = value; }, getCurrentView: () => currentView, getOverlayViewMode: () => overlayViewMode, renderView });
const startOverlayLiveRefresh = () => startOverlayLiveRefreshHelper({ getOverlayLiveRefreshTimer: () => overlayLiveRefreshTimer, setOverlayLiveRefreshTimer: (value) => { overlayLiveRefreshTimer = value; }, getCurrentView: () => currentView, getOverlayViewMode: () => overlayViewMode, renderOverlay });
const startOverlayLiveRefresh = () => startOverlayLiveRefreshHelper({ getOverlayLiveRefreshTimer: () => overlayLiveRefreshTimer, setOverlayLiveRefreshTimer: (value) => { overlayLiveRefreshTimer = value; }, getCurrentView: () => currentView, getOverlayViewMode: () => overlayViewMode, refreshOverlayDynamics });
const ensureAudioContext = () => ensureAudioContextHelper({ state, getAudioCtx: () => audioCtx, setAudioCtx: (value) => { audioCtx = value; } });
const playPassingBeep = () => playPassingBeepHelper({ ensureAudioContext });
const playFinishSiren = () => playFinishSirenHelper({ ensureAudioContext });
@@ -180,7 +180,7 @@ const startSessionWithAudio = (session, callbacks) => {
const practice = ["open_practice", "free_practice", "practice"].includes(String(session?.type || ""));
return raceAudioEngine.runStartSequence(session, { ...callbacks, skipAudio: practice });
};
const tickClock = () => tickClockHelper({ dom, currentLanguage, handleSessionTimerTick, getActiveSession, getCurrentView: () => currentView, getOverlayMode: () => overlayMode, renderView, updateHeaderState });
const tickClock = () => tickClockHelper({ dom, currentLanguage, handleSessionTimerTick, getActiveSession, getCurrentView: () => currentView, getOverlayMode: () => overlayMode, renderView, refreshTimingDynamics, updateHeaderState });
const buildTeamRaceResultsHtml = (event) => buildTeamRaceResultsHtmlHelper(event, { t, escapeHtml, resolveEventBranding, getClassName, buildPrintBrandBlock, buildTeamRaceStandings, renderTable, formatLap, renderTeamStintLog, renderSessionChannelAnalysis });
const connectDecoder = () => connectDecoderHelper({
state,
@@ -239,6 +239,48 @@ const renderDrivers = () => renderDriversView({ state, dom, t, driverBrandFilter
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 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();
if (!active) return;
const timing = getSessionTiming(active);
const clock = timing.followUpActive
? formatCountdown(timing.followUpRemainingMs)
: timing.untimed
? formatElapsedClock(timing.elapsedMs)
: formatCountdown(timing.remainingMs);
document.querySelectorAll("[data-overlay-clock]").forEach((node) => {
node.textContent = clock;
});
const rowsByKey = new Map(buildLeaderboard(active).map((row) => [String(row.key), row]));
document.querySelectorAll("[data-overlay-competitor]").forEach((node) => {
const row = rowsByKey.get(String(node.dataset.overlayCompetitor || ""));
if (!row) return;
const prediction = node.querySelector("[data-overlay-prediction]");
const fill = node.querySelector("[data-overlay-prediction-fill]");
if (prediction) prediction.textContent = formatPredictedLapDelta(row.predictedRemainingMs);
if (fill instanceof HTMLElement) {
fill.style.width = `${Math.max(0, Math.min(100, Math.round(Math.min(1, row.predictedProgress || 0) * 100)))}%`;
fill.classList.remove("overlay-prediction-good", "overlay-prediction-warn", "overlay-prediction-overdue");
fill.classList.add(`overlay-prediction-${row.predictionTone || "good"}`);
}
});
};
const refreshTimingDynamics = () => {
const active = getActiveSession();
if (!active) return;
const timing = getSessionTiming(active);
const clock = timing.followUpActive
? formatCountdown(timing.followUpRemainingMs)
: timing.untimed
? formatElapsedClock(timing.elapsedMs)
: formatCountdown(timing.remainingMs);
document.querySelectorAll("[data-timing-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 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 });

View File

@@ -177,15 +177,11 @@ function connectDecoderRole(role, deps) {
return;
}
if (Array.isArray(parsed)) {
parsed.forEach((message) => processDecoderMessage(message, role));
} else {
processDecoderMessage(parsed, role);
}
const messages = Array.isArray(parsed) ? parsed : [parsed];
const changed = messages.reduce((didChange, message) => processDecoderMessage(message, role) || didChange, false);
saveState();
updateConnectionBadge();
if (["timing", "dashboard", "overlay", "training"].includes(getCurrentView())) {
if (changed && ["timing", "dashboard", "overlay", "training"].includes(getCurrentView())) {
renderView();
}
};
@@ -330,7 +326,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") {
pushOverlayEvent("pit", `${entry.displayName || entry.driverName} • PIT IN`);
triggerRaceAudio("entering_pits", { variant: entry.carId || entry.driverId || transponder, driverId: entry.driverId, logData: { raceId: session.eventId, driverId: entry.driverId } });
saveState();
return;
return true;
}
if (decoderRole === "pit_exit") {
@@ -360,7 +356,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") {
pushOverlayEvent("pit", `${entry.displayName || entry.driverName} • PIT ${formatLap(pitStopMs)}`);
triggerRaceAudio("leaving_pits", { variant: entry.carId || entry.driverId || transponder, driverId: entry.driverId, logData: { raceId: session.eventId, driverId: entry.driverId } });
saveState();
return;
return true;
}
if (decoderRole === "split") {
@@ -392,7 +388,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") {
});
pushOverlayEvent("split", `${entry.displayName || entry.driverName} • ${formatLap(splitMs)}`);
saveState();
return;
return true;
}
if (startMode === "staggered" && !entry.startTimestamp) {
@@ -400,7 +396,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") {
entry.lastTimestamp = timestamp;
announcePassing(entry);
saveState();
return;
return true;
}
if (!entry.startTimestamp) {
@@ -448,7 +444,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") {
}
persistPassingToBackend(session.id, passing);
saveState();
return;
return true;
}
const previousPersonalBest = Number(entry.bestLapMs || 0) || null;
@@ -500,4 +496,6 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") {
}
}
announcePassing(entry);
saveState();
return true;
}

View File

@@ -160,7 +160,7 @@ export function renderOverlayPageView(deps) {
</div>
<div class="overlay-meta">
<button id="overlayFullscreen" class="btn overlay-fullscreen-btn" type="button">${t("overlay.fullscreen")}</button>
${overlayViewMode === "obs" && obsConfig && !obsConfig.showClock ? "" : `<div class="overlay-clock">${overlayClock}</div>`}
${overlayViewMode === "obs" && obsConfig && !obsConfig.showClock ? "" : `<div class="overlay-clock" data-overlay-clock>${overlayClock}</div>`}
<div class="overlay-status">${escapeHtml(overlayStatusLabel)}</div>
</div>
</header>`}

View File

@@ -65,7 +65,7 @@ export function renderOverlayLeaderboard(rows, { t, escapeHtml, formatTeamActive
.map((row, idx) => {
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
return `
<article class="overlay-race-row ${idx === 0 ? "overlay-race-row-leader" : ""} ${row.invalidPending ? "overlay-race-row-invalid" : ""}">
<article class="overlay-race-row ${idx === 0 ? "overlay-race-row-leader" : ""} ${row.invalidPending ? "overlay-race-row-invalid" : ""}" data-overlay-competitor="${escapeHtml(row.key)}">
<div class="overlay-race-pos">
<span class="pos-pill ${posClass}">${idx + 1}</span>
</div>
@@ -77,10 +77,10 @@ export function renderOverlayLeaderboard(rows, { t, escapeHtml, formatTeamActive
<div class="overlay-prediction">
<div class="overlay-prediction-meta">
<label>${t("overlay.next_predicted_lap")}</label>
<span>${formatPredictedLapDelta(row.predictedRemainingMs)}</span>
<span data-overlay-prediction>${formatPredictedLapDelta(row.predictedRemainingMs)}</span>
</div>
<div class="overlay-prediction-track">
<div class="overlay-prediction-fill overlay-prediction-${escapeHtml(row.predictionTone || "good")}" style="width:${Math.max(0, Math.min(100, Math.round((Math.min(1, row.predictedProgress || 0)) * 100)))}%"></div>
<div data-overlay-prediction-fill class="overlay-prediction-fill overlay-prediction-${escapeHtml(row.predictionTone || "good")}" style="width:${Math.max(0, Math.min(100, Math.round((Math.min(1, row.predictedProgress || 0)) * 100)))}%"></div>
</div>
</div>
</div>
@@ -223,7 +223,7 @@ export function renderObsOverlay(active, leaderboard, result, sessionTiming, bra
</div>
${obsConfig.showClock ? `
<div class="overlay-obs-clockblock">
<strong>${elapsedOrRemaining}</strong>
<strong data-overlay-clock>${elapsedOrRemaining}</strong>
<span>${escapeHtml(sessionTiming?.followUpActive ? t("timing.follow_up_active") : getStatusLabel(active.status))}</span>
</div>
` : ""}
@@ -247,7 +247,7 @@ export function renderObsOverlay(active, leaderboard, result, sessionTiming, bra
<p class="overlay-kicker">${escapeHtml(getEventName(active.eventId))}</p>
<h2>${escapeHtml(active.name)}</h2>
</div>
${obsConfig.showClock ? `<strong class="overlay-obs-lowerthird-clock">${elapsedOrRemaining}</strong>` : ""}
${obsConfig.showClock ? `<strong class="overlay-obs-lowerthird-clock" data-overlay-clock>${elapsedOrRemaining}</strong>` : ""}
</div>
<div class="overlay-obs-lowerthird-rows">
${compactRows.slice(0, 6).map((row, idx) => {
@@ -317,7 +317,7 @@ export function renderObsOverlay(active, leaderboard, result, sessionTiming, bra
<h2>${escapeHtml(active.name)}</h2>
</div>
</div>
${obsConfig.showClock ? `<div class="overlay-obs-tower-clock">${elapsedOrRemaining}</div>` : ""}
${obsConfig.showClock ? `<div class="overlay-obs-tower-clock" data-overlay-clock>${elapsedOrRemaining}</div>` : ""}
</div>
<div class="overlay-obs-tower-meta">
<span>${escapeHtml(getSessionTypeLabel(active.type))}</span>

View File

@@ -235,8 +235,9 @@ export function startAppVersionPollingHelper({ checkAppVersion, getAppVersionPol
export function startOverlaySyncHelper({ getOverlaySyncTimer, setOverlaySyncTimer, getLocalStateDirty, hydrateFromBackend, getCurrentView, renderView }) {
clearInterval(getOverlaySyncTimer());
setOverlaySyncTimer(setInterval(async () => {
if (getCurrentView() !== "overlay") return;
if (!getLocalStateDirty()) await hydrateFromBackend();
if (getCurrentView() === "overlay") renderView();
renderView();
}, 800));
}
@@ -248,10 +249,10 @@ export function startOverlayRotationHelper({ getOverlayRotationTimer, setOverlay
}, 8000));
}
export function startOverlayLiveRefreshHelper({ getOverlayLiveRefreshTimer, setOverlayLiveRefreshTimer, getCurrentView, getOverlayViewMode, renderOverlay }) {
export function startOverlayLiveRefreshHelper({ getOverlayLiveRefreshTimer, setOverlayLiveRefreshTimer, getCurrentView, getOverlayViewMode, refreshOverlayDynamics }) {
clearInterval(getOverlayLiveRefreshTimer());
setOverlayLiveRefreshTimer(setInterval(() => {
if (getCurrentView() === "overlay" && ["leaderboard", "tv", "team"].includes(getOverlayViewMode())) renderOverlay();
if (getCurrentView() === "overlay" && ["leaderboard", "speaker", "results", "tv", "team", "obs"].includes(getOverlayViewMode())) refreshOverlayDynamics();
}, 250));
}
@@ -418,12 +419,15 @@ export function handleSessionTimerTickHelper({ getActiveSession, isUntimedSessio
return { changed: true };
}
export function tickClockHelper({ dom, currentLanguage, handleSessionTimerTick, getActiveSession, getCurrentView, getOverlayMode, renderView, updateHeaderState }) {
export function tickClockHelper({ dom, currentLanguage, handleSessionTimerTick, getActiveSession, getCurrentView, getOverlayMode, renderView, refreshTimingDynamics = () => {}, updateHeaderState }) {
dom.clock.textContent = new Date().toLocaleString(currentLanguage() === "sv" ? "sv-SE" : "en-US");
const timerState = handleSessionTimerTick();
const active = getActiveSession();
if (getCurrentView() === "timing" && active && (active.status === "running" || timerState.changed)) renderView();
if (getCurrentView() === "timing" && active) {
if (timerState.changed) renderView();
else if (active.status === "running") refreshTimingDynamics();
}
if (getCurrentView() === "dashboard" && timerState.changed) renderView();
if (getOverlayMode() && getCurrentView() === "overlay" && active) renderView();
if (getOverlayMode() && getCurrentView() === "overlay" && active && timerState.changed) renderView();
if (timerState.changed) updateHeaderState();
}

View File

@@ -257,6 +257,7 @@ function renderRecentPassings(session, deps) {
}
export function renderTimingView(deps) {
const leaderboardModalScrollTop = document.querySelector("#leaderboardModalOverlay .modal-card")?.scrollTop || 0;
const {
state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, renderTable, uid,
getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings,
@@ -369,7 +370,7 @@ export function renderTimingView(deps) {
<span class="pill ${active.status === "running" ? "pill-green" : ""}">${escapeHtml(getStatusLabel(active.status))}</span>
</div>
<div class="timing-session-stats">
<article class="timing-session-stat"><span>${clockLabel}</span><strong>${clockValue}</strong></article>
<article class="timing-session-stat"><span>${clockLabel}</span><strong data-timing-clock>${clockValue}</strong></article>
<article class="timing-session-stat"><span>${t("timing.started")}</span><strong>${active.startedAt ? new Date(active.startedAt).toLocaleTimeString() : "-"}</strong></article>
<article class="timing-session-stat"><span>${t("table.start_mode")}</span><strong>${escapeHtml(getStartModeLabel(active.startMode))}</strong></article>
<article class="timing-session-stat"><span>${t("timing.seeding_mode")}</span><strong>${active.seedBestLapCount > 0 ? `${active.seedBestLapCount}` : "-"}</strong></article>
@@ -440,6 +441,11 @@ export function renderTimingView(deps) {
${selectedRow && active ? renderLeaderboardModal(active, selectedRow, { ...helperDeps, renderQuickAddActions: (session, transponder, idPrefix) => renderQuickAddActions(session, transponder, idPrefix, helperDeps) }) : ""}
`;
if (selectedRow && leaderboardModalScrollTop > 0) {
const modalCard = document.querySelector("#leaderboardModalOverlay .modal-card");
if (modalCard instanceof HTMLElement) modalCard.scrollTop = leaderboardModalScrollTop;
}
document.getElementById("timingConnect")?.addEventListener("click", () => {
const input = document.getElementById("timingWsUrl");
if (input && input.value.trim()) {
@@ -573,7 +579,7 @@ export function renderTimingView(deps) {
if (!tp) {
return;
}
processDecoderMessage({
const changed = processDecoderMessage({
msg: "PASSING",
transponder: tp,
rtc_time: new Date().toISOString(),
@@ -581,6 +587,7 @@ export function renderTimingView(deps) {
resend: false,
loop_id: "sim",
});
if (changed) renderView();
});
document.getElementById("setActiveSession")?.addEventListener("click", () => {

View File

@@ -59,7 +59,7 @@ function createHarness() {
test("split decoder records intermediate time without changing main lap state", () => {
const harness = createHarness();
harness.passing(17_000, "main");
assert.equal(harness.passing(17_000, "main"), true);
const result = harness.state.resultsBySession.session_1;
const entry = result.competitors.team_team_1;
assert.equal(entry.laps, 1);
@@ -150,3 +150,13 @@ test("connect decoder opens one websocket per enabled role", () => {
globalThis.WebSocket = OriginalWebSocket;
}
});
test("non-passing decoder messages do not persist race state", () => {
const harness = createHarness();
const changed = processDecoderMessageHelper({ msg: "STATUS", decoder: "online" }, {
state: harness.state,
getActiveSession: () => harness.session,
});
assert.equal(Boolean(changed), false);
assert.equal(harness.getSaveCount(), 0);
});

View File

@@ -5,6 +5,7 @@ import {
applyPersistedStateHelper,
hydrateFromBackendHelper,
syncStateToBackendHelper,
tickClockHelper,
} from "../src/runtime_services.js";
function withMockFetch(mock, callback) {
@@ -284,3 +285,29 @@ test("failed state sync leaves version and dirty state unchanged", async () => {
assert.equal(backend.available, false);
assert.equal(backend.lastError, "error.sync_failed:connection refused");
});
test("running timing clock updates without rebuilding the leaderboard details modal", () => {
let renders = 0;
let dynamicRefreshes = 0;
let headerRefreshes = 0;
const base = {
dom: { clock: { textContent: "" } },
currentLanguage: () => "sv",
getActiveSession: () => ({ status: "running" }),
getCurrentView: () => "timing",
getOverlayMode: () => false,
renderView: () => { renders += 1; },
refreshTimingDynamics: () => { dynamicRefreshes += 1; },
updateHeaderState: () => { headerRefreshes += 1; },
};
tickClockHelper({ ...base, handleSessionTimerTick: () => ({ changed: false }) });
assert.equal(dynamicRefreshes, 1);
assert.equal(renders, 0);
assert.equal(headerRefreshes, 0);
tickClockHelper({ ...base, handleSessionTimerTick: () => ({ changed: true }) });
assert.equal(dynamicRefreshes, 1);
assert.equal(renders, 1);
assert.equal(headerRefreshes, 1);
});

View File

@@ -74,6 +74,7 @@ test("SQLite API persists state, passings, and overlay session results", { timeo
PORT: String(port),
RC_TIMING_DATA_DIR: dataDir,
RC_TIMING_PID_FILE: "",
RC_ALLOWED_ORIGINS: "https://trusted.example",
},
stdio: ["ignore", "pipe", "pipe"],
});
@@ -87,6 +88,20 @@ test("SQLite API persists state, passings, and overlay session results", { timeo
assert.equal(health.ok, true);
assert.equal(health.dbPath, join(dataDir, "rc_timing.sqlite"));
const sameOriginResponse = await fetch(`${baseUrl}/api/health`, { headers: { Origin: baseUrl } });
assert.equal(sameOriginResponse.status, 200);
assert.equal(sameOriginResponse.headers.get("access-control-allow-origin"), baseUrl);
const allowedOriginResponse = await fetch(`${baseUrl}/api/health`, { headers: { Origin: "https://trusted.example" } });
assert.equal(allowedOriginResponse.status, 200);
assert.equal(allowedOriginResponse.headers.get("access-control-allow-origin"), "https://trusted.example");
const rejectedOriginResponse = await fetch(`${baseUrl}/api/ammc/start`, {
method: "POST",
headers: { Origin: "https://untrusted.example" },
});
assert.equal(rejectedOriginResponse.status, 403);
const soundManifestResponse = await fetch(`${baseUrl}/race-sounds/sound_manifest.json`);
const soundManifest = await soundManifestResponse.json();
assert.equal(soundManifestResponse.status, 200);