fix status and roadmap

This commit is contained in:
larssand
2026-09-08 17:26:55 +02:00
parent 9e6fe2386d
commit 443434d569
14 changed files with 401 additions and 8 deletions

View File

@@ -6,8 +6,16 @@ The application version is maintained in `package.json` and displayed in the adm
## Unreleased
### Added
- A prioritized ROADMAP.md with completion checklists.
- Server/save status, last load/save times, stale/offline warnings and manual retry of local changes.
- Isolated browser regression checks for class/driver/car creation and editing, retained drafts, reload persistence and offline save recovery (`npm run test:browser`).
### Fixed
- New browser profiles connect to the page origin rather than assuming backend port 8081.
- Correct chronological sorting of zero-padded times and deterministic ordering of distinct numeric-looking driver/car names. Name and identifier columns are explicitly treated as text.
- OBS race-info and standings panels wrap on narrow sources. Driver names retain space and timing fields wrap instead of overflowing.

View File

@@ -1,5 +1,7 @@
# RC RaceCore
Planned work and completion checklists are tracked in [ROADMAP.md](ROADMAP.md).
The running version is shown at the bottom of the menu and comes from `package.json`. Changes are recorded in [CHANGELOG.md](CHANGELOG.md).
English README. Swedish version: [`README.sv.md`](README.sv.md)
@@ -833,3 +835,11 @@ The repository includes `rc-race-sound-pack/`. `sound_manifest.json` is the sour
Effects are asynchronous and never awaited by decoder processing. Critical speech interrupts lower-priority speech; short passing effects may overlap. Missing WAV files are ignored safely. A driver or car may select `pass_01.wav` through `pass_08.wav`; until those optional files exist under `rc-race-sound-pack/sounds/effects/`, playback falls back to `transponder_pass.wav`.
Implemented automatic hooks include passings, personal/meeting best, fastest lap, historical class track record, leader and position changes, pit entry/exit, 60/30/10 seconds remaining, final lap/time expired, finish, winner, and podium. Penalty announcements reuse the existing judging correction action.
## Reliability checks and save status
The menu shows server connection, pending changes, saving, confirmed saves and save failures. It also shows the latest successful data load/save times. A visible warning indicates a lost or stale server connection; a successful health check does not clear a failed save. Use **Retry** to resend local changes. Server connection and decoder connection are separate indicators. Keep the tab open while changes are unsaved.
New browser profiles use the page origin (including its port) as the default backend URL. An explicitly configured backend URL takes precedence.
Run `npm test` for logic and API regression tests. Run `npm run test:browser` (Windows PowerShell: `npm.cmd run test:browser`) for real create/edit/reload checks of classes, drivers and cars, draft retention during background polling, and offline-save/retry behavior. Chrome, Chromium or Edge must be installed; set `CHROME_PATH` if its executable is not found automatically. The runner creates a temporary server database and browser profile and removes them on exit. It does not use your normal race data or start a decoder. See ROADMAP.md for workflows not yet covered.

View File

@@ -1,5 +1,7 @@
# RC RaceCore
Planerade förbättringar och avbockningsbara delmål finns i [ROADMAP.md](ROADMAP.md).
Versionen som körs visas längst ned i menyn och hämtas från `package.json`. Ändringar dokumenteras i [CHANGELOG.md](CHANGELOG.md).
RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika heat/finaler), AMMC WebSocket och lokal SQLite-lagring på Windows.
@@ -822,3 +824,11 @@ Repot innehåller `rc-race-sound-pack/`. `sound_manifest.json` är källan för
Effekter körs asynkront och väntas aldrig in av decoderflödet. Kritiska utrop avbryter lägre prioriterat tal; korta passing-ljud får överlappa. Saknade WAV-filer ignoreras säkert. En förare eller bil kan välja `pass_01.wav` till `pass_08.wav`; tills dessa valfria filer finns i `rc-race-sound-pack/sounds/effects/` används `transponder_pass.wav` som fallback.
Automatiska hooks omfattar passering, person-/meetingbästa, snabbaste varv, historiskt klass-banrekord, ledar- och positionsbyte, depå in/ut, 60/30/10 sekunder kvar, sista varv/tiden slut, finish, vinnare och podium. Penalty-utrop återanvänder domarvyns befintliga korrigeringsåtgärd.
## Driftsäkerhet och sparstatus
Menyn visar serveranslutning, väntande ändringar, pågående sparning, bekräftad sparning och sparfel. Där visas även senaste lyckade hämtning/sparning. En synlig varning visar tappad eller inaktuell serveranslutning; en lyckad anslutningskontroll tar inte bort ett sparfel. Klicka **Försök igen** för att skicka lokala ändringar igen. Serveranslutning och decoderanslutning har separata indikatorer. Behåll fliken öppen medan ändringar är osparade.
Nya webbläsarprofiler använder sidans adress inklusive port som standard för backend. En uttryckligen konfigurerad backend-adress går före standardvärdet.
Kör `npm test` för logik- och API-tester. Kör `npm run test:browser` (Windows PowerShell: `npm.cmd run test:browser`) för riktiga skapa/redigera/ladda-om-tester av klasser, förare och bilar, bibehållna formulärutkast under bakgrundsuppdatering samt offline-sparning och nytt sparförsök. Chrome, Chromium eller Edge måste finnas installerat; sätt `CHROME_PATH` om programmet inte hittas automatiskt. Testet skapar en tillfällig serverdatabas och webbläsarprofil och tar bort dem efteråt. Dina ordinarie racedata används inte och ingen decoder startas. ROADMAP.md visar vilka flöden som ännu saknar täckning.

79
ROADMAP.md Normal file
View File

@@ -0,0 +1,79 @@
# RC RaceCore Roadmap
Track work here with checkboxes. Check off a task only after implementation and verification. Record user-visible changes under `Unreleased` in [CHANGELOG.md](CHANGELOG.md). Priorities below are a working order, not release-date commitments.
## Existing foundation
- [x] Race Day, preflight checks and heat queue.
- [x] Automatic SQLite backups and local restore command.
- [x] Isolated session replay.
- [x] Training statistics, personal mobile profiles and car-change markers.
- [x] Interactive list sorting and OBS display modes.
- [x] Visible application version and changelog.
Verified 2026-09-08: 122 logic/API tests and the register/offline browser smoke suite passed on Linux with Chrome. Checked items below describe that coverage; other platforms and workflows remain open.
## 1. Reliability and browser regression tests
Acceptance: real create/edit/save flows persist after reload; background updates do not discard edits. Tests use isolated data, never the installation's database.
- [x] Add a repeatable browser smoke-test command with temporary database/profile.
- [x] Cover create/edit/save for classes, drivers and cars, including reload verification.
- [x] Verify register form drafts remain intact during background version polling.
- [ ] Cover sponsor events and race setup edits.
- [ ] Cover team creation, membership changes, editing and deletion.
- [ ] Cover session types, lap limits and start/finish controls.
- [ ] Cover browser sorting, popup overlays, and compact OBS layout automatically.
- [ ] Add Windows and Linux CI runs for supported runtimes.
## 2. Connection and save status
Acceptance: users can distinguish server contact, pending edits, an active save and a failed save; a health check never falsely confirms a failed write.
- [x] Show pending, saving, saved and failed state in the main interface.
- [x] Show last successful data load/save times.
- [x] Warn when the server connection is lost or its status becomes stale.
- [x] Offer manual retry for unsaved changes without discarding local edits.
- [x] Verify save failure/reconnection in a real browser.
- [ ] Extend stale-data feedback to the personal mobile profile.
- [ ] Review concurrent writes, reconnect recovery and multiple controllers editing the same race.
## 3. Clearer practice follow-up
Acceptance: a driver receives a short, encouraging summary based on their own class-specific data, with one useful next goal.
- [ ] Add an end-of-practice summary: pace, consistency, progress and achieved goals.
- [ ] Suggest one next training goal using existing calculations.
- [ ] Keep summaries separate by class and explain insufficient sample sizes.
- [ ] Include the summary in the personal mobile profile.
- [ ] Add coach/parent reward follow-up without turning practice into a public ranking.
## 4. Results archive
- [ ] Search completed sessions by date, class and driver without Race Setup.
- [ ] Show standings, lap history and penalties together.
- [ ] Reuse existing PDF/export functions from the archive.
- [ ] Define what remains available after deleting a race.
## 5. More natural race speech
- [ ] Optional backend-generated neural speech.
- [ ] Cache announcements and driver names before race day.
- [ ] Keep credentials on the backend, outside exports and public responses.
- [ ] Invalidate cached speech when language, voice or pronunciation changes.
- [ ] Provide provider status and a pre-generate action.
- [ ] Verify offline fallback and that playback never delays timing.
## 6. Permissions and mobile Race Control
- [ ] Define Admin, Race Director, Speaker and Read-only capabilities.
- [ ] Enforce permissions on server endpoints, not only in the UI.
- [ ] Keep public overlays and training links restricted.
- [ ] Build compact mobile controls for start/stop, track call, flags and next heat.
- [ ] Test concurrent control and accidental repeated commands.
## Documentation maintenance
- [ ] Update the architecture's module/dependency inventory as modules change.
- [ ] Complete state/settings/SQLite contracts and critical form-to-controller mappings.
- [ ] Consolidate older future-work notes so this file remains the primary roadmap.

View File

@@ -2652,3 +2652,12 @@ The application currently has no authentication or role model. Race-control soun
### 20.8 Tests
`tests/race_audio.test.js` covers normalization, language/event mapping, custom-file fallback, critical interruption, start-light order, random delay, and tone/timestamp callback order. Existing decoder, runtime persistence, audio fallback, race timing, and server integration suites remain the regression boundary.
## Reliability status and browser checks (2026-09)
- `src/sync_status.js` exports pure `getSyncStatus(backend, dirty, now)`. Its precedence is active writes, write failure, pending local changes, connection availability/staleness, then saved/connected. It does not mutate race state or initiate network calls.
- `src/runtime_services.js` records active state writes in `backend.pendingWrites`, keeps `writeError` distinct from contact status, records `lastSavedAt` only after a successful state POST, and records `lastLoadedAt` after applying a backend state response. A failed POST leaves local dirty/version tracking intact.
- Existing app-version polling reports server contact through an optional `onContact` callback. `src/app.js` updates `lastContactAt`/`connectionChecked` and renders status text once per second without rebuilding forms. Contact older than 15 seconds is stale. Decoder connectivity remains separate.
- `index.html` owns `syncStatus`, `syncStatusLabel`, `syncStatusDetail`, `syncStatusRetry`, and `syncWarning`. Retry uses the existing full-state POST when local changes or write errors exist. Successful full-state synchronization at the current local version clears the passing error too. Keep the page open when unsaved changes exist; this is not a new durable offline queue or a multi-controller conflict-resolution mechanism.
- `tests/sync_status.test.js` verifies status precedence and state-write failure/retry. `tests/browser_smoke.js` drives Chrome/Chromium/Edge over its local debugging protocol with Node's WebSocket API. It launches an isolated server using `RC_TIMING_DATA_DIR` and a temporary browser profile. `CHROME_PATH` overrides discovery. `RC_BROWSER_NO_SANDBOX=1` is an explicit opt-in for restricted test environments; normal runs keep the browser sandbox enabled.
- The browser suite covers register CRUD creation/editing (not deletion), drafts through two version polls, reload persistence, failed offline saves and retry. Session/team flows and CI platform coverage remain tracked in ROADMAP.md.

View File

@@ -24,6 +24,11 @@
</div>
<nav id="nav" class="nav"></nav>
<div class="sidebar-footer">
<div id="syncStatus" class="sync-status">
<strong id="syncStatusLabel" role="status" aria-live="polite"></strong>
<small id="syncStatusDetail"></small>
<button id="syncStatusRetry" class="btn btn-mini" type="button" hidden></button>
</div>
<p id="connectionBadge" class="badge badge-offline">Decoder Offline</p>
<small id="clock"></small>
<small id="appVersionLabel" class="app-version" hidden></small>
@@ -31,6 +36,7 @@
</aside>
<main class="content">
<p id="syncWarning" class="sync-warning" role="status" hidden></p>
<header class="topbar">
<div>
<h2 id="pageTitle">Dashboard</h2>

View File

@@ -22,6 +22,7 @@
"backup:restore": "node scripts/restore-backup.js",
"audit:prod": "npm audit --omit=dev",
"test": "node --experimental-default-type=module tests/run.js",
"test:browser": "node --experimental-default-type=module tests/browser_smoke.js",
"setup:windows": "powershell -NoProfile -ExecutionPolicy Bypass -File windows/setup.ps1"
},
"dependencies": {

View File

@@ -1,3 +1,4 @@
import { getSyncStatus } from "./sync_status.js";
import { installListSorting } from "./list_sorting.js";
import { escapeHtml } from "./html_escape.js";
import { getOverlayModeLabel, buildOverlayPanels, renderOverlaySidePanel, renderOverlayLeaderboard, renderTeamOverlay, renderObsOverlay } from "./overlays.js";
@@ -142,7 +143,12 @@ const hydrateFromBackend = () => hydrateFromBackendHelper({ backend, t, getBacke
const scheduleBackendSync = () => scheduleBackendSyncHelper({ getBackendSyncTimer: () => backendSyncTimer, setBackendSyncTimer: (value) => { backendSyncTimer = value; }, syncStateToBackend });
const syncStateToBackend = () => syncStateToBackendHelper({ backend, t, getBackendUrl, buildPersistableState, getLocalStateVersion: () => localStateVersion, getSyncedStateVersion: () => syncedStateVersion, setSyncedStateVersion: (value) => { syncedStateVersion = value; }, getLocalStateDirty: () => localStateDirty, setLocalStateDirty: (value) => { localStateDirty = value; } });
const pingBackend = () => pingBackendHelper({ backend, t, getBackendUrl });
const checkAppVersion = () => checkAppVersionHelper({ onVersion: (version) => {
const checkAppVersion = () => checkAppVersionHelper({ onContact: (online) => {
backend.available = online;
backend.connectionChecked = true;
if (online) backend.lastContactAt = Date.now();
updateSyncStatus();
}, onVersion: (version) => {
const label = document.getElementById("appVersionLabel");
if (label) { label.textContent = `RC RaceCore v${version}`; label.hidden = false; }
}, getBackendUrl, getBaselineAppVersion: () => baselineAppVersion, setBaselineAppVersion: (value) => { baselineAppVersion = value; }, getPendingAppVersion: () => pendingAppVersion, setPendingAppVersion: (value) => { pendingAppVersion = value; }, getLocalStateDirty: () => localStateDirty });
@@ -379,6 +385,18 @@ const TRANSLATIONS = {
"race_day.finish": "Avsluta heat",
"race_day.start_list": "Startlista",
"race_day.no_start_list": "Ingen startlista finns för sessionen.",
"sync.connecting": "Ansluter till servern",
"sync.connected": "Server ansluten",
"sync.saved": "Ändringar sparade",
"sync.pending": "Ändringar väntar på sparning",
"sync.saving": "Sparar till servern…",
"sync.error": "Sparning misslyckades",
"sync.offline": "Serveranslutning saknas",
"sync.stale": "Serverstatus är inaktuell",
"sync.retry": "Försök igen",
"sync.loaded": "Data hämtad {time}",
"sync.saved_at": "Sparat {time}",
"sync.stale_hint": "Visad data kan vara inaktuell.",
"race_day.preflight": "Förkontroll",
"race_day.check_summary": "{blockers} blockerar start · {warnings} varningar",
"race_day.heat_queue": "Heat-kö",
@@ -1604,7 +1622,7 @@ const TRANSLATIONS = {
"guide.race_day_3": "3. Förkontrollen visar blockerare först, sedan varningar och godkända kontroller. Knapparna vid problemen öppnar relevant meny; min/max kan rättas direkt i Race Day. Förkontrollen blockerar start när Main decoder är offline, deltagare eller transpondrar saknas, transpondrar är dubbla, varvfönstret är ogiltigt eller en positionsgrid är ofullständig.",
"guide.race_day_4": "4. Gula kontroller är varningar. Valfria split-/depådecoders, backend och ljud visas tydligt men ändrar inte vanlig varvräkning.",
"guide.race_day_5": "5. Vid start synkas state till backend och en konsistent SQLite-backup skapas i data/backups innan den befintliga audio- och starttonssekvensen körs. De 30 senaste sparas.",
"guide.race_day_6": "6. Använd Race Day för start, paus, fortsätt och avslut. Tidtagning, Domare och Overlay finns som direkta genvägar. Lokal restore kräver stoppad server och npm run backup:restore -- <fil.sqlite> --confirm.",
"guide.race_day_6": "6. Använd Race Day för start, paus, fortsätt och avslut. Menyn visar server- och sparstatus separat från decodern. Vid sparfel: behåll fliken öppen och klicka Försök igen; väntande ändringar är inte bekräftat sparade på servern. Tidtagning, Domare och Overlay finns som direkta genvägar. Lokal restore kräver stoppad server och npm run backup:restore -- <fil.sqlite> --confirm.",
"guide.race_day_7": "7. Session replay i Tidtagning spelar upp sparade passeringar i ett isolerat testläge. Välj session, ladda och kör i 1x, 2x, 5x eller 10x utan att ändra originalresultat eller träningsstatistik.",
"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.",
@@ -1753,6 +1771,18 @@ const TRANSLATIONS = {
"race_day.finish": "Finish heat",
"race_day.start_list": "Start list",
"race_day.no_start_list": "No start list is available for this session.",
"sync.connecting": "Connecting to server",
"sync.connected": "Server connected",
"sync.saved": "Changes saved",
"sync.pending": "Changes waiting to save",
"sync.saving": "Saving to server…",
"sync.error": "Save failed",
"sync.offline": "Server disconnected",
"sync.stale": "Server status is stale",
"sync.retry": "Retry",
"sync.loaded": "Data loaded {time}",
"sync.saved_at": "Saved {time}",
"sync.stale_hint": "Displayed data may be out of date.",
"race_day.preflight": "Preflight",
"race_day.check_summary": "{blockers} blocking start · {warnings} warnings",
"race_day.heat_queue": "Heat queue",
@@ -2978,7 +3008,7 @@ const TRANSLATIONS = {
"guide.race_day_3": "3. Preflight lists blockers first, then warnings and passed checks. Buttons beside issues open the relevant menu; min/max can be corrected directly in Race Day. Preflight blocks start when the Main decoder is offline, participants or transponders are missing, transponders are duplicated, the lap window is invalid, or a position grid is incomplete.",
"guide.race_day_4": "4. Yellow checks are warnings. Optional split/pit decoders, backend, and audio are visible but do not change normal lap counting.",
"guide.race_day_5": "5. Starting synchronizes state to the backend and creates a consistent SQLite backup in data/backups before the existing audio and start-tone sequence runs. The latest 30 are retained.",
"guide.race_day_6": "6. Use Race Day for start, pause, resume, and finish. Timing, Judging, and Overlay remain one click away. Local restore requires a stopped server and npm run backup:restore -- <file.sqlite> --confirm.",
"guide.race_day_6": "6. Use Race Day for start, pause, resume, and finish. The menu shows server/save status separately from the decoder. On save failure, keep the tab open and click Retry; pending changes are not confirmed saved on the server. Timing, Judging, and Overlay remain one click away. Local restore requires a stopped server and npm run backup:restore -- <file.sqlite> --confirm.",
"guide.race_day_7": "7. Session replay in Timing replays saved passings in an isolated test mode. Select a session, load it, and run at 1x, 2x, 5x, or 10x without modifying source results or training statistics.",
"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.",
@@ -3398,6 +3428,17 @@ async function init() {
updateConnectionBadge();
tickClock();
setInterval(tickClock, 1000);
updateSyncStatus();
setInterval(updateSyncStatus, 1000);
document.getElementById("syncStatusRetry")?.addEventListener("click", async () => {
if (backend.pendingWrites) return;
if (localStateDirty || backend.writeError || backend.passingError) {
await syncStateToBackend();
if (!backend.writeError && !localStateDirty) backend.passingError = "";
}
await checkAppVersion();
updateSyncStatus();
});
startAppVersionPolling();
Promise.allSettled([hydrateFromBackend(), loadAmmcConfigFromBackend(), refreshServerBackups({ render: false })]).finally(() => {
@@ -3421,6 +3462,32 @@ async function init() {
}
}
function updateSyncStatus() {
const container = document.getElementById("syncStatus");
if (!container) return;
const status = getSyncStatus(backend, localStateDirty);
container.dataset.state = status.kind;
const label = document.getElementById("syncStatusLabel");
const text = t("sync." + status.kind);
if (label.textContent !== text) label.textContent = text;
const detail = document.getElementById("syncStatusDetail");
const parts = [];
if (backend.lastLoadedAt) parts.push(t("sync.loaded", { time: new Date(backend.lastLoadedAt).toLocaleTimeString() }));
if (backend.lastSavedAt) parts.push(t("sync.saved_at", { time: new Date(backend.lastSavedAt).toLocaleTimeString() }));
if (!backend.available || status.kind === "stale") parts.push(t("sync.stale_hint"));
detail.textContent = parts.join(" · ");
const warning = document.getElementById("syncWarning");
if (warning) {
warning.hidden = !["offline", "stale", "error"].includes(status.kind);
warning.textContent = `${text}. ${t("sync.stale_hint")}`;
}
container.title = backend.writeError || backend.passingError || backend.lastError || "";
const retry = document.getElementById("syncStatusRetry");
retry.textContent = t("sync.retry");
retry.hidden = !status.canRetry;
retry.disabled = Boolean(backend.pendingWrites);
}
function markSettingsDraftActive(event) {
if (currentView !== "settings") return;
const target = event.target;
@@ -3682,6 +3749,7 @@ function saveState(options = {}) {
localStateVersion += 1;
localStateDirty = true;
scheduleBackendSync();
updateSyncStatus();
}
}
@@ -4210,7 +4278,7 @@ function importDataPayload(parsed, mode = "full") {
function getDefaultBackendUrl() {
if (window.location.protocol.startsWith("http") && window.location.hostname) {
return `${window.location.protocol}//${window.location.hostname}:8081`;
return window.location.origin;
}
return "http://127.0.0.1:8081";
}
@@ -5362,6 +5430,7 @@ function findDuplicateSessionTransponders(session) {
}
async function persistPassingToBackend(sessionId, passing) {
backend.pendingWrites = (backend.pendingWrites || 0) + 1;
try {
const res = await fetch(`${getBackendUrl()}/api/passings`, {
method: "POST",
@@ -5376,5 +5445,8 @@ async function persistPassingToBackend(sessionId, passing) {
} catch (error) {
backend.available = false;
backend.lastError = t("error.passing_save_failed", { msg: error instanceof Error ? error.message : String(error) });
backend.passingError = backend.lastError;
} finally {
backend.pendingWrites = Math.max(0, (backend.pendingWrites || 1) - 1);
}
}

View File

@@ -158,6 +158,7 @@ export async function hydrateFromBackendHelper({ backend, t, getBackendUrl, appl
applyPersistedState(payload.state);
saveState({ skipBackend: true });
backend.lastSyncAt = payload.updatedAt || new Date().toISOString();
backend.lastLoadedAt = new Date().toISOString();
}
backend.available = true;
backend.lastError = "";
@@ -173,6 +174,7 @@ export function scheduleBackendSyncHelper({ getBackendSyncTimer, setBackendSyncT
}
export async function syncStateToBackendHelper({ backend, t, getBackendUrl, buildPersistableState, getLocalStateVersion, getSyncedStateVersion, setSyncedStateVersion, setLocalStateDirty }) {
backend.pendingWrites = (backend.pendingWrites || 0) + 1;
const syncVersion = getLocalStateVersion();
try {
const res = await fetch(`${getBackendUrl()}/api/state`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(buildPersistableState()) });
@@ -180,11 +182,19 @@ export async function syncStateToBackendHelper({ backend, t, getBackendUrl, buil
backend.available = true;
backend.lastError = "";
backend.lastSyncAt = new Date().toISOString();
backend.lastSavedAt = backend.lastSyncAt;
backend.writeError = "";
setSyncedStateVersion(Math.max(getSyncedStateVersion(), syncVersion));
if (syncVersion === getLocalStateVersion()) setLocalStateDirty(false);
if (syncVersion === getLocalStateVersion()) {
setLocalStateDirty(false);
backend.passingError = "";
}
} catch (error) {
backend.available = false;
backend.lastError = t("error.sync_failed", { msg: error instanceof Error ? error.message : String(error) });
backend.writeError = backend.lastError;
} finally {
backend.pendingWrites = Math.max(0, (backend.pendingWrites || 1) - 1);
}
}
@@ -200,11 +210,12 @@ export async function pingBackendHelper({ backend, t, getBackendUrl }) {
}
}
export async function checkAppVersionHelper({ getBackendUrl, getBaselineAppVersion, setBaselineAppVersion, getPendingAppVersion, setPendingAppVersion, getLocalStateDirty, onVersion = () => {} }) {
export async function checkAppVersionHelper({ getBackendUrl, getBaselineAppVersion, setBaselineAppVersion, getPendingAppVersion, setPendingAppVersion, getLocalStateDirty, onVersion = () => {}, onContact = () => {} }) {
try {
const res = await fetch(`${getBackendUrl()}/api/app-version`, { cache: "no-store" });
if (!res.ok) return;
if (!res.ok) { onContact(false); return; }
const payload = await res.json();
onContact(true);
if (typeof payload.version === "string" && payload.version) onVersion(payload.version);
const key = `${payload.revision}:${payload.updatedAt}`;
if (!getBaselineAppVersion()) {
@@ -224,7 +235,7 @@ export async function checkAppVersionHelper({ getBackendUrl, getBaselineAppVersi
return;
}
window.location.reload();
} catch {}
} catch { onContact(false); }
}
export function startAppVersionPollingHelper({ checkAppVersion, getAppVersionPollTimer, setAppVersionPollTimer }) {

View File

@@ -4033,3 +4033,12 @@ select:focus {
}
.overlay-shell-obs .list-sort-toolbar { display: none; }
@media print { .list-sort-toolbar, .list-sort-arrow { display: none; } }
.sync-status { display: grid; gap: 5px; margin-bottom: 10px; padding: 9px; border: 1px solid var(--line); border-radius: 8px; font-size: 0.75rem; }
.sync-status small { line-height: 1.4; color: var(--muted); }
.sync-status[data-state="saved"] strong, .sync-status[data-state="connected"] strong { color: var(--success); }
.sync-status[data-state="error"] strong, .sync-status[data-state="offline"] strong { color: var(--danger); }
.sync-status[data-state="pending"] strong, .sync-status[data-state="stale"] strong { color: var(--gold, #d9a441); }
.sync-status [hidden] { display: none; }
.sync-warning { margin: 0 0 10px; padding: 8px 12px; border: 1px solid var(--danger); border-radius: 8px; background: var(--panel); color: var(--text); font-size: 0.8rem; }
.sync-warning[hidden] { display: none; }

8
src/sync_status.js Normal file
View File

@@ -0,0 +1,8 @@
export function getSyncStatus(backend, dirty, now = Date.now()) {
if (backend.pendingWrites > 0) return { kind: "saving", canRetry: false };
if (backend.writeError || backend.passingError) return { kind: "error", canRetry: true };
if (dirty) return { kind: "pending", canRetry: true };
if (!backend.available) return { kind: backend.connectionChecked || backend.lastContactAt || backend.lastError ? "offline" : "connecting", canRetry: true };
if (backend.lastContactAt && now - backend.lastContactAt > 15000) return { kind: "stale", canRetry: true };
return { kind: backend.lastSavedAt ? "saved" : "connected", canRetry: false };
}

130
tests/browser_smoke.js Normal file
View File

@@ -0,0 +1,130 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const project = dirname(dirname(fileURLToPath(import.meta.url)));
const chrome = [process.env.CHROME_PATH, "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", ...[process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA].filter(Boolean).flatMap(base => [join(base, "Google", "Chrome", "Application", "chrome.exe"), join(base, "Microsoft", "Edge", "Application", "msedge.exe")])].find(path => path && existsSync(path));
if (!chrome) throw new Error("Chrome/Chromium/Edge not found. Set CHROME_PATH to the browser executable.");
const temporary = mkdtempSync(join(tmpdir(), "racecore-browser-"));
const children = [];
let socket;
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
async function waitFor(check, label, timeout = 15000) {
const until = Date.now() + timeout;
while (Date.now() < until) {
try { if (await check()) return; } catch {}
await pause(100);
}
throw new Error("Timed out: " + label);
}
async function freePort() {
const server = createServer();
await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); });
const port = server.address().port;
await new Promise(resolve => server.close(resolve));
return port;
}
function launch(command, args, options = {}) {
const child = spawn(command, args, { cwd: project, stdio: "ignore", ...options });
children.push(child);
child.on("error", error => { console.error(error.message); });
return child;
}
try {
const port = await freePort();
const debugPort = await freePort();
const base = `http://127.0.0.1:${port}`;
launch(process.execPath, ["server.js"], { env: { ...process.env, HOST: "127.0.0.1", PORT: String(port), RC_TIMING_DATA_DIR: join(temporary, "data"), RC_TIMING_PID_FILE: "" } });
await waitFor(async () => (await fetch(base + "/api/health")).ok, "isolated server");
const initial = { classes: [{ id: "test_class", name: "Test Class" }], drivers: [], cars: [], events: [], sessions: [], resultsBySession: {}, settings: { language: "en", autoReconnect: false, backendUrl: base }, training: {} };
const seedResponse = await fetch(base + "/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(initial) });
assert.equal(seedResponse.ok, true);
launch(chrome, ["--headless", "--disable-gpu", "--no-first-run", "--no-default-browser-check", "--disable-background-networking", ...(process.env.RC_BROWSER_NO_SANDBOX === "1" ? ["--no-sandbox"] : []), `--user-data-dir=${join(temporary, "browser")}`, `--remote-debugging-port=${debugPort}`, "about:blank"]);
let target;
await waitFor(async () => { target = (await (await fetch(`http://127.0.0.1:${debugPort}/json/list`)).json()).find(page => page.type === "page"); return target; }, "headless browser");
socket = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve, reject) => { socket.addEventListener("open", resolve, { once: true }); socket.addEventListener("error", reject, { once: true }); });
let nextId = 0;
const requests = new Map();
const errors = [];
socket.addEventListener("message", event => {
const message = JSON.parse(event.data);
if (message.id) {
const request = requests.get(message.id);
requests.delete(message.id);
if (message.error) request?.reject(new Error(message.error.message)); else request?.resolve(message.result);
} else if (message.method === "Runtime.exceptionThrown") errors.push(message.params.exceptionDetails.text + ": " + (message.params.exceptionDetails.exception?.description || ""));
});
function send(method, params = {}) {
return new Promise((resolve, reject) => { const id = ++nextId; requests.set(id, { resolve, reject }); socket.send(JSON.stringify({ id, method, params })); });
}
async function evaluate(expression) {
const result = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true });
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text);
return result.result.value;
}
await send("Runtime.enable");
await send("Page.enable");
await send("Network.enable");
await send("Network.setBlockedURLs", { urls: ["*fonts.googleapis.com*", "*fonts.gstatic.com*"] });
const status = () => evaluate('document.getElementById("syncStatus")?.dataset.state');
async function navigate(view, form) {
await send("Page.navigate", { url: base + "/?view=" + view });
try {
await waitFor(async () => await evaluate(`Boolean(document.getElementById(${JSON.stringify(form)})) && document.getElementById("syncStatusDetail")?.textContent.includes("Data loaded")`), view + " hydrated");
} catch (error) {
throw new Error(error.message + "\n" + JSON.stringify({ errors, page: await evaluate("document.body.innerText.slice(0, 1800)") }));
}
}
async function fill(form, fields) {
await evaluate(`(() => { const form = document.getElementById(${JSON.stringify(form)}); for (const [name,value] of Object.entries(${JSON.stringify(fields)})) { const field=form.elements.namedItem(name); if (!field) throw new Error("Missing field "+name); field.value=value; field.dispatchEvent(new Event("input", {bubbles:true})); } })()`);
}
const savedState = async () => (await (await fetch(base + "/api/state")).json()).state;
for (const [view, prefix, collection, fields] of [
["classes", "class", "classes", { name: "Browser Class" }],
["drivers", "driver", "drivers", { name: "Browser Driver", classId: "test_class", transponder: "123456", club: "Test Club" }],
["cars", "car", "cars", { name: "Browser Car", transponder: "654321", brand: "Test Brand" }],
]) {
await navigate(view, prefix + "Form");
await fill(prefix + "Form", fields);
await evaluate(`document.getElementById("${prefix}Form").requestSubmit()`);
await waitFor(async () => (await savedState())[collection]?.some(item => item.name === fields.name), "create " + prefix);
const item = (await savedState())[collection].find(item => item.name === fields.name);
await evaluate(`document.getElementById(${JSON.stringify(prefix + "-edit-" + item.id)}).click()`);
await fill(prefix + "EditForm", { name: fields.name + " Updated" });
// Two version polls must not replace the active editor or its draft.
await pause(10500);
assert.equal(await evaluate(`document.getElementById("${prefix}EditForm").elements.namedItem("name").value`), fields.name + " Updated");
await evaluate(`document.getElementById("${prefix}EditSave").click()`);
await waitFor(async () => (await savedState())[collection]?.some(row => row.id === item.id && row.name === fields.name + " Updated"), "edit " + prefix);
await navigate(view, prefix + "Form");
assert.ok(await evaluate(`document.getElementById("view").textContent.includes(${JSON.stringify(fields.name + " Updated")})`));
console.log("PASS create/edit/reload and draft retention: " + collection);
}
await navigate("classes", "classForm");
await send("Network.emulateNetworkConditions", { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
await fill("classForm", { name: "Offline Draft" });
await evaluate('document.getElementById("classForm").requestSubmit()');
await waitFor(async () => (await status()) === "error", "visible save failure");
assert.ok(!(await savedState()).classes.some(item => item.name === "Offline Draft"));
await send("Network.emulateNetworkConditions", { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
await evaluate('document.getElementById("syncStatusRetry").click()');
await waitFor(async () => (await savedState()).classes.some(item => item.name === "Offline Draft"), "retry persisted draft");
await waitFor(async () => (await status()) === "saved", "confirmed saved status");
assert.deepEqual(errors, []);
console.log("PASS offline save failure, local draft retention, retry and confirmed saved status");
} finally {
socket?.close();
for (const child of children.reverse()) {
if (child.exitCode !== null || child.signalCode !== null) continue;
const exited = new Promise(resolve => child.once("exit", resolve));
child.kill();
await Promise.race([exited, pause(2000)]);
if (child.exitCode === null && child.signalCode === null) { child.kill("SIGKILL"); await Promise.race([exited, pause(1000)]); }
}
rmSync(temporary, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
}

View File

@@ -16,3 +16,4 @@ import "./training_views.test.js";
import "./session_replay.test.js";
import "./public_training_refresh.test.js";
import "./list_sorting.test.js";
import "./sync_status.test.js";

39
tests/sync_status.test.js Normal file
View File

@@ -0,0 +1,39 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getSyncStatus } from "../src/sync_status.js";
import { syncStateToBackendHelper } from "../src/runtime_services.js";
test("save status distinguishes dirty, saving, failed, connected and saved", () => {
assert.equal(getSyncStatus({}, false).kind, "connecting");
assert.equal(getSyncStatus({ available: true }, false).kind, "connected");
assert.equal(getSyncStatus({ available: true, lastSavedAt: "now" }, true).kind, "pending");
assert.equal(getSyncStatus({ pendingWrites: 1 }, true).kind, "saving");
assert.equal(getSyncStatus({ available: true, writeError: "failed" }, true).kind, "error");
assert.equal(getSyncStatus({ available: true, lastSavedAt: "now" }, false).kind, "saved");
});
test("offline and stale contact are not displayed as a successful live connection", () => {
assert.equal(getSyncStatus({ connectionChecked: true }, false).kind, "offline");
assert.equal(getSyncStatus({ available: true, lastContactAt: 1000 }, false, 17000).kind, "stale");
assert.equal(getSyncStatus({ available: true, lastContactAt: 1000 }, false, 2000).kind, "connected");
});
test("failed writes retain dirty state and a successful retry clears the error", async () => {
const previousFetch = globalThis.fetch;
const backend = {};
let dirty = true;
const deps = { backend, t: key => key, getBackendUrl: () => "http://test", buildPersistableState: () => ({}), getLocalStateVersion: () => 1, getSyncedStateVersion: () => 0, setSyncedStateVersion: () => {}, setLocalStateDirty: value => { dirty = value; } };
try {
globalThis.fetch = async () => { throw new Error("offline"); };
await syncStateToBackendHelper(deps);
assert.equal(dirty, true);
assert.equal(backend.pendingWrites, 0);
assert.ok(backend.writeError);
globalThis.fetch = async () => ({ ok: true });
await syncStateToBackendHelper(deps);
assert.equal(dirty, false);
assert.equal(backend.writeError, "");
assert.ok(backend.lastSavedAt);
assert.equal(backend.pendingWrites, 0);
} finally { globalThis.fetch = previousFetch; }
});