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:
20
src/app.js
20
src/app.js
@@ -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.",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user