diff --git a/README.md b/README.md index f9ddc8a..7466169 100644 --- a/README.md +++ b/README.md @@ -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. +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 - 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 @@ -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 - responsive layout for opening the same server from a phone on the local network -Direct mobile/bookmark link: `http://:8081/?view=training`. Selecting a driver adds its ID to the URL so that driver's page can be bookmarked. +Direct mobile/bookmark link: `http://: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. @@ -208,7 +212,7 @@ Each registered driver can have an optional personal mobile 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. -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. diff --git a/README.sv.md b/README.sv.md index 168b959..7f55797 100644 --- a/README.sv.md +++ b/README.sv.md @@ -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. +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 - 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 @@ -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 - responsiv layout som kan öppnas från mobilen via samma server i det lokala nätet -Direktlänk för mobil/bokmärke: `http://:8081/?view=training`. När en förare väljs läggs förarens ID i länken så just den förarens sida kan sparas som bokmärke. +Direktlänk för mobil/bokmärke: `http://: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. @@ -197,7 +201,7 @@ Varje registrerad förare kan få en valfri personlig mobillänk: 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. -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. diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index c32b9df..d32182b 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -160,18 +160,19 @@ Runtime environment variables: ### `server/public_training_profile.js` Role: -- pure CommonJS backend helper for tokenized driver profiles +- pure CommonJS backend helper for tokenized personal training profiles Owns: - enabled share-token lookup -- selected-driver lap filtering and aggregate statistics -- sanitized branding, class, goal, rank, and recommendation response +- profile resolution across several race-specific driver records +- 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 - bounded current-session lap, best-lap, and average calculations Security boundary: - returns no driver IDs, transponders, raw passings, decoder settings, or other driver names -- disabled, short, unknown, or 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 ### `scripts/serverctl.js` @@ -406,6 +407,20 @@ Owns: - Drivers - Cars +### `src/driver_profile_logic.js` +Type: +- pure personal-profile identity helpers + +Owns: +- resolving an explicit `profileId` or a legacy normalized-name profile key +- grouping race-specific driver records that belong to one person +- listing all driver IDs and class IDs attached to one personal profile + +Imported by: +- `src/core_views.js` for profile assignment in the Drivers UI +- `src/training_logic.js` for class-scoped lap aggregation +- `src/training_views.js` for person and class selectors + ### `src/misc_views.js` Type: - misc page renderers @@ -431,9 +446,9 @@ Type: Owns: - training-state normalization - 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 -- 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` - 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 @@ -443,6 +458,10 @@ Owns: Rules: - only passings with a matching `driverId` are included +- `profileId` links multiple race-specific driver records to one person without changing decoder or race identity +- statistics combine linked driver records only when their `classId` matches the selected class +- 2WD and 4WD lap times, stints, best-three, five-minute results, and recommendations are never combined +- legacy driver records without `profileId` are grouped by normalized driver name until explicitly linked or separated in the Drivers UI - invalid laps and lap times at or below `500 ms` are excluded - 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 @@ -474,8 +493,8 @@ Mutations: Route behavior: - `/?view=training` opens the training view directly -- `driver=` selects a driver and supports mobile bookmarks -- an explicitly enabled driver receives `/public-training/` as a separate read-only profile +- `driver=` selects a personal profile and `class=` selects its performance class for bookmarks +- an explicitly enabled personal profile receives `/public-training/` as a separate read-only profile with a class selector - replacing or disabling the share invalidates its old route immediately after state sync ### `public-training.html` @@ -494,7 +513,8 @@ Type: Owns: - 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 - 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 @@ -503,7 +523,7 @@ Important isolation: - does not import `app.js` - does not call `/api/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 ### `src/public_training.css` @@ -538,6 +558,11 @@ Owns: - `src/training_logic.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_views.js` @@ -1339,7 +1364,10 @@ Produced/normalized by: classId: string, transponder: 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 - 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` ```js @@ -1548,6 +1584,7 @@ This bucket is the most dynamic part of state and is mutated heavily during live { id: string, driverId: string, + classId: string, // class whose lap total advances the goal title: string, reward: string, targetLaps: number, @@ -1568,8 +1605,8 @@ This bucket is the most dynamic part of state and is mutated heavily during live ``` Share rules: -- no share exists until an admin explicitly enables the selected driver profile -- only one normalized share entry is retained per driver +- no share exists until an admin explicitly enables the selected personal profile +- only one normalized share entry is retained per personal profile - disabled shares fail public route lookup - rotating a share replaces its token; the previous URL no longer resolves after persistence - tokens are generated in the browser with `crypto.getRandomValues()`, never from the shorter UI `uid()` helper @@ -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. +`TrainingLap.driverId` always records the exact race/decoder driver entry that produced the lap. At read time, `src/driver_profile_logic.js` resolves all driver IDs belonging to the selected personal profile, then `src/training_logic.js` narrows them to the selected `classId`. This permits several transponders in one class while preventing incomparable classes from being mixed. + Migration behavior: - older state without `training.ledger` normalizes to an empty ledger - the next `saveState()` imports valid registered-driver laps from all current session results @@ -1950,17 +1989,17 @@ Authorization: - missing, disabled, replaced, or unknown tokens return `404` rather than revealing whether a driver exists Sanitized response fields: -- selected driver display name, class, and brand -- numeric club rank without other driver names -- aggregate totals, best/average time, consistency, controlled streak, and daily summaries -- optional `currentSession` aggregates when a valid start age (or backward-compatible recent `since` timestamp) is supplied -- next automatic milestone -- recommendation level and target lap -- selected driver's goal titles, reward text, progress, and claimed state +- selected person's display name plus linked class names and brands +- one `classProfiles` entry per linked class; no cross-class performance aggregate is emitted +- class-specific numeric club rank without other driver names +- class-specific totals, best/average time, consistency, controlled streak, stints, best-three, five-minute result, and daily summaries +- optional class-specific `currentSession` aggregates when a valid start age (or backward-compatible recent `since` timestamp) is supplied +- class-specific next automatic milestone, recommendation level, target lap, goals, and reward progress - public club branding and safe PNG/JPEG/WebP logo data Explicitly excluded: - transponder IDs +- club and country profile metadata - driver IDs and names of other drivers - cars, events, sessions, assignments, and raw passings - race settings and decoder/AMMC configuration @@ -2088,16 +2127,16 @@ npm test - invalid, anonymous, and sub-500 ms lap exclusion - replacement of current-session ledger rows after corrections - 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 - 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 - best-three consecutive calculation and five-minute final-lap/average-pace behavior `tests/public_training_profile.test.js` covers: - 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 - clock-skew-safe query normalization and bounds diff --git a/server/public_training_profile.js b/server/public_training_profile.js index 382dc44..97d4ad0 100644 --- a/server/public_training_profile.js +++ b/server/public_training_profile.js @@ -8,55 +8,100 @@ function hasPublicTrainingShare(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) { const share = findPublicTrainingShare(state, shareToken); if (!share) return null; - const driverId = String(share.driverId || ""); + const profileReference = String(share.driverId || ""); const drivers = Array.isArray(state?.drivers) ? state.drivers : []; - const driver = drivers.find((item) => String(item?.id || "") === driverId); - if (!driver) return null; + const driverProfile = getPublicDriverProfile(drivers, profileReference); + if (!driverProfile?.drivers.length) return null; + const driver = driverProfile.drivers[0]; 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 driverLaps = ledger - .filter((entry) => String(entry?.driverId || "") === driverId && Number(entry?.lapMs || 0) > 500) - .map((entry) => ({ - timestamp: Math.max(0, Number(entry.timestamp || 0) || 0), - lapMs: Math.max(0, Math.round(Number(entry.lapMs || 0) || 0)), - sessionId: String(entry.sessionId || ""), - })) - .sort((left, right) => left.timestamp - right.timestamp); - const stats = buildPublicTrainingStats(driverLaps); - const goals = (Array.isArray(state?.training?.goals) ? state.training.goals : []) - .filter((goal) => String(goal?.driverId || "") === driverId) - .map((goal) => buildPublicTrainingGoal(goal, stats.totalLaps)); - const rankedTotals = getRankedTrainingTotals(ledger, new Set(drivers.map((item) => String(item?.id || "")).filter(Boolean))); - const rankIndex = rankedTotals.findIndex(([id]) => id === driverId); + const profileReferences = new Set([driverProfile.id, ...driverProfile.drivers.map((item) => String(item.id || ""))]); + const profileClassIds = [...new Set(driverProfile.drivers.map((item) => String(item.classId || "")).filter(Boolean))]; + const classProfiles = profileClassIds.map((classId) => { + const classDrivers = driverProfile.drivers.filter((item) => String(item.classId || "") === classId); + const classDriverIds = new Set(classDrivers.map((item) => String(item.id || ""))); + const classLaps = ledger + .filter((entry) => classDriverIds.has(String(entry?.driverId || "")) && Number(entry?.lapMs || 0) > 500) + .map((entry) => ({ + timestamp: Math.max(0, Number(entry.timestamp || 0) || 0), + lapMs: Math.max(0, Math.round(Number(entry.lapMs || 0) || 0)), + sessionId: String(entry.sessionId || ""), + })) + .sort((left, right) => left.timestamp - right.timestamp); + 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 currentSession = sinceTimestamp - ? buildPublicTrainingWindowStats(driverLaps.filter((lap) => lap.timestamp >= sinceTimestamp && (!windowMs || lap.timestamp <= sinceTimestamp + windowMs))) - : null; return { updatedAt: String(updatedAt || ""), profile: { name: String(driver.name || "Driver").trim(), - brand: String(driver.brand || "").trim(), - className: String(driverClass?.name || "").trim(), - clubRank: rankIndex >= 0 ? rankIndex + 1 : 0, - rankedDrivers: rankedTotals.length, + brand: activeClass.brand, + className: activeClass.className, + clubRank: activeClass.clubRank, + rankedDrivers: activeClass.rankedDrivers, }, branding: { clubName: String(settings.clubName || "JMK RB RaceController").trim(), clubTagline: String(settings.clubTagline || "RC Timing System").trim(), logoDataUrl: normalizePublicLogoDataUrl(settings.logoDataUrl), }, - stats, - nextMilestone: getPublicTrainingMilestone(stats.totalLaps), - recommendation: getPublicTrainingRecommendation(stats), - goals, - currentSession, + classProfiles, + stats: activeClass.stats, + nextMilestone: activeClass.nextMilestone, + recommendation: activeClass.recommendation, + 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(); ledger.forEach((entry) => { const driverId = String(entry?.driverId || ""); - if (!driverId || !registeredDriverIds.has(driverId) || Number(entry?.lapMs || 0) <= 500) return; - totals.set(driverId, (totals.get(driverId) || 0) + 1); + const profileId = profileByDriverId.get(driverId); + 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]); } diff --git a/src/app.js b/src/app.js index 7f3ba42..0adbfdb 100644 --- a/src/app.js +++ b/src/app.js @@ -205,12 +205,12 @@ const syncTrainingLedger = () => { state.training = syncTrainingLedgerLogic(state.training, state, { uid }); return state.training; }; -const buildDriverTrainingStats = (trainingState, driverId) => buildDriverTrainingStatsLogic(trainingState, driverId); -const buildDriverActivePracticeStats = (trainingState, driverId, nowTs = Date.now()) => buildDriverActivePracticeStatsLogic(trainingState, driverId, nowTs); +const buildDriverTrainingStats = (trainingState, driverId, classId = "") => buildDriverTrainingStatsLogic(trainingState, driverId, classId); +const buildDriverActivePracticeStats = (trainingState, driverId, nowTs = Date.now(), classId = "") => buildDriverActivePracticeStatsLogic(trainingState, driverId, nowTs, classId); const buildTrainingRecommendation = (stats) => buildTrainingRecommendationLogic(stats); const getTrainingGoalProgress = (goal, stats) => getTrainingGoalProgressLogic(goal, 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 recalculateCompetitorFromPassings = (session, rowKey) => recalculateCompetitorFromPassingsLogic(session, rowKey, { ensureSessionResult, getCompetitorPassings, isCountedPassing }); 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.intro": "Följ träning över flera sessioner och gör utvecklingen synlig mellan tävlingarna.", "training.driver": "Förare", + "training.class": "Klass", "training.no_drivers_title": "Ingen förare registrerad", "training.no_drivers_body": "Skapa en förare först. Träningsstatistik kräver en registrerad förare på passeringarna.", "training.total_laps": "Totala varv", @@ -430,11 +431,20 @@ const TRANSLATIONS = { "drivers.create": "Skapa förare", "drivers.name_placeholder": "Förarnamn", "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.speech_name": "Uttalsnamn", "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.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.title": "Förare", "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_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_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_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.", @@ -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_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_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.subtitle": "Extern leaderboard-skärm", "overlay.no_active": "Ingen aktiv session vald.", @@ -1259,6 +1270,7 @@ const TRANSLATIONS = { "training.eyebrow": "Driver development", "training.intro": "Track practice across sessions and make progress visible between race days.", "training.driver": "Driver", + "training.class": "Class", "training.no_drivers_title": "No driver registered", "training.no_drivers_body": "Create a driver first. Training statistics require passings linked to a registered driver.", "training.total_laps": "Total laps", @@ -1398,11 +1410,20 @@ const TRANSLATIONS = { "drivers.create": "Create Driver", "drivers.name_placeholder": "Driver name", "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.speech_name": "Spoken name", "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.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.title": "Drivers", "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_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_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_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.", @@ -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_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_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.subtitle": "External leaderboard screen", "overlay.no_active": "No active session selected.", @@ -2219,6 +2241,7 @@ let selectedSessionEditId = null; let selectedTeamEditId = null; let selectedJudgeKey = null; let selectedTrainingDriverId = String(urlParams.get("driver") || ""); +let selectedTrainingClassId = String(urlParams.get("class") || ""); let judgingCompetitorFilter = "all"; let judgingLogFilter = "all"; let quickAddDraft = null; @@ -2286,6 +2309,9 @@ function normalizeDriver(driver = {}) { transponder: String(driver?.transponder || "").trim(), brand: String(driver?.brand || "").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() { return [ - ["id", "name", "class", "brand", "transponder", "spoken_name"], - ...state.drivers.map((driver) => [driver.id, driver.name, getClassName(driver.classId), driver.brand || "", driver.transponder || "", driver.speechName || ""]), + ["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 || "", driver.club || "", driver.country || "", driver.profileId || ""]), ]; } @@ -3029,13 +3055,15 @@ function renderNav() { }); } -function updateViewUrl(view, driverId = "") { +function updateViewUrl(view, driverId = "", classId = "") { if (overlayMode) return; const nextUrl = new URL(window.location.href); if (view && view !== "dashboard") nextUrl.searchParams.set("view", view); else nextUrl.searchParams.delete("view"); if (view === "training" && driverId) nextUrl.searchParams.set("driver", driverId); else nextUrl.searchParams.delete("driver"); + if (view === "training" && classId) nextUrl.searchParams.set("class", classId); + else nextUrl.searchParams.delete("class"); window.history.replaceState({}, "", nextUrl); } diff --git a/src/core_views.js b/src/core_views.js index ff2789e..cd01d52 100644 --- a/src/core_views.js +++ b/src/core_views.js @@ -1,3 +1,5 @@ +import { getDriverProfileGroups, getDriverProfileId } from "./driver_profile_logic.js"; + function getScheduleDriftSummary(state) { const scheduledSessions = state.sessions .filter((session) => Number(session.startedAt || 0) > 0) @@ -373,9 +375,24 @@ export function renderDriversView(deps) { const classOptions = state.classes.map((c) => ``).join(""); const driverSearch = driverBrandFilter().trim().toLowerCase(); 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 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 ``; + }) + .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 = `
@@ -386,6 +403,15 @@ export function renderDriversView(deps) { + + + + ${existingClubs.map((club) => ``).join("")} + ${existingCountries.map((country) => ``).join("")}
@@ -397,21 +423,27 @@ export function renderDriversView(deps) {
${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( - (d) => ` + (d) => { + const profile = profileGroups.find((item) => item.id === getDriverProfileId(d)); + return ` ${escapeHtml(d.name)} ${escapeHtml(getClassName(d.classId))} + ${escapeHtml(profile?.club || d.club || "-")} + ${escapeHtml(profile?.country || d.country || "-")} ${escapeHtml(d.brand || "-")} ${escapeHtml(d.transponder || "-")} + ${escapeHtml(profile?.representative?.name || d.name)}${profile?.drivers.length > 1 ? ` · ${profile.drivers.length}` : ""} ${escapeHtml(d.speechName || d.name)} - ` + `; + } ) )}
@@ -436,6 +468,16 @@ export function renderDriversView(deps) { + + +
@@ -452,14 +494,29 @@ export function renderDriversView(deps) { document.getElementById("driverForm")?.addEventListener("submit", (e) => { e.preventDefault(); 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( normalizeDriver({ - id: uid("driver"), - name: String(form.get("name")).trim(), + id: driverId, + name: driverName, classId: String(form.get("classId")), brand: String(form.get("brand") || "").trim(), transponder: String(form.get("transponder") || "").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(); @@ -475,6 +532,21 @@ export function 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) => { document.getElementById(`driver-edit-${d.id}`)?.addEventListener("click", () => { setSelectedDriverEditId(d.id); @@ -527,6 +599,10 @@ export function renderDriversView(deps) { const cleanedBrand = String(form.get("brand") || "").trim(); const cleanedTp = String(form.get("transponder") || "").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) { setFormError("driverEditError", t("validation.required_name")); return; @@ -536,18 +612,25 @@ export function renderDriversView(deps) { return; } setFormError("driverEditError", ""); - state.drivers = state.drivers.map((item) => - item.id === editingDriver.id - ? normalizeDriver({ + state.drivers = state.drivers.map((item) => { + if (item.id === editingDriver.id) { + return normalizeDriver({ ...item, name: cleanedName, classId: cleanedClassId || item.classId, brand: cleanedBrand, transponder: cleanedTp, speechName: cleanedSpeechName, - }) - : item - ); + profileId: cleanedProfileId, + club: cleanedClub, + country: cleanedCountry, + }); + } + if (getDriverProfileId(item) === cleanedProfileId) { + return normalizeDriver({ ...item, club: cleanedClub, country: cleanedCountry }); + } + return item; + }); setSelectedDriverEditId(null); saveState(); renderView(); diff --git a/src/driver_profile_logic.js b/src/driver_profile_logic.js new file mode 100644 index 0000000..d58530a --- /dev/null +++ b/src/driver_profile_logic.js @@ -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; +} diff --git a/src/misc_views.js b/src/misc_views.js index d005714..1c6d072 100644 --- a/src/misc_views.js +++ b/src/misc_views.js @@ -64,7 +64,7 @@ export function renderGuideView({ dom, 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 })}
diff --git a/src/public_training.css b/src/public_training.css index 802926d..9543e3c 100644 --- a/src/public_training.css +++ b/src/public_training.css @@ -165,6 +165,24 @@ button { 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 { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); diff --git a/src/public_training.js b/src/public_training.js index 5fa032c..9290dc7 100644 --- a/src/public_training.js +++ b/src/public_training.js @@ -6,13 +6,14 @@ const language = requestedLanguage === "en" || requestedLanguage === "sv" : String(navigator.language || "sv").toLowerCase().startsWith("sv") ? "sv" : "en"; const TIMER_DURATION_MS = 20 * 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 = { sv: { loading: "Laddar träningsprofil...", loadError: "Kunde inte läsa träningsprofilen. Länken kan vara avstängd eller ersatt.", development: "Förarutveckling", + class: "Klass", rank: "Klubbrank {rank} av {count}", laps: "Totala varv", days: "Träningsdagar", @@ -62,6 +63,7 @@ const messages = { loading: "Loading training profile...", loadError: "The training profile could not be loaded. The link may be disabled or replaced.", development: "Driver development", + class: "Class", rank: "Club rank {rank} of {count}", laps: "Total laps", days: "Practice days", @@ -160,9 +162,17 @@ function recommendationCopy(recommendation) { function renderProfile(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 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 || "") ? `` : `
RC
`; @@ -189,6 +199,9 @@ function renderProfile(data) { `).join("") : `-`; + const classOptions = classProfiles.map((item) => ( + `` + )).join(""); document.documentElement.lang = language; document.title = `${profile.name} - ${branding.clubName}`; @@ -205,8 +218,8 @@ function renderProfile(data) { ${escapeHtml(t("development"))}

${escapeHtml(profile.name)}

- ${profile.className ? `${escapeHtml(profile.className)}` : ""} - ${profile.brand ? `${escapeHtml(profile.brand)}` : ""} + ${classProfiles.length > 1 ? `` : activeClass?.className ? `${escapeHtml(activeClass.className)}` : ""} + ${activeClass?.brand ? `${escapeHtml(activeClass.brand)}` : ""} ${rank ? `${escapeHtml(rank)}` : ""}
@@ -282,6 +295,15 @@ function renderProfile(data) {
`; + 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(); updateTimerDisplay(); } @@ -292,7 +314,7 @@ function renderStat(label, value) { function readTimerState() { 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; } catch { return null; @@ -300,8 +322,12 @@ function readTimerState() { } function writeTimerState(value) { - if (value) localStorage.setItem(TIMER_STORAGE_KEY, JSON.stringify(value)); - else localStorage.removeItem(TIMER_STORAGE_KEY); + if (value) localStorage.setItem(getTimerStorageKey(), JSON.stringify(value)); + else localStorage.removeItem(getTimerStorageKey()); +} + +function getTimerStorageKey() { + return `rc-training-timer:${shareToken}:${selectedClassId || "default"}`; } function getTimerElapsed(state, now = Date.now()) { diff --git a/src/training_logic.js b/src/training_logic.js index 0416424..ed5621b 100644 --- a/src/training_logic.js +++ b/src/training_logic.js @@ -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 }) { const source = training && typeof training === "object" ? training : {}; const goals = Array.isArray(source.goals) @@ -5,6 +17,7 @@ export function normalizeTrainingState(training, { uid }) { .map((goal) => ({ id: String(goal?.id || uid("goal")), driverId: String(goal?.driverId || ""), + classId: String(goal?.classId || ""), title: String(goal?.title || "").trim(), reward: String(goal?.reward || "").trim(), 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 }); } -export function getDriverTrainingLaps(state, driverId) { +export function getDriverTrainingLaps(state, driverId, classId = "") { const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : []; + const profileReferences = getTrainingDriverIds(state, driverId, classId); 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) => ({ sessionId: String(entry.sessionId || ""), sessionName: String(entry.sessionName || ""), @@ -249,8 +263,8 @@ export function getBestTrainingDurationResult(stints, targetMs = 5 * 60 * 1000) return best; } -export function buildDriverTrainingStats(state, driverId) { - const laps = getDriverTrainingLaps(state, driverId); +export function buildDriverTrainingStats(state, driverId, classId = "") { + const laps = getDriverTrainingLaps(state, driverId, classId); const lapTimes = laps.map((lap) => lap.lapMs); const recentLapTimes = lapTimes.slice(-30); 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 : []) .find((item) => String(item?.id || "") === String(state?.activeSessionId || "")); 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 startedAt = Math.max(0, Number(session.startedAt || 0) || 0); + const profileReferences = getTrainingDriverIds(state, driverId, classId); const laps = (Array.isArray(result?.passings) ? result.passings : []) .filter((passing) => ( - String(passing?.driverId || "") === String(driverId || "") + profileReferences.has(String(passing?.driverId || "")) && passing?.validLap !== false && Number(passing?.lapMs || 0) > 500 && (!startedAt || Number(passing?.timestamp || 0) >= startedAt) diff --git a/src/training_views.js b/src/training_views.js index e8f0392..38fb56d 100644 --- a/src/training_views.js +++ b/src/training_views.js @@ -1,3 +1,5 @@ +import { getDriverProfileGroups, getDriverProfileReferences } from "./driver_profile_logic.js"; + export function renderTrainingView(deps) { const { state, @@ -18,18 +20,26 @@ export function renderTrainingView(deps) { getNextTrainingMilestone, getSelectedDriverId, setSelectedDriverId, + getSelectedClassId, + setSelectedClassId, } = deps; state.training = normalizeTrainingState(state.training); syncTrainingLedger(); - const drivers = [...state.drivers].sort((left, right) => String(left.name).localeCompare(String(right.name))); - let selectedDriverId = String(getSelectedDriverId() || ""); - if (!drivers.some((driver) => driver.id === selectedDriverId)) { - selectedDriverId = drivers[0]?.id || ""; - setSelectedDriverId(selectedDriverId); + const profiles = getDriverProfileGroups(state) + .sort((left, right) => String(left.representative?.name || "").localeCompare(String(right.representative?.name || ""))); + let selectedProfileId = String(getSelectedDriverId() || ""); + let selectedProfile = profiles.find((profile) => profile.id === selectedProfileId || profile.driverIds.includes(selectedProfileId)); + 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 = `

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

@@ -39,16 +49,38 @@ export function renderTrainingView(deps) { return; } - const selectedDriver = drivers.find((driver) => driver.id === selectedDriverId); - const stats = buildDriverTrainingStats(state, selectedDriverId); - const activePractice = buildDriverActivePracticeStats(state, selectedDriverId); + let selectedClassId = String(getSelectedClassId() || ""); + if (!selectedProfile.classIds.includes(selectedClassId)) { + 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 nextMilestone = getNextTrainingMilestone(stats); - const goals = state.training.goals.filter((goal) => goal.driverId === selectedDriverId); - const profileShare = (state.training.shares || []).find((share) => share.driverId === selectedDriverId && share.enabled); + const goals = state.training.goals.filter((goal) => ( + 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 driverOptions = drivers - .map((driver) => ``) + const driverOptions = profiles + .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 ``; + }) + .join(""); + const classOptions = selectedProfile.classIds + .map((classId) => { + const className = state.classes.find((item) => item.id === classId)?.name || classId; + return ``; + }) .join(""); const goalMarkup = goals.length @@ -98,15 +130,16 @@ export function renderTrainingView(deps) { `).join("") : `${escapeHtml(t("training.no_history"))}`; - const clubRows = drivers - .map((driver) => ({ driver, stats: driver.id === selectedDriverId ? stats : buildDriverTrainingStats(state, driver.id) })) - .sort((left, right) => right.stats.totalLaps - left.stats.totalLaps || String(left.driver.name).localeCompare(String(right.driver.name))) + const clubRows = profiles + .filter((profile) => !selectedClassId || profile.classIds.includes(selectedClassId)) + .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) => { const milestone = getNextTrainingMilestone(entry.stats); return ` - + ${index + 1} - + ${entry.stats.totalLaps} ${entry.stats.trainingDays} ${formatLap(entry.stats.bestLapMs)} @@ -127,6 +160,10 @@ export function renderTrainingView(deps) { ${escapeHtml(t("training.driver"))} +
${profileShare ? ` @@ -288,11 +325,16 @@ export function renderTrainingView(deps) { document.getElementById("training-driver")?.addEventListener("change", (event) => { setSelectedDriverId(event.currentTarget.value); + setSelectedClassId(""); + renderView(); + }); + document.getElementById("training-class")?.addEventListener("change", (event) => { + setSelectedClassId(event.currentTarget.value); renderView(); }); document.getElementById("trainingHeroEnableProfile")?.addEventListener("click", () => { - replacePublicProfileShare(state, selectedDriverId); + replacePublicProfileShare(state, selectedProfileId, profileReferences); saveState(); renderView(); }); @@ -316,7 +358,8 @@ export function renderTrainingView(deps) { const targetLaps = Math.max(1, Math.round(Number(form.get("targetLaps") || 0) || 0)); state.training.goals.push({ id: uid("goal"), - driverId: selectedDriverId, + driverId: selectedProfileId, + classId: selectedClassId, targetLaps, title: String(form.get("title") || "").trim(), reward: String(form.get("reward") || "").trim(), @@ -341,7 +384,7 @@ export function renderTrainingView(deps) { }); document.getElementById("trainingPublicProfileEnable")?.addEventListener("click", () => { - replacePublicProfileShare(state, selectedDriverId); + replacePublicProfileShare(state, selectedProfileId, profileReferences); saveState(); renderView(); }); @@ -353,18 +396,19 @@ export function renderTrainingView(deps) { }); document.getElementById("trainingPublicProfileRotate")?.addEventListener("click", () => { if (!confirm(t("training.replace_link_confirm"))) return; - replacePublicProfileShare(state, selectedDriverId); + replacePublicProfileShare(state, selectedProfileId, profileReferences); saveState(); renderView(); }); 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(); renderView(); }); document.querySelectorAll(".training-club-driver").forEach((button) => { button.addEventListener("click", () => { setSelectedDriverId(button.dataset.driverId || ""); + setSelectedClassId(button.dataset.classId || ""); renderView(); }); }); @@ -390,14 +434,14 @@ function generateShareToken() { return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); } -function replacePublicProfileShare(state, driverId) { +function replacePublicProfileShare(state, driverId, profileReferences = new Set([driverId])) { const nextShare = { driverId, token: generateShareToken(), enabled: true, 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) { diff --git a/tests/public_training_profile.test.js b/tests/public_training_profile.test.js index e8a0040..cd7c658 100644 --- a/tests/public_training_profile.test.js +++ b/tests/public_training_profile.test.js @@ -20,12 +20,13 @@ const DISABLED_TOKEN = "disabled-token-123456789012345678901"; function buildState(now = Date.now()) { return { drivers: [ - { id: "driver_1", name: "Driver One", brand: "Associated", 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", profileId: "profile_1", name: "Driver One", brand: "Associated 2WD", classId: "class_1", transponder: "secret-transponder-one" }, + { 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: [ - { id: "class_1", name: "Modified" }, - { id: "class_2", name: "Stock" }, + { id: "class_1", name: "2WD" }, + { id: "class_2", name: "4WD" }, ], settings: { 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 - 4000, lapMs: 16000 }, { 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 }, ], }, @@ -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); assert.equal(profile.profile.name, "Driver One"); - assert.equal(profile.profile.brand, "Associated"); - assert.equal(profile.profile.className, "Modified"); + assert.equal(profile.profile.brand, "Associated 2WD"); + assert.equal(profile.profile.className, "2WD"); assert.equal(profile.profile.clubRank, 1); assert.equal(profile.profile.rankedDrivers, 2); 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[0].title, "Bronze"); 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); assert.equal(serialized.includes("Driver Two"), false); diff --git a/tests/training_logic.test.js b/tests/training_logic.test.js index 12a7a87..a0c6adb 100644 --- a/tests/training_logic.test.js +++ b/tests/training_logic.test.js @@ -14,6 +14,7 @@ import { splitTrainingStints, syncTrainingLedger, } from "../src/training_logic.js"; +import { getDriverProfileGroups } from "../src/driver_profile_logic.js"; function createUid() { let index = 0; @@ -109,6 +110,72 @@ test("driver statistics remain isolated and aggregate days and sessions", () => 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", () => { const startedAt = 10_000; const state = {