diff --git a/README.md b/README.md index 1147b92..1a67c6c 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,21 @@ Recommended deployment: - `SV` - `EN` +## Training And Rewards + +The `Training` menu provides a non-race progression view for registered drivers: + +- lifetime valid laps calculated from stored session results +- number of recorded sessions, personal best, and recent consistency spread +- a 20-minute recommendation split into maximum-focus and controlled 80-90% blocks +- custom lap milestones with quick targets at `500`, `1000`, and `1500` laps +- optional reward text and a delivered/redeemed state +- responsive layout for opening the same server from a phone on the local network + +Direct mobile/bookmark link: `http://:8081/?view=training`. Selecting a driver adds its ID to the URL so that driver's page can be bookmarked. + +Training statistics currently include only valid laps linked to a registered driver. Anonymous Open Practice transponders are intentionally excluded until they are assigned to a driver. + ## Create Race Wizard - `Race Setup` now starts with a four-step wizard for new races: @@ -311,20 +326,14 @@ Example: - supports `Undo latest` and per-log-row undo for multiple recent manual actions ## Windows installation -Run in PowerShell in the project directory. +Use 64-bit Windows 11 and Node.js 22 or 24 LTS. The easiest supported setup is: -1. Install Node.js LTS (18+). -2. Install dependencies: - ```powershell - npm install - ``` -3. Start the server in the background: - ```powershell - npm start - ``` -4. Open: -- `http://localhost:8081` -- or from another computer: `http://:8081` +1. Clone or extract the complete repository, preferably to `C:\\Live_RC`. +2. Double-click `windows\\install.bat`. +3. Start with `windows\\start_backend.bat` or `npm start`. +4. Open `http://localhost:8081`. + +The setup script can install Node.js LTS through `winget`, runs locked dependency installation with `npm ci`, verifies SQLite/Express, and checks the bundled AMMC runtime. Full instructions and troubleshooting: [`windows/README.md`](windows/README.md). Common commands: ```powershell @@ -343,10 +352,15 @@ npm run start:fg ### Windows scripts The `windows/` folder includes: +- `windows\\install.bat` installs and verifies Node/npm dependencies - `windows\\start_ammc.bat` starts AMMC in the background -- `windows\\start_backend.bat` starts `npm start` in the background (log: `logs\\backend.log`) +- `windows\\start_backend.bat` starts `npm start` in the background - `windows\\start_all.bat` starts both AMMC + backend and opens the web page -- `windows\\stop_all.bat` stops the backend on port `8081` and `ammc-amb.exe` +- `windows\\stop_all.bat` stops the managed backend and `ammc-amb.exe` + +Backend logs: +- `logs\\server.out.log` +- `logs\\server.err.log` SQLite database file: - `data\\rc_timing.sqlite` diff --git a/README.sv.md b/README.sv.md index 8a566b7..45fffa9 100644 --- a/README.sv.md +++ b/README.sv.md @@ -157,6 +157,21 @@ Rekommenderad deploy: - AMMC + npm setup på Windows och Linux - Språkval i UI: `SV` / `EN` +## Träning Och Belöningar + +Menyn `Träning` ger förarna utveckling och delmål även mellan tävlingarna: + +- totalt antal giltiga varv beräknat från sparade sessionsresultat +- antal sessioner, personbästa och spridning på de senaste varven +- rekommenderat 20-minuterspass uppdelat i maxfokus och kontrollerad körning på 80-90 procent +- egna varvmål med snabbval på `500`, `1000` och `1500` varv +- valfri reward/belöning och markering när den har delats ut +- responsiv layout som kan öppnas från mobilen via samma server i det lokala nätet + +Direktlänk för mobil/bokmärke: `http://:8081/?view=training`. När en förare väljs läggs förarens ID i länken så just den förarens sida kan sparas som bokmärke. + +Träningsstatistiken räknar just nu bara giltiga varv som är kopplade till en registrerad förare. Anonyma transpondrar från Open Practice räknas inte förrän de kopplats till en förare. + ## Create Race Wizard - `Race Setup` startar nu med en fyrstegs wizard för nya race: @@ -300,20 +315,14 @@ Praktiskt exempel: - både `Ångra senaste` och undo-knapp per loggrad finns för flera manuella åtgärder ## Windows installation -Kör i PowerShell i projektmappen. +Använd 64-bitars Windows 11 och Node.js 22 eller 24 LTS. Enklaste installationen är: -1. Installera Node.js LTS (18+). -2. Installera dependencies: - ```powershell - npm install - ``` -3. Starta servern i bakgrunden: - ```powershell - npm start - ``` -4. Öppna: - - `http://localhost:8081` - - eller från annan dator: `http://:8081` +1. Klona eller packa upp hela repot, helst till `C:\\Live_RC`. +2. Dubbelklicka på `windows\\install.bat`. +3. Starta med `windows\\start_backend.bat` eller `npm start`. +4. Öppna `http://localhost:8081`. + +Installationsskriptet kan installera Node.js LTS med `winget`, kör en låst dependency-installation med `npm ci`, verifierar SQLite/Express och kontrollerar bundlad AMMC. Komplett guide och felsökning: [`windows/README.md`](windows/README.md). Vanliga kommandon: ```powershell @@ -332,10 +341,15 @@ npm run start:fg ### Windows scripts (bakgrundsstart) Det finns färdiga `.bat`-filer i mappen `windows/`: +- `windows\\install.bat` installerar och verifierar Node/npm-dependencies - `windows\\start_ammc.bat` startar AMMC i bakgrunden -- `windows\\start_backend.bat` startar `npm start` i bakgrunden (logg: `logs\\backend.log`) +- `windows\\start_backend.bat` startar `npm start` i bakgrunden - `windows\\start_all.bat` startar både AMMC + backend och öppnar webbsidan -- `windows\\stop_all.bat` stoppar backend på port `8081` och `ammc-amb.exe` +- `windows\\stop_all.bat` stoppar den hanterade backendprocessen och `ammc-amb.exe` + +Backendloggar: +- `logs\\server.out.log` +- `logs\\server.err.log` SQLite-filen skapas automatiskt här: - `data\\rc_timing.sqlite` diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 5df315d..568b735 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -90,12 +90,21 @@ 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 run setup:windows` ### `server.js` Role: @@ -149,6 +158,40 @@ Uses: - `logs/server.out.log` - `logs/server.err.log` +### 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: + +```mermaid +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 --> Native[better-sqlite3 prebuilt N-API binary] + NpmCi --> Postinstall[scripts/verify-install.js] + Postinstall --> SQLite[SQLite in-memory query] + Postinstall --> AMMC[AMMC/windows64 runtime check] + SetupPs --> ServerCtl[scripts/serverctl.js] +``` + +Important constraints: +- the repository should be installed in a short ASCII path such as `C:\\Live_RC` +- AMMC Windows binaries and DLLs must remain together in `AMMC\\windows64` +- `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 `9000` only when PowerShell is elevated + ## 2.2 Frontend files ### `src/app.js` @@ -347,6 +390,42 @@ Owns: - branding/audio settings UI - import/export settings UI +### `src/training_logic.js` +Type: +- pure training/statistics logic + +Owns: +- training-state normalization +- valid driver-lap aggregation across `resultsBySession` +- lifetime/session/best-lap/consistency calculations +- rule-based practice recommendation selection +- milestone progress calculation + +Rules: +- only passings with a matching `driverId` are included +- invalid laps and lap times at or below `500 ms` are excluded +- 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 + +### `src/training_views.js` +Type: +- training page renderer/controller + +Owns: +- driver selector +- training stat cards and recommendation display +- milestone/reward create, claim, and delete actions +- mobile-responsive training markup + +Mutations: +- replaces or appends entries in `state.training.goals` +- always calls injected `saveState()` before rerender + +Route behavior: +- `/?view=training` opens the training view directly +- `driver=` selects a driver and supports mobile bookmarks +- this is currently the same trusted-LAN admin shell, not a separate public read-only profile route + ## 3. Direct Import Dependencies ## 3.1 Static import graph @@ -368,6 +447,8 @@ Owns: - `src/timing_views.js` - `src/race_render_helpers.js` - `src/judging_logic.js` +- `src/training_logic.js` +- `src/training_views.js` ### `src/event_workspace_controller.js` imports - `src/event_views.js` @@ -543,6 +624,7 @@ Primary state buckets: - `state.cars` - `state.classes` - `state.resultsBySession` +- `state.training` - `state.settings` - `state.passings` @@ -739,6 +821,8 @@ sequenceDiagram | `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/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 | | `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 | @@ -1094,7 +1178,7 @@ If one of these breaks, inspect this controller first. - `initSchema()` - initializes SQLite schema - `watchAppFiles()` - - bumps app version on file changes + - bumps app version when `index.html` or any top-level `src/*.js` / `src/*.css` file changes - `loadAmmcConfig()` / `saveAmmcConfig()` - AMMC config file persistence - `normalizeAmmcConfig(...)` @@ -1128,6 +1212,7 @@ This section describes the practical shape of the frontend state as persisted by events: Event[], sessions: Session[], resultsBySession: Record, + training: TrainingState, activeSessionId: string, settings: Settings } @@ -1343,7 +1428,44 @@ Practical shape: This bucket is the most dynamic part of state and is mutated heavily during live timing and judging. -## 15.8 `Settings` +## 15.8 `TrainingState` + +```js +{ + goals: TrainingGoal[] +} +``` + +`TrainingGoal`: + +```js +{ + id: string, + driverId: string, + title: string, + reward: string, + targetLaps: number, + createdAt: string, // ISO timestamp + claimedAt: string // empty until reward is delivered +} +``` + +Normalized by: +- `normalizeTrainingState()` wrapper in `src/app.js` +- `normalizeTrainingState()` 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 `resultsBySession` so corrections and invalidated laps are reflected immediately. + +Known lifecycle constraint: +- deleting historical sessions removes their source passings and therefore lowers calculated lifetime totals +- a future immutable training ledger should be introduced before session archival/deletion is used routinely + +## 15.9 `Settings` Practical shape contains at least: - theme diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4422d11 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,855 @@ +{ + "name": "rc-timing-control", + "version": "1.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rc-timing-control", + "version": "1.1.0", + "hasInstallScript": true, + "dependencies": { + "better-sqlite3": "^13.0.3", + "express": "^4.21.2" + }, + "engines": { + "node": ">=22 <27", + "npm": ">=10" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/package.json b/package.json index 3258cf9..920d752 100644 --- a/package.json +++ b/package.json @@ -3,15 +3,23 @@ "version": "1.1.0", "private": true, "description": "RC timing app with AMMC websocket ingest and local SQLite persistence", + "engines": { + "node": ">=22 <27", + "npm": ">=10" + }, "scripts": { + "preinstall": "node scripts/preinstall.js", + "postinstall": "node scripts/verify-install.js", "start": "node scripts/serverctl.js start", "start:fg": "node server.js", "stop": "node scripts/serverctl.js stop", "status": "node scripts/serverctl.js status", - "restart": "node scripts/serverctl.js restart" + "restart": "node scripts/serverctl.js restart", + "verify": "node scripts/verify-install.js", + "setup:windows": "powershell -NoProfile -ExecutionPolicy Bypass -File windows/setup.ps1" }, "dependencies": { - "better-sqlite3": "^11.8.1", + "better-sqlite3": "^13.0.3", "express": "^4.21.2" } } diff --git a/scripts/preinstall.js b/scripts/preinstall.js new file mode 100644 index 0000000..9798fc4 --- /dev/null +++ b/scripts/preinstall.js @@ -0,0 +1,27 @@ +const path = require("path"); + +const [major] = process.versions.node.split(".").map(Number); +const supportedArchitectures = new Set(["x64", "arm64"]); + +if (!Number.isInteger(major) || major < 22 || major >= 27) { + console.error(""); + console.error(`[install] Unsupported Node.js version: ${process.version}`); + console.error("[install] Install Node.js 22 or 24 LTS, then run npm install again."); + console.error("[install] Windows: double-click windows\\install.bat or run windows\\setup.ps1."); + console.error(""); + process.exit(1); +} + +if (process.platform === "win32" && !supportedArchitectures.has(process.arch)) { + console.error(`[install] Unsupported Windows architecture: ${process.arch}`); + console.error("[install] Use 64-bit Windows 11 on x64 or arm64."); + process.exit(1); +} + +const projectPath = path.resolve(__dirname, ".."); +if (process.platform === "win32" && /[^\x20-\x7e]/.test(projectPath)) { + console.warn(`[install] Warning: project path contains non-ASCII characters: ${projectPath}`); + console.warn("[install] If a native dependency fails, move the project to C:\\Live_RC."); +} + +console.log(`[install] Node ${process.version}, npm lifecycle, ${process.platform}/${process.arch}: OK`); diff --git a/scripts/verify-install.js b/scripts/verify-install.js new file mode 100644 index 0000000..7bb2d8d --- /dev/null +++ b/scripts/verify-install.js @@ -0,0 +1,51 @@ +const fs = require("fs"); +const path = require("path"); + +const root = path.resolve(__dirname, ".."); + +function fail(message, error) { + console.error(`[verify] ${message}`); + if (error) { + console.error(`[verify] ${error instanceof Error ? error.message : String(error)}`); + } + if (process.platform === "win32") { + console.error("[verify] Retry with windows\\install.bat."); + console.error("[verify] Native fallback: run C:\\Program Files\\nodejs\\install_tools.bat as Administrator."); + } + process.exit(1); +} + +try { + require("express"); +} catch (error) { + fail("Express could not be loaded.", error); +} + +try { + const Database = require("better-sqlite3"); + const db = new Database(":memory:"); + const row = db.prepare("SELECT 1 AS ok").get(); + db.close(); + if (row?.ok !== 1) { + fail("SQLite verification query returned an unexpected result."); + } +} catch (error) { + fail("better-sqlite3 could not be loaded or initialized.", error); +} + +for (const directory of ["data", "logs"]) { + fs.mkdirSync(path.join(root, directory), { recursive: true }); +} + +if (process.platform === "win32") { + const ammcDir = path.join(root, "AMMC", "windows64"); + const requiredFiles = ["ammc-amb.exe", "ammc.dll", "MyLapsSDK.dll"]; + const missingFiles = requiredFiles.filter((file) => !fs.existsSync(path.join(ammcDir, file))); + if (missingFiles.length > 0) { + fail(`Bundled AMMC runtime is incomplete: ${missingFiles.join(", ")}`); + } + console.log(`[verify] AMMC Windows runtime: ${ammcDir}`); +} + +console.log(`[verify] Express and SQLite: OK (${process.platform}/${process.arch}, Node ${process.version})`); +console.log("[verify] Installation is ready. Start with: npm start"); diff --git a/server.js b/server.js index 75c3597..4558b01 100644 --- a/server.js +++ b/server.js @@ -11,10 +11,12 @@ const DB_DIR = path.join(__dirname, "data"); const DB_PATH = path.join(DB_DIR, "rc_timing.sqlite"); const AMMC_CONFIG_PATH = path.join(DB_DIR, "ammc_config.json"); const PID_FILE = process.env.RC_TIMING_PID_FILE || ""; +const SRC_DIR = path.join(__dirname, "src"); const WATCHED_FILES = [ path.join(__dirname, "index.html"), - path.join(__dirname, "src", "app.js"), - path.join(__dirname, "src", "styles.css"), + ...fs.readdirSync(SRC_DIR) + .filter((fileName) => /\.(?:js|css)$/i.test(fileName)) + .map((fileName) => path.join(SRC_DIR, fileName)), ]; fs.mkdirSync(DB_DIR, { recursive: true }); diff --git a/src/app.js b/src/app.js index a9739dd..063885c 100644 --- a/src/app.js +++ b/src/app.js @@ -17,6 +17,8 @@ import { renderEventManagerMarkup } from "./event_manager_view.js"; import { renderEventManagerView } from "./event_manager_controller.js"; import { createDefaultAmmcConfigHelper, getManagedWsUrlHelper, loadAmmcConfigFromBackendHelper, saveAmmcConfigToBackendHelper, refreshAmmcStatusHelper, startManagedAmmcHelper, stopManagedAmmcHelper, applyPersistedStateHelper, hydrateFromBackendHelper, scheduleBackendSyncHelper, syncStateToBackendHelper, pingBackendHelper, checkAppVersionHelper, startAppVersionPollingHelper, startOverlaySyncHelper, startOverlayRotationHelper, startOverlayLiveRefreshHelper, ensureAudioContextHelper, playPassingBeepHelper, playFinishSirenHelper, playLeaderCueHelper, playStartCueHelper, playBestLapCueHelper, pushOverlayEventHelper, speakTextHelper, announcePassingHelper, announceRaceFinishedHelper, handleSessionTimerTickHelper, tickClockHelper } from "./runtime_services.js"; import { connectDecoderHelper, disconnectDecoderHelper, processDecoderMessageHelper } from "./decoder_runtime.js"; +import { normalizeTrainingState as normalizeTrainingStateLogic, buildDriverTrainingStats as buildDriverTrainingStatsLogic, buildTrainingRecommendation as buildTrainingRecommendationLogic, getTrainingGoalProgress as getTrainingGoalProgressLogic } from "./training_logic.js"; +import { renderTrainingView } from "./training_views.js"; import { renderTimingView, renderJudgingView } from "./timing_views.js"; @@ -122,7 +124,7 @@ const saveAmmcConfigToBackend = (config) => saveAmmcConfigToBackendHelper(config const refreshAmmcStatus = () => refreshAmmcStatusHelper({ ammc, t, getBackendUrl }); const startManagedAmmc = () => startManagedAmmcHelper({ ammc, t, getBackendUrl }); const stopManagedAmmc = () => stopManagedAmmcHelper({ ammc, t, getBackendUrl }); -const applyPersistedState = (persisted) => applyPersistedStateHelper(persisted, { state, backend, t, getDefaultBackendUrl, DEFAULT_LANGUAGE, DEFAULT_THEME, AVAILABLE_THEMES, normalizeDriver, normalizeCar, normalizeEvent, normalizeSession, normalizeStoredRacePreset, normalizeObsOverlaySettings, applyTheme }); +const applyPersistedState = (persisted) => applyPersistedStateHelper(persisted, { state, backend, t, getDefaultBackendUrl, DEFAULT_LANGUAGE, DEFAULT_THEME, AVAILABLE_THEMES, normalizeDriver, normalizeCar, normalizeEvent, normalizeSession, normalizeStoredRacePreset, normalizeObsOverlaySettings, normalizeTrainingState, applyTheme }); const hydrateFromBackend = () => hydrateFromBackendHelper({ backend, t, getBackendUrl, applyPersistedState, saveState }); const scheduleBackendSync = () => scheduleBackendSyncHelper({ getBackendSyncTimer: () => backendSyncTimer, setBackendSyncTimer: (value) => { backendSyncTimer = value; }, syncStateToBackend }); const syncStateToBackend = () => syncStateToBackendHelper({ backend, t, getBackendUrl, buildPersistableState, getLocalStateVersion: () => localStateVersion, getSyncedStateVersion: () => syncedStateVersion, setSyncedStateVersion: (value) => { syncedStateVersion = value; }, getLocalStateDirty: () => localStateDirty, setLocalStateDirty: (value) => { localStateDirty = value; } }); @@ -195,6 +197,11 @@ const renderGuide = () => renderGuideView({ dom, t }); const renderOverlay = () => renderOverlayPageView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatPredictedLapDelta, getActiveSession, buildLeaderboard, ensureSessionResult, getSessionTiming, getVisiblePassings, resolveEventBranding, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings, getOverlayModeLabel, getStatusLabel, getObsOverlayConfig, buildOverlayPanels, normalizeStartMode, renderPositionGrid, getEventName, getSessionTypeLabel, getStartModeLabel, renderRaceStandingsTableView, renderTeamOverlay, renderObsOverlay, renderOverlayLeaderboard, renderOverlaySidePanel, formatTeamActiveMemberLabel, getSessionGridEntries, overlayViewMode, overlayMode, publicOverlayMode, overlayEvents, overlayRotationIndex, openOverlayWindow, buildOverlayUrl }); const renderTiming = () => renderTimingView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatRaceClock, renderTable, uid, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings, getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid, connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, ensureAudioContext, validateTrackSessionForStart, pushOverlayEvent, saveState, updateHeaderState, renderView, normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey: () => selectedLeaderboardKey, setSelectedLeaderboardKey: (value) => { selectedLeaderboardKey = value; }, getQuickAddDraft: () => quickAddDraft, setQuickAddDraft: (value) => { quickAddDraft = value; }, getPassingValidationLabel, isCountedPassing, getCompetitorPassings, getManualCorrectionSummary, formatTeamActiveMemberLabel, lastFinishAnnouncementSessionId: () => lastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId: (value) => { lastFinishAnnouncementSessionId = value; }, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, overlayEvents: () => overlayEvents, setOverlayEvents: (value) => { overlayEvents = value; } }); const renderJudging = () => renderJudgingView({ state, dom, t, escapeHtml, formatLap, renderTable, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTypeLabel, getJudgeFilteredRows, getJudgeFilteredLog, getCompetitorPassings, isCountedPassing, getPassingValidationLabel, undoJudgingAdjustment, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, renderView, getSelectedJudgeKey: () => selectedJudgeKey, setSelectedJudgeKey: (value) => { selectedJudgeKey = value; }, getJudgingCompetitorFilter: () => judgingCompetitorFilter, setJudgingCompetitorFilter: (value) => { judgingCompetitorFilter = value; }, getJudgingLogFilter: () => judgingLogFilter, setJudgingLogFilter: (value) => { judgingLogFilter = value; } }); +const normalizeTrainingState = (training) => normalizeTrainingStateLogic(training, { uid }); +const buildDriverTrainingStats = (trainingState, driverId) => buildDriverTrainingStatsLogic(trainingState, driverId); +const buildTrainingRecommendation = (stats) => buildTrainingRecommendationLogic(stats); +const getTrainingGoalProgress = (goal, stats) => getTrainingGoalProgressLogic(goal, stats); +const renderTraining = () => renderTrainingView({ state, dom, t, escapeHtml, formatLap, uid, saveState, renderView, normalizeTrainingState, buildDriverTrainingStats, buildTrainingRecommendation, getTrainingGoalProgress, getSelectedDriverId: () => selectedTrainingDriverId, setSelectedDriverId: (value) => { selectedTrainingDriverId = value; updateViewUrl("training", value); } }); const applyCompetitorCorrection = (session, row, options = {}) => applyCompetitorCorrectionLogic(session, row, options, { ensureSessionResult, uid, t, formatLap, saveState }); const recalculateCompetitorFromPassings = (session, rowKey) => recalculateCompetitorFromPassingsLogic(session, rowKey, { ensureSessionResult, getCompetitorPassings, isCountedPassing }); const invalidateCompetitorLastLap = (session, row) => invalidateCompetitorLastLapLogic(session, row, { ensureSessionResult, getCompetitorPassings, isCountedPassing, recalculateCompetitorFromPassings, uid, t, formatLap, saveState }); @@ -212,6 +219,7 @@ const NAV_ITEMS = [ { id: "classes", titleKey: "nav.classes", subtitleKey: "nav.classes_sub" }, { id: "drivers", titleKey: "nav.drivers", subtitleKey: "nav.drivers_sub" }, { id: "cars", titleKey: "nav.cars", subtitleKey: "nav.cars_sub" }, + { id: "training", titleKey: "nav.training", subtitleKey: "nav.training_sub" }, { id: "timing", titleKey: "nav.timing", subtitleKey: "nav.timing_sub" }, { id: "judging", titleKey: "nav.judging", subtitleKey: "nav.judging_sub" }, { id: "settings", titleKey: "nav.settings", subtitleKey: "nav.settings_sub" }, @@ -227,7 +235,7 @@ const STORAGE_KEY = "rc_timing_control_v1"; const DEFAULT_LANGUAGE = "sv"; const DEFAULT_THEME = "dark"; const AVAILABLE_THEMES = ["dark", "nord", "light"]; -const EXPORT_SCHEMA_VERSION = 1; +const EXPORT_SCHEMA_VERSION = 2; const TRANSLATIONS = { sv: { @@ -245,6 +253,8 @@ const TRANSLATIONS = { "nav.drivers_sub": "Förare och personliga transpondrar", "nav.cars": "Bilar", "nav.cars_sub": "Bilar med fasta transpondrar", + "nav.training": "Träning", + "nav.training_sub": "Utveckling, delmål och belöningar", "nav.timing": "Tidtagning", "nav.timing_sub": "Live timing-board", "nav.judging": "Domare", @@ -264,6 +274,46 @@ const TRANSLATIONS = { "ui.event": "Event", "ui.decoder_online": "Decoder online", "ui.decoder_offline": "Decoder offline", + "training.eyebrow": "Förarutveckling", + "training.intro": "Följ träning över flera sessioner och gör utvecklingen synlig mellan tävlingarna.", + "training.driver": "Förare", + "training.no_drivers_title": "Ingen förare registrerad", + "training.no_drivers_body": "Skapa en förare först. Träningsstatistik kräver en registrerad förare på passeringarna.", + "training.total_laps": "Totala varv", + "training.valid_laps": "Giltiga registrerade varv", + "training.sessions": "Sessioner", + "training.recorded_sessions": "Med registrerade förarvarv", + "training.best_lap": "Bästa varv", + "training.personal_best": "Personbästa i sparad data", + "training.consistency": "Spridning", + "training.last_30_laps": "P10-P90, senaste 30 varven", + "training.recommendation": "Rekommenderat träningspass", + "training.plan_baseline_title": "Bygg en baslinje", + "training.plan_baseline_body": "Kör lugnt och jämnt tills minst 10 rena varv är registrerade. Fokusera på samma linje och bromspunkt varje varv.", + "training.plan_consistency_title": "Höj din lägstanivå", + "training.plan_consistency_body": "Spridningen är hög. Kör först 10 minuter nära max, sedan 10 minuter på 80-90 procent där rena, repeterbara varv är viktigare än ett enstaka snabbt varv.", + "training.plan_progression_title": "Balansera fart och kontroll", + "training.plan_progression_body": "Du har en stabil grund. Kör 10 minuter med maxfokus och avsluta med 10 minuter kontrollerad körning där målet är en obruten serie jämna varv.", + "training.plan_target": "Kontrollmål omkring {lap}", + "training.plan_no_target": "Registrera fler varv för tidsmål", + "training.block_push": "Maxfokus: hitta gränsen", + "training.block_control": "80-90%: höj lägstanivån", + "training.new_goal": "Nytt delmål", + "training.target_laps": "Antal varv", + "training.quick_targets": "Snabbval för antal varv", + "training.goal_name": "Namn på delmålet", + "training.goal_name_placeholder": "Till exempel Bronsnivå", + "training.reward": "Belöning", + "training.reward_placeholder": "Till exempel ny kaross eller klubbmärke", + "training.add_goal": "Lägg till delmål", + "training.goals": "Delmål och rewards", + "training.no_goals": "Inga delmål finns för den här föraren ännu.", + "training.default_goal_title": "Nå {laps} varv", + "training.goal_progress": "Framsteg mot delmål", + "training.goal_remaining": "{laps} varv kvar", + "training.goal_reached": "Målet är nått", + "training.goal_claimed": "Belöning utdelad", + "training.claim_reward": "Markera belöning utdelad", "mode.track": "Sponsor Event", "mode.race": "Race", "dashboard.events": "Event", @@ -1008,11 +1058,11 @@ const TRANSLATIONS = { "guide.host_7": "7. OBS overlay finns nu som publik URL: `/public-overlay/obs` och kan konfigureras från Overlay-menyn.", "guide.host_8": "8. Sätt `PUBLIC_OVERLAY_TOKEN` på servern om du vill kräva `?token=...` för publika overlay-länkar.", "guide.windows_title": "Windows + AMMC + npm", - "guide.windows_1": "1. Installera Node.js LTS och Visual C++ Runtime 2015-2022 på hosten som ska köra `live_event`.", - "guide.windows_2": "2. Standardbinär för Managed AMMC på Windows-host: `AMMC/windows64/ammc-amb.exe`.", - "guide.windows_3": "3. Kör `npm start` på Windows-hosten. Då är det där AMMC startas om du använder Managed AMMC.", - "guide.windows_4": "4. I Settings: `Decoder IP / host` = decoderns IP, t.ex. `192.168.1.11`.", - "guide.windows_5": "5. I appen: `Backend URL` = http://:8081, `WebSocket URL` = ws://:9000.", + "guide.windows_1": "1. Använd 64-bitars Windows 11. Dubbelklicka på `windows/install.bat`; skriptet installerar vid behov Node.js LTS med winget och kör `npm ci`.", + "guide.windows_2": "2. Installationen kräver Node 22 eller 24 LTS och verifierar automatiskt Express, SQLite samt `AMMC/windows64`.", + "guide.windows_3": "3. Starta i bakgrunden med `npm start` eller `windows/start_backend.bat`. Kontrollera med `npm run status` och stoppa med `npm stop`.", + "guide.windows_4": "4. I Inställningar: `Decoder IP / host` = decoderns IP, t.ex. `192.168.1.10`. Managed AMMC körs på Windows-hosten.", + "guide.windows_5": "5. Från andra enheter används `http://:8081` och `ws://:9000`. Se `windows/README.md` för brandvägg och felsökning.", "guide.linux_title": "Linux + npm", "guide.linux_1": "1. Installera Node 22 LTS, build-essential och python3 på Linux-hosten.", "guide.linux_2": "2. Standardbinär för Managed AMMC på Linux-host: `AMMC/linux_x86-64/ammc-amb`.", @@ -1052,6 +1102,8 @@ const TRANSLATIONS = { "nav.drivers_sub": "Drivers and personal transponders", "nav.cars": "Cars", "nav.cars_sub": "Track cars with fixed transponders", + "nav.training": "Training", + "nav.training_sub": "Development, milestones and rewards", "nav.timing": "Timing", "nav.timing_sub": "Live timing board", "nav.judging": "Judging", @@ -1071,6 +1123,46 @@ const TRANSLATIONS = { "ui.event": "Event", "ui.decoder_online": "Decoder Online", "ui.decoder_offline": "Decoder Offline", + "training.eyebrow": "Driver development", + "training.intro": "Track practice across sessions and make progress visible between race days.", + "training.driver": "Driver", + "training.no_drivers_title": "No driver registered", + "training.no_drivers_body": "Create a driver first. Training statistics require passings linked to a registered driver.", + "training.total_laps": "Total laps", + "training.valid_laps": "Valid recorded laps", + "training.sessions": "Sessions", + "training.recorded_sessions": "With recorded driver laps", + "training.best_lap": "Best lap", + "training.personal_best": "Personal best in stored data", + "training.consistency": "Spread", + "training.last_30_laps": "P10-P90, latest 30 laps", + "training.recommendation": "Recommended practice session", + "training.plan_baseline_title": "Build a baseline", + "training.plan_baseline_body": "Drive calmly and consistently until at least 10 clean laps are recorded. Repeat the same line and braking point every lap.", + "training.plan_consistency_title": "Raise your minimum level", + "training.plan_consistency_body": "Lap spread is high. Run 10 minutes near maximum pace, then 10 minutes at 80-90 percent where clean, repeatable laps matter more than one fast lap.", + "training.plan_progression_title": "Balance pace and control", + "training.plan_progression_body": "You have a stable baseline. Run 10 minutes at maximum focus and finish with 10 controlled minutes aiming for an unbroken sequence of consistent laps.", + "training.plan_target": "Control target around {lap}", + "training.plan_no_target": "Record more laps to calculate a target", + "training.block_push": "Maximum focus: find the limit", + "training.block_control": "80-90%: raise the floor", + "training.new_goal": "New milestone", + "training.target_laps": "Target laps", + "training.quick_targets": "Quick lap targets", + "training.goal_name": "Milestone name", + "training.goal_name_placeholder": "For example Bronze level", + "training.reward": "Reward", + "training.reward_placeholder": "For example a new body or club badge", + "training.add_goal": "Add milestone", + "training.goals": "Milestones and rewards", + "training.no_goals": "This driver has no milestones yet.", + "training.default_goal_title": "Reach {laps} laps", + "training.goal_progress": "Milestone progress", + "training.goal_remaining": "{laps} laps remaining", + "training.goal_reached": "Milestone reached", + "training.goal_claimed": "Reward delivered", + "training.claim_reward": "Mark reward delivered", "mode.track": "Track Event", "mode.race": "Race", "dashboard.events": "Events", @@ -1812,11 +1904,11 @@ const TRANSLATIONS = { "guide.host_4": "4. Run the backend on Windows -> the Windows binary is used: `AMMC/windows64/ammc-amb.exe`.", "guide.host_5": "5. The `AMMC executable` field in Settings is a path on the backend host, not on the client laptop.", "guide.windows_title": "Windows + AMMC + npm", - "guide.windows_1": "1. Install Node.js LTS and Visual C++ Runtime 2015-2022 on the host that will run `live_event`.", - "guide.windows_2": "2. Default Managed AMMC binary on a Windows host: `AMMC/windows64/ammc-amb.exe`.", - "guide.windows_3": "3. Run `npm start` on the Windows host. Managed AMMC starts there if enabled.", - "guide.windows_4": "4. In Settings: `Decoder IP / host` = decoder IP, for example `192.168.1.11`.", - "guide.windows_5": "5. In app: `Backend URL` = http://:8081, `WebSocket URL` = ws://:9000.", + "guide.windows_1": "1. Use 64-bit Windows 11. Double-click `windows/install.bat`; it installs Node.js LTS with winget when needed and runs `npm ci`.", + "guide.windows_2": "2. Setup requires Node 22 or 24 LTS and automatically verifies Express, SQLite, and `AMMC/windows64`.", + "guide.windows_3": "3. Start in the background with `npm start` or `windows/start_backend.bat`. Check with `npm run status` and stop with `npm stop`.", + "guide.windows_4": "4. In Settings, `Decoder IP / host` is the decoder address, for example `192.168.1.10`. Managed AMMC runs on the Windows host.", + "guide.windows_5": "5. Other devices use `http://:8081` and `ws://:9000`. See `windows/README.md` for firewall and troubleshooting.", "guide.linux_title": "Linux + npm", "guide.linux_1": "1. Install Node 22 LTS, build-essential and python3 on the Linux host.", "guide.linux_2": "2. Default Managed AMMC binary on a Linux host: `AMMC/linux_x86-64/ammc-amb`.", @@ -1856,7 +1948,8 @@ const overlayViewMode = allowedOverlayModes.includes(routeOverlayMode) : "leaderboard"; const publicOverlayMode = Boolean(publicOverlayMatch); const state = loadState(); -let currentView = overlayMode ? "overlay" : "dashboard"; +const requestedView = String(urlParams.get("view") || "").toLowerCase(); +let currentView = overlayMode ? "overlay" : NAV_ITEMS.some((item) => item.id === requestedView) ? requestedView : "dashboard"; let wsClient = null; let reconnectTimer = null; let backendSyncTimer = null; @@ -1875,6 +1968,7 @@ let selectedEventEditId = null; let selectedSessionEditId = null; let selectedTeamEditId = null; let selectedJudgeKey = null; +let selectedTrainingDriverId = String(urlParams.get("driver") || ""); let judgingCompetitorFilter = "all"; let judgingLogFilter = "all"; let quickAddDraft = null; @@ -2202,6 +2296,7 @@ function seedDefaultData() { state.cars = state.cars.map((car) => normalizeCar(car)).filter((car) => car.name); state.events = state.events.map((event) => normalizeEvent(event)); state.sessions = state.sessions.map((session) => normalizeSession(session)); + state.training = normalizeTrainingState(state.training); saveState({ skipBackend: true }); } @@ -2218,6 +2313,7 @@ function loadState() { events: parsed.events || [], sessions: parsed.sessions || [], resultsBySession: parsed.resultsBySession || {}, + training: normalizeTrainingState(parsed.training), activeSessionId: parsed.activeSessionId || null, settings: { wsUrl: parsed.settings?.wsUrl || "ws://127.0.0.1:9000", @@ -2262,6 +2358,7 @@ function loadState() { events: [], sessions: [], resultsBySession: {}, + training: normalizeTrainingState(), activeSessionId: null, settings: { wsUrl: "ws://127.0.0.1:9000", @@ -2382,6 +2479,7 @@ function buildPersistableState() { events: state.events, sessions: state.sessions.map((session) => normalizeSession(session)), resultsBySession: state.resultsBySession, + training: normalizeTrainingState(state.training), activeSessionId: state.activeSessionId, settings: state.settings, }; @@ -2441,6 +2539,7 @@ function buildDataExportPayload() { events: state.events, sessions: state.sessions, resultsBySession: state.resultsBySession, + training: normalizeTrainingState(state.training), }; } @@ -2601,6 +2700,7 @@ function importDataPayload(parsed, mode = "full") { state.events = incomingEvents.map((item) => normalizeEvent(item)); state.sessions = incomingSessions.map((item) => normalizeSession(item)); state.resultsBySession = parsed?.resultsBySession && typeof parsed.resultsBySession === "object" ? parsed.resultsBySession : {}; + state.training = normalizeTrainingState(parsed?.training); if (!state.sessions.some((session) => session.id === state.activeSessionId)) { state.activeSessionId = null; } @@ -2634,6 +2734,7 @@ function renderNav() { button.textContent = t(item.titleKey); button.addEventListener("click", () => { currentView = item.id; + updateViewUrl(currentView); renderNav(); renderView(); }); @@ -2641,6 +2742,16 @@ function renderNav() { }); } +function updateViewUrl(view, driverId = "") { + if (overlayMode) return; + const nextUrl = new URL(window.location.href); + if (view && view !== "dashboard") nextUrl.searchParams.set("view", view); + else nextUrl.searchParams.delete("view"); + if (view === "training" && driverId) nextUrl.searchParams.set("driver", driverId); + else nextUrl.searchParams.delete("driver"); + window.history.replaceState({}, "", nextUrl); +} + function renderView() { clearModalEscapeHandler(); const navMeta = NAV_ITEMS.find((x) => x.id === currentView); @@ -2675,6 +2786,9 @@ function renderView() { case "cars": renderCars(); break; + case "training": + renderTraining(); + break; case "timing": renderTiming(); break; diff --git a/src/decoder_runtime.js b/src/decoder_runtime.js index a0c4812..fde368f 100644 --- a/src/decoder_runtime.js +++ b/src/decoder_runtime.js @@ -155,7 +155,7 @@ export function connectDecoderHelper(deps) { saveState(); updateConnectionBadge(); - if (["timing", "dashboard", "overlay"].includes(getCurrentView())) { + if (["timing", "dashboard", "overlay", "training"].includes(getCurrentView())) { renderView(); } }; diff --git a/src/runtime_services.js b/src/runtime_services.js index 3be8f65..5545cc2 100644 --- a/src/runtime_services.js +++ b/src/runtime_services.js @@ -86,13 +86,14 @@ export async function stopManagedAmmcHelper({ ammc, t, getBackendUrl }) { } export function applyPersistedStateHelper(persisted, deps) { - const { state, getDefaultBackendUrl, DEFAULT_LANGUAGE, DEFAULT_THEME, AVAILABLE_THEMES, normalizeDriver, normalizeCar, normalizeEvent, normalizeSession, normalizeStoredRacePreset, normalizeObsOverlaySettings, applyTheme } = deps; + const { state, getDefaultBackendUrl, DEFAULT_LANGUAGE, DEFAULT_THEME, AVAILABLE_THEMES, normalizeDriver, normalizeCar, normalizeEvent, normalizeSession, normalizeStoredRacePreset, normalizeObsOverlaySettings, normalizeTrainingState, applyTheme } = deps; state.classes = persisted.classes || []; state.drivers = (persisted.drivers || []).map((driver) => normalizeDriver(driver)).filter((driver) => driver.name); state.cars = (persisted.cars || []).map((car) => normalizeCar(car)).filter((car) => car.name); state.events = (persisted.events || []).map((event) => normalizeEvent(event)); state.sessions = (persisted.sessions || []).map((session) => normalizeSession(session)); state.resultsBySession = persisted.resultsBySession || {}; + state.training = normalizeTrainingState(persisted.training); state.activeSessionId = persisted.activeSessionId || null; state.settings = { wsUrl: persisted.settings?.wsUrl || state.settings.wsUrl || "ws://127.0.0.1:9000", diff --git a/src/styles.css b/src/styles.css index 11b6b75..50a6d2c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -294,6 +294,167 @@ body { font-size: 1.5rem; } +.training-hero { + display: flex; + align-items: end; + justify-content: space-between; + gap: 24px; + padding: 24px; + border: 1px solid color-mix(in srgb, var(--line) 65%, var(--accent) 35%); + border-radius: 18px; + background: + radial-gradient(circle at 12% 0%, color-mix(in srgb, var(--accent) 22%, transparent), transparent 42%), + linear-gradient(135deg, var(--panel), var(--panel-2)); + box-shadow: var(--shadow); +} + +.training-hero h3 { + margin: 5px 0 4px; + font-family: Orbitron, sans-serif; + font-size: clamp(1.8rem, 4vw, 3.2rem); +} + +.training-hero p { + max-width: 720px; + margin: 0; + color: var(--muted); +} + +.training-kicker { + color: color-mix(in srgb, var(--accent) 65%, var(--text) 35%); + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.training-driver-select { + display: grid; + min-width: min(320px, 100%); + gap: 6px; + color: var(--muted); + font-size: 0.82rem; +} + +.training-stats { + margin-top: 14px; +} + +.training-layout { + display: grid; + grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.75fr); + gap: 14px; + margin-top: 14px; +} + +.training-plan .panel-body > h3 { + margin: 8px 0; + font-family: Orbitron, sans-serif; +} + +.training-plan .panel-body > p { + color: var(--muted); + line-height: 1.55; +} + +.training-blocks { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-top: 18px; +} + +.training-blocks div { + display: grid; + gap: 4px; + padding: 14px; + border-left: 3px solid var(--accent); + border-radius: 8px; + background: color-mix(in srgb, var(--surface-elevated) 88%, var(--accent) 12%); +} + +.training-blocks strong { + font-family: Orbitron, sans-serif; + font-size: 1.25rem; +} + +.training-blocks span { + color: var(--muted); +} + +.training-goal-form label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 0.82rem; +} + +.training-presets { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.training-goals { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 12px; +} + +.training-goal { + padding: 14px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--surface-elevated); +} + +.training-goal.reached { + border-color: color-mix(in srgb, #47d98a 60%, var(--line) 40%); +} + +.training-goal.claimed { + opacity: 0.72; +} + +.training-goal-head { + display: flex; + align-items: start; + justify-content: space-between; + gap: 14px; +} + +.training-goal h4 { + margin: 4px 0 0; +} + +.training-goal-head > strong { + white-space: nowrap; + font-family: Orbitron, sans-serif; +} + +.training-goal p { + color: var(--muted); +} + +.training-progress { + height: 6px; + margin: 14px 0; + overflow: hidden; + border-radius: 99px; + background: color-mix(in srgb, var(--line) 75%, transparent); +} + +.training-progress span { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--accent), #ffcc4d); +} + +.training-empty { + max-width: 680px; +} + .panel-row { display: grid; grid-template-columns: 1.4fr 1fr; @@ -2113,6 +2274,10 @@ select:focus { .panel-row { grid-template-columns: 1fr; } + + .training-layout { + grid-template-columns: 1fr; + } } @media (max-width: 820px) { @@ -2149,6 +2314,24 @@ select:focus { gap: 10px; } + .training-hero { + align-items: stretch; + flex-direction: column; + padding: 18px; + } + + .training-driver-select { + min-width: 0; + } + + .training-blocks { + grid-template-columns: 1fr; + } + + .training-goals { + grid-template-columns: 1fr; + } + .field-card-checkbox { grid-column: span 1; } diff --git a/src/training_logic.js b/src/training_logic.js new file mode 100644 index 0000000..57b7754 --- /dev/null +++ b/src/training_logic.js @@ -0,0 +1,113 @@ +export function normalizeTrainingState(training, { uid }) { + const source = training && typeof training === "object" ? training : {}; + const goals = Array.isArray(source.goals) + ? source.goals + .map((goal) => ({ + id: String(goal?.id || uid("goal")), + driverId: String(goal?.driverId || ""), + title: String(goal?.title || "").trim(), + reward: String(goal?.reward || "").trim(), + targetLaps: Math.max(1, Math.round(Number(goal?.targetLaps || 0) || 0)), + createdAt: String(goal?.createdAt || new Date().toISOString()), + claimedAt: goal?.claimedAt ? String(goal.claimedAt) : "", + })) + .filter((goal) => goal.driverId && goal.targetLaps > 0) + : []; + + return { goals }; +} + +export function getDriverTrainingLaps(state, driverId) { + const laps = []; + Object.entries(state?.resultsBySession || {}).forEach(([sessionId, result]) => { + (Array.isArray(result?.passings) ? result.passings : []).forEach((passing) => { + const lapMs = Number(passing?.lapMs || 0); + if ( + String(passing?.driverId || "") === String(driverId || "") && + passing?.validLap !== false && + lapMs > 500 + ) { + laps.push({ + sessionId, + timestamp: Number(passing.timestamp || 0) || 0, + lapMs, + }); + } + }); + }); + return laps.sort((left, right) => left.timestamp - right.timestamp); +} + +function percentile(values, ratio) { + if (!values.length) return 0; + const sorted = [...values].sort((left, right) => left - right); + const index = Math.min(sorted.length - 1, Math.max(0, Math.round((sorted.length - 1) * ratio))); + return sorted[index]; +} + +export function buildDriverTrainingStats(state, driverId) { + const laps = getDriverTrainingLaps(state, driverId); + const lapTimes = laps.map((lap) => lap.lapMs); + const recentLapTimes = lapTimes.slice(-30); + const totalLaps = laps.length; + const bestLapMs = totalLaps ? Math.min(...lapTimes) : 0; + const averageLapMs = totalLaps ? Math.round(lapTimes.reduce((sum, value) => sum + value, 0) / totalLaps) : 0; + const recentAverageMs = recentLapTimes.length + ? Math.round(recentLapTimes.reduce((sum, value) => sum + value, 0) / recentLapTimes.length) + : 0; + const recentP10Ms = percentile(recentLapTimes, 0.1); + const recentP90Ms = percentile(recentLapTimes, 0.9); + const consistencySpreadPct = recentAverageMs + ? ((recentP90Ms - recentP10Ms) / recentAverageMs) * 100 + : 0; + + return { + totalLaps, + totalSessions: new Set(laps.map((lap) => lap.sessionId)).size, + bestLapMs, + averageLapMs, + recentAverageMs, + consistencySpreadPct, + recentLapCount: recentLapTimes.length, + lastLapAt: laps.at(-1)?.timestamp || 0, + }; +} + +export function buildTrainingRecommendation(stats) { + if (!stats || stats.totalLaps < 10) { + return { + level: "baseline", + titleKey: "training.plan_baseline_title", + bodyKey: "training.plan_baseline_body", + targetLapMs: 0, + }; + } + + if (stats.recentLapCount >= 10 && stats.consistencySpreadPct > 8) { + return { + level: "consistency", + titleKey: "training.plan_consistency_title", + bodyKey: "training.plan_consistency_body", + targetLapMs: Math.round(stats.bestLapMs * 1.08), + }; + } + + return { + level: "progression", + titleKey: "training.plan_progression_title", + bodyKey: "training.plan_progression_body", + targetLapMs: Math.round(stats.bestLapMs * 1.05), + }; +} + +export function getTrainingGoalProgress(goal, stats) { + const targetLaps = Math.max(1, Number(goal?.targetLaps || 1)); + const currentLaps = Math.max(0, Number(stats?.totalLaps || 0)); + return { + currentLaps, + targetLaps, + remainingLaps: Math.max(0, targetLaps - currentLaps), + percent: Math.min(100, Math.max(0, (currentLaps / targetLaps) * 100)), + reached: currentLaps >= targetLaps, + }; +} diff --git a/src/training_views.js b/src/training_views.js new file mode 100644 index 0000000..b065aac --- /dev/null +++ b/src/training_views.js @@ -0,0 +1,181 @@ +export function renderTrainingView(deps) { + const { + state, + dom, + t, + escapeHtml, + formatLap, + uid, + saveState, + renderView, + normalizeTrainingState, + buildDriverTrainingStats, + buildTrainingRecommendation, + getTrainingGoalProgress, + getSelectedDriverId, + setSelectedDriverId, + } = deps; + + state.training = normalizeTrainingState(state.training); + const drivers = [...state.drivers].sort((left, right) => String(left.name).localeCompare(String(right.name))); + let selectedDriverId = String(getSelectedDriverId() || ""); + if (!drivers.some((driver) => driver.id === selectedDriverId)) { + selectedDriverId = drivers[0]?.id || ""; + setSelectedDriverId(selectedDriverId); + } + + if (!selectedDriverId) { + dom.view.innerHTML = ` +
+

${escapeHtml(t("training.no_drivers_title"))}

+

${escapeHtml(t("training.no_drivers_body"))}

+
+ `; + return; + } + + const selectedDriver = drivers.find((driver) => driver.id === selectedDriverId); + const stats = buildDriverTrainingStats(state, selectedDriverId); + const recommendation = buildTrainingRecommendation(stats); + const goals = state.training.goals.filter((goal) => goal.driverId === selectedDriverId); + const driverOptions = drivers + .map((driver) => ``) + .join(""); + + const goalMarkup = goals.length + ? goals.map((goal) => { + const progress = getTrainingGoalProgress(goal, stats); + const statusClass = goal.claimedAt ? "claimed" : progress.reached ? "reached" : "active"; + const statusLabel = goal.claimedAt + ? t("training.goal_claimed") + : progress.reached + ? t("training.goal_reached") + : t("training.goal_remaining", { laps: progress.remainingLaps }); + return ` +
+
+
+ ${escapeHtml(statusLabel)} +

${escapeHtml(goal.title || t("training.default_goal_title", { laps: goal.targetLaps }))}

+
+ ${progress.currentLaps} / ${progress.targetLaps} +
+
+ +
+ ${goal.reward ? `

${escapeHtml(t("training.reward"))}: ${escapeHtml(goal.reward)}

` : ""} +
+ ${progress.reached && !goal.claimedAt ? `` : ""} + +
+
+ `; + }).join("") + : `

${escapeHtml(t("training.no_goals"))}

`; + + const targetText = recommendation.targetLapMs + ? t("training.plan_target", { lap: formatLap(recommendation.targetLapMs) }) + : t("training.plan_no_target"); + + dom.view.innerHTML = ` +
+
+ ${escapeHtml(t("training.eyebrow"))} +

${escapeHtml(selectedDriver.name)}

+

${escapeHtml(t("training.intro"))}

+
+ +
+ +
+ ${trainingStat(t("training.total_laps"), String(stats.totalLaps), t("training.valid_laps"))} + ${trainingStat(t("training.sessions"), String(stats.totalSessions), t("training.recorded_sessions"))} + ${trainingStat(t("training.best_lap"), formatLap(stats.bestLapMs), t("training.personal_best"))} + ${trainingStat(t("training.consistency"), stats.recentLapCount ? `${stats.consistencySpreadPct.toFixed(1)}%` : "-", t("training.last_30_laps"))} +
+ +
+
+

${escapeHtml(t("training.recommendation"))}

20 min
+
+ ${escapeHtml(t(recommendation.titleKey))} +

${escapeHtml(targetText)}

+

${escapeHtml(t(recommendation.bodyKey))}

+
+
10 min${escapeHtml(t("training.block_push"))}
+
10 min${escapeHtml(t("training.block_control"))}
+
+
+
+ +
+

${escapeHtml(t("training.new_goal"))}

+
+
+ +
+ ${[500, 1000, 1500].map((value) => ``).join("")} +
+ + + +
+
+
+
+ +
+

${escapeHtml(t("training.goals"))}

${goals.length}
+
${goalMarkup}
+
+ `; + + document.getElementById("training-driver")?.addEventListener("change", (event) => { + setSelectedDriverId(event.currentTarget.value); + renderView(); + }); + + document.querySelectorAll(".training-preset").forEach((button) => { + button.addEventListener("click", () => { + const input = document.querySelector('#training-goal-form input[name="targetLaps"]'); + if (input instanceof HTMLInputElement) input.value = button.dataset.laps || "500"; + }); + }); + + document.getElementById("training-goal-form")?.addEventListener("submit", (event) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const targetLaps = Math.max(1, Math.round(Number(form.get("targetLaps") || 0) || 0)); + state.training.goals.push({ + id: uid("goal"), + driverId: selectedDriverId, + targetLaps, + title: String(form.get("title") || "").trim(), + reward: String(form.get("reward") || "").trim(), + createdAt: new Date().toISOString(), + claimedAt: "", + }); + saveState(); + renderView(); + }); + + goals.forEach((goal) => { + document.getElementById(`training-claim-${goal.id}`)?.addEventListener("click", () => { + state.training.goals = state.training.goals.map((item) => item.id === goal.id ? { ...item, claimedAt: new Date().toISOString() } : item); + saveState(); + renderView(); + }); + document.getElementById(`training-delete-${goal.id}`)?.addEventListener("click", () => { + state.training.goals = state.training.goals.filter((item) => item.id !== goal.id); + saveState(); + renderView(); + }); + }); +} + +function trainingStat(label, value, note) { + return `

${label}

${value}

${note}
`; +} diff --git a/windows/README.md b/windows/README.md index f6b19d6..c09d80e 100644 --- a/windows/README.md +++ b/windows/README.md @@ -1,174 +1,169 @@ -# JMK RB Live Event +# Windows 11 installation -RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika heat/finaler), AMMC WebSocket och lokal SQLite-lagring på Windows. +This folder contains the supported Windows 11 installation and start scripts for JMK RB RaceController. -## Vad som ingår -- Event-lägen: - - `Race (driver transponders)` - - `Track Event (shared cars)` -- UI-separering: - - `Event` = sponsor-event med delade bilar/transpondrar - - `Race Setup` = riktiga race med personlig transponder per förare -- `Race Setup` innehåller nu även: - - välj exakt vilka förare som är med i racet - - practice-ranking - - kval-ranking med `poäng` eller `bästa resultat` - - inbyggd guide för hur man skapar race steg för steg - - beskrivningar direkt i alla fält under `Raceformat` - - sessionstyp `Free Practice` för löpande varvtider utan seedning - - auto-generering av kvalheat från practice-ranking eller klasslista - - reseeding av kommande kvalheat från aktuell ranking - - auto-generering av `A/B/C...` finaler från ranking - - sparad manuell grid per session via dragbar grid-editor - - final-ranking över flera leg med räknade finalheat - - valbar `bump-up` mellan finaler - - reserverade bump-platser i högre finaler - - visuell finalmatris med reserverade bump-platser - - dragbar grid-editor för positionsstart - - utskrift/export av heatsheets per kval/final - - overlay-vy för extern leaderboard-skärm - - flera overlay-lägen: leaderboard, speaker och results - - utskrift av startlistor och resultat - - genererade kval/finaler ärver tid och starttyp från raceformatet - - finish-ljud som siren i stället för browser-röst -- Sessioner: `practice`, `qualification`, `heat`, `final` -- Sponsor-verktyg: - - Skapa rundor automatiskt (`qualification`, `heat`, `final`) - - Auto-assign förare -> bil per session -- Live timing från AMMC WebSocket (`msg: "PASSING"`) -- Hanterad AMMC från webbgränssnittet: - - backend kan starta/stoppa lokal `ammc-amb` på Windows, Linux och macOS - - läser bundlade binärer från `AMMC/windows64`, `AMMC/linux_x86-64`, `AMMC/apple_m` -- Redigering i UI: - - Klasser, eventnamn/datum, förare och bilar kan redigeras direkt -- Live race-kontroll: - - Nedräkning under pågående session - - Auto-finish vid tidslut med status `Race is finished` - - Leaderboard-sortering: varv först, därefter närmast måltid för sessionen (t.ex. 5 min = 600s) - - Browserljud för passing (`blipp` eller tala förarnamn) och målgång - - Sessioninställningar för `Mass start`, `Position start`, `Staggered` - - `Timing` visar grid/startordning för aktiv `Position start`-session - - leaderboard visar både `gap till ledaren`, `gap till bilen framför` och `eget delta` mot förra varvet - - Practice/Kval kan seedas på bästa `2` eller `3` varv i sessionsinställningar -- Persistens: - - Frontend state i browser (`localStorage`) - - Samma state + passeringar sparas i lokal SQLite via Node-backend -- Inbyggd `Guide`-meny i appen med steg-för-steg för: - - Sponsor-event (10 personer / 4 bilar) - - Vanligt race - - AMMC + npm setup på Windows och Linux -- Språkval i UI: `SV` / `EN` +## Supported runtime -## Windows installation -Kör i PowerShell i projektmappen. +- 64-bit Windows 11 (`x64` or `arm64`) +- Node.js `22` or `24` LTS +- npm `10` or newer +- bundled AMMC runtime in `AMMC\windows64` -1. Installera Node.js LTS (18+). -2. Installera dependencies: - ```powershell - npm install - ``` -3. Starta servern i bakgrunden: - ```powershell - npm start - ``` -4. Öppna: - - `http://localhost:8081` - - eller från annan dator: `http://:8081` +Node.js 18 and 20 are not supported by the current native SQLite dependency. + +## Recommended installation + +1. Extract or clone the complete repository, preferably to a short path such as `C:\Live_RC`. +2. Double-click `windows\install.bat`. +3. Approve the Node.js LTS installation if Windows asks for it. +4. Wait until the script reports `Windows setup completed`. +5. Start with `windows\start_backend.bat` or `npm start`. +6. Open `http://localhost:8081`. + +The installer: + +- checks the Windows architecture +- checks Node.js and installs current LTS with `winget` when needed +- installs/checks Microsoft Visual C++ 2015-2022 x64 Runtime for bundled AMMC +- runs `npm ci` from `package-lock.json` +- verifies that Express loads +- opens an in-memory SQLite database through `better-sqlite3` +- verifies `ammc-amb.exe`, `ammc.dll`, and `MyLapsSDK.dll` +- creates `data\` and `logs\` + +Run from PowerShell instead when you want options: + +```powershell +Set-ExecutionPolicy -Scope Process Bypass +.\windows\setup.ps1 +``` + +Install, add inbound firewall rules for ports `8081` and `9000`, and start the server: + +```powershell +Start-Process powershell -Verb RunAs -ArgumentList '-ExecutionPolicy Bypass -File .\windows\setup.ps1 -OpenFirewall -StartServer' +``` + +## Manual installation + +Install Node.js 22 or 24 LTS, then run in the repository root: + +```powershell +npm ci +npm run verify +npm start +``` + +`npm ci` installs exactly the dependency versions in `package-lock.json`. The install fails early with an actionable message if Node.js is unsupported. The post-install verification also fails if the native SQLite module cannot run. + +## Runtime commands -Vanliga kommandon: ```powershell npm start -npm stop -npm restart npm run status +npm restart +npm stop npm run start:fg ``` -- `npm start` startar `live_event` i bakgrunden -- `npm stop` stoppar processen via `data/server.pid` -- `npm restart` startar om backend -- `npm run status` visar om backend kör -- `npm run start:fg` kör i foreground för felsökning +- `npm start` starts `server.js` in the background. +- `npm run status` reads `data\server.pid` and reports process state. +- `npm restart` restarts the background process. +- `npm stop` stops it. +- `npm run start:fg` runs in the current terminal for troubleshooting. +- stdout is written to `logs\server.out.log`. +- stderr is written to `logs\server.err.log`. +- SQLite data is written to `data\rc_timing.sqlite`. -### Windows scripts (bakgrundsstart) -Det finns färdiga `.bat`-filer i mappen `windows/`: -- `windows\\start_ammc.bat` startar AMMC i bakgrunden -- `windows\\start_backend.bat` startar `npm start` i bakgrunden (logg: `logs\\backend.log`) -- `windows\\start_all.bat` startar både AMMC + backend och öppnar webbsidan -- `windows\\stop_all.bat` stoppar backend på port `8081` och `ammc-amb.exe` +## Windows helper scripts -SQLite-filen skapas automatiskt här: -- `data\\rc_timing.sqlite` +- `windows\install.bat`: install and verify all runtime dependencies +- `windows\start_backend.bat`: start the Node backend in the background +- `windows\start_ammc.bat`: start bundled AMMC manually +- `windows\start_all.bat`: start AMMC, backend, and open the browser +- `windows\stop_all.bat`: stop the backend and `ammc-amb.exe` -## Koppla mot AMMC -Det finns nu två sätt: +Manual AMMC start with a decoder IP: -### A. Hanterad AMMC i webbgränssnittet -Viktigt: -- AMMC körs på samma host där `npm start` / `node server.js` körs. -- Om du öppnar sidan från en annan laptop startas ingen AMMC där. -- Fältet `AMMC binär` i `Settings` är en sökväg på backend-hosten, inte på klienten som surfar in. +```bat +windows\start_ammc.bat 192.168.1.10 +``` -1. Lägg AMMC-binärerna i projektmappen `AMMC/` (redan gjort i denna repo). -2. Öppna `Settings`. -3. Aktivera `Hanterad AMMC / Managed AMMC`. -4. Sätt `Decoder IP / host`, kontrollera port `9000`, spara. -5. Klicka `Starta AMMC / Start AMMC`. -6. Klicka `Använd serverns WS-url / Use server WS URL` så sätts klienten till t.ex. `ws://:9000`. +The script resolves the executable to: -Standardbinärer: -- Linux-host: `AMMC/linux_x86-64/ammc-amb` -- Windows-host: `AMMC/windows64/ammc-amb.exe` -- macOS-host: `AMMC/apple_m/ammc-amb` +```text +AMMC\windows64\ammc-amb.exe +``` + +You can override its defaults before starting: + +```bat +set DECODER_IP=192.168.1.10 +set WS_PORT=9000 +windows\start_ammc.bat +``` + +## Managed AMMC from the web UI + +The recommended normal operation is to let the backend manage AMMC: + +1. Start RaceController with `npm start`. +2. Open `Settings`. +3. Enable `Managed AMMC`. +4. Set the decoder IP and WebSocket port `9000`. +5. Save and click `Start AMMC`. +6. Use the server WebSocket URL and connect the decoder. + +AMMC always runs on the computer that runs `npm start`. Opening the UI from a laptop does not start AMMC on that laptop. + +## Network access + +- Admin/UI/backend: TCP `8081` +- AMMC WebSocket: TCP `9000` + +Find the Windows server address with: -### B. Manuell start -Starta AMMC med WebSocket (exempel): ```powershell -ammc-amb.exe -w 9000 192.168.1.11 +ipconfig ``` -I appen: -1. `Settings` -2. Sätt `WebSocket URL` till t.ex. `ws://127.0.0.1:9000` -3. Sätt `Backend URL` till `http://127.0.0.1:8081` -4. Klicka `Test Backend` -5. Gå till `Timing` och klicka `Connect Decoder` +Then open this from another computer on the LAN: -Om du kör Linux-brandvägg (UFW), öppna porten: -```bash -sudo ufw allow 8081/tcp +```text +http://:8081 ``` -## Auto reload vid uppdatering -- Servern bevakar `index.html`, `src/app.js` och `src/styles.css`. -- När du uppdaterar filer i `live_event` och sparar, laddar klienten om sidan automatiskt. -- Om backendkoden ändras, kör `npm restart`. +Do not expose the full admin UI directly to the internet. Publish only `/public-overlay/*` through a reverse proxy when an external overlay is needed. -## Verifiera att SQLite sparar -Hämta senaste passeringar via API: -- `http://localhost:8081/api/passings` +## Native dependency troubleshooting -Hämta sparad app-state: -- `http://localhost:8081/api/state` +`better-sqlite3` normally installs a prebuilt Windows binary and does not require Visual Studio. If npm falls back to compiling it: -## Viktig regel för sponsor-event -- I **samma pågående session** måste varje aktiv bil ha unikt transponder-ID. -- Samma transponder-ID kan återanvändas i **nästa session** (Heat 1 -> Heat 2 -> Heat 3 -> Final 1 ...). +1. Confirm `node --version` reports Node 22 or 24. +2. Move the repository to a short ASCII path such as `C:\Live_RC`. +3. Run the Node native-tools installer as Administrator: -## Referens AMMC JSON -Officiell quick-start: -- https://www.ammconverter.eu/docs/intro/quick-start/ - -Exempelmeddelande: -```json -{ - "msg": "PASSING", - "passing_number": 1, - "transponder": 232323, - "rtc_time": "2022-10-11T22:57:36.099+02:00", - "strength": 0.0, - "resend": false, - "tran_code": "ID:232323", - "loop_id": "55" -} +```powershell +& "C:\Program Files\nodejs\install_tools.bat" ``` + +4. Reopen PowerShell and run: + +```powershell +npm ci +npm run verify +``` + +## Update an existing installation + +Stop the service, update the files, reinstall locked dependencies, verify, and restart: + +```powershell +npm stop +git pull +npm ci +npm run verify +npm start +``` + +Keep `data\` when updating. It contains the SQLite database, saved state, PID file, and local AMMC configuration. diff --git a/windows/install.bat b/windows/install.bat new file mode 100644 index 0000000..4ab8aa5 --- /dev/null +++ b/windows/install.bat @@ -0,0 +1,18 @@ +@echo off +setlocal + +echo JMK RB RaceController - Windows 11 setup +echo. + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0setup.ps1" %* +set "EXIT_CODE=%ERRORLEVEL%" + +echo. +if not "%EXIT_CODE%"=="0" ( + echo [ERROR] Setup failed with exit code %EXIT_CODE%. +) else ( + echo [OK] Setup completed. +) +echo Press any key to close. +pause >nul +exit /b %EXIT_CODE% diff --git a/windows/setup.ps1 b/windows/setup.ps1 new file mode 100644 index 0000000..8031f5b --- /dev/null +++ b/windows/setup.ps1 @@ -0,0 +1,143 @@ +[CmdletBinding()] +param( + [switch]$OpenFirewall, + [switch]$StartServer +) + +$ErrorActionPreference = "Stop" +$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + +function Write-Step([string]$Message) { + Write-Host "" + Write-Host "==> $Message" -ForegroundColor Cyan +} + +function Refresh-ProcessPath { + $machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine") + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") + $env:Path = "$machinePath;$userPath" +} + +function Get-NodeMajor { + $node = Get-Command node.exe -ErrorAction SilentlyContinue + if (-not $node) { + return $null + } + + $rawVersion = (& $node.Source --version).Trim().TrimStart("v") + $majorText = $rawVersion.Split(".")[0] + $major = 0 + if ([int]::TryParse($majorText, [ref]$major)) { + return $major + } + return $null +} + +function Install-NodeLts { + $winget = Get-Command winget.exe -ErrorAction SilentlyContinue + if (-not $winget) { + throw "Node.js 22/24 LTS is required. Install it from https://nodejs.org/en/download and run this script again." + } + + Write-Step "Installing Node.js LTS with winget" + & $winget.Source install --id OpenJS.NodeJS.LTS --exact --accept-source-agreements --accept-package-agreements + if ($LASTEXITCODE -ne 0) { + throw "winget could not install Node.js LTS (exit code $LASTEXITCODE)." + } + Refresh-ProcessPath +} + +function Install-VcRuntime { + $winget = Get-Command winget.exe -ErrorAction SilentlyContinue + if (-not $winget) { + Write-Warning "winget was not found; Microsoft Visual C++ 2015-2022 x64 Runtime could not be checked." + return + } + + Write-Step "Installing Microsoft Visual C++ 2015-2022 x64 Runtime" + & $winget.Source install --id "Microsoft.VCRedist.2015+.x64" --exact --silent --accept-source-agreements --accept-package-agreements + if ($LASTEXITCODE -ne 0) { + Write-Warning "Visual C++ Runtime install/check returned exit code $LASTEXITCODE. AMMC may still work if it is already installed." + } +} + +Write-Host "JMK RB RaceController - Windows 11 setup" -ForegroundColor White +Write-Host "Project: $ProjectRoot" + +$nodeMajor = Get-NodeMajor +if ($null -eq $nodeMajor -or $nodeMajor -lt 22 -or $nodeMajor -ge 27) { + Install-NodeLts + $nodeMajor = Get-NodeMajor +} + +if ($null -eq $nodeMajor -or $nodeMajor -lt 22 -or $nodeMajor -ge 27) { + throw "A supported Node.js version was not found after installation. Reopen PowerShell and run windows\setup.ps1 again." +} + +Install-VcRuntime + +$nodeCommand = (Get-Command node.exe).Source +$npmCommand = (Get-Command npm.cmd -ErrorAction SilentlyContinue).Source +if (-not $npmCommand) { + throw "npm.cmd was not found. Repair the Node.js installation and run this script again." +} + +Write-Step "Checking runtime" +Write-Host "Node: $(& $nodeCommand --version)" +Write-Host "npm: $(& $npmCommand --version)" +Write-Host "OS: $([Environment]::OSVersion.VersionString) / $env:PROCESSOR_ARCHITECTURE" + +Set-Location $ProjectRoot + +Write-Step "Installing locked npm dependencies" +if (Test-Path (Join-Path $ProjectRoot "package-lock.json")) { + & $npmCommand ci --no-audit --no-fund +} else { + & $npmCommand install --no-audit --no-fund +} + +if ($LASTEXITCODE -ne 0) { + Write-Host "" + Write-Host "If better-sqlite3 attempted a source build, run this once as Administrator:" -ForegroundColor Yellow + Write-Host ' & "C:\Program Files\nodejs\install_tools.bat"' -ForegroundColor Yellow + throw "npm dependency installation failed (exit code $LASTEXITCODE)." +} + +Write-Step "Verifying Express, SQLite and bundled AMMC" +& $nodeCommand scripts\verify-install.js +if ($LASTEXITCODE -ne 0) { + throw "Runtime verification failed (exit code $LASTEXITCODE)." +} + +if ($OpenFirewall) { + Write-Step "Adding Windows Firewall rules" + $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator + ) + if (-not $isAdmin) { + Write-Warning "Firewall rules require an Administrator PowerShell. Setup continues without them." + } else { + foreach ($port in @(8081, 9000)) { + $ruleName = "JMK RB RaceController TCP $port" + if (-not (Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue)) { + New-NetFirewallRule -DisplayName $ruleName -Direction Inbound -Protocol TCP -LocalPort $port -Action Allow | Out-Null + } + Write-Host "Firewall: TCP $port allowed" + } + } +} + +if ($StartServer) { + Write-Step "Starting RaceController" + & $npmCommand start + if ($LASTEXITCODE -ne 0) { + throw "RaceController did not start (exit code $LASTEXITCODE)." + } +} + +Write-Host "" +Write-Host "Windows setup completed." -ForegroundColor Green +Write-Host "Start: npm start" +Write-Host "Status: npm run status" +Write-Host "Stop: npm stop" +Write-Host "Open: http://localhost:8081" diff --git a/windows/start_all.bat b/windows/start_all.bat index 4b51550..5dbbaec 100644 --- a/windows/start_all.bat +++ b/windows/start_all.bat @@ -1,8 +1,10 @@ @echo off setlocal -call "%~dp0start_ammc.bat" +call "%~dp0start_ammc.bat" %* +if errorlevel 1 exit /b %ERRORLEVEL% call "%~dp0start_backend.bat" +if errorlevel 1 exit /b %ERRORLEVEL% start "" "http://localhost:8081" diff --git a/windows/start_ammc.bat b/windows/start_ammc.bat index 0da8f74..769456b 100644 --- a/windows/start_ammc.bat +++ b/windows/start_ammc.bat @@ -1,19 +1,21 @@ @echo off setlocal -if "%AMMC_EXE%"=="" set "AMMC_EXE=%~dp0ammc-amb.exe" -if not exist "%AMMC_EXE%" if exist "%~dp0..\ammc-amb.exe" set "AMMC_EXE=%~dp0..\ammc-amb.exe" -if "%DECODER_IP%"=="" set "DECODER_IP=192.168.1.11" +for %%I in ("%~dp0..") do set "APP_DIR=%%~fI" +if "%AMMC_EXE%"=="" set "AMMC_EXE=%APP_DIR%\AMMC\windows64\ammc-amb.exe" +if not "%~1"=="" set "DECODER_IP=%~1" +if "%DECODER_IP%"=="" set "DECODER_IP=192.168.1.10" if "%WS_PORT%"=="" set "WS_PORT=9000" if not exist "%AMMC_EXE%" ( echo [ERROR] Could not find AMMC executable: echo %AMMC_EXE% - echo Set AMMC_EXE in this file or as environment variable. + echo Expected bundled path: %APP_DIR%\AMMC\windows64\ammc-amb.exe + echo Set AMMC_EXE as an environment variable to override it. exit /b 1 ) -start "AMMC" /min cmd /c "\"%AMMC_EXE%\" -w %WS_PORT% %DECODER_IP%" +start "AMMC" /min "%AMMC_EXE%" -w %WS_PORT% %DECODER_IP% echo [OK] AMMC started in background diff --git a/windows/start_backend.bat b/windows/start_backend.bat index 2619d71..07ca39c 100644 --- a/windows/start_backend.bat +++ b/windows/start_backend.bat @@ -2,14 +2,25 @@ setlocal for %%I in ("%~dp0..") do set "APP_DIR=%%~fI" -set "LOG_DIR=%APP_DIR%\logs" -if not exist "%LOG_DIR%" mkdir "%LOG_DIR%" +where node.exe >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] Node.js was not found. Run windows\install.bat first. + exit /b 1 +) -start "RC Timing Backend" /min cmd /c "cd /d \"%APP_DIR%\" && npm start >> \"%LOG_DIR%\backend.log\" 2>&1" +pushd "%APP_DIR%" +call npm.cmd start +set "EXIT_CODE=%ERRORLEVEL%" +popd + +if not "%EXIT_CODE%"=="0" ( + echo [ERROR] Backend start failed. + exit /b %EXIT_CODE% +) echo [OK] Backend started in background echo URL: http://localhost:8081 -echo Log: %LOG_DIR%\backend.log +echo Logs: %APP_DIR%\logs\server.out.log endlocal diff --git a/windows/stop_all.bat b/windows/stop_all.bat index d30047a..cd3fa77 100644 --- a/windows/stop_all.bat +++ b/windows/stop_all.bat @@ -1,11 +1,13 @@ @echo off setlocal -for /f "tokens=5" %%p in ('netstat -ano ^| findstr ":8081" ^| findstr "LISTENING"') do ( - taskkill /PID %%p /F >nul 2>&1 -) +for %%I in ("%~dp0..") do set "APP_DIR=%%~fI" + +pushd "%APP_DIR%" +if exist "node_modules" call npm.cmd stop +popd taskkill /IM ammc-amb.exe /F >nul 2>&1 -echo [OK] Stopped backend on :8081 and ammc-amb.exe (if running) +echo [OK] Stopped RaceController and ammc-amb.exe (if running) endlocal