From e5e1754185cbcf4c6bb8f7786672574ecd4dba33 Mon Sep 17 00:00:00 2001 From: larssand Date: Sat, 29 Aug 2026 09:56:45 +0200 Subject: [PATCH] add more train stats per driver --- README.md | 5 +- README.sv.md | 5 +- docs/javascript-architecture.md | 30 ++++- src/app.js | 90 ++++++++++++++- src/misc_views.js | 2 +- src/styles.css | 197 ++++++++++++++++++++++++++++++++ src/training_logic.js | 153 ++++++++++++++++++++++++- src/training_views.js | 164 ++++++++++++++++++++++++++ tests/training_logic.test.js | 84 ++++++++++++++ 9 files changed, 722 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 46a01f2..c4bac2d 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,9 @@ The personal profile can also store `Club` and `Country`. These values are inher - personal development trend comparing the latest 30 valid laps with the preceding 30, always isolated per class - automatic achievements for first practice, lap totals, practice days, controlled streaks, and completed five-minute stints - a recent club progress feed ordered by activity rather than driver position or lap-count ranking +- a collapsed Extra statistics section with active 30-minute pace blocks based on a trimmed average +- class-specific setup markers for tires, suspension, car changes, or custom notes, including an automatic 30-minute before/after comparison +- detailed stint analysis with normal pace, best 10-lap race pace, mistakes, consistency, and start/end development - automatic progression levels at useful lap totals without manual setup - a configurable practice timer split into maximum-focus and controlled 80-90% blocks - custom lap milestones with quick targets at `500`, `1000`, and `1500` laps @@ -207,7 +210,7 @@ Direct mobile/bookmark link: `http://:8081/?view=training`. Selecting 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. -Judging corrections update ledger laps while the source session still exists. Once a session is deleted, its synchronized training laps remain in the ledger so lifetime progression is not lost. Full data export includes the ledger and reward goals. +Judging corrections update ledger laps while the source session still exists. Once a session is deleted, its synchronized training laps remain in the ledger so lifetime progression is not lost. Full data export includes the ledger, reward goals, and setup markers. ### Read-only mobile profile diff --git a/README.sv.md b/README.sv.md index 561e3e7..c9ca822 100644 --- a/README.sv.md +++ b/README.sv.md @@ -186,6 +186,9 @@ Förarprofilen kan även lagra `Klubb` och `Land`. Värdena ärvs när ytterliga - personlig utvecklingstrend som jämför de senaste 30 giltiga varven med föregående 30, alltid separat per klass - automatiska achievements för första passet, varvmängd, träningsdagar, kontrollerade varvserier och fullföljd femminutersstint - klubbfeed sorterad på senaste aktivitet i stället för placering eller totalvarvsranking +- en hopfällbar Extra statistik-del med aktiva 30-minutersblock baserade på trimmat snitt +- klasspecifika setupmarkörer för däck, dämpare, bilbyte eller egen notering med automatisk jämförelse 30 minuter före/efter +- detaljerad stintanalys med normaltempo, bästa race pace över 10 varv, misstag, konsekvens och utveckling mellan start/slut - automatiska utvecklingsnivåer vid lämpliga varvantal utan manuell konfiguration - rekommenderat träningspass med valbara minuter för maxfokus och kontrollerad körning på 80-90 procent - egna varvmål med snabbval på `500`, `1000` och `1500` varv @@ -196,7 +199,7 @@ Direktlänk för mobil/bokmärke: `http://:8081/?view=training`. När 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. -Domarkorrigeringar uppdaterar loggade varv så länge källsessionen finns kvar. När sessionen senare tas bort ligger de synkade träningsvarven kvar så förarens livstidsutveckling inte försvinner. Full dataexport inkluderar både varvloggen och belöningsmålen. +Domarkorrigeringar uppdaterar loggade varv så länge källsessionen finns kvar. När sessionen senare tas bort ligger de synkade träningsvarven kvar så förarens livstidsutveckling inte försvinner. Full dataexport inkluderar varvloggen, belöningsmålen och setupmarkörerna. ### Skrivskyddad mobilprofil diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index ed0f73b..b0f5b3e 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -476,6 +476,9 @@ Owns: - daily summaries, controlled-lap streak, and recent personal-best gain - signed personal pace and consistency trends from the latest 30 laps versus the preceding 30, with a minimum of 10 laps in each window - derived automatic achievements for sessions, lap totals, training days, controlled streaks, and a completed five-minute result +- active 30-minute pace buckets using a trimmed average and omitting empty clock periods +- persisted class-specific setup markers and 30-minute before/after comparisons +- extended stint analysis with normal pace, best 10-lap race pace, mistake count, consistency, and start/end change - rule-based practice recommendation selection - automatic and custom milestone progress calculation @@ -500,6 +503,9 @@ Rules: - a boundary lap whose `lapMs` contains the full break is excluded from pace metrics - duration-result ranking compares lap count first and elapsed result time second - a duration result is valid only after crossing the target and includes a final lap whose start is before the target cutoff +- a stint needs at least 3 valid pace laps for consistency/ranking and at least 10 for Race pace 10 +- a stint mistake is a lap more than 15 percent slower than that stint's median +- setup comparisons need at least 3 valid laps in each 30-minute side of the marker ### `src/training_views.js` Type: @@ -514,10 +520,12 @@ Owns: - compact live-practice card and primary personal-profile action for the selected driver - personal trend and achievement cards that never combine classes - recent club progress feed ordered by latest activity, without position numbers or cross-driver performance scoring +- collapsed Extra statistics with pace heatmap, setup-marker actions, weekday pace, featured stints, and an expandable full stint table Mutations: - replaces or appends entries in `state.training.goals` - creates, rotates, enables, or disables entries in `state.training.shares` +- creates and removes entries in `state.training.markers` - always calls injected `saveState()` before rerender Route behavior: @@ -1758,7 +1766,8 @@ This bucket is the most dynamic part of state and is mutated heavily during live { goals: TrainingGoal[], ledger: TrainingLap[], - shares: TrainingShare[] + shares: TrainingShare[], + markers: TrainingMarker[] } ``` @@ -1812,6 +1821,21 @@ Share rules: } ``` +`TrainingMarker`: + +```js +{ + id: string, + driverId: string, // exact driver entry; profile resolution happens on read + classId: string, + sessionId: string, + timestamp: number, + type: "setup" | "tires" | "suspension" | "car" | "note", + note: string, + createdAt: string +} +``` + Normalized by: - `normalizeTrainingState()` wrapper in `src/app.js` - `normalizeTrainingState()` implementation in `src/training_logic.js` @@ -1831,6 +1855,7 @@ Migration behavior: - older state without `training.ledger` normalizes to an empty ledger - the next `saveState()` imports valid registered-driver laps from all current session results - invalid laps, anonymous transponders, and laps at or below `500 ms` are excluded +- older state without `training.markers` normalizes to an empty marker list ## 15.9 `Settings` @@ -2334,6 +2359,9 @@ npm test - 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 +- trimmed 30-minute pace buckets and weekday aggregation +- class-isolated setup-marker before/after comparison +- short-stint suppression, 10-lap race pace, and mistake detection `tests/public_training_profile.test.js` covers: - enabled/disabled/unknown share-token handling diff --git a/src/app.js b/src/app.js index 01581fc..491ac08 100644 --- a/src/app.js +++ b/src/app.js @@ -18,7 +18,7 @@ import { renderEventManagerMarkup } from "./event_manager_view.js"; import { renderEventManagerView } from "./event_manager_controller.js"; import { createDefaultAmmcConfigHelper, getManagedWsUrlHelper, loadAmmcConfigFromBackendHelper, saveAmmcConfigToBackendHelper, refreshAmmcStatusHelper, startManagedAmmcHelper, stopManagedAmmcHelper, applyPersistedStateHelper, hydrateFromBackendHelper, scheduleBackendSyncHelper, syncStateToBackendHelper, pingBackendHelper, checkAppVersionHelper, startAppVersionPollingHelper, startOverlaySyncHelper, startOverlayRotationHelper, startOverlayLiveRefreshHelper, ensureAudioContextHelper, playPassingBeepHelper, playFinishSirenHelper, playLeaderCueHelper, playStartCueHelper, playBestLapCueHelper, pushOverlayEventHelper, getSpeechVoicesHelper, speakTextHelper, announcePassingHelper, announceRaceStartedHelper, announceRaceFinishedHelper, handleSessionTimerTickHelper, tickClockHelper } from "./runtime_services.js"; import { connectDecoderHelper, disconnectDecoderHelper, processDecoderMessageHelper } from "./decoder_runtime.js"; -import { normalizeTrainingState as normalizeTrainingStateLogic, syncTrainingLedger as syncTrainingLedgerLogic, buildDriverTrainingStats as buildDriverTrainingStatsLogic, buildDriverTimingChannelStats as buildDriverTimingChannelStatsLogic, buildDriverActivePracticeStats as buildDriverActivePracticeStatsLogic, buildTrainingRecommendation as buildTrainingRecommendationLogic, getTrainingGoalProgress as getTrainingGoalProgressLogic, getNextTrainingMilestone as getNextTrainingMilestoneLogic } from "./training_logic.js"; +import { normalizeTrainingState as normalizeTrainingStateLogic, syncTrainingLedger as syncTrainingLedgerLogic, buildDriverTrainingStats as buildDriverTrainingStatsLogic, buildDriverTimingChannelStats as buildDriverTimingChannelStatsLogic, buildDriverActivePracticeStats as buildDriverActivePracticeStatsLogic, buildTrainingRecommendation as buildTrainingRecommendationLogic, getTrainingGoalProgress as getTrainingGoalProgressLogic, getNextTrainingMilestone as getNextTrainingMilestoneLogic, buildTrainingPaceBuckets as buildTrainingPaceBucketsLogic, buildTrainingWeekdayStats as buildTrainingWeekdayStatsLogic, buildTrainingStintAnalysis as buildTrainingStintAnalysisLogic, buildTrainingMarkerComparisons as buildTrainingMarkerComparisonsLogic } from "./training_logic.js"; import { renderTrainingView } from "./training_views.js"; import { importDriversCsv, importCarsCsv } from "./csv_import.js"; @@ -293,7 +293,7 @@ const buildDriverActivePracticeStats = (trainingState, driverId, nowTs = Date.no 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, buildDriverTimingChannelStats: buildDriverTimingChannelStatsLogic, 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 renderTraining = () => renderTrainingView({ state, dom, t, escapeHtml, formatLap, formatRaceClock, uid, saveState, renderView, normalizeTrainingState, syncTrainingLedger, buildDriverTrainingStats, buildDriverTimingChannelStats: buildDriverTimingChannelStatsLogic, buildDriverActivePracticeStats, buildTrainingRecommendation, getTrainingGoalProgress, getNextTrainingMilestone, buildTrainingPaceBuckets: buildTrainingPaceBucketsLogic, buildTrainingWeekdayStats: buildTrainingWeekdayStatsLogic, buildTrainingStintAnalysis: buildTrainingStintAnalysisLogic, buildTrainingMarkerComparisons: buildTrainingMarkerComparisonsLogic, 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 = {}) => { const result = applyCompetitorCorrectionLogic(session, row, options, { ensureSessionResult, uid, t, formatLap, saveState }); if (!options.reset && ((Number(options.timeMsDelta || 0) > 0) || (Number(options.lapDelta || 0) < 0))) { @@ -445,6 +445,45 @@ const TRANSLATIONS = { "training.history": "Träningshistorik per dag", "training.detailed_stats": "Mer statistik", "training.detailed_stats_hint": "Snitt, varvserie, mellantider, depåstopp och historik per träningsdag.", + "training.extra_stats": "Extra statistik", + "training.extra_stats_hint": "Tempo per 30 minuter, setupändringar och djupare stintanalys.", + "training.pace_30_title": "Tempo per aktivt 30-minutersblock", + "training.pace_30_hint": "Visar bara block med körning. Tempot är ett trimmat snitt så enstaka krascher inte styr hela blocket.", + "training.no_pace_data": "Det finns inte tillräckligt med varv för en tempokarta ännu.", + "training.weekday_pace": "Tempo per veckodag", + "training.weekday_hint": "Visas först efter minst tre träningsdagar och använder bara dagar med körning.", + "training.weekday_needs_days": "Minst tre träningsdagar behövs för en meningsfull jämförelse.", + "training.add_marker": "Markera en biländring", + "training.add_marker_hint": "Markeringen sparas nu för vald förare och klass. Systemet jämför 30 minuter före och efter.", + "training.marker_type": "Typ av ändring", + "training.marker_setup": "Ny setup", + "training.marker_tires": "Bytte däck", + "training.marker_suspension": "Ändrade dämpare", + "training.marker_car": "Bytte bil", + "training.marker_note": "Egen notering", + "training.marker_description": "Beskrivning", + "training.marker_placeholder": "Till exempel mjukare framfjädrar", + "training.mark_now": "Markera ändring nu", + "training.no_markers": "Inga setupmarkörer finns i den här klassen.", + "training.marker_collecting": "Samlar underlag ({before} före / {after} efter)", + "training.marker_improved": "{lap} snabbare efter ändringen", + "training.marker_slower": "{lap} långsammare efter ändringen", + "training.stint": "Stint", + "training.start": "Start", + "training.trimmed_pace": "Normaltempo", + "training.race_pace_10": "Race pace 10", + "training.mistakes": "Misstag", + "training.start_end": "Start/slut", + "training.insufficient_data": "För lite data", + "training.no_stints": "Inga stintar finns ännu.", + "training.stint_faster": "{lap} snabbare", + "training.stint_slower": "{lap} långsammare", + "training.stint_same": "Oförändrat", + "training.stint_analysis": "Stintanalys", + "training.stint_analysis_hint": "En paus över två minuter startar en ny stint. Kortare stintar visas men rankas inte.", + "training.best_stint": "Bästa stint {stint}: {laps} varv, {pace}", + "training.show_all_stints": "Visa alla {count} stintar", + "training.stats_method": "Normaltempo använder trimmat snitt. Misstag är varv mer än 15% långsammare än stintens median. Minst 3 varv krävs för konsekvens och 10 för Race pace 10.", "training.split_passings": "Mellantidspasseringar", "training.best_split": "Bästa mellantid", "training.average_split": "Snitt mellantid", @@ -1398,6 +1437,10 @@ const TRANSLATIONS = { "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.", "guide.training_15": "15. Utvecklingskortet jämför de senaste 30 giltiga varven med de föregående 30 inom samma klass. Positivt värde betyder snabbare snittempo eller mindre spridning; för litet underlag visas som insamling i stället för en osäker trend.", "guide.training_16": "16. Achievements och klubbens framstegsfeed lyfter träningspass, varvmål, jämn körning och fullföljda femminutersstints. Feedens ordning följer senaste aktivitet och är inte en ranking mellan förare.", + "guide.training_17": "17. Öppna Extra statistik för tempo per aktivt 30-minutersblock. Tomma tider döljs och tempot använder trimmat snitt så enstaka krascher inte förvränger jämförelsen.", + "guide.training_18": "18. Före en biländring väljer du Ny setup, däck, dämpare, bil eller egen notering och klickar Markera ändring nu. Markören sparas endast för vald personlig profil och klass.", + "guide.training_19": "19. När det finns minst tre giltiga varv inom 30 minuter både före och efter markören visas förbättringen automatiskt. Jämför bara pass med liknande bana och förhållanden.", + "guide.training_20": "20. Stintanalysen använder glapp över två minuter som gräns. Normaltempo är trimmat snitt, misstag är varv över 15 procent långsammare än stintens median och Race pace 10 kräver tio sammanhängande varv.", "overlay.title": "Overlay", "overlay.subtitle": "Extern leaderboard-skärm", "overlay.no_active": "Ingen aktiv session vald.", @@ -1603,6 +1646,45 @@ const TRANSLATIONS = { "training.history": "Daily practice history", "training.detailed_stats": "More statistics", "training.detailed_stats_hint": "Averages, lap streak, split timing, pit stops, and daily practice history.", + "training.extra_stats": "Extra statistics", + "training.extra_stats_hint": "30-minute pace, setup changes, and deeper stint analysis.", + "training.pace_30_title": "Pace per active 30-minute block", + "training.pace_30_hint": "Only blocks with driving are shown. Pace uses a trimmed average so isolated crashes do not control the block.", + "training.no_pace_data": "There are not enough laps for a pace map yet.", + "training.weekday_pace": "Pace by weekday", + "training.weekday_hint": "Shown after at least three practice days and includes only days with driving.", + "training.weekday_needs_days": "At least three practice days are required for a meaningful comparison.", + "training.add_marker": "Mark a car change", + "training.add_marker_hint": "The marker is saved now for the selected driver and class. The system compares 30 minutes before and after.", + "training.marker_type": "Change type", + "training.marker_setup": "New setup", + "training.marker_tires": "Changed tires", + "training.marker_suspension": "Changed suspension", + "training.marker_car": "Changed car", + "training.marker_note": "Custom note", + "training.marker_description": "Description", + "training.marker_placeholder": "For example softer front springs", + "training.mark_now": "Mark change now", + "training.no_markers": "There are no setup markers in this class.", + "training.marker_collecting": "Collecting data ({before} before / {after} after)", + "training.marker_improved": "{lap} faster after the change", + "training.marker_slower": "{lap} slower after the change", + "training.stint": "Stint", + "training.start": "Start", + "training.trimmed_pace": "Normal pace", + "training.race_pace_10": "Race pace 10", + "training.mistakes": "Mistakes", + "training.start_end": "Start/end", + "training.insufficient_data": "Not enough data", + "training.no_stints": "No stints are available yet.", + "training.stint_faster": "{lap} faster", + "training.stint_slower": "{lap} slower", + "training.stint_same": "Unchanged", + "training.stint_analysis": "Stint analysis", + "training.stint_analysis_hint": "A break longer than two minutes starts a new stint. Shorter stints are shown but not ranked.", + "training.best_stint": "Best stint {stint}: {laps} laps, {pace}", + "training.show_all_stints": "Show all {count} stints", + "training.stats_method": "Normal pace uses a trimmed average. A mistake is a lap over 15% slower than the stint median. At least 3 laps are required for consistency and 10 for Race pace 10.", "training.split_passings": "Split passings", "training.best_split": "Best split", "training.average_split": "Average split", @@ -2556,6 +2638,10 @@ const TRANSLATIONS = { "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.", "guide.training_15": "15. The development card compares the latest 30 valid laps with the preceding 30 in the same class. A positive value means faster average pace or a smaller spread; insufficient data is shown as collecting rather than an unreliable trend.", "guide.training_16": "16. Achievements and the club progress feed recognize practice sessions, lap milestones, controlled driving, and completed five-minute stints. The feed follows recent activity and is not a ranking between drivers.", + "guide.training_17": "17. Open Extra statistics for pace per active 30-minute block. Empty periods are hidden and pace uses a trimmed average so isolated crashes do not distort the comparison.", + "guide.training_18": "18. Before changing the car, select New setup, tires, suspension, car, or a custom note and click Mark change now. The marker is stored only for the selected personal profile and class.", + "guide.training_19": "19. Once at least three valid laps exist within 30 minutes both before and after the marker, the change is compared automatically. Only compare runs with similar track conditions.", + "guide.training_20": "20. Stint analysis uses gaps longer than two minutes as boundaries. Normal pace is a trimmed average, mistakes are laps over 15 percent slower than the stint median, and Race pace 10 requires ten consecutive laps.", "overlay.title": "Overlay", "overlay.subtitle": "External leaderboard screen", "overlay.no_active": "No active session selected.", diff --git a/src/misc_views.js b/src/misc_views.js index 2de1723..8d08c13 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", "guide.training_14", "guide.training_15", "guide.training_16"], "", { 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", "guide.training_15", "guide.training_16", "guide.training_17", "guide.training_18", "guide.training_19", "guide.training_20"], "", { t })}
diff --git a/src/styles.css b/src/styles.css index a6f5659..6d5233c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -10,6 +10,8 @@ --accent-2: #ff3b30; --ok: #26c281; --warn: #f5a623; + --success: var(--ok); + --danger: var(--accent-2); --surface-soft: rgba(255, 255, 255, 0.02); --surface-card: rgba(255, 255, 255, 0.03); --surface-elevated: rgba(255, 255, 255, 0.025); @@ -799,6 +801,175 @@ body { text-transform: uppercase; } +.training-pace-heatmap { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); + gap: 8px; + margin-bottom: 16px; +} + +.training-pace-cell { + display: grid; + grid-template-columns: 1fr auto; + gap: 4px 8px; + min-height: 106px; + padding: 12px; + border: 1px solid color-mix(in srgb, var(--success) 34%, var(--line)); + border-radius: 12px; + background: + linear-gradient(145deg, color-mix(in srgb, var(--success) var(--pace-heat), transparent), transparent 72%), + var(--surface-elevated); +} + +.training-pace-cell > span, +.training-pace-cell > strong { + font-size: 0.76rem; +} + +.training-pace-cell > b { + grid-column: 1 / -1; + font: 700 1.12rem Orbitron, sans-serif; +} + +.training-pace-cell > small { + grid-column: 1 / -1; + color: var(--muted); +} + +.training-pace-cell > em { + grid-column: 1 / -1; + width: fit-content; + padding: 3px 7px; + border: 1px solid color-mix(in srgb, var(--accent) 48%, var(--line)); + border-radius: 999px; + color: color-mix(in srgb, var(--accent) 58%, var(--text)); + font-size: 0.68rem; + font-style: normal; +} + +.training-extra-grid { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(280px, 0.65fr); + gap: 12px; + margin: 12px 0 18px; +} + +.training-extra-grid .training-simple-section { + min-width: 0; +} + +.training-marker-form { + grid-template-columns: minmax(160px, 0.7fr) minmax(220px, 1.3fr) auto; + align-items: end; +} + +.training-marker-form label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 0.8rem; +} + +.training-marker-list { + display: grid; + gap: 7px; + margin-top: 14px; +} + +.training-marker-entry { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 10px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-elevated); +} + +.training-marker-entry div { + min-width: 0; +} + +.training-marker-entry span, +.training-marker-entry strong, +.training-marker-entry small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.training-marker-entry span, +.training-marker-entry small { + color: var(--muted); + font-size: 0.7rem; +} + +.training-marker-entry > b { + font-size: 0.76rem; + white-space: nowrap; +} + +.training-marker-entry > b.is-positive { + color: var(--success); +} + +.training-marker-entry > b.is-negative { + color: var(--danger); +} + +.training-weekdays { + display: grid; + gap: 7px; +} + +.training-weekdays article { + display: grid; + grid-template-columns: 44px 1fr auto; + align-items: center; + gap: 8px; + padding: 9px 10px; + border-bottom: 1px solid var(--line); +} + +.training-weekdays span, +.training-weekdays small { + color: var(--muted); + font-size: 0.72rem; +} + +.training-weekdays strong { + font-family: Orbitron, sans-serif; +} + +.training-stint-table { + min-width: 1040px; +} + +.training-stint-table tr.is-muted { + opacity: 0.58; +} + +.training-stint-all { + margin-top: 10px; +} + +.training-stint-all > summary { + width: fit-content; + cursor: pointer; + color: color-mix(in srgb, var(--accent) 62%, var(--text)); + font-weight: 700; +} + +.training-stint-all > .training-history-wrap { + margin-top: 10px; +} + +.training-method-note { + margin: 14px 0 0; +} + .training-club-table tr.is-selected td { background: color-mix(in srgb, var(--accent) 8%, transparent); } @@ -3370,3 +3541,29 @@ select:focus { object-fit: contain; border-radius: 7px; } + +@media (max-width: 900px) { + .training-extra-grid, + .training-marker-form { + grid-template-columns: 1fr; + } + + .training-marker-form .btn { + width: 100%; + } +} + +@media (max-width: 620px) { + .training-pace-heatmap { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .training-marker-entry { + grid-template-columns: 1fr auto; + } + + .training-marker-entry > b { + grid-column: 1 / -1; + grid-row: 2; + } +} diff --git a/src/training_logic.js b/src/training_logic.js index d95ae83..82c4184 100644 --- a/src/training_logic.js +++ b/src/training_logic.js @@ -70,7 +70,25 @@ export function normalizeTrainingState(training, { uid }) { }) : []; - return { goals, ledger, shares }; + const markers = Array.isArray(source.markers) + ? source.markers + .map((marker) => ({ + id: String(marker?.id || uid("training_marker")), + driverId: String(marker?.driverId || ""), + classId: String(marker?.classId || ""), + sessionId: String(marker?.sessionId || ""), + timestamp: Math.max(0, Number(marker?.timestamp || 0) || 0), + type: ["setup", "tires", "suspension", "car", "note"].includes(String(marker?.type || "").toLowerCase()) + ? String(marker.type).toLowerCase() + : "setup", + note: String(marker?.note || "").trim(), + createdAt: String(marker?.createdAt || new Date().toISOString()), + })) + .filter((marker) => marker.driverId && marker.classId && marker.timestamp > 0) + .sort((left, right) => left.timestamp - right.timestamp) + : []; + + return { goals, ledger, shares, markers }; } function getPassingSourceKey(sessionId, passing, index) { @@ -111,7 +129,7 @@ export function syncTrainingLedger(training, state, { uid }) { }); }); - return normalizeTrainingState({ goals: normalized.goals, ledger: [...retained, ...current], shares: normalized.shares }, { uid }); + return normalizeTrainingState({ goals: normalized.goals, ledger: [...retained, ...current], shares: normalized.shares, markers: normalized.markers }, { uid }); } export function getDriverTrainingLaps(state, driverId, classId = "") { @@ -145,6 +163,137 @@ function consistencySpread(values) { return mean ? ((percentile(values, 0.9) - percentile(values, 0.1)) / mean) * 100 : 0; } +function trimmedAverage(values, trimRatio = 0.1) { + if (!values.length) return 0; + const sorted = [...values].sort((left, right) => left - right); + const trimCount = sorted.length >= 5 ? Math.max(1, Math.floor(sorted.length * trimRatio)) : 0; + const kept = trimCount ? sorted.slice(trimCount, sorted.length - trimCount) : sorted; + return average(kept); +} + +export function getDriverTrainingMarkers(state, driverId, classId = "") { + const profileReferences = getTrainingDriverIds(state, driverId, classId); + const normalizedClassId = String(classId || ""); + return (Array.isArray(state?.training?.markers) ? state.training.markers : []) + .filter((marker) => ( + profileReferences.has(String(marker?.driverId || "")) + && (!normalizedClassId || String(marker?.classId || "") === normalizedClassId) + )) + .map((marker) => ({ ...marker })) + .sort((left, right) => Number(left.timestamp || 0) - Number(right.timestamp || 0)); +} + +export function buildTrainingPaceBuckets(state, driverId, classId = "", bucketMinutes = 30) { + const laps = getDriverTrainingLaps(state, driverId, classId); + const bucketMs = Math.max(5, Number(bucketMinutes || 30)) * 60 * 1000; + const buckets = new Map(); + laps.forEach((lap) => { + const date = new Date(lap.timestamp); + const bucketMinutesValue = bucketMs / 60000; + const startTs = new Date( + date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), + Math.floor(date.getMinutes() / bucketMinutesValue) * bucketMinutesValue, 0, 0 + ).getTime(); + const key = String(startTs); + const bucket = buckets.get(key) || { startTs, endTs: startTs + bucketMs, laps: [], sessionIds: new Set() }; + bucket.laps.push(lap); + if (lap.sessionId) bucket.sessionIds.add(lap.sessionId); + buckets.set(key, bucket); + }); + const markers = getDriverTrainingMarkers(state, driverId, classId); + return [...buckets.values()] + .sort((left, right) => left.startTs - right.startTs) + .map((bucket) => { + const values = bucket.laps.map((lap) => lap.lapMs); + return { + startTs: bucket.startTs, + endTs: bucket.endTs, + lapCount: values.length, + paceMs: trimmedAverage(values), + medianMs: percentile(values, 0.5), + bestLapMs: values.length ? Math.min(...values) : 0, + sessionIds: [...bucket.sessionIds], + markers: markers.filter((marker) => marker.timestamp >= bucket.startTs && marker.timestamp < bucket.endTs), + }; + }); +} + +export function buildTrainingWeekdayStats(state, driverId, classId = "") { + const groups = Array.from({ length: 7 }, (_, weekday) => ({ weekday, laps: [] })); + getDriverTrainingLaps(state, driverId, classId).forEach((lap) => groups[new Date(lap.timestamp).getDay()].laps.push(lap.lapMs)); + return groups.map((group) => ({ + weekday: group.weekday, + lapCount: group.laps.length, + paceMs: trimmedAverage(group.laps), + bestLapMs: group.laps.length ? Math.min(...group.laps) : 0, + })); +} + +export function buildTrainingMarkerComparisons(state, driverId, classId = "", windowMinutes = 30) { + const laps = getDriverTrainingLaps(state, driverId, classId); + const windowMs = Math.max(5, Number(windowMinutes || 30)) * 60 * 1000; + return getDriverTrainingMarkers(state, driverId, classId).map((marker) => { + const comparableLaps = marker.sessionId ? laps.filter((lap) => lap.sessionId === marker.sessionId) : laps; + const before = comparableLaps.filter((lap) => lap.timestamp < marker.timestamp && lap.timestamp >= marker.timestamp - windowMs); + const after = comparableLaps.filter((lap) => lap.timestamp >= marker.timestamp && lap.timestamp < marker.timestamp + windowMs); + const beforePaceMs = before.length >= 3 ? trimmedAverage(before.map((lap) => lap.lapMs)) : 0; + const afterPaceMs = after.length >= 3 ? trimmedAverage(after.map((lap) => lap.lapMs)) : 0; + return { + ...marker, + beforeLapCount: before.length, + afterLapCount: after.length, + beforePaceMs, + afterPaceMs, + improvementMs: beforePaceMs && afterPaceMs ? beforePaceMs - afterPaceMs : null, + }; + }); +} + +function getBestConsecutiveAverage(laps, count) { + let best = 0; + for (let index = 0; index <= laps.length - count; index += 1) { + const candidate = average(laps.slice(index, index + count).map((lap) => lap.lapMs)); + if (!best || candidate < best) best = candidate; + } + return best; +} + +export function buildTrainingStintAnalysis(state, driverId, classId = "") { + const stints = splitTrainingStints(getDriverTrainingLaps(state, driverId, classId)); + const rows = stints.map((stint, index) => { + const laps = stint.laps.filter((lap) => !lap.excludedFromPace); + const values = laps.map((lap) => lap.lapMs); + const medianMs = percentile(values, 0.5); + const mistakeThresholdMs = medianMs ? medianMs * 1.15 : 0; + const mistakes = values.filter((lapMs) => mistakeThresholdMs && lapMs > mistakeThresholdMs).length; + const comparisonSize = values.length >= 6 ? Math.max(3, Math.floor(values.length / 3)) : 0; + const startPaceMs = comparisonSize ? trimmedAverage(values.slice(0, comparisonSize)) : 0; + const endPaceMs = comparisonSize ? trimmedAverage(values.slice(-comparisonSize)) : 0; + return { + id: `${stint.sessionId}:${stint.startedAt}:${index}`, + number: index + 1, + sessionId: stint.sessionId, + startedAt: stint.startedAt, + endedAt: stint.endedAt, + durationMs: stint.durationMs, + lapCount: values.length, + bestLapMs: values.length ? Math.min(...values) : 0, + paceMs: trimmedAverage(values), + racePace10Ms: values.length >= 10 ? getBestConsecutiveAverage(laps, 10) : 0, + mistakes, + mistakePct: values.length ? (mistakes / values.length) * 100 : 0, + consistencyPct: values.length >= 3 ? consistencySpread(values) : null, + startPaceMs, + endPaceMs, + endChangeMs: startPaceMs && endPaceMs ? startPaceMs - endPaceMs : null, + insufficient: values.length < 3, + }; + }); + const eligible = rows.filter((row) => !row.insufficient); + const best = eligible.reduce((current, row) => (!current || row.paceMs < current.paceMs ? row : current), null); + return { rows: rows.sort((left, right) => right.startedAt - left.startedAt), best }; +} + export function buildTrainingAchievements(stats) { const source = stats && typeof stats === "object" ? stats : {}; const definitions = [ diff --git a/src/training_views.js b/src/training_views.js index 7608491..6b86311 100644 --- a/src/training_views.js +++ b/src/training_views.js @@ -1,6 +1,30 @@ import { getDriverProfileGroups, getDriverProfileReferences } from "./driver_profile_logic.js"; +function renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock }) { + const endChange = row.endChangeMs == null + ? "-" + : row.endChangeMs > 0 + ? t("training.stint_faster", { lap: formatLap(row.endChangeMs) }) + : row.endChangeMs < 0 + ? t("training.stint_slower", { lap: formatLap(Math.abs(row.endChangeMs)) }) + : t("training.stint_same"); + return ` + + ${row.number} + ${escapeHtml(new Date(row.startedAt).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" }))} + ${row.lapCount} + ${formatLap(row.bestLapMs)} + ${formatLap(row.paceMs)} + ${row.racePace10Ms ? formatLap(row.racePace10Ms) : "-"} + ${row.mistakes} (${row.mistakePct.toFixed(0)}%) + ${escapeHtml(endChange)} + ${row.consistencyPct == null ? escapeHtml(t("training.insufficient_data")) : `${row.consistencyPct.toFixed(1)}%`} + ${formatRaceClock(row.durationMs)} + `; +} + export function renderTrainingView(deps) { + const extraStatsWasOpen = Boolean(document.getElementById("trainingExtraStats")?.open); const { state, dom, @@ -19,6 +43,10 @@ export function renderTrainingView(deps) { buildTrainingRecommendation, getTrainingGoalProgress, getNextTrainingMilestone, + buildTrainingPaceBuckets, + buildTrainingWeekdayStats, + buildTrainingStintAnalysis, + buildTrainingMarkerComparisons, getSelectedDriverId, setSelectedDriverId, getSelectedClassId, @@ -61,12 +89,17 @@ export function renderTrainingView(deps) { setSelectedClassId(selectedClassId); } 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); const channelStats = buildDriverTimingChannelStats(state, selectedProfileId, selectedClassId); const activePractice = buildDriverActivePracticeStats(state, selectedProfileId, Date.now(), selectedClassId); const recommendation = buildTrainingRecommendation(stats); const nextMilestone = getNextTrainingMilestone(stats); + const paceBuckets = buildTrainingPaceBuckets(state, selectedProfileId, selectedClassId, 30).slice(-24); + const weekdayStats = buildTrainingWeekdayStats(state, selectedProfileId, selectedClassId); + const stintAnalysis = buildTrainingStintAnalysis(state, selectedProfileId, selectedClassId); + const markerComparisons = buildTrainingMarkerComparisons(state, selectedProfileId, selectedClassId, 30).slice().reverse(); const goals = state.training.goals.filter((goal) => ( profileReferences.has(String(goal.driverId || "")) && (!goal.classId || String(goal.classId) === selectedClassId) @@ -182,6 +215,56 @@ export function renderTrainingView(deps) { `; }).join(""); + const paceValues = paceBuckets.map((bucket) => bucket.paceMs).filter(Boolean); + const fastestPace = paceValues.length ? Math.min(...paceValues) : 0; + const slowestPace = paceValues.length ? Math.max(...paceValues) : 0; + const paceHeatmap = paceBuckets.length + ? paceBuckets.map((bucket) => { + const range = Math.max(1, slowestPace - fastestPace); + const score = slowestPace === fastestPace ? 0.7 : Math.max(0, Math.min(1, (slowestPace - bucket.paceMs) / range)); + const heat = Math.round(12 + score * 56); + return ` +
+ ${escapeHtml(new Date(bucket.startTs).toLocaleDateString(undefined, { month: "short", day: "numeric" }))} + ${escapeHtml(new Date(bucket.startTs).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }))} + ${formatLap(bucket.paceMs)} + ${escapeHtml(t("training.lap_count", { laps: bucket.lapCount }))} - PB ${formatLap(bucket.bestLapMs)} + ${bucket.markers.map((marker) => `${escapeHtml(t(`training.marker_${marker.type}`))}`).join("")} +
`; + }).join("") + : `

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

`; + const activeWeekdays = weekdayStats.filter((day) => day.lapCount > 0); + const weekdayMarkup = stats.trainingDays >= 3 && activeWeekdays.length + ? activeWeekdays.map((day) => { + const weekday = new Intl.DateTimeFormat(undefined, { weekday: "short" }).format(new Date(2026, 7, 23 + day.weekday)); + return `
${escapeHtml(weekday)}${formatLap(day.paceMs)}${escapeHtml(t("training.lap_count", { laps: day.lapCount }))}
`; + }).join("") + : `

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

`; + const featuredStints = [stintAnalysis.best, ...stintAnalysis.rows.slice(0, 3)] + .filter((row, index, rows) => row && 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("table.duration"))}`; + const featuredStintRows = featuredStints.length + ? featuredStints.map((row) => renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock })).join("") + : `${escapeHtml(t("training.no_stints"))}`; + const allStintRows = stintAnalysis.rows.map((row) => renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock })).join(""); + const markerMarkup = markerComparisons.length + ? markerComparisons.map((marker) => { + const comparison = marker.improvementMs == null + ? t("training.marker_collecting", { before: marker.beforeLapCount, after: marker.afterLapCount }) + : marker.improvementMs > 0 + ? t("training.marker_improved", { lap: formatLap(marker.improvementMs) }) + : marker.improvementMs < 0 + ? t("training.marker_slower", { lap: formatLap(Math.abs(marker.improvementMs)) }) + : t("training.stint_same"); + return ` +
+
${escapeHtml(t(`training.marker_${marker.type}`))}${escapeHtml(marker.note || t(`training.marker_${marker.type}`))}${escapeHtml(new Date(marker.timestamp).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" }))}
+ ${escapeHtml(comparison)} + +
`; + }).join("") + : `

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

`; + dom.view.innerHTML = `
@@ -335,6 +418,62 @@ export function renderTrainingView(deps) {
+
+ + ${escapeHtml(t("training.extra_stats"))}${escapeHtml(t("training.extra_stats_hint"))} + + + +
+
+

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

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

+ ${paceBuckets.length} +
+
${paceHeatmap}
+ +
+
+

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

+

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

+
+ + + +
+
${markerMarkup}
+
+
+

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

+

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

+
${weekdayMarkup}
+
+
+ +
+

${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}
+
+
` : ""} +

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

+
+
+
${escapeHtml(t("training.detailed_stats"))}${escapeHtml(t("training.detailed_stats_hint"))} @@ -403,6 +542,31 @@ export function renderTrainingView(deps) {
`; + document.getElementById("trainingMarkerForm")?.addEventListener("submit", (event) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + state.training.markers = Array.isArray(state.training.markers) ? state.training.markers : []; + state.training.markers.push({ + id: uid("training_marker"), + driverId: selectedClassDriver.id, + classId: selectedClassId, + sessionId: activePractice?.sessionId || "", + timestamp: Date.now(), + type: String(form.get("type") || "setup"), + note: String(form.get("note") || "").trim(), + createdAt: new Date().toISOString(), + }); + saveState(); + renderView(); + }); + document.querySelectorAll("[data-training-marker-delete]").forEach((button) => { + button.addEventListener("click", () => { + state.training.markers = (state.training.markers || []).filter((marker) => marker.id !== button.dataset.trainingMarkerDelete); + saveState(); + renderView(); + }); + }); + document.getElementById("trainingBackOverview")?.addEventListener("click", () => { setSelectedDriverId(""); setSelectedClassId(""); diff --git a/tests/training_logic.test.js b/tests/training_logic.test.js index cbcc580..bd7f894 100644 --- a/tests/training_logic.test.js +++ b/tests/training_logic.test.js @@ -6,7 +6,11 @@ import { buildDriverTrainingStats, buildDriverTimingChannelStats, buildTrainingAchievements, + buildTrainingMarkerComparisons, + buildTrainingPaceBuckets, buildTrainingRecommendation, + buildTrainingStintAnalysis, + buildTrainingWeekdayStats, getBestConsecutiveTrainingLaps, getBestTrainingDurationResult, getDriverTrainingLaps, @@ -40,6 +44,10 @@ test("normalization removes invalid and duplicate training records", () => { { driverId: "driver_1", token: "b".repeat(24), enabled: true }, { driverId: "driver_2", token: "short", enabled: true }, ], + markers: [ + { id: "marker_1", driverId: "driver_1", classId: "class_1", timestamp: 5000, type: "tires", note: " New tires " }, + { id: "marker_invalid", driverId: "", classId: "class_1", timestamp: 5000 }, + ], }, { uid: createUid() }); assert.equal(normalized.goals.length, 1); @@ -47,6 +55,8 @@ test("normalization removes invalid and duplicate training records", () => { assert.equal(normalized.goals[0].reward, "Badge"); assert.deepEqual(normalized.ledger.map((lap) => lap.id), ["lap_1"]); assert.deepEqual(normalized.shares.map((share) => share.token), ["a".repeat(24)]); + assert.equal(normalized.markers.length, 1); + assert.equal(normalized.markers[0].note, "New tires"); }); test("ledger sync retains archived laps and rebuilds current session laps", () => { @@ -57,6 +67,7 @@ test("ledger sync retains archived laps and rebuilds current session laps", () = { id: "archived", sourceKey: "old:1", sessionId: "old", driverId: "driver_1", lapMs: 19000, timestamp: 1000 }, { id: "stale", sourceKey: "live:old", sessionId: "live", driverId: "driver_1", lapMs: 30000, timestamp: 2000 }, ], + markers: [{ id: "marker_1", driverId: "driver_1", classId: "class_1", timestamp: 2500, type: "setup" }], }; const state = { sessions: [{ id: "live", name: "Free Practice", eventId: "event_1" }], @@ -84,6 +95,7 @@ test("ledger sync retains archived laps and rebuilds current session laps", () = assert.equal(liveLap.eventName, "Club practice"); assert.equal(synced.goals.length, 1); assert.equal(synced.shares.length, 1); + assert.equal(synced.markers.length, 1); }); test("driver statistics remain isolated and aggregate days and sessions", () => { @@ -371,3 +383,75 @@ test("automatic achievements expose unlocked and upcoming personal goals", () => assert.equal(achievements.find((item) => item.id === "days_10").unlocked, false); assert.equal(achievements.find((item) => item.id === "five_minute").unlocked, true); }); + +test("pace buckets use a trimmed average and keep classes isolated", () => { + const start = new Date(2026, 7, 23, 12, 0, 0).getTime(); + const twoWheelLaps = [10_000, ...Array.from({ length: 10 }, () => 20_000), 60_000]; + const state = { + drivers: [ + { id: "driver_2wd", profileId: "profile", classId: "2wd" }, + { id: "driver_4wd", profileId: "profile", classId: "4wd" }, + ], + training: { + ledger: [ + ...twoWheelLaps.map((lapMs, index) => ({ driverId: "driver_2wd", sessionId: "practice", timestamp: start + index * 60_000, lapMs })), + { driverId: "driver_4wd", sessionId: "practice", timestamp: start + 60_000, lapMs: 14_000 }, + ], + markers: [], + }, + }; + const buckets = buildTrainingPaceBuckets(state, "profile", "2wd", 30); + const weekdays = buildTrainingWeekdayStats(state, "profile", "2wd"); + + assert.equal(buckets.length, 1); + assert.equal(buckets[0].lapCount, 12); + assert.equal(buckets[0].paceMs, 20_000); + assert.equal(weekdays.find((day) => day.weekday === 0).lapCount, 12); +}); + +test("setup markers compare only the selected class before and after the change", () => { + const markerAt = new Date(2026, 7, 23, 13, 0, 0).getTime(); + const state = { + drivers: [ + { id: "driver_2wd", profileId: "profile", classId: "2wd" }, + { id: "driver_4wd", profileId: "profile", classId: "4wd" }, + ], + training: { + ledger: [ + ...[25, 20, 15].map((minutes, index) => ({ driverId: "driver_2wd", timestamp: markerAt - minutes * 60_000, lapMs: 20_000 + index * 100 })), + ...[5, 10, 15].map((minutes, index) => ({ driverId: "driver_2wd", timestamp: markerAt + minutes * 60_000, lapMs: 18_000 + index * 100 })), + { driverId: "driver_4wd", timestamp: markerAt + 60_000, lapMs: 12_000 }, + ], + markers: [{ id: "marker", driverId: "driver_2wd", classId: "2wd", timestamp: markerAt, type: "tires" }], + }, + }; + const [comparison] = buildTrainingMarkerComparisons(state, "profile", "2wd", 30); + + assert.equal(comparison.beforeLapCount, 3); + assert.equal(comparison.afterLapCount, 3); + assert.equal(comparison.beforePaceMs, 20_100); + assert.equal(comparison.afterPaceMs, 18_100); + assert.equal(comparison.improvementMs, 2_000); +}); + +test("stint analysis hides race pace 10 for short stints and flags slow laps", () => { + const firstStart = new Date(2026, 7, 23, 12, 0, 0).getTime(); + const firstLaps = [...Array.from({ length: 10 }, () => 18_000), 30_000]; + let timestamp = firstStart; + const ledger = firstLaps.map((lapMs) => { + timestamp += lapMs; + return { driverId: "driver", sessionId: "practice", timestamp, lapMs }; + }); + ledger.push({ driverId: "driver", sessionId: "practice", timestamp: timestamp + 180_001, lapMs: 18_000 }); + ledger.push({ driverId: "driver", sessionId: "practice", timestamp: timestamp + 198_001, lapMs: 18_000 }); + const analysis = buildTrainingStintAnalysis({ training: { ledger } }, "driver"); + + assert.equal(analysis.rows.length, 2); + const longStint = analysis.rows.find((row) => row.lapCount === 11); + const shortStint = analysis.rows.find((row) => row.lapCount === 2); + assert.equal(longStint.racePace10Ms, 18_000); + assert.equal(longStint.mistakes, 1); + assert.equal(shortStint.racePace10Ms, 0); + assert.equal(shortStint.consistencyPct, null); + assert.equal(shortStint.insufficient, true); +});