formatRaceClock rendered a doubled-up, garbled time (e.g. "02:48.20:207") everywhere a race clock is shown - Timing, Overlay, Results archive, Judging, Training stint logs - because it concatenated both a centiseconds and a milliseconds computation into the same string. Only the milliseconds part was ever meant to be there. Found while building realistic demo data for new screenshots, since every prior test/demo session was empty and never exercised this code path with a real result. Also regenerates all screenshots in docs/screenshots against a realistic seeded race (drivers, cars, qualifying heats, finals, practice history, a real judging correction) instead of empty demo data, adds two new ones (Managed AMMC config, the in-app Guide page) aimed at helping a new user understand initial setup, removes 6 screenshots orphaned by an earlier README revision, and updates both README.md and README.sv.md's screenshot tables accordingly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
68 KiB
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):
- 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 incomingPOST /api/statewhose 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 viaRC_RESULT_PROTECTION_WINDOW_MSto keep the test fast, plus a check that the protection expires and a later legitimate change to that same session still takes effect. - 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 existingstartedAgoMsclock-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 andgetCompetitorPassingsfor 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/buildRacePackagePayloadfunctions 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 rawpassingsSQLite rows (write-only; nothing in the app reads them), which would otherwise grow forever, unreachable, still holding driver IDs indefinitely. A newPOST /api/passings/purgeendpoint 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.
- Optional backend-generated neural speech.
- Cache announcements and driver names before race day.
- Keep credentials on the backend, outside exports and public responses.
- Invalidate cached speech when language, voice or pronunciation changes.
- Provide provider status and a pre-generate action.
- Verify offline fallback and that playback never delays timing.
6. Permissions and mobile Race Control
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.
- Define Admin, Race Director, Speaker and Read-only capabilities.
- Enforce permissions on server endpoints, not only in the UI.
- Keep public overlays and training links restricted.
- Build compact mobile controls for start/stop, track call, flags and next heat.
- Test concurrent control and accidental repeated commands.
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
trackLayoutIdwhen it starts (commitStart()insrc/app.js), covering every session type including Open Practice, since practice sessions feed the track-record calculation too. - The "track_record" audio cue (
getTrackRecordMsinsrc/timing_logic.js) now only compares a live lap against other sessions tagged with the same layout. With no layouts configured, every session'strackLayoutIdis"", 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.
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.
getTeamDriverPoolrestored 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'sdriverIds/manualGridIds, alongside the existing assignment cleanup. - Added a
duplicate_team_membersRace Day preflight check specifically forteam_racesessions, 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-statusshells 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=1bypasses 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'spackage.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 existingscripts/serverctl.jswith a newupdatecommand: 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 tologs/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-versionevery 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
updateStatusalready 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.
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
0on one early-return path,{updated, locked}on the normal path) - the caller silently readundefinedin 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.
bumpCountis 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,getCompetitorElapsedMsinsrc/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 (
finishSessioninsrc/session_control.js) never folded that final, still-open pause segment intototalPausedMsthe 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.getSessionTimingalso now freezes a finished session's clock atendedAtrather 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,getBestTrainingDurationResultinsrc/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 (
buildPracticeStandingssorts fastest-first,generateQualifyingForRace/reseedUpcomingQualifyingchunk 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/carsPerFinalare freely adjustable (plain number fields, no hidden constraints) - e.g. 12 drivers atcarsPerHeat: 4cleanly makes 3 heats of 4, and settingcarsPerFinalto 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 ÷carsPerFinalrather 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 fortype: "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 (
activeSettingsTabinsrc/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.activeinsrc/styles.css) via new.settings-tab/.settings-tab.activeclasses, 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 bothsettingsRaceAudioTestand every[data-test-race-audio]button) calledpersistRaceAudioFormValues, which both applies the form's current values tostate.settings.raceAudioand callssaveState()(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, nosaveState()call). - Split
persistRaceAudioFormValuesintoapplyRaceAudioFormValuesFromDom(apply only, in memory) andpersistRaceAudioFormValues(apply +saveState(), unchanged for its existing callers).testRaceAudionow 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 bothcentiseconds(2-digit) andmillis(3-digit) from the same elapsed value and concatenated both into the returned string -${m}:${s}.${centiseconds}:${millis}- producing a garbled result like02:48.20:207everywhere 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 siblingformatLaphelper's format; thecentisecondscomputation 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) andguide.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.mdandREADME.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 inREADME.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.
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 rawguide.*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.