From 01e11657487d0333eb1687f998230a3d0e8d28a9 Mon Sep 17 00:00:00 2001 From: larssand Date: Wed, 9 Sep 2026 09:02:44 +0200 Subject: [PATCH] =?UTF-8?q?l=C3=A4gg=20till=20stegtr=C3=A4ning-=C3=B6vning?= =?UTF-8?q?=20+=20v1.3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Andra guidade träningsövningen i Open Practice: konfigurera en stegserie (antal varv + lugnt/kontrollerat/snabbt per steg), förvalt till klassiska pyramiden. Training-sidan visar aktuellt steg, kvarvarande varv och en progress-pill per steg. När sekvensen är klar jämförs de två kontrollerade stegens snitt neutralt (t.ex. "+0.90s"), utan att kalla en försämring för trötthet. syncTrainingLedger sprider nu resten av det normaliserade training-objektet istället för att lista fält manuellt, så samma klass av bugg som drabbade Jämna varv inte kan upprepas för framtida fält. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 6 ++ ROADMAP.md | 12 ++-- package-lock.json | 4 +- package.json | 2 +- src/app.js | 29 +++++++++- src/styles.css | 60 ++++++++++++++++++++ src/training_logic.js | 107 +++++++++++++++++++++++++++++++++-- src/training_views.js | 88 ++++++++++++++++++++++++++++ tests/training_logic.test.js | 98 ++++++++++++++++++++++++++++++++ 9 files changed, 390 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcd0788..0e9aa82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.3.0 - 2026-09-09 + +### Added + +- "Stegträning" (step training): a second guided practice exercise inside an active Open Practice session. Configure a sequence of steps (lap count plus calm/controlled/fast effort per step, defaulting to the classic pyramid); the Training page shows the current step, remaining laps, and a per-step progress pill, and once two "controlled" steps have laps it shows the plain pace difference between the first and last one - never labeled as fatigue. + ## 1.2.0 - 2026-09-09 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index b9a1722..89f7a0e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -86,11 +86,13 @@ Acceptance: two structured, configurable exercises run inside an already-active Verified 2026-09-09: `buildEvenLapsProgress` in `src/training_logic.js` (unit-tested for streak tracking, tolerance, and exclusion handling) plus a manual browser check of the live card, config form and streak reset messaging inside an active Open Practice session. Found and fixed two real bugs in the process: `syncTrainingLedger` rebuilt `state.training` on every save without carrying the new `evenLaps` field through, silently discarding every saved configuration; and the Training page crashed for any driver with fewer than 3 laps in every stint (a very common state - e.g. anyone in their first couple of laps), because a dedup filter read `.id` on a `null` "best stint" entry. -**Stegträning (step training):** -- [ ] Configurable steps: lap count plus an instruction per step (calm / controlled / fast). -- [ ] Show the current step and remaining laps in that step. -- [ ] Treat calm/controlled/fast as perceived effort shown to the driver, not an exact lap-time target the system enforces - there is no reliable way to measure how hard a driver is pushing. -- [ ] Compare the two controlled-pace steps afterward without automatically labeling a slower second step as fatigue. +**Stegträning (step training):** implemented 2026-09-09. +- [x] Configurable steps: lap count plus an instruction per step (calm / controlled / fast). The form offers the classic pyramid (calm/controlled/fast/controlled/calm, 5 laps each) as an editable starting point; a step's lap count can be set to 0 to skip it, so shorter or longer sequences are both possible. +- [x] Show the current step and remaining laps in that step, plus a pill per step showing progress and highlighting the current one. +- [x] Treat calm/controlled/fast as perceived effort shown to the driver, not an exact lap-time target the system enforces - there is no reliable way to measure how hard a driver is pushing. +- [x] Compare the two controlled-pace steps afterward without automatically labeling a slower second step as fatigue: shown as a plain pace difference ("Controlled step 1: 0:21.100 · Controlled step 2: 0:22.000 (+0.90s)"), generalized to the first and last "controlled" step in the driver's own configuration rather than assuming exactly the pyramid's two. + +Verified 2026-09-09: `buildStepTrainingProgress` in `src/training_logic.js` (unit-tested for step advancement, pace-exclusion handling, and the controlled-step comparison) plus a manual browser check covering the default pyramid, mid-exercise progress, reconfiguring to a shorter sequence, and the completed-sequence comparison text. ## 4. Results archive diff --git a/package-lock.json b/package-lock.json index edfc33d..0cd3fc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.2.0", + "version": "1.3.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index a259e68..5ac84c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.2.0", + "version": "1.3.0", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/src/app.js b/src/app.js index 4b717e0..bfd201a 100644 --- a/src/app.js +++ b/src/app.js @@ -24,7 +24,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, buildEvenLapsProgress as buildEvenLapsProgressLogic, buildTrainingRecommendation as buildTrainingRecommendationLogic, getTrainingGoalProgress as getTrainingGoalProgressLogic, getNextTrainingMilestone as getNextTrainingMilestoneLogic, buildTrainingMissions as buildTrainingMissionsLogic, buildTrainingHighlights as buildTrainingHighlightsLogic, getClubTrainingGoalProgress as getClubTrainingGoalProgressLogic, buildTrainingPaceBuckets as buildTrainingPaceBucketsLogic, buildTrainingWeekdayStats as buildTrainingWeekdayStatsLogic, buildTrainingStintAnalysis as buildTrainingStintAnalysisLogic, buildTrainingMarkerComparisons as buildTrainingMarkerComparisonsLogic } from "./training_logic.js"; +import { normalizeTrainingState as normalizeTrainingStateLogic, syncTrainingLedger as syncTrainingLedgerLogic, buildDriverTrainingStats as buildDriverTrainingStatsLogic, buildDriverTimingChannelStats as buildDriverTimingChannelStatsLogic, buildDriverActivePracticeStats as buildDriverActivePracticeStatsLogic, buildEvenLapsProgress as buildEvenLapsProgressLogic, buildStepTrainingProgress as buildStepTrainingProgressLogic, buildTrainingRecommendation as buildTrainingRecommendationLogic, getTrainingGoalProgress as getTrainingGoalProgressLogic, getNextTrainingMilestone as getNextTrainingMilestoneLogic, buildTrainingMissions as buildTrainingMissionsLogic, buildTrainingHighlights as buildTrainingHighlightsLogic, getClubTrainingGoalProgress as getClubTrainingGoalProgressLogic, 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"; @@ -320,10 +320,11 @@ const syncTrainingLedger = () => { const buildDriverTrainingStats = (trainingState, driverId, classId = "") => buildDriverTrainingStatsLogic(trainingState, driverId, classId); const buildDriverActivePracticeStats = (trainingState, driverId, nowTs = Date.now(), classId = "") => buildDriverActivePracticeStatsLogic(trainingState, driverId, nowTs, classId); const buildEvenLapsProgress = (trainingState, driverId, classId, config, nowTs = Date.now()) => buildEvenLapsProgressLogic(trainingState, driverId, classId, config, nowTs); +const buildStepTrainingProgress = (trainingState, driverId, classId, config, nowTs = Date.now()) => buildStepTrainingProgressLogic(trainingState, driverId, classId, config, nowTs); 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, syncStateToBackend, renderView, normalizeTrainingState, syncTrainingLedger, buildDriverTrainingStats, buildDriverTimingChannelStats: buildDriverTimingChannelStatsLogic, buildDriverActivePracticeStats, buildEvenLapsProgress, buildTrainingRecommendation, getTrainingGoalProgress, getNextTrainingMilestone, buildTrainingMissions: buildTrainingMissionsLogic, buildTrainingHighlights: buildTrainingHighlightsLogic, getClubTrainingGoalProgress: getClubTrainingGoalProgressLogic, 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 renderTraining = () => renderTrainingView({ state, dom, t, escapeHtml, formatLap, formatRaceClock, uid, saveState, syncStateToBackend, renderView, normalizeTrainingState, syncTrainingLedger, buildDriverTrainingStats, buildDriverTimingChannelStats: buildDriverTimingChannelStatsLogic, buildDriverActivePracticeStats, buildEvenLapsProgress, buildStepTrainingProgress, buildTrainingRecommendation, getTrainingGoalProgress, getNextTrainingMilestone, buildTrainingMissions: buildTrainingMissionsLogic, buildTrainingHighlights: buildTrainingHighlightsLogic, getClubTrainingGoalProgress: getClubTrainingGoalProgressLogic, 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))) { @@ -647,6 +648,18 @@ const TRANSLATIONS = { "training.evenlaps_lapcount_label": "Antal varv i serien", "training.evenlaps_save": "Spara", "training.evenlaps_suggestion_hint": "Förslag baserat på ditt bästa varv: {lap}", + "training.steptraining_eyebrow": "Övning", + "training.steptraining_title": "Stegträning", + "training.steptraining_hint": "Bygg din stegserie nedan för att börja. Standard är den klassiska pyramiden: lugnt, kontrollerat, snabbt, kontrollerat, lugnt.", + "training.steptraining_complete": "Serien klar – bra jobbat!", + "training.steptraining_current": "Steg {step}/{total}: {effort}", + "training.steptraining_remaining": "{laps} varv kvar", + "training.steptraining_controlled_compare": "Kontrollerat steg 1: {first} · Kontrollerat steg 2: {last} ({diff})", + "training.steptraining_config_hint": "Ange 0 varv för att hoppa över ett steg. Lugnt/kontrollerat/snabbt är en känsla, inte en exakt tid – systemet kan inte mäta hur hårt du kör.", + "training.steptraining_save": "Spara", + "training.steptraining_effort_calm": "Lugnt", + "training.steptraining_effort_controlled": "Kontrollerat", + "training.steptraining_effort_fast": "Snabbt", "training.stints": "Stints", "training.best_three": "Bästa 3 sammanhängande", "training.best_five": "Bästa 5 min", @@ -2047,6 +2060,18 @@ const TRANSLATIONS = { "training.evenlaps_lapcount_label": "Laps in the streak", "training.evenlaps_save": "Save", "training.evenlaps_suggestion_hint": "Suggested from your best lap: {lap}", + "training.steptraining_eyebrow": "Exercise", + "training.steptraining_title": "Step training", + "training.steptraining_hint": "Build your step sequence below to get started. The default is the classic pyramid: calm, controlled, fast, controlled, calm.", + "training.steptraining_complete": "Sequence complete - nice work!", + "training.steptraining_current": "Step {step}/{total}: {effort}", + "training.steptraining_remaining": "{laps} laps remaining", + "training.steptraining_controlled_compare": "Controlled step 1: {first} · Controlled step 2: {last} ({diff})", + "training.steptraining_config_hint": "Set 0 laps to skip a step. Calm/controlled/fast is a feel, not an exact time - the system can't measure how hard you're pushing.", + "training.steptraining_save": "Save", + "training.steptraining_effort_calm": "Calm", + "training.steptraining_effort_controlled": "Controlled", + "training.steptraining_effort_fast": "Fast", "training.stints": "Stints", "training.best_three": "Best 3 consecutive", "training.best_five": "Best 5 min", diff --git a/src/styles.css b/src/styles.css index e205bc6..082afa0 100644 --- a/src/styles.css +++ b/src/styles.css @@ -549,6 +549,66 @@ body { font-size: 0.8rem; } +.training-steptraining-card { + margin-top: 12px; + padding: 16px 18px; + border: 1px solid color-mix(in srgb, var(--accent-2, var(--accent)) 45%, var(--line) 55%); + border-radius: 16px; + background: + linear-gradient(110deg, color-mix(in srgb, var(--accent-2, var(--accent)) 10%, transparent), transparent 48%), + var(--panel); + box-shadow: var(--shadow); +} + +.training-steptraining-steps { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 10px 0 12px; +} + +.training-steptraining-pill { + padding: 4px 10px; + border: 1px solid var(--line); + border-radius: 999px; + font-size: 0.72rem; + color: var(--muted); + background: var(--surface-elevated); +} + +.training-steptraining-pill.is-current { + border-color: var(--accent); + color: var(--accent); + font-weight: 600; +} + +.training-steptraining-pill.is-done { + border-color: color-mix(in srgb, var(--success) 55%, var(--line) 45%); + color: var(--success); +} + +.training-steptraining-form { + display: grid; + gap: 8px; + margin-top: 4px; +} + +.training-steptraining-row { + display: grid; + grid-template-columns: minmax(120px, 1fr) 90px; + gap: 8px; +} + +.training-steptraining-row select, +.training-steptraining-row input { + width: 100%; +} + +.training-steptraining-form .btn { + justify-self: start; + margin-top: 4px; +} + .training-stats { margin-top: 14px; } diff --git a/src/training_logic.js b/src/training_logic.js index 76848ba..365f85b 100644 --- a/src/training_logic.js +++ b/src/training_logic.js @@ -131,7 +131,32 @@ export function normalizeTrainingState(training, { uid }) { }) : []; - return { goals, ledger, shares, markers, clubGoal, evenLaps }; + const seenStepTrainingConfigs = new Set(); + const stepTraining = Array.isArray(source.stepTraining) + ? source.stepTraining + .map((entry) => ({ + id: String(entry?.id || uid("step_training")), + driverId: String(entry?.driverId || ""), + classId: String(entry?.classId || ""), + steps: Array.isArray(entry?.steps) + ? entry.steps + .map((step) => ({ + effort: ["calm", "controlled", "fast"].includes(String(step?.effort || "")) ? String(step.effort) : "calm", + lapCount: Math.max(0, Math.round(Number(step?.lapCount || 0) || 0)), + })) + .filter((step) => step.lapCount > 0) + : [], + updatedAt: String(entry?.updatedAt || new Date().toISOString()), + })) + .filter((entry) => { + const key = `${entry.driverId}:${entry.classId}`; + if (!entry.driverId || !entry.classId || !entry.steps.length || seenStepTrainingConfigs.has(key)) return false; + seenStepTrainingConfigs.add(key); + return true; + }) + : []; + + return { goals, ledger, shares, markers, clubGoal, evenLaps, stepTraining }; } function getPassingSourceKey(sessionId, passing, index) { @@ -175,13 +200,12 @@ 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. return normalizeTrainingState({ - goals: normalized.goals, + ...normalized, ledger: [...retained, ...current], - shares: normalized.shares, - markers: normalized.markers, - clubGoal: normalized.clubGoal, - evenLaps: normalized.evenLaps, }, { uid }); } @@ -832,6 +856,77 @@ export function buildEvenLapsProgress(state, driverId, classId, config, nowTs = }; } +const STEP_TRAINING_EFFORTS = ["calm", "controlled", "fast"]; + +// "Stegträning" (step training): a driver-configured sequence of steps (lap count plus a +// perceived-effort instruction - calm/controlled/fast), run live inside an already-active +// Open Practice session. Effort is guidance shown to the driver, never an enforced lap-time +// target: there is no reliable way to measure how hard a driver is actually pushing. When +// the config repeats "controlled" (the classic calm/controlled/fast/controlled/calm shape), +// the first and last controlled steps are compared afterward as a neutral pace difference, +// never automatically labeled as fatigue. +export function buildStepTrainingProgress(state, driverId, classId, config, nowTs = Date.now()) { + const steps = Array.isArray(config?.steps) + ? config.steps + .map((step) => ({ + effort: STEP_TRAINING_EFFORTS.includes(String(step?.effort || "")) ? String(step.effort) : "calm", + lapCount: Math.max(0, Math.round(Number(step?.lapCount || 0) || 0)), + })) + .filter((step) => step.lapCount > 0) + : []; + if (!steps.length) return null; + + const active = getActivePracticeLaps(state, driverId, classId); + const liveLaps = active?.laps || []; + const totalLaps = steps.reduce((sum, step) => sum + step.lapCount, 0); + + let cursor = 0; + const stepStates = steps.map((step, index) => { + const stepLaps = liveLaps.slice(cursor, cursor + step.lapCount); + cursor += step.lapCount; + const paceValues = stepLaps.filter((lap) => !lap.excludedFromPace).map((lap) => lap.lapMs); + return { + index, + effort: step.effort, + lapCount: step.lapCount, + lapsCompleted: stepLaps.length, + done: stepLaps.length >= step.lapCount, + averageLapMs: paceValues.length ? Math.round(paceValues.reduce((sum, value) => sum + value, 0) / paceValues.length) : null, + }; + }); + + const complete = liveLaps.length >= totalLaps; + const currentStepIndex = complete ? steps.length - 1 : stepStates.findIndex((step) => !step.done); + const currentStepState = stepStates[currentStepIndex] || null; + + const controlledSteps = stepStates.filter((step) => step.effort === "controlled" && step.averageLapMs != null); + const controlledComparison = controlledSteps.length >= 2 + ? { + firstAvgMs: controlledSteps[0].averageLapMs, + lastAvgMs: controlledSteps.at(-1).averageLapMs, + diffMs: controlledSteps.at(-1).averageLapMs - controlledSteps[0].averageLapMs, + } + : null; + + return { + active: Boolean(active), + totalSteps: steps.length, + totalLaps, + completedLaps: Math.min(liveLaps.length, totalLaps), + complete, + currentStepIndex, + currentStep: currentStepState && { + index: currentStepState.index, + effort: currentStepState.effort, + lapCount: currentStepState.lapCount, + lapsCompleted: currentStepState.lapsCompleted, + lapsRemaining: Math.max(0, currentStepState.lapCount - currentStepState.lapsCompleted), + }, + steps: stepStates, + controlledComparison, + }; +} + export function buildTrainingRecommendation(stats) { if (!stats || stats.totalLaps < 10) { return { diff --git a/src/training_views.js b/src/training_views.js index 89af191..f6cb60b 100644 --- a/src/training_views.js +++ b/src/training_views.js @@ -49,6 +49,7 @@ export function renderTrainingView(deps) { buildDriverTimingChannelStats, buildDriverActivePracticeStats, buildEvenLapsProgress, + buildStepTrainingProgress, buildTrainingRecommendation, getTrainingGoalProgress, getNextTrainingMilestone, @@ -109,6 +110,19 @@ export function renderTrainingView(deps) { const evenLapsConfig = (state.training.evenLaps || []).find((entry) => entry.driverId === selectedClassDriver.id && entry.classId === selectedClassId) || null; const evenLapsProgress = activePractice ? buildEvenLapsProgress(state, selectedProfileId, selectedClassId, evenLapsConfig, Date.now()) : null; const evenLapsSuggestedTargetMs = stats.bestLapMs ? Math.round(stats.bestLapMs * 1.05) : 0; + const stepTrainingConfig = (state.training.stepTraining || []).find((entry) => entry.driverId === selectedClassDriver.id && entry.classId === selectedClassId) || null; + const stepTrainingProgress = activePractice ? buildStepTrainingProgress(state, selectedProfileId, selectedClassId, stepTrainingConfig, Date.now()) : null; + const stepTrainingEffortLabel = (effort) => t(`training.steptraining_effort_${effort}`); + const defaultStepTrainingRows = [ + { effort: "calm", lapCount: 5 }, + { effort: "controlled", lapCount: 5 }, + { effort: "fast", lapCount: 5 }, + { effort: "controlled", lapCount: 5 }, + { effort: "calm", lapCount: 5 }, + ]; + const stepTrainingFormRows = stepTrainingConfig?.steps?.length + ? [...stepTrainingConfig.steps, ...Array(Math.max(0, 5 - stepTrainingConfig.steps.length)).fill({ effort: "calm", lapCount: 0 })].slice(0, 5) + : defaultStepTrainingRows; const recommendation = buildTrainingRecommendation(stats); const nextMilestone = getNextTrainingMilestone(stats); const missions = buildTrainingMissions(stats); @@ -384,6 +398,53 @@ export function renderTrainingView(deps) { ${evenLapsSuggestedTargetMs && !evenLapsConfig ? `

${escapeHtml(t("training.evenlaps_suggestion_hint", { lap: formatLap(evenLapsSuggestedTargetMs) }))}

` : ""} + +
+
+
+ ${escapeHtml(t("training.steptraining_eyebrow"))} +

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

+
+
+ ${stepTrainingConfig && stepTrainingProgress ? ` + ${stepTrainingProgress.complete + ? `

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

` + : stepTrainingProgress.currentStep + ? `

+ ${escapeHtml(t("training.steptraining_current", { step: stepTrainingProgress.currentStepIndex + 1, total: stepTrainingProgress.totalSteps, effort: stepTrainingEffortLabel(stepTrainingProgress.currentStep.effort) }))} + ${escapeHtml(t("training.steptraining_remaining", { laps: stepTrainingProgress.currentStep.lapsRemaining }))} +

` + : ""} +
+ ${stepTrainingProgress.steps.map((step) => ` + + ${escapeHtml(stepTrainingEffortLabel(step.effort))} · ${step.lapsCompleted}/${step.lapCount} + + `).join("")} +
+ ${stepTrainingProgress.controlledComparison ? ` +

${escapeHtml(t("training.steptraining_controlled_compare", { + first: formatLap(stepTrainingProgress.controlledComparison.firstAvgMs), + last: formatLap(stepTrainingProgress.controlledComparison.lastAvgMs), + diff: `${stepTrainingProgress.controlledComparison.diffMs >= 0 ? "+" : ""}${(stepTrainingProgress.controlledComparison.diffMs / 1000).toFixed(2)}s`, + }))}

+ ` : ""} + ` : `

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

`} +
+ ${stepTrainingFormRows.map((row, index) => ` +
+ + +
+ `).join("")} + +
+

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

+
` : ""}
@@ -690,6 +751,33 @@ export function renderTrainingView(deps) { saveState(); renderView(); }); + document.getElementById("trainingStepTrainingForm")?.addEventListener("submit", (event) => { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const steps = [0, 1, 2, 3, 4] + .map((index) => ({ + effort: String(form.get(`effort_${index}`) || "calm"), + lapCount: Math.max(0, Math.round(Number(form.get(`laps_${index}`) || 0) || 0)), + })) + .filter((step) => step.lapCount > 0); + if (!steps.length) return; + state.training.stepTraining = Array.isArray(state.training.stepTraining) ? state.training.stepTraining : []; + const existing = state.training.stepTraining.find((entry) => entry.driverId === selectedClassDriver.id && entry.classId === selectedClassId); + if (existing) { + existing.steps = steps; + existing.updatedAt = new Date().toISOString(); + } else { + state.training.stepTraining.push({ + id: uid("step_training"), + driverId: selectedClassDriver.id, + classId: selectedClassId, + steps, + updatedAt: new Date().toISOString(), + }); + } + saveState(); + renderView(); + }); document.getElementById("trainingMarkerForm")?.addEventListener("submit", (event) => { event.preventDefault(); const form = new FormData(event.currentTarget); diff --git a/tests/training_logic.test.js b/tests/training_logic.test.js index cc70ac1..fe0e4d1 100644 --- a/tests/training_logic.test.js +++ b/tests/training_logic.test.js @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; import { buildDriverActivePracticeStats, buildEvenLapsProgress, + buildStepTrainingProgress, buildDriverTrainingStats, buildDriverTimingChannelStats, buildTrainingAchievements, @@ -363,6 +364,103 @@ test("even laps progress without a config or an active session", () => { assert.equal(progress.lastLapMs, null); }); +test("step training progress advances through steps and has nothing to compare until two controlled steps have laps", () => { + const startedAt = 10_000; + const state = { + activeSessionId: "practice_1", + sessions: [{ id: "practice_1", type: "open_practice", status: "running", startedAt }], + resultsBySession: { + practice_1: { + passings: [ + { driverId: "driver_1", timestamp: 11_000, lapMs: 25_000, validLap: true }, + { driverId: "driver_1", timestamp: 37_000, lapMs: 25_500, validLap: true }, + { driverId: "driver_1", timestamp: 59_000, lapMs: 21_000, validLap: true }, + { driverId: "driver_1", timestamp: 81_200, lapMs: 21_200, validLap: true }, + { driverId: "driver_1", timestamp: 99_200, lapMs: 18_000, validLap: true }, + ], + }, + }, + }; + const config = { steps: [ + { effort: "calm", lapCount: 2 }, + { effort: "controlled", lapCount: 2 }, + { effort: "fast", lapCount: 2 }, + { effort: "controlled", lapCount: 2 }, + ] }; + const progress = buildStepTrainingProgress(state, "driver_1", "", config, 120_000); + assert.equal(progress.active, true); + assert.equal(progress.totalSteps, 4); + assert.equal(progress.totalLaps, 8); + assert.equal(progress.completedLaps, 5); + assert.equal(progress.complete, false); + assert.equal(progress.currentStepIndex, 2, "the third step (fast) is current after two calm and two controlled laps"); + assert.deepEqual(progress.currentStep, { index: 2, effort: "fast", lapCount: 2, lapsCompleted: 1, lapsRemaining: 1 }); + assert.equal(progress.steps[1].averageLapMs, 21_100, "the first controlled step's pace is averaged"); + assert.equal(progress.controlledComparison, null, "only one controlled step has laps so far, nothing to compare yet"); +}); + +test("step training progress compares the first and last controlled steps as a neutral pace difference", () => { + const startedAt = 10_000; + const state = { + activeSessionId: "practice_1", + sessions: [{ id: "practice_1", type: "open_practice", status: "running", startedAt }], + resultsBySession: { + practice_1: { + passings: [ + { driverId: "driver_1", timestamp: 11_000, lapMs: 25_000, validLap: true }, + { driverId: "driver_1", timestamp: 37_000, lapMs: 25_500, validLap: true }, + { driverId: "driver_1", timestamp: 59_000, lapMs: 21_000, validLap: true }, + { driverId: "driver_1", timestamp: 81_200, lapMs: 21_200, validLap: true }, + { driverId: "driver_1", timestamp: 99_200, lapMs: 18_000, validLap: true }, + { driverId: "driver_1", timestamp: 117_000, lapMs: 17_800, validLap: true }, + { driverId: "driver_1", timestamp: 138_800, lapMs: 21_800, validLap: true }, + { driverId: "driver_1", timestamp: 161_000, lapMs: 22_200, validLap: true }, + ], + }, + }, + }; + const config = { steps: [ + { effort: "calm", lapCount: 2 }, + { effort: "controlled", lapCount: 2 }, + { effort: "fast", lapCount: 2 }, + { effort: "controlled", lapCount: 2 }, + ] }; + const progress = buildStepTrainingProgress(state, "driver_1", "", config, 200_000); + assert.equal(progress.complete, true); + assert.equal(progress.currentStepIndex, 3); + assert.deepEqual(progress.controlledComparison, { firstAvgMs: 21_100, lastAvgMs: 22_000, diffMs: 900 }); +}); + +test("step training progress counts an excluded lap toward the step but not its pace average", () => { + const startedAt = 10_000; + const state = { + activeSessionId: "practice_1", + sessions: [{ id: "practice_1", type: "open_practice", status: "running", startedAt }], + resultsBySession: { + practice_1: { + passings: [ + { driverId: "driver_1", timestamp: 11_000, lapMs: 21_000, validLap: true }, + { driverId: "driver_1", timestamp: 200_000, lapMs: 180_000, validLap: true, validationWarning: "above_max_stint" }, + ], + }, + }, + }; + const config = { steps: [{ effort: "controlled", lapCount: 2 }] }; + const progress = buildStepTrainingProgress(state, "driver_1", "", config, 400_000); + assert.equal(progress.steps[0].lapsCompleted, 2, "both laps count toward the step's lap count"); + assert.equal(progress.steps[0].averageLapMs, 21_000, "the excluded lap is left out of the pace average"); + assert.equal(progress.complete, true); +}); + +test("step training progress without a config or an active session", () => { + assert.equal(buildStepTrainingProgress({}, "driver_1", "", null), null); + assert.equal(buildStepTrainingProgress({}, "driver_1", "", { steps: [] }), null); + const progress = buildStepTrainingProgress({ activeSessionId: "x", sessions: [], resultsBySession: {} }, "driver_1", "", { steps: [{ effort: "calm", lapCount: 3 }] }); + assert.equal(progress.active, false); + assert.equal(progress.completedLaps, 0); + assert.equal(progress.complete, false); +}); + test("training stints split after a break over two minutes or a session change", () => { const laps = [ { sessionId: "practice_1", timestamp: 18_000, lapMs: 18_000 },