diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5b1ada1..dcd0788 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,8 +6,13 @@ The application version is maintained in `package.json` and displayed in the adm
## Unreleased
+No changes recorded yet.
+
+## 1.2.0 - 2026-09-09
+
### Added
+- "Jämna varv" (even laps): a guided practice exercise inside an active Open Practice session. Set a target lap time, tolerance and lap count (with a suggested target from the driver's own personal best); the Training page shows the live streak and today's best streak, and a missed lap starts a new streak without losing the best one.
- Personal mobile profiles show last successful data retrieval, stale-data and failed-update warnings, and a Retry button without replacing loaded statistics or active forms. Requests time out after 15 seconds and cannot overlap.
- Browser coverage for Race Day start/pause/resume/finish and switching to the next queued heat, against a simulated decoder socket.
- Browser coverage for opening the OBS overlay as a real popup window (not a direct URL load): compact layout, and live clock updates that freeze on pause, polling independently of the admin page.
@@ -18,6 +23,7 @@ The application version is maintained in `package.json` and displayed in the adm
- Open Practice sessions on a sponsor (track-mode) event could show a ready/green Race Day preflight yet refuse to start, blocked by a stricter assignment check the preflight never warned about. Open Practice is now exempt from that check everywhere, matching the preflight.
- The Race Day view did not refresh when the decoder connected, disconnected, or errored, so its Start button and preflight checklist could stay stale after a page load until something else triggered a re-render.
- Printing or exporting start lists, results, heat sheets and team reports always showed a false "popup blocked" error and produced nothing, in every modern browser. Opening the OBS overlay as a popup had the same false "blocked" check and its window never received focus. Both used `window.open(..., "noopener,noreferrer,...")`, which returns `null` even on success by current browser standards; both now open without those flags.
+- The Training page crashed for any driver with fewer than 3 laps in every stint - a common state, e.g. a driver's first couple of laps in a session - because a dedup step read a property off a `null` "best stint" entry.
## 1.1.2 - 2026-09-08
diff --git a/ROADMAP.md b/ROADMAP.md
index fac2bd2..b9a1722 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -73,6 +73,25 @@ Acceptance: a driver receives a short, encouraging summary based on their own cl
- [ ] Include the summary in the personal mobile profile.
- [ ] Add coach/parent reward follow-up without turning practice into a public ranking.
+### Guided practice exercises
+
+Acceptance: two structured, configurable exercises run inside an already-active Open Practice session, per driver and class, without stopping other drivers or creating a new session. Named for what they help a driver do, not for who supposedly likes them - "Jämna varv" ("Even laps", formerly "Konsistens-jakten") and "Stegträning" ("Step training", formerly "Pyramiden") - and focused on succeeding rather than performing perfectly.
+
+**Jämna varv (even laps):** implemented 2026-09-09.
+- [x] Driver configures target lap time, tolerance and lap count; suggested default 5 laps / ±0.5s - 15 laps / 0.2s is frustrating for a beginner, but stays adjustable for anyone who wants it stricter.
+- [x] Offer a suggested target derived from the driver's own personal best (best lap × 1.05) when enough data exists, shown as the form's placeholder and a hint, never forced.
+- [x] Tolerance is ± around the target lap time: every lap in the streak must land within `target ± tolerance`, not a spread between the streak's fastest and slowest lap. Decided 2026-09-09 - simpler to understand and to show live ("hit the target"), and matches how a beginner already thinks about it.
+- [x] Show the current streak and today's best streak live, e.g. "4/5 even laps - Best streak today: 7".
+- [x] A lap outside tolerance (or one excluded from pace, e.g. a pit stop) ends the current streak but never erases prior progress; messaged as "New streak - try to beat your best", not as a failure.
+
+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.
+
## 4. Results archive
- [ ] Search completed sessions by date, class and driver without Race Setup.
@@ -101,12 +120,6 @@ Acceptance: a driver receives a short, encouraging summary based on their own cl
- [ ] Update the architecture's module/dependency inventory as modules change.
- [ ] Complete state/settings/SQLite contracts and critical form-to-controller mappings.
-- [ ] Consolidate older future-work notes so this file remains the primary roadmap.
+- [x] Consolidate older future-work notes so this file remains the primary roadmap.
-
-# Träningsstat
-lägga till mera skoj utvecklande tränings format
-- [ ]
-2. "Pyramiden" (För extrem mental uthållighet)I racing tappar man ofta koncentrationen i mitten av ett lopp. Detta upplägg tvingar dig att hålla fokus när hjärnan börjar bli trött.Fas 1: 5 varv på 70% fart (Hitta linjerna och värm upp)Fas 2: 5 varv på 90% fart (Skruva upp tempot)Fas 3: 5 varv på 100% fart (Maxattack)Fas 4: 5 varv på 90% fart (Håll farten trots trötthet)Fas 5: 5 varv på 70% fart (Nedvarvning och analys av bilen)
-- [ ]
-3. "Konsistens-jakten" (F1-förarnas favorit)F1-team bryr sig ofta mer om konstanta varvtider än ett enda supersnabbt varv.Bestäm dig för en måltid (t.ex. 85% av din maxfart).Kör 15 varv på rad där målet är att alla varv ska ligga inom (konfigurerbart) 0,2 sekunder från varandra.Om du kör ett varv som är för snabbt eller för långsamt, "stryks" serien och du får börja om. Detta bygger ett otroligt muskelminne för bromspunkter och kurvhastighet.
\ No newline at end of file
+Consolidated 2026-09-09: the raw "Träningsstat" brainstorm notes (Pyramiden, Konsistens-jakten) were folded into section 3's "Guided practice exercises" as a proper specification and removed from here.
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 14f39ea..edfc33d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "rc-racecore",
- "version": "1.1.2",
+ "version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rc-racecore",
- "version": "1.1.2",
+ "version": "1.2.0",
"hasInstallScript": true,
"dependencies": {
"better-sqlite3": "^12.11.1",
diff --git a/package.json b/package.json
index 1ce5393..a259e68 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "rc-racecore",
- "version": "1.1.2",
+ "version": "1.2.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 98fb99e..4b717e0 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, 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, 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";
@@ -319,10 +319,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 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, 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, 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))) {
@@ -632,6 +633,20 @@ const TRANSLATIONS = {
"training.session_laps": "Varv i passet",
"training.latest_lap": "Senaste varv",
"training.live_practice_hint": "Alla kan fortsätta köra i samma Open/Free Practice. Kortet visar bara den valda förarens giltiga varv sedan den aktuella sessionen startades.",
+ "training.evenlaps_eyebrow": "Övning",
+ "training.evenlaps_title": "Jämna varv",
+ "training.evenlaps_hint": "Ställ in en måltid och tolerans nedan för att börja. Varje varv inom toleransen bygger på serien.",
+ "training.evenlaps_streak": "{current}/{target} jämna varv",
+ "training.evenlaps_new_streak": "Ny serie – försök slå din bästa",
+ "training.evenlaps_start_hint": "Kör ditt första varv för att starta en serie.",
+ "training.evenlaps_best_today": "Bästa serie idag: {best}",
+ "training.evenlaps_target": "Måltid",
+ "training.evenlaps_last_lap": "Senaste varv",
+ "training.evenlaps_target_label": "Måltid (sekunder)",
+ "training.evenlaps_tolerance_label": "Tolerans ± (sekunder)",
+ "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.stints": "Stints",
"training.best_three": "Bästa 3 sammanhängande",
"training.best_five": "Bästa 5 min",
@@ -2018,6 +2033,20 @@ const TRANSLATIONS = {
"training.session_laps": "Session laps",
"training.latest_lap": "Latest lap",
"training.live_practice_hint": "Everyone can keep driving in the same Open/Free Practice. This card shows only the selected driver's valid laps since the current session started.",
+ "training.evenlaps_eyebrow": "Exercise",
+ "training.evenlaps_title": "Even laps",
+ "training.evenlaps_hint": "Set a target lap time and tolerance below to get started. Every lap within tolerance extends the streak.",
+ "training.evenlaps_streak": "{current}/{target} even laps",
+ "training.evenlaps_new_streak": "New streak - try to beat your best",
+ "training.evenlaps_start_hint": "Run your first lap to start a streak.",
+ "training.evenlaps_best_today": "Best streak today: {best}",
+ "training.evenlaps_target": "Target",
+ "training.evenlaps_last_lap": "Latest lap",
+ "training.evenlaps_target_label": "Target lap time (seconds)",
+ "training.evenlaps_tolerance_label": "Tolerance ± (seconds)",
+ "training.evenlaps_lapcount_label": "Laps in the streak",
+ "training.evenlaps_save": "Save",
+ "training.evenlaps_suggestion_hint": "Suggested from your best lap: {lap}",
"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 3185a2e..e205bc6 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -492,6 +492,63 @@ body {
font-size: 1rem;
}
+.training-evenlaps-card {
+ margin-top: 12px;
+ padding: 16px 18px;
+ border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--line) 55%);
+ border-radius: 16px;
+ background:
+ linear-gradient(110deg, color-mix(in srgb, var(--accent) 10%, transparent), transparent 48%),
+ var(--panel);
+ box-shadow: var(--shadow);
+}
+
+.training-evenlaps-status {
+ margin: 10px 0 6px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+ font-size: 0.92rem;
+}
+
+.training-evenlaps-status strong {
+ font-family: Orbitron, sans-serif;
+ font-size: 0.82rem;
+ color: var(--muted);
+ white-space: nowrap;
+}
+
+.training-evenlaps-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px 14px;
+ margin: 0 0 12px;
+ color: var(--muted);
+ font-size: 0.78rem;
+}
+
+.training-evenlaps-meta .is-positive {
+ color: var(--success);
+}
+
+.training-evenlaps-meta .is-negative {
+ color: var(--danger);
+}
+
+.training-evenlaps-form {
+ grid-template-columns: repeat(3, minmax(110px, 1fr)) auto;
+ align-items: end;
+}
+
+.training-evenlaps-form label {
+ display: grid;
+ gap: 6px;
+ color: var(--muted);
+ font-size: 0.8rem;
+}
+
.training-stats {
margin-top: 14px;
}
@@ -3845,11 +3902,13 @@ select:focus {
@media (max-width: 900px) {
.training-extra-grid,
- .training-marker-form {
+ .training-marker-form,
+ .training-evenlaps-form {
grid-template-columns: 1fr;
}
- .training-marker-form .btn {
+ .training-marker-form .btn,
+ .training-evenlaps-form .btn {
width: 100%;
}
}
diff --git a/src/training_logic.js b/src/training_logic.js
index 783adf3..76848ba 100644
--- a/src/training_logic.js
+++ b/src/training_logic.js
@@ -111,7 +111,27 @@ export function normalizeTrainingState(training, { uid }) {
.sort((left, right) => left.timestamp - right.timestamp)
: [];
- return { goals, ledger, shares, markers, clubGoal };
+ const seenEvenLapsConfigs = new Set();
+ const evenLaps = Array.isArray(source.evenLaps)
+ ? source.evenLaps
+ .map((entry) => ({
+ id: String(entry?.id || uid("even_laps")),
+ driverId: String(entry?.driverId || ""),
+ classId: String(entry?.classId || ""),
+ targetMs: Math.max(0, Math.round(Number(entry?.targetMs || 0) || 0)),
+ toleranceMs: Math.max(0, Math.round(Number(entry?.toleranceMs || 0) || 0)),
+ lapCount: Math.max(1, Math.round(Number(entry?.lapCount || 0) || 5)),
+ updatedAt: String(entry?.updatedAt || new Date().toISOString()),
+ }))
+ .filter((entry) => {
+ const key = `${entry.driverId}:${entry.classId}`;
+ if (!entry.driverId || !entry.classId || entry.targetMs <= 0 || seenEvenLapsConfigs.has(key)) return false;
+ seenEvenLapsConfigs.add(key);
+ return true;
+ })
+ : [];
+
+ return { goals, ledger, shares, markers, clubGoal, evenLaps };
}
function getPassingSourceKey(sessionId, passing, index) {
@@ -161,6 +181,7 @@ export function syncTrainingLedger(training, state, { uid }) {
shares: normalized.shares,
markers: normalized.markers,
clubGoal: normalized.clubGoal,
+ evenLaps: normalized.evenLaps,
}, { uid });
}
@@ -694,7 +715,7 @@ export function buildDriverTimingChannelStats(state, driverId, classId = "") {
};
}
-export function buildDriverActivePracticeStats(state, driverId, nowTs = Date.now(), classId = "") {
+function getActivePracticeLaps(state, driverId, classId) {
const session = (Array.isArray(state?.sessions) ? state.sessions : [])
.find((item) => String(item?.id || "") === String(state?.activeSessionId || ""));
const practiceTypes = new Set(["open_practice", "free_practice", "practice"]);
@@ -719,6 +740,13 @@ export function buildDriverActivePracticeStats(state, driverId, nowTs = Date.now
stintBoundary: Boolean(passing.stintBoundary || passing.validationWarning === "above_max_stint"),
}))
.sort((left, right) => left.timestamp - right.timestamp);
+ return { session, startedAt, laps };
+}
+
+export function buildDriverActivePracticeStats(state, driverId, nowTs = Date.now(), classId = "") {
+ const active = getActivePracticeLaps(state, driverId, classId);
+ if (!active) return null;
+ const { session, startedAt, laps } = active;
const paceLapTimes = laps.filter((lap) => !lap.excludedFromPace).map((lap) => lap.lapMs);
const bestLapMs = paceLapTimes.length ? Math.min(...paceLapTimes) : 0;
const controlZoneMaxMs = bestLapMs ? Math.round(bestLapMs * 1.08) : 0;
@@ -739,6 +767,71 @@ export function buildDriverActivePracticeStats(state, driverId, nowTs = Date.now
};
}
+// "Jämna varv" (even laps): a driver-configured target lap time and tolerance, run live
+// inside an already-active Open Practice session. Tolerance is +/- around the target lap
+// time (not the spread between the streak's fastest and slowest lap) - decided because
+// it's simpler to understand and to show live, and matches how a beginner already thinks
+// about "hit the target". A lap outside tolerance, or one excluded from pace (pit/reset),
+// ends the current streak but never erases the best streak already reached today.
+export function buildEvenLapsProgress(state, driverId, classId, config, nowTs = Date.now()) {
+ const targetMs = Math.round(Number(config?.targetMs || 0));
+ const toleranceMs = Math.round(Number(config?.toleranceMs || 0));
+ const lapCount = Math.max(1, Math.round(Number(config?.lapCount || 0) || 0));
+ if (!(targetMs > 0) || !(toleranceMs >= 0)) return null;
+
+ const withinTolerance = (lapMs) => Math.abs(lapMs - targetMs) <= toleranceMs;
+ const longestStreak = (lapTimes) => {
+ let longest = 0;
+ let current = 0;
+ for (const lap of lapTimes) {
+ if (!lap.excludedFromPace && withinTolerance(lap.lapMs)) {
+ current += 1;
+ longest = Math.max(longest, current);
+ } else {
+ current = 0;
+ }
+ }
+ return longest;
+ };
+
+ const active = getActivePracticeLaps(state, driverId, classId);
+ const liveLaps = active?.laps || [];
+ let currentStreak = 0;
+ for (let index = liveLaps.length - 1; index >= 0; index -= 1) {
+ if (!liveLaps[index].excludedFromPace && withinTolerance(liveLaps[index].lapMs)) currentStreak += 1;
+ else break;
+ }
+
+ const dayStart = new Date(nowTs);
+ dayStart.setHours(0, 0, 0, 0);
+ const profileReferences = getTrainingDriverIds(state, driverId, classId);
+ const ledgerToday = (Array.isArray(state?.training?.ledger) ? state.training.ledger : [])
+ .filter((entry) => profileReferences.has(String(entry?.driverId || "")) && Number(entry?.timestamp || 0) >= dayStart.getTime())
+ .map((entry) => ({ timestamp: Number(entry.timestamp || 0), lapMs: Number(entry.lapMs || 0), excludedFromPace: Boolean(entry.excludedFromPace) }));
+ const seen = new Set();
+ const todayLaps = [...ledgerToday, ...liveLaps]
+ .sort((left, right) => left.timestamp - right.timestamp)
+ .filter((lap) => {
+ const key = `${lap.timestamp}:${lap.lapMs}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+ const bestStreakToday = Math.max(longestStreak(todayLaps), currentStreak);
+ const lastLap = liveLaps.at(-1) || null;
+
+ return {
+ active: Boolean(active),
+ targetMs,
+ toleranceMs,
+ lapCount,
+ currentStreak,
+ bestStreakToday,
+ lastLapMs: lastLap?.lapMs ?? null,
+ lastLapWithinTolerance: lastLap ? !lastLap.excludedFromPace && withinTolerance(lastLap.lapMs) : null,
+ };
+}
+
export function buildTrainingRecommendation(stats) {
if (!stats || stats.totalLaps < 10) {
return {
diff --git a/src/training_views.js b/src/training_views.js
index cb7f6c2..89af191 100644
--- a/src/training_views.js
+++ b/src/training_views.js
@@ -48,6 +48,7 @@ export function renderTrainingView(deps) {
buildDriverTrainingStats,
buildDriverTimingChannelStats,
buildDriverActivePracticeStats,
+ buildEvenLapsProgress,
buildTrainingRecommendation,
getTrainingGoalProgress,
getNextTrainingMilestone,
@@ -105,6 +106,9 @@ export function renderTrainingView(deps) {
const stats = buildDriverTrainingStats(state, selectedProfileId, selectedClassId);
const channelStats = buildDriverTimingChannelStats(state, selectedProfileId, selectedClassId);
const activePractice = buildDriverActivePracticeStats(state, selectedProfileId, Date.now(), selectedClassId);
+ const evenLapsConfig = (state.training.evenLaps || []).find((entry) => entry.driverId === selectedClassDriver.id && entry.classId === selectedClassId) || null;
+ const evenLapsProgress = activePractice ? buildEvenLapsProgress(state, selectedProfileId, selectedClassId, evenLapsConfig, Date.now()) : null;
+ const evenLapsSuggestedTargetMs = stats.bestLapMs ? Math.round(stats.bestLapMs * 1.05) : 0;
const recommendation = buildTrainingRecommendation(stats);
const nextMilestone = getNextTrainingMilestone(stats);
const missions = buildTrainingMissions(stats);
@@ -273,8 +277,13 @@ export function renderTrainingView(deps) {
return `
${escapeHtml(t("training.weekday_needs_days"))}
`; + // stintAnalysis.best is null until some stint has reached 3 valid laps, and a null + // entry here used to crash the whole Training page: the dedup below reads item.id on + // every entry of `rows`, including a still-present null one, not just the row it's + // currently considering. const featuredStints = [stintAnalysis.best, ...stintAnalysis.rows.slice(0, 3)] - .filter((row, index, rows) => row && rows.findIndex((item) => item.id === row.id) === index); + .filter(Boolean) + .filter((row, index, rows) => rows.findIndex((item) => item.id === row.id) === index); const stintColumns = `${escapeHtml(t("training.live_practice_hint"))}
+ ++ ${escapeHtml(evenLapsProgress.currentStreak > 0 + ? t("training.evenlaps_streak", { current: evenLapsProgress.currentStreak, target: evenLapsProgress.lapCount }) + : evenLapsProgress.bestStreakToday > 0 + ? t("training.evenlaps_new_streak") + : t("training.evenlaps_start_hint"))} + ${escapeHtml(t("training.evenlaps_best_today", { best: evenLapsProgress.bestStreakToday }))} +
+ + ` : `${escapeHtml(t("training.evenlaps_hint"))}
`} + + ${evenLapsSuggestedTargetMs && !evenLapsConfig ? `${escapeHtml(t("training.evenlaps_suggestion_hint", { lap: formatLap(evenLapsSuggestedTargetMs) }))}
` : ""} +