Add class-specific driver training profiles and metadata
This commit is contained in:
@@ -181,6 +181,10 @@ The default view is intentionally simple: four key statistics, the next lap targ
|
|||||||
|
|
||||||
When Open Practice, Free Practice, or Practice is running, the selected driver gets a compact live card with current valid laps, best lap, average, and latest lap. The session remains shared by everyone; only the mobile 20-minute timer and personal profile link are unique per driver. The primary action at the top creates or opens that link.
|
When Open Practice, Free Practice, or Practice is running, the selected driver gets a compact live card with current valid laps, best lap, average, and latest lap. The session remains shared by everyone; only the mobile 20-minute timer and personal profile link are unique per driver. The primary action at the top creates or opens that link.
|
||||||
|
|
||||||
|
One person can have separate driver entries for classes such as 2WD and 4WD and link them to the same `Practice profile` under `Drivers`. The person then has one profile and one mobile link, while every performance statistic is selected and calculated separately per class. Multiple transponders within the same class are combined in that class's statistics.
|
||||||
|
|
||||||
|
The personal profile can also store `Club` and `Country`. These values are inherited when another class entry is linked to the same practice profile and are included in full JSON and driver CSV exports. They are not disclosed by the public profile link.
|
||||||
|
|
||||||
- durable lifetime valid-lap ledger synchronized from stored session results
|
- durable lifetime valid-lap ledger synchronized from stored session results
|
||||||
- practice days, recorded sessions, personal best, overall/recent averages, controlled-lap streak, and recent consistency spread
|
- practice days, recorded sessions, personal best, overall/recent averages, controlled-lap streak, and recent consistency spread
|
||||||
- automatic stint boundaries after a passing gap longer than 2 minutes
|
- automatic stint boundaries after a passing gap longer than 2 minutes
|
||||||
@@ -193,7 +197,7 @@ When Open Practice, Free Practice, or Practice is running, the selected driver g
|
|||||||
- optional reward text and a delivered/redeemed state
|
- optional reward text and a delivered/redeemed state
|
||||||
- responsive layout for opening the same server from a phone on the local network
|
- responsive layout for opening the same server from a phone on the local network
|
||||||
|
|
||||||
Direct mobile/bookmark link: `http://<server-ip>:8081/?view=training`. Selecting a driver adds its ID to the URL so that driver's page can be bookmarked.
|
Direct mobile/bookmark link: `http://<server-ip>:8081/?view=training`. Selecting a profile and class adds both to the URL so the exact view 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.
|
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.
|
||||||
|
|
||||||
@@ -208,7 +212,7 @@ Each registered driver can have an optional personal mobile link:
|
|||||||
3. Copy or open the generated link.
|
3. Copy or open the generated link.
|
||||||
4. Use `Create new link` to revoke the previous URL, or `Disable profile` to turn sharing off.
|
4. Use `Create new link` to revoke the previous URL, or `Disable profile` to turn sharing off.
|
||||||
|
|
||||||
The URL uses a 192-bit random bearer token rather than a driver ID. The public API returns only that driver's display name, class/brand, aggregate practice statistics, daily summaries, goals and reward text. It does not return transponder IDs, other driver names, race setup or admin state.
|
The URL uses a 192-bit random bearer token rather than a driver ID. The same link provides a class selector with fully separate lap times and practice results for each linked class. The public API does not return transponder IDs, other driver names, race setup or admin state.
|
||||||
|
|
||||||
The mobile page also includes a local 20-minute practice timer: 10 minutes maximum focus followed by 10 minutes controlled 80-90% driving. While a shared Free Practice session keeps running, the card shows that driver's lap count, best lap and average since the personal timer started. Timer state stays in that phone's browser and does not modify race data.
|
The mobile page also includes a local 20-minute practice timer: 10 minutes maximum focus followed by 10 minutes controlled 80-90% driving. While a shared Free Practice session keeps running, the card shows that driver's lap count, best lap and average since the personal timer started. Timer state stays in that phone's browser and does not modify race data.
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,10 @@ Grundvyn är avsiktligt enkel: fyra nyckeltal, nästa varvmål, dagens 20-minute
|
|||||||
|
|
||||||
När Open Practice, Free Practice eller Practice pågår får vald förare ett kompakt livekort med aktuella giltiga varv, bästa varv, snitt och senaste varv. Sessionen är fortfarande gemensam för alla; endast mobilens 20-minuterstimer och den personliga profillänken är unik per förare. Primärknappen högst upp skapar eller öppnar länken.
|
När Open Practice, Free Practice eller Practice pågår får vald förare ett kompakt livekort med aktuella giltiga varv, bästa varv, snitt och senaste varv. Sessionen är fortfarande gemensam för alla; endast mobilens 20-minuterstimer och den personliga profillänken är unik per förare. Primärknappen högst upp skapar eller öppnar länken.
|
||||||
|
|
||||||
|
En person kan ha flera förarposter för exempelvis 2WD och 4WD och länka dem till samma `Träningsprofil` under `Förare`. Personen får då en profil och en mobillänk, men all prestandastatistik väljs och beräknas separat per klass. Flera transpondrar inom samma klass summeras däremot i den klassens statistik.
|
||||||
|
|
||||||
|
Förarprofilen kan även lagra `Klubb` och `Land`. Värdena ärvs när ytterligare klassposter länkas till samma träningsprofil och följer med full JSON- och förar-CSV-export. De lämnas inte ut av den publika profillänken.
|
||||||
|
|
||||||
- beständig logg med totalt antal giltiga varv synkad från sparade sessionsresultat
|
- beständig logg med totalt antal giltiga varv synkad från sparade sessionsresultat
|
||||||
- träningsdagar, antal sessioner, personbästa, total-/30-varvssnitt, kontrollerad varvserie och spridning
|
- träningsdagar, antal sessioner, personbästa, total-/30-varvssnitt, kontrollerad varvserie och spridning
|
||||||
- automatisk ny stint efter ett glapp på mer än 2 minuter mellan passeringar
|
- automatisk ny stint efter ett glapp på mer än 2 minuter mellan passeringar
|
||||||
@@ -182,7 +186,7 @@ När Open Practice, Free Practice eller Practice pågår får vald förare ett k
|
|||||||
- valfri reward/belöning och markering när den har delats ut
|
- 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
|
- responsiv layout som kan öppnas från mobilen via samma server i det lokala nätet
|
||||||
|
|
||||||
Direktlänk för mobil/bokmärke: `http://<server-ip>: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.
|
Direktlänk för mobil/bokmärke: `http://<server-ip>:8081/?view=training`. När en profil och klass väljs läggs de i länken så rätt vy 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.
|
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.
|
||||||
|
|
||||||
@@ -197,7 +201,7 @@ Varje registrerad förare kan få en valfri personlig mobillänk:
|
|||||||
3. Kopiera eller öppna länken som skapas.
|
3. Kopiera eller öppna länken som skapas.
|
||||||
4. Använd `Skapa ny länk` för att återkalla den gamla eller `Stäng av profil` för att stoppa delningen.
|
4. Använd `Skapa ny länk` för att återkalla den gamla eller `Stäng av profil` för att stoppa delningen.
|
||||||
|
|
||||||
Länken använder en slumpad 192-bitars bearer-nyckel i stället för förar-ID. Det publika API:t lämnar bara ut den valda förarens visningsnamn, klass/märke, summerad träningsstatistik, dagshistorik, mål och belöningstext. Transponder-ID, andra förares namn, race setup och admin-state lämnas inte ut.
|
Länken använder en slumpad 192-bitars bearer-nyckel i stället för förar-ID. Samma länk visar profilens klasser i ett klassval, med helt separata varvtider och träningsresultat. Det publika API:t lämnar inte ut transponder-ID, andra förares namn, race setup eller admin-state.
|
||||||
|
|
||||||
Mobilsidan har också en lokal 20-minuterstimer: 10 minuter maxfokus följt av 10 minuter kontrollerad körning på 80-90 procent. Medan en gemensam Free Practice fortsätter vara igång visar kortet just den förarens antal varv, bästa varv och snitt sedan den personliga timern startades. Timern sparas bara i mobilens webbläsare och ändrar ingen racedata.
|
Mobilsidan har också en lokal 20-minuterstimer: 10 minuter maxfokus följt av 10 minuter kontrollerad körning på 80-90 procent. Medan en gemensam Free Practice fortsätter vara igång visar kortet just den förarens antal varv, bästa varv och snitt sedan den personliga timern startades. Timern sparas bara i mobilens webbläsare och ändrar ingen racedata.
|
||||||
|
|
||||||
|
|||||||
@@ -160,18 +160,19 @@ Runtime environment variables:
|
|||||||
|
|
||||||
### `server/public_training_profile.js`
|
### `server/public_training_profile.js`
|
||||||
Role:
|
Role:
|
||||||
- pure CommonJS backend helper for tokenized driver profiles
|
- pure CommonJS backend helper for tokenized personal training profiles
|
||||||
|
|
||||||
Owns:
|
Owns:
|
||||||
- enabled share-token lookup
|
- enabled share-token lookup
|
||||||
- selected-driver lap filtering and aggregate statistics
|
- profile resolution across several race-specific driver records
|
||||||
- sanitized branding, class, goal, rank, and recommendation response
|
- strict per-class lap filtering and aggregate statistics
|
||||||
|
- sanitized branding, class-specific goal, rank, and recommendation response
|
||||||
- clock-skew-safe personal timer start/window normalization
|
- clock-skew-safe personal timer start/window normalization
|
||||||
- bounded current-session lap, best-lap, and average calculations
|
- bounded current-session lap, best-lap, and average calculations
|
||||||
|
|
||||||
Security boundary:
|
Security boundary:
|
||||||
- returns no driver IDs, transponders, raw passings, decoder settings, or other driver names
|
- returns no driver IDs, transponders, raw passings, decoder settings, or other driver names
|
||||||
- disabled, short, unknown, or driverless tokens return no profile
|
- disabled, short, unknown, or profileless tokens return no profile
|
||||||
- logo data is restricted to bounded PNG/JPEG/WebP data URLs
|
- logo data is restricted to bounded PNG/JPEG/WebP data URLs
|
||||||
|
|
||||||
### `scripts/serverctl.js`
|
### `scripts/serverctl.js`
|
||||||
@@ -406,6 +407,20 @@ Owns:
|
|||||||
- Drivers
|
- Drivers
|
||||||
- Cars
|
- Cars
|
||||||
|
|
||||||
|
### `src/driver_profile_logic.js`
|
||||||
|
Type:
|
||||||
|
- pure personal-profile identity helpers
|
||||||
|
|
||||||
|
Owns:
|
||||||
|
- resolving an explicit `profileId` or a legacy normalized-name profile key
|
||||||
|
- grouping race-specific driver records that belong to one person
|
||||||
|
- listing all driver IDs and class IDs attached to one personal profile
|
||||||
|
|
||||||
|
Imported by:
|
||||||
|
- `src/core_views.js` for profile assignment in the Drivers UI
|
||||||
|
- `src/training_logic.js` for class-scoped lap aggregation
|
||||||
|
- `src/training_views.js` for person and class selectors
|
||||||
|
|
||||||
### `src/misc_views.js`
|
### `src/misc_views.js`
|
||||||
Type:
|
Type:
|
||||||
- misc page renderers
|
- misc page renderers
|
||||||
@@ -431,9 +446,9 @@ Type:
|
|||||||
Owns:
|
Owns:
|
||||||
- training-state normalization
|
- training-state normalization
|
||||||
- durable ledger synchronization from `resultsBySession`
|
- durable ledger synchronization from `resultsBySession`
|
||||||
- valid driver-lap aggregation across the training ledger
|
- valid driver-lap aggregation across the training ledger, scoped by personal profile and class
|
||||||
- lifetime/session/day/best-lap/average/consistency calculations
|
- lifetime/session/day/best-lap/average/consistency calculations
|
||||||
- active Open/Free/regular Practice statistics scoped to one driver and the current session start
|
- active Open/Free/regular Practice statistics scoped to one personal profile, one class, and the current session start
|
||||||
- stint splitting after passing gaps greater than `120000 ms`
|
- stint splitting after passing gaps greater than `120000 ms`
|
||||||
- best N consecutive laps and fixed-duration result calculation
|
- best N consecutive laps and fixed-duration result calculation
|
||||||
- five-minute (`300000 ms`) results include the final lap when its start is before the cutoff and expose average pace for the counted laps
|
- five-minute (`300000 ms`) results include the final lap when its start is before the cutoff and expose average pace for the counted laps
|
||||||
@@ -443,6 +458,10 @@ Owns:
|
|||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- only passings with a matching `driverId` are included
|
- only passings with a matching `driverId` are included
|
||||||
|
- `profileId` links multiple race-specific driver records to one person without changing decoder or race identity
|
||||||
|
- statistics combine linked driver records only when their `classId` matches the selected class
|
||||||
|
- 2WD and 4WD lap times, stints, best-three, five-minute results, and recommendations are never combined
|
||||||
|
- legacy driver records without `profileId` are grouped by normalized driver name until explicitly linked or separated in the Drivers UI
|
||||||
- invalid laps and lap times at or below `500 ms` are excluded
|
- invalid laps and lap times at or below `500 ms` are excluded
|
||||||
- anonymous Open Practice transponders are excluded until registered as drivers
|
- 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
|
- consistency is the P10-to-P90 spread of the latest 30 valid laps divided by their mean
|
||||||
@@ -474,8 +493,8 @@ Mutations:
|
|||||||
|
|
||||||
Route behavior:
|
Route behavior:
|
||||||
- `/?view=training` opens the training view directly
|
- `/?view=training` opens the training view directly
|
||||||
- `driver=<driverId>` selects a driver and supports mobile bookmarks
|
- `driver=<profileId>` selects a personal profile and `class=<classId>` selects its performance class for bookmarks
|
||||||
- an explicitly enabled driver receives `/public-training/<shareToken>` as a separate read-only profile
|
- an explicitly enabled personal profile receives `/public-training/<shareToken>` as a separate read-only profile with a class selector
|
||||||
- replacing or disabling the share invalidates its old route immediately after state sync
|
- replacing or disabling the share invalidates its old route immediately after state sync
|
||||||
|
|
||||||
### `public-training.html`
|
### `public-training.html`
|
||||||
@@ -494,7 +513,8 @@ Type:
|
|||||||
|
|
||||||
Owns:
|
Owns:
|
||||||
- fetching only `/public-training/api/:shareToken`
|
- fetching only `/public-training/api/:shareToken`
|
||||||
- rendering one driver's sanitized statistics, goals, rewards, and daily history
|
- rendering one person's sanitized statistics, goals, rewards, and daily history with a class selector
|
||||||
|
- switching between class-specific statistics without combining lap performance across classes
|
||||||
- local 20-minute practice timer with 10-minute push/control phases
|
- local 20-minute practice timer with 10-minute push/control phases
|
||||||
- token-scoped start-age and bounded-window polling for aggregate lap count, best lap, and average during the personal timer; using an age avoids mobile/server clock-skew errors and the bounded window freezes results after the timer finishes
|
- token-scoped start-age and bounded-window polling for aggregate lap count, best lap, and average during the personal timer; using an age avoids mobile/server clock-skew errors and the bounded window freezes results after the timer finishes
|
||||||
- Swedish/English copy selected by `lang` query or browser locale
|
- Swedish/English copy selected by `lang` query or browser locale
|
||||||
@@ -503,7 +523,7 @@ Important isolation:
|
|||||||
- does not import `app.js`
|
- does not import `app.js`
|
||||||
- does not call `/api/state`
|
- does not call `/api/state`
|
||||||
- does not mutate backend or race state
|
- does not mutate backend or race state
|
||||||
- timer state is stored in the mobile browser under a token-scoped localStorage key
|
- timer state is stored in the mobile browser under a token-and-class-scoped localStorage key
|
||||||
- session-window responses contain aggregates only; individual lap rows and timestamps are not exposed
|
- session-window responses contain aggregates only; individual lap rows and timestamps are not exposed
|
||||||
|
|
||||||
### `src/public_training.css`
|
### `src/public_training.css`
|
||||||
@@ -538,6 +558,11 @@ Owns:
|
|||||||
- `src/training_logic.js`
|
- `src/training_logic.js`
|
||||||
- `src/training_views.js`
|
- `src/training_views.js`
|
||||||
|
|
||||||
|
### Training/profile imports
|
||||||
|
- `src/core_views.js` imports `src/driver_profile_logic.js`
|
||||||
|
- `src/training_logic.js` imports `src/driver_profile_logic.js`
|
||||||
|
- `src/training_views.js` imports `src/driver_profile_logic.js`
|
||||||
|
|
||||||
### `src/event_workspace_controller.js` imports
|
### `src/event_workspace_controller.js` imports
|
||||||
- `src/event_views.js`
|
- `src/event_views.js`
|
||||||
|
|
||||||
@@ -1339,7 +1364,10 @@ Produced/normalized by:
|
|||||||
classId: string,
|
classId: string,
|
||||||
transponder: string,
|
transponder: string,
|
||||||
brand: string,
|
brand: string,
|
||||||
speechName: string // optional phonetic form used only by browser speech
|
speechName: string, // optional phonetic form used only by browser speech
|
||||||
|
profileId: string, // shared personal practice-profile identity; race identity remains `id`
|
||||||
|
club: string, // personal profile metadata, not class performance data
|
||||||
|
country: string // personal profile metadata, not class performance data
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1353,6 +1381,14 @@ Used by:
|
|||||||
- decoder passing resolution
|
- decoder passing resolution
|
||||||
- exports/imports
|
- exports/imports
|
||||||
|
|
||||||
|
Profile rules:
|
||||||
|
- `src/driver_profile_logic.js` resolves `profileId`, groups linked driver records, and returns all references belonging to one person
|
||||||
|
- a driver without an explicit `profileId` receives a legacy grouping key based on its normalized name
|
||||||
|
- creating another same-name driver links to that profile by default; the Drivers editor can link to another profile or create a separate one
|
||||||
|
- `driverId`, `classId`, and transponder remain independent per race entry
|
||||||
|
- `club` and `country` are inherited and synchronized across explicitly linked entries by the Drivers UI
|
||||||
|
- practice goals and public share tokens belong to the personal profile, while measured performance belongs to a selected class
|
||||||
|
|
||||||
## 15.4 `Car`
|
## 15.4 `Car`
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@@ -1548,6 +1584,7 @@ This bucket is the most dynamic part of state and is mutated heavily during live
|
|||||||
{
|
{
|
||||||
id: string,
|
id: string,
|
||||||
driverId: string,
|
driverId: string,
|
||||||
|
classId: string, // class whose lap total advances the goal
|
||||||
title: string,
|
title: string,
|
||||||
reward: string,
|
reward: string,
|
||||||
targetLaps: number,
|
targetLaps: number,
|
||||||
@@ -1568,8 +1605,8 @@ This bucket is the most dynamic part of state and is mutated heavily during live
|
|||||||
```
|
```
|
||||||
|
|
||||||
Share rules:
|
Share rules:
|
||||||
- no share exists until an admin explicitly enables the selected driver profile
|
- no share exists until an admin explicitly enables the selected personal profile
|
||||||
- only one normalized share entry is retained per driver
|
- only one normalized share entry is retained per personal profile
|
||||||
- disabled shares fail public route lookup
|
- disabled shares fail public route lookup
|
||||||
- rotating a share replaces its token; the previous URL no longer resolves after persistence
|
- rotating a share replaces its token; the previous URL no longer resolves after persistence
|
||||||
- tokens are generated in the browser with `crypto.getRandomValues()`, never from the shorter UI `uid()` helper
|
- tokens are generated in the browser with `crypto.getRandomValues()`, never from the shorter UI `uid()` helper
|
||||||
@@ -1604,6 +1641,8 @@ Persisted through:
|
|||||||
|
|
||||||
Derived statistics are intentionally not stored. They are recalculated from `state.training.ledger`. Before persistence, `saveState()` synchronizes every session currently present in `resultsBySession`: existing rows for those session IDs are replaced from current valid passings, so judging corrections are reflected. Session IDs absent from `resultsBySession` are not removed from the ledger, preserving lifetime totals after archival or deletion.
|
Derived statistics are intentionally not stored. They are recalculated from `state.training.ledger`. Before persistence, `saveState()` synchronizes every session currently present in `resultsBySession`: existing rows for those session IDs are replaced from current valid passings, so judging corrections are reflected. Session IDs absent from `resultsBySession` are not removed from the ledger, preserving lifetime totals after archival or deletion.
|
||||||
|
|
||||||
|
`TrainingLap.driverId` always records the exact race/decoder driver entry that produced the lap. At read time, `src/driver_profile_logic.js` resolves all driver IDs belonging to the selected personal profile, then `src/training_logic.js` narrows them to the selected `classId`. This permits several transponders in one class while preventing incomparable classes from being mixed.
|
||||||
|
|
||||||
Migration behavior:
|
Migration behavior:
|
||||||
- older state without `training.ledger` normalizes to an empty ledger
|
- older state without `training.ledger` normalizes to an empty ledger
|
||||||
- the next `saveState()` imports valid registered-driver laps from all current session results
|
- the next `saveState()` imports valid registered-driver laps from all current session results
|
||||||
@@ -1950,17 +1989,17 @@ Authorization:
|
|||||||
- missing, disabled, replaced, or unknown tokens return `404` rather than revealing whether a driver exists
|
- missing, disabled, replaced, or unknown tokens return `404` rather than revealing whether a driver exists
|
||||||
|
|
||||||
Sanitized response fields:
|
Sanitized response fields:
|
||||||
- selected driver display name, class, and brand
|
- selected person's display name plus linked class names and brands
|
||||||
- numeric club rank without other driver names
|
- one `classProfiles` entry per linked class; no cross-class performance aggregate is emitted
|
||||||
- aggregate totals, best/average time, consistency, controlled streak, and daily summaries
|
- class-specific numeric club rank without other driver names
|
||||||
- optional `currentSession` aggregates when a valid start age (or backward-compatible recent `since` timestamp) is supplied
|
- class-specific totals, best/average time, consistency, controlled streak, stints, best-three, five-minute result, and daily summaries
|
||||||
- next automatic milestone
|
- optional class-specific `currentSession` aggregates when a valid start age (or backward-compatible recent `since` timestamp) is supplied
|
||||||
- recommendation level and target lap
|
- class-specific next automatic milestone, recommendation level, target lap, goals, and reward progress
|
||||||
- selected driver's goal titles, reward text, progress, and claimed state
|
|
||||||
- public club branding and safe PNG/JPEG/WebP logo data
|
- public club branding and safe PNG/JPEG/WebP logo data
|
||||||
|
|
||||||
Explicitly excluded:
|
Explicitly excluded:
|
||||||
- transponder IDs
|
- transponder IDs
|
||||||
|
- club and country profile metadata
|
||||||
- driver IDs and names of other drivers
|
- driver IDs and names of other drivers
|
||||||
- cars, events, sessions, assignments, and raw passings
|
- cars, events, sessions, assignments, and raw passings
|
||||||
- race settings and decoder/AMMC configuration
|
- race settings and decoder/AMMC configuration
|
||||||
@@ -2088,16 +2127,16 @@ npm test
|
|||||||
- invalid, anonymous, and sub-500 ms lap exclusion
|
- invalid, anonymous, and sub-500 ms lap exclusion
|
||||||
- replacement of current-session ledger rows after corrections
|
- replacement of current-session ledger rows after corrections
|
||||||
- retention of archived/deleted-session history
|
- retention of archived/deleted-session history
|
||||||
- per-driver isolation and day/session aggregation
|
- personal-profile isolation, same-class multi-transponder aggregation, and 2WD/4WD separation
|
||||||
- recommendation branch selection
|
- recommendation branch selection
|
||||||
- custom goal and automatic milestone progress
|
- custom goal and automatic milestone progress
|
||||||
- selected-driver active-practice isolation, current-start filtering, and non-practice rejection
|
- selected-profile/class active-practice isolation, current-start filtering, and non-practice rejection
|
||||||
- two-minute stint boundaries, cross-session isolation, and boundary-lap exclusion
|
- two-minute stint boundaries, cross-session isolation, and boundary-lap exclusion
|
||||||
- best-three consecutive calculation and five-minute final-lap/average-pace behavior
|
- best-three consecutive calculation and five-minute final-lap/average-pace behavior
|
||||||
|
|
||||||
`tests/public_training_profile.test.js` covers:
|
`tests/public_training_profile.test.js` covers:
|
||||||
- enabled/disabled/unknown share-token handling
|
- enabled/disabled/unknown share-token handling
|
||||||
- selected-driver isolation and response sanitization
|
- personal-profile isolation, class-specific statistics, and response sanitization
|
||||||
- personal timer aggregate and frozen-window calculations
|
- personal timer aggregate and frozen-window calculations
|
||||||
- clock-skew-safe query normalization and bounds
|
- clock-skew-safe query normalization and bounds
|
||||||
|
|
||||||
|
|||||||
@@ -8,55 +8,100 @@ function hasPublicTrainingShare(state, shareToken) {
|
|||||||
return Boolean(findPublicTrainingShare(state, shareToken));
|
return Boolean(findPublicTrainingShare(state, shareToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPublicDriverProfileId(driver) {
|
||||||
|
const explicitId = String(driver?.profileId || "").trim();
|
||||||
|
if (explicitId) return explicitId;
|
||||||
|
const name = String(driver?.name || "").trim().toLocaleLowerCase().replace(/\s+/g, " ");
|
||||||
|
return name ? `legacy:${name}` : `driver:${String(driver?.id || "unknown")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPublicDriverProfile(drivers, reference) {
|
||||||
|
const normalizedReference = String(reference || "");
|
||||||
|
const anchor = drivers.find((driver) => (
|
||||||
|
String(driver?.id || "") === normalizedReference
|
||||||
|
|| getPublicDriverProfileId(driver) === normalizedReference
|
||||||
|
));
|
||||||
|
if (!anchor) return null;
|
||||||
|
const profileId = getPublicDriverProfileId(anchor);
|
||||||
|
return {
|
||||||
|
id: profileId,
|
||||||
|
drivers: drivers.filter((driver) => getPublicDriverProfileId(driver) === profileId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp = 0, windowMs = 0) {
|
function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp = 0, windowMs = 0) {
|
||||||
const share = findPublicTrainingShare(state, shareToken);
|
const share = findPublicTrainingShare(state, shareToken);
|
||||||
if (!share) return null;
|
if (!share) return null;
|
||||||
const driverId = String(share.driverId || "");
|
const profileReference = String(share.driverId || "");
|
||||||
const drivers = Array.isArray(state?.drivers) ? state.drivers : [];
|
const drivers = Array.isArray(state?.drivers) ? state.drivers : [];
|
||||||
const driver = drivers.find((item) => String(item?.id || "") === driverId);
|
const driverProfile = getPublicDriverProfile(drivers, profileReference);
|
||||||
if (!driver) return null;
|
if (!driverProfile?.drivers.length) return null;
|
||||||
|
const driver = driverProfile.drivers[0];
|
||||||
|
|
||||||
const classes = Array.isArray(state?.classes) ? state.classes : [];
|
const classes = Array.isArray(state?.classes) ? state.classes : [];
|
||||||
const driverClass = classes.find((item) => String(item?.id || "") === String(driver.classId || ""));
|
|
||||||
const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : [];
|
const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : [];
|
||||||
const driverLaps = ledger
|
const profileReferences = new Set([driverProfile.id, ...driverProfile.drivers.map((item) => String(item.id || ""))]);
|
||||||
.filter((entry) => String(entry?.driverId || "") === driverId && Number(entry?.lapMs || 0) > 500)
|
const profileClassIds = [...new Set(driverProfile.drivers.map((item) => String(item.classId || "")).filter(Boolean))];
|
||||||
.map((entry) => ({
|
const classProfiles = profileClassIds.map((classId) => {
|
||||||
timestamp: Math.max(0, Number(entry.timestamp || 0) || 0),
|
const classDrivers = driverProfile.drivers.filter((item) => String(item.classId || "") === classId);
|
||||||
lapMs: Math.max(0, Math.round(Number(entry.lapMs || 0) || 0)),
|
const classDriverIds = new Set(classDrivers.map((item) => String(item.id || "")));
|
||||||
sessionId: String(entry.sessionId || ""),
|
const classLaps = ledger
|
||||||
}))
|
.filter((entry) => classDriverIds.has(String(entry?.driverId || "")) && Number(entry?.lapMs || 0) > 500)
|
||||||
.sort((left, right) => left.timestamp - right.timestamp);
|
.map((entry) => ({
|
||||||
const stats = buildPublicTrainingStats(driverLaps);
|
timestamp: Math.max(0, Number(entry.timestamp || 0) || 0),
|
||||||
const goals = (Array.isArray(state?.training?.goals) ? state.training.goals : [])
|
lapMs: Math.max(0, Math.round(Number(entry.lapMs || 0) || 0)),
|
||||||
.filter((goal) => String(goal?.driverId || "") === driverId)
|
sessionId: String(entry.sessionId || ""),
|
||||||
.map((goal) => buildPublicTrainingGoal(goal, stats.totalLaps));
|
}))
|
||||||
const rankedTotals = getRankedTrainingTotals(ledger, new Set(drivers.map((item) => String(item?.id || "")).filter(Boolean)));
|
.sort((left, right) => left.timestamp - right.timestamp);
|
||||||
const rankIndex = rankedTotals.findIndex(([id]) => id === driverId);
|
const stats = buildPublicTrainingStats(classLaps);
|
||||||
|
const rankedTotals = getRankedTrainingTotals(ledger, drivers, classId);
|
||||||
|
const rankIndex = rankedTotals.findIndex(([profileId]) => profileId === driverProfile.id);
|
||||||
|
const classGoals = (Array.isArray(state?.training?.goals) ? state.training.goals : [])
|
||||||
|
.filter((goal) => profileReferences.has(String(goal?.driverId || "")) && (!goal?.classId || String(goal.classId) === classId))
|
||||||
|
.map((goal) => buildPublicTrainingGoal(goal, stats.totalLaps));
|
||||||
|
const brands = [...new Set(classDrivers.map((item) => String(item.brand || "").trim()).filter(Boolean))];
|
||||||
|
return {
|
||||||
|
classId,
|
||||||
|
className: String(classes.find((item) => String(item?.id || "") === classId)?.name || "").trim(),
|
||||||
|
brand: brands.join(" / "),
|
||||||
|
clubRank: rankIndex >= 0 ? rankIndex + 1 : 0,
|
||||||
|
rankedDrivers: rankedTotals.length,
|
||||||
|
stats,
|
||||||
|
nextMilestone: getPublicTrainingMilestone(stats.totalLaps),
|
||||||
|
recommendation: getPublicTrainingRecommendation(stats),
|
||||||
|
goals: classGoals,
|
||||||
|
currentSession: sinceTimestamp
|
||||||
|
? buildPublicTrainingWindowStats(classLaps.filter((lap) => lap.timestamp >= sinceTimestamp && (!windowMs || lap.timestamp <= sinceTimestamp + windowMs)))
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const activeClass = classProfiles[0] || {
|
||||||
|
classId: "", className: "", brand: "", clubRank: 0, rankedDrivers: 0,
|
||||||
|
stats: buildPublicTrainingStats([]), nextMilestone: getPublicTrainingMilestone(0),
|
||||||
|
recommendation: getPublicTrainingRecommendation({ totalLaps: 0 }), goals: [], currentSession: null,
|
||||||
|
};
|
||||||
const settings = state?.settings && typeof state.settings === "object" ? state.settings : {};
|
const settings = state?.settings && typeof state.settings === "object" ? state.settings : {};
|
||||||
const currentSession = sinceTimestamp
|
|
||||||
? buildPublicTrainingWindowStats(driverLaps.filter((lap) => lap.timestamp >= sinceTimestamp && (!windowMs || lap.timestamp <= sinceTimestamp + windowMs)))
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
updatedAt: String(updatedAt || ""),
|
updatedAt: String(updatedAt || ""),
|
||||||
profile: {
|
profile: {
|
||||||
name: String(driver.name || "Driver").trim(),
|
name: String(driver.name || "Driver").trim(),
|
||||||
brand: String(driver.brand || "").trim(),
|
brand: activeClass.brand,
|
||||||
className: String(driverClass?.name || "").trim(),
|
className: activeClass.className,
|
||||||
clubRank: rankIndex >= 0 ? rankIndex + 1 : 0,
|
clubRank: activeClass.clubRank,
|
||||||
rankedDrivers: rankedTotals.length,
|
rankedDrivers: activeClass.rankedDrivers,
|
||||||
},
|
},
|
||||||
branding: {
|
branding: {
|
||||||
clubName: String(settings.clubName || "JMK RB RaceController").trim(),
|
clubName: String(settings.clubName || "JMK RB RaceController").trim(),
|
||||||
clubTagline: String(settings.clubTagline || "RC Timing System").trim(),
|
clubTagline: String(settings.clubTagline || "RC Timing System").trim(),
|
||||||
logoDataUrl: normalizePublicLogoDataUrl(settings.logoDataUrl),
|
logoDataUrl: normalizePublicLogoDataUrl(settings.logoDataUrl),
|
||||||
},
|
},
|
||||||
stats,
|
classProfiles,
|
||||||
nextMilestone: getPublicTrainingMilestone(stats.totalLaps),
|
stats: activeClass.stats,
|
||||||
recommendation: getPublicTrainingRecommendation(stats),
|
nextMilestone: activeClass.nextMilestone,
|
||||||
goals,
|
recommendation: activeClass.recommendation,
|
||||||
currentSession,
|
goals: activeClass.goals,
|
||||||
|
currentSession: activeClass.currentSession,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,12 +145,16 @@ function buildPublicTrainingGoal(goal, currentLaps) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRankedTrainingTotals(ledger, registeredDriverIds) {
|
function getRankedTrainingTotals(ledger, drivers, classId = "") {
|
||||||
|
const profileByDriverId = new Map(drivers
|
||||||
|
.filter((driver) => !classId || String(driver?.classId || "") === String(classId))
|
||||||
|
.map((driver) => [String(driver?.id || ""), getPublicDriverProfileId(driver)]));
|
||||||
const totals = new Map();
|
const totals = new Map();
|
||||||
ledger.forEach((entry) => {
|
ledger.forEach((entry) => {
|
||||||
const driverId = String(entry?.driverId || "");
|
const driverId = String(entry?.driverId || "");
|
||||||
if (!driverId || !registeredDriverIds.has(driverId) || Number(entry?.lapMs || 0) <= 500) return;
|
const profileId = profileByDriverId.get(driverId);
|
||||||
totals.set(driverId, (totals.get(driverId) || 0) + 1);
|
if (!profileId || Number(entry?.lapMs || 0) <= 500) return;
|
||||||
|
totals.set(profileId, (totals.get(profileId) || 0) + 1);
|
||||||
});
|
});
|
||||||
return [...totals.entries()].sort((left, right) => right[1] - left[1]);
|
return [...totals.entries()].sort((left, right) => right[1] - left[1]);
|
||||||
}
|
}
|
||||||
|
|||||||
48
src/app.js
48
src/app.js
@@ -205,12 +205,12 @@ const syncTrainingLedger = () => {
|
|||||||
state.training = syncTrainingLedgerLogic(state.training, state, { uid });
|
state.training = syncTrainingLedgerLogic(state.training, state, { uid });
|
||||||
return state.training;
|
return state.training;
|
||||||
};
|
};
|
||||||
const buildDriverTrainingStats = (trainingState, driverId) => buildDriverTrainingStatsLogic(trainingState, driverId);
|
const buildDriverTrainingStats = (trainingState, driverId, classId = "") => buildDriverTrainingStatsLogic(trainingState, driverId, classId);
|
||||||
const buildDriverActivePracticeStats = (trainingState, driverId, nowTs = Date.now()) => buildDriverActivePracticeStatsLogic(trainingState, driverId, nowTs);
|
const buildDriverActivePracticeStats = (trainingState, driverId, nowTs = Date.now(), classId = "") => buildDriverActivePracticeStatsLogic(trainingState, driverId, nowTs, classId);
|
||||||
const buildTrainingRecommendation = (stats) => buildTrainingRecommendationLogic(stats);
|
const buildTrainingRecommendation = (stats) => buildTrainingRecommendationLogic(stats);
|
||||||
const getTrainingGoalProgress = (goal, stats) => getTrainingGoalProgressLogic(goal, stats);
|
const getTrainingGoalProgress = (goal, stats) => getTrainingGoalProgressLogic(goal, stats);
|
||||||
const getNextTrainingMilestone = (stats) => getNextTrainingMilestoneLogic(stats);
|
const getNextTrainingMilestone = (stats) => getNextTrainingMilestoneLogic(stats);
|
||||||
const renderTraining = () => renderTrainingView({ state, dom, t, escapeHtml, formatLap, formatRaceClock, uid, saveState, renderView, normalizeTrainingState, syncTrainingLedger, buildDriverTrainingStats, buildDriverActivePracticeStats, buildTrainingRecommendation, getTrainingGoalProgress, getNextTrainingMilestone, getSelectedDriverId: () => selectedTrainingDriverId, setSelectedDriverId: (value) => { selectedTrainingDriverId = value; updateViewUrl("training", value); } });
|
const renderTraining = () => renderTrainingView({ state, dom, t, escapeHtml, formatLap, formatRaceClock, uid, saveState, renderView, normalizeTrainingState, syncTrainingLedger, buildDriverTrainingStats, buildDriverActivePracticeStats, buildTrainingRecommendation, getTrainingGoalProgress, getNextTrainingMilestone, getSelectedDriverId: () => selectedTrainingDriverId, setSelectedDriverId: (value) => { selectedTrainingDriverId = value; updateViewUrl("training", value, selectedTrainingClassId); }, getSelectedClassId: () => selectedTrainingClassId, setSelectedClassId: (value) => { selectedTrainingClassId = value; updateViewUrl("training", selectedTrainingDriverId, value); } });
|
||||||
const applyCompetitorCorrection = (session, row, options = {}) => applyCompetitorCorrectionLogic(session, row, options, { ensureSessionResult, uid, t, formatLap, saveState });
|
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 recalculateCompetitorFromPassings = (session, rowKey) => recalculateCompetitorFromPassingsLogic(session, rowKey, { ensureSessionResult, getCompetitorPassings, isCountedPassing });
|
||||||
const invalidateCompetitorLastLap = (session, row) => invalidateCompetitorLastLapLogic(session, row, { ensureSessionResult, getCompetitorPassings, isCountedPassing, recalculateCompetitorFromPassings, uid, t, formatLap, saveState });
|
const invalidateCompetitorLastLap = (session, row) => invalidateCompetitorLastLapLogic(session, row, { ensureSessionResult, getCompetitorPassings, isCountedPassing, recalculateCompetitorFromPassings, uid, t, formatLap, saveState });
|
||||||
@@ -291,6 +291,7 @@ const TRANSLATIONS = {
|
|||||||
"training.eyebrow": "Förarutveckling",
|
"training.eyebrow": "Förarutveckling",
|
||||||
"training.intro": "Följ träning över flera sessioner och gör utvecklingen synlig mellan tävlingarna.",
|
"training.intro": "Följ träning över flera sessioner och gör utvecklingen synlig mellan tävlingarna.",
|
||||||
"training.driver": "Förare",
|
"training.driver": "Förare",
|
||||||
|
"training.class": "Klass",
|
||||||
"training.no_drivers_title": "Ingen förare registrerad",
|
"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.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.total_laps": "Totala varv",
|
||||||
@@ -430,11 +431,20 @@ const TRANSLATIONS = {
|
|||||||
"drivers.create": "Skapa förare",
|
"drivers.create": "Skapa förare",
|
||||||
"drivers.name_placeholder": "Förarnamn",
|
"drivers.name_placeholder": "Förarnamn",
|
||||||
"drivers.brand_placeholder": "Team / märke (valfritt)",
|
"drivers.brand_placeholder": "Team / märke (valfritt)",
|
||||||
"drivers.brand_filter_placeholder": "Sök namn / transponder / brand",
|
"drivers.brand_filter_placeholder": "Sök namn / transponder / märke / klubb / land",
|
||||||
"drivers.transponder_placeholder": "Personlig transponder (valfritt)",
|
"drivers.transponder_placeholder": "Personlig transponder (valfritt)",
|
||||||
"drivers.speech_name": "Uttalsnamn",
|
"drivers.speech_name": "Uttalsnamn",
|
||||||
"drivers.speech_name_placeholder": "Uttal för uppläsning (valfritt)",
|
"drivers.speech_name_placeholder": "Uttal för uppläsning (valfritt)",
|
||||||
"drivers.speech_name_hint": "Skriv namnet som webbläsarrösten ska uttala det. Lämna tomt för vanligt förarnamn.",
|
"drivers.speech_name_hint": "Skriv namnet som webbläsarrösten ska uttala det. Lämna tomt för vanligt förarnamn.",
|
||||||
|
"drivers.training_profile": "Träningsprofil",
|
||||||
|
"drivers.training_profile_hint": "Länka flera klass- och transponderposter för samma person till en gemensam träningsprofil.",
|
||||||
|
"drivers.training_profile_auto": "Automatiskt: samma namn eller ny profil",
|
||||||
|
"drivers.training_profile_new": "Ny separat profil",
|
||||||
|
"drivers.training_profile_separate": "Koppla loss till ny separat profil",
|
||||||
|
"drivers.club": "Klubb",
|
||||||
|
"drivers.club_placeholder": "Klubb (valfritt)",
|
||||||
|
"drivers.country": "Land",
|
||||||
|
"drivers.country_placeholder": "Land (valfritt)",
|
||||||
"drivers.add": "Lägg till förare",
|
"drivers.add": "Lägg till förare",
|
||||||
"drivers.title": "Förare",
|
"drivers.title": "Förare",
|
||||||
"cars.create": "Skapa bil",
|
"cars.create": "Skapa bil",
|
||||||
@@ -1122,7 +1132,7 @@ const TRANSLATIONS = {
|
|||||||
"guide.dashboard_3": "Det gör det lättare att se om tävlingsdagen ligger före eller efter schema direkt från dashboarden.",
|
"guide.dashboard_3": "Det gör det lättare att se om tävlingsdagen ligger före eller efter schema direkt från dashboarden.",
|
||||||
"guide.dashboard_4": "Kom igång-korten och den grupperade sidomenyn följer samma ordning: anslut systemet, registrera vid behov, skapa körning och öppna sedan Tidtagning/Overlay.",
|
"guide.dashboard_4": "Kom igång-korten och den grupperade sidomenyn följer samma ordning: anslut systemet, registrera vid behov, skapa körning och öppna sedan Tidtagning/Overlay.",
|
||||||
"guide.training_title": "Träning, utveckling och belöningar",
|
"guide.training_title": "Träning, utveckling och belöningar",
|
||||||
"guide.training_1": "1. Registrera föraren och koppla den personliga transpondern under Förare. Endast passeringar med ett registrerat förar-ID kan räknas i träningsprofilen.",
|
"guide.training_1": "1. Registrera föraren och transpondern under Förare. Klubb och land är valfria profilfält. Har personen flera klasser väljer du samma Träningsprofil på varje förarpost.",
|
||||||
"guide.training_2": "2. Kör Free Practice, Open Practice eller en vanlig racesession. Giltiga varv kopieras automatiskt till en beständig träningslogg; korta/långa ogiltiga varv räknas inte.",
|
"guide.training_2": "2. Kör Free Practice, Open Practice eller en vanlig racesession. Giltiga varv kopieras automatiskt till en beständig träningslogg; korta/långa ogiltiga varv räknas inte.",
|
||||||
"guide.training_3": "3. Öppna Träning och välj förare. Grundvyn visar bara totalvarv, träningsdagar, personbästa, jämnhet, nästa mål och dagens pass. Öppna Mer statistik eller Klubbens träningsöversikt bara när det behövs.",
|
"guide.training_3": "3. Öppna Träning och välj förare. Grundvyn visar bara totalvarv, träningsdagar, personbästa, jämnhet, nästa mål och dagens pass. Öppna Mer statistik eller Klubbens träningsöversikt bara när det behövs.",
|
||||||
"guide.training_4": "4. Nästa automatiska nivå räknas utan konfiguration. Skapa egna delmål om klubben eller en förälder vill koppla en fysisk belöning till exempelvis 500, 1000 eller 1500 varv.",
|
"guide.training_4": "4. Nästa automatiska nivå räknas utan konfiguration. Skapa egna delmål om klubben eller en förälder vill koppla en fysisk belöning till exempelvis 500, 1000 eller 1500 varv.",
|
||||||
@@ -1135,6 +1145,7 @@ const TRANSLATIONS = {
|
|||||||
"guide.training_11": "11. Knappen högst upp skapar eller öppnar den valda förarens personliga mobillänk. Föraren startar sin egen 20-minuterstimer i mobilen utan att starta om eller påverka den gemensamma practice-sessionen.",
|
"guide.training_11": "11. Knappen högst upp skapar eller öppnar den valda förarens personliga mobillänk. Föraren startar sin egen 20-minuterstimer i mobilen utan att starta om eller påverka den gemensamma practice-sessionen.",
|
||||||
"guide.training_12": "12. Ett glapp över 2 minuter mellan passeringar startar automatiskt en ny stint. Ett långt pausvarv används som gräns men räknas inte som prestationsvarv i stintstatistiken.",
|
"guide.training_12": "12. Ett glapp över 2 minuter mellan passeringar startar automatiskt en ny stint. Ett långt pausvarv används som gräns men räknas inte som prestationsvarv i stintstatistiken.",
|
||||||
"guide.training_13": "13. Bästa 5 min använder 300 sekunder. Ett slutvarv räknas om det startade före 5:00 även om målgången sker efter tiden, exempelvis 17/05:14.12:123. Avg pace visas för varven i just det resultatet.",
|
"guide.training_13": "13. Bästa 5 min använder 300 sekunder. Ett slutvarv räknas om det startade före 5:00 även om målgången sker efter tiden, exempelvis 17/05:14.12:123. Avg pace visas för varven i just det resultatet.",
|
||||||
|
"guide.training_14": "14. En personlig profil och mobillänk kan innehålla flera klasser, men 2WD/4WD-statistik, stints, varvtider och rekommendationer visas alltid separat per vald klass.",
|
||||||
"overlay.title": "Overlay",
|
"overlay.title": "Overlay",
|
||||||
"overlay.subtitle": "Extern leaderboard-skärm",
|
"overlay.subtitle": "Extern leaderboard-skärm",
|
||||||
"overlay.no_active": "Ingen aktiv session vald.",
|
"overlay.no_active": "Ingen aktiv session vald.",
|
||||||
@@ -1259,6 +1270,7 @@ const TRANSLATIONS = {
|
|||||||
"training.eyebrow": "Driver development",
|
"training.eyebrow": "Driver development",
|
||||||
"training.intro": "Track practice across sessions and make progress visible between race days.",
|
"training.intro": "Track practice across sessions and make progress visible between race days.",
|
||||||
"training.driver": "Driver",
|
"training.driver": "Driver",
|
||||||
|
"training.class": "Class",
|
||||||
"training.no_drivers_title": "No driver registered",
|
"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.no_drivers_body": "Create a driver first. Training statistics require passings linked to a registered driver.",
|
||||||
"training.total_laps": "Total laps",
|
"training.total_laps": "Total laps",
|
||||||
@@ -1398,11 +1410,20 @@ const TRANSLATIONS = {
|
|||||||
"drivers.create": "Create Driver",
|
"drivers.create": "Create Driver",
|
||||||
"drivers.name_placeholder": "Driver name",
|
"drivers.name_placeholder": "Driver name",
|
||||||
"drivers.brand_placeholder": "Team / brand (optional)",
|
"drivers.brand_placeholder": "Team / brand (optional)",
|
||||||
"drivers.brand_filter_placeholder": "Search name / transponder / brand",
|
"drivers.brand_filter_placeholder": "Search name / transponder / brand / club / country",
|
||||||
"drivers.transponder_placeholder": "Personal transponder (optional)",
|
"drivers.transponder_placeholder": "Personal transponder (optional)",
|
||||||
"drivers.speech_name": "Spoken name",
|
"drivers.speech_name": "Spoken name",
|
||||||
"drivers.speech_name_placeholder": "Pronunciation for announcements (optional)",
|
"drivers.speech_name_placeholder": "Pronunciation for announcements (optional)",
|
||||||
"drivers.speech_name_hint": "Enter the name as the browser voice should pronounce it. Leave blank to use the regular driver name.",
|
"drivers.speech_name_hint": "Enter the name as the browser voice should pronounce it. Leave blank to use the regular driver name.",
|
||||||
|
"drivers.training_profile": "Practice profile",
|
||||||
|
"drivers.training_profile_hint": "Link multiple class and transponder entries for the same person to one shared practice profile.",
|
||||||
|
"drivers.training_profile_auto": "Automatic: same name or new profile",
|
||||||
|
"drivers.training_profile_new": "New separate profile",
|
||||||
|
"drivers.training_profile_separate": "Unlink to a new separate profile",
|
||||||
|
"drivers.club": "Club",
|
||||||
|
"drivers.club_placeholder": "Club (optional)",
|
||||||
|
"drivers.country": "Country",
|
||||||
|
"drivers.country_placeholder": "Country (optional)",
|
||||||
"drivers.add": "Add Driver",
|
"drivers.add": "Add Driver",
|
||||||
"drivers.title": "Drivers",
|
"drivers.title": "Drivers",
|
||||||
"cars.create": "Create Track Car",
|
"cars.create": "Create Track Car",
|
||||||
@@ -2090,7 +2111,7 @@ const TRANSLATIONS = {
|
|||||||
"guide.dashboard_3": "That makes it easier to see whether the race day is running ahead or behind schedule directly from the dashboard.",
|
"guide.dashboard_3": "That makes it easier to see whether the race day is running ahead or behind schedule directly from the dashboard.",
|
||||||
"guide.dashboard_4": "The Getting started cards and grouped side navigation follow the same order: connect the system, register entries when needed, create a run, then open Timing/Overlay.",
|
"guide.dashboard_4": "The Getting started cards and grouped side navigation follow the same order: connect the system, register entries when needed, create a run, then open Timing/Overlay.",
|
||||||
"guide.training_title": "Practice, development and rewards",
|
"guide.training_title": "Practice, development and rewards",
|
||||||
"guide.training_1": "1. Register the driver and personal transponder under Drivers. Only passings linked to a registered driver ID count toward the practice profile.",
|
"guide.training_1": "1. Register the driver and transponder under Drivers. Club and country are optional profile fields. If the person races several classes, select the same Practice profile on every driver entry.",
|
||||||
"guide.training_2": "2. Run Free Practice, Open Practice or a regular race session. Valid laps are copied automatically to a durable training ledger; invalid short or long laps do not count.",
|
"guide.training_2": "2. Run Free Practice, Open Practice or a regular race session. Valid laps are copied automatically to a durable training ledger; invalid short or long laps do not count.",
|
||||||
"guide.training_3": "3. Open Training and select a driver. The default view only shows total laps, practice days, personal best, consistency, the next target and today's session. Open More statistics or Club practice overview only when needed.",
|
"guide.training_3": "3. Open Training and select a driver. The default view only shows total laps, practice days, personal best, consistency, the next target and today's session. Open More statistics or Club practice overview only when needed.",
|
||||||
"guide.training_4": "4. The next automatic level needs no configuration. Add custom milestones when the club or a parent wants to attach a physical reward to targets such as 500, 1000 or 1500 laps.",
|
"guide.training_4": "4. The next automatic level needs no configuration. Add custom milestones when the club or a parent wants to attach a physical reward to targets such as 500, 1000 or 1500 laps.",
|
||||||
@@ -2103,6 +2124,7 @@ const TRANSLATIONS = {
|
|||||||
"guide.training_11": "11. The top action creates or opens the selected driver's personal mobile link. The driver starts an individual 20-minute timer on the phone without restarting or changing the shared practice session.",
|
"guide.training_11": "11. The top action creates or opens the selected driver's personal mobile link. The driver starts an individual 20-minute timer on the phone without restarting or changing the shared practice session.",
|
||||||
"guide.training_12": "12. A gap longer than 2 minutes between passings automatically starts a new stint. A long return lap defines the boundary but is excluded from stint performance metrics.",
|
"guide.training_12": "12. A gap longer than 2 minutes between passings automatically starts a new stint. A long return lap defines the boundary but is excluded from stint performance metrics.",
|
||||||
"guide.training_13": "13. Best 5 min uses 300 seconds. A final lap counts when it started before 5:00 even if it finishes later, for example 17/05:14.12:123. Average pace is calculated from the laps in that exact result.",
|
"guide.training_13": "13. Best 5 min uses 300 seconds. A final lap counts when it started before 5:00 even if it finishes later, for example 17/05:14.12:123. Average pace is calculated from the laps in that exact result.",
|
||||||
|
"guide.training_14": "14. One personal profile and mobile link can contain several classes, but 2WD/4WD statistics, stints, lap times, and recommendations are always shown separately for the selected class.",
|
||||||
"overlay.title": "Overlay",
|
"overlay.title": "Overlay",
|
||||||
"overlay.subtitle": "External leaderboard screen",
|
"overlay.subtitle": "External leaderboard screen",
|
||||||
"overlay.no_active": "No active session selected.",
|
"overlay.no_active": "No active session selected.",
|
||||||
@@ -2219,6 +2241,7 @@ let selectedSessionEditId = null;
|
|||||||
let selectedTeamEditId = null;
|
let selectedTeamEditId = null;
|
||||||
let selectedJudgeKey = null;
|
let selectedJudgeKey = null;
|
||||||
let selectedTrainingDriverId = String(urlParams.get("driver") || "");
|
let selectedTrainingDriverId = String(urlParams.get("driver") || "");
|
||||||
|
let selectedTrainingClassId = String(urlParams.get("class") || "");
|
||||||
let judgingCompetitorFilter = "all";
|
let judgingCompetitorFilter = "all";
|
||||||
let judgingLogFilter = "all";
|
let judgingLogFilter = "all";
|
||||||
let quickAddDraft = null;
|
let quickAddDraft = null;
|
||||||
@@ -2286,6 +2309,9 @@ function normalizeDriver(driver = {}) {
|
|||||||
transponder: String(driver?.transponder || "").trim(),
|
transponder: String(driver?.transponder || "").trim(),
|
||||||
brand: String(driver?.brand || "").trim(),
|
brand: String(driver?.brand || "").trim(),
|
||||||
speechName: String(driver?.speechName || "").trim(),
|
speechName: String(driver?.speechName || "").trim(),
|
||||||
|
profileId: String(driver?.profileId || "").trim(),
|
||||||
|
club: String(driver?.club || "").trim(),
|
||||||
|
country: String(driver?.country || "").trim(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2833,8 +2859,8 @@ function buildDirectoryExportPayload() {
|
|||||||
|
|
||||||
function buildDriversCsvRows() {
|
function buildDriversCsvRows() {
|
||||||
return [
|
return [
|
||||||
["id", "name", "class", "brand", "transponder", "spoken_name"],
|
["id", "name", "class", "brand", "transponder", "spoken_name", "club", "country", "profile_id"],
|
||||||
...state.drivers.map((driver) => [driver.id, driver.name, getClassName(driver.classId), driver.brand || "", driver.transponder || "", driver.speechName || ""]),
|
...state.drivers.map((driver) => [driver.id, driver.name, getClassName(driver.classId), driver.brand || "", driver.transponder || "", driver.speechName || "", driver.club || "", driver.country || "", driver.profileId || ""]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3029,13 +3055,15 @@ function renderNav() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateViewUrl(view, driverId = "") {
|
function updateViewUrl(view, driverId = "", classId = "") {
|
||||||
if (overlayMode) return;
|
if (overlayMode) return;
|
||||||
const nextUrl = new URL(window.location.href);
|
const nextUrl = new URL(window.location.href);
|
||||||
if (view && view !== "dashboard") nextUrl.searchParams.set("view", view);
|
if (view && view !== "dashboard") nextUrl.searchParams.set("view", view);
|
||||||
else nextUrl.searchParams.delete("view");
|
else nextUrl.searchParams.delete("view");
|
||||||
if (view === "training" && driverId) nextUrl.searchParams.set("driver", driverId);
|
if (view === "training" && driverId) nextUrl.searchParams.set("driver", driverId);
|
||||||
else nextUrl.searchParams.delete("driver");
|
else nextUrl.searchParams.delete("driver");
|
||||||
|
if (view === "training" && classId) nextUrl.searchParams.set("class", classId);
|
||||||
|
else nextUrl.searchParams.delete("class");
|
||||||
window.history.replaceState({}, "", nextUrl);
|
window.history.replaceState({}, "", nextUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getDriverProfileGroups, getDriverProfileId } from "./driver_profile_logic.js";
|
||||||
|
|
||||||
function getScheduleDriftSummary(state) {
|
function getScheduleDriftSummary(state) {
|
||||||
const scheduledSessions = state.sessions
|
const scheduledSessions = state.sessions
|
||||||
.filter((session) => Number(session.startedAt || 0) > 0)
|
.filter((session) => Number(session.startedAt || 0) > 0)
|
||||||
@@ -373,9 +375,24 @@ export function renderDriversView(deps) {
|
|||||||
const classOptions = state.classes.map((c) => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).join("");
|
const classOptions = state.classes.map((c) => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).join("");
|
||||||
const driverSearch = driverBrandFilter().trim().toLowerCase();
|
const driverSearch = driverBrandFilter().trim().toLowerCase();
|
||||||
const filteredDrivers = state.drivers.filter((driver) =>
|
const filteredDrivers = state.drivers.filter((driver) =>
|
||||||
!driverSearch || [driver.name, driver.speechName, driver.transponder, driver.brand].map((value) => String(value || "").toLowerCase()).some((value) => value.includes(driverSearch))
|
!driverSearch || [driver.name, driver.speechName, driver.transponder, driver.brand, driver.club, driver.country].map((value) => String(value || "").toLowerCase()).some((value) => value.includes(driverSearch))
|
||||||
);
|
);
|
||||||
const editingDriver = state.drivers.find((driver) => driver.id === selectedDriverEditId()) || null;
|
const editingDriver = state.drivers.find((driver) => driver.id === selectedDriverEditId()) || null;
|
||||||
|
const profileGroups = getDriverProfileGroups(state);
|
||||||
|
const profileOptions = profileGroups
|
||||||
|
.map((profile) => {
|
||||||
|
const profileName = profile.representative?.name || t("common.unknown_driver");
|
||||||
|
const classNames = profile.classIds.map((classId) => getClassName(classId)).filter(Boolean).join(" / ");
|
||||||
|
const label = classNames ? `${profileName} - ${classNames}` : profileName;
|
||||||
|
return `<option value="${escapeHtml(profile.id)}">${escapeHtml(label)}</option>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
const editingProfileId = editingDriver ? getDriverProfileId(editingDriver) : "";
|
||||||
|
const existingClubs = [...new Set(state.drivers.map((driver) => String(driver.club || "").trim()).filter(Boolean))].sort();
|
||||||
|
const existingCountries = [...new Set([
|
||||||
|
"Sverige", "Norge", "Finland", "Danmark", "Island", "Tyskland", "Nederländerna", "Belgien", "Frankrike", "Storbritannien", "Polen", "Tjeckien", "Österrike", "Schweiz",
|
||||||
|
...state.drivers.map((driver) => String(driver.country || "").trim()).filter(Boolean),
|
||||||
|
])].sort();
|
||||||
|
|
||||||
dom.view.innerHTML = `
|
dom.view.innerHTML = `
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
@@ -386,6 +403,15 @@ export function renderDriversView(deps) {
|
|||||||
<input name="brand" placeholder="${t("drivers.brand_placeholder")}" />
|
<input name="brand" placeholder="${t("drivers.brand_placeholder")}" />
|
||||||
<input name="transponder" placeholder="${t("drivers.transponder_placeholder")}" />
|
<input name="transponder" placeholder="${t("drivers.transponder_placeholder")}" />
|
||||||
<input name="speechName" placeholder="${t("drivers.speech_name_placeholder")}" title="${t("drivers.speech_name_hint")}" />
|
<input name="speechName" placeholder="${t("drivers.speech_name_placeholder")}" title="${t("drivers.speech_name_hint")}" />
|
||||||
|
<input name="club" list="driverClubOptions" placeholder="${t("drivers.club_placeholder")}" />
|
||||||
|
<input name="country" list="driverCountryOptions" placeholder="${t("drivers.country_placeholder")}" />
|
||||||
|
<label>${t("drivers.training_profile")}<select name="profileId" title="${t("drivers.training_profile_hint")}">
|
||||||
|
<option value="">${t("drivers.training_profile_auto")}</option>
|
||||||
|
${profileOptions}
|
||||||
|
<option value="__new__">${t("drivers.training_profile_new")}</option>
|
||||||
|
</select></label>
|
||||||
|
<datalist id="driverClubOptions">${existingClubs.map((club) => `<option value="${escapeHtml(club)}"></option>`).join("")}</datalist>
|
||||||
|
<datalist id="driverCountryOptions">${existingCountries.map((country) => `<option value="${escapeHtml(country)}"></option>`).join("")}</datalist>
|
||||||
<button class="btn btn-primary" type="submit">${t("drivers.add")}</button>
|
<button class="btn btn-primary" type="submit">${t("drivers.add")}</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -397,21 +423,27 @@ export function renderDriversView(deps) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
${renderTable(
|
${renderTable(
|
||||||
[t("table.name"), t("table.class"), t("table.brand"), t("table.transponder"), t("drivers.speech_name"), t("events.actions")],
|
[t("table.name"), t("table.class"), t("drivers.club"), t("drivers.country"), t("table.brand"), t("table.transponder"), t("drivers.training_profile"), t("drivers.speech_name"), t("events.actions")],
|
||||||
filteredDrivers.map(
|
filteredDrivers.map(
|
||||||
(d) => `
|
(d) => {
|
||||||
|
const profile = profileGroups.find((item) => item.id === getDriverProfileId(d));
|
||||||
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td>${escapeHtml(d.name)}</td>
|
<td>${escapeHtml(d.name)}</td>
|
||||||
<td>${escapeHtml(getClassName(d.classId))}</td>
|
<td>${escapeHtml(getClassName(d.classId))}</td>
|
||||||
|
<td>${escapeHtml(profile?.club || d.club || "-")}</td>
|
||||||
|
<td>${escapeHtml(profile?.country || d.country || "-")}</td>
|
||||||
<td>${escapeHtml(d.brand || "-")}</td>
|
<td>${escapeHtml(d.brand || "-")}</td>
|
||||||
<td>${escapeHtml(d.transponder || "-")}</td>
|
<td>${escapeHtml(d.transponder || "-")}</td>
|
||||||
|
<td>${escapeHtml(profile?.representative?.name || d.name)}${profile?.drivers.length > 1 ? ` · ${profile.drivers.length}` : ""}</td>
|
||||||
<td>${escapeHtml(d.speechName || d.name)}</td>
|
<td>${escapeHtml(d.speechName || d.name)}</td>
|
||||||
<td class="actions-inline">
|
<td class="actions-inline">
|
||||||
<button id="driver-edit-${d.id}" class="btn">${t("common.edit")}</button>
|
<button id="driver-edit-${d.id}" class="btn">${t("common.edit")}</button>
|
||||||
<button id="driver-delete-${d.id}" class="btn btn-danger">${t("common.delete")}</button>
|
<button id="driver-delete-${d.id}" class="btn btn-danger">${t("common.delete")}</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`
|
`;
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -436,6 +468,16 @@ export function renderDriversView(deps) {
|
|||||||
<input name="brand" value="${escapeHtml(editingDriver.brand || "")}" placeholder="${t("drivers.brand_placeholder")}" />
|
<input name="brand" value="${escapeHtml(editingDriver.brand || "")}" placeholder="${t("drivers.brand_placeholder")}" />
|
||||||
<input name="transponder" value="${escapeHtml(editingDriver.transponder || "")}" placeholder="${t("drivers.transponder_placeholder")}" />
|
<input name="transponder" value="${escapeHtml(editingDriver.transponder || "")}" placeholder="${t("drivers.transponder_placeholder")}" />
|
||||||
<input name="speechName" value="${escapeHtml(editingDriver.speechName || "")}" placeholder="${t("drivers.speech_name_placeholder")}" title="${t("drivers.speech_name_hint")}" />
|
<input name="speechName" value="${escapeHtml(editingDriver.speechName || "")}" placeholder="${t("drivers.speech_name_placeholder")}" title="${t("drivers.speech_name_hint")}" />
|
||||||
|
<input name="club" list="driverClubOptions" value="${escapeHtml(profileGroups.find((profile) => profile.id === editingProfileId)?.club || editingDriver.club || "")}" placeholder="${t("drivers.club_placeholder")}" />
|
||||||
|
<input name="country" list="driverCountryOptions" value="${escapeHtml(profileGroups.find((profile) => profile.id === editingProfileId)?.country || editingDriver.country || "")}" placeholder="${t("drivers.country_placeholder")}" />
|
||||||
|
<label>${t("drivers.training_profile")}<select name="profileId" title="${t("drivers.training_profile_hint")}">
|
||||||
|
${profileGroups.map((profile) => {
|
||||||
|
const profileName = profile.representative?.name || t("common.unknown_driver");
|
||||||
|
const classNames = profile.classIds.map((classId) => getClassName(classId)).filter(Boolean).join(" / ");
|
||||||
|
return `<option value="${escapeHtml(profile.id)}" ${profile.id === editingProfileId ? "selected" : ""}>${escapeHtml(classNames ? `${profileName} - ${classNames}` : profileName)}</option>`;
|
||||||
|
}).join("")}
|
||||||
|
<option value="__new__">${t("drivers.training_profile_separate")}</option>
|
||||||
|
</select></label>
|
||||||
<p class="form-error" id="driverEditError" hidden></p>
|
<p class="form-error" id="driverEditError" hidden></p>
|
||||||
<div class="actions-inline">
|
<div class="actions-inline">
|
||||||
<button class="btn btn-primary" id="driverEditSave" type="button">${t("common.save")}</button>
|
<button class="btn btn-primary" id="driverEditSave" type="button">${t("common.save")}</button>
|
||||||
@@ -452,14 +494,29 @@ export function renderDriversView(deps) {
|
|||||||
document.getElementById("driverForm")?.addEventListener("submit", (e) => {
|
document.getElementById("driverForm")?.addEventListener("submit", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const form = new FormData(e.currentTarget);
|
const form = new FormData(e.currentTarget);
|
||||||
|
const driverId = uid("driver");
|
||||||
|
const driverName = String(form.get("name") || "").trim();
|
||||||
|
const requestedProfileId = String(form.get("profileId") || "").trim();
|
||||||
|
const sameNameProfile = profileGroups.find((profile) => (
|
||||||
|
String(profile.representative?.name || "").trim().toLocaleLowerCase().replace(/\s+/g, " ") === driverName.toLocaleLowerCase().replace(/\s+/g, " ")
|
||||||
|
));
|
||||||
|
const profileId = requestedProfileId && requestedProfileId !== "__new__"
|
||||||
|
? requestedProfileId
|
||||||
|
: requestedProfileId === "__new__"
|
||||||
|
? uid("profile")
|
||||||
|
: sameNameProfile?.id || uid("profile");
|
||||||
|
const linkedProfile = profileGroups.find((profile) => profile.id === profileId);
|
||||||
state.drivers.push(
|
state.drivers.push(
|
||||||
normalizeDriver({
|
normalizeDriver({
|
||||||
id: uid("driver"),
|
id: driverId,
|
||||||
name: String(form.get("name")).trim(),
|
name: driverName,
|
||||||
classId: String(form.get("classId")),
|
classId: String(form.get("classId")),
|
||||||
brand: String(form.get("brand") || "").trim(),
|
brand: String(form.get("brand") || "").trim(),
|
||||||
transponder: String(form.get("transponder") || "").trim(),
|
transponder: String(form.get("transponder") || "").trim(),
|
||||||
speechName: String(form.get("speechName") || "").trim(),
|
speechName: String(form.get("speechName") || "").trim(),
|
||||||
|
profileId,
|
||||||
|
club: String(form.get("club") || linkedProfile?.club || "").trim(),
|
||||||
|
country: String(form.get("country") || linkedProfile?.country || "").trim(),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
saveState();
|
saveState();
|
||||||
@@ -475,6 +532,21 @@ export function renderDriversView(deps) {
|
|||||||
renderDriversView(deps);
|
renderDriversView(deps);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const bindProfileMetadataFill = (formId) => {
|
||||||
|
const formNode = document.getElementById(formId);
|
||||||
|
const profileSelect = formNode?.querySelector('select[name="profileId"]');
|
||||||
|
profileSelect?.addEventListener("change", () => {
|
||||||
|
const profile = profileGroups.find((item) => item.id === profileSelect.value);
|
||||||
|
if (!profile) return;
|
||||||
|
const clubInput = formNode.querySelector('input[name="club"]');
|
||||||
|
const countryInput = formNode.querySelector('input[name="country"]');
|
||||||
|
if (clubInput instanceof HTMLInputElement) clubInput.value = profile.club || "";
|
||||||
|
if (countryInput instanceof HTMLInputElement) countryInput.value = profile.country || "";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
bindProfileMetadataFill("driverForm");
|
||||||
|
bindProfileMetadataFill("driverEditForm");
|
||||||
|
|
||||||
state.drivers.forEach((d) => {
|
state.drivers.forEach((d) => {
|
||||||
document.getElementById(`driver-edit-${d.id}`)?.addEventListener("click", () => {
|
document.getElementById(`driver-edit-${d.id}`)?.addEventListener("click", () => {
|
||||||
setSelectedDriverEditId(d.id);
|
setSelectedDriverEditId(d.id);
|
||||||
@@ -527,6 +599,10 @@ export function renderDriversView(deps) {
|
|||||||
const cleanedBrand = String(form.get("brand") || "").trim();
|
const cleanedBrand = String(form.get("brand") || "").trim();
|
||||||
const cleanedTp = String(form.get("transponder") || "").trim();
|
const cleanedTp = String(form.get("transponder") || "").trim();
|
||||||
const cleanedSpeechName = String(form.get("speechName") || "").trim();
|
const cleanedSpeechName = String(form.get("speechName") || "").trim();
|
||||||
|
const requestedProfileId = String(form.get("profileId") || "").trim();
|
||||||
|
const cleanedProfileId = requestedProfileId === "__new__" ? uid("profile") : requestedProfileId || editingProfileId;
|
||||||
|
const cleanedClub = String(form.get("club") || "").trim();
|
||||||
|
const cleanedCountry = String(form.get("country") || "").trim();
|
||||||
if (!cleanedName) {
|
if (!cleanedName) {
|
||||||
setFormError("driverEditError", t("validation.required_name"));
|
setFormError("driverEditError", t("validation.required_name"));
|
||||||
return;
|
return;
|
||||||
@@ -536,18 +612,25 @@ export function renderDriversView(deps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFormError("driverEditError", "");
|
setFormError("driverEditError", "");
|
||||||
state.drivers = state.drivers.map((item) =>
|
state.drivers = state.drivers.map((item) => {
|
||||||
item.id === editingDriver.id
|
if (item.id === editingDriver.id) {
|
||||||
? normalizeDriver({
|
return normalizeDriver({
|
||||||
...item,
|
...item,
|
||||||
name: cleanedName,
|
name: cleanedName,
|
||||||
classId: cleanedClassId || item.classId,
|
classId: cleanedClassId || item.classId,
|
||||||
brand: cleanedBrand,
|
brand: cleanedBrand,
|
||||||
transponder: cleanedTp,
|
transponder: cleanedTp,
|
||||||
speechName: cleanedSpeechName,
|
speechName: cleanedSpeechName,
|
||||||
})
|
profileId: cleanedProfileId,
|
||||||
: item
|
club: cleanedClub,
|
||||||
);
|
country: cleanedCountry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (getDriverProfileId(item) === cleanedProfileId) {
|
||||||
|
return normalizeDriver({ ...item, club: cleanedClub, country: cleanedCountry });
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
setSelectedDriverEditId(null);
|
setSelectedDriverEditId(null);
|
||||||
saveState();
|
saveState();
|
||||||
renderView();
|
renderView();
|
||||||
|
|||||||
46
src/driver_profile_logic.js
Normal file
46
src/driver_profile_logic.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
function normalizeProfileName(value) {
|
||||||
|
return String(value || "").trim().toLocaleLowerCase().replace(/\s+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDriverProfileId(driver) {
|
||||||
|
const explicitId = String(driver?.profileId || "").trim();
|
||||||
|
if (explicitId) return explicitId;
|
||||||
|
const legacyName = normalizeProfileName(driver?.name);
|
||||||
|
return legacyName ? `legacy:${legacyName}` : `driver:${String(driver?.id || "unknown")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDriverProfileGroups(state) {
|
||||||
|
const groups = new Map();
|
||||||
|
(Array.isArray(state?.drivers) ? state.drivers : []).forEach((driver) => {
|
||||||
|
const profileId = getDriverProfileId(driver);
|
||||||
|
const group = groups.get(profileId) || { id: profileId, drivers: [] };
|
||||||
|
group.drivers.push(driver);
|
||||||
|
groups.set(profileId, group);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...groups.values()].map((group) => ({
|
||||||
|
...group,
|
||||||
|
representative: group.drivers.find((driver) => String(driver.id || "") === group.id) || group.drivers[0] || null,
|
||||||
|
club: String(group.drivers.find((driver) => String(driver.club || "").trim())?.club || "").trim(),
|
||||||
|
country: String(group.drivers.find((driver) => String(driver.country || "").trim())?.country || "").trim(),
|
||||||
|
driverIds: group.drivers.map((driver) => String(driver.id || "")).filter(Boolean),
|
||||||
|
classIds: [...new Set(group.drivers.map((driver) => String(driver.classId || "")).filter(Boolean))],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDriverProfile(state, reference) {
|
||||||
|
const normalizedReference = String(reference || "");
|
||||||
|
return getDriverProfileGroups(state).find((group) => (
|
||||||
|
group.id === normalizedReference
|
||||||
|
|| group.driverIds.includes(normalizedReference)
|
||||||
|
)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDriverProfileReferences(state, reference) {
|
||||||
|
const profile = getDriverProfile(state, reference);
|
||||||
|
const references = new Set([String(reference || "")].filter(Boolean));
|
||||||
|
if (!profile) return references;
|
||||||
|
references.add(profile.id);
|
||||||
|
profile.driverIds.forEach((driverId) => references.add(driverId));
|
||||||
|
return references;
|
||||||
|
}
|
||||||
@@ -64,7 +64,7 @@ export function renderGuideView({ dom, t }) {
|
|||||||
|
|
||||||
<div class="guide-section-grid mt-16">
|
<div class="guide-section-grid mt-16">
|
||||||
${renderGuidePanel("guide.dashboard_title", ["guide.dashboard_1", "guide.dashboard_2", "guide.dashboard_3", "guide.dashboard_4"], "", { t })}
|
${renderGuidePanel("guide.dashboard_title", ["guide.dashboard_1", "guide.dashboard_2", "guide.dashboard_3", "guide.dashboard_4"], "", { t })}
|
||||||
${renderGuidePanel("guide.training_title", ["guide.training_1", "guide.training_2", "guide.training_3", "guide.training_4", "guide.training_5", "guide.training_6", "guide.training_7", "guide.training_8", "guide.training_9", "guide.training_10", "guide.training_11", "guide.training_12", "guide.training_13"], "", { t })}
|
${renderGuidePanel("guide.training_title", ["guide.training_1", "guide.training_2", "guide.training_3", "guide.training_4", "guide.training_5", "guide.training_6", "guide.training_7", "guide.training_8", "guide.training_9", "guide.training_10", "guide.training_11", "guide.training_12", "guide.training_13", "guide.training_14"], "", { t })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="guide-section-grid mt-16">
|
<div class="guide-section-grid mt-16">
|
||||||
|
|||||||
@@ -165,6 +165,24 @@ button {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-class-select {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 6px 4px 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(7, 12, 20, 0.5);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-class-select select {
|
||||||
|
width: auto;
|
||||||
|
min-width: 110px;
|
||||||
|
padding: 4px 28px 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-stat-grid {
|
.profile-stat-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ const language = requestedLanguage === "en" || requestedLanguage === "sv"
|
|||||||
: String(navigator.language || "sv").toLowerCase().startsWith("sv") ? "sv" : "en";
|
: String(navigator.language || "sv").toLowerCase().startsWith("sv") ? "sv" : "en";
|
||||||
const TIMER_DURATION_MS = 20 * 60 * 1000;
|
const TIMER_DURATION_MS = 20 * 60 * 1000;
|
||||||
const TIMER_PHASE_MS = 10 * 60 * 1000;
|
const TIMER_PHASE_MS = 10 * 60 * 1000;
|
||||||
const TIMER_STORAGE_KEY = `rc-training-timer:${shareToken}`;
|
let selectedClassId = new URLSearchParams(window.location.search).get("class") || "";
|
||||||
|
|
||||||
const messages = {
|
const messages = {
|
||||||
sv: {
|
sv: {
|
||||||
loading: "Laddar träningsprofil...",
|
loading: "Laddar träningsprofil...",
|
||||||
loadError: "Kunde inte läsa träningsprofilen. Länken kan vara avstängd eller ersatt.",
|
loadError: "Kunde inte läsa träningsprofilen. Länken kan vara avstängd eller ersatt.",
|
||||||
development: "Förarutveckling",
|
development: "Förarutveckling",
|
||||||
|
class: "Klass",
|
||||||
rank: "Klubbrank {rank} av {count}",
|
rank: "Klubbrank {rank} av {count}",
|
||||||
laps: "Totala varv",
|
laps: "Totala varv",
|
||||||
days: "Träningsdagar",
|
days: "Träningsdagar",
|
||||||
@@ -62,6 +63,7 @@ const messages = {
|
|||||||
loading: "Loading training profile...",
|
loading: "Loading training profile...",
|
||||||
loadError: "The training profile could not be loaded. The link may be disabled or replaced.",
|
loadError: "The training profile could not be loaded. The link may be disabled or replaced.",
|
||||||
development: "Driver development",
|
development: "Driver development",
|
||||||
|
class: "Class",
|
||||||
rank: "Club rank {rank} of {count}",
|
rank: "Club rank {rank} of {count}",
|
||||||
laps: "Total laps",
|
laps: "Total laps",
|
||||||
days: "Practice days",
|
days: "Practice days",
|
||||||
@@ -160,9 +162,17 @@ function recommendationCopy(recommendation) {
|
|||||||
|
|
||||||
function renderProfile(data) {
|
function renderProfile(data) {
|
||||||
profileData = data;
|
profileData = data;
|
||||||
const { profile, branding, stats, nextMilestone, recommendation, goals, currentSession } = data;
|
const { profile, branding } = data;
|
||||||
|
const classProfiles = Array.isArray(data.classProfiles) ? data.classProfiles : [];
|
||||||
|
let activeClass = classProfiles.find((item) => String(item.classId || "") === selectedClassId) || classProfiles[0] || null;
|
||||||
|
if (activeClass && selectedClassId !== activeClass.classId) selectedClassId = activeClass.classId;
|
||||||
|
const stats = activeClass?.stats || data.stats;
|
||||||
|
const nextMilestone = activeClass?.nextMilestone || data.nextMilestone;
|
||||||
|
const recommendation = activeClass?.recommendation || data.recommendation;
|
||||||
|
const goals = activeClass?.goals || data.goals || [];
|
||||||
|
const currentSession = activeClass?.currentSession || data.currentSession;
|
||||||
const recommendationText = recommendationCopy(recommendation);
|
const recommendationText = recommendationCopy(recommendation);
|
||||||
const rank = profile.clubRank && profile.rankedDrivers ? t("rank", { rank: profile.clubRank, count: profile.rankedDrivers }) : "";
|
const rank = activeClass?.clubRank && activeClass?.rankedDrivers ? t("rank", { rank: activeClass.clubRank, count: activeClass.rankedDrivers }) : "";
|
||||||
const logo = /^data:image\/(?:png|jpeg|webp);base64,/i.test(branding.logoDataUrl || "")
|
const logo = /^data:image\/(?:png|jpeg|webp);base64,/i.test(branding.logoDataUrl || "")
|
||||||
? `<img src="${escapeHtml(branding.logoDataUrl)}" alt="" />`
|
? `<img src="${escapeHtml(branding.logoDataUrl)}" alt="" />`
|
||||||
: `<div class="profile-brand-mark">RC</div>`;
|
: `<div class="profile-brand-mark">RC</div>`;
|
||||||
@@ -189,6 +199,9 @@ function renderProfile(data) {
|
|||||||
</tr>
|
</tr>
|
||||||
`).join("")
|
`).join("")
|
||||||
: `<tr><td colspan="4">-</td></tr>`;
|
: `<tr><td colspan="4">-</td></tr>`;
|
||||||
|
const classOptions = classProfiles.map((item) => (
|
||||||
|
`<option value="${escapeHtml(item.classId)}" ${item.classId === selectedClassId ? "selected" : ""}>${escapeHtml(item.className || item.classId)}</option>`
|
||||||
|
)).join("");
|
||||||
|
|
||||||
document.documentElement.lang = language;
|
document.documentElement.lang = language;
|
||||||
document.title = `${profile.name} - ${branding.clubName}`;
|
document.title = `${profile.name} - ${branding.clubName}`;
|
||||||
@@ -205,8 +218,8 @@ function renderProfile(data) {
|
|||||||
<span class="profile-eyebrow">${escapeHtml(t("development"))}</span>
|
<span class="profile-eyebrow">${escapeHtml(t("development"))}</span>
|
||||||
<h1>${escapeHtml(profile.name)}</h1>
|
<h1>${escapeHtml(profile.name)}</h1>
|
||||||
<div class="profile-meta">
|
<div class="profile-meta">
|
||||||
${profile.className ? `<span class="profile-pill">${escapeHtml(profile.className)}</span>` : ""}
|
${classProfiles.length > 1 ? `<label class="profile-class-select"><span>${escapeHtml(t("class"))}</span><select id="profileClass">${classOptions}</select></label>` : activeClass?.className ? `<span class="profile-pill">${escapeHtml(activeClass.className)}</span>` : ""}
|
||||||
${profile.brand ? `<span class="profile-pill">${escapeHtml(profile.brand)}</span>` : ""}
|
${activeClass?.brand ? `<span class="profile-pill">${escapeHtml(activeClass.brand)}</span>` : ""}
|
||||||
${rank ? `<span class="profile-pill">${escapeHtml(rank)}</span>` : ""}
|
${rank ? `<span class="profile-pill">${escapeHtml(rank)}</span>` : ""}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -282,6 +295,15 @@ function renderProfile(data) {
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
`;
|
`;
|
||||||
|
document.getElementById("profileClass")?.addEventListener("change", (event) => {
|
||||||
|
selectedClassId = String(event.currentTarget.value || "");
|
||||||
|
const nextUrl = new URL(window.location.href);
|
||||||
|
if (selectedClassId) nextUrl.searchParams.set("class", selectedClassId);
|
||||||
|
else nextUrl.searchParams.delete("class");
|
||||||
|
window.history.replaceState({}, "", nextUrl);
|
||||||
|
lastTimerPhase = "";
|
||||||
|
renderProfile(profileData);
|
||||||
|
});
|
||||||
bindTimerControls();
|
bindTimerControls();
|
||||||
updateTimerDisplay();
|
updateTimerDisplay();
|
||||||
}
|
}
|
||||||
@@ -292,7 +314,7 @@ function renderStat(label, value) {
|
|||||||
|
|
||||||
function readTimerState() {
|
function readTimerState() {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(localStorage.getItem(TIMER_STORAGE_KEY) || "null");
|
const parsed = JSON.parse(localStorage.getItem(getTimerStorageKey()) || "null");
|
||||||
return parsed && typeof parsed === "object" ? parsed : null;
|
return parsed && typeof parsed === "object" ? parsed : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -300,8 +322,12 @@ function readTimerState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function writeTimerState(value) {
|
function writeTimerState(value) {
|
||||||
if (value) localStorage.setItem(TIMER_STORAGE_KEY, JSON.stringify(value));
|
if (value) localStorage.setItem(getTimerStorageKey(), JSON.stringify(value));
|
||||||
else localStorage.removeItem(TIMER_STORAGE_KEY);
|
else localStorage.removeItem(getTimerStorageKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimerStorageKey() {
|
||||||
|
return `rc-training-timer:${shareToken}:${selectedClassId || "default"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTimerElapsed(state, now = Date.now()) {
|
function getTimerElapsed(state, now = Date.now()) {
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
|
import { getDriverProfile, getDriverProfileReferences } from "./driver_profile_logic.js";
|
||||||
|
|
||||||
|
function getTrainingDriverIds(state, driverId, classId = "") {
|
||||||
|
const profile = getDriverProfile(state, driverId);
|
||||||
|
if (!profile) return getDriverProfileReferences(state, driverId);
|
||||||
|
const normalizedClassId = String(classId || "");
|
||||||
|
return new Set(profile.drivers
|
||||||
|
.filter((driver) => !normalizedClassId || String(driver.classId || "") === normalizedClassId)
|
||||||
|
.map((driver) => String(driver.id || ""))
|
||||||
|
.filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeTrainingState(training, { uid }) {
|
export function normalizeTrainingState(training, { uid }) {
|
||||||
const source = training && typeof training === "object" ? training : {};
|
const source = training && typeof training === "object" ? training : {};
|
||||||
const goals = Array.isArray(source.goals)
|
const goals = Array.isArray(source.goals)
|
||||||
@@ -5,6 +17,7 @@ export function normalizeTrainingState(training, { uid }) {
|
|||||||
.map((goal) => ({
|
.map((goal) => ({
|
||||||
id: String(goal?.id || uid("goal")),
|
id: String(goal?.id || uid("goal")),
|
||||||
driverId: String(goal?.driverId || ""),
|
driverId: String(goal?.driverId || ""),
|
||||||
|
classId: String(goal?.classId || ""),
|
||||||
title: String(goal?.title || "").trim(),
|
title: String(goal?.title || "").trim(),
|
||||||
reward: String(goal?.reward || "").trim(),
|
reward: String(goal?.reward || "").trim(),
|
||||||
targetLaps: Math.max(1, Math.round(Number(goal?.targetLaps || 0) || 0)),
|
targetLaps: Math.max(1, Math.round(Number(goal?.targetLaps || 0) || 0)),
|
||||||
@@ -101,10 +114,11 @@ export function syncTrainingLedger(training, state, { uid }) {
|
|||||||
return normalizeTrainingState({ goals: normalized.goals, ledger: [...retained, ...current], shares: normalized.shares }, { uid });
|
return normalizeTrainingState({ goals: normalized.goals, ledger: [...retained, ...current], shares: normalized.shares }, { uid });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDriverTrainingLaps(state, driverId) {
|
export function getDriverTrainingLaps(state, driverId, classId = "") {
|
||||||
const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : [];
|
const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : [];
|
||||||
|
const profileReferences = getTrainingDriverIds(state, driverId, classId);
|
||||||
return ledger
|
return ledger
|
||||||
.filter((entry) => String(entry?.driverId || "") === String(driverId || "") && Number(entry?.lapMs || 0) > 500)
|
.filter((entry) => profileReferences.has(String(entry?.driverId || "")) && Number(entry?.lapMs || 0) > 500)
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
sessionId: String(entry.sessionId || ""),
|
sessionId: String(entry.sessionId || ""),
|
||||||
sessionName: String(entry.sessionName || ""),
|
sessionName: String(entry.sessionName || ""),
|
||||||
@@ -249,8 +263,8 @@ export function getBestTrainingDurationResult(stints, targetMs = 5 * 60 * 1000)
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDriverTrainingStats(state, driverId) {
|
export function buildDriverTrainingStats(state, driverId, classId = "") {
|
||||||
const laps = getDriverTrainingLaps(state, driverId);
|
const laps = getDriverTrainingLaps(state, driverId, classId);
|
||||||
const lapTimes = laps.map((lap) => lap.lapMs);
|
const lapTimes = laps.map((lap) => lap.lapMs);
|
||||||
const recentLapTimes = lapTimes.slice(-30);
|
const recentLapTimes = lapTimes.slice(-30);
|
||||||
const earlierLapTimes = lapTimes.slice(0, Math.max(0, lapTimes.length - recentLapTimes.length));
|
const earlierLapTimes = lapTimes.slice(0, Math.max(0, lapTimes.length - recentLapTimes.length));
|
||||||
@@ -300,7 +314,7 @@ export function buildDriverTrainingStats(state, driverId) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDriverActivePracticeStats(state, driverId, nowTs = Date.now()) {
|
export function buildDriverActivePracticeStats(state, driverId, nowTs = Date.now(), classId = "") {
|
||||||
const session = (Array.isArray(state?.sessions) ? state.sessions : [])
|
const session = (Array.isArray(state?.sessions) ? state.sessions : [])
|
||||||
.find((item) => String(item?.id || "") === String(state?.activeSessionId || ""));
|
.find((item) => String(item?.id || "") === String(state?.activeSessionId || ""));
|
||||||
const practiceTypes = new Set(["open_practice", "free_practice", "practice"]);
|
const practiceTypes = new Set(["open_practice", "free_practice", "practice"]);
|
||||||
@@ -308,9 +322,10 @@ export function buildDriverActivePracticeStats(state, driverId, nowTs = Date.now
|
|||||||
|
|
||||||
const result = state?.resultsBySession?.[session.id];
|
const result = state?.resultsBySession?.[session.id];
|
||||||
const startedAt = Math.max(0, Number(session.startedAt || 0) || 0);
|
const startedAt = Math.max(0, Number(session.startedAt || 0) || 0);
|
||||||
|
const profileReferences = getTrainingDriverIds(state, driverId, classId);
|
||||||
const laps = (Array.isArray(result?.passings) ? result.passings : [])
|
const laps = (Array.isArray(result?.passings) ? result.passings : [])
|
||||||
.filter((passing) => (
|
.filter((passing) => (
|
||||||
String(passing?.driverId || "") === String(driverId || "")
|
profileReferences.has(String(passing?.driverId || ""))
|
||||||
&& passing?.validLap !== false
|
&& passing?.validLap !== false
|
||||||
&& Number(passing?.lapMs || 0) > 500
|
&& Number(passing?.lapMs || 0) > 500
|
||||||
&& (!startedAt || Number(passing?.timestamp || 0) >= startedAt)
|
&& (!startedAt || Number(passing?.timestamp || 0) >= startedAt)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getDriverProfileGroups, getDriverProfileReferences } from "./driver_profile_logic.js";
|
||||||
|
|
||||||
export function renderTrainingView(deps) {
|
export function renderTrainingView(deps) {
|
||||||
const {
|
const {
|
||||||
state,
|
state,
|
||||||
@@ -18,18 +20,26 @@ export function renderTrainingView(deps) {
|
|||||||
getNextTrainingMilestone,
|
getNextTrainingMilestone,
|
||||||
getSelectedDriverId,
|
getSelectedDriverId,
|
||||||
setSelectedDriverId,
|
setSelectedDriverId,
|
||||||
|
getSelectedClassId,
|
||||||
|
setSelectedClassId,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
state.training = normalizeTrainingState(state.training);
|
state.training = normalizeTrainingState(state.training);
|
||||||
syncTrainingLedger();
|
syncTrainingLedger();
|
||||||
const drivers = [...state.drivers].sort((left, right) => String(left.name).localeCompare(String(right.name)));
|
const profiles = getDriverProfileGroups(state)
|
||||||
let selectedDriverId = String(getSelectedDriverId() || "");
|
.sort((left, right) => String(left.representative?.name || "").localeCompare(String(right.representative?.name || "")));
|
||||||
if (!drivers.some((driver) => driver.id === selectedDriverId)) {
|
let selectedProfileId = String(getSelectedDriverId() || "");
|
||||||
selectedDriverId = drivers[0]?.id || "";
|
let selectedProfile = profiles.find((profile) => profile.id === selectedProfileId || profile.driverIds.includes(selectedProfileId));
|
||||||
setSelectedDriverId(selectedDriverId);
|
if (!selectedProfile) {
|
||||||
|
selectedProfile = profiles[0] || null;
|
||||||
|
selectedProfileId = selectedProfile?.id || "";
|
||||||
|
setSelectedDriverId(selectedProfileId);
|
||||||
|
} else if (selectedProfileId !== selectedProfile.id) {
|
||||||
|
selectedProfileId = selectedProfile.id;
|
||||||
|
setSelectedDriverId(selectedProfileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedDriverId) {
|
if (!selectedProfileId || !selectedProfile) {
|
||||||
dom.view.innerHTML = `
|
dom.view.innerHTML = `
|
||||||
<section class="panel training-empty">
|
<section class="panel training-empty">
|
||||||
<div class="panel-header"><h3>${escapeHtml(t("training.no_drivers_title"))}</h3></div>
|
<div class="panel-header"><h3>${escapeHtml(t("training.no_drivers_title"))}</h3></div>
|
||||||
@@ -39,16 +49,38 @@ export function renderTrainingView(deps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedDriver = drivers.find((driver) => driver.id === selectedDriverId);
|
let selectedClassId = String(getSelectedClassId() || "");
|
||||||
const stats = buildDriverTrainingStats(state, selectedDriverId);
|
if (!selectedProfile.classIds.includes(selectedClassId)) {
|
||||||
const activePractice = buildDriverActivePracticeStats(state, selectedDriverId);
|
selectedClassId = selectedProfile.classIds[0] || "";
|
||||||
|
setSelectedClassId(selectedClassId);
|
||||||
|
}
|
||||||
|
const selectedDriver = selectedProfile.representative;
|
||||||
|
const profileReferences = getDriverProfileReferences(state, selectedProfileId);
|
||||||
|
const stats = buildDriverTrainingStats(state, selectedProfileId, selectedClassId);
|
||||||
|
const activePractice = buildDriverActivePracticeStats(state, selectedProfileId, Date.now(), selectedClassId);
|
||||||
const recommendation = buildTrainingRecommendation(stats);
|
const recommendation = buildTrainingRecommendation(stats);
|
||||||
const nextMilestone = getNextTrainingMilestone(stats);
|
const nextMilestone = getNextTrainingMilestone(stats);
|
||||||
const goals = state.training.goals.filter((goal) => goal.driverId === selectedDriverId);
|
const goals = state.training.goals.filter((goal) => (
|
||||||
const profileShare = (state.training.shares || []).find((share) => share.driverId === selectedDriverId && share.enabled);
|
profileReferences.has(String(goal.driverId || ""))
|
||||||
|
&& (!goal.classId || String(goal.classId) === selectedClassId)
|
||||||
|
));
|
||||||
|
const profileShare = (state.training.shares || []).find((share) => profileReferences.has(String(share.driverId || "")) && share.enabled);
|
||||||
const publicProfileUrl = profileShare ? buildPublicProfileUrl(profileShare.token, state.settings?.language) : "";
|
const publicProfileUrl = profileShare ? buildPublicProfileUrl(profileShare.token, state.settings?.language) : "";
|
||||||
const driverOptions = drivers
|
const driverOptions = profiles
|
||||||
.map((driver) => `<option value="${escapeHtml(driver.id)}" ${driver.id === selectedDriverId ? "selected" : ""}>${escapeHtml(driver.name)}</option>`)
|
.map((profile) => {
|
||||||
|
const classNames = profile.classIds
|
||||||
|
.map((classId) => state.classes.find((item) => item.id === classId)?.name || "")
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" / ");
|
||||||
|
const label = classNames ? `${profile.representative?.name || "-"} - ${classNames}` : profile.representative?.name || "-";
|
||||||
|
return `<option value="${escapeHtml(profile.id)}" ${profile.id === selectedProfileId ? "selected" : ""}>${escapeHtml(label)}</option>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
const classOptions = selectedProfile.classIds
|
||||||
|
.map((classId) => {
|
||||||
|
const className = state.classes.find((item) => item.id === classId)?.name || classId;
|
||||||
|
return `<option value="${escapeHtml(classId)}" ${classId === selectedClassId ? "selected" : ""}>${escapeHtml(className)}</option>`;
|
||||||
|
})
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
const goalMarkup = goals.length
|
const goalMarkup = goals.length
|
||||||
@@ -98,15 +130,16 @@ export function renderTrainingView(deps) {
|
|||||||
</tr>
|
</tr>
|
||||||
`).join("")
|
`).join("")
|
||||||
: `<tr><td colspan="4">${escapeHtml(t("training.no_history"))}</td></tr>`;
|
: `<tr><td colspan="4">${escapeHtml(t("training.no_history"))}</td></tr>`;
|
||||||
const clubRows = drivers
|
const clubRows = profiles
|
||||||
.map((driver) => ({ driver, stats: driver.id === selectedDriverId ? stats : buildDriverTrainingStats(state, driver.id) }))
|
.filter((profile) => !selectedClassId || profile.classIds.includes(selectedClassId))
|
||||||
.sort((left, right) => right.stats.totalLaps - left.stats.totalLaps || String(left.driver.name).localeCompare(String(right.driver.name)))
|
.map((profile) => ({ profile, driver: profile.representative, stats: profile.id === selectedProfileId ? stats : buildDriverTrainingStats(state, profile.id, selectedClassId) }))
|
||||||
|
.sort((left, right) => right.stats.totalLaps - left.stats.totalLaps || String(left.driver?.name || "").localeCompare(String(right.driver?.name || "")))
|
||||||
.map((entry, index) => {
|
.map((entry, index) => {
|
||||||
const milestone = getNextTrainingMilestone(entry.stats);
|
const milestone = getNextTrainingMilestone(entry.stats);
|
||||||
return `
|
return `
|
||||||
<tr class="${entry.driver.id === selectedDriverId ? "is-selected" : ""}">
|
<tr class="${entry.profile.id === selectedProfileId ? "is-selected" : ""}">
|
||||||
<td>${index + 1}</td>
|
<td>${index + 1}</td>
|
||||||
<td><button class="training-club-driver" data-driver-id="${escapeHtml(entry.driver.id)}" type="button">${escapeHtml(entry.driver.name)}</button></td>
|
<td><button class="training-club-driver" data-driver-id="${escapeHtml(entry.profile.id)}" data-class-id="${escapeHtml(selectedClassId)}" type="button">${escapeHtml(entry.driver?.name || "-")}</button></td>
|
||||||
<td>${entry.stats.totalLaps}</td>
|
<td>${entry.stats.totalLaps}</td>
|
||||||
<td>${entry.stats.trainingDays}</td>
|
<td>${entry.stats.trainingDays}</td>
|
||||||
<td>${formatLap(entry.stats.bestLapMs)}</td>
|
<td>${formatLap(entry.stats.bestLapMs)}</td>
|
||||||
@@ -127,6 +160,10 @@ export function renderTrainingView(deps) {
|
|||||||
<span>${escapeHtml(t("training.driver"))}</span>
|
<span>${escapeHtml(t("training.driver"))}</span>
|
||||||
<select id="training-driver">${driverOptions}</select>
|
<select id="training-driver">${driverOptions}</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="training-driver-select">
|
||||||
|
<span>${escapeHtml(t("training.class"))}</span>
|
||||||
|
<select id="training-class">${classOptions}</select>
|
||||||
|
</label>
|
||||||
<div class="actions training-hero-actions">
|
<div class="actions training-hero-actions">
|
||||||
${profileShare ? `
|
${profileShare ? `
|
||||||
<button class="btn btn-primary" id="trainingHeroOpenProfile" type="button">${escapeHtml(t("training.start_personal"))}</button>
|
<button class="btn btn-primary" id="trainingHeroOpenProfile" type="button">${escapeHtml(t("training.start_personal"))}</button>
|
||||||
@@ -288,11 +325,16 @@ export function renderTrainingView(deps) {
|
|||||||
|
|
||||||
document.getElementById("training-driver")?.addEventListener("change", (event) => {
|
document.getElementById("training-driver")?.addEventListener("change", (event) => {
|
||||||
setSelectedDriverId(event.currentTarget.value);
|
setSelectedDriverId(event.currentTarget.value);
|
||||||
|
setSelectedClassId("");
|
||||||
|
renderView();
|
||||||
|
});
|
||||||
|
document.getElementById("training-class")?.addEventListener("change", (event) => {
|
||||||
|
setSelectedClassId(event.currentTarget.value);
|
||||||
renderView();
|
renderView();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("trainingHeroEnableProfile")?.addEventListener("click", () => {
|
document.getElementById("trainingHeroEnableProfile")?.addEventListener("click", () => {
|
||||||
replacePublicProfileShare(state, selectedDriverId);
|
replacePublicProfileShare(state, selectedProfileId, profileReferences);
|
||||||
saveState();
|
saveState();
|
||||||
renderView();
|
renderView();
|
||||||
});
|
});
|
||||||
@@ -316,7 +358,8 @@ export function renderTrainingView(deps) {
|
|||||||
const targetLaps = Math.max(1, Math.round(Number(form.get("targetLaps") || 0) || 0));
|
const targetLaps = Math.max(1, Math.round(Number(form.get("targetLaps") || 0) || 0));
|
||||||
state.training.goals.push({
|
state.training.goals.push({
|
||||||
id: uid("goal"),
|
id: uid("goal"),
|
||||||
driverId: selectedDriverId,
|
driverId: selectedProfileId,
|
||||||
|
classId: selectedClassId,
|
||||||
targetLaps,
|
targetLaps,
|
||||||
title: String(form.get("title") || "").trim(),
|
title: String(form.get("title") || "").trim(),
|
||||||
reward: String(form.get("reward") || "").trim(),
|
reward: String(form.get("reward") || "").trim(),
|
||||||
@@ -341,7 +384,7 @@ export function renderTrainingView(deps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("trainingPublicProfileEnable")?.addEventListener("click", () => {
|
document.getElementById("trainingPublicProfileEnable")?.addEventListener("click", () => {
|
||||||
replacePublicProfileShare(state, selectedDriverId);
|
replacePublicProfileShare(state, selectedProfileId, profileReferences);
|
||||||
saveState();
|
saveState();
|
||||||
renderView();
|
renderView();
|
||||||
});
|
});
|
||||||
@@ -353,18 +396,19 @@ export function renderTrainingView(deps) {
|
|||||||
});
|
});
|
||||||
document.getElementById("trainingPublicProfileRotate")?.addEventListener("click", () => {
|
document.getElementById("trainingPublicProfileRotate")?.addEventListener("click", () => {
|
||||||
if (!confirm(t("training.replace_link_confirm"))) return;
|
if (!confirm(t("training.replace_link_confirm"))) return;
|
||||||
replacePublicProfileShare(state, selectedDriverId);
|
replacePublicProfileShare(state, selectedProfileId, profileReferences);
|
||||||
saveState();
|
saveState();
|
||||||
renderView();
|
renderView();
|
||||||
});
|
});
|
||||||
document.getElementById("trainingPublicProfileDisable")?.addEventListener("click", () => {
|
document.getElementById("trainingPublicProfileDisable")?.addEventListener("click", () => {
|
||||||
state.training.shares = state.training.shares.map((share) => share.driverId === selectedDriverId ? { ...share, enabled: false } : share);
|
state.training.shares = state.training.shares.map((share) => profileReferences.has(String(share.driverId || "")) ? { ...share, enabled: false } : share);
|
||||||
saveState();
|
saveState();
|
||||||
renderView();
|
renderView();
|
||||||
});
|
});
|
||||||
document.querySelectorAll(".training-club-driver").forEach((button) => {
|
document.querySelectorAll(".training-club-driver").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
setSelectedDriverId(button.dataset.driverId || "");
|
setSelectedDriverId(button.dataset.driverId || "");
|
||||||
|
setSelectedClassId(button.dataset.classId || "");
|
||||||
renderView();
|
renderView();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -390,14 +434,14 @@ function generateShareToken() {
|
|||||||
return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
function replacePublicProfileShare(state, driverId) {
|
function replacePublicProfileShare(state, driverId, profileReferences = new Set([driverId])) {
|
||||||
const nextShare = {
|
const nextShare = {
|
||||||
driverId,
|
driverId,
|
||||||
token: generateShareToken(),
|
token: generateShareToken(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
state.training.shares = [...state.training.shares.filter((share) => share.driverId !== driverId), nextShare];
|
state.training.shares = [...state.training.shares.filter((share) => !profileReferences.has(String(share.driverId || ""))), nextShare];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyText(value) {
|
async function copyText(value) {
|
||||||
|
|||||||
@@ -20,12 +20,13 @@ const DISABLED_TOKEN = "disabled-token-123456789012345678901";
|
|||||||
function buildState(now = Date.now()) {
|
function buildState(now = Date.now()) {
|
||||||
return {
|
return {
|
||||||
drivers: [
|
drivers: [
|
||||||
{ id: "driver_1", name: "Driver One", brand: "Associated", classId: "class_1", transponder: "secret-transponder-one" },
|
{ id: "driver_1", profileId: "profile_1", name: "Driver One", brand: "Associated 2WD", classId: "class_1", transponder: "secret-transponder-one" },
|
||||||
{ id: "driver_2", name: "Driver Two", brand: "Xray", classId: "class_2", transponder: "secret-transponder-two" },
|
{ id: "driver_1_4wd", profileId: "profile_1", name: "Driver One", brand: "Associated 4WD", classId: "class_2", transponder: "secret-transponder-four" },
|
||||||
|
{ id: "driver_2", profileId: "profile_2", name: "Driver Two", brand: "Xray", classId: "class_1", transponder: "secret-transponder-two" },
|
||||||
],
|
],
|
||||||
classes: [
|
classes: [
|
||||||
{ id: "class_1", name: "Modified" },
|
{ id: "class_1", name: "2WD" },
|
||||||
{ id: "class_2", name: "Stock" },
|
{ id: "class_2", name: "4WD" },
|
||||||
],
|
],
|
||||||
settings: {
|
settings: {
|
||||||
clubName: "Test Club",
|
clubName: "Test Club",
|
||||||
@@ -49,6 +50,8 @@ function buildState(now = Date.now()) {
|
|||||||
{ driverId: "driver_1", sessionId: "free", timestamp: now - 12000, lapMs: 16500 },
|
{ driverId: "driver_1", sessionId: "free", timestamp: now - 12000, lapMs: 16500 },
|
||||||
{ driverId: "driver_1", sessionId: "free", timestamp: now - 4000, lapMs: 16000 },
|
{ driverId: "driver_1", sessionId: "free", timestamp: now - 4000, lapMs: 16000 },
|
||||||
{ driverId: "driver_1", sessionId: "free", timestamp: now - 2000, lapMs: 500 },
|
{ driverId: "driver_1", sessionId: "free", timestamp: now - 2000, lapMs: 500 },
|
||||||
|
{ driverId: "driver_1_4wd", sessionId: "free", timestamp: now - 11000, lapMs: 14500 },
|
||||||
|
{ driverId: "driver_1_4wd", sessionId: "free", timestamp: now - 5000, lapMs: 14000 },
|
||||||
{ driverId: "driver_2", sessionId: "free", timestamp: now - 3000, lapMs: 12000 },
|
{ driverId: "driver_2", sessionId: "free", timestamp: now - 3000, lapMs: 12000 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -68,8 +71,8 @@ test("public profile contains only the selected driver's sanitized aggregates",
|
|||||||
const profile = buildPublicTrainingProfile(buildState(now), DRIVER_ONE_TOKEN, "2026-08-24T12:00:00.000Z", now - 30000, 30000);
|
const profile = buildPublicTrainingProfile(buildState(now), DRIVER_ONE_TOKEN, "2026-08-24T12:00:00.000Z", now - 30000, 30000);
|
||||||
|
|
||||||
assert.equal(profile.profile.name, "Driver One");
|
assert.equal(profile.profile.name, "Driver One");
|
||||||
assert.equal(profile.profile.brand, "Associated");
|
assert.equal(profile.profile.brand, "Associated 2WD");
|
||||||
assert.equal(profile.profile.className, "Modified");
|
assert.equal(profile.profile.className, "2WD");
|
||||||
assert.equal(profile.profile.clubRank, 1);
|
assert.equal(profile.profile.clubRank, 1);
|
||||||
assert.equal(profile.profile.rankedDrivers, 2);
|
assert.equal(profile.profile.rankedDrivers, 2);
|
||||||
assert.equal(profile.branding.clubName, "Test Club");
|
assert.equal(profile.branding.clubName, "Test Club");
|
||||||
@@ -80,6 +83,11 @@ test("public profile contains only the selected driver's sanitized aggregates",
|
|||||||
assert.equal(profile.goals.length, 1);
|
assert.equal(profile.goals.length, 1);
|
||||||
assert.equal(profile.goals[0].title, "Bronze");
|
assert.equal(profile.goals[0].title, "Bronze");
|
||||||
assert.equal(profile.goals[0].percent, 80);
|
assert.equal(profile.goals[0].percent, 80);
|
||||||
|
assert.equal(profile.classProfiles.length, 2);
|
||||||
|
assert.equal(profile.classProfiles[0].stats.totalLaps, 4);
|
||||||
|
assert.equal(profile.classProfiles[1].className, "4WD");
|
||||||
|
assert.equal(profile.classProfiles[1].stats.totalLaps, 2);
|
||||||
|
assert.equal(profile.classProfiles[1].stats.bestLapMs, 14000);
|
||||||
|
|
||||||
const serialized = JSON.stringify(profile);
|
const serialized = JSON.stringify(profile);
|
||||||
assert.equal(serialized.includes("Driver Two"), false);
|
assert.equal(serialized.includes("Driver Two"), false);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
splitTrainingStints,
|
splitTrainingStints,
|
||||||
syncTrainingLedger,
|
syncTrainingLedger,
|
||||||
} from "../src/training_logic.js";
|
} from "../src/training_logic.js";
|
||||||
|
import { getDriverProfileGroups } from "../src/driver_profile_logic.js";
|
||||||
|
|
||||||
function createUid() {
|
function createUid() {
|
||||||
let index = 0;
|
let index = 0;
|
||||||
@@ -109,6 +110,72 @@ test("driver statistics remain isolated and aggregate days and sessions", () =>
|
|||||||
assert.equal(stats.dailySummaries[1].laps, 2);
|
assert.equal(stats.dailySummaries[1].laps, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("one driver profile combines transponders within a class but keeps 2WD and 4WD separate", () => {
|
||||||
|
const state = {
|
||||||
|
drivers: [
|
||||||
|
{ id: "driver_2wd_a", name: "Same Driver", profileId: "profile_same", classId: "class_2wd" },
|
||||||
|
{ id: "driver_2wd_b", name: "Same Driver", profileId: "profile_same", classId: "class_2wd" },
|
||||||
|
{ id: "driver_4wd", name: "Same Driver", profileId: "profile_same", classId: "class_4wd" },
|
||||||
|
],
|
||||||
|
training: {
|
||||||
|
ledger: [
|
||||||
|
{ driverId: "driver_2wd_a", sessionId: "practice", timestamp: 10_000, lapMs: 18_000 },
|
||||||
|
{ driverId: "driver_2wd_b", sessionId: "practice", timestamp: 30_000, lapMs: 17_500 },
|
||||||
|
{ driverId: "driver_4wd", sessionId: "practice", timestamp: 50_000, lapMs: 15_000 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const twoWheelDrive = buildDriverTrainingStats(state, "profile_same", "class_2wd");
|
||||||
|
const fourWheelDrive = buildDriverTrainingStats(state, "profile_same", "class_4wd");
|
||||||
|
|
||||||
|
assert.equal(twoWheelDrive.totalLaps, 2);
|
||||||
|
assert.equal(twoWheelDrive.bestLapMs, 17_500);
|
||||||
|
assert.equal(fourWheelDrive.totalLaps, 1);
|
||||||
|
assert.equal(fourWheelDrive.bestLapMs, 15_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("legacy same-name entries share a profile while an explicit profile can remain separate", () => {
|
||||||
|
const groups = getDriverProfileGroups({
|
||||||
|
drivers: [
|
||||||
|
{ id: "legacy_2wd", name: "Test Driver", classId: "2wd", club: "JMK RB", country: "Sverige" },
|
||||||
|
{ id: "legacy_4wd", name: " test driver ", classId: "4wd" },
|
||||||
|
{ id: "separate", name: "Test Driver", profileId: "profile_other", classId: "2wd" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(groups.length, 2);
|
||||||
|
assert.equal(groups.find((group) => group.id.startsWith("legacy:"))?.drivers.length, 2);
|
||||||
|
assert.equal(groups.find((group) => group.id.startsWith("legacy:"))?.club, "JMK RB");
|
||||||
|
assert.equal(groups.find((group) => group.id.startsWith("legacy:"))?.country, "Sverige");
|
||||||
|
assert.equal(groups.find((group) => group.id === "profile_other")?.drivers.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("active practice profile statistics include only linked entries in the selected class", () => {
|
||||||
|
const state = {
|
||||||
|
activeSessionId: "practice",
|
||||||
|
drivers: [
|
||||||
|
{ id: "two_a", name: "Driver", profileId: "profile", classId: "2wd" },
|
||||||
|
{ id: "two_b", name: "Driver", profileId: "profile", classId: "2wd" },
|
||||||
|
{ id: "four", name: "Driver", profileId: "profile", classId: "4wd" },
|
||||||
|
],
|
||||||
|
sessions: [{ id: "practice", name: "Open", type: "open_practice", status: "running", startedAt: 1_000 }],
|
||||||
|
resultsBySession: {
|
||||||
|
practice: {
|
||||||
|
passings: [
|
||||||
|
{ driverId: "two_a", timestamp: 20_000, lapMs: 18_000, validLap: true },
|
||||||
|
{ driverId: "two_b", timestamp: 39_000, lapMs: 17_500, validLap: true },
|
||||||
|
{ driverId: "four", timestamp: 55_000, lapMs: 15_000, validLap: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const stats = buildDriverActivePracticeStats(state, "profile", 60_000, "2wd");
|
||||||
|
assert.equal(stats.laps, 2);
|
||||||
|
assert.equal(stats.bestLapMs, 17_500);
|
||||||
|
});
|
||||||
|
|
||||||
test("active practice stats include only the selected driver's current valid laps", () => {
|
test("active practice stats include only the selected driver's current valid laps", () => {
|
||||||
const startedAt = 10_000;
|
const startedAt = 10_000;
|
||||||
const state = {
|
const state = {
|
||||||
|
|||||||
Reference in New Issue
Block a user