lägg till jämna varv-övning + v1.2.0

Ny guidad träningsövning i Open Practice: sätt måltid, tolerans
och antal varv, se aktuell serie och dagens bästa live på
Training-sidan. Ett missat varv startar en ny serie utan att
radera bästa resultatet.

Hittade och fixade två buggar under vägen:
- syncTrainingLedger byggde om training-state vid varje sparning
  utan att föra vidare det nya evenLaps-fältet, så sparade
  konfigurationer försvann direkt
- Training-sidan kraschade för alla förare med färre än 3 varv i
  varje stint (t.ex. de första varven i ett pass) eftersom en
  dedupe-funktion läste en egenskap på ett null-värde

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-09 08:51:50 +02:00
parent d18cd7fb5e
commit 9122e91f2e
9 changed files with 341 additions and 18 deletions

View File

@@ -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 `<article><span>${escapeHtml(weekday)}</span><strong>${formatLap(day.paceMs)}</strong><small>${escapeHtml(t("training.lap_count", { laps: day.lapCount }))}</small></article>`;
}).join("")
: `<p class="hint">${escapeHtml(t("training.weekday_needs_days"))}</p>`;
// 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 = `<tr><th>${escapeHtml(t("training.stint"))}</th><th>${escapeHtml(t("training.start"))}</th><th>${escapeHtml(t("training.laps"))}</th><th>${escapeHtml(t("training.best_lap"))}</th><th>${escapeHtml(t("training.trimmed_pace"))}</th><th>${escapeHtml(t("training.race_pace_10"))}</th><th>${escapeHtml(t("training.mistakes"))}</th><th>${escapeHtml(t("training.start_end"))}</th><th>${escapeHtml(t("training.consistency"))}</th><th>${escapeHtml(t("training.stint_five_minute"))}</th><th>${escapeHtml(t("training.stint_duration"))}</th></tr>`;
const featuredStintRows = featuredStints.length
? featuredStints.map((row) => renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock })).join("")
@@ -345,6 +354,36 @@ export function renderTrainingView(deps) {
</div>
<p>${escapeHtml(t("training.live_practice_hint"))}</p>
</section>
<section class="training-evenlaps-card">
<div class="training-live-head">
<div>
<span class="training-kicker">${escapeHtml(t("training.evenlaps_eyebrow"))}</span>
<h3>${escapeHtml(t("training.evenlaps_title"))}</h3>
</div>
</div>
${evenLapsConfig && evenLapsProgress ? `
<p class="training-evenlaps-status">
${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"))}
<strong>${escapeHtml(t("training.evenlaps_best_today", { best: evenLapsProgress.bestStreakToday }))}</strong>
</p>
<div class="training-evenlaps-meta">
<span>${escapeHtml(t("training.evenlaps_target"))}: ${formatLap(evenLapsProgress.targetMs)} ± ${(evenLapsProgress.toleranceMs / 1000).toFixed(1)}s</span>
${evenLapsProgress.lastLapMs != null ? `<span class="${evenLapsProgress.lastLapWithinTolerance ? "is-positive" : "is-negative"}">${escapeHtml(t("training.evenlaps_last_lap"))}: ${formatLap(evenLapsProgress.lastLapMs)}</span>` : ""}
</div>
` : `<p class="hint">${escapeHtml(t("training.evenlaps_hint"))}</p>`}
<form id="trainingEvenLapsForm" class="form-grid training-evenlaps-form">
<label>${escapeHtml(t("training.evenlaps_target_label"))}<input name="targetSec" type="number" min="1" step="0.1" value="${evenLapsConfig ? (evenLapsConfig.targetMs / 1000).toFixed(1) : evenLapsSuggestedTargetMs ? (evenLapsSuggestedTargetMs / 1000).toFixed(1) : ""}" placeholder="${evenLapsSuggestedTargetMs ? (evenLapsSuggestedTargetMs / 1000).toFixed(1) : ""}" /></label>
<label>${escapeHtml(t("training.evenlaps_tolerance_label"))}<input name="toleranceSec" type="number" min="0.1" step="0.1" value="${evenLapsConfig ? (evenLapsConfig.toleranceMs / 1000).toFixed(1) : "0.5"}" /></label>
<label>${escapeHtml(t("training.evenlaps_lapcount_label"))}<input name="lapCount" type="number" min="1" step="1" value="${evenLapsConfig ? evenLapsConfig.lapCount : 5}" /></label>
<button class="btn btn-primary" type="submit">${escapeHtml(t("training.evenlaps_save"))}</button>
</form>
${evenLapsSuggestedTargetMs && !evenLapsConfig ? `<p class="hint">${escapeHtml(t("training.evenlaps_suggestion_hint", { lap: formatLap(evenLapsSuggestedTargetMs) }))}</p>` : ""}
</section>
` : ""}
<div class="grid cols-4 training-stats">
@@ -623,6 +662,34 @@ export function renderTrainingView(deps) {
</div>
`;
document.getElementById("trainingEvenLapsForm")?.addEventListener("submit", (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const targetMs = Math.round(Number(form.get("targetSec") || 0) * 1000);
const toleranceMs = Math.round(Number(form.get("toleranceSec") || 0) * 1000);
const lapCount = Math.max(1, Math.round(Number(form.get("lapCount") || 0) || 5));
if (!(targetMs > 0) || !(toleranceMs >= 0)) return;
state.training.evenLaps = Array.isArray(state.training.evenLaps) ? state.training.evenLaps : [];
const existing = state.training.evenLaps.find((entry) => entry.driverId === selectedClassDriver.id && entry.classId === selectedClassId);
if (existing) {
existing.targetMs = targetMs;
existing.toleranceMs = toleranceMs;
existing.lapCount = lapCount;
existing.updatedAt = new Date().toISOString();
} else {
state.training.evenLaps.push({
id: uid("even_laps"),
driverId: selectedClassDriver.id,
classId: selectedClassId,
targetMs,
toleranceMs,
lapCount,
updatedAt: new Date().toISOString(),
});
}
saveState();
renderView();
});
document.getElementById("trainingMarkerForm")?.addEventListener("submit", (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);