From af79b610dbde13a518c1efdfde258e025e6e6687 Mon Sep 17 00:00:00 2001 From: larssand Date: Sat, 10 Oct 2026 20:08:17 +0200 Subject: [PATCH] feat(training): base training stats on practice, keep race laps separate Training stats count Open/Free practice by default; laps from qualifying, heats and finals are kept and shown under Based on: Races or All, marked with race, session and series. Races are listed per race session and never split into stints. Same picker in the mobile profile. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 +++ ROADMAP.md | 24 +++++++ package-lock.json | 4 +- package.json | 2 +- server.js | 2 +- server/public_training_profile.js | 18 ++++- src/app.js | 20 +++++- src/public_training.js | 28 ++++++++ src/styles.css | 3 + src/training_logic.js | 90 +++++++++++++++++++++---- src/training_views.js | 94 ++++++++++++++++++++------- tests/browser_smoke.js | 2 + tests/browser_training_source.js | 60 +++++++++++++++++ tests/public_training_profile.test.js | 17 +++++ tests/training_logic.test.js | 45 +++++++++++++ 15 files changed, 377 insertions(+), 42 deletions(-) create mode 100644 tests/browser_training_source.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e6bce0d..6a8b3f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.35.0 - 2026-10-10 + +### Changed + +- Training stats are now about practice. Laps from qualifying, heats, finals and team races are still saved, but they only count when **Based on: Races** or **All** is chosen. The choice is next to the layout picker, on Training stats and in the mobile profile. + - Race laps are marked 🏁 with race, session and series, e.g. "🏁 Winter #3 2WD · Kval 2 · Winter Cup". + - With **Races** chosen, the stint table is replaced by one row per race session: laps, best, average and consistency. A race is one fixed-time run, so race laps never form stints, even under **All**. + - Goals, rewards, achievements and club progress count practice only. + - Earlier saved laps get their session type from their session; laps from deleted sessions count as practice, so nothing disappears. + ## 1.34.0 - 2026-10-10 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index c49cac3..2610f63 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -807,6 +807,30 @@ Verified 2026-10-10: Not done yet: the rule that a disqualification from a whole round gives a 0 that may not be dropped (ÖRC draft p. 6). Also no public web page for the standings, only print/PDF. +## Training stats: practice by default, race laps separate + +Decided with the user 2026-10-10: +- Training stats (per layout) are about practice, but every lap from every session was blended in, with no way to tell a final from Open Practice. +- Race laps are kept but separate, and marked with the race they came from. +- Races never form stints: a race is one fixed-time run (5 min in 1/10 buggy, 45-60 min in nitro). + +- [x] Ledger laps carry `sessionType` (and keep `eventId`). Older laps get it from their session on the next sync; unknown counts as practice. +- [x] `getDriverTrainingLaps(..., { source })`: `practice` (default), `race` or `all`. It is threaded through `getDriverTrainingLapsForLayout`, `buildDriverTrainingStats` and `buildTrainingStintAnalysis`. Every other caller (goals, rewards, achievements, club progress) gets practice only. +- [x] Stints (`splitTrainingStints`) only from practice laps, also under `all`. New `buildTrainingRaceSessions`: one row per race session with laps, best, average and spread. +- [x] UI: + - "Based on" picker next to the layout picker on the driver page and the club overview. + - Daily history lists 🏁 race · session for days with race laps. + - Under Races, the race-session table ("🏁 race · session · series", series looked up from `state.series`) replaces stints. + - The mobile profile (`?source=`) gets the same picker; the server's `buildPublicTrainingProfile` filters the same way. +- [x] Guide `training_2` (sv/en) updated. + +Verified 2026-10-10: +- 261 unit tests pass. New tests cover: sessionType tagging and back-fill, practice by default, race/all counts, a race lap not becoming the training best, stints practice-only under "all" and none under "race", the race-session summary, and the public profile filter (default, race, all, invalid value). +- `tests/browser_training_source.js` picks a driver with practice and qualifying laps and checks that practice shows stints and no race marks, and that Races shows the race-session table with "🏁 TS Winter #3 TS 2WD · Kval 2 · TS Winter Cup", 3 laps and no stint table. +- 66 browser scenarios pass. + +Noticed while testing, not fixed: opening Training stats with `?driver=` in the URL can lose the driver if the page renders before the backend data has loaded. The selection is reset because the driver isn't known yet. Choosing the driver on the page works. + ## Documentation maintenance - [x] Update the architecture's module/dependency inventory as modules change. diff --git a/package-lock.json b/package-lock.json index d213707..0e1ff8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.34.0", + "version": "1.35.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.34.0", + "version": "1.35.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index 60b6910..27fea9d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.34.0", + "version": "1.35.0", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/server.js b/server.js index 04e33a3..ad7b05b 100644 --- a/server.js +++ b/server.js @@ -599,7 +599,7 @@ app.get("/public-training/api/:shareToken", (req, res) => { // neither independently of the general practice timer above, each started at its own time. const evenLapsSince = publicTrainingProfile.normalizePublicTrainingSince(req.query.evenLapsSince, req.query.evenLapsStartedAgoMs); const stepTrainingSince = publicTrainingProfile.normalizePublicTrainingSince(req.query.stepTrainingSince, req.query.stepTrainingStartedAgoMs); - const profile = stored ? publicTrainingProfile.buildPublicTrainingProfile(stored.state, String(req.params.shareToken || ""), stored.updatedAt, sinceTimestamp, windowMs, evenLapsSince, stepTrainingSince, String(req.query.layout || "current")) : null; + const profile = stored ? publicTrainingProfile.buildPublicTrainingProfile(stored.state, String(req.params.shareToken || ""), stored.updatedAt, sinceTimestamp, windowMs, evenLapsSince, stepTrainingSince, String(req.query.layout || "current"), String(req.query.source || "practice")) : null; if (!profile) { res.status(404).json({ error: "Profile not found" }); return; diff --git a/server/public_training_profile.js b/server/public_training_profile.js index 2c5d629..c7a8573 100644 --- a/server/public_training_profile.js +++ b/server/public_training_profile.js @@ -87,7 +87,14 @@ function normalizePublicTrainingLayout(value, state) { return layouts.some((item) => String(item?.id || "") === layout) ? layout : "current"; } -function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp = 0, windowMs = 0, evenLapsSince = 0, stepTrainingSince = 0, layout = "current") { +// Mirrors getTrainingLapSource in src/training_logic.js: race laps (qualifying, heats, +// finals, team races) only count when "race" or "all" is chosen; unknown = practice. +const PUBLIC_RACE_SESSION_TYPES = new Set(["qualification", "heat", "final", "team_race"]); +function normalizePublicTrainingSource(value) { + return ["practice", "race", "all"].includes(String(value)) ? String(value) : "practice"; +} + +function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp = 0, windowMs = 0, evenLapsSince = 0, stepTrainingSince = 0, layout = "current", source = "practice") { const share = findPublicTrainingShare(state, shareToken); if (!share) return null; const visibility = normalizePublicTrainingVisibility(share.visibility); @@ -98,7 +105,13 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp const driver = driverProfile.drivers[0]; const classes = Array.isArray(state?.classes) ? state.classes : []; - const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : []; + const selectedSource = normalizePublicTrainingSource(source); + const sessionTypes = new Map((Array.isArray(state?.sessions) ? state.sessions : []).map((session) => [String(session?.id || ""), String(session?.type || "")])); + const ledger = (Array.isArray(state?.training?.ledger) ? state.training.ledger : []).filter((entry) => { + if (selectedSource === "all") return true; + const isRace = PUBLIC_RACE_SESSION_TYPES.has(String(entry?.sessionType || sessionTypes.get(String(entry?.sessionId || "")) || "")); + return selectedSource === "race" ? isRace : !isRace; + }); const currentLayoutId = getCurrentTrackLayoutId(state); const selectedLayout = normalizePublicTrainingLayout(layout, state); const layoutFilterId = selectedLayout === "current" ? currentLayoutId : selectedLayout === "none" ? "" : selectedLayout; @@ -188,6 +201,7 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp logoDataUrl: normalizePublicLogoDataUrl(settings.logoDataUrl), }, classProfiles, + lapSource: selectedSource, trackLayouts: { selected: selectedLayout, currentId: currentLayoutId, diff --git a/src/app.js b/src/app.js index 7855467..70c15e4 100644 --- a/src/app.js +++ b/src/app.js @@ -1389,6 +1389,14 @@ const TRANSLATIONS = { "layouts.bar_none": "Ingen banlayout satt Ă€nnu", "layouts.new": "Ny layout", "layouts.min_lap": "Minsta varvtid (s)", + "training.source": "Underlag", + "training.race_sessions": "Racepass 🏁", + "training.race_sessions_hint": "Ett race körs pĂ„ fast tid och delas inte upp i stints. HĂ€r visas varje kval, heat och final för sig.", + "training.race_session": "Race · pass · serie", + "training.no_race_sessions": "Inga racepass Ă€nnu.", + "training.source_practice": "TrĂ€ning", + "training.source_race": "Race (kval, heat, final) 🏁", + "training.source_all": "Alla", "layouts.new_min_lap_prompt": "Minsta varvtid i sekunder för den nya layouten (tomt = som tĂ€vlingen, kan Ă€ndras under InstĂ€llningar):", "layouts.max_lap": "LĂ€ngsta varvtid (s)", "layouts.lap_from_event": "som tĂ€vlingen", @@ -2210,7 +2218,7 @@ const TRANSLATIONS = { "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 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 eller Open Practice. Giltiga varv kopieras automatiskt till en bestĂ€ndig trĂ€ningslogg; korta/lĂ„nga ogiltiga varv rĂ€knas inte. Varv frĂ„n kval, heat och final sparas ocksĂ„ men rĂ€knas bara nĂ€r du vĂ€ljer Underlag: Race eller Alla - de mĂ€rks 🏁 med tĂ€vling, pass och serie, och visas per racepass i stĂ€llet för stints.", "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_5": "5. NĂ€r mĂ„let nĂ„tts kan belöningen markeras som utdelad. DirektlĂ€nken `/?view=training&driver=` kan bokmĂ€rkas pĂ„ förarens mobil.", @@ -3103,6 +3111,14 @@ const TRANSLATIONS = { "layouts.bar_none": "No track layout set yet", "layouts.new": "New layout", "layouts.min_lap": "Min lap time (s)", + "training.source": "Based on", + "training.race_sessions": "Race sessions 🏁", + "training.race_sessions_hint": "A race runs for a fixed time and isn't split into stints. Each qualifying heat, heat and final is shown on its own.", + "training.race_session": "Race · session · series", + "training.no_race_sessions": "No race sessions yet.", + "training.source_practice": "Practice", + "training.source_race": "Races (qualifying, heats, finals) 🏁", + "training.source_all": "All", "layouts.new_min_lap_prompt": "Minimum lap time in seconds for the new layout (empty = from the event, can be changed in Settings):", "layouts.max_lap": "Max lap time (s)", "layouts.lap_from_event": "from the event", @@ -3924,7 +3940,7 @@ const TRANSLATIONS = { "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 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 or Open Practice. Valid laps are copied automatically to a durable training ledger; invalid short or long laps do not count. Laps from qualifying, heats and finals are kept too but only count when you pick Based on: Races or All - they are marked 🏁 with race, session and series, and shown per race session instead of as stints.", "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_5": "5. When a target is reached, mark its reward as delivered. Bookmark `/?view=training&driver=` on the driver's phone for direct access.", diff --git a/src/public_training.js b/src/public_training.js index 8b9c75f..732c814 100644 --- a/src/public_training.js +++ b/src/public_training.js @@ -8,6 +8,7 @@ const DEFAULT_TIMER_CONFIG = Object.freeze({ pushMinutes: 10, controlMinutes: 10 const MAX_TIMER_BLOCK_MINUTES = 120; let selectedClassId = new URLSearchParams(window.location.search).get("class") || ""; let selectedLayout = new URLSearchParams(window.location.search).get("layout") || "current"; +let selectedSource = new URLSearchParams(window.location.search).get("source") || "practice"; const messages = { sv: { @@ -23,6 +24,10 @@ const messages = { development: "Förarutveckling", class: "Klass", layoutStats: "Banlayout", + lapSource: "Underlag", + lapSourcePractice: "TrĂ€ning", + lapSourceRace: "Race 🏁", + lapSourceAll: "Alla", layoutCurrent: "Aktiv layout", layoutNone: "Utan layout (före första layouten)", layoutAll: "Alla layouter", @@ -193,6 +198,10 @@ const messages = { development: "Driver development", class: "Class", layoutStats: "Track layout", + lapSource: "Based on", + lapSourcePractice: "Practice", + lapSourceRace: "Races 🏁", + lapSourceAll: "All", layoutCurrent: "Current layout", layoutNone: "No layout (before the first layout)", layoutAll: "All layouts", @@ -647,6 +656,7 @@ function renderProfile(data) { ${classProfiles.length > 1 ? `` : activeClass?.className ? `${escapeHtml(activeClass.className)}` : ""} ${activeClass?.brand ? `${escapeHtml(activeClass.brand)}` : ""} ${renderLayoutSelect(data.trackLayouts)} + ${renderSourceSelect(data.lapSource)} @@ -942,6 +952,14 @@ function renderProfile(data) { window.history.replaceState({}, "", nextUrl); loadProfile(); }); + document.getElementById("profileSource")?.addEventListener("change", (event) => { + selectedSource = String(event.currentTarget.value || "practice"); + const nextUrl = new URL(window.location.href); + if (selectedSource !== "practice") nextUrl.searchParams.set("source", selectedSource); + else nextUrl.searchParams.delete("source"); + window.history.replaceState({}, "", nextUrl); + loadProfile(); + }); document.getElementById("profileClass")?.addEventListener("change", (event) => { selectedClassId = String(event.currentTarget.value || ""); const nextUrl = new URL(window.location.href); @@ -1308,6 +1326,15 @@ function renderLayoutSelect(trackLayouts) { `; } +// Practice by default; race laps (qualifying, heats, finals) only on request. +function renderSourceSelect(source) { + const selected = source || "practice"; + const option = (value, label) => ``; + return ``; +} + async function loadProfile(background = false) { if (profileConnection.loading || (background && profileIsBeingEdited())) return; profileConnection.loading = true; @@ -1330,6 +1357,7 @@ async function loadProfile(background = false) { const stepTrainingRun = readExerciseRunState("steptraining"); if (stepTrainingRun) apiUrl.searchParams.set("stepTrainingStartedAgoMs", String(Math.max(0, Date.now() - stepTrainingRun.startedAt))); if (selectedLayout !== "current") apiUrl.searchParams.set("layout", selectedLayout); + if (selectedSource !== "practice") apiUrl.searchParams.set("source", selectedSource); const response = await fetch(apiUrl, { cache: "no-store", signal: controller.signal }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); diff --git a/src/styles.css b/src/styles.css index 2a5418c..ef588a0 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4581,3 +4581,6 @@ tr.is-removed td.lap-actions { .series-table td, .series-table th { text-align: center; white-space: nowrap; } .series-table td:nth-child(2), .series-table th:nth-child(2) { text-align: left; } .series-struck { color: var(--muted); } + +/* Training stats: race laps (shown under "Races" / "All") carry their origin */ +.training-race-origin { color: var(--muted); font-size: 0.72rem; } diff --git a/src/training_logic.js b/src/training_logic.js index 327f46b..9c0f9ed 100644 --- a/src/training_logic.js +++ b/src/training_logic.js @@ -83,6 +83,8 @@ export function normalizeTrainingState(training, { uid }) { lapMs: Math.max(0, Math.round(Number(entry?.lapMs || 0) || 0)), recordedAt: String(entry?.recordedAt || (timestamp ? new Date(timestamp).toISOString() : new Date().toISOString())), trackLayoutId: String(entry?.trackLayoutId || "").trim(), + sessionType: String(entry?.sessionType || "").trim(), + eventId: String(entry?.eventId || "").trim(), }; }) .filter((entry) => { @@ -248,6 +250,7 @@ export function syncTrainingLedger(training, state, { uid }) { // can run one Open Practice all season and switch layout mid-session, and laps on // either side of the switch must land on their own layout. trackLayoutId: timestamp ? getTrackLayoutIdAt(state?.trackLayouts, timestamp) : String(session?.trackLayoutId || "").trim(), + sessionType: String(session?.type || ""), }); }); }); @@ -255,18 +258,74 @@ export function syncTrainingLedger(training, state, { uid }) { // Spread the rest of `normalized` rather than listing each field by name: an evenLaps // config once went missing here because this rebuild listed fields individually and // silently dropped a newly-added one. Only `ledger` actually needs to change. + // Laps saved before the session type was recorded get it from their session, if it still + // exists (otherwise they stay "" and count as practice - nothing disappears). + retained.forEach((entry) => { + if (!entry.sessionType && sessions.has(entry.sessionId)) entry.sessionType = String(sessions.get(entry.sessionId).type || ""); + }); return normalizeTrainingState({ ...normalized, ledger: [...retained, ...current], }, { uid }); } -export function getDriverTrainingLaps(state, driverId, classId = "") { +// Training stats are about practice: laps from qualifying, heats, finals and team races are +// kept (a driver may want to compare race pace) but only counted when "race" or "all" is +// chosen. Laps whose session type is unknown count as practice. +const RACE_SESSION_TYPES = new Set(["qualification", "heat", "final", "team_race"]); +export const TRAINING_LAP_SOURCES = Object.freeze(["practice", "race", "all"]); + +export function getTrainingLapSource(sessionType) { + return RACE_SESSION_TYPES.has(String(sessionType || "")) ? "race" : "practice"; +} + +export function normalizeTrainingLapSource(value) { + return TRAINING_LAP_SOURCES.includes(String(value)) ? String(value) : "practice"; +} + +const practiceLapsOnly = (laps) => laps.filter((lap) => lap.source !== "race"); + +// One row per race session (a qualifying heat, a final ...) the driver took part in: laps, +// best, average and spread - the race counterpart of the practice stint table. +export function buildTrainingRaceSessions(state, driverId, classId = "", { layout = "all" } = {}) { + const bySession = new Map(); + getDriverTrainingLapsForLayout(state, driverId, classId, layout, "race").forEach((lap) => { + if (!bySession.has(lap.sessionId)) bySession.set(lap.sessionId, { sessionId: lap.sessionId, sessionType: lap.sessionType, sessionName: lap.sessionName, eventId: lap.eventId, eventName: lap.eventName, laps: [] }); + bySession.get(lap.sessionId).laps.push(lap); + }); + return [...bySession.values()].map((session) => { + const values = session.laps.filter((lap) => !lap.excludedFromPace).map((lap) => lap.lapMs); + return { + sessionId: session.sessionId, + sessionType: session.sessionType, + sessionName: session.sessionName, + eventId: session.eventId, + eventName: session.eventName, + startedAt: Math.min(...session.laps.map((lap) => lap.timestamp - lap.lapMs)), + lapCount: session.laps.length, + bestLapMs: values.length ? Math.min(...values) : 0, + averageLapMs: values.length ? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length) : 0, + consistencyPct: values.length >= 3 ? consistencySpread(values) : null, + }; + }).sort((a, b) => b.startedAt - a.startedAt); +} + +export function getDriverTrainingLaps(state, driverId, classId = "", { source = "practice" } = {}) { const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : []; const profileReferences = getTrainingDriverIds(state, driverId, classId); + const sessionTypes = new Map((Array.isArray(state?.sessions) ? state.sessions : []).map((session) => [String(session.id), String(session.type || "")])); + const wanted = normalizeTrainingLapSource(source); return ledger .filter((entry) => profileReferences.has(String(entry?.driverId || "")) && Number(entry?.lapMs || 0) > 500) - .map((entry) => ({ + .map((entry) => { + const sessionType = String(entry.sessionType || sessionTypes.get(String(entry.sessionId || "")) || ""); + return { entry, sessionType, source: getTrainingLapSource(sessionType) }; + }) + .filter(({ source: lapSource }) => wanted === "all" || lapSource === wanted) + .map(({ entry, sessionType, source: lapSource }) => ({ + sessionType, + source: lapSource, + eventId: String(entry.eventId || ""), sessionId: String(entry.sessionId || ""), sessionName: String(entry.sessionName || ""), eventName: String(entry.eventName || ""), @@ -292,8 +351,8 @@ export function getDriverTrainingLaps(state, driverId, classId = "") { // `layout` picks which laps count: "current" (default, the active layout), "all" (every // layout blended - the overall view), "none" (laps driven before any layout existed), or // a specific layout id. -export function getDriverTrainingLapsForLayout(state, driverId, classId = "", layout = "current") { - const laps = getDriverTrainingLaps(state, driverId, classId); +export function getDriverTrainingLapsForLayout(state, driverId, classId = "", layout = "current", source = "practice") { + const laps = getDriverTrainingLaps(state, driverId, classId, { source }); if (layout === "all") return laps; const layoutId = layout === "current" || !layout ? getTrackLayoutIdAt(state?.trackLayouts) : layout === "none" ? "" : String(layout); return laps.filter((lap) => lap.trackLayoutId === layoutId); @@ -453,10 +512,12 @@ function getTrainingStintMetrics(stint) { }; } -export function buildTrainingStintAnalysis(state, driverId, classId = "", { layout = "current" } = {}) { +export function buildTrainingStintAnalysis(state, driverId, classId = "", { layout = "current", source = "practice" } = {}) { // Scoped to one track layout (the active one unless another is chosen), same as // buildDriverTrainingStats - a "best stint" spanning two tracks would not be comparable. - const stints = splitTrainingStints(getDriverTrainingLapsForLayout(state, driverId, classId, layout)); + // Stints are a practice idea (breaks split a run into stints). A race is one fixed-time run, + // so race laps never form stints - see buildTrainingRaceSessions for those. + const stints = splitTrainingStints(practiceLapsOnly(getDriverTrainingLapsForLayout(state, driverId, classId, layout, source))); const rows = stints.map((stint, index) => { const { laps, values, outlierLaps, startPaceMs, endPaceMs, endChangeMs, strongFinish } = getTrainingStintMetrics(stint); return { @@ -531,8 +592,13 @@ function buildDailySummaries(laps) { const byDay = new Map(); laps.forEach((lap) => { const key = getLocalDayKey(lap.timestamp); - const current = byDay.get(key) || { dateKey: key, laps: 0, totalMs: 0, bestLapMs: 0, latestTimestamp: 0 }; + const current = byDay.get(key) || { dateKey: key, laps: 0, totalMs: 0, bestLapMs: 0, latestTimestamp: 0, raceLaps: 0, raceSessions: [] }; current.laps += 1; + if (lap.source === "race") { + current.raceLaps += 1; + const label = [lap.eventName, lap.sessionName].filter(Boolean).join(" · "); + if (label && !current.raceSessions.includes(label)) current.raceSessions.push(label); + } current.totalMs += lap.lapMs; current.bestLapMs = current.bestLapMs ? Math.min(current.bestLapMs, lap.lapMs) : lap.lapMs; current.latestTimestamp = Math.max(current.latestTimestamp, lap.timestamp); @@ -675,15 +741,15 @@ export function getBestTrainingDurationResult(stints, targetMs = 5 * 60 * 1000) return best; } -export function buildDriverTrainingStats(state, driverId, classId = "", { layout = "current" } = {}) { +export function buildDriverTrainingStats(state, driverId, classId = "", { layout = "current", source = "practice" } = {}) { // Lifetime commitment stats (how much a driver has trained, ever) read the full lap // history - a track rebuild doesn't erase past practice. Every pace/record/consistency // stat below instead reads layoutLaps, scoped to whichever track layout is active right // now, so a lap from a track that no longer exists can't be reported as today's "record" // or blend into today's consistency numbers. The `layout` option picks another layout, // or "all" for the overall view - see getDriverTrainingLapsForLayout. - const laps = getDriverTrainingLaps(state, driverId, classId); - const layoutLaps = getDriverTrainingLapsForLayout(state, driverId, classId, layout); + const laps = getDriverTrainingLaps(state, driverId, classId, { source }); + const layoutLaps = getDriverTrainingLapsForLayout(state, driverId, classId, layout, source); const historyPaceLaps = laps.filter((lap) => !lap.excludedFromPace); const paceLaps = layoutLaps.filter((lap) => !lap.excludedFromPace); const lapTimes = paceLaps.map((lap) => lap.lapMs); @@ -704,7 +770,7 @@ export function buildDriverTrainingStats(state, driverId, classId = "", { layout const controlZoneMinMs = bestLapMs; const controlTargetMs = bestLapMs ? Math.round(bestLapMs * 1.08) : 0; const dailySummaries = buildDailySummaries(historyPaceLaps); - const stints = splitTrainingStints(layoutLaps); + const stints = splitTrainingStints(practiceLapsOnly(layoutLaps)); const stintMetrics = stints.map((stint) => ({ stint, ...getTrainingStintMetrics(stint) })); const controlledLapStreak = getLongestControlledLapStreak(stints, controlTargetMs); const completedFiveMinuteStints = stints.filter((stint) => getBestTrainingDurationResult([stint], 5 * 60 * 1000)).length; @@ -715,7 +781,7 @@ export function buildDriverTrainingStats(state, driverId, classId = "", { layout const bestFiveMinuteResult = getBestTrainingDurationResult(stints, 5 * 60 * 1000); const todayKey = getLocalDayKey(Date.now()); const todayLaps = layoutLaps.filter((lap) => getLocalDayKey(lap.timestamp) === todayKey); - const todayStints = splitTrainingStints(todayLaps); + const todayStints = splitTrainingStints(practiceLapsOnly(todayLaps)); const todayStintMetrics = todayStints.map((stint) => ({ stint, ...getTrainingStintMetrics(stint) })); const todayLapTimes = todayLaps.filter((lap) => !lap.excludedFromPace).map((lap) => lap.lapMs); diff --git a/src/training_views.js b/src/training_views.js index 37dde17..d48735c 100644 --- a/src/training_views.js +++ b/src/training_views.js @@ -2,11 +2,30 @@ import { sortValuesAttribute } from "./list_sorting.js"; import { getDriverProfileGroups, getDriverProfileReferences } from "./driver_profile_logic.js"; import { normalizeTrackLayouts } from "./track_layouts_logic.js"; import { renderTrackLayoutBar, bindTrackLayoutBar } from "./track_layout_bar.js"; +import { buildTrainingRaceSessions } from "./training_logic.js"; // Which track layout the pace/record/consistency numbers are for: "current" (default), // "all" (overall), "none" (laps from before any layout existed), or a layout id. Module // state like the tab selections, so it survives re-renders while the page is open. let selectedTrainingLayout = "current"; +// Which laps the stats are built from: "practice" (default - training), "race" (qualifying, +// heats, finals) or "all". Race laps are marked 🏁 with their race, session and series. +let selectedTrainingSource = "practice"; + +function trainingSourceSelectMarkup(state, t, escapeHtml) { + const option = (value) => ``; + return ` + `; +} + +// "🏁 ÖRC Winter #3 2WD · Kval 2 · ÖRC Winter Championship" for a race stint or day. +function raceOriginLabel({ eventId, eventName, sessionName }, state) { + const series = (state.series || []).find((item) => eventId && item.eventIds?.includes(eventId)); + return `🏁 ${[eventName, sessionName, series?.name].filter(Boolean).join(" · ")}`; +} function trainingLayoutSelectMarkup(state, t, escapeHtml) { const layouts = normalizeTrackLayouts(state.trackLayouts); @@ -32,12 +51,37 @@ function bindTrainingLayoutControls({ state, t, addTrackLayout, renderView }) { selectedTrainingLayout = event.target.value; renderView(); }); + document.getElementById("training-source")?.addEventListener("change", (event) => { + selectedTrainingSource = event.target.value; + renderView(); + }); bindTrackLayoutBar(document.getElementById("view"), { state, t, addTrackLayout, renderView }); } const EXERCISE_KIND_EMOJI = { timer: "⏱", evenLaps: "🎯", stepTraining: "đŸȘœ" }; -function renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock }) { +function renderRaceSessionsSection(rows, { t, escapeHtml, formatLap, state }) { + return ` +
+

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

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

+
+
+ + + ${rows.length ? rows.map((row) => ` + + + + + + + + `).join("") : ``} +
${escapeHtml(t("training.date"))}${escapeHtml(t("training.race_session"))}${escapeHtml(t("training.laps"))}${escapeHtml(t("training.best_lap"))}${escapeHtml(t("training.average_lap"))}${escapeHtml(t("training.consistency"))}
${escapeHtml(new Date(row.startedAt).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" }))}${escapeHtml(raceOriginLabel(row, state))}${row.lapCount}${formatLap(row.bestLapMs)}${formatLap(row.averageLapMs)}${row.consistencyPct == null ? "-" : `${row.consistencyPct.toFixed(1)}%`}
${escapeHtml(t("training.no_race_sessions"))}
+
`; +} + +function renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock, state }) { const paceRetention = row.endChangeMs == null ? "-" : row.endChangeMs > 0 @@ -144,7 +188,7 @@ export function renderTrainingView(deps) { const selectedDriver = selectedProfile.representative; const selectedClassDriver = selectedProfile.drivers.find((driver) => String(driver.classId || "") === selectedClassId) || selectedDriver; const profileReferences = getDriverProfileReferences(state, selectedProfileId); - const stats = buildDriverTrainingStats(state, selectedProfileId, selectedClassId, { layout: selectedTrainingLayout }); + const stats = buildDriverTrainingStats(state, selectedProfileId, selectedClassId, { layout: selectedTrainingLayout, source: selectedTrainingSource }); const channelStats = buildDriverTimingChannelStats(state, selectedProfileId, selectedClassId); const activePractice = buildDriverActivePracticeStats(state, selectedProfileId, Date.now(), selectedClassId); const evenLapsConfig = (state.training.evenLaps || []).find((entry) => entry.driverId === selectedClassDriver.id && entry.classId === selectedClassId) || null; @@ -169,7 +213,7 @@ export function renderTrainingView(deps) { const highlights = buildTrainingHighlights({ ...stats, latestSplitGainMs: channelStats.latestSplitGainMs }); const paceBuckets = buildTrainingPaceBuckets(state, selectedProfileId, selectedClassId, 30).slice(-24); const weekdayStats = buildTrainingWeekdayStats(state, selectedProfileId, selectedClassId); - const stintAnalysis = buildTrainingStintAnalysis(state, selectedProfileId, selectedClassId, { layout: selectedTrainingLayout }); + const stintAnalysis = buildTrainingStintAnalysis(state, selectedProfileId, selectedClassId, { layout: selectedTrainingLayout, source: selectedTrainingSource }); const markerComparisons = buildTrainingMarkerComparisons(state, selectedProfileId, selectedClassId, 30).slice().reverse(); const goals = state.training.goals.filter((goal) => ( profileReferences.has(String(goal.driverId || "")) @@ -267,7 +311,7 @@ export function renderTrainingView(deps) { const dailyRows = stats.dailySummaries.length ? stats.dailySummaries.slice(0, 10).map((day) => ` - ${escapeHtml(new Date(day.latestTimestamp).toLocaleDateString(undefined, { dateStyle: "medium" }))} + ${escapeHtml(new Date(day.latestTimestamp).toLocaleDateString(undefined, { dateStyle: "medium" }))}${day.raceLaps ? day.raceSessions.map((label) => `
🏁 ${escapeHtml(label)}`).join("") : ""} ${day.laps} ${formatLap(day.bestLapMs)} ${formatLap(day.averageLapMs)} @@ -317,7 +361,7 @@ export function renderTrainingView(deps) { : t("training.trend_stable"); 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, { layout: selectedTrainingLayout }) })) + .map((profile) => ({ profile, driver: profile.representative, stats: profile.id === selectedProfileId ? stats : buildDriverTrainingStats(state, profile.id, selectedClassId, { layout: selectedTrainingLayout, source: selectedTrainingSource }) })) .sort((left, right) => right.stats.lastLapAt - left.stats.lastLapAt || String(left.driver?.name || "").localeCompare(String(right.driver?.name || ""))) .map((entry) => { const milestone = getNextTrainingMilestone(entry.stats); @@ -366,9 +410,26 @@ export function renderTrainingView(deps) { .filter((row, index, rows) => rows.findIndex((item) => item.id === row.id) === index); const stintColumns = `${escapeHtml(t("training.stint"))}${escapeHtml(t("training.start"))}${escapeHtml(t("training.laps"))}${escapeHtml(t("training.best_lap"))}${escapeHtml(t("training.trimmed_pace"))}${escapeHtml(t("training.race_pace_10"))}${escapeHtml(t("training.mistakes"))}${escapeHtml(t("training.start_end"))}${escapeHtml(t("training.consistency"))}${escapeHtml(t("training.stint_five_minute"))}${escapeHtml(t("training.stint_duration"))}`; const featuredStintRows = featuredStints.length - ? featuredStints.map((row) => renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock })).join("") + ? featuredStints.map((row) => renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock, state })).join("") : `${escapeHtml(t("training.no_stints"))}`; - const allStintRows = stintAnalysis.rows.map((row) => renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock })).join(""); + const allStintRows = stintAnalysis.rows.map((row) => renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock, state })).join(""); + // Stints are for practice only; with "Races" chosen the race-session table replaces them. + const stintSectionMarkup = ` +
+

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

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

+ ${stintAnalysis.best ? `${escapeHtml(t("training.best_stint", { stint: stintAnalysis.best.number, laps: stintAnalysis.best.lapCount, pace: formatLap(stintAnalysis.best.paceMs) }))}` : ""} +
+
+ ${stintColumns}${featuredStintRows}
+
+ ${stintAnalysis.rows.length > featuredStints.length ? ` +
+ ${escapeHtml(t("training.show_all_stints", { count: stintAnalysis.rows.length }))} +
+ ${stintColumns}${allStintRows}
+
+
` : ""} +`; const markerMarkup = markerComparisons.length ? markerComparisons.map((marker) => { const comparison = marker.improvementMs == null @@ -404,6 +465,7 @@ export function renderTrainingView(deps) { ${trainingLayoutSelectMarkup(state, t, escapeHtml)} + ${trainingSourceSelectMarkup(state, t, escapeHtml)} ${renderTrackLayoutBar({ state, t, escapeHtml })}
@@ -729,20 +791,7 @@ export function renderTrainingView(deps) {
-
-

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

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

- ${stintAnalysis.best ? `${escapeHtml(t("training.best_stint", { stint: stintAnalysis.best.number, laps: stintAnalysis.best.lapCount, pace: formatLap(stintAnalysis.best.paceMs) }))}` : ""} -
-
- ${stintColumns}${featuredStintRows}
-
- ${stintAnalysis.rows.length > featuredStints.length ? ` -
- ${escapeHtml(t("training.show_all_stints", { count: stintAnalysis.rows.length }))} -
- ${stintColumns}${allStintRows}
-
-
` : ""} + ${selectedTrainingSource === "race" ? renderRaceSessionsSection(buildTrainingRaceSessions(state, selectedProfileId, selectedClassId, { layout: selectedTrainingLayout }), { t, escapeHtml, formatLap, state }) : stintSectionMarkup}

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

${trainingInsight(t("training.average_lap"), formatLap(stats.averageLapMs))} @@ -1060,7 +1109,7 @@ export function buildTrainingOverviewRows(state, profiles, buildDriverTrainingSt driver: profile.representative, classId, className: state.classes.find((item) => String(item.id || "") === String(classId || ""))?.name || classId || "-", - stats: buildDriverTrainingStats(state, profile.id, classId, { layout: selectedTrainingLayout }), + stats: buildDriverTrainingStats(state, profile.id, classId, { layout: selectedTrainingLayout, source: selectedTrainingSource }), })); }).sort((left, right) => ( right.stats.lastLapAt - left.stats.lastLapAt @@ -1169,6 +1218,7 @@ function renderTrainingOverview({ addTrackLayout, state, dom, t, escapeHtml, for ${trainingLayoutSelectMarkup(state, t, escapeHtml)} + ${trainingSourceSelectMarkup(state, t, escapeHtml)} ${renderTrackLayoutBar({ state, t, escapeHtml })}
diff --git a/tests/browser_smoke.js b/tests/browser_smoke.js index 26a445a..a85e863 100644 --- a/tests/browser_smoke.js +++ b/tests/browser_smoke.js @@ -16,6 +16,7 @@ import { runSuspectLapsSmoke } from "./browser_suspect_laps.js"; import { runCorrectionsScopeSmoke } from "./browser_corrections_scope.js"; import { runDriversPageSmoke } from "./browser_drivers_page.js"; import { runSeriesSmoke } from "./browser_series.js"; +import { runTrainingSourceSmoke } from "./browser_training_source.js"; import { runObsPopupSmoke } from "./browser_obs_popup.js"; import { runDecoderReconnectSmoke } from "./browser_decoder_reconnect.js"; import assert from "node:assert/strict"; @@ -162,6 +163,7 @@ try { await runCorrectionsScopeSmoke({ evaluate, navigate, savedState, waitFor, base }); await runDriversPageSmoke({ evaluate, navigate, savedState, waitFor, base }); await runSeriesSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs }); + await runTrainingSourceSmoke({ evaluate, navigate, savedState, waitFor, base }); await runRaceDaySmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, dialogs }); await runRaceDayStartSequencesSmoke({ evaluate, navigate, waitFor, pause, base, dialogs }); await runObsPopupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, debugPort, dialogs, realClick }); diff --git a/tests/browser_training_source.js b/tests/browser_training_source.js new file mode 100644 index 0000000..34dc1ac --- /dev/null +++ b/tests/browser_training_source.js @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; + +// Training stats "Based on": practice by default; "Races" replaces the stint table with one +// row per race session, marked 🏁 with race, session and series - race laps never form stints. +export async function runTrainingSourceSmoke({ evaluate, navigate, savedState, waitFor, base }) { + const patchState = async (patch) => { + const current = await savedState(); + Object.assign(current, patch); + const response = await fetch(base + "/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(current) }); + assert.equal(response.ok, true); + }; + const seeded = await savedState(); + const t0 = Date.now() - 3600000; + const lap = (sessionId, offset, lapMs) => ({ driverId: "ts_driver", competitorKey: "driver_ts_driver", sessionId, lapMs, timestamp: t0 + offset, validLap: true }); + await patchState({ + classes: [...seeded.classes, { id: "ts_class", name: "TS 2WD" }], + drivers: [...seeded.drivers, { id: "ts_driver", profileId: "ts_profile", name: "TS Driver", classId: "ts_class", transponder: "8770001" }], + events: [...seeded.events, { id: "ts_practice_event", name: "TS Practice", classId: "ts_class", mode: "race", raceConfig: { practiceOnly: true } }, { id: "ts_race_event", name: "TS Winter #3 TS 2WD", classId: "ts_class", mode: "race", date: "2026-11-29" }], + sessions: [...seeded.sessions, { id: "ts_op", eventId: "ts_practice_event", name: "TS Open Practice", type: "open_practice", status: "finished" }, { id: "ts_q2", eventId: "ts_race_event", name: "Kval 2", type: "qualification", status: "finished" }], + series: [...(seeded.series || []), { id: "ts_series", name: "TS Winter Cup", eventIds: ["ts_race_event"], classIds: ["ts_class"] }], + resultsBySession: { + ...seeded.resultsBySession, + ts_op: { passings: [lap("ts_op", 20000, 20000), lap("ts_op", 40000, 20000), lap("ts_op", 59500, 19500), lap("ts_op", 79000, 19500)], competitors: {} }, + ts_q2: { passings: [lap("ts_q2", 900000, 18000), lap("ts_q2", 918000, 18200), lap("ts_q2", 936400, 18400)], competitors: {} }, + }, + }); + + try { + await navigate("training", "view"); + // The overview has its own driver picker; the driver page has another one. + await waitFor(async () => Boolean(await evaluate(`[...document.querySelectorAll("#training-overview-driver option, #training-driver option")].some((option) => option.value === "ts_profile")`)), "driver listed"); + await evaluate(`(() => { const select = document.getElementById("training-overview-driver") || document.getElementById("training-driver"); select.value = "ts_profile"; select.dispatchEvent(new Event("change")); })()`); + await waitFor(async () => (await evaluate(`document.getElementById("training-driver")?.value`)) === "ts_profile" && Boolean(await evaluate(`document.getElementById("training-source")`)), "training stats for the driver"); + assert.equal(await evaluate(`document.getElementById("training-source").value`), "practice", "practice is the default"); + assert.equal(await evaluate(`Boolean(document.querySelector('[data-sort-id="training-stints-featured"]'))`), true, "stints for practice"); + assert.equal(await evaluate(`document.querySelectorAll(".training-race-origin").length`), 0, "no race laps mixed into practice"); + + await evaluate(`(() => { const select = document.getElementById("training-source"); select.value = "race"; select.dispatchEvent(new Event("change")); })()`); + await waitFor(async () => Boolean(await evaluate(`document.querySelector('[data-sort-id="training-race-sessions"]')`)), "race sessions table"); + assert.equal(await evaluate(`Boolean(document.querySelector('[data-sort-id="training-stints-featured"]'))`), false, "no stints for races"); + const row = await evaluate(`[...document.querySelectorAll('[data-sort-id="training-race-sessions"] tbody tr')].map((tr) => [...tr.children].map((td) => td.textContent.trim()))[0]`); + assert.equal(row[1], "🏁 TS Winter #3 TS 2WD · Kval 2 · TS Winter Cup", "race, session and series"); + assert.equal(row[2], "3"); + console.log("PASS training stats: practice by default; races shown per race session with 🏁 race · session · series, never as stints"); + } finally { + const current = await savedState(); + const results = { ...current.resultsBySession }; + delete results.ts_op; + delete results.ts_q2; + await patchState({ + resultsBySession: results, + sessions: current.sessions.filter((session) => !session.id.startsWith("ts_")), + events: current.events.filter((event) => !event.id.startsWith("ts_")), + series: (current.series || []).filter((series) => series.id !== "ts_series"), + drivers: current.drivers.filter((driver) => driver.id !== "ts_driver"), + classes: current.classes.filter((item) => item.id !== "ts_class"), + training: { ...current.training, ledger: (current.training?.ledger || []).filter((entry) => !String(entry.sessionId).startsWith("ts_")) }, + }); + } +} diff --git a/tests/public_training_profile.test.js b/tests/public_training_profile.test.js index e0e6dec..7ba3918 100644 --- a/tests/public_training_profile.test.js +++ b/tests/public_training_profile.test.js @@ -490,3 +490,20 @@ test("public profile lets the driver pick a layout, an older one, laps from befo assert.deepEqual(layouts.options, [{ id: "layout_1", label: "Layout 1" }, { id: "layout_2", label: "Layout 2" }], "oldest first, only id and label are exposed"); assert.equal(layouts.hasUntaggedLaps, true); }); + +test("public profile counts practice by default; race laps only with source race or all", () => { + const now = Date.now(); + const state = buildState(now); + state.sessions = [{ id: "free", type: "free_practice" }, { id: "heat_1", type: "heat" }]; + state.training.ledger.push( + { driverId: "driver_1", sessionId: "heat_1", sessionType: "heat", timestamp: now - 9000, lapMs: 15000 }, + { driverId: "driver_1", sessionId: "heat_1", timestamp: now - 7000, lapMs: 15500 }, // type comes from the session + ); + const stats = (source) => buildPublicTrainingProfile(state, DRIVER_ONE_TOKEN, "", 0, 0, 0, 0, "current", source).classProfiles.find((item) => item.classId === "class_1").stats; + assert.equal(stats(undefined).totalLaps, 4, "practice only by default"); + assert.equal(stats("practice").bestLapMs, 16000); + assert.equal(stats("race").totalLaps, 2); + assert.equal(stats("race").bestLapMs, 15000); + assert.equal(stats("all").totalLaps, 6); + assert.equal(buildPublicTrainingProfile(state, DRIVER_ONE_TOKEN, "", 0, 0, 0, 0, "current", "bogus").lapSource, "practice"); +}); diff --git a/tests/training_logic.test.js b/tests/training_logic.test.js index d558470..82d7803 100644 --- a/tests/training_logic.test.js +++ b/tests/training_logic.test.js @@ -14,6 +14,7 @@ import { buildTrainingPaceBuckets, buildTrainingRecommendation, buildTrainingStintAnalysis, + buildTrainingRaceSessions, buildTrainingWeekdayStats, countExerciseSessions, countDriverExerciseSessions, @@ -983,3 +984,47 @@ test("layout choice \"none\" picks the laps driven before any layout existed", ( assert.deepEqual(getDriverTrainingLapsForLayout(state, "driver_1", "", "current").map((lap) => lap.lapMs), [16_000]); assert.equal(getDriverTrainingLapsForLayout(state, "driver_1", "", "all").length, 2); }); + +test("training stats count practice by default; race laps are kept, marked and shown per race session, never as stints", () => { + let n = 0; + const uid = (prefix) => `${prefix}_${++n}`; + const lap = (sessionId, timestamp, lapMs) => ({ driverId: "d1", competitorKey: "d1", sessionId, lapMs, timestamp, validLap: true }); + const state = { + drivers: [{ id: "d1", name: "D1", classId: "c1" }], + classes: [{ id: "c1", name: "2WD" }], + events: [{ id: "e_practice", name: "Club practice" }, { id: "e_race", name: "Winter #3 2WD" }], + sessions: [ + { id: "op", name: "Open Practice", type: "open_practice", eventId: "e_practice" }, + { id: "q2", name: "Kval 2", type: "qualification", eventId: "e_race" }, + ], + series: [{ id: "s", name: "Winter Cup", eventIds: ["e_race"] }], + resultsBySession: { + op: { passings: [lap("op", 1000, 20000), lap("op", 21000, 20000), lap("op", 41000, 19500), lap("op", 60500, 19500)] }, + q2: { passings: [lap("q2", 500000, 18000), lap("q2", 518000, 18200), lap("q2", 536200, 18400)] }, + }, + }; + state.training = syncTrainingLedger({}, state, { uid }); + assert.deepEqual(state.training.ledger.map((entry) => entry.sessionType), ["open_practice", "open_practice", "open_practice", "open_practice", "qualification", "qualification", "qualification"]); + + assert.equal(getDriverTrainingLaps(state, "d1").length, 4, "practice by default"); + assert.equal(getDriverTrainingLaps(state, "d1", "", { source: "race" }).length, 3); + assert.equal(getDriverTrainingLaps(state, "d1", "", { source: "all" }).length, 7); + assert.equal(buildDriverTrainingStats(state, "d1").bestLapMs, 19500, "a race lap doesn't become the training best"); + assert.equal(buildDriverTrainingStats(state, "d1", "", { source: "all" }).bestLapMs, 18000); + + // Stints come from practice laps only, even with "all"; with "race" there are none. + const allStints = buildTrainingStintAnalysis(state, "d1", "", { source: "all" }); + assert.equal(allStints.rows.reduce((sum, row) => sum + row.lapCount, 0), 4); + assert.equal(buildTrainingStintAnalysis(state, "d1", "", { source: "race" }).rows.length, 0); + + const races = buildTrainingRaceSessions(state, "d1"); + assert.equal(races.length, 1); + assert.deepEqual([races[0].sessionName, races[0].eventName, races[0].eventId, races[0].lapCount, races[0].bestLapMs, races[0].averageLapMs], ["Kval 2", "Winter #3 2WD", "e_race", 3, 18000, 18200]); + + // A lap saved before session types were recorded gets it from its (still existing) session. + const old = { ...state, training: { ...state.training, ledger: state.training.ledger.map(({ sessionType, ...entry }) => entry) } }; + assert.equal(getDriverTrainingLaps(old, "d1", "", { source: "race" }).length, 3); + delete old.resultsBySession; + const resynced = syncTrainingLedger(old.training, old, { uid }); + assert.equal(resynced.ledger.filter((entry) => entry.sessionType === "qualification").length, 3, "back-filled on the next sync"); +});