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:
33
src/app.js
33
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",
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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