From c2ed00861afa8ee8ccc6e08b2b58d80b88188739 Mon Sep 17 00:00:00 2001 From: larssand Date: Mon, 24 Aug 2026 16:06:36 +0200 Subject: [PATCH] Add persistent driver training history and progression --- README.md | 8 +- README.sv.md | 8 +- docs/javascript-architecture.md | 43 ++++++++-- src/app.js | 56 +++++++++++- src/misc_views.js | 7 +- src/styles.css | 136 ++++++++++++++++++++++++++++- src/training_logic.js | 148 ++++++++++++++++++++++++++++---- src/training_views.js | 54 +++++++++++- 8 files changed, 424 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 1a67c6c..891abdf 100644 --- a/README.md +++ b/README.md @@ -172,8 +172,10 @@ Recommended deployment: The `Training` menu provides a non-race progression view for registered drivers: -- lifetime valid laps calculated from stored session results -- number of recorded sessions, personal best, and recent consistency spread +- durable lifetime valid-lap ledger synchronized from stored session results +- practice days, recorded sessions, personal best, overall/recent averages, controlled-lap streak, and recent consistency spread +- daily history for the latest practice days and recent personal-best improvement +- automatic progression levels at useful lap totals without manual setup - a 20-minute recommendation split into maximum-focus and controlled 80-90% blocks - custom lap milestones with quick targets at `500`, `1000`, and `1500` laps - optional reward text and a delivered/redeemed state @@ -183,6 +185,8 @@ 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. + ## Create Race Wizard - `Race Setup` now starts with a four-step wizard for new races: diff --git a/README.sv.md b/README.sv.md index 45fffa9..79ccd2d 100644 --- a/README.sv.md +++ b/README.sv.md @@ -161,8 +161,10 @@ Rekommenderad deploy: Menyn `Träning` ger förarna utveckling och delmål även mellan tävlingarna: -- totalt antal giltiga varv beräknat från sparade sessionsresultat -- antal sessioner, personbästa och spridning på de senaste varven +- beständig logg med totalt antal giltiga varv synkad från sparade sessionsresultat +- träningsdagar, antal sessioner, personbästa, total-/30-varvssnitt, kontrollerad varvserie och spridning +- daglig historik för de senaste träningsdagarna och förbättring mot tidigare personbästa +- automatiska utvecklingsnivåer vid lämpliga varvantal utan manuell konfiguration - rekommenderat 20-minuterspass uppdelat i maxfokus och kontrollerad körning på 80-90 procent - egna varvmål med snabbval på `500`, `1000` och `1500` varv - valfri reward/belöning och markering när den har delats ut @@ -172,6 +174,8 @@ 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. + ## Create Race Wizard - `Race Setup` startar nu med en fyrstegs wizard för nya race: diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 4d05eaa..f76d9c8 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -399,16 +399,21 @@ Type: Owns: - training-state normalization -- valid driver-lap aggregation across `resultsBySession` -- lifetime/session/best-lap/consistency calculations +- durable ledger synchronization from `resultsBySession` +- valid driver-lap aggregation across the training ledger +- lifetime/session/day/best-lap/average/consistency calculations +- daily summaries, controlled-lap streak, and recent personal-best gain - rule-based practice recommendation selection -- milestone progress calculation +- automatic and custom milestone progress calculation Rules: - only passings with a matching `driverId` are included - invalid laps and lap times at or below `500 ms` are excluded - anonymous Open Practice transponders are excluded until registered as drivers - consistency is the P10-to-P90 spread of the latest 30 valid laps divided by their mean +- every save replaces ledger rows only for session IDs that still exist in `resultsBySession` +- ledger rows for a deleted session are retained and become historical records +- `sourceKey` deduplicates repeated saves of the same session passing ### `src/training_views.js` Type: @@ -417,6 +422,7 @@ Type: Owns: - driver selector - training stat cards and recommendation display +- automatic next-level card and daily practice history - milestone/reward create, claim, and delete actions - mobile-responsive training markup @@ -1435,7 +1441,8 @@ This bucket is the most dynamic part of state and is mutated heavily during live ```js { - goals: TrainingGoal[] + goals: TrainingGoal[], + ledger: TrainingLap[] } ``` @@ -1453,20 +1460,40 @@ This bucket is the most dynamic part of state and is mutated heavily during live } ``` +`TrainingLap`: + +```js +{ + id: string, + sourceKey: string, // sessionId + passing timestamp + competitor + passing index + sessionId: string, + sessionName: string, + eventId: string, + eventName: string, + driverId: string, + timestamp: number, + lapMs: number, + recordedAt: string +} +``` + Normalized by: - `normalizeTrainingState()` wrapper in `src/app.js` - `normalizeTrainingState()` implementation in `src/training_logic.js` +- `syncTrainingLedger()` wrapper in `src/app.js` +- `syncTrainingLedger()` implementation in `src/training_logic.js` Persisted through: - `buildPersistableState()` in `src/app.js` - `applyPersistedStateHelper()` in `src/runtime_services.js` - existing full-state `POST /api/state` backend path -Derived statistics are intentionally not stored. They are recalculated from `resultsBySession` so corrections and invalidated laps are reflected immediately. +Derived statistics are intentionally not stored. They are recalculated from `state.training.ledger`. Before persistence, `saveState()` synchronizes every session currently present in `resultsBySession`: existing rows for those session IDs are replaced from current valid passings, so judging corrections are reflected. Session IDs absent from `resultsBySession` are not removed from the ledger, preserving lifetime totals after archival or deletion. -Known lifecycle constraint: -- deleting historical sessions removes their source passings and therefore lowers calculated lifetime totals -- a future immutable training ledger should be introduced before session archival/deletion is used routinely +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 ## 15.9 `Settings` diff --git a/src/app.js b/src/app.js index 063885c..37af21f 100644 --- a/src/app.js +++ b/src/app.js @@ -17,7 +17,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, speakTextHelper, announcePassingHelper, announceRaceFinishedHelper, handleSessionTimerTickHelper, tickClockHelper } from "./runtime_services.js"; import { connectDecoderHelper, disconnectDecoderHelper, processDecoderMessageHelper } from "./decoder_runtime.js"; -import { normalizeTrainingState as normalizeTrainingStateLogic, buildDriverTrainingStats as buildDriverTrainingStatsLogic, buildTrainingRecommendation as buildTrainingRecommendationLogic, getTrainingGoalProgress as getTrainingGoalProgressLogic } from "./training_logic.js"; +import { normalizeTrainingState as normalizeTrainingStateLogic, syncTrainingLedger as syncTrainingLedgerLogic, buildDriverTrainingStats as buildDriverTrainingStatsLogic, buildTrainingRecommendation as buildTrainingRecommendationLogic, getTrainingGoalProgress as getTrainingGoalProgressLogic, getNextTrainingMilestone as getNextTrainingMilestoneLogic } from "./training_logic.js"; import { renderTrainingView } from "./training_views.js"; @@ -198,10 +198,15 @@ const renderOverlay = () => renderOverlayPageView({ state, dom, t, escapeHtml, f const renderTiming = () => renderTimingView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatRaceClock, renderTable, uid, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings, getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid, connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, ensureAudioContext, validateTrackSessionForStart, pushOverlayEvent, saveState, updateHeaderState, renderView, normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey: () => selectedLeaderboardKey, setSelectedLeaderboardKey: (value) => { selectedLeaderboardKey = value; }, getQuickAddDraft: () => quickAddDraft, setQuickAddDraft: (value) => { quickAddDraft = value; }, getPassingValidationLabel, isCountedPassing, getCompetitorPassings, getManualCorrectionSummary, formatTeamActiveMemberLabel, lastFinishAnnouncementSessionId: () => lastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId: (value) => { lastFinishAnnouncementSessionId = value; }, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, overlayEvents: () => overlayEvents, setOverlayEvents: (value) => { overlayEvents = value; } }); const renderJudging = () => renderJudgingView({ state, dom, t, escapeHtml, formatLap, renderTable, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTypeLabel, getJudgeFilteredRows, getJudgeFilteredLog, getCompetitorPassings, isCountedPassing, getPassingValidationLabel, undoJudgingAdjustment, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, renderView, getSelectedJudgeKey: () => selectedJudgeKey, setSelectedJudgeKey: (value) => { selectedJudgeKey = value; }, getJudgingCompetitorFilter: () => judgingCompetitorFilter, setJudgingCompetitorFilter: (value) => { judgingCompetitorFilter = value; }, getJudgingLogFilter: () => judgingLogFilter, setJudgingLogFilter: (value) => { judgingLogFilter = value; } }); const normalizeTrainingState = (training) => normalizeTrainingStateLogic(training, { uid }); +const syncTrainingLedger = () => { + state.training = syncTrainingLedgerLogic(state.training, state, { uid }); + return state.training; +}; const buildDriverTrainingStats = (trainingState, driverId) => buildDriverTrainingStatsLogic(trainingState, driverId); const buildTrainingRecommendation = (stats) => buildTrainingRecommendationLogic(stats); const getTrainingGoalProgress = (goal, stats) => getTrainingGoalProgressLogic(goal, stats); -const renderTraining = () => renderTrainingView({ state, dom, t, escapeHtml, formatLap, uid, saveState, renderView, normalizeTrainingState, buildDriverTrainingStats, buildTrainingRecommendation, getTrainingGoalProgress, getSelectedDriverId: () => selectedTrainingDriverId, setSelectedDriverId: (value) => { selectedTrainingDriverId = value; updateViewUrl("training", value); } }); +const getNextTrainingMilestone = (stats) => getNextTrainingMilestoneLogic(stats); +const renderTraining = () => renderTrainingView({ state, dom, t, escapeHtml, formatLap, uid, saveState, renderView, normalizeTrainingState, syncTrainingLedger, buildDriverTrainingStats, buildTrainingRecommendation, getTrainingGoalProgress, getNextTrainingMilestone, getSelectedDriverId: () => selectedTrainingDriverId, setSelectedDriverId: (value) => { selectedTrainingDriverId = value; updateViewUrl("training", value); } }); const applyCompetitorCorrection = (session, row, options = {}) => applyCompetitorCorrectionLogic(session, row, options, { ensureSessionResult, uid, t, formatLap, saveState }); const recalculateCompetitorFromPassings = (session, rowKey) => recalculateCompetitorFromPassingsLogic(session, rowKey, { ensureSessionResult, getCompetitorPassings, isCountedPassing }); const invalidateCompetitorLastLap = (session, row) => invalidateCompetitorLastLapLogic(session, row, { ensureSessionResult, getCompetitorPassings, isCountedPassing, recalculateCompetitorFromPassings, uid, t, formatLap, saveState }); @@ -314,6 +319,21 @@ const TRANSLATIONS = { "training.goal_reached": "Målet är nått", "training.goal_claimed": "Belöning utdelad", "training.claim_reward": "Markera belöning utdelad", + "training.training_days": "Träningsdagar", + "training.sessions_count": "{sessions} sparade sessioner", + "training.next_level": "Nästa automatiska nivå", + "training.next_level_target": "Nå {laps} varv", + "training.average_lap": "Snittvarv", + "training.recent_average": "Snitt senaste 30", + "training.controlled_streak": "Kontrollerad serie", + "training.lap_count": "{laps} varv", + "training.last_activity": "Senaste aktivitet", + "training.no_activity": "Ingen aktivitet ännu", + "training.history": "Träningshistorik per dag", + "training.no_history": "Ingen träningshistorik finns ännu.", + "training.date": "Datum", + "training.laps": "Varv", + "training.pb_improvement": "Senaste 30: {lap} snabbare", "mode.track": "Sponsor Event", "mode.race": "Race", "dashboard.events": "Event", @@ -936,6 +956,7 @@ const TRANSLATIONS = { "guide.card_sponsor_blurb": "Delade bilar, roterande förare och heat/finaler för sponsor- och prova-på-event.", "guide.card_race_blurb": "Personliga transpondrar, kval/finaler, raceformat och seedning för riktiga tävlingsrace.", "guide.card_team_blurb": "Lagrace och endurance med team, stintlogg och summerade varv över lång tid.", + "guide.card_training_blurb": "Förarutveckling, beständig varvhistorik, träningsmål och belöningar i mobilvänlig vy.", "guide.card_decoder_blurb": "AMMC, backend, WebSocket, Windows/Linux och lokal SQLite-lagring.", "guide.sponsor_title": "Skapa Sponsor Event: 10 personer, 4 bilar", "guide.sponsor_1": "1. Lägg upp 4 bilar i sidan Bilar med unikt transponder-ID. Lägg gärna också in märke/modell i brandfältet.", @@ -1007,6 +1028,13 @@ const TRANSLATIONS = { "guide.dashboard_1": "Översikt visar nu skillnaden mellan planerad tid och faktisk körtid för alla startade sessioner.", "guide.dashboard_2": "Planerad tid räknar sessionstid plus follow-up. Faktisk tid räknar verklig tid från start till stopp eller nuvarande tid om heatet fortfarande kör.", "guide.dashboard_3": "Det gör det lättare att se om tävlingsdagen ligger före eller efter schema direkt från dashboarden.", + "guide.training_title": "Träning, utveckling och belöningar", + "guide.training_1": "1. Registrera föraren och koppla den personliga transpondern under Förare. Endast passeringar med ett registrerat förar-ID kan räknas i träningsprofilen.", + "guide.training_2": "2. Kör Free Practice, Open Practice eller en vanlig racesession. Giltiga varv kopieras automatiskt till en beständig träningslogg; korta/långa ogiltiga varv räknas inte.", + "guide.training_3": "3. Öppna Träning och välj förare. Sidan visar totalvarv, träningsdagar, personbästa, snitt, senaste 30 varv, kontrollerad serie och historik per dag.", + "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.", + "guide.training_6": "6. Träningsloggen uppdateras av domarkorrigeringar medan sessionen finns, men behålls när sessionen senare raderas. Full dataexport i Inställningar inkluderar både logg och mål.", "overlay.title": "Overlay", "overlay.subtitle": "Extern leaderboard-skärm", "overlay.no_active": "Ingen aktiv session vald.", @@ -1163,6 +1191,21 @@ const TRANSLATIONS = { "training.goal_reached": "Milestone reached", "training.goal_claimed": "Reward delivered", "training.claim_reward": "Mark reward delivered", + "training.training_days": "Practice days", + "training.sessions_count": "{sessions} recorded sessions", + "training.next_level": "Next automatic level", + "training.next_level_target": "Reach {laps} laps", + "training.average_lap": "Average lap", + "training.recent_average": "Latest 30 average", + "training.controlled_streak": "Controlled streak", + "training.lap_count": "{laps} laps", + "training.last_activity": "Latest activity", + "training.no_activity": "No activity yet", + "training.history": "Daily practice history", + "training.no_history": "No practice history is available yet.", + "training.date": "Date", + "training.laps": "Laps", + "training.pb_improvement": "Latest 30: {lap} faster", "mode.track": "Track Event", "mode.race": "Race", "dashboard.events": "Events", @@ -1785,6 +1828,7 @@ const TRANSLATIONS = { "guide.card_sponsor_blurb": "Shared cars, rotating drivers and heat/final flow for sponsor or try-out events.", "guide.card_race_blurb": "Personal transponders, qualifying/finals, race format and seeding for competition racing.", "guide.card_team_blurb": "Team race and endurance with teams, stint logs and total laps over long sessions.", + "guide.card_training_blurb": "Driver development, durable lap history, practice goals and rewards in a mobile-friendly view.", "guide.card_decoder_blurb": "AMMC, backend, WebSocket, Windows/Linux and local SQLite storage.", "guide.sponsor_title": "Create Sponsor Event: 10 drivers, 4 cars", "guide.sponsor_1": "1. Add 4 cars in Cars with unique transponder IDs. You can also store brand/model in the brand field.", @@ -1856,6 +1900,13 @@ const TRANSLATIONS = { "guide.dashboard_1": "Overview now shows the difference between planned time and actual elapsed time for all started sessions.", "guide.dashboard_2": "Planned time includes session duration plus follow-up. Actual time uses real time from start to stop, or the current time if the heat is still running.", "guide.dashboard_3": "That makes it easier to see whether the race day is running ahead or behind schedule directly from the dashboard.", + "guide.training_title": "Practice, development and rewards", + "guide.training_1": "1. Register the driver and personal transponder under Drivers. Only passings linked to a registered driver ID count toward the practice profile.", + "guide.training_2": "2. Run Free Practice, Open Practice or a regular race session. Valid laps are copied automatically to a durable training ledger; invalid short or long laps do not count.", + "guide.training_3": "3. Open Training and select a driver. The page shows total laps, practice days, personal best, averages, latest 30 laps, controlled streak and daily history.", + "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.", + "guide.training_6": "6. The training ledger follows judging corrections while the source session exists, but remains when that session is later deleted. Full data export in Settings includes both ledger and goals.", "overlay.title": "Overlay", "overlay.subtitle": "External leaderboard screen", "overlay.no_active": "No active session selected.", @@ -2472,6 +2523,7 @@ function setupLanguageControl() { } function buildPersistableState() { + syncTrainingLedger(); return { classes: state.classes, drivers: state.drivers.map((driver) => normalizeDriver(driver)), diff --git a/src/misc_views.js b/src/misc_views.js index a7ee221..250ee73 100644 --- a/src/misc_views.js +++ b/src/misc_views.js @@ -31,6 +31,7 @@ export function renderGuideView({ dom, t }) { ${renderGuideOverviewCard("guide.sponsor_title", "guide.card_sponsor_blurb", { t })} ${renderGuideOverviewCard("guide.race_title", "guide.card_race_blurb", { t })} ${renderGuideOverviewCard("guide.team_title", "guide.card_team_blurb", { t })} + ${renderGuideOverviewCard("guide.training_title", "guide.card_training_blurb", { t })} ${renderGuideOverviewCard("guide.windows_title", "guide.card_decoder_blurb", { t })} @@ -63,11 +64,15 @@ export function renderGuideView({ dom, t }) {
${renderGuidePanel("guide.dashboard_title", ["guide.dashboard_1", "guide.dashboard_2", "guide.dashboard_3"], "", { t })} - ${renderGuidePanel("guide.host_title", ["guide.host_1", "guide.host_2", "guide.host_3", "guide.host_4", "guide.host_5", "guide.host_6", "guide.host_7", "guide.host_8"], "", { t })} + ${renderGuidePanel("guide.training_title", ["guide.training_1", "guide.training_2", "guide.training_3", "guide.training_4", "guide.training_5", "guide.training_6"], "", { t })}
+ ${renderGuidePanel("guide.host_title", ["guide.host_1", "guide.host_2", "guide.host_3", "guide.host_4", "guide.host_5", "guide.host_6", "guide.host_7", "guide.host_8"], "", { t })} ${renderGuidePanel("guide.windows_title", ["guide.windows_1", "guide.windows_2", "guide.windows_3", "guide.windows_4", "guide.windows_5"], "", { t })} +
+ +
${renderGuidePanel("guide.linux_title", ["guide.linux_1", "guide.linux_2", "guide.linux_3"], "", { t })}
diff --git a/src/styles.css b/src/styles.css index 50a6d2c..f477232 100644 --- a/src/styles.css +++ b/src/styles.css @@ -200,6 +200,12 @@ body { background: rgba(225, 6, 0, 0.15); } +.badge.success { + color: color-mix(in srgb, #47d98a 70%, var(--text) 30%); + border-color: color-mix(in srgb, #47d98a 55%, var(--line) 45%); + background: color-mix(in srgb, #47d98a 14%, transparent); +} + .content { padding: 20px 24px 26px; } @@ -340,6 +346,107 @@ body { margin-top: 14px; } +.training-insight-grid { + display: grid; + grid-template-columns: minmax(280px, 0.8fr) minmax(0, 1.2fr); + gap: 14px; + margin-top: 14px; +} + +.training-milestone-card, +.training-insights { + border: 1px solid var(--line); + border-radius: 16px; + background: var(--panel); + box-shadow: var(--shadow); +} + +.training-milestone-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 8px 18px; + padding: 18px; + background: + linear-gradient(115deg, color-mix(in srgb, var(--accent) 14%, transparent), transparent 52%), + var(--panel); +} + +.training-milestone-card h3, +.training-milestone-card p { + margin: 5px 0 0; +} + +.training-milestone-card p, +.training-insights span { + color: var(--muted); +} + +.training-milestone-card > strong { + font-family: Orbitron, sans-serif; + white-space: nowrap; +} + +.training-milestone-card .training-progress { + grid-column: 1 / -1; + margin: 6px 0 0; +} + +.training-insights { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + overflow: hidden; +} + +.training-insights > div { + display: grid; + gap: 5px; + padding: 14px 16px; + border-bottom: 1px solid var(--line); +} + +.training-insights > div:nth-child(odd) { + border-right: 1px solid var(--line); +} + +.training-insights > div:nth-last-child(-n + 2) { + border-bottom: 0; +} + +.training-insights span { + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.training-insights strong { + overflow-wrap: anywhere; +} + +.training-history-wrap { + overflow-x: auto; +} + +.training-history-table { + width: 100%; + border-collapse: collapse; +} + +.training-history-table th, +.training-history-table td { + padding: 11px 12px; + border-bottom: 1px solid var(--line); + text-align: left; + white-space: nowrap; +} + +.training-history-table th { + color: var(--muted); + font-size: 0.75rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + .training-layout { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.75fr); @@ -883,7 +990,7 @@ body { } .guide-overview-grid { - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); } .guide-overview-card { @@ -2278,6 +2385,10 @@ select:focus { .training-layout { grid-template-columns: 1fr; } + + .training-insight-grid { + grid-template-columns: 1fr; + } } @media (max-width: 820px) { @@ -2332,6 +2443,29 @@ select:focus { grid-template-columns: 1fr; } + .training-milestone-card { + grid-template-columns: 1fr; + } + + .training-milestone-card .training-progress { + grid-column: auto; + } + + .training-insights { + grid-template-columns: 1fr; + } + + .training-insights > div, + .training-insights > div:nth-child(odd), + .training-insights > div:nth-last-child(-n + 2) { + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .training-insights > div:last-child { + border-bottom: 0; + } + .field-card-checkbox { grid-column: span 1; } diff --git a/src/training_logic.js b/src/training_logic.js index 57b7754..29bdde9 100644 --- a/src/training_logic.js +++ b/src/training_logic.js @@ -14,28 +14,89 @@ export function normalizeTrainingState(training, { uid }) { .filter((goal) => goal.driverId && goal.targetLaps > 0) : []; - return { goals }; + const seenLedgerKeys = new Set(); + const ledger = Array.isArray(source.ledger) + ? source.ledger + .map((entry) => { + const timestamp = Math.max(0, Number(entry?.timestamp || 0) || 0); + return { + id: String(entry?.id || uid("training_lap")), + sourceKey: String(entry?.sourceKey || ""), + sessionId: String(entry?.sessionId || ""), + sessionName: String(entry?.sessionName || "").trim(), + eventId: String(entry?.eventId || ""), + eventName: String(entry?.eventName || "").trim(), + driverId: String(entry?.driverId || ""), + timestamp, + lapMs: Math.max(0, Math.round(Number(entry?.lapMs || 0) || 0)), + recordedAt: String(entry?.recordedAt || (timestamp ? new Date(timestamp).toISOString() : new Date().toISOString())), + }; + }) + .filter((entry) => { + const key = entry.sourceKey || entry.id; + if (!entry.driverId || entry.lapMs <= 500 || seenLedgerKeys.has(key)) return false; + seenLedgerKeys.add(key); + return true; + }) + .sort((left, right) => left.timestamp - right.timestamp) + : []; + + return { goals, ledger }; +} + +function getPassingSourceKey(sessionId, passing, index) { + const competitor = String(passing?.competitorKey || passing?.driverId || passing?.transponder || "unknown"); + return `${sessionId}:${Number(passing?.timestamp || 0) || 0}:${competitor}:${index}`; +} + +export function syncTrainingLedger(training, state, { uid }) { + const normalized = normalizeTrainingState(training, { uid }); + const results = state?.resultsBySession && typeof state.resultsBySession === "object" ? state.resultsBySession : {}; + const currentSessionIds = new Set(Object.keys(results)); + const retained = normalized.ledger.filter((entry) => !currentSessionIds.has(entry.sessionId)); + const sessions = new Map((Array.isArray(state?.sessions) ? state.sessions : []).map((session) => [String(session.id), session])); + const events = new Map((Array.isArray(state?.events) ? state.events : []).map((event) => [String(event.id), event])); + const current = []; + + Object.entries(results).forEach(([sessionId, result]) => { + const session = sessions.get(String(sessionId)); + const event = session ? events.get(String(session.eventId || "")) : null; + (Array.isArray(result?.passings) ? result.passings : []).forEach((passing, index) => { + const driverId = String(passing?.driverId || ""); + const lapMs = Math.round(Number(passing?.lapMs || 0) || 0); + if (!driverId || passing?.validLap === false || lapMs <= 500) return; + const timestamp = Math.max(0, Number(passing?.timestamp || 0) || 0); + const sourceKey = getPassingSourceKey(sessionId, passing, index); + current.push({ + id: `training_${sourceKey}`, + sourceKey, + sessionId: String(sessionId), + sessionName: String(session?.name || "").trim(), + eventId: String(session?.eventId || ""), + eventName: String(event?.name || "").trim(), + driverId, + timestamp, + lapMs, + recordedAt: timestamp ? new Date(timestamp).toISOString() : new Date().toISOString(), + }); + }); + }); + + return normalizeTrainingState({ goals: normalized.goals, ledger: [...retained, ...current] }, { uid }); } export function getDriverTrainingLaps(state, driverId) { - const laps = []; - Object.entries(state?.resultsBySession || {}).forEach(([sessionId, result]) => { - (Array.isArray(result?.passings) ? result.passings : []).forEach((passing) => { - const lapMs = Number(passing?.lapMs || 0); - if ( - String(passing?.driverId || "") === String(driverId || "") && - passing?.validLap !== false && - lapMs > 500 - ) { - laps.push({ - sessionId, - timestamp: Number(passing.timestamp || 0) || 0, - lapMs, - }); - } - }); - }); - return laps.sort((left, right) => left.timestamp - right.timestamp); + const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : []; + return ledger + .filter((entry) => String(entry?.driverId || "") === String(driverId || "") && Number(entry?.lapMs || 0) > 500) + .map((entry) => ({ + sessionId: String(entry.sessionId || ""), + sessionName: String(entry.sessionName || ""), + eventName: String(entry.eventName || ""), + timestamp: Number(entry.timestamp || 0) || 0, + lapMs: Number(entry.lapMs || 0), + })) + .sort((left, right) => left.timestamp - right.timestamp); } function percentile(values, ratio) { @@ -45,10 +106,36 @@ function percentile(values, ratio) { return sorted[index]; } +function getLocalDayKey(timestamp) { + const date = new Date(Number(timestamp || 0)); + if (Number.isNaN(date.getTime())) return "unknown"; + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +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 }; + current.laps += 1; + current.totalMs += lap.lapMs; + current.bestLapMs = current.bestLapMs ? Math.min(current.bestLapMs, lap.lapMs) : lap.lapMs; + current.latestTimestamp = Math.max(current.latestTimestamp, lap.timestamp); + byDay.set(key, current); + }); + return [...byDay.values()] + .map((day) => ({ ...day, averageLapMs: day.laps ? Math.round(day.totalMs / day.laps) : 0 })) + .sort((left, right) => right.latestTimestamp - left.latestTimestamp); +} + export function buildDriverTrainingStats(state, driverId) { const laps = getDriverTrainingLaps(state, driverId); const lapTimes = laps.map((lap) => lap.lapMs); const recentLapTimes = lapTimes.slice(-30); + const earlierLapTimes = lapTimes.slice(0, Math.max(0, lapTimes.length - recentLapTimes.length)); const totalLaps = laps.length; const bestLapMs = totalLaps ? Math.min(...lapTimes) : 0; const averageLapMs = totalLaps ? Math.round(lapTimes.reduce((sum, value) => sum + value, 0) / totalLaps) : 0; @@ -60,16 +147,32 @@ export function buildDriverTrainingStats(state, driverId) { const consistencySpreadPct = recentAverageMs ? ((recentP90Ms - recentP10Ms) / recentAverageMs) * 100 : 0; + const previousBestMs = earlierLapTimes.length ? Math.min(...earlierLapTimes) : 0; + const recentBestMs = recentLapTimes.length ? Math.min(...recentLapTimes) : 0; + const personalBestGainMs = previousBestMs && recentBestMs ? Math.max(0, previousBestMs - recentBestMs) : 0; + const controlTargetMs = bestLapMs ? Math.round(bestLapMs * 1.08) : 0; + let controlledLapStreak = 0; + for (let index = lapTimes.length - 1; index >= 0; index -= 1) { + if (!controlTargetMs || lapTimes[index] > controlTargetMs) break; + controlledLapStreak += 1; + } + const dailySummaries = buildDailySummaries(laps); return { totalLaps, totalSessions: new Set(laps.map((lap) => lap.sessionId)).size, + trainingDays: dailySummaries.length, bestLapMs, averageLapMs, recentAverageMs, + recentBestMs, + previousBestMs, + personalBestGainMs, + controlledLapStreak, consistencySpreadPct, recentLapCount: recentLapTimes.length, lastLapAt: laps.at(-1)?.timestamp || 0, + dailySummaries, }; } @@ -111,3 +214,10 @@ export function getTrainingGoalProgress(goal, stats) { reached: currentLaps >= targetLaps, }; } + +export function getNextTrainingMilestone(stats) { + const currentLaps = Math.max(0, Number(stats?.totalLaps || 0)); + const presets = [100, 250, 500, 1000, 1500, 2500, 5000]; + const targetLaps = presets.find((target) => target > currentLaps) || (Math.floor(currentLaps / 1000) + 1) * 1000; + return getTrainingGoalProgress({ targetLaps }, stats); +} diff --git a/src/training_views.js b/src/training_views.js index b065aac..555f4c6 100644 --- a/src/training_views.js +++ b/src/training_views.js @@ -9,14 +9,17 @@ export function renderTrainingView(deps) { saveState, renderView, normalizeTrainingState, + syncTrainingLedger, buildDriverTrainingStats, buildTrainingRecommendation, getTrainingGoalProgress, + getNextTrainingMilestone, getSelectedDriverId, setSelectedDriverId, } = deps; state.training = normalizeTrainingState(state.training); + syncTrainingLedger(); const drivers = [...state.drivers].sort((left, right) => String(left.name).localeCompare(String(right.name))); let selectedDriverId = String(getSelectedDriverId() || ""); if (!drivers.some((driver) => driver.id === selectedDriverId)) { @@ -37,6 +40,7 @@ export function renderTrainingView(deps) { const selectedDriver = drivers.find((driver) => driver.id === selectedDriverId); const stats = buildDriverTrainingStats(state, selectedDriverId); const recommendation = buildTrainingRecommendation(stats); + const nextMilestone = getNextTrainingMilestone(stats); const goals = state.training.goals.filter((goal) => goal.driverId === selectedDriverId); const driverOptions = drivers .map((driver) => ``) @@ -76,6 +80,19 @@ export function renderTrainingView(deps) { const targetText = recommendation.targetLapMs ? t("training.plan_target", { lap: formatLap(recommendation.targetLapMs) }) : t("training.plan_no_target"); + const lastActivity = stats.lastLapAt + ? new Date(stats.lastLapAt).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) + : t("training.no_activity"); + const dailyRows = stats.dailySummaries.length + ? stats.dailySummaries.slice(0, 10).map((day) => ` + + ${escapeHtml(new Date(day.latestTimestamp).toLocaleDateString(undefined, { dateStyle: "medium" }))} + ${day.laps} + ${formatLap(day.bestLapMs)} + ${formatLap(day.averageLapMs)} + + `).join("") + : `${escapeHtml(t("training.no_history"))}`; dom.view.innerHTML = `
@@ -92,11 +109,29 @@ export function renderTrainingView(deps) {
${trainingStat(t("training.total_laps"), String(stats.totalLaps), t("training.valid_laps"))} - ${trainingStat(t("training.sessions"), String(stats.totalSessions), t("training.recorded_sessions"))} + ${trainingStat(t("training.training_days"), String(stats.trainingDays), t("training.sessions_count", { sessions: stats.totalSessions }))} ${trainingStat(t("training.best_lap"), formatLap(stats.bestLapMs), t("training.personal_best"))} ${trainingStat(t("training.consistency"), stats.recentLapCount ? `${stats.consistencySpreadPct.toFixed(1)}%` : "-", t("training.last_30_laps"))}
+
+
+
+ ${escapeHtml(t("training.next_level"))} +

${escapeHtml(t("training.next_level_target", { laps: nextMilestone.targetLaps }))}

+

${escapeHtml(t("training.goal_remaining", { laps: nextMilestone.remainingLaps }))}

+
+ ${nextMilestone.currentLaps} / ${nextMilestone.targetLaps} +
+
+
+ ${trainingInsight(t("training.average_lap"), formatLap(stats.averageLapMs))} + ${trainingInsight(t("training.recent_average"), formatLap(stats.recentAverageMs))} + ${trainingInsight(t("training.controlled_streak"), t("training.lap_count", { laps: stats.controlledLapStreak }))} + ${trainingInsight(t("training.last_activity"), lastActivity)} +
+
+

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

20 min
@@ -127,6 +162,19 @@ export function renderTrainingView(deps) {
+
+
+

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

+ ${stats.personalBestGainMs ? `${escapeHtml(t("training.pb_improvement", { lap: formatLap(stats.personalBestGainMs) }))}` : ""} +
+
+ + + ${dailyRows} +
${escapeHtml(t("training.date"))}${escapeHtml(t("training.laps"))}${escapeHtml(t("training.best_lap"))}${escapeHtml(t("training.average_lap"))}
+
+
+

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

${goals.length}
${goalMarkup}
@@ -179,3 +227,7 @@ export function renderTrainingView(deps) { function trainingStat(label, value, note) { return `

${label}

${value}

${note}
`; } + +function trainingInsight(label, value) { + return `
${label}${value}
`; +}