Files
Live_RC/ROADMAP.md
larssand b5707f92d1 feat(series): run each round from the series page
Step 3 becomes a rounds table: pick drivers inline (copy from the
previous round, add a late entry), set qualifying rounds/finals/max
cars per heat per round, see the status, and create qualifying and
finals from there. Qualifying heats are now split evenly so nobody
drives alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:55:36 +02:00

166 KiB
Raw Blame History

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. Priorities below are a working order, not release-date commitments.

Existing foundation

  • Race Day, preflight checks and heat queue.
  • Automatic SQLite backups and local restore command.
  • Isolated session replay.
  • Training statistics, personal mobile profiles and car-change markers.
  • Interactive list sorting and OBS display modes.
  • 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.

Fixed 2026-09-09: a fresh Windows install reported the server failing around backups. Root cause: createBackupService created the backups subdirectory once, synchronously, at server startup with no error handling - if that failed for any reason (no write permission, a path beyond Windows' MAX_PATH, a OneDrive/cloud-sync folder locking it, antivirus interference), the whole Node process crashed before the HTTP server even started listening, taking down timing and everything else with it, not just backups. The directory creation is now non-fatal and retried lazily on each backup attempt, with a clear "Backup directory is unavailable" error surfaced only to the backup action itself; a file rename right after db.backup() also now retries briefly on EBUSY/EPERM, since a freshly written file is sometimes still momentarily held by antivirus/cloud-sync scanning on Windows. Verified with a new unit test (tests/backup_service.test.js) that blocks directory creation via a read-only parent directory and asserts the service still constructs and lists successfully, and with a real spawned-server integration check (RC_TIMING_DATA_DIR pointed at a data dir where the backups path is pre-occupied by a plain file) confirming /api/health stays up and only the backup request itself returns a clear error. The exact filesystem condition on the reporting machine is still unconfirmed - this closes the crash-the-whole-server failure mode regardless of which specific cause it turns out to be.

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.

  • Add a repeatable browser smoke-test command with temporary database/profile.
  • Cover create/edit/save for classes, drivers and cars, including reload verification.
  • Verify register form drafts remain intact during background version polling.
  • Cover sponsor-event creation/editing and endurance race wizard/name edits with reload verification.
  • Cover team creation, membership changes, editing and deletion.
  • Cover Open Practice to team-race conversion, duration/follow-up edits and persisted lap limits.
  • Cover Race Day start/pause/resume/finish controls and switching to the next queued heat, with a simulated decoder socket (no real AMMC hardware).
  • Cover session-type-specific start sequences (heat/qualification/race/team_race countdown and audio) and other transitions beyond Open Practice.
  • Verify leaderboard/TV/team sorting heading alignment at 390/900/1600 px and preserved race positions.
  • Verify OBS views have no interactive sorting controls.
  • Cover actual popup launching and compact OBS layout automatically.
  • Add Windows and Linux CI runs for supported runtimes.

Verified 2026-09-09: the OBS overlay popup opens via a real click on the admin "Open OBS overlay" button (window.open, not a direct URL navigation), renders the compact race-info/standings split layout, and its clock advances live and freezes on pause without a manual reload - polling independently of the admin page (tests/browser_obs_popup.js). Found and fixed a real bug in the process: window.open(..., "noopener,noreferrer,...") always returns null in modern browsers even when the window opens successfully (WHATWG spec), which made the blocked-popup check in openPrintWindow() and openOverlayWindow() always fire - print/export (start lists, results, heat sheets, team reports) showed a false "popup blocked" error and never wrote any content, and the overlay popup's .focus() call was unreachable. Both now open without those flags (same-origin, first-party content, so no security loss).

Verified 2026-09-08: Race Day start/pause/resume/finish and next-heat selection pass end to end (tests/browser_race_day.js) against a simulated decoder socket, with native-dialog auto-accept added to the browser harness so an unexpected alert()/confirm() fails the test instead of hanging it. Found and fixed two real bugs in the process: Open Practice sessions on a track-mode (sponsor) event showed a ready/green preflight but were silently blocked from starting by a stricter assignment check; and the Race Day view never refreshed when the decoder connected, disconnected, or errored, so its Start button and preflight checklist could stay stale after a page load.

Verified 2026-09-10 (tests/browser_race_day_start_sequences.js): unlike Open Practice, heat/qualification/final/team_race sessions run the real countdown-light-audio sequence in src/race_audio.js before a session is allowed to transition to "running" - confirmed a heat session stays status: "ready" immediately after clicking Start, that the Start button visibly ticks down through real countdown digits, and that it only becomes running once the full sequence (countdown, 5-light step, start delay) completes. For all four types, confirmed the announced-event log shows drivers_ready then start_sequence (plus qualifying_start in between for qualification only, nowhere else), and that pause/resume/finish behave the same as they already do for Open Practice. Every effect/voice was silenced via raceAudio.events so the test depends only on the sequence's own timers, not on real audio/TTS playback completing in a headless browser with no sound hardware - kept the test both fast and independent of any audio asset actually loading.

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.

  • Show pending, saving, saved and failed state in the main interface.
  • Show last successful data load/save times.
  • Warn when the server connection is lost or its status becomes stale.
  • Offer manual retry for unsaved changes without discarding local edits.
  • Verify save failure/reconnection in a real browser.
  • Extend stale-data feedback to the personal mobile profile: last applied retrieval, 30-second stale warning, timeout and Retry without discarding loaded data.
  • Detect and explain a Backend URL/page-origin mismatch, the real cause behind an otherwise unexplained "Failed to fetch" on every sync and backup.
  • Review concurrent writes with two admin browsers open at once; protect live race results from being overwritten, and shrink how stale a browser's data can get before its next save. Reported 2026-09-09, fixed the same day.
  • Full per-field/optimistic-locking protection so no concurrent edit (not just live results) can silently overwrite another admin's - deferred, see the report below for why.

Fixed 2026-09-09: a real Windows install showed "Backup failed: Sync failed: Failed to fetch" and every other backend call failing the same way, while the server itself was healthy (confirmed via Invoke-WebRequest returning 200 for /api/health, /api/state and /api/backups) - only the browser's own fetch() calls failed. Root cause: the page was opened via the LAN IP (http://192.168.1.100:8081) while the persisted Settings "Backend URL" still said http://127.0.0.1:8081 from an earlier session. Browsers treat those as different origins even on the same machine, so the server's CORS check (server.js, comparing the request's Origin header against its own Host header) correctly rejected it - but nothing told the user why, since a CORS-rejected response surfaces to fetch() as a generic "Failed to fetch" with no further detail, and a command-line tool like Invoke-WebRequest doesn't send an Origin header at all, so it never reproduced the symptom. The Settings page now compares window.location.origin against the configured Backend URL on every render and, when they differ, shows a plain-language warning naming the page's actual address plus a one-click "Use <address>" button that fixes it immediately. Verified with a headless-browser check (seeding a deliberately mismatched Backend URL, confirming the warning text names the real origin, and confirming the fix button both clears the warning and persists the corrected value to the backend) against an isolated temp SQLite instance.

Reported and fixed 2026-09-09: investigated whether two admin browsers open at once can overwrite each other's changes, per an explicit request to report the risk before making bigger changes. Findings: POST /api/state (server.js) always replaces the entire stored state with whatever the client's in-memory copy was - no version check, no per-field merge. A browser only ever pushes; it never re-fetches server state after its initial page load, except the Overlay view, which already refreshes every 800ms. So the longer any other view sits open without a save, the staler its copy gets, and its next save - even one editing something unrelated, like a driver's name - pushes that entire stale snapshot, silently reverting any changes another browser made in the meantime to drivers, classes, cars, events, sessions, settings, training data, activeSessionId and resultsBySession alike. The worst case: an idle browser's save reverting another browser's live, in-progress race results. Two things are already safe from this: the raw lap-crossing log (the separate, append-only passings SQLite table) can't be lost this way, and the mobile driver self-service endpoints (markers, "Jämna varv", "Stegträning") already read-modify-write just their own slice server-side rather than accepting a client's full blob.

Fixed with two scoped, low-risk changes rather than the bigger optimistic-locking rework (deferred - it needs real UX decisions about what a rejected/conflicting save should look like to the user, across every saveState() call site):

  1. Protect live results specifically (server.js): the server now remembers, in memory, which resultsBySession[sessionId] entries were just updated by a real live decoder passing (/api/passings, the only route driven by actual timing hardware). For 15 seconds after that, an incoming POST /api/state whose body disagrees with that session's just-written result has that one key overwritten back to the server's copy before being saved - every other field in the same save still goes through normally. Verified with an integration test (tests/server_persistence.integration.test.js) simulating exactly the idle-browser-reverts-live-race scenario, using a shortened protection window via RC_RESULT_PROTECTION_WINDOW_MS to keep the test fast, plus a check that the protection expires and a later legitimate change to that same session still takes effect.
  2. Shrink the staleness window everywhere else (src/runtime_services.js): every non-Overlay view now silently re-fetches server state in the background every 15 seconds (skipped while there's an unsynced local edit pending, same guard Overlay already uses) - without ever calling renderView(), so it can never discard an open, unsaved edit form's draft. This doesn't touch Overlay's own faster, render-driven refresh at all. Verified with a headless-browser check: an open, unsaved driver-name edit survives a full 17-second background-hydration cycle untouched, and a different, unrelated save from that same browser afterward is confirmed to carry a driver-name change a "second browser" made in the meantime, instead of the original stale page-load copy.

This closes the highest-impact case (losing live race data) and cuts the realistic exposure window for everything else from potentially hours to about 15 seconds. It does not prevent two admins from overwriting the exact same non-race field within that same ~15-second window - that needs the full optimistic-locking approach, tracked as a separate, deliberately deferred item above.

Implemented: decoder/socket diagnostics

Acceptance: each configured channel distinguishes an open socket from actual received data and successfully parsed passing messages. Inspecting diagnostics must not interrupt timing or rerender an active settings form.

  • Show Main, Split, Pit entry and Pit exit separately, with the configured WebSocket URL.
  • Show socket state, last connection, last received message and elapsed time since reception.
  • Count received frames, recognized passing messages (not accepted laps), ignored messages and parse errors per channel.
  • Add a bounded live message sample/log with pause, clear and copy controls.
  • Show why an incoming passing was ignored or rejected without changing lap validation.
  • Add simulated-socket tests for receiving, parsing, disconnecting and open status.
  • Verify automatic reconnect against a simulated decoder/network interruption (raw socket drop, not a clean close), checking for duplicate connections and correct reconnect-timer behavior.
  • Document IP/port/channel setup and the difference between decoder connection and incoming data.

Verified 2026-09-08: 128 logic/API tests and browser smoke tests passed, including mobile profile retention during network loss and recovery via Retry.

Verified 2026-09-09: a browser test (tests/browser_decoder_reconnect.js) drops a real TCP connection to a simulated decoder socket and confirms the frontend detects it, waits for the ~2s reconnect timer in src/decoder_runtime.js, and settles on exactly one live connection afterward - never two at once. Disabling auto-reconnect through the real Settings form (not a raw state patch, which never reaches an already-loaded page) correctly stops further retries after a drop. Reconnecting against physically real AMMC hardware remains a separate hardware-in-the-loop check this suite cannot cover.

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. Implemented 2026-09-09.

  • Add an end-of-practice summary: pace, consistency, progress and achieved goals. Triggered the moment the personal 5-minute timer on the mobile profile finishes ("Passet är klart") - a "Pass avslutat" card appears immediately alongside the existing finish cue.
  • Suggest one next training goal using existing calculations. Reuses the existing pace-based recommendation (buildTrainingRecommendation/getPublicTrainingRecommendation); falls back to the next lap-count milestone when there isn't yet enough data for a pace recommendation.
  • Keep summaries separate by class and explain insufficient sample sizes. Scoped to the driver's currently selected class, same as the rest of the page; explicitly explains when there aren't enough laps (this pass, or overall) to judge pace or consistency, rather than showing a misleading number.
  • Include the summary in the personal mobile profile. It only exists there - the admin Training page remains the always-current dashboard it already was.
  • Add coach/parent reward follow-up without turning practice into a public ranking. Decided 2026-09-09 - rather than a new notification mechanism, when a goal is reached during the pass its existing reward text is surfaced prominently in the summary ("Du nådde X - be din coach/förälder om: [belöning]"), still private to the driver's own share link, never a cross-driver comparison.

Verified 2026-09-09: unit tests (tests/public_training_profile.test.js) cover the new per-session consistency calculation added to buildPublicTrainingWindowStats (enough-laps vs. too-few-laps cases, with a hand-verified spread value). A real headless-browser check (tests/browser_* scratch pattern, isolated temp SQLite) seeded a driver mid-pass with a goal and a lap-count milestone both crossing exactly during the session, confirmed the summary reports the faster pace, the tight consistency, the goal, its reward text, and the milestone all correctly, confirmed a "Stäng" click dismisses it and the dismissal survives a reload of the same session, and confirmed a later, different session shows its own summary again. Found and fixed a real bug in the process: triggering the summary immediately (rather than waiting for the next background poll) called renderProfile() from inside updateTimerDisplay(), which itself calls back into updateTimerDisplay() - the reentrancy guard was updated after that call instead of before, so the stale guard let the finished-phase branch fire again on every reentry, recursing until the browser's call stack overflowed. A second headless check with a near-empty driver history confirmed the "not enough data" messaging renders cleanly instead.

Guided practice exercises

Acceptance: two structured, configurable exercises run inside an already-active Open Practice session, per driver and class, without stopping other drivers or creating a new session. Named for what they help a driver do, not for who supposedly likes them - "Jämna varv" ("Even laps", formerly "Konsistens-jakten") and "Stegträning" ("Step training", formerly "Pyramiden") - and focused on succeeding rather than performing perfectly. Available both from the admin Training page and from the driver's own personal mobile profile link, so the driver can configure, start and stop their own exercise independently of whether an admin session has it open.

Jämna varv (even laps): implemented 2026-09-09.

  • Driver configures target lap time, tolerance and lap count; suggested default 5 laps / ±0.5s - 15 laps / 0.2s is frustrating for a beginner, but stays adjustable for anyone who wants it stricter.
  • Offer a suggested target derived from the driver's own personal best (best lap × 1.05) when enough data exists, shown as the form's placeholder and a hint, never forced.
  • Tolerance is ± around the target lap time: every lap in the streak must land within target ± tolerance, not a spread between the streak's fastest and slowest lap. Decided 2026-09-09 - simpler to understand and to show live ("hit the target"), and matches how a beginner already thinks about it.
  • Show the current streak and today's best streak live, e.g. "4/5 even laps - Best streak today: 7".
  • A lap outside tolerance (or one excluded from pace, e.g. a pit stop) ends the current streak but never erases prior progress; messaged as "New streak - try to beat your best", not as a failure.

Verified 2026-09-09: buildEvenLapsProgress in src/training_logic.js (unit-tested for streak tracking, tolerance, and exclusion handling) plus a manual browser check of the live card, config form and streak reset messaging inside an active Open Practice session. Found and fixed two real bugs in the process: syncTrainingLedger rebuilt state.training on every save without carrying the new evenLaps field through, silently discarding every saved configuration; and the Training page crashed for any driver with fewer than 3 laps in every stint (a very common state - e.g. anyone in their first couple of laps), because a dedup filter read .id on a null "best stint" entry.

Stegträning (step training): implemented 2026-09-09.

  • Configurable steps: lap count plus an instruction per step (calm / controlled / fast). The form offers the classic pyramid (calm/controlled/fast/controlled/calm, 5 laps each) as an editable starting point; a step's lap count can be set to 0 to skip it, so shorter or longer sequences are both possible.
  • Show the current step and remaining laps in that step, plus a pill per step showing progress and highlighting the current one.
  • Treat calm/controlled/fast as perceived effort shown to the driver, not an exact lap-time target the system enforces - there is no reliable way to measure how hard a driver is pushing.
  • Compare the two controlled-pace steps afterward without automatically labeling a slower second step as fatigue: shown as a plain pace difference ("Controlled step 1: 0:21.100 · Controlled step 2: 0:22.000 (+0.90s)"), generalized to the first and last "controlled" step in the driver's own configuration rather than assuming exactly the pyramid's two.

Verified 2026-09-09: buildStepTrainingProgress in src/training_logic.js (unit-tested for step advancement, pace-exclusion handling, and the controlled-step comparison) plus a manual browser check covering the default pyramid, mid-exercise progress, reconfiguring to a shorter sequence, and the completed-sequence comparison text.

Mobile profile availability: implemented 2026-09-09.

  • Both exercises can be configured, started and stopped directly from the driver's own personal mobile profile link (public-training.html), the same way the existing self-service practice timer already works there - independent of whether an admin has the Training page open.
  • Progress (current streak/step, today's best, tolerance-based lap coloring, step pills) is shown live on the mobile page itself, computed server-side from the driver's own share token so raw lap rows and driver IDs never reach the client - only sanitized aggregates, matching the existing public-profile security boundary.
  • The exercise's own elapsed time is tracked independently of the general 5-minute practice timer and of each other (separate evenLapsStartedAgoMs/stepTrainingStartedAgoMs, mirroring the existing startedAgoMs clock-skew-safe pattern) so starting/stopping one does not affect the other or the general timer.

Verified 2026-09-09: unit tests in tests/public_training_profile.test.js cover savePublicEvenLapsConfig/savePublicStepTrainingConfig (auth via share token, class-membership checks, invalid-input rejection) and the sanitized progress builders, including an explicit assertion that driver IDs never appear in the serialized response. A full end-to-end headless-browser run against an isolated temp SQLite instance (never the installation database) exercised the real mobile page: saving a config via the new POST /public-training/api/:shareToken/even-laps and /step-training endpoints, starting/stopping via the UI buttons, and confirming live progress (streak count, tolerance-based lap coloring, step pills) renders correctly from seeded lap data - with zero uncaught JS errors and zero raw driverId/transponder leakage in the API response.

Exercise session history, completion credit, and session-count goals: implemented 2026-09-09, per a follow-up request - the driver wanted to look back at guided-exercise/practice sessions they'd started and see improvement over time, get explicit recognition for a completed session (not just ongoing lap/pace stats), and be able to set a reward goal based on "complete N sessions" the same way a lap-count goal already works.

  • Completing any of the three self-service sessions (the general timer, "Jämna varv", "Stegträning") is auto-detected client-side and recorded as a permanent history entry - visible on both the driver's mobile profile ("Träningshistorik") and the admin Training page, so a coach can review it too.
  • A completion is verified server-side against the real ledger laps for that run, exactly the way live progress already is - the client can only ask for a completion to be recorded, never supply the outcome. A driver cannot fabricate a completed session (and therefore a reward) any more than they could fabricate a lap time.
  • A completed session earns a prominent, emoji-badged credit ("Du har genomfört 1 träningspass idag 🪜") shown first in the existing credits list, plus its own history row per completed session (kind, date, outcome summary).
  • Both per-driver goals and the club-wide shared goal can now target "N completed sessions of one exercise kind" as an alternative to "N laps", sharing the exact same reward/claim mechanism, editable from the same forms.

Verified 2026-09-09: unit tests across tests/training_logic.test.js (the new exerciseSessions log's normalization/dedup, countExerciseSessions/countDriverExerciseSessions, both goal-progress functions' new sessions branch) and tests/public_training_profile.test.js (server-side completion verification rejecting an unearned claim for all three kinds, accepting a genuine one, refusing a duplicate for the same run, and confirming driverId/classId never appear in the client-facing history). Two real bugs found and fixed while building this: ensureSessionResult-adjacent getTrainingGoalProgress had a pre-existing two-argument wrapper in app.js that silently dropped the new third exerciseSessionCount argument, so every sessions-metric goal displayed 0 progress regardless of real completions, until a real-browser check caught the mismatch between a debug log (computed correctly) and the rendered card (stuck at 0); and the club-goal creation form's fields were unreachable in a real click test because its <details> disclosure needed to be opened first, same as the per-driver goal form's. A full headless-browser run against an isolated temp SQLite instance covered: the mobile page auto-detecting and recording a real step-training completion with zero extra clicks, the credit and history section both rendering correctly, a second reload not duplicating the recorded session, and the admin Training page correctly computing goal progress and creating both a per-driver and a club sessions-metric goal through their real forms.

4. Results archive

Implemented 2026-09-09.

  • Search completed sessions by date, class and driver without Race Setup. New "Resultatarkiv" nav item lists every session with status: "finished" across all events, filterable by free text (event/session name), class, driver and a date range - none of which required knowing which event a session belonged to first, unlike every existing navigation path.
  • Show standings, lap history and penalties together. Reuses the existing single-session leaderboard builder (buildLeaderboard) for standings and getCompetitorPassings for a per-driver lap-by-lap breakdown (the same functions the live Timing view uses) rather than new logic; penalties/corrections (resultsBySession[id].adjustments) are shown in a session-wide list - previously visible nowhere outside the live judging action log.
  • Reuse existing PDF/export functions from the archive. No new export format was built: the archive's "Export PDF"/"Export race package (JSON)" buttons call the same per-event exportRaceResultsPdf/buildRacePackagePayload functions already used from Event Manager, since no "PDF of one session" export existed or was needed - the roadmap explicitly asked to reuse, not add a new format.
  • Define what remains available after deleting a race. Decided 2026-09-09, after confirming a real gap: training statistics and session replay both already read from their own independent, deliberately-retained copies (state.training.ledger, resultsBySession[id].passings) that survive event/session deletion by existing design - deleting a race does not and did not affect them. The one thing that previously outlived deletion with no purpose was the raw passings SQLite rows (write-only; nothing in the app reads them), which would otherwise grow forever, unreachable, still holding driver IDs indefinitely. A new POST /api/passings/purge endpoint removes exactly the deleted session(s)' rows, called from both delete-event and delete-session as a best-effort side call alongside the existing local state removal.

Verified 2026-09-09: unit tests (tests/server_persistence.integration.test.js) cover the purge endpoint (scoped deletion, an unrelated session's rows untouched, empty-input no-op) and (tests/race_timing_logic.test.js) a real bug found while building this: ensureSessionResult guarded adjustments/splits/pitEvents/pitStops against being missing on an already-existing result (e.g. an older/imported race package) but not passings itself, so opening a session's lap-history detail crashed outright for any such result - now guarded the same way as the other four. A full headless-browser run against an isolated temp SQLite instance seeded a finished session with two competitors and a penalty, confirmed all four filters (search, class, driver, date) correctly include/exclude it, confirmed the detail panel shows standings/penalties/lap-history correctly, and confirmed deleting the event through the real Events UI actually purges its session's raw passing rows from SQLite end to end.

Extended 2026-09-09: while updating the in-app Guide page to document this session's new features (see Documentation maintenance below), found that "Rensa data" (Settings -> Clear data) for the drivers/cars/races/all scopes has the exact same gap the "define what remains after deleting a race" bullet above already fixed for individual event/session deletion - all four scopes reset resultsBySession for every current session, not just ones tied to the cleared scope, so every session's raw passing rows became orphaned at once. clearDataScope now purges those same sessions' rows too, using the same /api/passings/purge endpoint. Verified with a headless-browser run: clearing race data through the real Settings UI empties resultsBySession and confirms the raw passing rows for the affected session are gone from SQLite afterward.

5. More natural race speech

Deferred 2026-09-09 at the user's request - requires a paid third-party neural TTS provider (API account/credentials) the user hasn't chosen yet, unlike everything else on this list. Revisit once a provider is picked.

Implemented 2026-09-27 without any paid service, per the user: better voice, no purchase, must work with no internet (or when it's briefly gone), Swedish and English from the start. Solved with Piper, the same free local neural TTS already used for the video guides, running on the server.

  • Optional backend-generated neural speech. server/tts.js (new) runs Piper per line, one generation at a time (a model load is CPU-heavy and must never starve the timing server), caches each line as a WAV under data/tts/, and dedupes identical in-flight requests. POST /api/tts/speech (a POST so it falls under the admin PIN), GET /api/tts/status. Voice picked per language from any installed <lang>_<REGION>-<name>-<quality>.onnx + .onnx.json, preferring medium quality.
  • Swedish and English out of the box, on Linux and Windows. npm run tts:install / Settings -> Audio -> "Install" (server/tts_install.js, scripts/install-piper.js) downloads the standalone Piper release for the OS (Linux x64/arm64, Windows x64, macOS) plus sv_SE-nst-medium and en_US-lessac-medium into <project>/piper/ (gitignored). Needs internet only while it runs; everything is offline afterwards. The standalone C++ build needs no Python and generates a new line in ~0.3 s here (vs ~1 s for the pipx Python package), so even dynamic lines (lap times, names) are fast enough live.
  • Cache announcements and driver names before race day: "Pre-generate phrases and driver names" renders every fixed phrase from the sound pack's voice_events (sv + en) and every driver's spoken name in both languages.
  • Keep credentials on the backend - not applicable any more: there are no credentials at all.
  • Invalidate cached speech when language, voice or pronunciation changes: the cache key is voice + speed + exact text, so a new voice, speed or speechName simply maps to a new file.
  • Provide status and a pre-generate action: Settings -> Audio -> "Voice engine" panel (src/tts_settings_view.js) shows whether Piper and each language's voice are installed, the cached line count, and install/pre-generate/test buttons with live progress.
  • Verify offline fallback and that playback never delays timing: src/piper_speech.js plays the server's WAV; if Piper is missing, answers an error, or takes longer than 2.5 s, the line is spoken by the browser voice instead - no announcement is lost, and it's all async, off the timing path. Hooked into the single shared speakTextHelper, so both the legacy speaker cues and the race-audio engine use it. Browser voice stays the default until someone picks "Local voice".

Verified 2026-09-27: tests/tts.test.js (voice discovery/picking, project-installed Piper preferred over PATH, one generation at a time, caching, in-flight dedupe, speed -> length scale, clear errors, pre-generate counts) and tests/piper_speech.test.js (fallback on 503, on a 2.5 s timeout, successful playback with volume/onEnd, busy refusal, interrupt cancels without the cancelled line falling back and talking over the new one). An integration test runs a real server against a fake Piper binary: WAV served, second request cached, 400/503 on bad input/missing language, 401 once the admin PIN is on. tests/browser_tts_settings.js: panel shows sv/en status, the engine choice persists across a reload. A real end-to-end run with the actual installer output (downloaded Piper + both voices) in a real browser: "Test the voice" played Piper audio 0.4 s after the click with no browser-voice fallback, and pre-generation produced 60 lines with 0 failures. The Windows zip's layout (piper/piper.exe) was checked against what the server looks for, but the installer hasn't been run on an actual Windows machine yet.

Follow-up 2026-09-27: male or female voice per language

Asked by the user right after confirming Piper works on both Linux and Windows: how to switch between a male and a female voice. Until then only one voice per language was installed and picked automatically.

  • Checked genders instead of assuming them from the voice names: generated the same sentence with each candidate and measured the median pitch (male ~85-155 Hz, female ~165-255 Hz). Swedish nst 81 Hz (male), lisa 184 Hz and alma 187 Hz (female); English ryan 152 Hz (male), lessac 208 Hz (female).
  • The installer's voice catalog (server/tts_install.js) now has one male and one female voice per language, each with a gender and a label. Defaults chosen by the user: male on both (NST, Ryan).
  • pickVoice uses the voice chosen in Settings if it's installed and speaks that language, otherwise the catalog default, then any medium voice. The choice travels as voice in POST /api/tts/speech; the cache is already keyed per voice, so switching never replays the other voice's lines.
  • Settings -> Audio -> Voice engine: a voice selector per language ("NST (male)", "Lisa (female)", ...) with its own Test button that always plays the Piper voice, never the browser fallback, so picking and hearing a voice can't be confused. New settings ttsVoiceSv / ttsVoiceEn, added to all three settings allowlists (anything not listed there is silently dropped on load). Pre-generation uses the chosen voices.

Verified 2026-09-27: unit tests cover catalog gender labels, the chosen voice winning, a chosen voice from the wrong language or no longer installed falling back, and the same text in another voice being a separate cached line. The integration test now checks that the chosen voice reaches the Piper process through the real server - and while updating it, found the test depended on the developer's own machine (it picked up voices from ~/.local/share/piper-voices), fixed by giving the test server its own empty home directory. A real browser run switched the Swedish voice from automatic to Lisa and measured the audio actually played: 80 Hz -> 182 Hz, and the choice was saved on the server.

6. Permissions and mobile Race Control

Deferred 2026-09-09 at the user's request - the largest remaining item, on par with or bigger than the full optimistic-locking rework already deferred under section 2. Today there is no login or authorization at all: anyone reaching the server on the network has full admin access. Building this needs an identity system that doesn't exist yet (no user accounts, no sessions), server-side authorization on every mutating endpoint (not just hiding UI controls), and a genuinely new "compact mobile Race Control" surface (start/stop, track call, flags, next heat) - plus a real decision on how login should work in practice on a shared trackside laptop (PIN codes per role? named accounts? offline/LAN-only requirement?) before any of it can be designed, let alone built. Revisit as its own dedicated design conversation.

The user framed the actual threat model clearly: this normally runs on a trusted local race-day LAN, so a random internet attacker isn't the realistic concern - but someone else on that same LAN/venue WiFi being able to open the admin UI and interfere with a live race (stop a session, change results, trigger the unauthenticated /api/update/run restart from the audit follow-up above) is a real, low-effort risk worth closing. That framing should drive the design: simple enough for volunteers on a shared trackside laptop, not an internet-grade identity system.

  • PIN codes per role, not named accounts. One shared PIN each for Admin, Race Director, Speaker, and Read-only (matching the capabilities list below) - a race club hands a laptop between whoever's running the desk that night; individual logins would just add friction nobody wants. Entering a PIN issues a signed, long-lived token (HMAC'd server-side, no session store needed - a secret already has to live somewhere, e.g. a value in data/, generated on first run) stored as a cookie, so it survives normal use for weeks and specifically survives the server restarting (relevant since the update checker and a power blip both restart the process mid-event).
  • IP/host allowlist as a separate, complementary layer, per the user's own question - not instead of the PIN, in addition to it: an optional RC_ADMIN_ALLOWED_CIDRS env var (same shape as the existing RC_ALLOWED_ORIGINS) that, when set, rejects admin requests from outside the listed ranges before the PIN even matters. Left unset by default - forcing IP configuration would break the simple "clone it and it just works" flow this app has always had, but a club that wants belt-and-suspenders on their venue's fixed subnet can turn it on.
  • 2FA: explicitly not part of this. Only relevant if a club deliberately exposes the server past their own LAN (port-forwarded for a remote stream day, say) - a real but separate scenario from "someone else at the track." If that ever comes up, the natural shape is requiring a TOTP code on top of the PIN specifically for requests arriving from outside the allowlisted ranges, not a blanket requirement that would slow down the normal trackside case for no benefit. Not planned unless a club actually asks for internet-facing access.
  • Server-side enforcement on every mutating endpoint, not just hiding buttons (this was already the plan below) - a small role-to-endpoint capability map, in the same spirit as the MANAGE_TAB_TARGETS-style lookup tables already used elsewhere in this codebase rather than scattering checks ad hoc through server.js.
  • Public overlays and training links are unaffected - they're already gated per-share-token independently of admin auth (see the security audit's scope notes) and are meant to be shared outside the operator group; nothing about this design changes that.
  • Decided 2026-09-24 by the user: off by default (today's zero-friction behavior), and a single Admin PIN first - per-role PINs come later.

Implemented 2026-09-24: v1 - one optional Admin PIN

  • server/auth.js (new): PIN hashed with scrypt (salted) and stored in data/auth.json, deliberately outside the app_state blob so it can never leak through GET /api/state, an export or a backup; an HMAC-signed, HttpOnly/SameSite=Strict cookie valid 30 days, signed with a per-installation secret in data/auth_secret - survives server restarts (the updater and power blips restart the process mid-event), and changing the PIN invalidates every earlier cookie. Failed logins are throttled (10 per 5 minutes per client address).
  • Middleware in server.js, registered right after the CORS middleware: once a PIN is set, every non-GET /api/* request plus GET /api/ammc/config (it exposes the AMMC executable path) needs the cookie - including POST /api/update/run, which closes the unauthenticated restart trigger from the 2026-09-12 audit follow-up. Other reads stay open on purpose: the public overlay hydrates through GET /api/state, so closing reads needs the sanitized public-overlay API first. Public overlay/training routes are unaffected. New routes: GET /api/auth/status, POST /api/auth/login, /logout, /pin, /disable.
  • Optional RC_ADMIN_ALLOWED_CIDRS (IPv4 CIDRs and/or exact addresses, comma-separated) applies to the same protected routes independently of the PIN - it works even with no PIN set.
  • Frontend: src/auth_view.js (new) wraps fetch once at startup so any 401 auth_required opens a PIN dialog; after a correct PIN the pending save is retried automatically. Settings -> System & backup has a new "Admin PIN" panel to turn protection on, change the PIN, log out, or turn it off.

Verified 2026-09-24: tests/auth.test.js (hashing, token expiry/tampering/PIN-change invalidation, protected vs open routes, throttling, CIDR matching) - it caught a real flaw while being written: Buffer.from(hex) silently drops a trailing odd character, so a signature with junk appended was accepted until verifyToken got a strict format check. An integration test against a real server confirms writes are open by default, rejected without the cookie once a PIN is set (including /api/update/run and changing the PIN), reads stay open, and the PIN hash never appears in GET /api/state. tests/browser_auth.js drives the real UI: enable PIN, log out, a normal edit opens the dialog, a wrong PIN is rejected, the right one lets the pending edit through, then turns protection off again. 212 unit/integration tests and 39 browser scenarios pass. Not covered yet: cross-origin UIs via RC_ALLOWED_ORIGINS (the cookie is same-origin only), and per-role PINs.

  • Define Admin, Race Director, Speaker and Read-only capabilities.
  • Enforce permissions on server endpoints, not only in the UI (v1: one Admin PIN for all writes - per-role enforcement still to do).
  • 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.

7. Track layout changes and stat continuity

Reported 2026-09-10 by the user: an indoor club running in the same physical building often reconfigures the track layout (different wall/gate placement) while everything else - the venue, the class, the drivers - stays the same. Nothing in the app knew a layout change happened, so lap-time comparisons silently treated before- and after-the-change laps as if they were run on the same track.

Design settled 2026-09-10 through a short back-and-forth with the user:

  • Scope: global to the installation (a layout change is a whole-venue event), not per class.
  • Layouts are contiguous - a club changes maybe every ~4 weeks and never switches back and forth - so only a start date/time is stored per layout ({ id, label, startedAt }); a layout's end is implicitly the next one's start, with no separate stop field to keep in sync. The label defaults to "Layout N" but can be renamed.
  • A session freezes which layout was active at the moment it started (not when it was created, and never recomputed later), so editing the layout list afterwards can't silently reclassify old results.
  • Driver/transponder mapping needs no changes at all when a layout changes - unlike an earlier system the user described, transponder-to-driver assignment in RC RaceCore was already fully independent of track geometry.

Implemented 2026-09-10 (src/track_layouts_logic.js, new):

  • Settings has a new "Banlayouter" panel to add a layout (name + start date/time, validated to be after the latest existing layout's start) and list/delete existing ones - deleting is blocked if any session already references it.
  • Every session is stamped with trackLayoutId when it starts (commitStart() in src/app.js), covering every session type including Open Practice, since practice sessions feed the track-record calculation too.
  • The "track_record" audio cue (getTrackRecordMs in src/timing_logic.js) now only compares a live lap against other sessions tagged with the same layout. With no layouts configured, every session's trackLayoutId is "", so the comparison is unscoped exactly as before - the feature is fully inert until a club actually adds a layout.
  • The Results Archive shows which layout each session ran on, both in the session list and the detail view's header badge.

Verified 2026-09-10: unit tests (tests/track_layouts_logic.test.js) cover normalization/sorting, the contiguous "layout active at time X" lookup at its boundaries, and getTrackRecordMs's layout scoping (including that a layout-tagged session is never conflated with an untagged legacy one). A full headless-browser run against an isolated temp SQLite instance added two layouts through the real Settings UI (confirming the default "Layout 1" label, the custom-label case, and that a backdated second layout is rejected with a clear message), started a session and confirmed it was stamped with whichever layout was active at that moment, confirmed the Results Archive shows the correct layout in both the list and the detail header, and confirmed deleting a layout still in use is blocked while deleting an unused one succeeds.

Extended 2026-09-10, per the user pointing out this matters most for Open Practice/training use, not races: the mobile driver training profile's own cross-session personal-best, pace-baseline and consistency statistics (buildDriverTrainingStats/buildTrainingStintAnalysis in src/training_logic.js, and the duplicate server-side implementation in server/public_training_profile.js that actually powers the public mobile link) were still comparing across a driver's entire lap ledger regardless of layout. Both now split every stat into two groups: lifetime/commitment numbers (total laps, total sessions, training days, calendar history, milestone badges) still read the full history - a track rebuild doesn't erase past practice - while every pace/record/consistency number (personal best, recent-average trend, consistency spread, the "stable pace" control zone, stint pace/streak achievements, "best stint") now reads only laps tagged with whichever layout is active right now. Each ledger entry is tagged with trackLayoutId at the moment it's synced from a session's passings (syncTrainingLedger), so it survives the source session later being deleted, the same way sessionName/eventName already do. Left deliberately unscoped, as lower-risk trend visualizations rather than single "your record is X" claims: the pace-bucket and weekday charts, marker before/after comparisons (narrow time windows, virtually always same-layout in practice), and the split/pit-stop timing-channel stats (track-geometry-independent for pit stops; splits are a smaller, less-used feature left for a future pass if needed).

Verified 2026-09-10: new unit tests in tests/training_logic.test.js and tests/public_training_profile.test.js cover a driver with laps on two different layouts, confirming lifetime totals count every lap while personal-best/consistency numbers only reflect the current layout, and that buildTrainingStintAnalysis's "best stint" pick never surfaces a faster stint from an old, incomparable layout. A full headless-browser run seeded a driver with a fast old-layout lap and a slower current-layout lap, then confirmed both the admin Training page and the real public mobile profile page show the slower current-layout lap as "Best lap" (never the faster but incomparable old one) while still showing the correct full lifetime lap count in both places.

Follow-up 2026-09-27: switching layout mid-season, and stats per layout

Reported by the user: they remembered being able to create Layout 1, 2, 3, 4 for Open Practice to get stats per layout - but couldn't find where to switch to a new one, and wanted both overall training stats and a per-layout view. Three real gaps, found by checking the code rather than assuming:

  1. Hard to find: layouts were only managed under Settings -> Appearance & overlay, where nobody looks for track layouts.
  2. No per-layout view: pace/best/consistency were hard-wired to the active layout; lifetime totals to all history. No way to look at an older layout or at everything together, even though every practice lap was already stored with its layout.
  3. Didn't fit how the user actually runs practice (their follow-up: "the same Open Practice all season, just switching layout"): a session's layout was frozen when it started, and every training lap inherited it - so in one season-long Open Practice every lap would have stayed on the first layout forever.

Also clarified by the user mid-work: layouts belong to practice, never to a race format.

  • Per-lap layout tagging (syncTrainingLedger, src/training_logic.js): each training lap gets the layout active when it was driven, not when its session started. Switching layout in a running Open Practice splits its laps correctly, no restart needed; the running practice session itself also moves onto the new layout. The race track-record cue (getTrackRecordMs) is unchanged - it compares whole other sessions and never a session's own laps.
  • Practice-only rules (src/track_layouts_logic.js): findRaceSessionBlockingLayoutChange refuses a new layout while a race-format session (anything but open/free practice - including a structured event's "practice" slot) is running or paused, with a message naming it; applies everywhere, Settings included. isTrackLayoutInUse also counts practice laps, so a layout that only received laps through a mid-session switch can't be deleted and silently shift those laps to the previous layout.
  • "New layout" where you drive (src/track_layout_bar.js, new): a "Track layout: Layout 3 - since 12 Sep [New layout]" strip on Training stats (always), Race Day and the Dashboard (button only when the session in focus is Open/Free Practice or there is none). One prompt for the name - blank gives the next "Layout N" - active from now.
  • Stats for: current layout / an older layout / no layout (before the first) / All layouts on Training stats (training-layout) and in the driver's mobile profile (profileLayout, ?layout= on GET /public-training/api/:shareToken, which now also returns trackLayouts: { selected, currentId, options[{id,label}], hasUntaggedLaps }). The choice drives best lap, pace, consistency, stints and the driver comparison; lifetime totals always cover every lap. buildDriverTrainingStats/buildTrainingStintAnalysis take { layout }, and the server copy in server/public_training_profile.js mirrors it. Found on the way: the buildDriverTrainingStats wrapper in app.js passed on only three arguments, which would have silently dropped the layout choice.

Verified 2026-09-27: unit tests cover an Open Practice spanning a layout switch (laps split 2+2 despite the session being stamped with the first layout), each layout choice's best lap and lap selection, lifetime totals unaffected, "none", the practice-only blocking rules, the in-use rule counting practice laps, and the public profile's layout choice/fallback/option list. tests/browser_track_layouts.js drives the real UI: New layout on Training stats creates "Layout 1" and the selector appears, Race Day hides the button for a qualifying session and shows it for Open Practice, and a running qualifying session blocks a new layout with a message naming it. A real browser run with a driver's laps on two layouts showed the right best lap for every choice in both admin and the mobile profile: current (Layout 2) 0:16.100, Layout 1 0:12.000, All layouts 0:12.000.

PDF export character encoding

Fixed 2026-09-09, reported while exporting an old race from the new Results archive - correctly suspected as a general PDF export bug, not specific to that one feature. buildSimplePdf in server.js (the shared renderer behind every PDF export: race results, start lists, heat sheets, team reports, and the archive) declared its font with no /Encoding at all, so viewers fell back to the standard font's built-in encoding, which has no å/ä/ö glyphs - and separately, the content stream was written as UTF-8, so even a correctly-encoded font would have received the wrong bytes (a multi-byte UTF-8 sequence where a single WinAnsi byte was expected), the classic "åäö" mojibake pattern. Fixed by declaring /Encoding /WinAnsiEncoding and writing the content stream through a proper WinAnsi (~CP1252) byte mapper instead of UTF-8 - Swedish å/ä/ö/Å/Ä/Ö map directly to their Latin-1 byte values, with the handful of CP1252 "smart punctuation" code points (curly quotes, en/em dash, €, etc.) mapped explicitly; anything else falls back to ? rather than corrupting the byte stream.

Verified 2026-09-09: a new integration test (tests/server_persistence.integration.test.js) exports a PDF with a Swedish title and a table row containing å/ä/ö/Å/Ä/Ö, asserts the font declares WinAnsiEncoding, asserts the correct single-byte WinAnsi sequence is present in the raw PDF bytes, and explicitly asserts the old UTF-8 byte sequence is not present - confirmed this test genuinely fails against the pre-fix code (not a tautology) by re-running it with the fix reverted.

Team Race roster duplicate-membership bug

Reported 2026-09-10 by the user: creating a team, adding a driver to it by mistake, then "moving" that driver to a different team by ticking them on the new team's edit form left the driver on both teams at once. Investigated before fixing, per the user's own account of the exact steps that triggered it.

Root cause: there was never a "move driver to another team" action - the only way to reassign someone was to tick their checkbox on the new team's edit form (event_manager_controller.js's teamForm/teamEditForm submit handlers). That handler only ever wrote the team being saved; it never checked whether the driver/car was already listed on a different team, so ticking them there added them without ever removing them from wherever they already were. Consequence found while investigating: findEventTeamForPassing (event_race_logic.js) resolves a team by the first array match, so a driver on two teams had every one of their live laps silently credited to only one of them - the other team got nothing, with no error surfaced anywhere except a misleading "duplicate transponders" preflight warning (and only when the driver actually had a transponder set).

Two related bugs surfaced in the same code while investigating, at the user's request to fix everything found:

  • getTeamDriverPool (event_race_logic.js) had been reduced, in an earlier change, to unconditionally return every driver in the club regardless of the event's class or configured participants - the class/participant-scoped filtering (with a documented fallback to "show everyone" only when the scoped list is empty) existed in an earlier version of this function and had been silently dropped.
  • Deleting a driver or car (core_views.js) cleaned up session assignments but never removed them from any team's roster, so a deleted driver/car stayed a permanent "member" of whichever teams they'd been added to, inflating team member counts forever.

Fixed:

  • reassignTeamMembers (event_race_logic.js, new) strips a driver/car being saved onto one team out of every other team in the same event - both the create-team and edit-team submit handlers now call it before writing, so ticking a driver onto a new team genuinely moves them rather than duplicating them.
  • The team edit/create checkbox list now shows "Redan i <team> - kryssar du i flyttas den hit" next to a driver/car already on a different team, so the move is visible before saving, not just after.
  • getTeamDriverPool restored to scope by the event's class/configured participants first, falling back to every driver only when that scoped list is empty (so a team can still be set up before participants exist, without silently showing the whole club's roster once they do).
  • Deleting a driver or car now also removes them from every team's driverIds/carIds, and from a session's driverIds/manualGridIds, alongside the existing assignment cleanup.
  • Added a duplicate_team_members Race Day preflight check specifically for team_race sessions, naming the driver/car by name - defense in depth for any state saved before this fix, so it's diagnosed accurately instead of surfacing as a confusing "duplicate transponders" error (or nothing at all, if the shared driver has no transponder).

Verified 2026-09-10: new unit tests (tests/race_timing_logic.test.js) cover reassignTeamMembers (strips from other teams, leaves the team being saved and non-overlapping teams untouched) and getTeamDriverPool's scoped-then-fallback behavior; new tests (tests/race_day_logic.test.js) cover the duplicate_team_members preflight check both tripping (naming the shared driver) and staying clear when teams don't overlap. A new permanent browser test (tests/browser_team_membership.js) reproduces the user's exact reported scenario end to end against a real race-mode event with a team_race session: creates Team A with Driver A, creates Team B with Driver B, opens Team B's edit form and confirms the "already on Team A" warning appears before saving, ticks Driver A onto Team B and saves, confirms Driver A is gone from Team A's roster and present on Team B's (the reported bug, now fixed), then deletes Driver B entirely and confirms they're gone from Team B's roster too.

In-app update checker and one-click updater

Implemented 2026-09-09, requested by the user: an easy way to see a new version is available in the GUI, and an easy way to update for someone not comfortable with git pull on their own.

  • Show whether a newer version is available, with the actual version number. GET /api/update-status shells out to the git binary already required for this deployment model (git fetch, git rev-parse, git show origin/<branch>:package.json) rather than any git-hosting-specific API, so it works the same against GitHub, GitLab or a self-hosted remote. Result is cached for 10 minutes (force=1 bypasses it) so opening Settings repeatedly doesn't hammer the remote. Settings now shows the installed version, and - only when an update is genuinely available - the new version number read from the remote's package.json, not just a commit hash.
  • One-click update, no git knowledge required. An "Uppdatera nu"/"Update now" button POSTs to /api/update/run, which spawns the existing scripts/serverctl.js with a new update command: stop -> git pull --ff-only (refuses and restarts on the code already installed rather than merging/rewriting history if the local branch has diverged - a trackside laptop is not a place to leave a merge conflict for someone to resolve) -> npm install (warns and continues on failure rather than leaving the server down) -> start, every step logged to logs/update.log. The confirmation dialog before starting adds an explicit extra warning if a race/session is currently active, since the update stops the server.
  • Auto-reload once the server is back. Deliberately built no new client-side polling for this: the app already re-polls /api/app-version every 5 seconds and reloads the page once it detects the running server's process identity changed (revision:updatedAt) - exactly what a restart-with-new-code produces - so the existing mechanism picks this up for free.
  • Surface it outside Settings too. Added 2026-09-10 after the user pointed out the update card was easy to miss buried in Settings: the sidebar, right next to the existing version label, now shows a small "Ny version vX.Y.Z - uppdatera" link whenever an update is available (reusing the same updateStatus already fetched at startup, no new endpoint) - visible on every view, not only Settings - and clicking it jumps straight into Settings with the update ready to run.

Verified 2026-09-10 (sidebar link): a full headless-browser run against a throwaway git clone confirmed the sidebar link appears on its own from the app's automatic startup check, without ever visiting Settings first, that it correctly names the real new version, that clicking it lands on the actual Settings page with the update ready to run there, and that it stays visible after navigating back to another view (not just a one-off render inside Settings).

Verified 2026-09-09: a new integration test (tests/server_persistence.integration.test.js) builds a throwaway local bare git repo plus two clones (standing in for "this installation" and "somewhere a newer commit was pushed"), confirms available: false initially, pushes a version bump from the second clone, confirms available: true with the correct remoteVersion read from its package.json, and confirms the 10-minute cache is respected until force=1. A full headless-browser run went further than any test this session before it: cloned the real project into a throwaway git working copy (own bare "remote", own temporary SQLite data dir - the real installation and its git history were never touched), started the real server from that clone, pushed a version bump to the throwaway remote from a second clone, then in a real browser opened Settings, saw "Version 9.9.9 finns tillgänglig", clicked "Uppdatera nu", accepted the confirmation dialog, and confirmed end-to-end: the server actually stopped, git pull --ff-only fast-forwarded, npm install ran, the server came back up on the same port serving the new code, and the open browser tab auto-reloaded on its own and displayed the new version - with zero uncaught JS exceptions throughout.

Security note added 2026-09-12 (see docs/security-performance-audit.md's follow-up review): the "active race" confirmation dialog described above is a client-side UX safeguard against an accidental click, not a security control - POST /api/update/run itself has no server-side authentication or confirmation, same as every other admin endpoint (see "Permissions and mobile Race Control" below), but is worth flagging on its own since it's a direct stop-the-server primitive rather than a data-exposure one. No code change made; tracked as part of the deferred permissions work.

Race flow correctness: qualifying, finals, bump-up, judging, pause timing

Reported 2026-09-10 by the user, asking specifically about practice/qualifying/heats/finals, bump-up, judging, pause/start, timers and audio. A thorough read-only audit of the actual code (not assumption) found several real, concrete bugs across this area - fixed together, at the user's request ("don't want those bugs").

Qualifying reseed (reseedUpcomingQualifying in src/event_race_logic.js):

  • Heats were sorted by comparing the full session name as text ("H10" sorts before "H2" lexicographically), scrambling the grid for any round with 10+ heats. Now sorts by the numeric heat number the naming regex already captured but was discarding.
  • If the participant count changed since a round was generated (drivers added or removed), the extra drivers were silently dropped, or an existing heat was left with an empty "ghost" grid - with no indication anything was wrong. Reseed now still updates whichever heats line up, but reports a clear warning naming how many rounds no longer match, telling the admin to regenerate instead of trusting a partial reseed.
  • The function's own return shape was inconsistent (a bare 0 on one early-return path, {updated, locked} on the normal path) - the caller silently read undefined in that case. Now always returns the same shape.

A driver with zero laps in a heat or final (DNS) used to vanish entirely rather than showing as a real, last-place result - not just from that round's standings, but silently from every future round they'd otherwise be reseeded into, and from the final grid entirely, because buildQualifyingStandings/buildFinalStandings (src/timing_logic.js) only ever saw drivers who had at least one recorded passing. Both now backfill a driver assigned to a finished heat/final who never appears in its results as an explicit "Ej start (DNS)" row, ranked last - a still-running or not-yet-started session's assigned drivers are correctly left alone, since they simply haven't raced yet.

Bump-up (generateFinalsForRace/applyBumpsForRace in src/event_race_logic.js):

  • With "reserve bump slots" turned off, bump-up did nothing at all, silently. Mains are seeded to full capacity when slots aren't reserved, and the old code only ever let a bumped driver in if the main had room below its own capacity - which it never did. Bump-up now reads the event's actual reserve-slots setting to decide whether to cap the main at its nominal size (reserved) or simply grow it by the bumped driver(s) (not reserved), instead of conflating "never reserved any room" with "already used up the room that was reserved."
  • A promoted driver stayed gridded in the lower main's still-upcoming leg(s) at the same time as their new, higher main - the same kind of double-entry the team-roster bug had, for multi-leg finals. Bump-up now also removes a promoted driver from the lower main's remaining (not-yet-run) legs; the leg that already produced the promotion decision keeps its real historical result untouched.
  • An oversized bump count (relative to the final's size) could seed as few as one driver per main, and with a large enough field that ran the A/B/C... main-letter naming past "Z" into unrelated punctuation characters - which then all silently collapsed onto main "A" when standings were read back. bumpCount is now clamped below the final size, and main width is additionally widened as needed so a generation run never produces more than 26 mains.

Judging (applyCompetitorCorrection/undoJudgingAdjustment in src/judging_logic.js): undoing a manual correction restored an absolute snapshot of the value from just before that specific correction was first applied, rather than reversing only that correction's own contribution. Two +5s penalties undone out of order (the first one undone first) wiped out both penalties on the first undo, then silently reinstated one of them on the second undo - leaving the driver with a live penalty behind two log entries both marked "undone", no warning anywhere. Undo now reverses each correction's own recorded delta from whatever the current cumulative value is, which is correct regardless of undo order.

Pause timing:

  • A competitor's own elapsed/comparison time (buildLeaderboard, getCompetitorElapsedMs in src/timing_logic.js) didn't exclude paused wall-clock time, even though the session's own countdown already did (getSessionTiming). A heat that had a track call wasn't comparable to one that didn't for qualifying ranking purposes, even though nobody actually lost race time to the stoppage. Fixed by subtracting the session's accumulated pause time from a competitor's elapsed time too - correct as long as passings aren't recorded while paused, which they aren't (the decoder already ignores them: session.status !== "running" is checked before any passing is processed).
  • Finishing a session while it was paused (finishSession in src/session_control.js) never folded that final, still-open pause segment into totalPausedMs the way resuming does - so a finished session's displayed elapsed time kept growing after finish, using how long ago it finished rather than how long it actually ran. getSessionTiming also now freezes a finished session's clock at endedAt rather than the live clock, so simply reopening or leaving a finished session's timing view open no longer shows a number that keeps climbing.

Also checked and confirmed already correct, no changes needed: the countdown/light start sequence's cancellation (no stuck "ready" state), the one_minute_remaining/thirty_seconds_remaining/ten_seconds_remaining/final_lap audio cues (each fires exactly once per start regardless of pauses, since they're gated on elapsedMs, which already excluded pause time correctly), and multi-cycle pause/resume accumulation and pause-during-follow-up math.

Not built in this pass, out of scope for a bug-fixing sweep: there is no disqualification/stop-and-go/drive-through judging action at all today (only lap/time corrections and invalidation) - the corresponding audio cues exist in RACE_AUDIO_EVENT_DEFINITIONS but nothing in the app ever triggers them. That's a missing feature, not a bug, and would need its own design pass.

Verified 2026-09-10: new unit tests cover every fix above - tests/race_timing_logic.test.js (numeric heat sort, field-size mismatch reporting, DNS backfill in both qualifying and final standings without ever inventing a fake 0ms "fastest lap", the reserve/non-reserve bump-up paths, the 26-main cap, pause exclusion in buildLeaderboard/getCompetitorElapsedMs, the frozen finished-session clock), a new tests/session_control.test.js (previously zero coverage for this file - pause/resume accumulation, follow-up pause, and the finish-while-paused fold-in), and tests/judging_logic.test.js (undoing two penalties in both orders, confirming the net always reaches zero). A full headless-browser run against a real, isolated server confirmed both the judging undo fix (two +5s penalties applied via the real Judging UI, undone out of order through the real undo buttons, correctly netting to +5000ms after the first undo and exactly 0 after both) and the bump-up fix (a real Event Manager "Apply bumps" click, with reserveBumpSlots off, actually grew the upper final's grid instead of silently doing nothing) end to end.

Follow-up questions from the user, checked against the actual code and confirmed already correct (no changes needed):

  • Practice "best 3 laps" / "best result within 5 minutes" (getBestConsecutiveTrainingLaps, getBestTrainingDurationResult in src/training_logic.js) is computed per driver, independently, from that driver's own lap timestamps after the fact - there's no shared clock, so multiple drivers practicing at once each get their own best-window result correctly, exactly as described.
  • Heats are seeded by speed from practice results (buildPracticeStandings sorts fastest-first, generateQualifyingForRace/reseedUpcomingQualifying chunk that sorted list in order) - Heat 1 is always the fastest group, Heat 2 the next, and so on. Proven with a concrete existing test (7 drivers, 3 per heat -> heat 1 gets the 3 fastest, heat 3 gets the single slowest, alone).
  • carsPerHeat/carsPerFinal are freely adjustable (plain number fields, no hidden constraints) - e.g. 12 drivers at carsPerHeat: 4 cleanly makes 3 heats of 4, and setting carsPerFinal to the full field size (or to a fraction of it) is the existing, direct way to choose "just one A final" vs "A + B", since the number of finals is derived from field size ÷ carsPerFinal rather than being a separate setting.
  • A genuinely per-driver rolling "your own 5 minutes starts at your first crossing" qualifying heat clock (as opposed to practice, where this already works) is not fully supported - the "staggered" start mode gives each driver their own reference point for lap-time math, but the heat's own auto-finish timer is still one shared clock from when the admin pressed Start. Flagged to the user as a separate, deliberately unbuilt gap, not fixed here.

Moving a driver between qualifying heats

Requested 2026-09-10 by the user, after confirming heats seed by speed correctly: with an odd field size (e.g. 7 drivers at 3 per heat), the last heat can end up with a single lone driver - and there was no way to manually move them into another heat. Investigated first: neither "Reseed" (redistributes every not-yet-run heat by current standings, not a targeted single-driver move) nor the grid editor (reorders start position within one heat, never moves a driver between heats) could do this - there was genuinely no path to it in the UI.

  • moveDriversIntoQualifyingHeat (src/event_race_logic.js, new) - the same "checking them elsewhere is the move" pattern already used for team rosters. Editing a qualification session's driver roster (a new checklist added to the existing session edit form, shown only for type: "qualification") now pulls a checked driver out of whichever other heat they're currently on. Scoped deliberately to the same round only ("Q2 H2" never touches "Q1"/"Q3"), since heat composition can legitimately differ round to round after a reseed - an admin fixing one round's lone-driver heat doesn't get other rounds silently reshuffled.
  • The checklist shows "Redan i {heat} - kryssar du i flyttas den hit" next to a driver already gridded elsewhere in the same round, so the move is visible before saving, not just after (mirrors the team-roster warning).
  • Extracted parseQualifyingHeatName (parsing a generated session's "Q<round> H<heat>" name) as a shared helper, now used by both the reseed function and this new one, rather than duplicating the regex a second time.

Verified 2026-09-10: new unit tests (tests/race_timing_logic.test.js) cover parseQualifyingHeatName and moveDriversIntoQualifyingHeat (moves a lone driver out of their old heat, leaves a different round's same-numbered heat untouched, and is a no-op for a session with no parseable heat name). A full headless-browser run against a real, isolated server reproduced the exact reported scenario end to end: generated real qualifying heats for 7 drivers at 3 per heat through the actual "Generate qualifying" button, confirmed heat 3 had exactly one lone driver, opened heat 2's edit form and confirmed it named "Q1 H3" as a warning before saving, ticked the driver in and saved, and confirmed heat 2 gained them while heat 3 became empty - no duplicate entry, nobody left alone.

Settings page: tabbed layout instead of one long scroll

Requested 2026-09-10 by the user: the Settings view stacked all eleven panel sections (decoder, race sounds, legacy audio, branding, track layouts, OBS overlay, managed AMMC, updates, storage/backup, clear data, race presets) in one column, making anything past the first couple of sections a long scroll to reach.

  • Added a small tab bar (src/settings.js) grouping the existing sections into four tabs, with no change to any section's own markup, ids, or event-binding logic - only wrapper <div data-settings-panel="..."> elements were added around existing content, and one out-of-order section (Managed AMMC) was assigned to the Connection tab via a second wrapper further down the page rather than physically moved, so no markup had to be reordered:
    • Anslutning / Connection - decoder setup + role cards + backend connection + decoder diagnostics + Managed AMMC.
    • Ljud / Audio - race sounds + legacy audio.
    • Utseende & overlay / Appearance & overlay - branding, track layouts, OBS overlay config.
    • System & backup - updates, storage/backup/export-import, clear data (danger zone), race presets.
  • The active tab is held in module-scoped state (activeSettingsTab in src/settings.js), not the URL, so it survives the periodic background re-renders the settings view already gets (update-status polling, decoder diagnostics) without snapping back to the first tab, and persists across switching to another view and back within the same session.
  • Tab styling reuses the existing sidebar nav's visual language (.nav-item/.nav-item.active in src/styles.css) via new .settings-tab/.settings-tab.active classes, kept as a sticky bar at the top of the view so it stays reachable while scrolling a long tab's content.

Verified 2026-09-10: a full headless-browser check against a real, isolated server confirmed exactly 4 tabs render, only the Connection tab's panels (including the out-of-order Managed AMMC section) are visible by default, clicking each tab shows that tab's controls and hides the others (decoder form, race-sounds table, track layouts, OBS config, storage/export, clear-data buttons, race presets all checked by id), every previously-existing control id is still present in the DOM after the restructure (nothing dropped), and the chosen tab survives switching to another view via the app's real sidebar navigation and back. The full existing tests:browser suite (all pre-existing browser regression coverage, including the decoder-settings test that already exercises the wsUrl field inside the now-tabbed Connection panel) was also re-run and passed unchanged.

Race-audio "Test" button was silently saving to the database

Reported 2026-09-10 by the user, noticed right after the settings-tabs restructure: clicking a "Test" button next to the race-sound settings (the general one, or a per-event one in the sound table) showed the app's own "Ändringar sparade" (changes saved) status - a plain sound preview shouldn't write to the database at all.

  • Root cause (src/settings.js): testRaceAudio (used by both settingsRaceAudioTest and every [data-test-race-audio] button) called persistRaceAudioFormValues, which both applies the form's current values to state.settings.raceAudio and calls saveState() (localStorage + a scheduled backend/SQLite sync). Reading the form's current, possibly-still-unsaved values was the point (so a test plays what you're about to save), but persisting them wasn't supposed to be part of that. The legacy (non-race-audio) test buttons never had this problem - they already only applied form values in memory (applyAudioFormValues, no saveState() call).
  • Split persistRaceAudioFormValues into applyRaceAudioFormValuesFromDom (apply only, in memory) and persistRaceAudioFormValues (apply + saveState(), unchanged for its existing callers). testRaceAudio now calls only the apply-only variant. The explicit "Save" button and the auto-save-on-change listener on every race-audio field are untouched and still persist as before - only the "Test" buttons stopped writing to the database.

Verified 2026-09-10: a headless-browser check against a real, isolated server confirmed both the general race-sounds "Test" button and a per-event "Test" button in the sound table no longer issue a POST /api/state (watched over the real network layer, not just state inspection), while the "Save" button and directly editing a race-audio field (e.g. toggling mute) both still do, exactly as before.

Race clock rendered as a malformed doubled-up time

Found 2026-09-10 while building realistic seeded demo data for the documentation screenshots below - the very first time in this repo's history that a screenshot was taken with an actual, real, multi-driver result on screen (every earlier demo/test fixture only ever had empty "No laps yet" sessions, so this was invisible until now).

  • formatRaceClock (src/app.js) computed both centiseconds (2-digit) and millis (3-digit) from the same elapsed value and concatenated both into the returned string - ${m}:${s}.${centiseconds}:${millis} - producing a garbled result like 02:48.20:207 everywhere a race clock is shown (Timing, Overlay/leaderboard-overlay, Results archive, Team Race stint logs, Training's stint duration column). Only the milliseconds portion was ever meant to be there, matching the sibling formatLap helper's format; the centiseconds computation and its extra : separator were dead, wrong code present since this function's first commit. Fixed to ${m}:${s}.${millis} (e.g. 02:48.207).

Verified 2026-09-10: the full unit test suite (193 tests) and the full browser regression suite passed unchanged (the function had no prior coverage - the existing tests only ever exercised stubbed versions of it - which is exactly how this shipped unnoticed). Visually confirmed fixed via the live-rendered Overlay, Results archive/Training stint tables, and Judging views in the regenerated screenshots below, all now showing a clean time instead of the doubled-up one.

Documentation: refreshed and expanded screenshots

Requested 2026-09-10 by the user: "create screenshots and update the images in the repo, and maybe add some more important images for a new user, so they get a good overview and understand how it needs to be set up." Every existing screenshot in docs/screenshots/ predated this whole session's work and, on inspection, was captured against essentially empty demo data (0 cars, "No laps yet", a broken multi-day-old "running" clock) - not representative of what the app actually looks like in use, and not helpful for a new user trying to picture their own setup.

  • Seeded a realistic demo dataset (via a scratch script, run only against an isolated temp server/SQLite dir - never the real installation) into an isolated headless-browser session: 2 classes, 10 drivers, 8 cars, one race event ("Klubbmästare Race 4") with 2 rounds of qualifying heats and A/B finals all carrying real recorded lap results, a live "running" A Final, and 4 days of varied practice history for one driver (for Training stats' weekday/pace-block/stint-analysis breakdowns).
  • Regenerated all 9 previously-referenced screenshots against this real data instead of an empty session: overview-active-session.jpg, race-setup-wizard.jpg, overlay.jpg, leaderboard-overlay.jpg, training-overview.jpg, training-extra-stats.jpg, judging-current.jpg (now shows a real manual +5s correction applied through the actual Judging UI), race-audio-settings.jpg, decoder-settings.jpg (now shows the new tabbed Settings layout from the earlier entry above).
  • Added two new screenshots aimed specifically at a new user's setup, filling gaps nothing previously covered: managed-ammc.jpg (the Managed AMMC configuration section - the actual first thing a new user configures, previously never pictured at all) and guide.jpg (the in-app Guide/documentation page, so a new user knows it exists before hunting for the README).
  • Deleted 6 stale, already-unreferenced screenshots left over from an earlier README revision (event.jpg, judging.jpg, overview.jpg, race-setup.jpg, team-overlay.jpg, timing.jpg).
  • Updated both README.md and README.sv.md's screenshot tables: filled the previously-empty cell with Managed AMMC and added a new row for the Guide page, with matching Swedish alt text/captions in README.sv.md.
  • Redacted incidental local-machine details that don't belong in a public screenshot before capturing (the dev machine's resolved AMMC executable path, and the ephemeral test server's random localhost ports) by overwriting just those specific form fields/text nodes in the DOM right before each shot - cosmetic only, never saved back to any backend.
  • While building this data, found and fixed a real, previously-invisible formatting bug (see the entry directly above) purely as a side effect of finally exercising the app with real results for the first time.

Follow-up 2026-09-12: guide.jpg had gone stale again, and Event Manager's new tabs were never pictured

Requested 2026-09-12 by the user alongside the documentation pass below: make sure the screenshots match the layouts shipped this session. Checked every screenshot against the current UI first rather than assuming - decoder-settings.jpg, race-audio-settings.jpg and managed-ammc.jpg (all Settings-tab shots) were already current, since Settings' tabs predate this session and were captured fresh for the entry above. Only two were actually stale/missing:

  • guide.jpg still showed the pre-Schedule-feature, pre-video-guides Guide page (6 overview cards, no "Video guides" section) - recaptured.
  • Event Manager's new tabbed layout (this session's own work) had never been pictured at all - added race-manage-tabs.jpg, scrolled to the tab bar with "Live & results" active (grid editor + qualifying standings visible), and a new row in both README screenshot tables.
  • Built scripts/capture-screenshots.mjs (new, reusable) rather than a throwaway script this time, since screenshot refreshes are clearly a recurring need - same isolated-server-plus-headless-Chrome pattern as the video recorder and test suite, sharing one seeded demo dataset (JMK RB, "Klubbmästare Race 4", matching the existing screenshots' branding) across every shot so future captures stay visually consistent with each other.

Verified 2026-09-12: visually inspected both new captures at full resolution (3200x1800, matching every other screenshot in the folder) - guide.jpg shows all 8 overview cards and the Video guides section with all 7 working links; race-manage-tabs.jpg clearly shows the 4-tab bar with real grid/standings content beneath it. Waited for the settings save the seeded decoder connection triggers to finish before capturing (syncStatus reaching "saved"), so neither shot shows a stray "Changes waiting to save" banner mid-capture the way an early attempt did.

Race clock direction: counts down for timed sessions, up for open practice

Asked 2026-09-10 by the user: when running a race, should the clock count down from the target duration to 0 (not up from 0)? Only open practice should just roll on with no limit. Checked against the actual code (getSessionTiming in src/timing_logic.js, and its use in the Timing view, Race Day view, and all Overlay pages): already correct, no changes made.

  • Only open_practice is treated as untimed (isUntimedSession) - its clock shows "Elapsed" and counts up with no limit.
  • Every other session type (qualification, heat, final, team_race, and the event-scoped "practice"/"free_practice") gets a targetMs from durationMin, and the displayed clock is targetMs - elapsedMs labeled "Remaining" - a countdown to 0, consistently in Timing, Race Day, and every Overlay variant (they all share the same sessionTiming.untimed branch).
  • Already covered by an existing test (tests/race_timing_logic.test.js, "session timing handles untimed practice, countdown, and follow-up"), and directly confirmed visually in the newly regenerated leaderboard-overlay.jpg screenshot above, where a running 8-minute final's clock reads "04:56" after roughly 3 minutes elapsed.

Wrong transponder registered to a driver (belongs to someone in a different class)

Asked 2026-09-10 by the user: if a driver is registered with the wrong transponder - one that's actually assigned to someone else, e.g. a driver in a different class, because of a registration mistake - is it quick and easy to notice and fix? And confirmed already correct: a driver who isn't entered in a heat but drives anyway must not have their laps recorded in that heat at all.

Investigated and confirmed already correct, no changes needed:

  • A driver whose registered transponder is picked up in a session they are NOT entered in (session.driverIds does not include them) is matched to { ignore: true } in resolveCompetitor (src/decoder_runtime.js) and the passing is dropped before anything is ever recorded (processDecoderMessageHelper returns immediately on competitor.ignore) - it never creates a competitor row, never appears in the leaderboard, nothing to clean up afterward. This also plays a "Unknown transponder" audio cue and logs an entry in Decoder Diagnostics in real time, so an attentive operator gets a live signal something is off.
  • src/race_day_logic.js already has a duplicate_transponders preflight check that blocks starting a session if two of ITS OWN entrants share an identical transponder.

Found a real gap while investigating, since the scenario as described (the OTHER driver holding that transponder is in a different class/event, not this session's entrant list) falls outside both of the above: nothing anywhere warned that a transponder being registered on one driver was already registered to a completely different driver, until it silently broke lap recording on race day. Editing a driver's transponder itself was always a plain, one-field form edit - trivial once you knew something was wrong - but nothing ever told you something was wrong in the first place.

  • findTransponderConflict (src/driver_profile_logic.js, new) finds another driver already holding the same transponder, scoped correctly: the same person's own multiple class entries (same profileId, e.g. one racer running both 2WD and 4WD with one physical chip) are never flagged, since that's the normal, expected case - only a collision with a genuinely different profile counts.
  • Wired into both the driver create form and the driver edit form (src/core_views.js): saving with a colliding transponder now shows a confirm dialog naming the other driver and their class (e.g. "Transponder 1000009 is already used by Marcus Dahl (4WD Buggy). Save anyway?") before it's written - cancelling aborts the save so the mistake can be corrected on the spot, confirming still allows it through for genuine cases (e.g. a transponder handed down from a retired driver).

Verified 2026-09-10: new unit tests (tests/driver_profile_logic.test.js) cover the conflict/no-conflict/self-edit/empty-transponder cases for findTransponderConflict directly. A full headless-browser run against a real, isolated server reproduced the exact reported scenario end to end through the real Drivers UI: created a 4WD driver with a transponder, then created a 2WD driver with the same transponder through the real create form - got the warning dialog naming the correct driver and class, confirmed through it, and confirmed the same warning fires from the driver edit form too when changing an existing driver's transponder into a collision. Also confirmed no false-positive warning when the same person's own second class entry legitimately reuses their own transponder.

Staggered start announcer, practice countdown cues, and per-driver "done" flag

Asked 2026-09-10 by the user, three related verification requests about start procedure and per-driver status:

  1. In a staggered-start qualifying heat, the app should announce each car's number in start order (~3s apart); a final should always start everyone together with the usual countdown and start sound.
  2. Free practice should announce "2 min left", "1 min left", and at the end "session ended, finish the lap you're on".
  3. In a staggered-start qualifying heat (individual ~5-minute windows per driver), the overlay/leaderboard should mark a driver "done" (green status or finish flag) once their own window has elapsed, even while the session is still running for others.

Investigated first, confirmed already correct, no changes needed:

  • Finals already always run the shared countdown → lights → start-tone sequence for everyone (runStartSequence in src/race_audio.js never varied by start mode before this work) - point 1's second half.
  • Free practice already gets "One minute remaining" / "Thirty seconds remaining" / "Ten seconds remaining" cues, and at time-up the voice line is literally "Time has expired. Finish the lap you are on." (time_expired in rc-race-sound-pack/sound_manifest.json) - already exactly what was asked for in point 2, except there is no "2 minutes remaining" cue (only 1 min/30s/10s exist). Flagged back to the user; not added since it wasn't confirmed as wanted over the existing ladder.

Found two genuine gaps and, after confirming the desired behavior with the user, built both:

  • Staggered release announcer (point 1's first half): runStartSequence (src/race_audio.js) now, immediately after the official GO/start-tone (only when session.startMode === "staggered"), calls out each car's transponder number in grid order (getSessionGridOrder, resolved to transponders in startSessionWithAudio in src/app.js since the audio engine itself has no access to state.drivers), waiting a real 3 seconds between each. A new staggered_release race-audio event was added ("Car {tp}." / "Bil {tp}." in the sound manifest) alongside the existing catalog. The session is already live and recording throughout - each driver's own personal timing clock already starts at their own first crossing regardless (pre-existing behavior) - this is purely the announcer telling marshals who goes next.
  • Per-driver "done" indicator (point 3): buildLeaderboard (src/timing_logic.js) now computes personalTimeUp per row - true once now - row.startTimestamp - totalPausedMs >= targetMs for a staggered-mode session with a real personal start reference (paused time excluded the same way the shared clock already excludes it). Rendered as a green "🏁 Done" pill next to the driver's name in the Timing leaderboard (src/timing_views.js), the standalone leaderboard/results/TV/speaker overlay (renderOverlayLeaderboard in src/overlays.js), and a plain 🏁 marker in both compact OBS layouts (lower-third and tower). Never flagged for mass/position starts, which already share one clock shown at the session level.

Verified 2026-09-10: new unit tests cover both - tests/race_audio.test.js (the staggered release sequence fires in grid order with real ~3s gaps only when startMode === "staggered", and never for a mass start even when a grid order is passed) and tests/race_timing_logic.test.js (personalTimeUp true/false cases, paused-time exclusion, never flagged outside staggered mode). A full headless-browser run against a real, isolated server reproduced the exact scenario end to end: started a real 3-driver staggered heat through the real Race Day "Start" button and confirmed all 3 transponders were announced in the session's actual grid order (not driver-creation order) with the real ~3-second gaps elapsing (not fired instantly), confirmed a mass-start final's sequence never calls out individual cars, then seeded one driver 6 minutes into their own 5-minute staggered window and another only 2 minutes in and confirmed the green "Done" pill appeared next to the first driver's name (and only that driver's) in both the real Timing leaderboard and the real standalone overlay page.

Staggered heat call-out order and shared clock, found live-testing the feature above

Reported 2026-09-10 by the user, right after testing the staggered-release announcer above with a real 4-driver, 2-heat qualifying setup: (1) the car call-outs did not follow the order the drivers appeared in "the list", and (2) the heat's shared timer just showed a flat 5:00 and didn't seem to count down - expected it to be sequential, with the last-released driver finishing around 5:20 or so per the timer.

  • Root cause of (1): getEventDrivers (src/event_race_logic.js) filtered the club's global drivers-directory array by membership in event.raceConfig.driverIds, but kept the directory's own creation order rather than the order the admin actually selected participants in - the two are unrelated whenever drivers weren't added to the club in the same order they were later picked for this event. This function feeds the fallback qualifying/finals grid (used whenever no practice data exists yet to seed from) and, through that, both the Start List display and the staggered announcer - so both looked "scrambled" relative to the actual entry list, but were still consistent with each other (not a separate mismatch bug). Fixed to preserve driverIds' own order, filtered down to drivers still valid for the event's class.
  • Root cause of (2), and confirmed (2)'s "timer not counting down" half did not reproduce - a live test showed the clock ticking down normally (05:09 → 05:08 → 05:07...) once the extension below was in place; the "started at 5:00" complaint was consistent with the flat, unextended base since the real issue was the missing extension, not a frozen clock. getSessionTiming (src/timing_logic.js) now widens a staggered session's own shared clock/auto-finish target by the full release schedule ((driverCount - 1) * staggerGapSec seconds, using the field generateQualifyingForRace already sets to 3s) on top of the plain duration - a 4-driver, 3-second-gap heat now shows and enforces 5:09, not a flat 5:00. Each competitor's own personal quota (getSessionTargetMs, used by buildLeaderboard's personalTimeUp from the entry above) is deliberately left untouched - only the session-level shared clock widens, not any individual driver's own allotted time.

Verified 2026-09-10: new unit tests cover both directly - tests/race_timing_logic.test.js (getEventDrivers follows selection order and drops stale/out-of-class ids; the staggered clock extension math for 4 drivers/3s gaps, a mass-start session with the same driver count staying unextended, and a solo staggered heat having no extension). A full headless-browser run reproduced the user's exact setup (4 drivers, participant order deliberately reversed from directory order, real "Generate qualifying" button, real Race Day "Start"): the Start List now correctly reads in the selected participant order, the announced transponder order matched it, and the live clock started at 05:09 and counted down normally instead of a flat, unextended 05:00.

Session picker grouped by event

Reported 2026-09-10 by the user: the "select session" dropdown (Timing) and the session replay picker turn into one very long, flat, hard-to-scan list once a club has generated several races' worth of practice + qualifying heats + finals.

  • groupSessionsByEventRecency (src/timing_views.js, new, view-local) groups sessions into <optgroup>s by their event, most-recently-created event first (state.events is already append-order), leaving each event's own sessions in their existing order. Applied to both activeSessionSelect and sessionReplaySelect; the event name moved from being repeated on every single option to the group label instead, shortening each option to just the session name and type.

Verified 2026-09-10 with a live headless-browser check against a real, isolated server: seeded 3 events with a mix of practice/qualifying/final sessions and confirmed the rendered <select> groups them into 3 correctly-labeled, newest-first <optgroup>s with each event's own sessions nested inside.

Time-of-day race day schedule

Requested 2026-09-10 by the user: when running a race day, should be able to set it up as time-scheduled - start Practice 1 at 12:34, and once it's done the next session (with drivers) is automatically set to start e.g. 3 minutes later at 12:42, and so on; must be pausable if something goes wrong, printable start lists, and a driver who's TQ in a qualifying heat but needs to request work time on their car should be able to start last in that heat instead.

Investigated first and confirmed two of the three pieces already existed, before building the missing one:

  • Printable/PDF start lists already existed (events.print_startlists/events.pdf_startlists in Event Manager).
  • Manually moving a driver to start last in their own heat already existed via the drag-and-drop grid editor (gridDragList in event_manager_controller.js, reordering session.manualGridIds) - already covers the "request mech time, start last" scenario without any new work.

Built the genuinely missing piece, confirmed with the user before starting given its size:

  • buildEventSchedule (src/event_race_logic.js, new) computes each race-mode session's planned clock time in running order (reusing the same getSessionSortWeight ordering the print/PDF output already uses: practice, then qualifying, then finals), cascading from a configured day-start time plus each session's own duration/follow-up plus a configurable gap between sessions. Once a session has actually started or finished, everything scheduled after it re-anchors to that real time instead of the original plan - a late start or a session that ran long (a paused session's real endedAt already reflects the full wall-clock time it took) automatically pushes the rest of the day forward with no separate "reschedule" action, which is what covers "must be able to pause" without any extra pause-specific schedule logic.
  • New "Schedule" panel in Event Manager (src/event_manager_view.js/event_manager_controller.js): a start-time + gap-between-sessions form, and (once set) a table of every session with its planned time range, its real start/end once it has one, and a "Delayed" flag for a session that started more than a minute late. New event.raceConfig.scheduleStartAt/scheduleGapMin fields, added to normalizeEvent's explicit field allowlist (an event's raceConfig is fully rebuilt from that allowlist on every save - any field not listed there is silently dropped, caught during this work before it shipped).
  • The scheduled time is now also shown on the printed/PDF start lists (buildRaceStartListsHtml in src/race_render_helpers.js) next to each session's name.

Found and fixed a real, previously-existing bug while building this, unrelated to the new feature but affecting the same ordering logic: getSessionSortWeight (src/race_render_helpers.js) used order[type] || 99 - since open_practice's own weight is 0, a falsy value, || silently replaced it with the 99 "unknown type" fallback, sorting practice sessions dead last (tied with genuinely unrecognized types) in the print/PDF start lists and results, instead of first. Fixed to ??.

Verified 2026-09-10: new unit tests cover getSessionSortWeight's fix directly, and buildEventSchedule's cascading math, the running-order sort, track-mode sessions being excluded, and the re-anchoring behavior for both a late-but-still-running session and a finished one whose real duration (including a pause) differed from the plan. A full headless-browser run against a real, isolated server reproduced the exact reported workflow: set a 12:34 start time with a 3-minute gap on a real Open Practice + Q1 H1, confirmed the schedule panel showed practice at 12:34-12:39 and qualifying at 12:42-12:47, confirmed the printed start list included "12:34" next to Open Practice, then made practice actually start 2 minutes late and confirmed the panel flagged it "Delayed" and qualifying's planned time shifted forward to 12:44 automatically.

Scheduled "Practice" sessions silently skipped the start ceremony

Reported 2026-09-10 by the user: starting a session in a sponsor event didn't count down like a normal race does. Investigation took a few rounds since the initial description ("says '10 seconds to start' and 'starting on the sound of the tone' but doesn't visibly count down") didn't match either of the two things actually tested:

  • A real headless-browser test of a sponsor-event heat (mass start, 10s countdown) showed the visible countdown ticking correctly (10, 9, 8...) - already working.
  • A real test of a session with type "practice" (the user's own guess at the cause - "träning", Swedish for practice) showed the opposite of the original complaint: the entire ceremony, audio included, was skipped in ~25ms and the session jumped straight to "running" - not "audio plays but visual doesn't move", but "nothing at all happens, instantly."

That second finding was real and worth fixing on its own even though it didn't match the original wording: confirmed with the user that a "Practice" session used as a scheduled slot in a structured race/sponsor event's session plan should get the same countdown/lights/start-tone ceremony as qualifying/heat/final - only the standalone "Quick Open Practice" shortcut (and "free_practice", both rolling/best-lap-ranked with no fixed roster) should still start instantly.

  • startSessionWithAudio (src/app.js) narrowed its ceremony-skip check from ["open_practice", "free_practice", "practice"] to just ["open_practice", "free_practice"]. "practice" sessions (built by the Race Setup wizard's own practice-session step, already ranked like a real timed session rather than rolling/best-lap like the other two practice types) now run the full runStartSequence.

Verified 2026-09-10: extended the existing permanent browser test (tests/browser_race_day_start_sequences.js) with a "practice"-type session run through the same full-ceremony assertions already used for qualification/final/team_race (real audio events logged, pause/resume/finish all work), plus a new explicit check that "open_practice" (the Quick Open Practice shortcut) still jumps straight to running with zero new audio events logged - locking in both sides of the distinction so this doesn't regress either direction. Full unit and browser regression suites re-run clean.

Event Manager: tabbed layout for race-mode events

Requested 2026-09-11 by the user, alongside a Guide update, as the second of two "make it easier for a new, non-technical person to use RaceCore" asks. Event Manager's race-mode page had grown into a long scroll: participants, teams, race format (practice/finals/rules + summary + generate/reseed/bump/export), live grid + four standings tables + channel analysis, and (since the schedule feature shipped this session) a Schedule panel plus the print/PDF Final Matrix - the same kind of length problem already fixed for the Settings page and the session picker earlier this session.

  • Wrapped the existing race-mode content (src/event_manager_view.js) into four <div data-manage-panel="..."> groups - setup (participants + teams), format (the whole race-format section, generation buttons included), live (grid editor + all standings + channel analysis), schedule (the Schedule panel + Final Matrix/print/PDF) - with a .manage-tabs bar to switch between them, mirroring the Settings tabs pattern (.manage-tab/.manage-tab.active, [data-manage-panel][hidden]). No section's own markup, ids, or event bindings moved - only wrapper divs were added, all contiguous, since this whole part of the page already lives inside a single event.mode === "race" conditional block.
  • The session-plan (create/list sessions) and branding sections, which apply to every event mode and sit above this block, stay always visible regardless of the active tab - as does the existing 4-card step overview (Setup/Format/Generate/Live), which is a permanent summary rather than a fifth tab.
  • The step-card overview's jump links and the race-summary's "fix this" warning links (both pre-existing, previously just scrollIntoView) now switch to the correct tab first via a small MANAGE_TAB_TARGETS lookup (src/event_manager_controller.js) - scrolling to an element hidden behind an inactive tab previously did nothing useful. The "Open Grid" button (session list) also now switches to the Live tab so the grid editor it opens is actually visible.
  • Track/sponsor events don't enter this conditional block at all (they only ever had session-plan, branding, sponsor tools and assignments - never the length problem), so they keep the exact same untabbed layout as before, unchanged.
  • Active tab is held in module-scoped state (activeManageTab in src/event_manager_controller.js, same approach as Settings' activeSettingsTab), so it survives re-renders from other actions on the page without snapping back to the first tab.

Verified 2026-09-11 with a live headless-browser check against a real, isolated server: confirmed the Setup tab is shown by default with the other three hidden; clicking each tab shows that tab's own content (format's generateQualifying button, live's grid editor, schedule's form and print buttons) while hiding the others; confirmed session-plan and branding stay visible on every tab; confirmed a step-card jump to a Live-tab target both switches to that tab and makes the target actually visible (not just scrolled-to-while-hidden); confirmed the Open Grid button switches to the Live tab; and confirmed a sponsor/track-mode event's manager page renders with zero .manage-tab elements, i.e. its layout is untouched. The full existing browser regression suite (23 tests, including the sponsor-event and race-wizard flows that exercise this same page) was also re-run and passed unchanged.

In-app Guide updated for this session's shipped features

Requested 2026-09-11 by the user as the first of two "make it easier for a new, non-technical person to use RaceCore" asks (the second is the Event Manager tabs entry above). The Guide (src/misc_views.js) hadn't been touched since several features shipped earlier this session, so it no longer reflected reality.

  • New "Race day time schedule" section (4 bullets) plus a matching overview card, documenting the new Schedule feature end to end: where to set it, how the cascade works, how it re-anchors to reality (including the "Delayed" flag), and that it prints on the start lists.
  • Two new bullets under Seeding/points tables/tie-break covering staggered start: each driver's own personal timing window and the green "Done" flag, and why the shared clock now extends for staggered heats.
  • A new bullet under Race sounds covering the staggered-start car call-out announcer, and a correction to an existing bullet that had gone stale this session - it previously said Open/Free Practice was the only thing that skipped the start ceremony, which stopped being true once "practice" sessions inside a structured event started getting the full sequence too.
  • A new bullet under Decoder roles noting Settings' new four-tab layout (Connection/Audio/Appearance & overlay/System & backup) and where the decoder settings documented in that section now live.
  • A new bullet under Manage race in four steps noting Event Manager's new tab layout, and a correction to the step-card bullet clarifying that clicking a card now switches tabs automatically, not just scrolls.
  • A new bullet under Race Day covering the driver transponder-conflict warning (added to Drivers earlier this session), framed as a complement to the existing preflight duplicate-transponder check it sits next to.

Verified 2026-09-11 with a live headless-browser check in both languages: confirmed the new Schedule section and overview card render with the correct title/content, confirmed no raw untranslated guide.* keys leaked onto the page in either language, and spot-checked the new staggered-done-flag, Settings-tabs, and Event-Manager-tabs bullets are all present and correctly translated.

Permanent regression tests for the three UI restructurings above

Requested 2026-09-11 by the user in response to "what's left in the tests, and what can be done to improve them" - the Settings tabs, Event Manager tabs and Schedule panel entries above had only been verified with throwaway scratch scripts during development, never with permanent coverage in npm run test:browser.

  • tests/browser_settings_tabs.js (new): Connection is the default tab with only its panel(s) visible; clicking each of the four tabs shows exactly its own panel and hides the rest; the selection survives an in-place re-render triggered by submitting settingsForm (requestSubmit()) instead of silently resetting to Connection - the actual failure mode activeSettingsTab being module-scoped state exists to prevent.
  • tests/browser_event_manager_tabs.js (new): Setup is the default manage tab with only its panel visible; clicking each of the four tabs shows exactly its own panel; the pre-existing quick-jump step cards and warning links (MANAGE_TAB_TARGETS) switch to the correct tab before their scrollIntoView, covering both a Format-tab target (manage-generation) and a Live-tab target (manage-live-results); and opening a session's grid editor (Open Grid button, in the always-visible session table above the tabs) switches to the Live tab via its own separate activeManageTab = "live" assignment.
  • tests/browser_event_schedule.js (new): the schedule form actually persists scheduleStartAt/scheduleGapMin onto event.raceConfig (the exact field this session's own investigation found normalizeEvent's allowlist was silently dropping before the fix); the table lists sessions in running order (practice before qualification) rather than seed order; a session started more than a minute after its planned time shows a real actual-start time and the "Delayed" badge; and the Clear button removes only the start time, leaving the configured gap untouched.
  • All three wired into tests/browser_smoke.js alongside the existing suite, sharing the one isolated server/browser instance the rest of the suite already spins up.

Verified 2026-09-11: npm run test:browser passes all 34 scenarios (11 new across the 3 new files, plus the pre-existing 23) against a real, isolated server and headless browser; npm test passes all 205 unit tests unchanged.

Recorded MKV video guide clips for new users

Suggested 2026-09-11 by the user, alongside the permanent-tests entry above: while writing the "real" tests, also produce an MKV video usable as a new-user video guide showing how to use the features. Confirmed scope with the user first: just the 3 UIs from that same entry (not a full onboarding walkthrough), as several short clips rather than one long video.

  • Validated the recording approach with a throwaway proof of concept before building anything permanent: Chrome DevTools Protocol's Page.startScreencast (JPEG frames over the existing CDP WebSocket connection the test harness already uses, each frame acknowledged via Page.screencastFrameAck or the stream stalls) piped through ffmpeg into H.264 MKV. Confirmed with ffprobe and a visual frame check that this produces a real, correctly-sized, playable video before investing further.
  • scripts/record-video-guides.mjs (new, permanent): a small reusable recorder built on the same isolated-server-plus-headless-Chrome pattern as tests/browser_smoke.js (own temp SQLite dir, own browser profile - never touches real installation data), with a per-clip seedState/record definition. Three clips recorded: settings-tabs, event-manager-tabs (including the quick-jump Open Grid button switching to the Live tab), and event-schedule (filling in a start time/gap, saving, and showing the resulting cascaded table). Output goes to the gitignored video-guides/ folder, not committed - these are sizeable binaries and the user hadn't asked for them checked into the repo.
  • First recording pass of the Event Manager and Schedule clips came out visually useless - the manage tabs and the Schedule panel both sit well below the fold on the Race Setup page, and a synthetic element.click() doesn't scroll anything into view the way a real user's click would, so the whole clip just sat on a static, unchanging top-of-page frame. Fixed by adding explicit scrollIntoView calls before each tab switch and before/after the schedule form actions, so the recording actually shows what it's demonstrating.

Verified 2026-09-11: extracted and visually inspected sample frames from all three clips after the scroll fix - Settings clip clearly shows each tab's own panel switching, Event Manager clip shows the tab bar plus the Open Grid button jumping to the Live tab, and the Schedule clip shows the filled-in form followed by the real cascaded table (Practice 1 12:30-12:40, Qualifying 1 12:43-12:48 with the 3-minute gap applied) - all legible, no garbled or blank frames. ffprobe confirmed all three are valid 1600x900 H.264 MKV files (10-13s each, under 750KB).

Follow-up: the clips needed a spoken narration track, not just a silent screen capture

The user's original ask ("a video guide showing how to do the various things") meant a narrated walkthrough - someone talking through what's on screen - not a silent recording. Rebuilt the recorder around that:

  • Installed espeak-ng (offline, open-source TTS - confirmed with the user first since it's a system package install, not something scoped to the repo) and switched each clip's definition from a flat list of { action, pause } steps to { text, action } steps: text is a narration line, synthesized to a WAV up front via espeak-ng so its real spoken duration is known before recording starts.
  • Rebuilt the timing model around that: the recording pauses for each line's actual measured duration (not a guessed fixed delay) after running its action, and the real wall-clock offset each line actually started at (network/DOM latency included) is recorded live during capture - not assumed from an idealized schedule, which would have drifted out of sync with page-load and IPC latency over a 30+ second clip.
  • The narration track is built after recording from those real per-line offsets, via ffmpeg's adelay (place each line at its own measured start time) and amix (normalize=0, since lines never overlap so there's no reason to quietly attenuate them), then muxed onto the silent video as an AAC audio stream.
  • Wrote actual narration scripts for all three clips (5-6 short spoken lines each) explaining what each tab/panel is for as it comes into view, not just describing the click.

Verified 2026-09-11: re-recorded all three clips and confirmed each now has both a video and an AAC audio stream (ffprobe). Extracted the volume envelope of the settings-tabs clip's audio and confirmed 5 distinct speech bursts separated by clean silence, matching its 5 narration lines. Cross-checked video frames against the audio timeline: at 8s into settings-tabs (mid second line, "Audio controls race sound announcements...") the Audio tab is the one on screen; at 20s into event-manager-tabs (the Schedule line) the Schedule tab/panel is on screen; at 12s into event-schedule (the "enter a start time" line) the form is visible filled in but not yet saved - narration and visuals line up correctly throughout, not just at the start.

Second follow-up: espeak-ng's voice was reported barely intelligible

Reported 2026-09-11 by the user right after listening to the first narrated batch: "går ju knappt höra va den säger.. robot röst från 80 talet" (can barely make out what it's saying - a voice like a robot from the 80s). espeak-ng is formant synthesis (the same family as the original DECtalk/Speak & Spell era of TTS this compares it to) - accurate about what it is, not a bug, just the wrong tool for something meant to be listened to and understood.

  • Installed Piper (pipx install piper-tts, an isolated CLI install via pipx rather than a system-wide pip install) - a small local neural (VITS-based) TTS that produces natural-sounding speech offline, no cloud service involved. Downloaded the en_US-lessac-medium voice model from the rhasspy/piper-voices collection on Hugging Face to ~/.local/share/piper-voices/ (a one-time, machine-local setup step - the ~60MB model isn't part of the repo).
  • Swapped synthesizeNarration in scripts/record-video-guides.mjs from shelling out to espeak-ng -w to piping each line into piper -m <model> -f <wav> over stdin. Piper's default output format (22050Hz mono 16-bit PCM WAV) matches what the rest of the pipeline (the adelay/amix sync logic, wavDurationMs) already assumed, so no other part of the recorder needed to change.

Verified 2026-09-11: re-recorded all three clips with Piper and confirmed each still has a valid video+AAC-audio MKV (ffprobe). Re-checked the settings-tabs clip's volume envelope - still 5 clean speech bursts in the right places, now visibly fuller/louder (Piper's neural waveform vs. espeak-ng's thin formant one), confirming the sync logic itself didn't need to change, only the synthesis backend.

Third follow-up: four more clips covering core onboarding, not just the recent tab changes

The user asked whether the 3 existing clips were enough, or more were needed - a fair question, since all 3 only covered UI restructured earlier this session, not what a genuinely new user hits first. Asked the user which of 4 candidate topics to add (all 4 chosen): connecting a decoder, adding drivers/cars/classes, creating a race through the 4-step wizard, and running a session on Race Day.

  • connect-decoder: the Connection tab, entering a decoder address and clicking Connect Now, the sidebar badge turning green - and, as a bonus demonstrating a real reliability feature, dropping the connection and showing it auto-reconnect. Needed a live fake decoder socket (the same one tests/browser_fake_decoder.js provides, imported directly rather than duplicated) running for the whole clip, not just as one-time seed data - added a prepare() hook to recordClip that runs before the server is seeded and returns { seedState, teardown, extra }, with extra merged into the context every step's action receives (so a step can call decoder.dropConnections() mid-clip) and teardown guaranteed to run even if the recording fails partway through.
  • add-drivers-cars: create a class, then a driver, then a car, switching between the three directory pages. Added a clickNav(label) helper that clicks the real sidebar .nav-item button by its label - the app itself is a client-side SPA (window.history.replaceState, no reload), and the recorder's own navigate() helper (built for test reliability, always a full Page.navigate) would have shown a page-load flash on every switch instead of the smooth transition a real user sees.
  • create-race: the Race Setup wizard's 4 steps (basics, participants, session plan, confirm) end to end, using the club_qualifying preset and "select all" participants, matching the same flow tests/browser_race_setup.js already exercises headlessly.
  • run-a-session: Start (with a real countdown and start-lights sequence, not the instant-start practice shortcut), Pause, Resume, Finish, then over to Timing. Reused the pattern from tests/browser_race_day_start_sequences.js (raceAudio enabled+unmuted so the sequence actually runs, but every event's effect/voice silenced via the same SILENCED_EVENTS construction, so nothing attempts real playback in headless Chrome) - and hit a genuine preflight gap while wiring it up: the seeded event had no raceConfig.minLapMs/maxLapMs, and buildRaceDayPreflight's lap-window check requires minLapMs > 0 to pass for anything other than open_practice, which a hand-seeded event (unlike one built through the real wizard, which always sets these via a preset) doesn't get for free. Fixed by seeding raceConfig: { minLapMs: 3000, maxLapMs: 60000 } directly.

Verified 2026-09-11: recorded all four clips (the first run-a-session attempt timed out waiting for the Start button to enable, which is what surfaced the lap-window preflight gap above - fixed and re-recorded clean). Visually inspected 2 frames per clip: connect-decoder shows the pre-filled decoder address then the green "Decoder Online" badge after the drop/reconnect; add-drivers-cars shows the created class, driver and car each appearing in their own list; create-race shows step 1 filled in and step 4's summary ("Friday Night Race", 4 participants, "1 x practice, 4 x qualification"); run-a-session shows the heat running with a real ticking clock and lit-up start lights, then the Timing page's header reading "HEAT - FINISHED" after the full start/pause/resume/finish cycle.

Fourth follow-up: linking the videos from README and the in-app Guide

The user asked whether the videos were actually linked anywhere yet - they weren't; all 7 clips only existed in the gitignored video-guides/ folder from the earlier feasibility work, unreachable from anything a user would actually open.

  • Moved all 7 .mkv files into docs/videos/ (alongside the existing docs/screenshots/) and changed scripts/record-video-guides.mjs's default output directory to match, dropping the now-unused video-guides/ entry from .gitignore - these are meant to be shipped, committed assets from now on, the same as the screenshots, not scratch output.
  • Added a "Video Guides"/"Videoguider" section to both README.md and README.sv.md (the project already maintains a parallel Swedish translation of the whole README), grouped into "Getting started" (the 4 new onboarding clips) and "Interface tour" (the original 3), linking directly to the committed files.
  • Added a matching "Video guides" panel to the in-app Guide page itself (src/misc_views.js), as the first section right after the overview cards - most discoverable spot, since it's exactly what a brand-new user should see first. Links point at /docs/videos/<name>.mkv, served automatically by the server's existing app.use("/docs", express.static(...)) route (already used for the screenshots' own doc pages) - no new server code needed. Added a matching overview card and 9 new translation keys (title, blurb, one label per clip) in both sv/en.

Verified 2026-09-11: confirmed all 7 files are served with 200 and the correct video/x-matroska content type via a real running (isolated) server instance. A full unit test run (205 tests) stayed green. A live headless-browser check of the Guide page in both languages confirmed the new panel renders with 7 correctly-labeled links to /docs/videos/... and no raw guide.* keys leaked - the first check of this appeared to show Swedish labels even with settings.language: "en" seeded, but that was the check script itself reading the page before its language-dependent re-render finished (a premature ready-check, not a real bug); waiting for the same hydration signal the rest of this session's browser tests already use confirmed English renders correctly.

Guide page split into tabs

Reported 2026-09-12 by the user: the Guide page itself needed to be "more överskådlig" (overviewable) - not just one long page. Fair complaint: by this point in the session the page had grown to ~20 stacked panels (Windows/Linux/SQLite setup, decoder roles, creating a sponsor event/regular race/race wizard, race format, the whole race-day operation topic list, free/open practice, team race, qualifying, dashboard, training, archive, audio, plus the new video guides panel from the entry above) - the same "one long scroll" problem already fixed twice this session for Settings and Event Manager.

  • Split into 5 tabs mirroring that same pattern (activeGuideTab, module-scoped so it survives an in-place re-render, same as activeSettingsTab/activeManageTab): Getting started (Windows/Linux/SQLite install, decoder roles, Managed AMMC), Create a race (sponsor event, regular race, race wizard, race format, the time-of-day schedule), Run a race (Race Day, the 4-step manage flow, lap validation/corrections, dashboard schedule-drift, free/open practice, team race, qualifying, race audio), Training & results (training stats, results archive), Video guides (unchanged from the entry above, just now its own tab instead of the first panel on the long page). The always-visible overview cards + intro at the top stay exactly as they were - only the ~20 detail panels below them got grouped into tabs.
  • New .guide-tabs/.guide-tab/[data-guide-panel][hidden] CSS, copied from the .manage-tabs family rather than introducing a third slightly-different pattern.
  • tests/browser_guide_tabs.js (new): default tab, per-tab panel visibility, and - the actual regression class this guards against - the active tab surviving an in-place re-render. Reused the language-switch re-render (languageSelect.onchange calls renderView() without navigating away) as the trigger, since Guide has no form of its own to submit the way Settings does.

Investigated alongside this, per the user's follow-up question about whether the page (or the app generally) had a real performance problem: checked how tickClock/updateSyncStatus (both on 1-second setIntervals) interact with an idle Guide page first - confirmed neither calls renderView() unless the current view is timing/race_day/dashboard/overlay (tickClock) or touches anything beyond a few specific status text nodes (updateSyncStatus), so an idle Guide page was never being silently re-rendered in the background. Then measured the Guide page's actual one-time render cost directly in a real headless browser (performance.now() around the nav click that triggers renderGuideView(), 20 repeated samples to rule out one-off JIT warmup noise): median 0.6ms, max 3.7ms, 433 DOM nodes, ~39KB of generated HTML - for comparison, Settings measured 5.7ms and Timing 2.5ms on the same run. No rendering performance issue exists or ever existed; the reported "slow" feeling was the page's length (now fixed above), not JavaScript performance.

Verified 2026-09-12: npm run test:browser passes all 37 scenarios (3 new, 34 pre-existing) including the new Guide tabs coverage; npm test passes all 205 unit tests unchanged. Live headless-browser checks in both languages confirmed correct tab labels/translations, no raw guide.* keys, and that the "Run a race" tab's content (guide.race_day_title) renders correctly. Refreshed docs/screenshots/guide.jpg to show the new tab bar.

Warm-up / transponder check before a heat

Asked by the user 2026-09-30: what happens if a driver in a running heat registered the wrong transponder? Checked first: fixing the transponder on the driver mid-heat works for every passing after the fix (drivers are looked up per passing), but laps already driven are stuck on an "Unknown driver" row, credited to the wrong driver, or - if the number belonged to someone not in the heat - thrown away entirely with no way to recover them. The user pointed at how other timing systems prevent this instead: a 1-2 minute warm-up where every driver in the heat must show a valid passing (green tick) before the start, with a quick transponder fix for anyone who doesn't. Built that; the mid-heat "move laps" safety net was offered too, and deferred by the user.

  • src/warmup_logic.js (new, pure): remembers each transponder the main decoder sees (bounded, in memory only - never saved or counted as laps), and builds the per-heat check: which entrants have crossed since the warm-up started, which unknown transponders crossed (naming the owner if it's a driver outside this heat), countdown.
  • decoder_runtime.js reports every main-line transponder before its running-session check - previously every passing before the start was discarded as no_running_session, so nothing could be checked pre-heat.
  • Race Day panel (race_day_view.js), shown for a selected, not-yet-started race-format heat with driver transponders (not practice, team race or sponsor/track events): Start warm-up (1/2/3 min) with a live countdown and new voice events warmup_start / warmup_1_minute / warmup_end (sv + en in the sound pack, so Piper pre-generation includes them); ✅/⏳ per driver in start order; for a missing driver, "Link unknown transponder" (one pick) or type a new one - saved on the driver immediately, with the same cross-person conflict warning as the Drivers page. The page refreshes on each passing but never while the operator is typing in a field.
  • Start warns - never blocks, a driver may simply not show up - listing who wasn't seen, and ends the warm-up. Preflight shows "Transponder check: seen/total" as a warning while someone is missing.
  • tests/browser_fake_decoder.js can now send real PASSING frames (sendPassing), so browser tests can exercise decoder-driven UI.

Verified 2026-09-30: unit tests (tests/warmup_logic.test.js) cover seen-since-start, a passing before the warm-up not counting, drivers without a transponder, unknown transponders newest-first with owner names, repeat counting and the size bound, and the countdown. tests/browser_warmup.js drives it with real decoder frames: Anna and Bo go green, Cia (registered wrong) stays missing until the unknown transponder is linked from the panel - then green and saved - preflight shows 3/3, and after a restarted warm-up Start warns naming the two not seen and starts the heat on confirm. While writing that test it first failed because a passing sent "before" the warm-up reached the page after the click and was (correctly) counted - a test timing issue, fixed by waiting for the frame to arrive. After that fix the same assertion still failed rarely (twice in about ten full-suite runs). Investigated with temporary logging: every run had exactly one decoder connection and the passing was processed before the start (no_running_session in the diagnostics), so it is not a duplicate-connection bug - but the flake could not be reproduced while logging (4/4 passed), so the root cause is unknown. The browser test now waits 750 ms between that passing and the start; the before/after rule itself is covered deterministically by the unit tests. 230 unit/integration tests pass, and the 46 browser scenarios passed three runs in a row.

Follow-up: the warm-up becomes the start count-down (EFRA / IFMAR)

The user's feedback on the same day: the warm-up should not be a separate part. It belongs in the heat start itself, as "heat starts in 2 min, 1 min". It must be possible to hold the start so the cars can be put on the grid. Qualifying uses a flying start, where each driver's 5 minutes start at their own crossing. Lengths should be 30 s, 1, 1:30 and 2 min. The goal is to follow EFRA and IFMAR. Checked first:

  • EVERLAPS (formerly RCM), which does exactly this: a "prologue" after Start, with calls every minute and at 30/20/10 s, and green ticks for drivers who have crossed. It also offers 30 s / 10 s shortcuts and Go.

  • EFRA General Rules 2024 §9.2-9.3.

  • IFMAR 1:10 IC Track 2024 §4.2.

  • src/warmup_logic.js became src/prologue_logic.js. It adds the prologue state (hold/resume/set remaining) and the call schedule:

    • EFRA 9.2b for a flying start: 1 min, 30 s, 10 s; with a longer count-down, every whole minute ("N minutes to the start").
    • IFMAR 4.2.4-4.2.6 for grid and staggered starts: "30 seconds, cars to the grid", then 9..3 second by second.
    • The start procedure takes over at 3 s (IFMAR 4.2.8: the signal comes 0-5 s later, which is our random delay). A flying start takes over at 0.
  • Race Day's Start on a race-format heat now starts the count-down. Choices are Go, 30 s, 1, 1:30, 2, 3 and 5 min. The default is set per event (qualPrologueSec / finalPrologueSec, 2 min) and comes from new fields in Race Setup.

    • While counting down: Hold / Resume, +1 min, To 30 s (IFMAR 4.2.8 grid restart from 30 s), To 10 s, Go now (warns about drivers not seen) and Cancel.
    • The hero clock shows the time to the start.
    • When the count-down runs out the heat starts by itself, with no dialog.
    • The global 10/30/60 s start count-down in Settings is skipped on Race Day. It still applies to Start on the Timing page.
  • The count-down lives only in the browser tab that started it (module state plus sessionStorage). A second browser on the same event therefore never runs its own count-down and doubles the calls. A reload restores it on hold, so a start is never fired by a reload.

  • runStartSequence takes skipCountdown and skipIntro:

    • "Go" still says "drivers, stand by" and runs the lights, random delay and horn.
    • After a full count-down only the lights, random delay and horn are left.
  • New start mode flying (EFRA 9.2b): no start procedure, just the horn and "Clock is running" (new voice event clock_running). Each car gets its own clock from its first crossing, like staggered.

    • It is the default for qualifying in new events and in the three built-in presets. Existing events keep the mode they already have saved.
    • The shared session clock gets one extra minute, so the last car to start still has its full window.
  • Latest personal clock start (EFRA 9.3, IFMAR 4.2.3) in decoder_runtime.js. A car first seen after the latest start gets that moment as its start, and its first crossing counts as a lap.

    • Flying start: 150 % of a standard lap after the horn. We take the first lap anyone completes in the heat as the standard lap, since there is no configured standard lap time.
    • Staggered start: when the first car completes a lap, or 1 minute after the start, whichever comes first.
  • Voice events warmup_* were replaced by prologue_start, prologue_minutes, prologue_grid, prologue_count, prologue_hold, prologue_resumed, prologue_cancelled and clock_running, in sv and en. The existing race_1_minute, race_30_seconds and race_10_seconds are reused.

Verified 2026-09-30:

  • 239 unit/integration tests pass. They include new tests for the call schedule per start mode, hold/resume, jumping to 10 s (skipped calls marked done, only the latest one spoken, calls come back after +1 min), length choices, the latest clock start rules, a flying start in the decoder (own clock, and a late car getting the latest start with a counted lap), and the audio sequence for flying and post-count-down grid starts.
  • tests/browser_prologue.js drives real decoder frames through the whole flow:
    • the default length comes from the event, and a passing before the count-down doesn't count;
    • Anna goes green and Cia is fixed by linking her unknown transponder;
    • hold keeps the clock still for 1.5 s, and it moves again after resume; +1 min works;
    • "Go now" warns about Bo only and starts on confirm;
    • a second heat started with 30 s and jumped to 10 s starts by itself with no dialog.
  • tests/browser_race_day_start_sequences.js was updated to the new flow: the heat counts down and never gets a second count-down, and the other types use Go.
  • The 48 browser scenarios passed three runs in a row.

Not done: a configurable standard lap time per event (EVERLAPS has one). Until then, the first completed lap stands in for it.

Race audio: passing tone only during a race, legacy audio removed

Asked by the user 2026-09-30: is the "Audio · Legacy" panel still needed, and during a race timing systems only give a passing tone - no names read out.

Checked first:

  • With race audio on (the default), the legacy passing/start/finish settings did nothing.

  • The browser voice/rate/pitch only fed the old voice.

  • The speaker cue switches duplicated the ones on the Timing page.

  • The legacy "Sound on" switch was a trap: speakTextHelper still checked it, so turning it off silenced every race-audio voice, Piper included, while the effects kept playing.

  • raceAudio.raceCallouts is "passing" by default, or "full". isSilencedDuringRace in race_audio.js drops personal_best, fastest_lap, track_record, new_leader, position_gain, position_loss and provisional_pole for non-practice sessions. The check sits in triggerRaceAudio in app.js; test buttons pass force and are never dropped. Start, clock, final-lap and results calls are untouched, and practice keeps everything.

  • Removed the legacy panel, its save code and its three test buttons, the legacy passing, start and finish fallbacks (announcePassingHelper, and the tone and voice in announceRaceStarted/FinishedHelper), and the audioEnabled gate in speakTextHelper. The Dashboard audio card now shows the race audio state and the race-calls mode. Old saved values are still read but no longer used.

Verified 2026-09-30:

  • 238 unit tests pass, including new tests for the silenced set, speaker mode, practice and no session; that a saved audioEnabled: false no longer mutes speech; and that race start no longer plays the old tone and voice.
  • The 49 browser scenarios pass. The settings test now checks that the legacy panel is gone, that race calls default to passing tone only, and that "Speaker" survives a save.

More local voices

Asked by the user 2026-09-30, choosing "more voices" over cloning celebrity voices. Cloning a real person's voice without consent was declined.

Checked the official Piper voice list (voices.json on Hugging Face). Only single-speaker voices were considered, because multi-speaker models would need a speaker picker. Candidates were downloaded to a scratch folder, and each one generated a sample line with the real Piper binary. Genders were then set from the measured median pitch, not from the name:

  • male: Alan 103 Hz, Northern English 110 Hz, Joe 98 Hz, John 117 Hz, Norman 103 Hz;

  • female: Alma 185 Hz, Alba 199 Hz, Cori 202 Hz.

  • Catalog in server/tts_install.js with accent and an extra flag. A standard install (the Install button and npm run tts:install) still fetches only NST, Lisa, Ryan and Lessac. Extra voices are fetched one at a time via POST /api/tts/install { voice }, and an unknown name is rejected with 400.

  • tts.status() reports downloadable: catalog voices not yet installed, with gender and accent.

  • Settings → Audio → Voice engine shows "More voices" with a Download button per voice (progress in the existing install log). Voice names now include gender and accent, e.g. "Alan (male, British)".

Verified 2026-09-30:

  • 240 unit tests pass. New tests check that installed voices aren't offered again, that gender and accent come through, that a standard install skips every extra voice, that a single-voice install fetches exactly that model and config (fetch stubbed, nothing downloaded), and that an unknown voice is rejected.
  • The browser test checks that Alan is offered as "Alan (male, British)" and that Download posts { voice: "en_GB-alan-medium" }. The request is intercepted in the page, so nothing is downloaded.
  • 50 browser scenarios pass.
  • Not run end-to-end: the real download through the API. It would have written into the developer's own piper/ folder. The same Hugging Face URLs were downloaded and played with Piper while the voices were being checked.

Best lap per layout, track record baseline

Asked by the user 2026-10-03: does "best lap per driver" work correctly? It seemed per stint, and should be per layout or per new open practice. Also: should it be called out in races and heats at all?

Checked:

  • Personal best (entry.bestLapMs in decoder_runtime.js) is per session and competitor. It was never reset per stint (a long break is a new statistics stint but keeps the best lap), so a new open practice already starts fresh.

  • The real problem was layout switches mid-practice. addTrackLayout moved the running practice onto the new layout, but every best lap from the old layout stayed. That blocked personal-best calls on the new track, polluted "session fastest" and ranked the practice leaderboard on old-layout laps.

  • Separately, getTrackRecordMs excluded the current session. So after driver A beat the old record, driver B was also called a "track record" for any lap between A's time and the old record.

  • Races and heats: none of these calls are made during a race by default since 1.28.0 ("Calls during a race": passing tone only, like EVERLAPS, RaceTime and MyRCM at IFMAR-level events). Speaker mode can turn them back on.

  • Competitor entries carry bestLapLayoutId, and passings carry trackLayoutId. The decoder resets the best lap if the session's layout differs from the entry's, which also catches a layout change made from another client.

  • addTrackLayout resets bestLapMs for every competitor in a running or paused practice it moves, and clears the overlay best-lap marker.

  • recalculateCompetitorFromPassings (judge corrections) only uses laps on the current layout for the best lap. Untagged older passings still count.

  • getTrackRecordMs includes the current session.

Verified 2026-10-03:

  • 243 unit tests pass. New tests cover: a personal best survives a new stint; after a layout switch the old 18 s no longer counts and a personal best is called on the new layout; passings carry their layout; the record includes the current session; judge recalculation is per layout.
  • The 51 browser scenarios pass. A new one checks that "New layout" during a running open practice moves it and resets the best lap while keeping the laps.

Reset an Open Practice without losing training stats

Asked by the user 2026-10-03: if a running Open Practice is reset to show only today's laps, do the drivers lose those laps from their training stats? Checked first:

  • syncTrainingLedger rebuilds every session that has results, and keeps ledger laps only for sessions without results.

  • "Reset data" deleted the session's results, but the Timing view's ensureSessionResult recreates an empty result for the active session at once. The next save therefore dropped all of that session's laps from the training stats.

  • A scratch script against syncTrainingLedger confirmed it: 3 laps → reset → 3 → first new lap → only the new one.

  • Reset sets session.resultsResetAt and calls syncTrainingLedger before clearing, so laps driven since the last save are captured. Ledger laps from before resultsResetAt are kept, even though the session has results again.

  • A judge invalidating a lap after the reset still removes it from the stats: only laps from before the reset are pinned.

  • The confirm text says the laps stay in the training stats.

Verified 2026-10-03:

  • 244 unit tests pass. A new test covers reset, the next-day lap, and that invalidation still applies after a reset.
  • tests/browser_practice_reset.js runs a running Open Practice with three laps through the real Reset button. It checks the confirm text, that Timing is cleared, that the practice is still running, that the three laps are still in the stats, and that a real decoder passing afterwards adds the fourth lap without removing the three.
  • With the old training_logic.js put back temporarily, that browser test fails ("laps kept right after the reset"), so it catches the bug.
  • 52 browser scenarios pass.

Suspiciously short practice laps, remove any lap

Asked by the user 2026-10-07: in Open Practice, new drivers and kids sometimes cut across the track, so a lap lands just above the minimum lap time. Example: someone usually laps 20-25 s and then does one or two laps of 15 s. Those laps should be flagged, visible (also in the overlay) and easy to remove.

Before this, only the last lap could be invalidated, in Timing or Judging. Training stats flag only slow outliers.

  • src/suspect_laps_logic.js: a lap is suspect when it is under 85 % of the median of the driver's last 20 normal laps on the same layout, with at least 5 such laps. Normal laps are counted, not suspect, and not the first lap of a stint. The driver is compared with themselves, so kids and fast drivers each get their own baseline.
  • decoder_runtime.js flags suspect laps in practice sessions only. A suspect lap is counted, but it is not used as a best lap and triggers no personal best, session fastest or record call. It adds a "suspect" overlay event.
  • judging_logic.js: setCompetitorLapStatus(session, rowKey, timestamp, "remove" | "restore" | "approve") works on any lap. Each action is logged with an undo (new undo type set_passing_suspect). The best-lap recalculation ignores suspect laps.
  • syncTrainingLedger leaves suspect laps out until they are approved.
  • UI:
    • The "Suspiciously short laps" panel on Timing and Race Day, with Approve / Remove.
    • ⚠ in the Timing leaderboard and in the overlays (live, team and OBS).
    • The driver's lap list (Timing driver details and Judging) shows every lap, including removed ones, with Remove / Restore / Approve.
  • Found while testing: a correction within RESULT_PROTECTION_WINDOW_MS (15 s) of a live passing was reverted by the server's stale-browser protection. It survived only if another passing followed before a reload. This also affected the existing "invalidate last lap". New POST /api/session-results (storeSessionResult, shared with /api/passings), and judge actions in app.js save through saveCorrectionFor(session), which also pushes the result there.

Verified 2026-10-07:

  • 251 unit and integration tests pass. New tests cover:
    • the threshold and minimum reference;
    • which laps form the reference;
    • best-lap and pending rules;
    • training stats excluding a suspect lap until approved;
    • the decoder flagging a 15 s lap among 20 s laps, keeping the best lap with no personal-best call, and flagging two shortcuts in a row;
    • races never flagging;
    • remove / restore / approve on any lap with undo;
    • /api/session-results winning over the hot-results protection.
  • tests/browser_suspect_laps.js sends a real decoder passing exactly 14 s after six laps around 20 s. It checks that the lap is flagged, not used as the best lap, shows ⚠ in the Timing leaderboard and the overlay, and appears in the Race Day panel, and that one click on Remove takes it away, logs it and clears the panel.
  • 56 browser scenarios pass.

Possible next step: a per-layout minimum lap time (option 3 from the proposal), not built.

Min/max lap time per track layout

Asked by the user 2026-10-07. The minimum and maximum lap time came only from the event (raceConfig), so Layout 2 had the same limits as Layout 1. A shorter rebuilt track would then reject real laps, and a longer one would let shortcuts through.

  • Layouts store optional minLapMs and maxLapMs (0 = not set). getLayoutLapWindowOverride in track_layouts_logic.js applies them to Open and Free practice on that layout only. It is used by both getSessionLapWindow, which decides whether a lap is too short or too long while timing, and the Race Day preflight. Races always keep the event's values.
  • Set when a layout is created: in Settings (min and max fields), or with the "New layout" button (asks for the min lap; empty means the event's). Edited per layout in the Settings list. Max ≤ min is refused. Race Day's lap window form saves to the layout when a practice session on a layout is in focus.

Verified 2026-10-07:

  • 253 unit tests pass. They check that practice uses the layout's values (both set, only min set, none set), that a heat on the same layout ignores them, the normalization, and that the Race Day preflight shows the layout's window.
  • The browser test saves 8.5 s / 40 s on a layout in Settings and checks that 30 / 20 is refused with an alert.
  • 57 browser scenarios pass.
  • Not covered by an automated test: the Race Day form saving to the layout during practice.

Reported by the user 2026-10-07: clicking the sidebar "update available" notice didn't land in the right Settings section, which is System & backup.

Since Settings was split into tabs, the link only switched the view, so the last-used tab (Connection by default) showed. Race Day's preflight "fix" buttons had the same issue.

  • openSettingsTab(tab, focusId) in settings.js. The update link opens system and scrolls to #settingsUpdateSection. navigateToView(view, { settingsTab }) is used by Race Day: decoder and backend fixes open connection, the audio fix opens audio.

Verified 2026-10-07:

  • A unit test covers the Race Day fix tabs.
  • The browser test clicks the sidebar link from the Dashboard and checks that System & backup is active and the Updates section is visible.
  • 253 unit and 58 browser scenarios pass.

Penalties and lap changes: Judging only, races only

Decided by the user 2026-10-07: in qualifying, heats and finals, the judge should be the one who modifies laps, not anyone at the Timing page. Penalties make no sense outside races.

Before this, Timing's driver details had the full set of penalty and last-lap buttons for every session type, and from 1.30.0 also Remove / Restore per lap.

  • Timing → driver details:
    • Race sessions show the corrections and the lap list read-only, plus "Open in Judging" (openJudging selects the driver and navigates).
    • Practice sessions show no corrections block, and keep Remove / Restore / Approve per lap.
    • The old click handlers were removed.
  • Judging: penalty buttons (±lap, ±seconds, reset) only for race sessions. Last-lap invalidate / restore and the per-lap list work for every session.
  • Guide texts validation_5-7 (sv/en) updated.

Verified 2026-10-07:

  • 253 unit tests pass.
  • tests/browser_corrections_scope.js checks a running heat: Timing has no penalty or lap-removal buttons and the lap list has no actions, "Open in Judging" lands on Judging with the penalty buttons and the driver. It then checks a running Open Practice: Timing has Remove per lap and no corrections block, and Judging has invalidate but no penalty or reset buttons.
  • 60 browser scenarios pass.

Drivers page: search focus, pronunciation test

Reported by the user 2026-10-08:

  • Searching on Drivers jumped out of the field after the first letter.

  • It should be possible to hear how a driver's spoken name or nickname sounds in announcements.

  • The Drivers and Cars search inputs re-rendered the whole page on every input event, which replaced the field. Focus and the caret are now restored, the same pattern results_archive_view.js already used.

  • testDriverVoice in app.js plays the passing sound (passSound), then personal_best and race_winner with driverName set to the spoken name, through race audio with force. It goes through the same voice path as live announcements. Buttons are in the add and edit forms (reading the unsaved form values) and per row in the list.

Verified 2026-10-08:

  • tests/browser_drivers_page.js types "DP And" letter by letter and checks focus after every keystroke.
  • It captures speechSynthesis.speak in the page and checks that the row button reads the saved spoken name, and that the add-form button reads an unsaved nickname without saving the driver.
  • 253 unit and 62 browser scenarios pass.

Series / cup

Asked by the user 2026-10-10, with the club's draft rules "ÖRC Winter Championship 2026/2027" (12 Sunday rounds, three classes, ÖRC points, best 9 of 12, members only) as the example. The user clarified:

  • classes must be freely chosen;

  • any number of rounds;

  • "guests" are simply drivers whose club (from the driver register) differs from the series' club.

  • src/series_logic.js (pure):

    • normalizeSeries;
    • points per place from a table, then step/min (getSeriesPointsForPlace, ÖRC formula max(10, 48 - 4 × (place - 10)));
    • scoreSeriesEvent: club filter with re-ranking, and no lap = 0 points with no place;
    • getCountedResultsCount: best N, or completed minus X with at least one;
    • buildSeriesStandings: one table per class, absence = 0 and droppable, counted flags, starts, ties (wins, then 2nds..., then the latest common round with places, else shared rank).
  • app.js:

    • getEventDayResult: final standings, then non-finalists in qualifying order, else qualifying; only once a qualifying/heat/final is finished; hasLap from counted passings.
    • createSeriesEvents: one event per date × class, " # ", with a race format preset.
    • Print and PDF export of the standings.
    • state.series persisted (load, default, export, backend hydrate).
  • src/series_view.js: the new "Series / cup" page, with the series list, settings (name, club with suggestions, classes, points preset/table/step/min, count rule), round picker, create-from-dates, and standings per class (dropped results in brackets).

  • Server PDF: wide tables (16 columns for 12 rounds) were truncated, with names cut to 4 characters and the last columns lost. Now: unchanged layout when it fits, otherwise compact columns, landscape A4 when still too wide, and only then is the widest column shortened. Checked visually with a 12-round, 14-driver table.

Verified 2026-10-10:

  • 259 unit and integration tests pass. tests/series_logic.test.js covers the ÖRC points incl. places 11-25, F1 with no points after the table, the club filter with re-ranking and case/space-insensitive matching, 0 points without a lap, best-N and drop-X counting incl. cancelled rounds, dropped absences, three-way ties settled by the latest round, more wins winning, per-class tables, and other classes' events ignored. The PDF integration test covers portrait for narrow tables, and landscape with full names and the last column for wide ones.
  • tests/browser_series.js checks the full flow:
    • a series is created on the page with a club, one class and drop-1;
    • two rounds are created from dates (named #1/#2 by date order);
    • round 1 gets a finished A-final where a guest wins;
    • the table shows Anna 100 and Bo 90 with the guest excluded and round 2 empty;
    • the PDF payload has the same row.
  • 65 browser scenarios pass.

Follow-up: open series or members only (2026-10-11)

The user pointed out that drivers from other clubs must be able to score in a normal series. The ÖRC example is the case that needs the members-only rule, and the README didn't make the choice clear.

  • series.scoring is all (default for new series) or club. A series saved with a club before the field existed normalizes to club, so its behaviour doesn't change. The club field is shown only for club. The print/PDF subtitle says "members of X only".
  • README (sv/en) describes both options.

Verified: 262 unit tests pass (open series with a club filled in, members-only, defaults, legacy). The browser test sets members-only and checks that the guest is excluded, then switches to every driver and checks that the guest scores and wins round 1 and that the club field hides, then switches back. 68 browser scenarios pass.

Follow-up: series page clarity (2026-10-11)

The user found the class ticks unsorted and hard to match to their labels, and selections that seemed not to save (all classes came back).

Causes:

  • The classes were inline toggles wrapping in creation order.

  • The form only saved on Save, so ticking a round or creating rounds re-rendered the page and dropped unsaved changes.

  • New series started with every class ticked.

  • Numbered steps. Steps 1-2 are one autosaving form (change → save, no Save button). Classes are a sorted grid of rows with the tick on the left, highlighted when selected, with All / None. New series start with none. Rounds are listed as aligned date · race · class rows.

  • Found in the screenshot: .btn-primary and .btn-danger inherited the light theme's dark text. They now have color: #fff.

  • New README screenshot docs/screenshots/series.jpg (node scripts/capture-screenshots.mjs series; SHOT_SCROLL=<selector> captures another part of the page for checking).

Verified: three captures checked visually (steps 1-2, step 3 before and after the button fix). The browser test checks that a new series starts unticked, that classes are sorted, and that a tick is saved without submit and highlighted. 262 unit and 69 browser scenarios pass.

Follow-up: series hub (2026-10-11)

The user found it unclear what to do manually after creating a series. They want:

  • an easier setup;
  • drivers added per race later;
  • heats created by how many take part;
  • the format allowed to differ, e.g. 2 heats + 1 final one round and 3 + 2 the next.

Before, the series only created empty races, and everything else meant opening each race in Race Setup.

  • Step 3 is a rounds table (getSeriesRounds in app.js): date, race/class, drivers, qualifying rounds / finals / max cars per heat (setSeriesRoundFormat, locked once qualifying exists; counted finals = all but one from three finals up), status (getSeriesRoundStatus in series_logic.js), Create qualifying / Create finals (the existing generators), Open (selectedEventEditId and Race Setup), and remove from the series.
  • Inline drivers per round: all / none, Copy from previous (same class, previous round by date), and New driver (registered in the class, with the series' club when members-only, and ticked).
  • Creating rounds from dates takes qualifying rounds, finals and max cars per heat. An existing race is added from a list instead of the old checkbox list.
  • splitIntoBalancedGroups in event_race_logic.js for qualifying heats and reseeding: even sizes, so nobody drives alone. Finals keep filling from the top.
  • Found while testing, fixed: a layout normalization test compared two Date.now() timestamps and failed intermittently. It now uses a fixed startedAt.

Verified 2026-10-11:

  • 264 unit tests pass. They cover round status, the even split (9→5+4, 13→5+4+4, order kept) and the qualifying generator (7 drivers, max 3 → 3+2+2).
  • The browser test, on the series page:
    • creates rounds with 2/1/2;
    • unticks Bo for round 1;
    • adds the late entry "SR Cia" (class and club checked, ticked);
    • copies round 1's field to round 2;
    • changes round 1 to 3 qualifying rounds while round 2 keeps 2;
    • creates qualifying (6 heats, sizes 2 and 1);
    • checks that the status shows and the format locks.
  • Screenshots of the table and the open driver list were checked. 70 browser scenarios pass.

Not done yet: the rule that a disqualification from a whole round gives a 0 that may not be dropped (ÖRC draft p. 6). Also no public web page for the standings, only print/PDF.

Training stats: practice by default, race laps separate

Decided with the user 2026-10-10:

  • Training stats (per layout) are about practice, but every lap from every session was blended in, with no way to tell a final from Open Practice.

  • Race laps are kept but separate, and marked with the race they came from.

  • Races never form stints: a race is one fixed-time run (5 min in 1/10 buggy, 45-60 min in nitro).

  • Ledger laps carry sessionType (and keep eventId). Older laps get it from their session on the next sync; unknown counts as practice.

  • getDriverTrainingLaps(..., { source }): practice (default), race or all. It is threaded through getDriverTrainingLapsForLayout, buildDriverTrainingStats and buildTrainingStintAnalysis. Every other caller (goals, rewards, achievements, club progress) gets practice only.

  • Stints (splitTrainingStints) only from practice laps, also under all. New buildTrainingRaceSessions: one row per race session with laps, best, average and spread.

  • UI:

    • "Based on" picker next to the layout picker on the driver page and the club overview.
    • Daily history lists 🏁 race · session for days with race laps.
    • Under Races, the race-session table ("🏁 race · session · series", series looked up from state.series) replaces stints.
    • The mobile profile (?source=) gets the same picker; the server's buildPublicTrainingProfile filters the same way.
  • Guide training_2 (sv/en) updated.

Verified 2026-10-10:

  • 261 unit tests pass. New tests cover: sessionType tagging and back-fill, practice by default, race/all counts, a race lap not becoming the training best, stints practice-only under "all" and none under "race", the race-session summary, and the public profile filter (default, race, all, invalid value).
  • tests/browser_training_source.js picks a driver with practice and qualifying laps and checks that practice shows stints and no race marks, and that Races shows the race-session table with "🏁 TS Winter #3 TS 2WD · Kval 2 · TS Winter Cup", 3 laps and no stint table.
  • 66 browser scenarios pass.

Noticed while testing: opening Training stats with ?driver= in the URL could lose the driver if the page rendered before the backend data had loaded. Fixed in 1.35.1:

  • initialLoadDone in app.js is set when the first hydrateFromBackend round settles.
  • Until then, the training view shows the overview without clearing the selection or the URL.
  • The re-render after the load opens the driver.
  • The browser test opens the link first, before the page has ever seen the driver. It fails with the fix disabled and passes with it. 261 unit and 67 browser scenarios pass.

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.
  • Update the in-app Guide page for this session's shipped features. Added three new "Träningsstatistik" steps (guided-exercise config, mobile self-service, the end-of-practice summary) and a new "Resultatarkiv" section (overview card + 5-step walkthrough); corrected guide.sqlite_2/_6, which claimed raw SQLite passings are always retained - no longer true now that deletion/clear-data purges them. Verified 2026-09-09 with a headless-browser check confirming the new content renders as real translated text (sv), not raw guide.* keys.

Consolidated 2026-09-09: the raw "Träningsstat" brainstorm notes (Pyramiden, Konsistens-jakten) were folded into section 3's "Guided practice exercises" as a proper specification and removed from here.

Closed out 2026-09-12, requested by the user alongside the screenshot refresh above: docs/javascript-architecture.md (~2850 lines) hadn't kept pace with this whole session's work. Used a research-only Explore pass first to get a concrete diff (doc location vs. actual code) rather than guessing what was stale, then applied it:

  • Section 2.2 (file inventory): added src/track_layouts_logic.js (previously undocumented anywhere - zero mentions), expanded src/settings.js's and src/event_manager_view.js/event_manager_controller.js's entries to describe their tab layouts (activeSettingsTab/activeManageTab, MANAGE_TAB_TARGETS) instead of the pre-tab flat panel lists. Section 2.1: added an entry for scripts/record-video-guides.mjs and scripts/capture-screenshots.mjs.
  • Section 4.1 (app.js wrapper functions): added buildEventSchedule, previously absent from every part of the doc despite being wired into both Event Manager and the printed start lists.
  • Section 9 (Dependency/Ownership Matrix): added rows for driver_profile_logic.js and track_layouts_logic.js.
  • Section 15 (Frontend State Schema): added the missing trackLayouts/audioEventLog top-level keys and a new TrackLayout type (15.1a); added Event.raceConfig.scheduleStartAt/scheduleGapMin and Session.trackLayoutId, both previously undocumented despite driving real features (the race day schedule, track-layout-scoped lap comparisons). Rewrote 15.9 Settings from a loose "contains at least" prose list into an actual field-by-field shape grouped by which Settings tab owns each field.
  • New section 15.10, SQLite Table Contract: the two actual tables (app_state, passings) straight from initSchema() in server.js - this literally didn't exist before, despite being one of the two explicit roadmap TODOs being closed here.
  • Section 16 (Backend API Contract): added the update-checker routes (GET /api/update-status, POST /api/update/run), GET /api/network/public-address, POST /api/passings/purge, and the three public-training self-service action routes (even-laps, step-training, exercise-sessions) plus the QR route - all previously live in server.js with zero documentation.
  • Section 18 (Automated Regression Tests): added tests/driver_profile_logic.test.js and tests/track_layouts_logic.test.js to the unit-test coverage list; rewrote the browser-suite appendix to list all 10 scenario files tests/browser_smoke.js actually invokes (it previously named only 2) and fixed a stale line claiming "session/team flows... remain tracked in ROADMAP.md" when both have had real coverage for a while.
  • Section 19 (Recommended Future Additions): removed the 3 items now addressed (resultsBySession schema, settings schema, SQLite contract) and the "browser-level smoke tests" item (long since done), keeping only the genuinely still-open items - neural TTS (deferred pending a provider) and permissions/auth (deferred pending its own design conversation), both already tracked in this file.

Deliberately left as pre-existing, lower-priority gaps rather than closing everything in one pass: several older modules (race_audio.js, sync_status.js, html_escape.js, list_sorting.js, results_archive_view.js, decoder_diagnostics.js/decoder_diagnostics_view.js) are documented only in later unnumbered appendix sections, not in section 2.2's main per-file inventory or section 3's import graph - real inconsistencies, but not something this session's changes touched, so fixing them was out of scope for "update as modules change."

Verified 2026-09-12: re-read every edited section back against the actual source (grep for each newly-documented symbol/field/route) to confirm nothing was invented; new subsections inserted between existing numbered ones (15.1a, 16.2a, 16.6a) use a letter suffix rather than renumbering everything after them.