lägg till stegträning-övning + v1.3.0

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 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-09 09:02:44 +02:00
parent 9122e91f2e
commit 01e1165748
9 changed files with 390 additions and 16 deletions

View File

@@ -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) {
</form>
${evenLapsSuggestedTargetMs && !evenLapsConfig ? `<p class="hint">${escapeHtml(t("training.evenlaps_suggestion_hint", { lap: formatLap(evenLapsSuggestedTargetMs) }))}</p>` : ""}
</section>
<section class="training-steptraining-card">
<div class="training-live-head">
<div>
<span class="training-kicker">${escapeHtml(t("training.steptraining_eyebrow"))}</span>
<h3>${escapeHtml(t("training.steptraining_title"))}</h3>
</div>
</div>
${stepTrainingConfig && stepTrainingProgress ? `
${stepTrainingProgress.complete
? `<p class="training-evenlaps-status"><strong>${escapeHtml(t("training.steptraining_complete"))}</strong></p>`
: stepTrainingProgress.currentStep
? `<p class="training-evenlaps-status">
${escapeHtml(t("training.steptraining_current", { step: stepTrainingProgress.currentStepIndex + 1, total: stepTrainingProgress.totalSteps, effort: stepTrainingEffortLabel(stepTrainingProgress.currentStep.effort) }))}
<strong>${escapeHtml(t("training.steptraining_remaining", { laps: stepTrainingProgress.currentStep.lapsRemaining }))}</strong>
</p>`
: ""}
<div class="training-steptraining-steps">
${stepTrainingProgress.steps.map((step) => `
<span class="training-steptraining-pill training-steptraining-${escapeHtml(step.effort)} ${step.done ? "is-done" : step.index === stepTrainingProgress.currentStepIndex ? "is-current" : ""}">
${escapeHtml(stepTrainingEffortLabel(step.effort))} · ${step.lapsCompleted}/${step.lapCount}
</span>
`).join("")}
</div>
${stepTrainingProgress.controlledComparison ? `
<p class="hint">${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`,
}))}</p>
` : ""}
` : `<p class="hint">${escapeHtml(t("training.steptraining_hint"))}</p>`}
<form id="trainingStepTrainingForm" class="training-steptraining-form">
${stepTrainingFormRows.map((row, index) => `
<div class="training-steptraining-row">
<select name="effort_${index}">
<option value="calm" ${row.effort === "calm" ? "selected" : ""}>${escapeHtml(stepTrainingEffortLabel("calm"))}</option>
<option value="controlled" ${row.effort === "controlled" ? "selected" : ""}>${escapeHtml(stepTrainingEffortLabel("controlled"))}</option>
<option value="fast" ${row.effort === "fast" ? "selected" : ""}>${escapeHtml(stepTrainingEffortLabel("fast"))}</option>
</select>
<input name="laps_${index}" type="number" min="0" step="1" value="${row.lapCount || ""}" placeholder="0" />
</div>
`).join("")}
<button class="btn btn-primary" type="submit">${escapeHtml(t("training.steptraining_save"))}</button>
</form>
<p class="hint">${escapeHtml(t("training.steptraining_config_hint"))}</p>
</section>
` : ""}
<div class="grid cols-4 training-stats">
@@ -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);