feat(training): base training stats on practice, keep race laps separate

Training stats count Open/Free practice by default; laps from
qualifying, heats and finals are kept and shown under Based on: Races
or All, marked with race, session and series. Races are listed per race
session and never split into stints. Same picker in the mobile profile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-10-10 20:08:17 +02:00
parent 9a9379aa83
commit af79b610db
15 changed files with 377 additions and 42 deletions

View File

@@ -1389,6 +1389,14 @@ const TRANSLATIONS = {
"layouts.bar_none": "Ingen banlayout satt ännu",
"layouts.new": "Ny layout",
"layouts.min_lap": "Minsta varvtid (s)",
"training.source": "Underlag",
"training.race_sessions": "Racepass 🏁",
"training.race_sessions_hint": "Ett race körs på fast tid och delas inte upp i stints. Här visas varje kval, heat och final för sig.",
"training.race_session": "Race · pass · serie",
"training.no_race_sessions": "Inga racepass ännu.",
"training.source_practice": "Träning",
"training.source_race": "Race (kval, heat, final) 🏁",
"training.source_all": "Alla",
"layouts.new_min_lap_prompt": "Minsta varvtid i sekunder för den nya layouten (tomt = som tävlingen, kan ändras under Inställningar):",
"layouts.max_lap": "Längsta varvtid (s)",
"layouts.lap_from_event": "som tävlingen",
@@ -2210,7 +2218,7 @@ const TRANSLATIONS = {
"guide.dashboard_4": "Kom igång-korten och den grupperade sidomenyn följer samma ordning: anslut systemet, registrera vid behov, skapa körning och öppna sedan Tidtagning/Overlay.",
"guide.training_title": "Träning, utveckling och belöningar",
"guide.training_1": "1. Registrera föraren och transpondern under Förare. Klubb och land är valfria profilfält. Har personen flera klasser väljer du samma Träningsprofil på varje förarpost.",
"guide.training_2": "2. Kör Free Practice, Open Practice eller en vanlig racesession. Giltiga varv kopieras automatiskt till en beständig träningslogg; korta/långa ogiltiga varv räknas inte.",
"guide.training_2": "2. Kör Free Practice eller Open Practice. Giltiga varv kopieras automatiskt till en beständig träningslogg; korta/långa ogiltiga varv räknas inte. Varv från kval, heat och final sparas också men räknas bara när du väljer Underlag: Race eller Alla - de märks 🏁 med tävling, pass och serie, och visas per racepass i stället för stints.",
"guide.training_3": "3. Öppna Träning och välj förare. Grundvyn visar bara totalvarv, träningsdagar, personbästa, jämnhet, nästa mål och dagens pass. Öppna Mer statistik eller Klubbens träningsöversikt bara när det behövs.",
"guide.training_4": "4. Nästa automatiska nivå räknas utan konfiguration. Skapa egna delmål om klubben eller en förälder vill koppla en fysisk belöning till exempelvis 500, 1000 eller 1500 varv.",
"guide.training_5": "5. När målet nåtts kan belöningen markeras som utdelad. Direktlänken `/?view=training&driver=<id>` kan bokmärkas på förarens mobil.",
@@ -3103,6 +3111,14 @@ const TRANSLATIONS = {
"layouts.bar_none": "No track layout set yet",
"layouts.new": "New layout",
"layouts.min_lap": "Min lap time (s)",
"training.source": "Based on",
"training.race_sessions": "Race sessions 🏁",
"training.race_sessions_hint": "A race runs for a fixed time and isn't split into stints. Each qualifying heat, heat and final is shown on its own.",
"training.race_session": "Race · session · series",
"training.no_race_sessions": "No race sessions yet.",
"training.source_practice": "Practice",
"training.source_race": "Races (qualifying, heats, finals) 🏁",
"training.source_all": "All",
"layouts.new_min_lap_prompt": "Minimum lap time in seconds for the new layout (empty = from the event, can be changed in Settings):",
"layouts.max_lap": "Max lap time (s)",
"layouts.lap_from_event": "from the event",
@@ -3924,7 +3940,7 @@ const TRANSLATIONS = {
"guide.dashboard_4": "The Getting started cards and grouped side navigation follow the same order: connect the system, register entries when needed, create a run, then open Timing/Overlay.",
"guide.training_title": "Practice, development and rewards",
"guide.training_1": "1. Register the driver and transponder under Drivers. Club and country are optional profile fields. If the person races several classes, select the same Practice profile on every driver entry.",
"guide.training_2": "2. Run Free Practice, Open Practice or a regular race session. Valid laps are copied automatically to a durable training ledger; invalid short or long laps do not count.",
"guide.training_2": "2. Run Free Practice or Open Practice. Valid laps are copied automatically to a durable training ledger; invalid short or long laps do not count. Laps from qualifying, heats and finals are kept too but only count when you pick Based on: Races or All - they are marked 🏁 with race, session and series, and shown per race session instead of as stints.",
"guide.training_3": "3. Open Training and select a driver. The default view only shows total laps, practice days, personal best, consistency, the next target and today's session. Open More statistics or Club practice overview only when needed.",
"guide.training_4": "4. The next automatic level needs no configuration. Add custom milestones when the club or a parent wants to attach a physical reward to targets such as 500, 1000 or 1500 laps.",
"guide.training_5": "5. When a target is reached, mark its reward as delivered. Bookmark `/?view=training&driver=<id>` on the driver's phone for direct access.",

View File

@@ -8,6 +8,7 @@ const DEFAULT_TIMER_CONFIG = Object.freeze({ pushMinutes: 10, controlMinutes: 10
const MAX_TIMER_BLOCK_MINUTES = 120;
let selectedClassId = new URLSearchParams(window.location.search).get("class") || "";
let selectedLayout = new URLSearchParams(window.location.search).get("layout") || "current";
let selectedSource = new URLSearchParams(window.location.search).get("source") || "practice";
const messages = {
sv: {
@@ -23,6 +24,10 @@ const messages = {
development: "Förarutveckling",
class: "Klass",
layoutStats: "Banlayout",
lapSource: "Underlag",
lapSourcePractice: "Träning",
lapSourceRace: "Race 🏁",
lapSourceAll: "Alla",
layoutCurrent: "Aktiv layout",
layoutNone: "Utan layout (före första layouten)",
layoutAll: "Alla layouter",
@@ -193,6 +198,10 @@ const messages = {
development: "Driver development",
class: "Class",
layoutStats: "Track layout",
lapSource: "Based on",
lapSourcePractice: "Practice",
lapSourceRace: "Races 🏁",
lapSourceAll: "All",
layoutCurrent: "Current layout",
layoutNone: "No layout (before the first layout)",
layoutAll: "All layouts",
@@ -647,6 +656,7 @@ function renderProfile(data) {
${classProfiles.length > 1 ? `<label class="profile-class-select"><span>${escapeHtml(t("class"))}</span><select id="profileClass">${classOptions}</select></label>` : activeClass?.className ? `<span class="profile-pill">${escapeHtml(activeClass.className)}</span>` : ""}
${activeClass?.brand ? `<span class="profile-pill">${escapeHtml(activeClass.brand)}</span>` : ""}
${renderLayoutSelect(data.trackLayouts)}
${renderSourceSelect(data.lapSource)}
</div>
</section>
@@ -942,6 +952,14 @@ function renderProfile(data) {
window.history.replaceState({}, "", nextUrl);
loadProfile();
});
document.getElementById("profileSource")?.addEventListener("change", (event) => {
selectedSource = String(event.currentTarget.value || "practice");
const nextUrl = new URL(window.location.href);
if (selectedSource !== "practice") nextUrl.searchParams.set("source", selectedSource);
else nextUrl.searchParams.delete("source");
window.history.replaceState({}, "", nextUrl);
loadProfile();
});
document.getElementById("profileClass")?.addEventListener("change", (event) => {
selectedClassId = String(event.currentTarget.value || "");
const nextUrl = new URL(window.location.href);
@@ -1308,6 +1326,15 @@ function renderLayoutSelect(trackLayouts) {
</select></label>`;
}
// Practice by default; race laps (qualifying, heats, finals) only on request.
function renderSourceSelect(source) {
const selected = source || "practice";
const option = (value, label) => `<option value="${value}" ${selected === value ? "selected" : ""}>${escapeHtml(label)}</option>`;
return `<label class="profile-class-select"><span>${escapeHtml(t("lapSource"))}</span><select id="profileSource">
${option("practice", t("lapSourcePractice"))}${option("race", t("lapSourceRace"))}${option("all", t("lapSourceAll"))}
</select></label>`;
}
async function loadProfile(background = false) {
if (profileConnection.loading || (background && profileIsBeingEdited())) return;
profileConnection.loading = true;
@@ -1330,6 +1357,7 @@ async function loadProfile(background = false) {
const stepTrainingRun = readExerciseRunState("steptraining");
if (stepTrainingRun) apiUrl.searchParams.set("stepTrainingStartedAgoMs", String(Math.max(0, Date.now() - stepTrainingRun.startedAt)));
if (selectedLayout !== "current") apiUrl.searchParams.set("layout", selectedLayout);
if (selectedSource !== "practice") apiUrl.searchParams.set("source", selectedSource);
const response = await fetch(apiUrl, { cache: "no-store", signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();

View File

@@ -4581,3 +4581,6 @@ tr.is-removed td.lap-actions {
.series-table td, .series-table th { text-align: center; white-space: nowrap; }
.series-table td:nth-child(2), .series-table th:nth-child(2) { text-align: left; }
.series-struck { color: var(--muted); }
/* Training stats: race laps (shown under "Races" / "All") carry their origin */
.training-race-origin { color: var(--muted); font-size: 0.72rem; }

View File

@@ -83,6 +83,8 @@ export function normalizeTrainingState(training, { uid }) {
lapMs: Math.max(0, Math.round(Number(entry?.lapMs || 0) || 0)),
recordedAt: String(entry?.recordedAt || (timestamp ? new Date(timestamp).toISOString() : new Date().toISOString())),
trackLayoutId: String(entry?.trackLayoutId || "").trim(),
sessionType: String(entry?.sessionType || "").trim(),
eventId: String(entry?.eventId || "").trim(),
};
})
.filter((entry) => {
@@ -248,6 +250,7 @@ export function syncTrainingLedger(training, state, { uid }) {
// can run one Open Practice all season and switch layout mid-session, and laps on
// either side of the switch must land on their own layout.
trackLayoutId: timestamp ? getTrackLayoutIdAt(state?.trackLayouts, timestamp) : String(session?.trackLayoutId || "").trim(),
sessionType: String(session?.type || ""),
});
});
});
@@ -255,18 +258,74 @@ export function syncTrainingLedger(training, state, { uid }) {
// Spread the rest of `normalized` rather than listing each field by name: an evenLaps
// config once went missing here because this rebuild listed fields individually and
// silently dropped a newly-added one. Only `ledger` actually needs to change.
// Laps saved before the session type was recorded get it from their session, if it still
// exists (otherwise they stay "" and count as practice - nothing disappears).
retained.forEach((entry) => {
if (!entry.sessionType && sessions.has(entry.sessionId)) entry.sessionType = String(sessions.get(entry.sessionId).type || "");
});
return normalizeTrainingState({
...normalized,
ledger: [...retained, ...current],
}, { uid });
}
export function getDriverTrainingLaps(state, driverId, classId = "") {
// Training stats are about practice: laps from qualifying, heats, finals and team races are
// kept (a driver may want to compare race pace) but only counted when "race" or "all" is
// chosen. Laps whose session type is unknown count as practice.
const RACE_SESSION_TYPES = new Set(["qualification", "heat", "final", "team_race"]);
export const TRAINING_LAP_SOURCES = Object.freeze(["practice", "race", "all"]);
export function getTrainingLapSource(sessionType) {
return RACE_SESSION_TYPES.has(String(sessionType || "")) ? "race" : "practice";
}
export function normalizeTrainingLapSource(value) {
return TRAINING_LAP_SOURCES.includes(String(value)) ? String(value) : "practice";
}
const practiceLapsOnly = (laps) => laps.filter((lap) => lap.source !== "race");
// One row per race session (a qualifying heat, a final ...) the driver took part in: laps,
// best, average and spread - the race counterpart of the practice stint table.
export function buildTrainingRaceSessions(state, driverId, classId = "", { layout = "all" } = {}) {
const bySession = new Map();
getDriverTrainingLapsForLayout(state, driverId, classId, layout, "race").forEach((lap) => {
if (!bySession.has(lap.sessionId)) bySession.set(lap.sessionId, { sessionId: lap.sessionId, sessionType: lap.sessionType, sessionName: lap.sessionName, eventId: lap.eventId, eventName: lap.eventName, laps: [] });
bySession.get(lap.sessionId).laps.push(lap);
});
return [...bySession.values()].map((session) => {
const values = session.laps.filter((lap) => !lap.excludedFromPace).map((lap) => lap.lapMs);
return {
sessionId: session.sessionId,
sessionType: session.sessionType,
sessionName: session.sessionName,
eventId: session.eventId,
eventName: session.eventName,
startedAt: Math.min(...session.laps.map((lap) => lap.timestamp - lap.lapMs)),
lapCount: session.laps.length,
bestLapMs: values.length ? Math.min(...values) : 0,
averageLapMs: values.length ? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length) : 0,
consistencyPct: values.length >= 3 ? consistencySpread(values) : null,
};
}).sort((a, b) => b.startedAt - a.startedAt);
}
export function getDriverTrainingLaps(state, driverId, classId = "", { source = "practice" } = {}) {
const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : [];
const profileReferences = getTrainingDriverIds(state, driverId, classId);
const sessionTypes = new Map((Array.isArray(state?.sessions) ? state.sessions : []).map((session) => [String(session.id), String(session.type || "")]));
const wanted = normalizeTrainingLapSource(source);
return ledger
.filter((entry) => profileReferences.has(String(entry?.driverId || "")) && Number(entry?.lapMs || 0) > 500)
.map((entry) => ({
.map((entry) => {
const sessionType = String(entry.sessionType || sessionTypes.get(String(entry.sessionId || "")) || "");
return { entry, sessionType, source: getTrainingLapSource(sessionType) };
})
.filter(({ source: lapSource }) => wanted === "all" || lapSource === wanted)
.map(({ entry, sessionType, source: lapSource }) => ({
sessionType,
source: lapSource,
eventId: String(entry.eventId || ""),
sessionId: String(entry.sessionId || ""),
sessionName: String(entry.sessionName || ""),
eventName: String(entry.eventName || ""),
@@ -292,8 +351,8 @@ export function getDriverTrainingLaps(state, driverId, classId = "") {
// `layout` picks which laps count: "current" (default, the active layout), "all" (every
// layout blended - the overall view), "none" (laps driven before any layout existed), or
// a specific layout id.
export function getDriverTrainingLapsForLayout(state, driverId, classId = "", layout = "current") {
const laps = getDriverTrainingLaps(state, driverId, classId);
export function getDriverTrainingLapsForLayout(state, driverId, classId = "", layout = "current", source = "practice") {
const laps = getDriverTrainingLaps(state, driverId, classId, { source });
if (layout === "all") return laps;
const layoutId = layout === "current" || !layout ? getTrackLayoutIdAt(state?.trackLayouts) : layout === "none" ? "" : String(layout);
return laps.filter((lap) => lap.trackLayoutId === layoutId);
@@ -453,10 +512,12 @@ function getTrainingStintMetrics(stint) {
};
}
export function buildTrainingStintAnalysis(state, driverId, classId = "", { layout = "current" } = {}) {
export function buildTrainingStintAnalysis(state, driverId, classId = "", { layout = "current", source = "practice" } = {}) {
// Scoped to one track layout (the active one unless another is chosen), same as
// buildDriverTrainingStats - a "best stint" spanning two tracks would not be comparable.
const stints = splitTrainingStints(getDriverTrainingLapsForLayout(state, driverId, classId, layout));
// Stints are a practice idea (breaks split a run into stints). A race is one fixed-time run,
// so race laps never form stints - see buildTrainingRaceSessions for those.
const stints = splitTrainingStints(practiceLapsOnly(getDriverTrainingLapsForLayout(state, driverId, classId, layout, source)));
const rows = stints.map((stint, index) => {
const { laps, values, outlierLaps, startPaceMs, endPaceMs, endChangeMs, strongFinish } = getTrainingStintMetrics(stint);
return {
@@ -531,8 +592,13 @@ function buildDailySummaries(laps) {
const byDay = new Map();
laps.forEach((lap) => {
const key = getLocalDayKey(lap.timestamp);
const current = byDay.get(key) || { dateKey: key, laps: 0, totalMs: 0, bestLapMs: 0, latestTimestamp: 0 };
const current = byDay.get(key) || { dateKey: key, laps: 0, totalMs: 0, bestLapMs: 0, latestTimestamp: 0, raceLaps: 0, raceSessions: [] };
current.laps += 1;
if (lap.source === "race") {
current.raceLaps += 1;
const label = [lap.eventName, lap.sessionName].filter(Boolean).join(" · ");
if (label && !current.raceSessions.includes(label)) current.raceSessions.push(label);
}
current.totalMs += lap.lapMs;
current.bestLapMs = current.bestLapMs ? Math.min(current.bestLapMs, lap.lapMs) : lap.lapMs;
current.latestTimestamp = Math.max(current.latestTimestamp, lap.timestamp);
@@ -675,15 +741,15 @@ export function getBestTrainingDurationResult(stints, targetMs = 5 * 60 * 1000)
return best;
}
export function buildDriverTrainingStats(state, driverId, classId = "", { layout = "current" } = {}) {
export function buildDriverTrainingStats(state, driverId, classId = "", { layout = "current", source = "practice" } = {}) {
// Lifetime commitment stats (how much a driver has trained, ever) read the full lap
// history - a track rebuild doesn't erase past practice. Every pace/record/consistency
// stat below instead reads layoutLaps, scoped to whichever track layout is active right
// now, so a lap from a track that no longer exists can't be reported as today's "record"
// or blend into today's consistency numbers. The `layout` option picks another layout,
// or "all" for the overall view - see getDriverTrainingLapsForLayout.
const laps = getDriverTrainingLaps(state, driverId, classId);
const layoutLaps = getDriverTrainingLapsForLayout(state, driverId, classId, layout);
const laps = getDriverTrainingLaps(state, driverId, classId, { source });
const layoutLaps = getDriverTrainingLapsForLayout(state, driverId, classId, layout, source);
const historyPaceLaps = laps.filter((lap) => !lap.excludedFromPace);
const paceLaps = layoutLaps.filter((lap) => !lap.excludedFromPace);
const lapTimes = paceLaps.map((lap) => lap.lapMs);
@@ -704,7 +770,7 @@ export function buildDriverTrainingStats(state, driverId, classId = "", { layout
const controlZoneMinMs = bestLapMs;
const controlTargetMs = bestLapMs ? Math.round(bestLapMs * 1.08) : 0;
const dailySummaries = buildDailySummaries(historyPaceLaps);
const stints = splitTrainingStints(layoutLaps);
const stints = splitTrainingStints(practiceLapsOnly(layoutLaps));
const stintMetrics = stints.map((stint) => ({ stint, ...getTrainingStintMetrics(stint) }));
const controlledLapStreak = getLongestControlledLapStreak(stints, controlTargetMs);
const completedFiveMinuteStints = stints.filter((stint) => getBestTrainingDurationResult([stint], 5 * 60 * 1000)).length;
@@ -715,7 +781,7 @@ export function buildDriverTrainingStats(state, driverId, classId = "", { layout
const bestFiveMinuteResult = getBestTrainingDurationResult(stints, 5 * 60 * 1000);
const todayKey = getLocalDayKey(Date.now());
const todayLaps = layoutLaps.filter((lap) => getLocalDayKey(lap.timestamp) === todayKey);
const todayStints = splitTrainingStints(todayLaps);
const todayStints = splitTrainingStints(practiceLapsOnly(todayLaps));
const todayStintMetrics = todayStints.map((stint) => ({ stint, ...getTrainingStintMetrics(stint) }));
const todayLapTimes = todayLaps.filter((lap) => !lap.excludedFromPace).map((lap) => lap.lapMs);

View File

@@ -2,11 +2,30 @@ import { sortValuesAttribute } from "./list_sorting.js";
import { getDriverProfileGroups, getDriverProfileReferences } from "./driver_profile_logic.js";
import { normalizeTrackLayouts } from "./track_layouts_logic.js";
import { renderTrackLayoutBar, bindTrackLayoutBar } from "./track_layout_bar.js";
import { buildTrainingRaceSessions } from "./training_logic.js";
// Which track layout the pace/record/consistency numbers are for: "current" (default),
// "all" (overall), "none" (laps from before any layout existed), or a layout id. Module
// state like the tab selections, so it survives re-renders while the page is open.
let selectedTrainingLayout = "current";
// Which laps the stats are built from: "practice" (default - training), "race" (qualifying,
// heats, finals) or "all". Race laps are marked 🏁 with their race, session and series.
let selectedTrainingSource = "practice";
function trainingSourceSelectMarkup(state, t, escapeHtml) {
const option = (value) => `<option value="${value}" ${selectedTrainingSource === value ? "selected" : ""}>${escapeHtml(t(`training.source_${value}`))}</option>`;
return `
<label class="training-driver-select">
<span>${escapeHtml(t("training.source"))}</span>
<select id="training-source">${option("practice")}${option("race")}${option("all")}</select>
</label>`;
}
// "🏁 ÖRC Winter #3 2WD · Kval 2 · ÖRC Winter Championship" for a race stint or day.
function raceOriginLabel({ eventId, eventName, sessionName }, state) {
const series = (state.series || []).find((item) => eventId && item.eventIds?.includes(eventId));
return `🏁 ${[eventName, sessionName, series?.name].filter(Boolean).join(" · ")}`;
}
function trainingLayoutSelectMarkup(state, t, escapeHtml) {
const layouts = normalizeTrackLayouts(state.trackLayouts);
@@ -32,12 +51,37 @@ function bindTrainingLayoutControls({ state, t, addTrackLayout, renderView }) {
selectedTrainingLayout = event.target.value;
renderView();
});
document.getElementById("training-source")?.addEventListener("change", (event) => {
selectedTrainingSource = event.target.value;
renderView();
});
bindTrackLayoutBar(document.getElementById("view"), { state, t, addTrackLayout, renderView });
}
const EXERCISE_KIND_EMOJI = { timer: "⏱️", evenLaps: "🎯", stepTraining: "🪜" };
function renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock }) {
function renderRaceSessionsSection(rows, { t, escapeHtml, formatLap, state }) {
return `
<div class="training-section-title">
<div><h3>${escapeHtml(t("training.race_sessions"))}</h3><p class="hint">${escapeHtml(t("training.race_sessions_hint"))}</p></div>
</div>
<div class="table-wrap training-history-wrap">
<table data-sort-id="training-race-sessions" class="training-history-table">
<thead><tr><th>${escapeHtml(t("training.date"))}</th><th>${escapeHtml(t("training.race_session"))}</th><th>${escapeHtml(t("training.laps"))}</th><th>${escapeHtml(t("training.best_lap"))}</th><th>${escapeHtml(t("training.average_lap"))}</th><th>${escapeHtml(t("training.consistency"))}</th></tr></thead>
<tbody>${rows.length ? rows.map((row) => `
<tr data-sort-values="${sortValuesAttribute([row.startedAt, row.eventName, row.lapCount, row.bestLapMs || null, row.averageLapMs || null, row.consistencyPct])}">
<td>${escapeHtml(new Date(row.startedAt).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" }))}</td>
<td class="training-race-origin">${escapeHtml(raceOriginLabel(row, state))}</td>
<td>${row.lapCount}</td>
<td>${formatLap(row.bestLapMs)}</td>
<td>${formatLap(row.averageLapMs)}</td>
<td>${row.consistencyPct == null ? "-" : `${row.consistencyPct.toFixed(1)}%`}</td>
</tr>`).join("") : `<tr><td colspan="6">${escapeHtml(t("training.no_race_sessions"))}</td></tr>`}</tbody>
</table>
</div>`;
}
function renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock, state }) {
const paceRetention = row.endChangeMs == null
? "-"
: row.endChangeMs > 0
@@ -144,7 +188,7 @@ export function renderTrainingView(deps) {
const selectedDriver = selectedProfile.representative;
const selectedClassDriver = selectedProfile.drivers.find((driver) => String(driver.classId || "") === selectedClassId) || selectedDriver;
const profileReferences = getDriverProfileReferences(state, selectedProfileId);
const stats = buildDriverTrainingStats(state, selectedProfileId, selectedClassId, { layout: selectedTrainingLayout });
const stats = buildDriverTrainingStats(state, selectedProfileId, selectedClassId, { layout: selectedTrainingLayout, source: selectedTrainingSource });
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;
@@ -169,7 +213,7 @@ export function renderTrainingView(deps) {
const highlights = buildTrainingHighlights({ ...stats, latestSplitGainMs: channelStats.latestSplitGainMs });
const paceBuckets = buildTrainingPaceBuckets(state, selectedProfileId, selectedClassId, 30).slice(-24);
const weekdayStats = buildTrainingWeekdayStats(state, selectedProfileId, selectedClassId);
const stintAnalysis = buildTrainingStintAnalysis(state, selectedProfileId, selectedClassId, { layout: selectedTrainingLayout });
const stintAnalysis = buildTrainingStintAnalysis(state, selectedProfileId, selectedClassId, { layout: selectedTrainingLayout, source: selectedTrainingSource });
const markerComparisons = buildTrainingMarkerComparisons(state, selectedProfileId, selectedClassId, 30).slice().reverse();
const goals = state.training.goals.filter((goal) => (
profileReferences.has(String(goal.driverId || ""))
@@ -267,7 +311,7 @@ export function renderTrainingView(deps) {
const dailyRows = stats.dailySummaries.length
? stats.dailySummaries.slice(0, 10).map((day) => `
<tr>
<td data-sort-value="${day.latestTimestamp}">${escapeHtml(new Date(day.latestTimestamp).toLocaleDateString(undefined, { dateStyle: "medium" }))}</td>
<td data-sort-value="${day.latestTimestamp}">${escapeHtml(new Date(day.latestTimestamp).toLocaleDateString(undefined, { dateStyle: "medium" }))}${day.raceLaps ? day.raceSessions.map((label) => `<br><small class="training-race-origin">🏁 ${escapeHtml(label)}</small>`).join("") : ""}</td>
<td>${day.laps}</td>
<td>${formatLap(day.bestLapMs)}</td>
<td>${formatLap(day.averageLapMs)}</td>
@@ -317,7 +361,7 @@ export function renderTrainingView(deps) {
: t("training.trend_stable");
const clubRows = profiles
.filter((profile) => !selectedClassId || profile.classIds.includes(selectedClassId))
.map((profile) => ({ profile, driver: profile.representative, stats: profile.id === selectedProfileId ? stats : buildDriverTrainingStats(state, profile.id, selectedClassId, { layout: selectedTrainingLayout }) }))
.map((profile) => ({ profile, driver: profile.representative, stats: profile.id === selectedProfileId ? stats : buildDriverTrainingStats(state, profile.id, selectedClassId, { layout: selectedTrainingLayout, source: selectedTrainingSource }) }))
.sort((left, right) => right.stats.lastLapAt - left.stats.lastLapAt || String(left.driver?.name || "").localeCompare(String(right.driver?.name || "")))
.map((entry) => {
const milestone = getNextTrainingMilestone(entry.stats);
@@ -366,9 +410,26 @@ export function renderTrainingView(deps) {
.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("")
? featuredStints.map((row) => renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock, state })).join("")
: `<tr><td colspan="11">${escapeHtml(t("training.no_stints"))}</td></tr>`;
const allStintRows = stintAnalysis.rows.map((row) => renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock })).join("");
const allStintRows = stintAnalysis.rows.map((row) => renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock, state })).join("");
// Stints are for practice only; with "Races" chosen the race-session table replaces them.
const stintSectionMarkup = `
<div class="training-section-title">
<div><h3>${escapeHtml(t("training.stint_analysis"))}</h3><p class="hint">${escapeHtml(t("training.stint_analysis_hint"))}</p></div>
${stintAnalysis.best ? `<span class="badge success">${escapeHtml(t("training.best_stint", { stint: stintAnalysis.best.number, laps: stintAnalysis.best.lapCount, pace: formatLap(stintAnalysis.best.paceMs) }))}</span>` : ""}
</div>
<div class="table-wrap training-history-wrap">
<table data-sort-id="training-stints-featured" class="training-history-table training-stint-table"><thead>${stintColumns}</thead><tbody>${featuredStintRows}</tbody></table>
</div>
${stintAnalysis.rows.length > featuredStints.length ? `
<details class="training-stint-all">
<summary>${escapeHtml(t("training.show_all_stints", { count: stintAnalysis.rows.length }))}</summary>
<div class="table-wrap training-history-wrap">
<table data-sort-id="training-stints-all" class="training-history-table training-stint-table"><thead>${stintColumns}</thead><tbody>${allStintRows}</tbody></table>
</div>
</details>` : ""}
`;
const markerMarkup = markerComparisons.length
? markerComparisons.map((marker) => {
const comparison = marker.improvementMs == null
@@ -404,6 +465,7 @@ export function renderTrainingView(deps) {
<select id="training-class">${classOptions}</select>
</label>
${trainingLayoutSelectMarkup(state, t, escapeHtml)}
${trainingSourceSelectMarkup(state, t, escapeHtml)}
${renderTrackLayoutBar({ state, t, escapeHtml })}
<div class="actions training-hero-actions">
<button class="btn" id="trainingBackOverview" type="button">${escapeHtml(t("training.back_overview"))}</button>
@@ -729,20 +791,7 @@ export function renderTrainingView(deps) {
</section>
</div>
<div class="training-section-title">
<div><h3>${escapeHtml(t("training.stint_analysis"))}</h3><p class="hint">${escapeHtml(t("training.stint_analysis_hint"))}</p></div>
${stintAnalysis.best ? `<span class="badge success">${escapeHtml(t("training.best_stint", { stint: stintAnalysis.best.number, laps: stintAnalysis.best.lapCount, pace: formatLap(stintAnalysis.best.paceMs) }))}</span>` : ""}
</div>
<div class="table-wrap training-history-wrap">
<table data-sort-id="training-stints-featured" class="training-history-table training-stint-table"><thead>${stintColumns}</thead><tbody>${featuredStintRows}</tbody></table>
</div>
${stintAnalysis.rows.length > featuredStints.length ? `
<details class="training-stint-all">
<summary>${escapeHtml(t("training.show_all_stints", { count: stintAnalysis.rows.length }))}</summary>
<div class="table-wrap training-history-wrap">
<table data-sort-id="training-stints-all" class="training-history-table training-stint-table"><thead>${stintColumns}</thead><tbody>${allStintRows}</tbody></table>
</div>
</details>` : ""}
${selectedTrainingSource === "race" ? renderRaceSessionsSection(buildTrainingRaceSessions(state, selectedProfileId, selectedClassId, { layout: selectedTrainingLayout }), { t, escapeHtml, formatLap, state }) : stintSectionMarkup}
<p class="hint training-method-note">${escapeHtml(t("training.stats_method"))}</p>
<section class="training-insights">
${trainingInsight(t("training.average_lap"), formatLap(stats.averageLapMs))}
@@ -1060,7 +1109,7 @@ export function buildTrainingOverviewRows(state, profiles, buildDriverTrainingSt
driver: profile.representative,
classId,
className: state.classes.find((item) => String(item.id || "") === String(classId || ""))?.name || classId || "-",
stats: buildDriverTrainingStats(state, profile.id, classId, { layout: selectedTrainingLayout }),
stats: buildDriverTrainingStats(state, profile.id, classId, { layout: selectedTrainingLayout, source: selectedTrainingSource }),
}));
}).sort((left, right) => (
right.stats.lastLapAt - left.stats.lastLapAt
@@ -1169,6 +1218,7 @@ function renderTrainingOverview({ addTrackLayout, state, dom, t, escapeHtml, for
</select>
</label>
${trainingLayoutSelectMarkup(state, t, escapeHtml)}
${trainingSourceSelectMarkup(state, t, escapeHtml)}
${renderTrackLayoutBar({ state, t, escapeHtml })}
</div>
</section>