add more train stats per driver

This commit is contained in:
larssand
2026-08-29 09:56:45 +02:00
parent 28e5c96010
commit e5e1754185
9 changed files with 722 additions and 8 deletions

View File

@@ -1,6 +1,30 @@
import { getDriverProfileGroups, getDriverProfileReferences } from "./driver_profile_logic.js";
function renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock }) {
const endChange = row.endChangeMs == null
? "-"
: row.endChangeMs > 0
? t("training.stint_faster", { lap: formatLap(row.endChangeMs) })
: row.endChangeMs < 0
? t("training.stint_slower", { lap: formatLap(Math.abs(row.endChangeMs)) })
: t("training.stint_same");
return `
<tr class="${row.insufficient ? "is-muted" : ""}">
<td>${row.number}</td>
<td>${escapeHtml(new Date(row.startedAt).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" }))}</td>
<td>${row.lapCount}</td>
<td>${formatLap(row.bestLapMs)}</td>
<td>${formatLap(row.paceMs)}</td>
<td>${row.racePace10Ms ? formatLap(row.racePace10Ms) : "-"}</td>
<td>${row.mistakes} (${row.mistakePct.toFixed(0)}%)</td>
<td>${escapeHtml(endChange)}</td>
<td>${row.consistencyPct == null ? escapeHtml(t("training.insufficient_data")) : `${row.consistencyPct.toFixed(1)}%`}</td>
<td>${formatRaceClock(row.durationMs)}</td>
</tr>`;
}
export function renderTrainingView(deps) {
const extraStatsWasOpen = Boolean(document.getElementById("trainingExtraStats")?.open);
const {
state,
dom,
@@ -19,6 +43,10 @@ export function renderTrainingView(deps) {
buildTrainingRecommendation,
getTrainingGoalProgress,
getNextTrainingMilestone,
buildTrainingPaceBuckets,
buildTrainingWeekdayStats,
buildTrainingStintAnalysis,
buildTrainingMarkerComparisons,
getSelectedDriverId,
setSelectedDriverId,
getSelectedClassId,
@@ -61,12 +89,17 @@ export function renderTrainingView(deps) {
setSelectedClassId(selectedClassId);
}
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);
const channelStats = buildDriverTimingChannelStats(state, selectedProfileId, selectedClassId);
const activePractice = buildDriverActivePracticeStats(state, selectedProfileId, Date.now(), selectedClassId);
const recommendation = buildTrainingRecommendation(stats);
const nextMilestone = getNextTrainingMilestone(stats);
const paceBuckets = buildTrainingPaceBuckets(state, selectedProfileId, selectedClassId, 30).slice(-24);
const weekdayStats = buildTrainingWeekdayStats(state, selectedProfileId, selectedClassId);
const stintAnalysis = buildTrainingStintAnalysis(state, selectedProfileId, selectedClassId);
const markerComparisons = buildTrainingMarkerComparisons(state, selectedProfileId, selectedClassId, 30).slice().reverse();
const goals = state.training.goals.filter((goal) => (
profileReferences.has(String(goal.driverId || ""))
&& (!goal.classId || String(goal.classId) === selectedClassId)
@@ -182,6 +215,56 @@ export function renderTrainingView(deps) {
`;
}).join("");
const paceValues = paceBuckets.map((bucket) => bucket.paceMs).filter(Boolean);
const fastestPace = paceValues.length ? Math.min(...paceValues) : 0;
const slowestPace = paceValues.length ? Math.max(...paceValues) : 0;
const paceHeatmap = paceBuckets.length
? paceBuckets.map((bucket) => {
const range = Math.max(1, slowestPace - fastestPace);
const score = slowestPace === fastestPace ? 0.7 : Math.max(0, Math.min(1, (slowestPace - bucket.paceMs) / range));
const heat = Math.round(12 + score * 56);
return `
<article class="training-pace-cell" style="--pace-heat:${heat}%">
<span>${escapeHtml(new Date(bucket.startTs).toLocaleDateString(undefined, { month: "short", day: "numeric" }))}</span>
<strong>${escapeHtml(new Date(bucket.startTs).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }))}</strong>
<b>${formatLap(bucket.paceMs)}</b>
<small>${escapeHtml(t("training.lap_count", { laps: bucket.lapCount }))} - PB ${formatLap(bucket.bestLapMs)}</small>
${bucket.markers.map((marker) => `<em>${escapeHtml(t(`training.marker_${marker.type}`))}</em>`).join("")}
</article>`;
}).join("")
: `<p class="hint">${escapeHtml(t("training.no_pace_data"))}</p>`;
const activeWeekdays = weekdayStats.filter((day) => day.lapCount > 0);
const weekdayMarkup = stats.trainingDays >= 3 && activeWeekdays.length
? activeWeekdays.map((day) => {
const weekday = new Intl.DateTimeFormat(undefined, { weekday: "short" }).format(new Date(2026, 7, 23 + day.weekday));
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>`;
const featuredStints = [stintAnalysis.best, ...stintAnalysis.rows.slice(0, 3)]
.filter((row, index, rows) => row && 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("table.duration"))}</th></tr>`;
const featuredStintRows = featuredStints.length
? featuredStints.map((row) => renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock })).join("")
: `<tr><td colspan="10">${escapeHtml(t("training.no_stints"))}</td></tr>`;
const allStintRows = stintAnalysis.rows.map((row) => renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock })).join("");
const markerMarkup = markerComparisons.length
? markerComparisons.map((marker) => {
const comparison = marker.improvementMs == null
? t("training.marker_collecting", { before: marker.beforeLapCount, after: marker.afterLapCount })
: marker.improvementMs > 0
? t("training.marker_improved", { lap: formatLap(marker.improvementMs) })
: marker.improvementMs < 0
? t("training.marker_slower", { lap: formatLap(Math.abs(marker.improvementMs)) })
: t("training.stint_same");
return `
<article class="training-marker-entry">
<div><span>${escapeHtml(t(`training.marker_${marker.type}`))}</span><strong>${escapeHtml(marker.note || t(`training.marker_${marker.type}`))}</strong><small>${escapeHtml(new Date(marker.timestamp).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" }))}</small></div>
<b class="${marker.improvementMs > 0 ? "is-positive" : marker.improvementMs < 0 ? "is-negative" : ""}">${escapeHtml(comparison)}</b>
<button class="btn btn-danger btn-mini" type="button" data-training-marker-delete="${escapeHtml(marker.id)}">${escapeHtml(t("common.delete"))}</button>
</article>`;
}).join("")
: `<p class="hint">${escapeHtml(t("training.no_markers"))}</p>`;
dom.view.innerHTML = `
<section class="training-hero">
<div>
@@ -335,6 +418,62 @@ export function renderTrainingView(deps) {
</div>
</details>
<details class="training-disclosure" id="trainingExtraStats" ${extraStatsWasOpen ? "open" : ""}>
<summary>
<span><strong>${escapeHtml(t("training.extra_stats"))}</strong><small>${escapeHtml(t("training.extra_stats_hint"))}</small></span>
<span class="training-disclosure-arrow">+</span>
</summary>
<div class="training-disclosure-body">
<div class="training-section-title">
<div><h3>${escapeHtml(t("training.pace_30_title"))}</h3><p class="hint">${escapeHtml(t("training.pace_30_hint"))}</p></div>
<span class="badge">${paceBuckets.length}</span>
</div>
<div class="training-pace-heatmap">${paceHeatmap}</div>
<div class="training-extra-grid">
<section class="training-simple-section">
<h3>${escapeHtml(t("training.add_marker"))}</h3>
<p class="hint">${escapeHtml(t("training.add_marker_hint"))}</p>
<form id="trainingMarkerForm" class="form-grid training-marker-form">
<label>${escapeHtml(t("training.marker_type"))}
<select name="type">
<option value="setup">${escapeHtml(t("training.marker_setup"))}</option>
<option value="tires">${escapeHtml(t("training.marker_tires"))}</option>
<option value="suspension">${escapeHtml(t("training.marker_suspension"))}</option>
<option value="car">${escapeHtml(t("training.marker_car"))}</option>
<option value="note">${escapeHtml(t("training.marker_note"))}</option>
</select>
</label>
<label>${escapeHtml(t("training.marker_description"))}<input name="note" maxlength="120" placeholder="${escapeHtml(t("training.marker_placeholder"))}" /></label>
<button class="btn btn-primary" type="submit">${escapeHtml(t("training.mark_now"))}</button>
</form>
<div class="training-marker-list">${markerMarkup}</div>
</section>
<section class="training-simple-section">
<h3>${escapeHtml(t("training.weekday_pace"))}</h3>
<p class="hint">${escapeHtml(t("training.weekday_hint"))}</p>
<div class="training-weekdays">${weekdayMarkup}</div>
</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 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 class="training-history-table training-stint-table"><thead>${stintColumns}</thead><tbody>${allStintRows}</tbody></table>
</div>
</details>` : ""}
<p class="hint training-method-note">${escapeHtml(t("training.stats_method"))}</p>
</div>
</details>
<details class="training-disclosure">
<summary>
<span><strong>${escapeHtml(t("training.detailed_stats"))}</strong><small>${escapeHtml(t("training.detailed_stats_hint"))}</small></span>
@@ -403,6 +542,31 @@ export function renderTrainingView(deps) {
</div>
`;
document.getElementById("trainingMarkerForm")?.addEventListener("submit", (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
state.training.markers = Array.isArray(state.training.markers) ? state.training.markers : [];
state.training.markers.push({
id: uid("training_marker"),
driverId: selectedClassDriver.id,
classId: selectedClassId,
sessionId: activePractice?.sessionId || "",
timestamp: Date.now(),
type: String(form.get("type") || "setup"),
note: String(form.get("note") || "").trim(),
createdAt: new Date().toISOString(),
});
saveState();
renderView();
});
document.querySelectorAll("[data-training-marker-delete]").forEach((button) => {
button.addEventListener("click", () => {
state.training.markers = (state.training.markers || []).filter((marker) => marker.id !== button.dataset.trainingMarkerDelete);
saveState();
renderView();
});
});
document.getElementById("trainingBackOverview")?.addEventListener("click", () => {
setSelectedDriverId("");
setSelectedClassId("");