Files
Live_RC/docs/javascript-architecture.md
larssand 8601954ea2 feat(practice): flag suspiciously short laps and remove any lap in one click
Practice laps under 85% of the driver's own median (a likely shortcut)
are flagged: counted, but never a best lap, record or training stat
until approved. Timing and Race Day get a one-click Approve/Remove
panel, overlays show a warning mark, and any lap can be removed or
restored from Timing and Judging with undo.

Also sends judge corrections straight to the server so its hot-results
protection can no longer hold back a correction made right after a
passing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:53:23 +02:00

115 KiB

RC RaceCore Architecture Reference

This is the technical architecture reference for the Live_RC codebase. It is intended for debugging, maintenance, and future refactoring work.

This document covers:

  • runtime components
  • backend/frontend boundaries
  • every JavaScript file in the repo
  • direct import dependencies
  • injected dependencies from app.js
  • state ownership
  • save/rerender rules
  • end-to-end data flows
  • where to debug specific failures

1. Runtime Architecture

1.1 Components

The system has four main runtime parts:

  1. Browser UI
  • index.html
  • src/styles.css
  • src/app.js
  • the rest of src/*.js
  1. Node backend
  • server.js
  • Express API
  • static file serving
  • AMMC process control
  • PDF export endpoint
  • app version endpoint
  • bounded SQLite backup API and local-only restore CLI
  1. Local persistence
  • SQLite via better-sqlite3
  • main file: data/rc_timing.sqlite
  • AMMC config JSON: data/ammc_config.json
  • rotating SQLite snapshots: data/backups/*.sqlite plus metadata sidecars
  1. Decoder / AMMC runtime
  • AMMC process started by backend or manually elsewhere
  • browser decoder socket handled in frontend runtime

1.2 High-level diagram

flowchart LR
  Browser[Browser UI\nindex.html + src/*.js] -->|HTTP GET / static files| Server[server.js / Express]
  Browser -->|GET/POST /api/state| Server
  Browser -->|GET/POST /api/ammc/*| Server
  Browser -->|POST /api/passings| Server
  Browser -->|GET /api/app-version| Server
  Browser -->|GET/POST /api/backups| Server
  Server -->|persist app state| SQLite[(SQLite)]
  Server -->|persist passings| SQLite
  Server -->|online backup API| Backups[(data/backups)]
  Server -->|read/write config| AMMCConfig[(ammc_config.json)]
  Server -->|spawn/stop| AMMC[AMMC process]
  Browser -->|WebSocket| DecoderWS[AMMC websocket / decoder feed]

1.3 Ownership split

Frontend owns:

  • UI rendering
  • in-memory app state while page is active
  • decoder websocket client
  • leaderboard and standings calculations
  • judging/corrections in current page state

Backend owns:

  • static asset serving
  • persisted app state snapshot
  • passings table persistence
  • AMMC process management
  • PDF generation endpoint
  • file-watch based app version bumping
  • SQLite online backup creation and bounded retention through server/backup_service.js

2. Repository JavaScript Inventory

2.1 Root/runtime files

package.json

Role:

  • package metadata
  • runtime dependencies
  • npm scripts

Dependencies:

  • express
  • better-sqlite3

Runtime contract:

  • Node.js >=22 <27
  • npm >=10
  • dependency graph locked by package-lock.json
  • scripts/preinstall.js rejects unsupported Node/Windows architecture before native install
  • scripts/verify-install.js loads Express and executes an in-memory SQLite query after install

Scripts:

  • npm start -> node scripts/serverctl.js start
  • npm run start:fg -> node server.js
  • npm stop
  • npm run status
  • npm restart
  • npm run verify
  • npm test
  • npm run backup:restore -- <backup-file.sqlite> --confirm
  • npm run setup:windows

server.js

Role:

  • backend runtime

Uses:

  • fs
  • path
  • os
  • child_process.spawn
  • express
  • better-sqlite3
  • server/public_training_profile.js
  • server/backup_service.js

Owns:

  • HTTP server
  • API routes
  • SQLite schema/init
  • static serving
  • file watching for app version
  • AMMC config + process state
  • public overlay route serving via same frontend shell
  • tokenized public driver training profile API, locally generated QR SVG, configurable public base URL, and standalone mobile shell
  • PDF export endpoint

Delegates public training token validation and response shaping to server/public_training_profile.js; server.js does not duplicate those rules.

Main API groups:

  • /api/health
  • /api/app-version
  • /api/network/public-address (explicit UI action only; never polled)
  • /api/backups (GET list, POST create only)
  • /api/state
  • /api/passings
  • /api/ammc/config
  • /api/ammc/status
  • /api/ammc/start
  • /api/ammc/stop
  • /api/export/pdf
  • /public-training/api/:shareToken

Static routes of interest:

  • /
  • /public-overlay
  • /public-overlay/obs
  • /public-training/:shareToken

Runtime environment variables:

  • PORT: HTTP port, default 8081
  • HOST: bind address, default 0.0.0.0
  • RC_TIMING_DATA_DIR: optional absolute/relative data directory override; defaults to <repo>/data
  • RC_TIMING_PID_FILE: optional PID-file path used by process control
  • PUBLIC_OVERLAY_TOKEN: optional shared token for public overlay routes
  • RC_ALLOWED_ORIGINS: optional comma-separated cross-origin UI origins; same-origin is always allowed

server/backup_service.js

Role:

  • server-side SQLite snapshot service

Owns:

  • data/backups creation
  • online better-sqlite3.backup(...) snapshots that remain consistent while WAL mode is active
  • sanitized backup names and JSON metadata sidecars
  • newest-first backup listing
  • bounded retention of the latest 30 snapshots

Security boundary:

  • the HTTP API can create and list metadata but cannot download or restore a database
  • destructive restore is available only through scripts/restore-backup.js on the backend host
  • the restore CLI requires the managed server to be stopped and an explicit --confirm flag
  • before replacement, restore validates SQLite/application state and creates a pre_restore safety snapshot

server/public_training_profile.js

Role:

  • pure CommonJS backend helper for tokenized personal training profiles

Owns:

  • enabled share-token lookup
  • profile resolution across several race-specific driver records
  • strict per-class lap filtering and aggregate statistics
  • sanitized branding plus class-specific goals and recommendation response
  • server-side visibility filtering for progress, achievements, performance/stints, goals, and history
  • bounded class-specific pace history, active 30-minute pace buckets, and recent stint summaries for the mobile profile
  • token-, profile-, and class-validated creation of public car/setup markers
  • clock-skew-safe personal timer start/window normalization
  • bounded current-session lap, best-lap, and average calculations

Security boundary:

  • returns no driver IDs, transponders, raw passings, decoder settings, or other driver names
  • disabled, short, unknown, or profileless tokens return no profile
  • logo data is restricted to bounded PNG/JPEG/WebP data URLs

scripts/serverctl.js

Role:

  • local process manager for server.js

Owns:

  • background start/stop/restart/status
  • pid file management
  • log file paths

Uses:

  • data/server.pid
  • logs/server.out.log
  • logs/server.err.log

server/auth.js

Role:

  • optional admin PIN protection (CommonJS, required by server.js)

Owns:

  • PIN hashing/verification (scrypt) in data/auth.json and the per-installation HMAC secret in data/auth_secret - both outside app_state on purpose
  • signed 30-day login cookie (rc_auth), invalidated when the PIN changes
  • login throttling per client address
  • middleware: once a PIN exists, rejects unauthenticated non-GET /api/* requests and GET /api/ammc/config with 401 { error: "auth_required" }; applies RC_ADMIN_ALLOWED_CIDRS (403 ip_not_allowed) to the same routes regardless of PIN
  • routes wired in server.js: GET /api/auth/status, POST /api/auth/login, /logout, /pin, /disable

server/tts.js, server/tts_install.js, scripts/install-piper.js

Role:

  • local neural text-to-speech via Piper (CommonJS, required by server.js)

Owns:

  • voice catalog in tts_install.js: one male + one female voice per language with gender/label (genders measured by pitch, not guessed); pickVoice prefers the voice chosen in Settings, then the catalog default
  • tts.js: finds the Piper binary (RC_PIPER_BIN, then <project>/piper/, then PATH, then ~/.local/bin) and voices (RC_PIPER_VOICES_DIR, data/piper-voices, <project>/piper/voices, ~/.local/share/piper-voices); generates one line at a time into a WAV cache under data/tts/ keyed by voice + speed + text; dedupes identical in-flight requests; background pre-generation with progress
  • tts_install.js: downloads the standalone Piper release for the OS and the standard voices (NST, Lisa, Ryan, Lessac) into <project>/piper/, skipping anything present. extra catalog voices (Alma, Alan, Northern English, Cori, Alba, Joe, John, Norman) are only fetched one at a time via POST /api/tts/install { voice }; tts.status() lists them as downloadable for the Settings panel. The catalog is used by both npm run tts:install (scripts/install-piper.js) and POST /api/tts/install
  • routes in server.js: GET /api/tts/status (open), POST /api/tts/speech, POST /api/tts/pregenerate, POST /api/tts/install (all PIN-protected once a PIN is set)

scripts/record-video-guides.mjs and scripts/capture-screenshots.mjs

Role:

  • documentation tooling - not run in production, not imported by server.js or any src/*.js module

Owns:

  • both spin up an isolated server.js (temp RC_TIMING_DATA_DIR) plus a headless Chrome instance driven over the DevTools Protocol via a raw WebSocket, the same harness pattern tests/browser_smoke.js uses - own temp SQLite dir, own browser profile, never touches a real installation
  • record-video-guides.mjs: narrated MKV screen-capture clips for new-user video guides (docs/videos/*.mkv, linked from README.md/README.sv.md and the in-app Guide page). Each clip is a list of { text, action } steps; narration audio is synthesized up front via Piper (pipx install piper-tts plus a downloaded voice model - not espeak-ng, which was tried first and found barely intelligible), so each line's real spoken duration is known before recording, and the final audio track is built from the actual measured wall-clock offset each line started at (ffmpeg's adelay/amix), not an idealized schedule - avoids drifting out of sync with page-load/network latency over a 30+ second clip. A prepare() hook lets a clip spin up a live dependency (e.g. a fake decoder socket via tests/browser_fake_decoder.js) before the app is seeded.
  • capture-screenshots.mjs: single-frame JPEG captures (docs/screenshots/*.jpg, 1600x900 CSS at deviceScaleFactor: 2) against one shared seeded demo dataset ("JMK RB" branding, a finished "Klubbmästare Race 4" event) so screenshots taken at different times stay visually consistent with each other.

Windows bootstrap

Files:

  • windows/install.bat
  • windows/setup.ps1
  • windows/start_backend.bat
  • windows/start_ammc.bat
  • windows/start_all.bat
  • windows/stop_all.bat

Dependency flow:

flowchart LR
  InstallBat[windows/install.bat] --> SetupPs[windows/setup.ps1]
  SetupPs -->|missing/unsupported| Winget[winget OpenJS.NodeJS.LTS]
  SetupPs --> VCRuntime[winget Microsoft.VCRedist.2015+.x64]
  SetupPs --> NpmCi[npm ci]
  NpmCi --> Preinstall[scripts/preinstall.js]
  NpmCi --> AllowScripts[package.json allowScripts]
  AllowScripts --> Native[better-sqlite3 v12 Windows prebuilt]
  NpmCi --> Postinstall[scripts/verify-install.js]
  Postinstall --> SQLite[SQLite in-memory query]
  Postinstall --> AMMC[Platform AMMC runtime check]
  AMMC --> Glibc[Linux x64 GLIBC >= 2.39 warning]
  AMMC --> Sim[ammc-sim fake decoder test source]
  SetupPs --> ServerCtl[scripts/serverctl.js]

Important constraints:

  • the repository should be installed in a short ASCII path such as C:\\Live_RC
  • better-sqlite3 is pinned to v12.11.x because its prebuilt Windows binary works with the supported Node.js releases; npm 12 is permitted to run only this dependency installer through package.json#allowScripts
  • npm ci --ignore-scripts is unsupported because it leaves the native SQLite binding uninstalled
  • AMMC Windows binaries and DLLs must remain together in AMMC\\windows64
  • all bundled Linux x86-64 AMMC programs require GLIBC 2.39+; verify-install.js checks every program's executable permission and reports host compatibility without blocking the RC RaceCore server
  • ammc-sim emulates a physical decoder for test passings; its output must still pass through ammc-amb and the normal WebSocket, validation, timing, and persistence flow
  • npm start is already a background start; batch wrappers must not create a second detached npm process
  • server process identity is stored in data\\server.pid
  • Windows stdout/stderr use logs\\server.out.log and logs\\server.err.log
  • the optional -OpenFirewall setup flag opens inbound TCP 8081 and decoder channels 9000-9003 only when PowerShell is elevated

2.2 Frontend files

src/app.js

Type:

  • frontend composition root / app shell

Owns:

  • global state
  • bootstrapping
  • route/view selection
  • local persistence/bootstrap
  • top-level DOM rendering
  • utility/formatting helpers
  • all dependency injection into extracted modules

This is the only file that should know about almost every other frontend module.

src/race_day_logic.js

Role:

  • pure Race Day queue and preflight calculations

Owns:

  • current/next/after-next ordering within one event
  • blocking checks for Main decoder, participants, transponders, duplicates, lap window, and position grid
  • warnings for backend backup availability, optional decoder roles, and audio

src/race_day_view.js

Role:

  • focused Race Day operator UI

Dependencies injected by app.js:

  • existing timing/session selectors and formatters
  • buildRaceDayQueue and buildRaceDayPreflight
  • centralized start, finish, pause, and resume controllers
  • navigation shortcuts to Timing, Judging, and Overlay

The view does not own timing state and does not implement a second race clock.

src/session_control.js

Role:

  • small pure mutation helpers for begin, finish, pause, and resume

The same helpers are used by the centralized operational start path so Race Day and Timing cannot drift into separate lifecycle behavior.

Safety backup contract:

  • session start synchronizes state and creates a pre_start snapshot before the existing audio/start sequence
  • full data import and every clear-data scope synchronize state and create a pre_change snapshot before mutation
  • if backup is unavailable, the operator must explicitly confirm before the existing operation continues

src/runtime_services.js

Type:

  • frontend runtime helpers

Owns:

  • backend hydrate/sync/version polling
  • overlay polling/rotation/live refresh
  • AMMC config/status/start/stop helpers
  • audio/speech helpers
  • session timer ticks

src/decoder_runtime.js

Type:

  • frontend decoder websocket runtime

Owns:

  • connect/disconnect decoder
  • process incoming decoder messages
  • resolve incoming passings into app state

src/timing_logic.js

Type:

  • timing domain logic

Owns:

  • active session and timing helpers
  • lap validation labels and lap window logic
  • leaderboard generation
  • practice/qual/final standings
  • team race standings and stint logs
  • grid helpers

src/judging_logic.js

Type:

  • judging domain logic

Owns:

  • manual corrections
  • invalidate/restore lap
  • undo handling
  • judging filters

src/event_race_logic.js

Type:

  • event/race domain logic

Owns:

  • event normalization
  • branding normalization
  • team normalization
  • race presets
  • race wizard defaults and session generation
  • practice-only Open Practice event/session generation
  • sponsor round generation
  • event drivers / teams / team driver pool
  • qualifying/finals generation and bumps
  • race summary/manage status logic
  • buildEventSchedule(event, sessions, { getSessionSortWeight }): cascades each race-mode session's planned clock time from event.raceConfig.scheduleStartAt, in running order (reusing getSessionSortWeight's practice→qualifying→finals ordering from race_render_helpers.js), each session's own duration/follow-up plus scheduleGapMin between sessions. Once a session has actually started/finished, everything after it re-anchors to that real time - a late start or an overrun (a pause's wall-clock time is already baked into a session's real endedAt) pushes the rest of the day forward automatically, with no separate "reschedule" step. Returns [] when no scheduleStartAt is set. Consumed by Event Manager's Schedule tab and by buildRaceStartListsHtml (prints the planned time next to each session).

src/race_render_helpers.js

Type:

  • race-specific render helpers

Owns:

  • team standings block
  • team stint log block
  • final matrix block
  • grid render block
  • print/export HTML builders

src/race_setup_ui.js

Type:

  • race setup shared UI fragments

Owns:

  • race format field cards
  • context cards
  • summary items/warnings
  • wizard steps/content
  • standings table UI

src/event_common.js

Type:

  • shared event/session utility module

Owns:

  • sessions for event
  • mode/start-mode labels
  • class/event name lookup
  • assignment list render
  • sessions table render

src/event_views.js

Type:

  • outer event/race workspace markup

src/event_workspace_controller.js

Type:

  • outer event/race workspace controller

Owns:

  • create/edit/delete event or race
  • race wizard step flow
  • list actions
  • opening manager view

src/event_manager_view.js

Type:

  • Hantera markup

Owns:

  • for a race-mode event: a 4-tab layout (.manage-tabs: Setup/Participants & teams, Format, Live & results, Schedule & print) wrapping the panels below in <div data-manage-panel="..."> groups - session-plan (create/list sessions) and branding stay always visible above the tabs regardless of the active one, as does the permanent 4-card step-status summary (Setup/Format/Generate/Live)
  • session forms
  • race format panel (Format tab)
  • branding panel (always visible)
  • team section (Setup tab)
  • sponsor tools
  • assignments section
  • Schedule panel (Schedule tab): start-time + gap-between-sessions form, and (once set) a table of every session's planned/actual time and a "Delayed" flag, built from buildEventSchedule
  • summary/actions panels
  • team/session modals
  • track/sponsor (non-race-mode) events skip the tabs entirely and keep the simple, untabbed layout - they never had enough content to need them

src/event_manager_controller.js

Type:

  • Hantera controller

Owns:

  • activeManageTab (module-scoped, same pattern as settings.js's activeSettingsTab) and MANAGE_TAB_TARGETS, a lookup from a quick-jump target id (the step-status cards, the race-summary's warning links) to the tab it now lives inside, so jumping to it switches tabs first - scrollIntoView on an element behind an inactive tab does nothing on its own
  • session create/edit/delete
  • race format save/apply/delete preset
  • branding save/logo save
  • team create/edit/delete
  • generation actions
  • assignment actions
  • print/export/grid actions (opening a session's grid editor also switches to the Live tab, since that's where the editor renders)
  • schedule form save/clear (scheduleForm/scheduleClear)

src/overlays.js

Type:

  • overlay renderers

Owns:

  • normal overlay
  • team overlay
  • OBS/public overlay
  • side panels
  • overlay leaderboard rows

src/session_replay.js

Type:

  • pure transient replay logic

Owns:

  • normalization and chronological sorting of saved session passings
  • deterministic play, pause, speed, finish, and reset state
  • time-sliced replay leaderboard snapshots for race and practice ranking
  • strict isolation from state.resultsBySession, SQLite, decoder ingestion, and training ledger mutations

Imported by:

  • src/app.js for the single transient replay runtime
  • src/timing_views.js for controls and incremental rendering
  • tests/session_replay.test.js

src/timing_views.js

Type:

  • timing/judging renderers

Owns:

  • timing page UI
  • judging page UI
  • leaderboard modal
  • recent passings UI
  • quick-add UI

src/core_views.js

Type:

  • overview/admin renderers

Owns:

  • Dashboard
  • Classes
  • Drivers
  • Cars

src/driver_profile_logic.js

Type:

  • pure personal-profile identity helpers

Owns:

  • resolving an explicit profileId or a legacy normalized-name profile key
  • grouping race-specific driver records that belong to one person
  • listing all driver IDs and class IDs attached to one personal profile

Imported by:

  • src/core_views.js for profile assignment in the Drivers UI
  • src/training_logic.js for class-scoped lap aggregation
  • src/training_views.js for person and class selectors

src/track_layouts_logic.js

Type:

  • pure track-layout identity/lookup helpers

Owns:

  • normalizing a single layout ({ id, label, startedAt }) and the installation's whole layout list, sorted by startedAt
  • resolving which layout was active at a given timestamp (getTrackLayoutIdAt) - layouts are contiguous, so a layout's implicit end is the next one's startedAt, with no separate stop field
  • default/next label generation (getTrackLayoutLabel, nextTrackLayoutLabel - "Layout N")

Imported by:

  • src/app.js to stamp session.trackLayoutId at the moment a session starts (never recomputed later, so editing the layout list afterwards can't retroactively reclassify old results) and to scope getTrackRecordMs (src/timing_logic.js) and training pace/consistency stats (src/training_logic.js, server/public_training_profile.js) to the current layout
  • src/settings.js for the "Banlayouter" panel (add/list/delete, delete blocked while any session still references the layout)
  • src/results_archive_view.js to show which layout a completed session ran on

src/auth_view.js

Type:

  • admin PIN UI

Owns:

  • installAuthFetchGuard: wraps window.fetch once at startup (app.js) so a 401 auth_required from /api/* opens the PIN dialog; app.js retries the pending sync after a successful login
  • showPinDialog: the login modal
  • mountAuthSettings: the "Admin PIN" panel in Settings -> System & backup (enable, change, log out, disable)

src/piper_speech.js and src/tts_settings_view.js

Type:

  • local voice playback and its settings panel

Owns:

  • piper_speech.js: speakWithPiper fetches the server's WAV and plays it; on any error or after 2.5 s it calls the browser-voice fallback instead. speakTextHelper (runtime_services.js) routes through it when settings.ttsEngine === "piper", so every race-audio voice uses it
  • tts_settings_view.js: the "Voice engine" panel in Settings -> Audio (engine choice, sv/en install status, install / pre-generate / test buttons)

src/track_layout_bar.js

Type:

  • shared UI strip

Owns:

  • "Track layout: - since [New layout]" on Training stats, Race Day and the Dashboard; the button prompts for a name and calls the injected addTrackLayout (which refuses while a race session is running - findRaceSessionBlockingLayoutChange in track_layouts_logic.js)

src/prologue_logic.js

Type:

  • pure heat-start logic per EFRA 9.2-9.3 / IFMAR 4.2: prologue (count-down), transponder check, latest personal clock start

Owns:

  • recordTransponderSighting: in-memory, bounded map of transponder -> last seen (never persisted, never counted as laps); fed by decoder_runtime.js's onTransponderSeen for every main-line passing, before the running-session check
  • buildWarmupCheck: per-heat entrant rows (seen since the count-down began or not), unknown transponders with owner names; used by the Race Day transponder check panel (race_day_view.js) via getRaceDayWarmupCheck in app.js
  • prologue state as a plain object (createPrologue, holdPrologue, resumePrologue, setPrologueRemaining, getPrologueRemainingMs); takeDuePrologueCall decides which voice call is due (every minute, 30 s, 10 s; grid starts add prologue_grid at 30 s and prologue_count 9..3); getPrologueHandoverMs is 3 s for grid/staggered starts (IFMAR 4.2.8) and 0 for a flying start; PROLOGUE_OPTIONS_SEC / normalizePrologueSec / formatPrologueOption for the event's qualPrologueSec / finalPrologueSec
  • usesPersonalClock (staggered, flying) and getLatestClockStartTs, used by decoder_runtime.js so a late car's clock starts at the latest allowed moment and its first crossing counts as a lap

The prologue controller lives in app.js (startRacePrologue, tickRacePrologue, hold/resume/+1 min/to 30 s/to 10 s/Go/Cancel). It is per browser tab (module state plus sessionStorage, restored on hold after a reload), so a second browser on the same event never runs its own count-down. When it runs out it calls startOperationalSession with skipCountdown and skipIntro; runStartSequence in race_audio.js then only runs lights, random delay and horn, or for a flying start the horn plus clock_running.

src/suspect_laps_logic.js

Type:

  • pure practice lap plausibility check

Owns:

  • isSuspectLap / getSuspectReferenceLaps (85 % of the driver's own median of up to 20 normal laps on the layout, at least 5), used by decoder_runtime.js to set passing.suspect in practice sessions
  • countsForBestLap, listPendingSuspectLaps (Timing / Race Day panel via renderSuspectLapsPanel in timing_views.js)
  • decisions go through setCompetitorLapStatus in judging_logic.js (remove / restore / approve, logged and undoable); app.js saves judge corrections with saveCorrectionFor, which also posts the session result to POST /api/session-results

src/misc_views.js

Type:

  • misc page renderers

Owns:

  • Guide page
  • Overlay page inside admin UI

src/settings.js

Type:

  • settings page renderer

Owns:

  • the page's 4-tab layout (.settings-tabs: Connection, Audio, Appearance & overlay, System & backup) - activeSettingsTab is module-scoped state, not part of app.js's state object, so the selected tab survives a re-render (e.g. after a save) within the same page load but resets on navigation
  • backend/decoder settings UI (Connection tab) and the "Banlayouter" track-layout panel (Appearance & overlay tab)
  • OBS settings UI
  • branding/audio settings UI
  • import/export settings UI
  • destructive data-clear controls and confirmation dialogs

settings.js never parses CSV or mutates canonical collections directly. It reads the selected file and calls the injected importDirectoryCsv(text, type) controller from app.js. Clear buttons similarly call the injected clearDataScope(scope) controller after confirmation.

src/csv_import.js

Type:

  • pure directory CSV parser and merge logic

Owns:

  • RFC-style quoted field parsing for comma-, semicolon-, and tab-separated files
  • UTF-8 BOM and Swedish/English column aliases
  • driver matching by stable ID, transponder, or unique name + class
  • car matching by stable ID, transponder, or unique name
  • automatic class creation for imported class names
  • non-destructive field merge where empty CSV cells do not erase existing values
  • added/updated/skipped import counters

It does not access DOM, browser storage, or global state. app.js passes current collections and uid, then normalizes and persists the returned collections.

Clear operations remove canonical state and dependent references only. The separate SQLite passings table is intentionally retained as an audit log because the LAN UI has no authenticated administrator boundary. Do not add a destructive passings endpoint without first implementing real admin authentication and authorization.

src/training_logic.js

Type:

  • pure training/statistics logic

Owns:

  • training-state normalization
  • durable ledger synchronization from resultsBySession
  • valid driver-lap aggregation across the training ledger, scoped by personal profile and class
  • lifetime/session/day/best-lap/average/consistency calculations
  • active Open/Free/regular Practice statistics scoped to one personal profile, one class, and the current session start
  • stint splitting at persisted decoder stintBoundary markers, with gaps greater than 120000 ms as a legacy fallback
  • best N consecutive laps and fixed-duration result calculation
  • five-minute (300000 ms) results include the final lap when its start is before the cutoff and expose average pace for the counted laps
  • daily summaries, stint-scoped controlled-lap streak, personal control zone, and recent personal-best gain
  • signed personal pace and consistency trends from the latest 30 laps versus the preceding 30, with a minimum of 10 laps in each window
  • derived automatic achievements for sessions, lap totals, training days, controlled streaks, strong finishes, and clean/completed five-minute results
  • derived optional daily missions for lap volume, controlled consecutive laps, and a completed five-minute stint
  • derived positive highlights for current activity, personal-best and split-time gains, pace, consistency, strong finishes, and endurance
  • active 30-minute pace buckets using a trimmed average and omitting empty clock periods
  • persisted class-specific setup markers and 30-minute before/after comparisons
  • extended stint analysis with normal pace, best 10-lap race pace, outlier count, consistency, start/end pace retention, and strong-finish detection
  • rule-based practice recommendation selection
  • automatic and custom milestone progress calculation
  • shared club-goal progress from a persisted ledger baseline

Rules:

  • only passings with a matching driverId are included
  • profileId links multiple race-specific driver records to one person without changing decoder or race identity
  • statistics combine linked driver records only when their classId matches the selected class
  • 2WD and 4WD lap times, stints, best-three, five-minute results, and recommendations are never combined
  • legacy driver records without profileId are grouped by normalized driver name until explicitly linked or separated in the Drivers UI
  • invalid laps and lap times at or below 500 ms are excluded; counted max-boundary laps remain in the ledger but are excluded from pace calculations
  • anonymous Open Practice transponders are excluded until registered as drivers
  • consistency is the P10-to-P90 spread of the latest 30 valid laps divided by their mean
  • pace trend is previous-window mean - current-window mean, so a positive value means faster recent laps
  • consistency trend is previous spread - current spread, so a positive value means a smaller recent spread
  • trend fields remain neutral until both comparison windows contain at least 10 valid laps
  • achievements are derived on read and are not persisted; custom reward goals remain persisted in state.training.goals
  • daily missions and positive highlights are derived on read, reset by local calendar day, and remain isolated to the selected class
  • the shared club goal is persisted, but its progress is derived from ledger.length - clubGoal.baselineLaps
  • every save replaces ledger rows only for session IDs that still exist in resultsBySession
  • ledger rows for a deleted session are retained and become historical records
  • sourceKey deduplicates repeated saves of the same session passing
  • a session change always starts a new stint
  • a passing with stintBoundary: true starts a new stint at that passing; a gap strictly greater than 2 minutes is the fallback for old data
  • a boundary lap whose lapMs contains the full break is counted in race results but excluded from pace and duration metrics
  • duration-result ranking compares lap count first and elapsed result time second
  • a duration result is valid only after crossing the target and includes a final lap whose start is before the target cutoff
  • a stint needs at least 3 valid pace laps for consistency/ranking and at least 10 for Race pace 10
  • an outlier lap is more than 15 percent slower than that stint's median
  • the personal control zone spans the class-specific personal best through 8 percent slower; its flow streak cannot cross a stint, source, or session boundary
  • a strong finish requires at least 6 pace laps and closing-third pace no slower than 1 percent above opening-third pace
  • setup comparisons need at least 3 valid laps in each 30-minute side of the marker

src/training_views.js

Type:

  • training page renderer/controller

Owns:

  • driver selector
  • simple default view with four key statistics, recommendation, next level, and active rewards
  • progressive disclosure for goal/share administration, detailed history, and club comparison
  • milestone/reward create, claim, and delete actions
  • mobile-responsive training markup
  • compact live-practice card and primary personal-profile action for the selected driver
  • personal trend and achievement cards that never combine classes
  • positive-credit and optional daily-challenge cards based on the selected driver's own development
  • recent club progress feed ordered by latest activity, without position numbers or cross-driver performance scoring
  • shared club-goal create, celebrate, replace, and delete actions in the overview
  • one collapsed More statistics section containing pace heatmap, setup-marker actions, weekday pace, control-zone/outlier/strong-finish totals, stint analysis, timing channels, and history

Mutations:

  • replaces or appends entries in state.training.goals
  • creates, rotates, enables, or disables entries in state.training.shares
  • persists per-share visibility for development, achievements, performance/stints, goals, and history
  • creates and removes entries in state.training.markers
  • replaces or clears state.training.clubGoal
  • always calls injected saveState() before rerender

Route behavior:

  • /?view=training opens the training view directly
  • driver=<profileId> selects a personal profile and class=<classId> selects its performance class for bookmarks
  • an explicitly enabled personal profile receives /public-training/<shareToken> as a separate restricted profile with a class selector and one token-limited setup-marker action
  • replacing or disabling the share invalidates its old route immediately after state sync

public-training.html

Type:

  • standalone public-profile entry document

Loads:

  • /src/public_training.css
  • /src/public_training.js

Served only after server.js validates the enabled share token in /public-training/:shareToken. QR images use /public-training/qr/:shareToken and validate the same token. baseUrl=local prefers a private 192.168.x.x or 10.x.x.x RaceCore server address and otherwise uses the first LAN address; an explicit HTTP(S) baseUrl is normalized to its origin for the public QR. The saved public value is settings.publicBaseUrl.

src/public_training.js

Type:

  • standalone public profile frontend

Owns:

  • fetching profile data from GET /public-training/api/:shareToken
  • posting only class-scoped setup markers to POST /public-training/api/:shareToken/markers
  • rendering one person's sanitized statistics, pace chart, recent stints, goals, rewards, and daily history with a class selector
  • hiding optional sections according to the server-provided share visibility map
  • switching between class-specific statistics without combining lap performance across classes
  • local 20-minute practice timer with 10-minute push/control phases
  • token-scoped start-age and bounded-window polling for aggregate lap count, best lap, and average during the personal timer; using an age avoids mobile/server clock-skew errors and the bounded window freezes results after the timer finishes
  • Swedish/English copy selected by lang query or browser locale

Important isolation:

  • does not import app.js
  • does not call /api/state
  • cannot mutate race, register, timing, goal, or reward state; its only backend mutation is an authenticated class-scoped setup marker
  • timer state is stored in the mobile browser under a token-and-class-scoped localStorage key
  • session-window responses contain aggregates only; individual lap rows and timestamps are not exposed

src/public_training.css

Type:

  • standalone responsive public-profile stylesheet

Owns:

  • phone-first layout independent from the admin sidebar/theme system
  • profile, level, timer, pace chart, collapsible stint details, goals, and daily-history presentation

3. Direct Import Dependencies

3.1 Static import graph

src/app.js imports

  • src/overlays.js
  • src/settings.js
  • src/race_setup_ui.js
  • src/event_race_logic.js
  • src/timing_logic.js
  • src/core_views.js
  • src/misc_views.js
  • src/event_common.js
  • src/event_workspace_controller.js
  • src/event_manager_view.js
  • src/event_manager_controller.js
  • src/runtime_services.js
  • src/decoder_runtime.js
  • src/timing_views.js
  • src/race_render_helpers.js
  • src/judging_logic.js
  • src/training_logic.js
  • src/training_views.js
  • src/csv_import.js

Training/profile imports

  • src/core_views.js imports src/driver_profile_logic.js
  • src/training_logic.js imports src/driver_profile_logic.js
  • src/training_views.js imports src/driver_profile_logic.js

src/event_workspace_controller.js imports

  • src/event_views.js

Backend/root imports

  • server.js imports Node core + express + better-sqlite3 + server/public_training_profile.js
  • server/public_training_profile.js imports no runtime modules
  • scripts/serverctl.js imports Node core only

3.2 Injected dependency graph

This project uses dependency injection heavily. These relationships do not appear as import statements, but they are architecturally real.

flowchart TD
  App[src/app.js]
  TRL[src/timing_logic.js]
  ERL[src/event_race_logic.js]
  EMC[src/event_manager_controller.js]
  TV[src/timing_views.js]
  OV[src/overlays.js]
  MISC[src/misc_views.js]
  RT[src/runtime_services.js]
  DEC[src/decoder_runtime.js]
  EW[src/event_workspace_controller.js]
  EView[src/event_views.js]
  EMV[src/event_manager_view.js]

  App --> TRL
  App --> ERL
  App --> EMC
  App --> TV
  App --> OV
  App --> MISC
  App --> RT
  App --> DEC
  App --> EW
  EW --> EView
  EMC --> EMV

4. app.js Wrapper Layer

app.js defines thin wrappers around extracted module exports so they receive the current state, current language, formatters, and other app-level helpers.

This wrapper layer is one of the most important parts of the architecture.

4.1 Event/race wrappers

  • normalizeRaceTeam
  • normalizeStoredRacePreset
  • getRaceFormatPresets
  • applyRaceFormatPreset
  • buildRaceFormatConfigFromForm
  • normalizeBrandingConfig
  • normalizeEvent
  • resolveEventBranding
  • buildDefaultRaceWizardDraft
  • getRaceWizardPreset
  • applyRaceWizardPresetDefaults
  • ensureRaceParticipantsConfigured
  • buildRaceSession
  • buildTrackSession
  • createSponsorRounds
  • buildRaceSessionsFromWizard
  • getRaceWizardSessionPlan
  • getEventDrivers
  • getEventTeams
  • getTeamDriverPool
  • findEventTeamForPassing
  • generateQualifyingForRace
  • reseedUpcomingQualifying
  • generateFinalsForRace
  • applyBumpsForRace
  • buildEventSchedule

4.2 Timing wrappers

  • getSessionTypeLabel
  • getStatusLabel
  • isUntimedSession
  • getActiveSession
  • getSessionTargetMs
  • getSessionLapWindow
  • isCountedPassing
  • getVisiblePassings
  • getPassingValidationLabel
  • getSessionTiming
  • ensureSessionResult
  • formatLapDelta
  • formatLeaderboardGap
  • getCompetitorElapsedMs
  • getCompetitorPassings
  • getCompetitorSeedMetric
  • getSessionEntrants
  • buildPracticeStandings
  • getQualifyingPointsValue
  • isHighPointsTable
  • compareNumberSet
  • buildQualifyingTieBreakNote
  • hasQualifyingPrimaryTie
  • buildQualifyingStandings
  • formatTeamActiveMemberLabel
  • buildTeamRaceStandings
  • buildTeamStintLog
  • getSessionGridEntries
  • getSessionGridOrder
  • ensureSessionDriverOrder
  • buildFinalStandings

4.3 Judging wrappers

  • getManualCorrectionSummary
  • applyCompetitorCorrection
  • recalculateCompetitorFromPassings
  • invalidateCompetitorLastLap
  • restoreCompetitorLastInvalidLap
  • findPassingByUndoMarker
  • undoJudgingAdjustment
  • getJudgeFilteredRows
  • getJudgeFilteredLog

4.4 Event/session shared wrappers

  • getSessionsForEvent
  • getModeLabel
  • normalizeStartMode
  • getStartModeLabel
  • getClassName
  • getEventName
  • renderAssignmentList
  • renderSessionsTable

4.5 Runtime wrappers

  • createDefaultAmmcConfig
  • getManagedWsUrl
  • loadAmmcConfigFromBackend
  • saveAmmcConfigToBackend
  • refreshAmmcStatus
  • startManagedAmmc
  • stopManagedAmmc
  • applyPersistedState
  • hydrateFromBackend
  • scheduleBackendSync
  • syncStateToBackend
  • pingBackend
  • checkAppVersion
  • startAppVersionPolling
  • startOverlaySync
  • startOverlayRotation
  • startOverlayLiveRefresh
  • ensureAudioContext
  • playPassingBeep
  • playFinishSiren
  • playLeaderCue
  • playStartCue
  • playBestLapCue
  • pushOverlayEvent
  • speakText
  • announcePassing
  • announceRaceFinished
  • handleSessionTimerTick
  • tickClock

4.6 Decoder wrappers

  • connectDecoder
  • disconnectDecoder
  • processDecoderMessage

5. State Model

Global state owner:

  • src/app.js

Primary state buckets:

  • state.events
  • state.sessions
  • state.drivers
  • state.cars
  • state.classes
  • state.resultsBySession
  • state.training
  • state.settings
  • state.passings

UI and runtime state in or near app.js:

  • selected event/session/team/grid IDs
  • current view
  • overlay mode/view mode
  • backend dirty/synced version flags
  • version polling flags
  • audio context
  • decoder connection reference

5.1 Persistence split

Frontend-local / backend-mirrored state:

  • most of state
  • synced through /api/state

SQLite persisted backend data:

  • app state snapshot
  • passings history

Separate config file:

  • data/ammc_config.json

6. Save / Rerender Rules

6.1 Rule

If a change must survive rerender, update the canonical state array.

Good:

state.events = state.events.map((item) =>
  item.id === eventId ? normalizeEvent({ ...item, raceConfig: nextConfig }) : item
);
saveState();
renderView();

Risky:

event.raceConfig = nextConfig;
saveState();
rerenderEventManager(eventId);

6.2 Why this matters

After the code split, many bugs came from:

  • changing a local event reference
  • but not replacing the stored object in state.events
  • then rerender reading from canonical state and showing the old value again

This pattern is especially important in:

  • src/event_manager_controller.js
  • src/event_workspace_controller.js

7. Screen Ownership

Overview

Render chain:

  • app.js -> renderView() -> renderDashboardView() in src/core_views.js

Classes / Drivers / Cars

Render chain:

  • app.js -> renderView() -> renderClassesView() / renderDriversView() / renderCarsView()

Event / Race Setup outer page

Render chain:

  • app.js -> renderEventWorkspace(mode)
  • event_workspace_controller.js -> renderEventWorkspaceMarkup() from event_views.js

Event / Race Setup manager (Hantera)

Render chain:

  • app.js -> renderEventManager(eventId)
  • event_manager_controller.js -> renderEventManagerMarkup() from event_manager_view.js

Timing / Judging

Render chain:

  • app.js -> renderView() -> renderTimingView() / renderJudgingView() from timing_views.js

Overlay admin page

Render chain:

  • app.js -> renderOverlay() -> renderOverlayPageView() from misc_views.js

Public overlays

Render chain:

  • app.js -> renderOverlay() -> helpers in overlays.js

Settings

Render chain:

  • app.js -> renderView() -> renderSettings()

8. End-to-End Flows

8.1 Page load

sequenceDiagram
  participant B as Browser
  participant A as app.js
  participant R as runtime_services.js
  participant S as server.js
  participant DB as SQLite

  B->>A: load app.js
  A->>A: loadState()
  A->>R: hydrateFromBackend()
  R->>S: GET /api/state
  S->>DB: read app_state
  DB-->>S: stored JSON
  S-->>R: state payload
  R-->>A: applyPersistedState()
  A->>A: renderNav()/renderView()

8.2 Event manager save flow

sequenceDiagram
  participant UI as event_manager_view.js
  participant C as event_manager_controller.js
  participant A as app.js
  participant R as runtime_services.js
  participant S as server.js

  UI->>C: submit form
  C->>C: build normalized object/config
  C->>A: update canonical state array
  C->>A: saveState()
  A->>R: schedule/sync backend state
  R->>S: POST /api/state
  C->>A: renderView()/rerenderEventManager()

8.3 Decoder passing flow

sequenceDiagram
  participant D as Decoder/AMMC WS
  participant DR as decoder_runtime.js
  participant TL as timing_logic.js
  participant A as app.js
  participant S as server.js
  participant DB as SQLite

  D->>DR: websocket message
  DR->>A: resolve session/driver/car/team
  DR->>TL: lap validation + result mutation inputs
  DR->>A: update resultsBySession
  A->>S: POST /api/passings
  S->>DB: insert passing
  A->>A: rerender timing/overlay

8.3.1 Multi-decoder topology and ownership

Operator-facing installation and IP configuration is documented in docs/decoder-setup.md and docs/decoder-setup.sv.md. This architecture section defines code ownership and invariants; the setup guides define deployment values and verification order.

The decoder runtime supports four explicit roles. Roles are semantic, not inferred from loop_id; the WebSocket connection used to receive a message determines how it is processed.

Runtime role Frontend URL setting Managed AMMC config Default port Canonical mutation
main settings.wsUrl decoderHost / wsPort 9000 validates and appends result.passings; increments laps
split settings.splitWsUrl splitDecoderHost / splitWsPort 9001 appends result.splits; updates split fields only
pit_entry settings.pitEntryWsUrl pitEntryDecoderHost / pitEntryWsPort 9002 opens pit state and appends a pit entry event
pit_exit settings.pitExitWsUrl pitExitDecoderHost / pitExitWsPort 9003 closes pit state and appends the measured stop

Physical and network chain:

physical loop -> physical decoder IP -> one AMMC child process on backend host
              -> unique AMMC WebSocket port -> browser WebSocket client with fixed role
              -> processDecoderMessageHelper(message, deps, role)

Example complete installation:

Main decoder      192.168.1.10 -> backend AMMC :9000 -> role main
Split decoder     192.168.1.11 -> backend AMMC :9001 -> role split
Pit entry decoder 192.168.1.12 -> backend AMMC :9002 -> role pit_entry
Pit exit decoder  192.168.1.13 -> backend AMMC :9003 -> role pit_exit
Browser host 192.168.1.100 uses ws://192.168.1.100:9000 through :9003

Invariants:

  • only main may change entry.laps, entry.lastTimestamp, or append to result.passings
  • minLapMs is a hard validity threshold: a faster passing is stored as below_min and does not increment laps
  • maxLapMs is a statistics threshold: a slower passing increments race laps, carries above_max_stint, and starts a new pace stint
  • split timing is measured from the latest main timestamp, or mass-start timestamp before the first completed lap
  • a duplicate pit-entry signal while entry.pitActive is true is ignored
  • pit exit without a prior pit entry is ignored
  • an optional channel can disconnect without stopping main lap counting
  • main, split, pit entry, and pit exit must use distinct AMMC WebSocket ports
  • every physical decoder normally has a distinct IP; reusing one feed for different roles would process the same crossing with conflicting semantics

8.3.2 Multi-decoder frontend lifecycle

src/app.js owns role-indexed wsClients and reconnectTimers maps. connectDecoderHelper() first disconnects all clients, always connects main, then connects enabled optional roles. connectDecoderRole() binds the role into every message callback. Auto-reconnect checks the current enable flags before reconnecting optional channels.

Decoder runtime state is persisted with separate status fields:

decoder: {
  connected, lastMessageAt, lastError,
  splitConnected, splitLastMessageAt, splitLastError,
  pitEntryConnected, pitEntryLastMessageAt, pitEntryLastError,
  pitExitConnected, pitExitLastMessageAt, pitExitLastError
}

Race data is stored under resultsBySession[sessionId]:

{
  passings: MainPassing[],
  splits: SplitPassing[],
  pitEvents: PitEvent[],
  pitStops: PitStop[],
  competitors: {
    [competitorKey]: {
      lastSplitMs, previousSplitMs, lastSplitDeltaMs, bestSplitMs,
      lastSplitTimestamp, lastSplitLap,
      pitActive, pitEnteredAt, pitStops,
      lastPitStopMs, totalPitMs, bestPitStopMs
    }
  }
}

The full result object is included in /api/state persistence. Only main passings are posted to /api/passings and the SQLite passings table. This preserves the existing lap ledger and prevents split/pit records from being interpreted as laps. If split or pit history later needs SQL reporting, add dedicated tables and endpoints rather than inserting those records into passings.

8.3.3 Split/pit attribution, analysis, and presentation

src/decoder_runtime.js snapshots the active member on pit_entry into the competitor record:

{
  pitEntryDriverId, pitEntryDriverName,
  pitEntryCarId, pitEntryCarName,
  pitEntryTransponder
}

On pit_exit, the resulting PitStop uses that entry snapshot rather than the currently active team member. This is essential for endurance races: a team may switch driver, car, or transponder while stopped. exitTransponder is stored separately and must not overwrite the entry attribution. After the stop is appended, the snapshot fields are cleared.

Analysis ownership is deliberately split into two pure functions:

Function Module Scope Consumers
buildSessionChannelStats(session, deps) src/timing_logic.js one heat/session, grouped by competitor key Timing, Race Setup, team standings, HTML reports, PDF reports
buildDriverTimingChannelStats(state, driverId, classId) src/training_logic.js one personal driver profile and selected class across sessions Training detailed statistics
buildPublicTimingChannelStats(state, classDriverIds) server/public_training_profile.js sanitized aggregate for one shared profile class public training profile API and mobile view

buildSessionChannelStats() aggregates split count/best/average and pit count/total/best/average, and returns a chronological enriched pit-stop log. renderSessionChannelAnalysis() in src/race_render_helpers.js is the canonical HTML presentation. Do not independently recalculate these values in Timing or Race Setup.

buildDriverTimingChannelStats() resolves every driver record belonging to the same personal profile but applies the selected class filter before reading resultsBySession. Consequently multiple transponders in 2WD can be aggregated while 2WD and 4WD data remain separate.

The public builder repeats the class isolation on the server because the client must never receive the full state. Its output is limited to split count/best/average and pit count/total/best/average. It intentionally excludes transponders, competitor keys, driver IDs, event names, timestamps, and individual pit stops. Keep the serialization security assertion in tests/public_training_profile.test.js whenever this payload changes.

Presentation/data path:

decoder_runtime -> resultsBySession[sessionId].splits/pitStops
                -> buildSessionChannelStats
                -> renderSessionChannelAnalysis
                -> Timing / Race Setup / HTML print
                -> buildSessionChannelPdfSections -> PDF export

resultsBySession -> buildDriverTimingChannelStats
                 -> Training / More statistics

resultsBySession -> buildPublicTimingChannelStats (server-side sanitized)
                 -> /public-training/api/:shareToken
                 -> personal mobile profile

Important invariants:

  • all displayed pit-stop counts come from completed pitStops, never unmatched pitEvents
  • the pit log is available for every session type, not only team_race
  • an optional split/pit channel never changes race laps, lap ranking, or finish timing
  • old stored pit stops without entry-attribution fields remain readable via competitor fallback labels
  • reports must use the same builders as live views to avoid differing totals

8.3.4 Managed AMMC process lifecycle

server.js owns four process states in ammcState.channels: main, split, pitEntry, and pitExit. startAmmcProcesses() normalizes and validates the complete configuration before spawning one child process per enabled role. All roles use the same resolved AMMC executable and working directory but receive different -w <port> <decoderHost> arguments.

The AMMC status API remains backward compatible: top-level status fields represent main; nested split, pitEntry, and pitExit objects expose optional process status. Combined lastOutput records include a role field. stopAmmcProcesses() stops all roles during an API stop request or server shutdown.

Configuration validation rejects:

  • ports outside 1..65535
  • duplicate ports among enabled channels
  • an enabled role without its decoder host when AMMC is started
  • missing AMMC executable

Changing backend AMMC process code requires npm restart or npm.cmd restart. Editing browser modules alone is handled by app-version polling, but a server process cannot hot-reload its own implementation.

8.4 Public overlay flow

sequenceDiagram
  participant O as Public overlay page
  participant A as app.js
  participant R as runtime_services.js
  participant S as server.js
  participant DB as SQLite

  O->>A: load public overlay route
  A->>R: hydrateFromBackend()
  R->>S: GET /api/state
  S->>DB: read app_state
  DB-->>S: stored JSON
  S-->>R: state snapshot
  A->>A: render overlay view
  A->>R: overlay sync polling

9. Dependency/Ownership Matrix

File Type Direct imports Imported by Owns DOM? Owns state? Mutates canonical state?
src/app.js shell many browser entry yes yes yes
src/runtime_services.js runtime helpers none app.js no no indirectly via injected setters
src/decoder_runtime.js runtime helpers none app.js no no yes, through injected state access
src/timing_logic.js logic none app.js no no yes, on session results through injected access
src/judging_logic.js logic none app.js no no yes, on session results through injected access
src/event_race_logic.js logic none app.js no no no, returns normalized/generated data
src/driver_profile_logic.js pure logic none app.js, core_views.js, training_logic.js, training_views.js no no no
src/track_layouts_logic.js pure logic none app.js no no no
src/race_render_helpers.js render helpers none app.js yes no no
src/race_setup_ui.js render helpers none app.js yes no no
src/event_common.js shared helpers none app.js mixed no no
src/event_views.js view none event_workspace_controller.js yes no no
src/event_workspace_controller.js controller event_views.js app.js yes no yes
src/event_manager_view.js view none app.js yes no no
src/event_manager_controller.js controller none app.js yes no yes
src/overlays.js view/render none app.js yes no no
src/session_replay.js pure replay logic none app.js, timing_views.js, tests no timer timestamps only no
src/timing_views.js view/render session_replay.js app.js yes replay timer only no
src/core_views.js view/render none app.js yes no no
src/misc_views.js view/render none app.js yes no no
src/settings.js view/render none app.js yes no no
src/csv_import.js pure import logic none app.js, tests no no no, returns merged collections
src/training_logic.js logic none app.js no no no, returns normalized/derived data
src/training_views.js view/controller none app.js yes no yes, through injected state access
public-training.html standalone entry public_training.css, public_training.js server.js route root only no no
src/public_training.js standalone public view none public-training.html yes local timer only no backend mutation
src/public_training.css standalone stylesheet n/a public-training.html n/a no no
server/public_training_profile.js backend pure logic none server.js, tests no no no
server.js backend runtime node core + deps npm/node n/a backend process state yes
scripts/serverctl.js process control node core npm scripts n/a no pid/log files only

10. Symptom-Based Entry Points

Value reverts after save

Open first:

  • src/event_manager_controller.js
  • src/event_race_logic.js
  • src/app.js

Button click does nothing

Open first:

  • matching *_view.js
  • matching *_controller.js

Overlay/timing crashes with missing helper

Open first:

  • src/app.js
  • target module signature / deps object

Wrong drivers in race/team flow

Open first:

  • src/event_race_logic.js
  • src/event_manager_controller.js
  • src/event_manager_view.js

Decoder online but no laps

Open first:

  • src/decoder_runtime.js
  • src/timing_logic.js
  • src/runtime_services.js

Public overlay works differently from admin overlay

Open first:

  • src/overlays.js
  • src/misc_views.js
  • src/runtime_services.js
  • server.js

11. Frontend vs Backend Boundary

Start in frontend when the problem is:

  • form save/revert
  • button binding
  • modal behavior
  • wrong driver/team/session visibility
  • leaderboard/standings math
  • overlay layout or missing values

Start in backend when the problem is:

  • persisted backend state endpoint
  • AMMC process control
  • public overlay route resolution
  • SQLite schema/data corruption
  • PDF export endpoint
  • app version/file-watch reload

12. Maintenance Discipline

Whenever a new module is added or a major responsibility moves:

  • update this file
  • add direct imports
  • note who calls the module
  • note whether it mutates canonical state
  • note whether the module owns DOM or only logic

This file should stay strict and technical. It is a debugging map, not user documentation.

13. Known Fragile Paths

These are the parts of the codebase that have historically been most likely to regress after refactors.

13.1 src/event_manager_controller.js

Why fragile:

  • owns many independent save paths in one controller
  • mixes DOM binding, normalization, state updates, and rerender logic
  • uses both state.events and state.sessions
  • depends on a large injected dependency set from app.js

High-risk operations:

  • race format save
  • preset apply/save/delete
  • session create/edit/delete
  • team create/edit/delete
  • assignment actions
  • grid actions

Typical failures:

  • button click works but value reverts after rerender
  • wrong scope for team drivers vs race participants
  • session type saves wrong value
  • modal save button stops submitting after markup change

Debug rule:

  • first verify the handler fires
  • then verify canonical array update
  • then verify rerender reads the updated object

13.2 src/event_race_logic.js

Why fragile:

  • contains defaults and normalization logic
  • owns race presets and wizard defaults
  • decides driver/team visibility in race flows

High-risk functions:

  • normalizeEvent(...)
  • buildRaceFormatConfigFromForm(...)
  • buildOpenPracticeBundle(...)
  • getEventDrivers(...)
  • getTeamDriverPool(...)
  • generateQualifyingForRace(...)
  • generateFinalsForRace(...)

Typical failures:

  • saved values snap back to defaults
  • wrong driver lists in race/team setup
  • preset application overwrites custom changes

Debug rule:

  • check whether normalization is reintroducing fallback values
  • check whether the controller passes the correct event object into these helpers

13.3 src/app.js dependency wiring

Why fragile:

  • almost every extracted module still depends on app.js wiring
  • one missing helper injection can break a whole screen

Typical failures:

  • ... is not defined
  • ... is not a function
  • only one overlay mode breaks while others work
  • a view loads but one action silently fails

Debug rule:

  • inspect the wrapper in app.js
  • inspect the called module signature
  • verify the helper is passed through exactly once

13.4 src/timing_logic.js

Why fragile:

  • central math for leaderboard, standings, lap validity, and team timing
  • used by normal race, qualifying, finals, free practice, and team race

High-risk functions:

  • getSessionLapWindow(...)
  • buildLeaderboard(...)
  • buildQualifyingStandings(...)
  • buildTeamRaceStandings(...)
  • buildTeamStintLog(...)

Typical failures:

  • invalid laps counted or hidden incorrectly
  • gap/delta metrics wrong
  • team standings or stints split wrong

Debug rule:

  • confirm incoming session config first
  • then inspect row/metric calculation
  • only after that inspect rendering

13.5 src/runtime_services.js

Why fragile:

  • multiple background loops interact with UI and backend state
  • can produce symptoms that look unrelated to runtime logic

Typical failures:

  • decoder online/offline after refresh
  • page reloads at the wrong time
  • overlay timer/preview stops moving
  • stale backend state overwrites local changes

Debug rule:

  • inspect timers and polling before touching screen renderers

14. Function-Level Map

This section lists the most important functions to know in each critical file.

14.1 src/app.js

Bootstrap and persistence

  • init()
    • application startup
    • runs initial render/bootstrap flow
  • seedDefaultData()
    • seeds initial local data when needed
  • loadState()
    • reads persisted frontend state
  • saveState(options)
    • persists frontend state and schedules backend sync
  • buildPersistableState()
    • selects what is written to backend/local persistence

Routing and rendering

  • renderNav()
    • main side navigation render
  • renderView()
    • main route switch for current page
  • renderEventWorkspace(mode)
    • thin wrapper into event workspace controller
  • renderEventManager(eventId)
    • thin wrapper into event manager controller
  • renderOverlay()
    • overlay/admin overlay dispatch point

Normalization and formatting

  • normalizeDriver(...)
  • normalizeCar(...)
  • normalizeSession(...)
  • formatLap(...)
  • formatPredictedLapDelta(...)
  • formatCountdown(...)
  • formatElapsedClock(...)
  • formatRaceClock(...)
  • formatSeedMetric(...)

Export/print helpers

  • buildRacePackagePayload(...)
  • importRacePackagePayload(...)
  • openPrintWindow(...)
  • requestPdfExport(...)
  • exportRaceStartListsPdf(...)
  • exportRaceResultsPdf(...)
  • exportTeamRaceResultsPdf(...)
  • exportSessionHeatSheetPdf(...)

Decoder/session helpers still local

  • persistPassingToBackend(...)
  • validateTrackSessionForStart(...)
  • findDuplicateSessionTransponders(...)
  • autoAssignTrackSession(...)

14.2 src/event_manager_controller.js

Manager setup

  • renderEventManagerView(context)
    • main controller entry
    • resolves event/session/team/current manager state
    • binds all forms and buttons in Hantera

Important internal controller patterns

  • refreshManager()
    • full rerender helper used after saves
  • updateEvent(updater)
    • canonical state.events update path

High-risk save sections inside this controller

  • branding save form submit
  • race format form submit
  • preset apply/save/delete handlers
  • session create form submit
  • session edit form submit
  • team create form submit
  • team edit form submit
  • team delete action

If one of these breaks, inspect this controller first.

14.3 src/event_race_logic.js

Normalization

  • normalizeEvent(...)
    • central race/event normalization
  • normalizeBrandingConfig(...)
  • normalizeRaceTeam(...)
  • normalizeStoredRacePreset(...)

Race format / preset logic

  • getRaceFormatPresets(...)
  • applyRaceFormatPreset(...)
  • buildRaceFormatConfigFromForm(...)

Race wizard logic

  • buildDefaultRaceWizardDraft(...)
  • getRaceWizardPreset(...)
  • applyRaceWizardPresetDefaults(...)
  • buildRaceSessionsFromWizard(...)
  • getRaceWizardSessionPlan(...)

Pool/scoping logic

  • getEventDrivers(...)
  • getEventTeams(...)
  • getTeamDriverPool(...)
  • findEventTeamForPassing(...)

Race generation logic

  • generateQualifyingForRace(...)
  • reseedUpcomingQualifying(...)
  • generateFinalsForRace(...)
  • applyBumpsForRace(...)

14.4 src/timing_logic.js

Session state

  • getActiveSession(...)
  • getSessionTargetMs(...)
  • getSessionLapWindow(...)
  • getSessionTiming(...)

Leaderboard core

  • ensureSessionResult(...)
  • buildLeaderboard(...)
  • getCompetitorPassings(...)
  • getCompetitorSeedMetric(...)
  • formatLeaderboardGap(...)
  • formatLapDelta(...)

Standings

  • buildPracticeStandings(...)
  • buildQualifyingStandings(...)
  • buildFinalStandings(...)
  • buildTeamRaceStandings(...)
  • buildTeamStintLog(...)

Grid

  • getSessionGridEntries(...)
  • getSessionGridOrder(...)
  • ensureSessionDriverOrder(...)

14.5 src/runtime_services.js

Backend sync/runtime

  • hydrateFromBackendHelper(...)
  • syncStateToBackendHelper(...)
  • scheduleBackendSyncHelper(...)
  • pingBackendHelper(...)
  • checkAppVersionHelper(...)
  • startAppVersionPollingHelper(...)

Overlay/runtime refresh

  • startOverlaySyncHelper(...)
  • startOverlayRotationHelper(...)
  • startOverlayLiveRefreshHelper(...)
  • tickClockHelper(...)
  • handleSessionTimerTickHelper(...)

Background rendering rule:

  • Timing, Dashboard, and Overlay may rerender for live timing updates where explicitly allowed.
  • decoder WebSocket open, close, and error events must not rerender Settings; they update the global connection badge instead.
  • app.js#renderViewFromBackground() skips the one-time backend/voice refresh while a Settings control has focus or has changed.
  • explicit Settings actions such as Save, Start AMMC, Stop AMMC, or Refresh may render after completing because the submitted values are already captured.

AMMC/backend control

  • loadAmmcConfigFromBackendHelper(...)
  • saveAmmcConfigToBackendHelper(...)
  • refreshAmmcStatusHelper(...)
  • startManagedAmmcHelper(...)
  • stopManagedAmmcHelper(...)

Audio/event feed

  • pushOverlayEventHelper(...)
  • announcePassingHelper(...)
  • announceRaceStartedHelper(...)
  • announceRaceFinishedHelper(...)
  • speakTextHelper(...)
  • getSpeechVoicesHelper(...)
  • selectSpeechVoice(...)
  • getSessionAnnouncementText(...)

Start and finish lifecycle announcements are initiated from timing_views.js, while timer-based finish uses handleSessionTimerTickHelper(...). Passing announcements use competitor.speechName when available. Speech does not interrupt another driver name; a busy speech queue falls back to the passing beep. Start and finish announcements deliberately interrupt pending passing speech.

14.6 server.js

Backend API and persistence

  • initSchema()
    • initializes SQLite schema
  • watchAppFiles()
    • bumps app version when index.html or any top-level src/*.js / src/*.css file changes
  • loadAmmcConfig() / saveAmmcConfig()
    • AMMC config file persistence
  • normalizeAmmcConfig(...)
    • validates/stabilizes AMMC config
  • buildAmmcStatus()
    • backward-compatible main status plus nested split/pit channel statuses
  • startAmmcProcesses() / stopAmmcProcesses()
    • lifecycle for every enabled decoder role
  • startAmmcProcess(role) / stopAmmcProcess(role)
    • lifecycle for one AMMC child process
  • PDF export helpers below /api/export/pdf

Critical backend routes

  • GET /api/state
  • POST /api/state
  • POST /api/passings
  • GET/POST /api/ammc/*
  • GET /api/app-version

These are the first backend touchpoints to inspect if the frontend bug crosses the API boundary.

15. Frontend State Schema

This section describes the practical shape of the frontend state as persisted by buildPersistableState() in src/app.js.

15.1 Persisted top-level shape

{
  classes: Class[],
  drivers: Driver[],
  cars: Car[],
  events: Event[],
  sessions: Session[],
  resultsBySession: Record<string, SessionResult>,
  training: TrainingState,
  activeSessionId: string,
  settings: Settings,
  trackLayouts: TrackLayout[],
  audioEventLog: AudioEventLogEntry[]
}

trackLayouts and audioEventLog are both append/edit-only lists with no other top-level state referencing them by shape (sessions reference a layout only by trackLayoutId, a plain string - see 15.6). audioEventLog records every triggered race-audio cue ({ event, sessionId, ts, ... }, see section 20) for diagnostics and the "recent audio events" checks in the browser test suite; it is not surfaced in any admin UI.

15.1a TrackLayout

{
  id: string,
  label: string,      // defaults to "Layout N", user-renamable
  startedAt: number    // epoch ms; layouts are contiguous, so a layout's implicit
                        // end is simply the next layout's startedAt - there is no
                        // separate end/stop field to keep in sync
}

Normalized by normalizeTrackLayout/normalizeTrackLayouts in src/track_layouts_logic.js. Managed from Settings' "Banlayouter" panel; deleting a layout is blocked while any session still references it via trackLayoutId. See that module's doc entry in section 2.2 for how lookups/scoping work.

15.2 Class

{
  id: string,
  name: string
}

Produced/normalized by:

  • normalizeImportedClass() in src/app.js

15.3 Driver

{
  id: string,
  name: string,
  classId: string,
  transponder: string,
  brand: string,
  speechName: string, // optional phonetic form used only by browser speech
  profileId: string, // shared personal practice-profile identity; race identity remains `id`
  club: string, // personal profile metadata, not class performance data
  country: string // personal profile metadata, not class performance data
}

Normalized by:

  • normalizeDriver() in src/app.js

Used by:

  • state.drivers
  • race participant selection
  • team selection
  • decoder passing resolution
  • exports/imports

Profile rules:

  • src/driver_profile_logic.js resolves profileId, groups linked driver records, and returns all references belonging to one person
  • a driver without an explicit profileId receives a legacy grouping key based on its normalized name
  • creating another same-name driver links to that profile by default; the Drivers editor can link to another profile or create a separate one
  • driverId, classId, and transponder remain independent per race entry
  • club and country are inherited and synchronized across explicitly linked entries by the Drivers UI
  • practice goals and public share tokens belong to the personal profile, while measured performance belongs to a selected class

15.4 Car

{
  id: string,
  name: string,
  transponder: string,
  brand: string
}

Normalized by:

  • normalizeCar() in src/app.js

Used by:

  • state.cars
  • sponsor event shared cars
  • team race car selection
  • decoder passing resolution

15.5 Event

Practical shape:

{
  id: string,
  name: string,
  date: string,
  classId: string,
  mode: "track" | "race",
  branding: BrandingConfig,
  raceConfig: RaceConfig
}

Normalized by:

  • normalizeEvent() in src/event_race_logic.js

BrandingConfig

{
  brandName: string,
  brandTagline: string,
  pdfFooter: string,
  pdfTheme: "" | "classic" | "minimal" | "motorsport",
  logoDataUrl: string
}

Normalized by:

  • normalizeBrandingConfig() in src/event_race_logic.js

RaceConfig

Practical normalized shape:

{
  presetId: string,
  qualifyingScoring: "points" | "best",
  qualifyingRounds: number,
  carsPerHeat: number,
  qualDurationMin: number,
  qualStartMode: string,
  qualSeedLapCount: number,
  qualSeedMethod: "best_sum" | "average" | "consecutive",
  countedQualRounds: number,
  qualifyingPointsTable: "rank_low" | "field_desc" | "ifmar",
  qualifyingTieBreak: "rounds" | "best_lap" | "best_round",
  carsPerFinal: number,
  finalLegs: number,
  countedFinalLegs: number,
  finalDurationMin: number,
  finalStartMode: string,
  followUpSec: number,
  minLapMs: number,
  maxLapMs: number,
  bumpCount: number,
  reserveBumpSlots: boolean,
  driverIds: string[],
  participantsConfigured: boolean,
  finalsSource: "practice" | "qualifying",
  teams: RaceTeam[]
  practiceOnly: boolean,
  scheduleStartAt: number | null,  // epoch ms; null when no time-of-day schedule is set
  scheduleGapMin: number            // minutes between sessions, defaults to 3
}

scheduleStartAt/scheduleGapMin drive buildEventSchedule() (section 4.1, and its doc entry under src/event_race_logic.js in section 2.2) - the race day Schedule tab's cascading planned-time table. Set/cleared from Event Manager's Schedule tab (scheduleForm/scheduleClear in event_manager_controller.js).

practiceOnly: true identifies the simplified Open Practice flow. It deliberately has no class scope or configured participants, uses an open_practice session, ignores durationMin in timing logic, and must not require qualifying/finals in race summary warnings.

RaceTeam

{
  id: string,
  name: string,
  driverIds: string[],
  carIds: string[]
}

Normalized by:

  • normalizeRaceTeam() in src/event_race_logic.js

15.6 Session

Practical shape:

{
  id: string,
  eventId: string,
  name: string,
  type: string,
  durationMin: number,
  followUpSec: number,
  startMode: string,
  status: string,
  startedAt: number | null,
  endedAt: number | null,
  staggerGapSec: number,
  maxCars: number,
  seedLapCount: number,
  seedMethod: string,
  generated: boolean,
  driverIds: string[],
  manualGridIds: string[],
  gridLocked: boolean,
  trackLayoutId: string,  // "" if no track layout is configured; stamped at
                           // commitStart() to whichever layout is active then, and
                           // moved to a new layout if one is added while a practice
                           // session is running. Training laps don't inherit it - each
                           // lap is tagged with the layout active when it was driven
                           // (syncTrainingLedger)
  ...additional race/session fields
}

Normalized by:

  • normalizeSession() in src/app.js

Used by:

  • race setup
  • timing view
  • team race
  • overlay
  • exports/printouts

15.7 resultsBySession

Practical shape:

{
  [sessionId: string]: {
    competitors: {
      [rowKey: string]: {
        key: string,
        driverId?: string,
        driverName?: string,
        carId?: string,
        carName?: string,
        teamId?: string,
        teamName?: string,
        transponder: string,
        laps: number,
        bestLapMs: number,
        lastLapMs: number,
        totalMs: number,
        manualLapAdjustment?: number,
        manualTimeAdjustmentMs?: number,
        invalidPending?: boolean,
        invalidReason?: string,
        validationWarning?: "above_max_stint" | "",
        excludedFromPace?: boolean,
        stintBoundary?: boolean,
        ...runtime-derived leaderboard fields
      }
    },
    passings: Passing[],
    adjustments?: Adjustment[]
  }
}

This bucket is the most dynamic part of state and is mutated heavily during live timing and judging.

15.8 TrainingState

{
  goals: TrainingGoal[],
  ledger: TrainingLap[],
  shares: TrainingShare[], // includes per-link public section visibility
  markers: TrainingMarker[],
  clubGoal: TrainingClubGoal | null
}

TrainingGoal:

{
  id: string,
  driverId: string,
  classId: string, // class whose lap total advances the goal
  title: string,
  reward: string,
  targetLaps: number,
  createdAt: string, // ISO timestamp
  claimedAt: string // empty until reward is delivered
}

TrainingShare:

{
  driverId: string,
  token: string, // 24 random bytes encoded as base64url (192 bits)
  enabled: boolean,
  createdAt: string
}

TrainingClubGoal:

{
  id: string,
  title: string,
  reward: string,
  targetLaps: number,
  baselineLaps: number, // ledger length when this club challenge starts
  createdAt: string,
  celebratedAt: string // empty until the reached goal is acknowledged
}

Club-goal progress is intentionally aggregate-only. Creating a replacement goal captures the current ledger length as a new baseline; historical laps remain available for personal statistics but do not advance the new shared target.

Share rules:

  • no share exists until an admin explicitly enables the selected personal profile
  • only one normalized share entry is retained per personal profile
  • disabled shares fail public route lookup
  • rotating a share replaces its token; the previous URL no longer resolves after persistence
  • tokens are generated in the browser with crypto.getRandomValues(), never from the shorter UI uid() helper

TrainingLap:

{
  id: string,
  sourceKey: string, // sessionId + passing timestamp + competitor + passing index
  sessionId: string,
  sessionName: string,
  eventId: string,
  eventName: string,
  driverId: string,
  timestamp: number,
  lapMs: number,
  recordedAt: string
}

TrainingMarker:

{
  id: string,
  driverId: string, // exact driver entry; profile resolution happens on read
  classId: string,
  sessionId: string,
  timestamp: number,
  type: "setup" | "tires" | "suspension" | "car" | "note",
  note: string,
  createdAt: string
}

Normalized by:

  • normalizeTrainingState() wrapper in src/app.js
  • normalizeTrainingState() implementation in src/training_logic.js
  • syncTrainingLedger() wrapper in src/app.js
  • syncTrainingLedger() implementation in src/training_logic.js

Persisted through:

  • buildPersistableState() in src/app.js
  • applyPersistedStateHelper() in src/runtime_services.js
  • existing full-state POST /api/state backend path

Derived statistics are intentionally not stored. They are recalculated from state.training.ledger. Before persistence, saveState() synchronizes every session currently present in resultsBySession: existing rows for those session IDs are replaced from current valid passings, so judging corrections are reflected. Session IDs absent from resultsBySession are not removed from the ledger, preserving lifetime totals after archival or deletion.

TrainingLap.driverId always records the exact race/decoder driver entry that produced the lap. At read time, src/driver_profile_logic.js resolves all driver IDs belonging to the selected personal profile, then src/training_logic.js narrows them to the selected classId. This permits several transponders in one class while preventing incomparable classes from being mixed.

Migration behavior:

  • older state without training.ledger normalizes to an empty ledger
  • the next saveState() imports valid registered-driver laps from all current session results
  • invalid laps, anonymous transponders, and laps at or below 500 ms are excluded
  • older state without training.markers normalizes to an empty marker list
  • older state without training.clubGoal normalizes to null

15.9 Settings

Practical shape (field names as actually read/written in src/settings.js and src/app.js - there is no single strict schema function; it's normalized piecemeal through applyPersistedState() and each form's own submit handler):

{
  // Connection tab
  theme: "dark" | "nord" | "light",
  language: "sv" | "en",
  backendUrl: string,
  wsUrl: string,                    // main decoder WebSocket URL
  splitDecoderEnabled: boolean,
  splitWsUrl: string,
  pitDecoderEnabled: boolean,
  pitEntryWsUrl: string,
  pitExitWsUrl: string,
  autoReconnect: boolean,
  publicBaseUrl: string,

  // Audio tab
  audioEnabled: boolean,
  passingSoundMode: string,
  startSoundEnabled: boolean,
  finishSoundEnabled: boolean,
  finishVoiceEnabled: boolean,      // legacy name; controls start/finish voice announcements
  speechVoiceUri: string,
  speechRate: number,
  speechPitch: number,
  ttsEngine: "browser" | "piper", // voice engine; Piper falls back to the browser voice on any failure
  ttsVoiceSv: string,              // chosen Piper voice name per language; "" = catalog default (male)
  ttsVoiceEn: string,
  speakerPassingCueEnabled: boolean,
  speakerLeaderCueEnabled: boolean,
  speakerFinishCueEnabled: boolean,
  speakerBestLapCueEnabled: boolean,
  speakerTop3CueEnabled: boolean,
  speakerSessionStartCueEnabled: boolean,
  raceAudio: RaceAudioSettings,     // see section 20.3 - the newer, more complete race-audio config (countdown, lights, per-event effect/voice toggles, staggered-release timing)

  // Appearance & overlay tab
  clubName: string,
  clubTagline: string,
  pdfFooter: string,
  pdfTheme: "" | "classic" | "minimal" | "motorsport",
  logoDataUrl: string,
  obsOverlay: object,               // OBS overlay layout/theme/column config

  // System & backup tab
  racePresets: StoredRacePreset[]
}

trackLayouts (section 15.1a) lives at the top level, not under Settings, even though it's managed from the Appearance & overlay tab alongside branding.

Settings' own 4-tab layout (activeSettingsTab, module-scoped - see the src/settings.js doc entry in 2.2) groups these fields as marked above; the underlying state.settings object itself has no such grouping, it's a flat bag of fields.

15.10 SQLite Table Contract

Defined in initSchema() in server.js. There are exactly two tables - almost everything else in the app (classes, drivers, cars, events, sessions, results, settings, training, track layouts) lives in one JSON blob, not relational rows; see "Persistence split" (section 5.1) for why.

CREATE TABLE app_state (
  id INTEGER PRIMARY KEY CHECK (id = 1),  -- always exactly one row
  state_json TEXT NOT NULL,               -- the entire frontend state tree (section 15.1), as JSON
  updated_at TEXT NOT NULL
);

CREATE TABLE passings (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  session_id TEXT NOT NULL,
  timestamp_ms INTEGER NOT NULL,
  transponder TEXT NOT NULL,
  driver_id TEXT,
  driver_name TEXT,
  car_id TEXT,
  car_name TEXT,
  strength REAL,
  loop_id TEXT,
  resend INTEGER NOT NULL DEFAULT 0,
  raw_json TEXT NOT NULL,
  created_at TEXT NOT NULL
);
CREATE INDEX idx_passings_session ON passings(session_id);
CREATE INDEX idx_passings_timestamp ON passings(timestamp_ms);

app_state is a single-row key/value store, not a normalized table - POST /api/state (16.4) always replaces the whole state_json blob (see the optimistic-locking discussion in ROADMAP.md, section 2). passings is the one durable, append-only record of every raw decoder message that isn't reachable through /api/state at all - only deletable via POST /api/passings/purge (16.6a), never overwritten by a state save. Its columns match GET /api/passings's response shape (16.6) exactly, field for field.

16. Backend API Contract

This section describes the practical contract exposed by server.js.

16.1 GET /api/health

Response:

{
  "ok": true,
  "dbPath": "/abs/path/to/data/rc_timing.sqlite"
}

Use for:

  • health check
  • confirming backend is alive

16.2 GET /api/app-version

Response:

{
  "revision": 1,
  "updatedAt": "2026-03-30T...Z"
}

Use for:

  • frontend file-change reload detection
  • app version polling in runtime services

16.2a GET /api/update-status, POST /api/update/run

GET /api/update-status (optional ?force=1 to bypass its own cache) runs checkForUpdate() and returns whether a newer commit is available on the configured git remote, cached for UPDATE_CHECK_CACHE_MS. Backs the in-app update checker (Settings, System & backup tab) and the sidebar "New version - update" link (ROADMAP.md "In-app update checker and one-click updater").

POST /api/update/run responds immediately ({ ok: true, message: "..." }) and spawns a detached scripts/serverctl.js update child process that outlives the request - it stops the currently running server, git pulls, runs npm install, then starts a fresh server, reusing the same stop/start machinery as npm run restart. The HTTP response returns before any of that happens, so the frontend cannot observe success/failure directly; it just expects the server to become briefly unreachable and then come back on the new version.

16.2b GET /api/network/public-address

Calls https://api.ipify.org server-side (5s timeout) and returns { address, suggestedBaseUrl }. Used by Settings to suggest a public Backend URL when configuring remote access; the outbound call happens from the backend, not the browser, so it works even when the admin page itself is only reachable over LAN/VPN.

16.3 GET /api/state

Success response:

{
  "state": { /* persisted frontend state */ },
  "updatedAt": "2026-03-30T...Z"
}

Empty response when no state exists:

{
  "state": null,
  "updatedAt": null
}

Error response:

{
  "error": "Stored app state is invalid JSON"
}

Use for:

  • frontend hydration
  • public overlay hydration

16.4 POST /api/state

Request body:

  • full persistable frontend state object
  • effectively the output of buildPersistableState()

Success response:

{
  "ok": true,
  "updatedAt": "2026-03-30T...Z"
}

Error response:

{
  "error": "Expected JSON body"
}

Important note:

  • this endpoint stores the full frontend state snapshot in app_state
  • it is not a patch endpoint

16.5 POST /api/passings

Request body:

{
  "sessionId": "session_...",
  "passing": {
    "timestamp": 1711111111111,
    "transponder": "1234567",
    "driverId": "driver_...",
    "driverName": "Name",
    "carId": "car_...",
    "carName": "Car",
    "strength": -53.2,
    "loopId": "1",
    "resend": false,
    "...": "raw decoder-derived fields may exist"
  },
  "sessionResult": {
    "...": "optional current resultsBySession[sessionId] snapshot"
  }
}

Success response:

{
  "ok": true
}

Error response:

{
  "error": "Expected { sessionId, passing }"
}

Important note:

  • if sessionResult is provided, backend also hot-updates app_state.resultsBySession[sessionId]
  • this is important for overlays hydrating from backend instead of direct decoder websocket

16.6 GET /api/passings

Query parameters:

  • sessionId optional
  • limit optional, max 1000

Response:

{
  "rows": [
    {
      "id": 1,
      "session_id": "session_...",
      "timestamp_ms": 1711111111111,
      "transponder": "1234567",
      "driver_id": "driver_...",
      "driver_name": "Name",
      "car_id": "car_...",
      "car_name": "Car",
      "strength": -53.2,
      "loop_id": "1",
      "resend": 0,
      "raw_json": "{...}",
      "created_at": "2026-03-30T...Z"
    }
  ]
}

Use for:

  • reading persisted passings history
  • debugging session passings

16.6a POST /api/passings/purge

Body: { "sessionIds": string[] }. Deletes every raw passings row for the given session ids and responds { ok: true, deleted: <count> }. The raw lap-crossing log is the one piece of session data not reachable at all through /api/state (it lives only in the append-only SQLite passings table, deliberately never included in the state blob a POST /api/state could overwrite - see section 17's "Backend hydration overwriting local edits" gotcha), so deleting an event/session or running the "Rensa data" clear-data flow calls this separately to avoid leaving orphaned rows behind with no UI ever pointing at them again.

16.7 GET /api/ammc/config

Response:

{
  "config": { /* normalized AMMC config */ },
  "status": { /* buildAmmcStatus() output */ }
}

16.8 POST /api/ammc/config

Request body:

  • config object to normalize and save

Success response:

{
  "ok": true,
  "config": { /* normalized config */ },
  "status": { /* buildAmmcStatus() output */ }
}

Error response:

{
  "error": "...",
  "status": { /* buildAmmcStatus() output */ }
}

16.9 GET /api/ammc/status

Response:

  • current output of buildAmmcStatus()

Practical fields include:

  • whether process is running
  • pid
  • timestamps
  • last exit code/signal
  • last error
  • recent log lines
  • resolved executable path
  • current normalized config

16.10 POST /api/ammc/start

Success response:

{
  "ok": true,
  "status": { /* buildAmmcStatus() */ }
}

Error response:

{
  "error": "...",
  "status": { /* buildAmmcStatus() */ }
}

16.11 POST /api/ammc/stop

Success response:

{
  "ok": true,
  "status": { /* buildAmmcStatus() */ }
}

Error response:

{
  "error": "...",
  "status": { /* buildAmmcStatus() */ }
}

16.12 POST /api/export/pdf

Request body:

{
  "title": "...",
  "subtitle": "...",
  "brandName": "...",
  "brandTagline": "...",
  "footer": "...",
  "theme": "classic|minimal|motorsport",
  "logoDataUrl": "data:image/jpeg;base64,...",
  "filename": "results.pdf",
  "sections": [
    {
      "title": "...",
      "headers": ["..."],
      "rows": [["...", "..."]]
    }
  ]
}

Success response:

  • binary PDF with Content-Type: application/pdf

Error response:

{
  "error": "..."
}

16.13 Public overlay routes

Routes:

  • /public-overlay
  • /public-overlay/:mode

Behavior:

  • if PUBLIC_OVERLAY_TOKEN is set, query param token must match or the route returns 403 Forbidden
  • if no token environment variable is set, the overlay route is available without a query token
  • on success serves index.html

Use for:

  • public OBS/browser-source overlays without exposing admin path directly

16.14 Public training profile routes

Routes:

  • page: GET /public-training/:shareToken
  • QR code for the page's own URL: GET /public-training/qr/:shareToken
  • data: GET /public-training/api/:shareToken
  • restricted actions (same token authorization as the data route): POST /public-training/api/:shareToken/markers, .../even-laps, .../step-training, .../exercise-sessions - the driver-self-service actions (marking a lap, "Jämna varv" lap-evening, step-training logging, and guided exercise session logging) each read-modify-write only their own slice of state.training server-side rather than accepting a client's full state blob, so an idle mobile session can't revert unrelated admin edits the way a raw POST /api/state could (see section 17.6).

Authorization:

  • shareToken must match an enabled entry in state.training.shares
  • token length is at least 24 characters after normalization
  • missing, disabled, replaced, or unknown tokens return 404 rather than revealing whether a driver exists

Sanitized response fields:

  • selected person's display name plus linked class names and brands
  • one classProfiles entry per linked class; no cross-class performance aggregate is emitted
  • no club rank or other driver names; the public profile focuses on personal development
  • class-specific totals plus optional pace history, achievements, stint summaries, best-three, five-minute result, goals, and daily summaries
  • optional detail sections are omitted server-side according to the share visibility map; core lap/day/best/consistency totals remain visible
  • performance data includes bounded active 30-minute pace buckets and recent stint summaries without raw laps
  • optional class-specific currentSession aggregates when a valid start age (or backward-compatible recent since timestamp) is supplied
  • class-specific next automatic milestone, recommendation level, target lap, goals, and reward progress
  • public club branding and safe PNG/JPEG/WebP logo data

Explicitly excluded:

  • transponder IDs
  • club and country profile metadata
  • driver IDs and names of other drivers
  • cars, events, sessions, assignments, and raw passings
  • race settings and decoder/AMMC configuration
  • complete /api/state payload

Static serving boundary:

  • Express serves static files only under /src and /docs
  • data/, AMMC/, server.js, scripts, manifests, and package files are not static downloads
  • public-training.html and index.html are served only by explicit routes/fallback handlers

17. Regression History / Known Gotchas

This section captures bugs already seen in this codebase after the modular split.

17.1 Missing dependency injection from app.js

Observed failures:

  • DEFAULT_OBS_OVERLAY_SETTINGS before initialization
  • OBS_LAYOUTS before initialization
  • formatLap is not defined
  • formatPredictedLapDelta is not a function
  • getPassingValidationLabel dependency missing

Pattern:

  • helper/module was extracted or wrapper changed
  • consumer module signature expected injected helper
  • app.js no longer passed it through correctly

Mitigation:

  • whenever a module crashes after split, compare:
    • imported function in app.js
    • wrapper function in app.js
    • destructured params in target module

17.2 Local mutation vs canonical state update

Observed failures:

  • race format values reverted after save
  • team create/edit/delete looked successful but UI snapped back
  • branding changes could be lost after rerender

Pattern:

  • local event object mutated
  • canonical state.events was not updated via array replacement

Mitigation:

  • in controllers, prefer updateEvent(...) / array replacement pattern
  • do not rely on mutating local references inside a controller

17.3 Event manager save fragility

Observed failures:

  • session type saved as open practice instead of selected type
  • team save buttons did nothing
  • team save worked for create but not edit
  • race format save reverted min/max values

Pattern:

  • Hantera mixes many forms, rerenders, and local references in one controller

Mitigation:

  • treat src/event_manager_controller.js as a high-risk file
  • verify submit source, selected DOM node, canonical update, then rerender in that order

17.4 Team race pool confusion

Observed failures:

  • team race showed class-only drivers when user wanted all drivers
  • race participant list and team pool affected each other
  • selecting team members unexpectedly affected participant selection or vice versa

Pattern:

  • using the same pool/list for:
    • race participants
    • team selection

Mitigation:

  • keep race participant scope and team pool scope separate
  • document clearly whether team race uses:
    • all drivers
    • or only race participants

17.5 Overlay/public route issues

Observed failures:

  • public overlay loaded plain page without JS/CSS behavior
  • overlay preview timer in main app did not tick
  • OBS config dropdowns closed immediately
  • overlay/logo/theme inconsistencies

Pattern:

  • nested routes and overlay runtime had different bootstrap needs

Mitigation:

  • prefer absolute asset paths in index.html
  • keep overlay refresh timers separate from admin rerenders
  • test:
    • admin overlay page
    • popup overlay
    • public overlay
    • OBS overlay

17.6 Backend hydration overwriting local edits

Observed failures:

  • edits in admin pages could appear to save and then revert

Pattern:

  • local dirty state vs backend hydration/sync race

Mitigation:

  • treat backend hydration and local dirty flags as runtime concerns
  • inspect src/runtime_services.js together with the affected save path

17.7 Settings forms replaced by decoder reconnect

Observed failure:

  • decoder IP/port fields and checkboxes reset while the operator was typing
  • the operator had to enter and save values before the next refresh

Pattern:

  • an unreachable or unstable WebSocket triggers error, close, reconnect after two seconds, and open/error again
  • each lifecycle event previously called full renderView() while Settings was active
  • initial backend hydration or voiceschanged could also replace a newly focused form

Mitigation:

  • never include settings in decoder lifecycle rerender targets in src/decoder_runtime.js
  • use renderViewFromBackground() for asynchronous app/bootstrap updates
  • mark Settings as draft-active on focusin, input, and change
  • keep a regression assertion in tests/decoder_runtime.test.js that open/error/close does not render Settings

18. Automated Regression Tests

Run:

npm test

tests/training_logic.test.js covers:

  • training-state normalization and duplicate rejection
  • invalid, anonymous, and sub-500 ms lap exclusion
  • replacement of current-session ledger rows after corrections
  • retention of archived/deleted-session history
  • personal-profile isolation, same-class multi-transponder aggregation, and 2WD/4WD separation
  • recommendation branch selection
  • custom goal and automatic milestone progress
  • selected-profile/class active-practice isolation, current-start filtering, and non-practice rejection
  • configured max-lap and two-minute fallback stint boundaries, cross-session/source isolation, and boundary-lap exclusion
  • best-three consecutive calculation and five-minute final-lap/average-pace behavior, including a sanity bound that prevents impossible combined results
  • trimmed 30-minute pace buckets and weekday aggregation
  • class-isolated setup-marker before/after comparison
  • short-stint suppression, 10-lap race pace, and mistake detection

tests/public_training_profile.test.js covers:

  • enabled/disabled/unknown share-token handling
  • personal-profile isolation, class-specific statistics, and response sanitization
  • personal timer aggregate and frozen-window calculations
  • clock-skew-safe query normalization and bounds

tests/driver_profile_logic.test.js covers findTransponderConflict:

  • flags a transponder shared across two different personal profiles
  • never flags one person's own multi-class entries sharing a transponder
  • names the conflicting driver and class in the returned conflict

tests/race_timing_logic.test.js covers:

  • race-format parsing, defaults, lap limits, participants, and teams
  • practice-only Open Practice bundle creation and persisted mode markers
  • wizard generation of practice, qualifying, and team-race sessions
  • qualifying heat generation and manual-grid-safe reseeding
  • final generation, reserved bump slots, and bump-up promotion
  • timed, follow-up, and untimed session clocks
  • race lap windows and best/consecutive seed metrics
  • qualifying points, tie comparisons, and leaderboard gaps
  • multi-leg final standings and counted-leg scoring
  • getEventDrivers preserving participant-selection order (not directory creation order) and dropping stale/out-of-class ids
  • getSessionSortWeight's practice-first ordering (?? not ||, since open_practice's own weight is 0)
  • buildEventSchedule's cascading planned-time math, running-order sort, track-mode exclusion, and re-anchoring to a session's real start/end (including overtime from a pause)
  • personalTimeUp (staggered-mode personal window elapsed, excluding paused time) and the staggered-mode shared-clock extension

tests/track_layouts_logic.test.js covers:

  • layout normalization/sorting and label defaulting/renaming
  • getTrackLayoutIdAt's contiguous "layout active at time X" lookup at its boundaries
  • getTrackRecordMs scoping a live lap's comparison to only same-layout sessions, never conflating a layout-tagged session with an untagged legacy one

tests/judging_logic.test.js covers:

  • cumulative lap/time corrections, reset, and correction-state undo
  • manual lap invalidation and competitor-result recalculation
  • restoration and undo of passing validity
  • safe handling when no counted lap exists
  • competitor and judging-log filters

tests/runtime_persistence.test.js covers:

  • persisted entity/settings normalization during backend hydration
  • invalid-theme fallback and retention of local setting defaults
  • hydration success/error behavior and no-write-back handling
  • full-state POST payloads and synced-version advancement
  • dirty-state protection when newer local edits occur during a request
  • failed-sync behavior without false version advancement

tests/decoder_runtime.test.js covers:

  • split and pit roles never incrementing normal laps
  • pit-entry attribution surviving an endurance driver/transponder change
  • one WebSocket per enabled decoder role
  • minimum-lap shortcut rejection and maximum-lap counted stint boundaries
  • decoder lifecycle events not rerendering and replacing the Settings form

tests/audio_runtime.test.js covers:

  • explicit and language-based speech voice selection
  • practice-specific versus race-specific start/finish phrases
  • per-driver phonetic spoken-name selection and busy-speech beep fallback
  • race-start overlay and audio cue dispatch

tests/server_persistence.integration.test.js starts an isolated backend with a temporary SQLite data directory and covers:

  • health/database path reporting
  • empty-state response and full app-state round-trip
  • passing persistence and session-scoped passing queries
  • atomic hot-update of resultsBySession[sessionId] for overlay hydration
  • preservation of the rest of the stored state during a passing hot-update

The suite uses Node's built-in node:test runner and does not add a test-framework dependency. Windows PowerShell/CMD can run the same suite with npm.cmd test.

Closed out 2026-09-12: resultsBySession's competitor row schema (15.7), a full settings schema snapshot (15.9), and the SQLite table contract (15.10) are now documented; the browser regression suite (section 18) already covers every critical create/edit/save workflow (classes, drivers, cars, sponsor events, the race wizard, team management, Race Day start/pause/resume/finish, session-type-specific start sequences, Settings tabs, Event Manager tabs, the schedule panel, OBS popup, decoder reconnect).

If the project grows further, the next useful additions to this document are:

  • a full controller-to-view ID map for every form/button (large - the doc currently documents this per-feature as each area is touched, e.g. the manage-tab targets in 2.2's event_manager_controller.js entry, rather than as one exhaustive cross-reference)
  • permissions/authentication design once that work actually starts (ROADMAP.md, "Permissions and mobile Race Control" - deferred, no identity system exists yet)

20. Race Audio Architecture

20.1 Ownership and dependency direction

src/race_audio.js owns audio scheduling only. It does not own session state, laps, validation, leaderboards, penalties, pit timing, or finish rules.

Dependency flow:

AMMC WebSocket
  -> decoder_runtime.processDecoderMessageHelper
  -> existing result/leaderboard mutation
  -> triggerRaceAudio(event, metadata)
  -> race_audio.createRaceAudioEngine
  -> short HTMLAudio effect + prioritized Web Speech queue

Timing Start button
  -> timing_views validation
  -> raceAudioEngine.runStartSequence
  -> start-light state callbacks
  -> start tone is initiated
  -> existing commitStart callback sets session.startedAt/status

1-second application clock
  -> runtime_services.handleSessionTimerTickHelper
  -> existing remaining/follow-up/finish rules
  -> one-shot audio markers on the normalized session
  -> triggerRaceAudio

The service is constructed in src/app.js. src/app.js is the composition root and injects triggerRaceAudio, startSessionWithAudio, historical-record lookup, speech, settings, and logging callbacks into the extracted modules.

20.2 Files and contracts

  • src/race_audio.js: event catalog, settings normalization, manifest loading, safe effect playback, fallback, priority/cooldown queue, templates, start-light state machine, start sequence, status.
  • rc-race-sound-pack/sound_manifest.json: event-to-effect mapping, sv/en templates, runtime defaults, optional passing pool names.
  • server.js: read-only /race-sounds static route. JSON is no-store; WAV files may be cached for one day.
  • src/decoder_runtime.js: emits audio metadata only after existing validation/result mutation. Split timing never emits a lap; pit roles emit entering_pits/leaving_pits without adding laps.
  • src/runtime_services.js: Web Speech adapter, persisted-state hydration, and one-shot 60/30/10/time-expired/final-lap markers.
  • src/timing_views.js: visual start lights and manual race-control cues. It retains the authoritative client-side start callback.
  • src/settings.js: persistent volume/language/start configuration and per-event effect/voice matrix.
  • src/core_views.js: optional passSound on driver and car records.

20.3 Persisted schema

state.settings.raceAudio is normalized on local load, backend hydration, seed, import/export, and save:

{
  enabled, muted, language,
  voiceUri, voiceRate, voicePitch,
  masterVolume, effectsVolume, voiceVolume, passingVolume,
  startCountdownSeconds,
  startLights, lightIntervalMs,
  randomStartDelay, minStartDelay, maxStartDelay,
  perCompetitorPassingTone,
  events: { [eventKey]: { effect, voice } },
  templates: { [eventKey]: string }
}

Drivers and cars optionally persist passSound. Sessions persist audioAnnouncements, a small list of one-shot clock markers reset at every start. Audio log entries are persisted in state.audioEventLog and bounded to 500 entries. Decoder passings reuse the normal state synchronization path, so audio logging never adds a blocking write to transponder processing; timing persistence remains the source of truth.

20.4 Priority, concurrency, and failure behavior

  • critical: track call, red/yellow flag, false start, timing failure, pause.
  • high: start, finish, penalty, marshal, final lap.
  • normal: fastest lap, track record, leader, countdown, results.
  • low: personal best and position changes.
  • pass: short transponder effects.

Effects use independent Audio instances and may overlap. They never hold decoder processing. Voice is serialized by priority. A critical voice can cancel the current lower-priority browser utterance. Cooldowns are scoped by event and competitor where available.

All fetch, JSON, file, Audio.play(), speech, subscriber, and log failures are caught. A custom missing passing sound retries effects/transponder_pass.wav. Failure never mutates or rejects the timing path.

20.5 Start timing invariant

Start-light state is independent from sound and exposes:

IDLE -> GRID -> READY -> LIGHT_1 -> LIGHT_2 -> LIGHT_3 -> LIGHT_4 -> LIGHT_5 -> WAIT_RANDOM -> GO

The random value is generated once inside the race-controller service, not by the visual light component. After the delay, the engine initiates the start tone and immediately invokes the existing commitStart callback. That callback sets session.status = running and session.startedAt = Date.now(). No HTTP request occurs between tone initiation and timestamp assignment.

Practice types intentionally bypass this sequence. This preserves continuously running Open Practice behavior.

20.6 Event mapping and calculations

Automatic decoder events are emitted only for valid main-loop laps. Personal best requires an earlier personal best. Fastest lap requires an earlier session fastest lap. Track record requires an earlier best lap from another session in the same class. Leader/position events compare the existing leaderboard immediately before and after the valid passing. These checks observe timing results; they do not recalculate standings independently.

Finish announcements run only after the existing follow-up/finish logic marks the session finished. Winner and podium use buildLeaderboard(session) after that transition. Positive time penalties and removed laps emit the penalty event after the existing judging function saves its correction.

20.7 Known boundary

The application currently has no authentication or role model. Race-control sound buttons therefore inherit the same local operator access as all Timing and Judging controls. Do not expose the administrative application publicly; expose only the existing public overlay endpoints. A future authorization layer should gate all race-control actions together rather than adding audio-only permissions.

20.8 Tests

tests/race_audio.test.js covers normalization, language/event mapping, custom-file fallback, critical interruption, start-light order, random delay, and tone/timestamp callback order. Existing decoder, runtime persistence, audio fallback, race timing, and server integration suites remain the regression boundary.

Reliability status and browser checks (2026-09)

  • src/sync_status.js exports pure getSyncStatus(backend, dirty, now). Its precedence is active writes, write failure, pending local changes, connection availability/staleness, then saved/connected. It does not mutate race state or initiate network calls.
  • src/runtime_services.js records active state writes in backend.pendingWrites, keeps writeError distinct from contact status, records lastSavedAt only after a successful state POST, and records lastLoadedAt after applying a backend state response. A failed POST leaves local dirty/version tracking intact.
  • Existing app-version polling reports server contact through an optional onContact callback. src/app.js updates lastContactAt/connectionChecked and renders status text once per second without rebuilding forms. Contact older than 15 seconds is stale. Decoder connectivity remains separate.
  • index.html owns syncStatus, syncStatusLabel, syncStatusDetail, syncStatusRetry, and syncWarning. Retry uses the existing full-state POST when local changes or write errors exist. Successful full-state synchronization at the current local version clears the passing error too. Keep the page open when unsaved changes exist; this is not a new durable offline queue or a multi-controller conflict-resolution mechanism.
  • tests/sync_status.test.js verifies status precedence and state-write failure/retry. tests/browser_smoke.js drives Chrome/Chromium/Edge over its local debugging protocol with Node's WebSocket API. It launches an isolated server using RC_TIMING_DATA_DIR and a temporary browser profile. CHROME_PATH overrides discovery. RC_BROWSER_NO_SANDBOX=1 is an explicit opt-in for restricted test environments; normal runs keep the browser sandbox enabled.
  • The browser suite covers register CRUD creation/editing (not deletion), drafts through two version polls, reload persistence, failed offline saves and retry, session/team flows, and Race Day's full session lifecycle. CI platform coverage (Windows/Linux runs) remains tracked in ROADMAP.md as the one still-open item under "Reliability and browser regression tests" - everything else in that roadmap section is done.

tests/browser_smoke.js is the entry point (npm run test:browser); it wires together one isolated server/browser instance and invokes every scenario file below in sequence:

  • tests/browser_race_setup.js - sponsor-event/race/team/session creation, editing, and reload persistence
  • tests/browser_team_membership.js - moving a driver between teams (the duplicate-membership bug fix), team-scoped driver pool, and team roster cleanup on driver deletion
  • tests/browser_event_manager_tabs.js - Event Manager's 4-tab layout: default tab, per-tab panel visibility, the quick-jump step cards/warning links switching tabs before scrolling, and the Open Grid button jumping to the Live tab
  • tests/browser_event_schedule.js - the Schedule tab: saving/clearing a time-of-day schedule, the cascaded running-order table, and the "Delayed" badge
  • tests/browser_settings_tabs.js - Settings' 4-tab layout: default tab, per-tab panel visibility, and the active tab surviving a save-triggered re-render
  • tests/browser_race_day.js - Race Day start/pause/resume/finish, switching to the next queued heat, against a simulated decoder socket
  • tests/browser_race_day_start_sequences.js - the real countdown/light/audio start sequence for heat/qualification/final/team_race/practice sessions (as opposed to open_practice/free_practice's instant start)
  • tests/browser_obs_popup.js - the OBS overlay popup opening via a real click, compact layout, and independent live-updates
  • tests/browser_decoder_reconnect.js - auto-reconnect after a dropped socket, settling on exactly one connection, and auto-reconnect staying off when disabled
  • tests/browser_overlays.js - shared overlay column alignment and sorting isolation across viewport sizes

tests/browser_fake_decoder.js is a shared helper (not a scenario file itself): it completes just the WebSocket upgrade handshake so any scenario needing a "connected decoder" doesn't need real AMMC hardware, with dropConnections() to simulate a network interruption.

Overlay headings and rows use the same --overlay-columns grid tracks in src/styles.css; narrow interactive lists scroll horizontally as one unit. OBS grid rows retain their compact-screen wrapping because they have no sorting toolbar.

Decoder Diagnostics (Browser-Local)

Dependency graph: app.js -> decoder_runtime.js -> decoder_diagnostics.js; app.js -> settings.js -> decoder_diagnostics_view.js -> decoder_diagnostics.js.

decoder_diagnostics.js exports a shared in-memory collector and an injectable-clock factory for tests. Fixed channel keys are main/split/pit_entry/pit_exit. Socket callbacks record connecting/open/error/close, incoming frame count/time and JSON failures. Existing processDecoderMessageHelper branches report ignored reasons or handled/invalid outcomes without changing returns, lap validation, persistence or race state. PASSING is a message counter, not a lap counter. Handled includes non-lap pit/split/start-reference events. At most 50 events and 512 characters per sample are retained per channel; snapshots copy logs so consumers cannot mutate collection state.

decoder_diagnostics_view.js mounts inside a details element outside settingsForm. A one-second timer patches only its own text nodes; it skips closed/paused panels, clears the old timer on remount, and self-clears after detachment. Pause affects display, not collection. Clearing diagnostics preserves socket state and timestamps. Copy uses Clipboard API with a selectable readonly textarea fallback for HTTP. Untrusted payloads are assigned through textContent/value, never interpolated into HTML. No SQLite/localStorage writes, backend endpoint, or public overlay exposure is added. Refresh resets diagnostics independently of saved settings.

Reason codes: invalid_message (not an object), not_passing, no_running_session, missing_transponder, not_registered, already_in_pit, pit_exit_without_entry, split_without_reference, split_locktime, below_min (stored invalid lap). Handled events: pit_entry, pit_exit, split, start_reference, lap_counted or the existing validation warning. invalid_json means parsing failed before processing. The raw frame helps diagnose upstream protocol problems without changing parsing behavior.

Tests: decoder_diagnostics.test.js covers bounds, isolation, snapshots and outcomes; decoder_runtime.test.js exercises socket reception/JSON errors/disconnection/open status without settings rerender. browser_smoke.js verifies live diagnostics update while an edited settings URL remains unchanged. Hardware/network reconnect behavior still requires a real-decoder smoke test.

Mobile Profile Retrieval Status

public-training.html owns a connection-status container outside publicTrainingRoot. src/public_training.js keeps a browser-memory profileConnection (lastSuccessAt, failed, loading) and patches only status text, state attribute and retry button each second. No additional script dependency or reverse-proxy allowlist route is needed. src/public_training.css supplies compact theme-aware status styling.

loadProfile serializes requests, uses AbortController with a 15-second timer, and releases its lock in finally. Existing 10-second visible-page polling and before/after-response edit guards remain. Only a successfully applied profile updates lastSuccessAt; responses deferred due to active editing never falsely refresh it. Failed background requests preserve loaded DOM and data. A 30-second age marks data stale. Retry uses the same background-safe load path. Status is not a write acknowledgement or decoder health indicator. This change adds no persistence or server/API contract.

tests/public_training_refresh.test.js exercises stale/failure/recovery, non-overlap, deferred responses, independent status DOM and timeout recovery. Browser smoke coverage simulates network loss on a real personal profile and retries after network restoration using isolated state.