fix live leaderbord details
This commit is contained in:
@@ -7,6 +7,7 @@ RC RaceCore is an RC timing and race-control system with support for sponsor eve
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- JavaScript module overview: `docs/javascript-architecture.md`
|
- JavaScript module overview: `docs/javascript-architecture.md`
|
||||||
|
- Security and performance audit: `docs/security-performance-audit.md`
|
||||||
- Swedish operational guide: `README.sv.md`
|
- Swedish operational guide: `README.sv.md`
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
@@ -51,6 +52,8 @@ If you want simple protection for public overlays, set this environment variable
|
|||||||
PUBLIC_OVERLAY_TOKEN=your-secret-token
|
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:
|
Then the public overlay URL must include:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he
|
|||||||
## Arkitektur
|
## Arkitektur
|
||||||
|
|
||||||
- JavaScript-modulöversikt: `docs/javascript-architecture.md`
|
- JavaScript-modulöversikt: `docs/javascript-architecture.md`
|
||||||
|
- Säkerhets- och prestandaaudit: `docs/security-performance-audit.md`
|
||||||
- Primär engelsk README: `README.md`
|
- Primär engelsk README: `README.md`
|
||||||
|
|
||||||
## Skärmbilder
|
## 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
|
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:
|
Då måste den publika overlay-länken innehålla:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ Runtime environment variables:
|
|||||||
- `RC_TIMING_DATA_DIR`: optional absolute/relative data directory override; defaults to `<repo>/data`
|
- `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
|
- `RC_TIMING_PID_FILE`: optional PID-file path used by process control
|
||||||
- `PUBLIC_OVERLAY_TOKEN`: optional shared token for public overlay routes
|
- `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`
|
### `server/public_training_profile.js`
|
||||||
Role:
|
Role:
|
||||||
|
|||||||
128
docs/security-performance-audit.md
Normal file
128
docs/security-performance-audit.md
Normal 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.
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"status": "node scripts/serverctl.js status",
|
"status": "node scripts/serverctl.js status",
|
||||||
"restart": "node scripts/serverctl.js restart",
|
"restart": "node scripts/serverctl.js restart",
|
||||||
"verify": "node scripts/verify-install.js",
|
"verify": "node scripts/verify-install.js",
|
||||||
|
"audit:prod": "npm audit --omit=dev",
|
||||||
"test": "node --experimental-default-type=module tests/run.js",
|
"test": "node --experimental-default-type=module tests/run.js",
|
||||||
"setup:windows": "powershell -NoProfile -ExecutionPolicy Bypass -File windows/setup.ps1"
|
"setup:windows": "powershell -NoProfile -ExecutionPolicy Bypass -File windows/setup.ps1"
|
||||||
},
|
},
|
||||||
|
|||||||
24
server.js
24
server.js
@@ -59,13 +59,33 @@ const ammcState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const app = express();
|
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(express.json({ limit: "20mb" }));
|
||||||
app.use((req, res, next) => {
|
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-Methods", "GET,POST,OPTIONS");
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||||
|
}
|
||||||
if (req.method === "OPTIONS") {
|
if (req.method === "OPTIONS") {
|
||||||
res.status(204).end();
|
res.status(corsAllowed ? 204 : 403).end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
next();
|
next();
|
||||||
|
|||||||
46
src/app.js
46
src/app.js
@@ -140,7 +140,7 @@ const checkAppVersion = () => checkAppVersionHelper({ getBackendUrl, getBaseline
|
|||||||
const startAppVersionPolling = () => startAppVersionPollingHelper({ checkAppVersion, getAppVersionPollTimer: () => appVersionPollTimer, setAppVersionPollTimer: (value) => { appVersionPollTimer = value; } });
|
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 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 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 ensureAudioContext = () => ensureAudioContextHelper({ state, getAudioCtx: () => audioCtx, setAudioCtx: (value) => { audioCtx = value; } });
|
||||||
const playPassingBeep = () => playPassingBeepHelper({ ensureAudioContext });
|
const playPassingBeep = () => playPassingBeepHelper({ ensureAudioContext });
|
||||||
const playFinishSiren = () => playFinishSirenHelper({ ensureAudioContext });
|
const playFinishSiren = () => playFinishSirenHelper({ ensureAudioContext });
|
||||||
@@ -180,7 +180,7 @@ const startSessionWithAudio = (session, callbacks) => {
|
|||||||
const practice = ["open_practice", "free_practice", "practice"].includes(String(session?.type || ""));
|
const practice = ["open_practice", "free_practice", "practice"].includes(String(session?.type || ""));
|
||||||
return raceAudioEngine.runStartSequence(session, { ...callbacks, skipAudio: practice });
|
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 buildTeamRaceResultsHtml = (event) => buildTeamRaceResultsHtmlHelper(event, { t, escapeHtml, resolveEventBranding, getClassName, buildPrintBrandBlock, buildTeamRaceStandings, renderTable, formatLap, renderTeamStintLog, renderSessionChannelAnalysis });
|
||||||
const connectDecoder = () => connectDecoderHelper({
|
const connectDecoder = () => connectDecoderHelper({
|
||||||
state,
|
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 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 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 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 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 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 normalizeTrainingState = (training) => normalizeTrainingStateLogic(training, { uid });
|
||||||
|
|||||||
@@ -177,15 +177,11 @@ function connectDecoderRole(role, deps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(parsed)) {
|
const messages = Array.isArray(parsed) ? parsed : [parsed];
|
||||||
parsed.forEach((message) => processDecoderMessage(message, role));
|
const changed = messages.reduce((didChange, message) => processDecoderMessage(message, role) || didChange, false);
|
||||||
} else {
|
|
||||||
processDecoderMessage(parsed, role);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveState();
|
|
||||||
updateConnectionBadge();
|
updateConnectionBadge();
|
||||||
if (["timing", "dashboard", "overlay", "training"].includes(getCurrentView())) {
|
if (changed && ["timing", "dashboard", "overlay", "training"].includes(getCurrentView())) {
|
||||||
renderView();
|
renderView();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -330,7 +326,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") {
|
|||||||
pushOverlayEvent("pit", `${entry.displayName || entry.driverName} • PIT IN`);
|
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 } });
|
triggerRaceAudio("entering_pits", { variant: entry.carId || entry.driverId || transponder, driverId: entry.driverId, logData: { raceId: session.eventId, driverId: entry.driverId } });
|
||||||
saveState();
|
saveState();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (decoderRole === "pit_exit") {
|
if (decoderRole === "pit_exit") {
|
||||||
@@ -360,7 +356,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") {
|
|||||||
pushOverlayEvent("pit", `${entry.displayName || entry.driverName} • PIT ${formatLap(pitStopMs)}`);
|
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 } });
|
triggerRaceAudio("leaving_pits", { variant: entry.carId || entry.driverId || transponder, driverId: entry.driverId, logData: { raceId: session.eventId, driverId: entry.driverId } });
|
||||||
saveState();
|
saveState();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (decoderRole === "split") {
|
if (decoderRole === "split") {
|
||||||
@@ -392,7 +388,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") {
|
|||||||
});
|
});
|
||||||
pushOverlayEvent("split", `${entry.displayName || entry.driverName} • ${formatLap(splitMs)}`);
|
pushOverlayEvent("split", `${entry.displayName || entry.driverName} • ${formatLap(splitMs)}`);
|
||||||
saveState();
|
saveState();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (startMode === "staggered" && !entry.startTimestamp) {
|
if (startMode === "staggered" && !entry.startTimestamp) {
|
||||||
@@ -400,7 +396,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") {
|
|||||||
entry.lastTimestamp = timestamp;
|
entry.lastTimestamp = timestamp;
|
||||||
announcePassing(entry);
|
announcePassing(entry);
|
||||||
saveState();
|
saveState();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!entry.startTimestamp) {
|
if (!entry.startTimestamp) {
|
||||||
@@ -448,7 +444,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") {
|
|||||||
}
|
}
|
||||||
persistPassingToBackend(session.id, passing);
|
persistPassingToBackend(session.id, passing);
|
||||||
saveState();
|
saveState();
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousPersonalBest = Number(entry.bestLapMs || 0) || null;
|
const previousPersonalBest = Number(entry.bestLapMs || 0) || null;
|
||||||
@@ -500,4 +496,6 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
announcePassing(entry);
|
announcePassing(entry);
|
||||||
|
saveState();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ export function renderOverlayPageView(deps) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="overlay-meta">
|
<div class="overlay-meta">
|
||||||
<button id="overlayFullscreen" class="btn overlay-fullscreen-btn" type="button">${t("overlay.fullscreen")}</button>
|
<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 class="overlay-status">${escapeHtml(overlayStatusLabel)}</div>
|
||||||
</div>
|
</div>
|
||||||
</header>`}
|
</header>`}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function renderOverlayLeaderboard(rows, { t, escapeHtml, formatTeamActive
|
|||||||
.map((row, idx) => {
|
.map((row, idx) => {
|
||||||
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
|
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
|
||||||
return `
|
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">
|
<div class="overlay-race-pos">
|
||||||
<span class="pos-pill ${posClass}">${idx + 1}</span>
|
<span class="pos-pill ${posClass}">${idx + 1}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,10 +77,10 @@ export function renderOverlayLeaderboard(rows, { t, escapeHtml, formatTeamActive
|
|||||||
<div class="overlay-prediction">
|
<div class="overlay-prediction">
|
||||||
<div class="overlay-prediction-meta">
|
<div class="overlay-prediction-meta">
|
||||||
<label>${t("overlay.next_predicted_lap")}</label>
|
<label>${t("overlay.next_predicted_lap")}</label>
|
||||||
<span>${formatPredictedLapDelta(row.predictedRemainingMs)}</span>
|
<span data-overlay-prediction>${formatPredictedLapDelta(row.predictedRemainingMs)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="overlay-prediction-track">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,7 +223,7 @@ export function renderObsOverlay(active, leaderboard, result, sessionTiming, bra
|
|||||||
</div>
|
</div>
|
||||||
${obsConfig.showClock ? `
|
${obsConfig.showClock ? `
|
||||||
<div class="overlay-obs-clockblock">
|
<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>
|
<span>${escapeHtml(sessionTiming?.followUpActive ? t("timing.follow_up_active") : getStatusLabel(active.status))}</span>
|
||||||
</div>
|
</div>
|
||||||
` : ""}
|
` : ""}
|
||||||
@@ -247,7 +247,7 @@ export function renderObsOverlay(active, leaderboard, result, sessionTiming, bra
|
|||||||
<p class="overlay-kicker">${escapeHtml(getEventName(active.eventId))}</p>
|
<p class="overlay-kicker">${escapeHtml(getEventName(active.eventId))}</p>
|
||||||
<h2>${escapeHtml(active.name)}</h2>
|
<h2>${escapeHtml(active.name)}</h2>
|
||||||
</div>
|
</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>
|
||||||
<div class="overlay-obs-lowerthird-rows">
|
<div class="overlay-obs-lowerthird-rows">
|
||||||
${compactRows.slice(0, 6).map((row, idx) => {
|
${compactRows.slice(0, 6).map((row, idx) => {
|
||||||
@@ -317,7 +317,7 @@ export function renderObsOverlay(active, leaderboard, result, sessionTiming, bra
|
|||||||
<h2>${escapeHtml(active.name)}</h2>
|
<h2>${escapeHtml(active.name)}</h2>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<div class="overlay-obs-tower-meta">
|
<div class="overlay-obs-tower-meta">
|
||||||
<span>${escapeHtml(getSessionTypeLabel(active.type))}</span>
|
<span>${escapeHtml(getSessionTypeLabel(active.type))}</span>
|
||||||
|
|||||||
@@ -235,8 +235,9 @@ export function startAppVersionPollingHelper({ checkAppVersion, getAppVersionPol
|
|||||||
export function startOverlaySyncHelper({ getOverlaySyncTimer, setOverlaySyncTimer, getLocalStateDirty, hydrateFromBackend, getCurrentView, renderView }) {
|
export function startOverlaySyncHelper({ getOverlaySyncTimer, setOverlaySyncTimer, getLocalStateDirty, hydrateFromBackend, getCurrentView, renderView }) {
|
||||||
clearInterval(getOverlaySyncTimer());
|
clearInterval(getOverlaySyncTimer());
|
||||||
setOverlaySyncTimer(setInterval(async () => {
|
setOverlaySyncTimer(setInterval(async () => {
|
||||||
|
if (getCurrentView() !== "overlay") return;
|
||||||
if (!getLocalStateDirty()) await hydrateFromBackend();
|
if (!getLocalStateDirty()) await hydrateFromBackend();
|
||||||
if (getCurrentView() === "overlay") renderView();
|
renderView();
|
||||||
}, 800));
|
}, 800));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,10 +249,10 @@ export function startOverlayRotationHelper({ getOverlayRotationTimer, setOverlay
|
|||||||
}, 8000));
|
}, 8000));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startOverlayLiveRefreshHelper({ getOverlayLiveRefreshTimer, setOverlayLiveRefreshTimer, getCurrentView, getOverlayViewMode, renderOverlay }) {
|
export function startOverlayLiveRefreshHelper({ getOverlayLiveRefreshTimer, setOverlayLiveRefreshTimer, getCurrentView, getOverlayViewMode, refreshOverlayDynamics }) {
|
||||||
clearInterval(getOverlayLiveRefreshTimer());
|
clearInterval(getOverlayLiveRefreshTimer());
|
||||||
setOverlayLiveRefreshTimer(setInterval(() => {
|
setOverlayLiveRefreshTimer(setInterval(() => {
|
||||||
if (getCurrentView() === "overlay" && ["leaderboard", "tv", "team"].includes(getOverlayViewMode())) renderOverlay();
|
if (getCurrentView() === "overlay" && ["leaderboard", "speaker", "results", "tv", "team", "obs"].includes(getOverlayViewMode())) refreshOverlayDynamics();
|
||||||
}, 250));
|
}, 250));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,12 +419,15 @@ export function handleSessionTimerTickHelper({ getActiveSession, isUntimedSessio
|
|||||||
return { changed: true };
|
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");
|
dom.clock.textContent = new Date().toLocaleString(currentLanguage() === "sv" ? "sv-SE" : "en-US");
|
||||||
const timerState = handleSessionTimerTick();
|
const timerState = handleSessionTimerTick();
|
||||||
const active = getActiveSession();
|
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 (getCurrentView() === "dashboard" && timerState.changed) renderView();
|
||||||
if (getOverlayMode() && getCurrentView() === "overlay" && active) renderView();
|
if (getOverlayMode() && getCurrentView() === "overlay" && active && timerState.changed) renderView();
|
||||||
if (timerState.changed) updateHeaderState();
|
if (timerState.changed) updateHeaderState();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ function renderRecentPassings(session, deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function renderTimingView(deps) {
|
export function renderTimingView(deps) {
|
||||||
|
const leaderboardModalScrollTop = document.querySelector("#leaderboardModalOverlay .modal-card")?.scrollTop || 0;
|
||||||
const {
|
const {
|
||||||
state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, renderTable, uid,
|
state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, renderTable, uid,
|
||||||
getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings,
|
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>
|
<span class="pill ${active.status === "running" ? "pill-green" : ""}">${escapeHtml(getStatusLabel(active.status))}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="timing-session-stats">
|
<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("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("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>
|
<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) }) : ""}
|
${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", () => {
|
document.getElementById("timingConnect")?.addEventListener("click", () => {
|
||||||
const input = document.getElementById("timingWsUrl");
|
const input = document.getElementById("timingWsUrl");
|
||||||
if (input && input.value.trim()) {
|
if (input && input.value.trim()) {
|
||||||
@@ -573,7 +579,7 @@ export function renderTimingView(deps) {
|
|||||||
if (!tp) {
|
if (!tp) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
processDecoderMessage({
|
const changed = processDecoderMessage({
|
||||||
msg: "PASSING",
|
msg: "PASSING",
|
||||||
transponder: tp,
|
transponder: tp,
|
||||||
rtc_time: new Date().toISOString(),
|
rtc_time: new Date().toISOString(),
|
||||||
@@ -581,6 +587,7 @@ export function renderTimingView(deps) {
|
|||||||
resend: false,
|
resend: false,
|
||||||
loop_id: "sim",
|
loop_id: "sim",
|
||||||
});
|
});
|
||||||
|
if (changed) renderView();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("setActiveSession")?.addEventListener("click", () => {
|
document.getElementById("setActiveSession")?.addEventListener("click", () => {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function createHarness() {
|
|||||||
|
|
||||||
test("split decoder records intermediate time without changing main lap state", () => {
|
test("split decoder records intermediate time without changing main lap state", () => {
|
||||||
const harness = createHarness();
|
const harness = createHarness();
|
||||||
harness.passing(17_000, "main");
|
assert.equal(harness.passing(17_000, "main"), true);
|
||||||
const result = harness.state.resultsBySession.session_1;
|
const result = harness.state.resultsBySession.session_1;
|
||||||
const entry = result.competitors.team_team_1;
|
const entry = result.competitors.team_team_1;
|
||||||
assert.equal(entry.laps, 1);
|
assert.equal(entry.laps, 1);
|
||||||
@@ -150,3 +150,13 @@ test("connect decoder opens one websocket per enabled role", () => {
|
|||||||
globalThis.WebSocket = OriginalWebSocket;
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
applyPersistedStateHelper,
|
applyPersistedStateHelper,
|
||||||
hydrateFromBackendHelper,
|
hydrateFromBackendHelper,
|
||||||
syncStateToBackendHelper,
|
syncStateToBackendHelper,
|
||||||
|
tickClockHelper,
|
||||||
} from "../src/runtime_services.js";
|
} from "../src/runtime_services.js";
|
||||||
|
|
||||||
function withMockFetch(mock, callback) {
|
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.available, false);
|
||||||
assert.equal(backend.lastError, "error.sync_failed:connection refused");
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ test("SQLite API persists state, passings, and overlay session results", { timeo
|
|||||||
PORT: String(port),
|
PORT: String(port),
|
||||||
RC_TIMING_DATA_DIR: dataDir,
|
RC_TIMING_DATA_DIR: dataDir,
|
||||||
RC_TIMING_PID_FILE: "",
|
RC_TIMING_PID_FILE: "",
|
||||||
|
RC_ALLOWED_ORIGINS: "https://trusted.example",
|
||||||
},
|
},
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
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.ok, true);
|
||||||
assert.equal(health.dbPath, join(dataDir, "rc_timing.sqlite"));
|
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 soundManifestResponse = await fetch(`${baseUrl}/race-sounds/sound_manifest.json`);
|
||||||
const soundManifest = await soundManifestResponse.json();
|
const soundManifest = await soundManifestResponse.json();
|
||||||
assert.equal(soundManifestResponse.status, 200);
|
assert.equal(soundManifestResponse.status, 200);
|
||||||
|
|||||||
Reference in New Issue
Block a user