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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user