add more train stats per driver
This commit is contained in:
@@ -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("");
|
||||
|
||||
Reference in New Issue
Block a user