import { getDriverProfileGroups, getDriverProfileReferences } from "./driver_profile_logic.js";
function renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock }) {
const paceRetention = 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");
const endChange = row.strongFinish
? `${paceRetention} · ${t("training.strong_finish")}`
: paceRetention;
return `
| ${row.number} |
${escapeHtml(new Date(row.startedAt).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" }))} |
${row.lapCount} |
${formatLap(row.bestLapMs)} |
${formatLap(row.paceMs)} |
${row.racePace10Ms ? formatLap(row.racePace10Ms) : "-"} |
${row.mistakes} (${row.mistakePct.toFixed(0)}%) |
${escapeHtml(endChange)} |
${row.consistencyPct == null ? escapeHtml(t("training.insufficient_data")) : `${row.consistencyPct.toFixed(1)}%`} |
${row.fiveMinuteResult ? `${row.fiveMinuteResult.lapCount}/${formatRaceClock(row.fiveMinuteResult.totalMs)} · ${t("training.average_short")} ${formatLap(row.fiveMinuteResult.averageLapMs)}` : "-"} |
${formatRaceClock(row.durationMs)} |
`;
}
export function renderTrainingView(deps) {
const moreStatsWasOpen = Boolean(document.getElementById("trainingMoreStats")?.open);
const goalsSharingElement = document.getElementById("trainingGoalsSharing");
const goalsSharingWasOpen = goalsSharingElement ? goalsSharingElement.open : true;
const {
state,
dom,
t,
escapeHtml,
formatLap,
formatRaceClock,
uid,
saveState,
syncStateToBackend,
renderView,
normalizeTrainingState,
syncTrainingLedger,
buildDriverTrainingStats,
buildDriverTimingChannelStats,
buildDriverActivePracticeStats,
buildTrainingRecommendation,
getTrainingGoalProgress,
getNextTrainingMilestone,
buildTrainingMissions,
buildTrainingHighlights,
getClubTrainingGoalProgress,
buildTrainingPaceBuckets,
buildTrainingWeekdayStats,
buildTrainingStintAnalysis,
buildTrainingMarkerComparisons,
getSelectedDriverId,
setSelectedDriverId,
getSelectedClassId,
setSelectedClassId,
} = deps;
state.training = normalizeTrainingState(state.training);
syncTrainingLedger();
const profiles = getDriverProfileGroups(state)
.sort((left, right) => String(left.representative?.name || "").localeCompare(String(right.representative?.name || "")));
let selectedProfileId = String(getSelectedDriverId() || "");
let selectedProfile = resolveTrainingProfile(profiles, selectedProfileId);
if (selectedProfileId && !selectedProfile) {
selectedProfileId = "";
setSelectedDriverId("");
setSelectedClassId("");
} else if (selectedProfile && selectedProfileId !== selectedProfile.id) {
selectedProfileId = selectedProfile.id;
setSelectedDriverId(selectedProfileId);
}
if (!profiles.length) {
dom.view.innerHTML = `
${escapeHtml(t("training.no_drivers_body"))}
`;
return;
}
if (!selectedProfileId || !selectedProfile) {
renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, uid, saveState, profiles, buildDriverTrainingStats, getNextTrainingMilestone, getClubTrainingGoalProgress, setSelectedDriverId, setSelectedClassId, renderView });
return;
}
let selectedClassId = String(getSelectedClassId() || "");
if (!selectedProfile.classIds.includes(selectedClassId)) {
selectedClassId = selectedProfile.classIds[0] || "";
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 missions = buildTrainingMissions(stats);
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);
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)
));
const profileShare = (state.training.shares || []).find((share) => profileReferences.has(String(share.driverId || "")) && share.enabled);
const publicBaseUrl = normalizePublicBaseUrl(state.settings?.publicBaseUrl);
const localBaseUrl = window.location.origin;
const publicProfileUrl = profileShare ? buildPublicProfileUrl(profileShare.token, state.settings?.language, publicBaseUrl || localBaseUrl) : "";
const qrLanguage = state.settings?.language === "en" ? "en" : "sv";
const localQrUrl = profileShare ? buildPublicProfileQrUrl(profileShare.token, qrLanguage, "local") : "";
const publicQrUrl = profileShare && publicBaseUrl && publicBaseUrl !== localBaseUrl
? buildPublicProfileQrUrl(profileShare.token, qrLanguage, publicBaseUrl)
: "";
const driverOptions = `` + profiles
.map((profile) => {
const classNames = profile.classIds
.map((classId) => state.classes.find((item) => item.id === classId)?.name || "")
.filter(Boolean)
.join(" / ");
const label = classNames ? `${profile.representative?.name || "-"} - ${classNames}` : profile.representative?.name || "-";
return ``;
})
.join("");
const classOptions = selectedProfile.classIds
.map((classId) => {
const className = state.classes.find((item) => item.id === classId)?.name || classId;
return ``;
})
.join("");
const goalMarkup = goals.length
? goals.map((goal) => {
const progress = getTrainingGoalProgress(goal, stats);
const statusClass = goal.claimedAt ? "claimed" : progress.reached ? "reached" : "active";
const statusLabel = goal.claimedAt
? t("training.goal_claimed")
: progress.reached
? t("training.goal_reached")
: t("training.goal_remaining", { laps: progress.remainingLaps });
return `
${escapeHtml(statusLabel)}
${escapeHtml(goal.title || t("training.default_goal_title", { laps: goal.targetLaps }))}
${progress.currentLaps} / ${progress.targetLaps}
${goal.reward ? `${escapeHtml(t("training.reward"))}: ${escapeHtml(goal.reward)}
` : ""}
${progress.reached && !goal.claimedAt ? `` : ""}
`;
}).join("")
: `${escapeHtml(t("training.no_goals"))}
`;
const targetText = recommendation.targetLapMs
? t("training.plan_target", { lap: formatLap(recommendation.targetLapMs) })
: t("training.plan_no_target");
const lastActivity = stats.lastLapAt
? new Date(stats.lastLapAt).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })
: t("training.no_activity");
const dailyRows = stats.dailySummaries.length
? stats.dailySummaries.slice(0, 10).map((day) => `
| ${escapeHtml(new Date(day.latestTimestamp).toLocaleDateString(undefined, { dateStyle: "medium" }))} |
${day.laps} |
${formatLap(day.bestLapMs)} |
${formatLap(day.averageLapMs)} |
`).join("")
: `| ${escapeHtml(t("training.no_history"))} |
`;
const unlockedAchievements = (stats.achievements || []).filter((achievement) => achievement.unlocked);
const nextAchievement = (stats.achievements || []).find((achievement) => !achievement.unlocked);
const visibleAchievements = [...unlockedAchievements.slice(-5), ...(nextAchievement ? [nextAchievement] : [])];
const achievementMarkup = visibleAchievements.length
? visibleAchievements.map((achievement) => `
${achievement.unlocked ? "OK" : `${Math.round(achievement.percent)}%`}
${escapeHtml(t(`training.achievement_${achievement.id}`))}
${escapeHtml(t(achievement.unlocked ? "training.achievement_unlocked" : "training.achievement_next"))}
`).join("")
: `${escapeHtml(t("training.achievement_empty"))}
`;
const highlightMarkup = highlights.map((highlight) => `
+${escapeHtml(formatTrainingHighlight(highlight, { t, formatLap }))}
`).join("");
const missionMarkup = missions.map((mission) => `
${mission.completed ? "OK" : `${Math.round(mission.percent)}%`}
${escapeHtml(t(`training.mission_${mission.id}`))}
${escapeHtml(t("training.mission_progress", { current: mission.current, target: mission.target }))}
`).join("");
const paceTrend = !stats.trendSampleReady
? t("training.trend_collecting")
: stats.averageTrendMs > 0
? t("training.trend_faster", { lap: formatLap(stats.averageTrendMs) })
: stats.averageTrendMs < 0
? t("training.trend_slower", { lap: formatLap(Math.abs(stats.averageTrendMs)) })
: t("training.trend_stable");
const consistencyTrend = !stats.trendSampleReady
? t("training.trend_collecting_short")
: stats.consistencyTrendPct > 0.1
? t("training.consistency_better", { value: stats.consistencyTrendPct.toFixed(1) })
: stats.consistencyTrendPct < -0.1
? t("training.consistency_building")
: 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) }))
.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);
return `
|
${entry.stats.totalLaps} |
${entry.stats.trainingDays} |
${formatLap(entry.stats.bestLapMs)} |
${milestone.targetLaps} |
`;
}).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 `
${escapeHtml(new Date(bucket.startTs).toLocaleDateString(undefined, { month: "short", day: "numeric" }))}
${escapeHtml(new Date(bucket.startTs).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }))}
${formatLap(bucket.paceMs)}
${escapeHtml(t("training.lap_count", { laps: bucket.lapCount }))} - PB ${formatLap(bucket.bestLapMs)}
${bucket.markers.map((marker) => `${escapeHtml(t(`training.marker_${marker.type}`))}`).join("")}
`;
}).join("")
: `${escapeHtml(t("training.no_pace_data"))}
`;
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 `${escapeHtml(weekday)}${formatLap(day.paceMs)}${escapeHtml(t("training.lap_count", { laps: day.lapCount }))}`;
}).join("")
: `${escapeHtml(t("training.weekday_needs_days"))}
`;
const featuredStints = [stintAnalysis.best, ...stintAnalysis.rows.slice(0, 3)]
.filter((row, index, rows) => row && rows.findIndex((item) => item.id === row.id) === index);
const stintColumns = `| ${escapeHtml(t("training.stint"))} | ${escapeHtml(t("training.start"))} | ${escapeHtml(t("training.laps"))} | ${escapeHtml(t("training.best_lap"))} | ${escapeHtml(t("training.trimmed_pace"))} | ${escapeHtml(t("training.race_pace_10"))} | ${escapeHtml(t("training.mistakes"))} | ${escapeHtml(t("training.start_end"))} | ${escapeHtml(t("training.consistency"))} | ${escapeHtml(t("training.stint_five_minute"))} | ${escapeHtml(t("training.stint_duration"))} |
`;
const featuredStintRows = featuredStints.length
? featuredStints.map((row) => renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock })).join("")
: `| ${escapeHtml(t("training.no_stints"))} |
`;
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 `
${escapeHtml(t(`training.marker_${marker.type}`))}${escapeHtml(marker.note || t(`training.marker_${marker.type}`))}${escapeHtml(new Date(marker.timestamp).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" }))}
${escapeHtml(comparison)}
`;
}).join("")
: `${escapeHtml(t("training.no_markers"))}
`;
dom.view.innerHTML = `
${escapeHtml(t("training.eyebrow"))}
${escapeHtml(selectedDriver.name)}
${escapeHtml(t("training.intro"))}
${profileShare ? `
` : `
`}
${activePractice ? `
${escapeHtml(t("training.live_now"))}
${escapeHtml(activePractice.sessionName || t("training.active_practice"))}
${escapeHtml(t("training.shared_practice"))}
${trainingInsight(t("training.session_laps"), String(activePractice.laps))}
${trainingInsight(t("training.best_lap"), formatLap(activePractice.bestLapMs))}
${trainingInsight(t("training.average_lap"), formatLap(activePractice.averageLapMs))}
${trainingInsight(t("training.latest_lap"), formatLap(activePractice.lastLapMs))}
${trainingInsight(t("training.control_zone"), activePractice.controlZoneMaxMs ? `${formatLap(activePractice.controlZoneMinMs)} - ${formatLap(activePractice.controlZoneMaxMs)}` : "-")}
${trainingInsight(t("training.controlled_streak"), t("training.lap_count", { laps: activePractice.controlledLapStreak }))}
${escapeHtml(t("training.live_practice_hint"))}
` : ""}
${trainingStat(t("training.total_laps"), String(stats.totalLaps), t("training.valid_laps"))}
${trainingStat(t("training.training_days"), String(stats.trainingDays), t("training.sessions_count", { sessions: stats.totalSessions }))}
${trainingStat(t("training.best_lap"), formatLap(stats.bestLapMs), t("training.personal_best"))}
${trainingStat(t("training.consistency"), stats.recentLapCount ? `${stats.consistencySpreadPct.toFixed(1)}%` : "-", t("training.last_30_laps"))}
${escapeHtml(t("training.credit_eyebrow"))}
${escapeHtml(t(stats.todayLaps > 0 ? "training.credit_today_title" : "training.credit_ready_title", { driver: selectedDriver.name }))}
${escapeHtml(t(stats.todayLaps > 0 ? "training.credit_today_body" : "training.credit_ready_body"))}
${escapeHtml(t("training.optional_challenges"))}
${escapeHtml(t("training.daily_missions"))}
${missions.filter((mission) => mission.completed).length}/${missions.length}
${escapeHtml(t("training.daily_missions_hint"))}
${missionMarkup}
${escapeHtml(t("training.your_progress"))}
${escapeHtml(t("training.recent_trend"))}
30 + 30 ${escapeHtml(t("training.laps").toLowerCase())}
${trainingInsight(t("training.pace_trend"), paceTrend)}
${trainingInsight(t("training.consistency_trend"), consistencyTrend)}
${escapeHtml(t("training.trend_hint"))}
${escapeHtml(t("training.personal_credit"))}
${escapeHtml(t("training.achievements"))}
${unlockedAchievements.length}/${(stats.achievements || []).length}
${achievementMarkup}
${escapeHtml(t("training.next_level"))}
${escapeHtml(t("training.next_level_target", { laps: nextMilestone.targetLaps }))}
${escapeHtml(t("training.goal_remaining", { laps: nextMilestone.remainingLaps }))}
${nextMilestone.currentLaps} / ${nextMilestone.targetLaps}
${escapeHtml(t(recommendation.titleKey))}
${escapeHtml(targetText)}
${escapeHtml(t(recommendation.bodyKey))}
${escapeHtml(t("training.block_one"))}${escapeHtml(t("training.block_push"))}
${escapeHtml(t("training.block_two"))}${escapeHtml(t("training.block_control"))}
${goals.length ? `
` : ""}
${escapeHtml(t("training.manage_goals_sharing"))}${escapeHtml(t("training.manage_goals_sharing_hint"))}
+
${escapeHtml(t("training.new_goal"))}
${escapeHtml(t("training.public_profile"))}
${escapeHtml(t(profileShare ? "training.public_profile_active" : "training.public_profile_off"))}
${escapeHtml(t("training.public_profile_help"))}
${profileShare ? `
${escapeHtml(t("training.local_profile_qr"))}
${escapeHtml(t("training.local_profile_qr_hint"))}
${publicQrUrl ? `
${escapeHtml(t("training.public_profile_qr"))}
${escapeHtml(new URL(publicProfileUrl).host)}
` : ""}
${escapeHtml(t("training.public_profile_qr_hint"))}
` : `
`}
${escapeHtml(t("training.detailed_stats"))}${escapeHtml(t("training.detailed_stats_hint"))}
+
${escapeHtml(t("training.pace_30_title"))}
${escapeHtml(t("training.pace_30_hint"))}
${paceBuckets.length}
${paceHeatmap}
${escapeHtml(t("training.stint_analysis"))}
${escapeHtml(t("training.stint_analysis_hint"))}
${stintAnalysis.best ? `
${escapeHtml(t("training.best_stint", { stint: stintAnalysis.best.number, laps: stintAnalysis.best.lapCount, pace: formatLap(stintAnalysis.best.paceMs) }))}` : ""}
${stintColumns}${featuredStintRows}
${stintAnalysis.rows.length > featuredStints.length ? `
${escapeHtml(t("training.show_all_stints", { count: stintAnalysis.rows.length }))}
${stintColumns}${allStintRows}
` : ""}
${escapeHtml(t("training.stats_method"))}
${trainingInsight(t("training.average_lap"), formatLap(stats.averageLapMs))}
${trainingInsight(t("training.recent_average"), formatLap(stats.recentAverageMs))}
${trainingInsight(t("training.control_zone"), stats.controlZoneMaxMs ? `${formatLap(stats.controlZoneMinMs)} - ${formatLap(stats.controlZoneMaxMs)}` : "-")}
${trainingInsight(t("training.controlled_streak"), t("training.lap_count", { laps: stats.controlledLapStreak }))}
${trainingInsight(t("training.outlier_laps"), String(stats.outlierLaps))}
${trainingInsight(t("training.strong_finishes"), String(stats.strongFinishStints))}
${trainingInsight(t("training.last_activity"), lastActivity)}
${trainingInsight(t("training.stints"), String(stats.stintCount))}
${trainingInsight(t("training.best_three"), stats.bestThreeConsecutive ? `3/${formatRaceClock(stats.bestThreeConsecutive.totalMs)}` : "-")}
${trainingInsight(t("training.best_five"), stats.bestFiveMinuteResult ? `${stats.bestFiveMinuteResult.lapCount}/${formatRaceClock(stats.bestFiveMinuteResult.totalMs)}` : "-")}
${trainingInsight(t("training.five_avg_pace"), stats.bestFiveMinuteResult ? formatLap(stats.bestFiveMinuteResult.averageLapMs) : "-")}
${trainingInsight(t("training.split_passings"), String(channelStats.splitCount))}
${trainingInsight(t("training.best_split"), channelStats.splitCount ? formatLap(channelStats.bestSplitMs) : "-")}
${trainingInsight(t("training.latest_sector_gain"), channelStats.latestSplitGainMs ? formatLap(channelStats.latestSplitGainMs) : "-")}
${trainingInsight(t("training.average_split"), channelStats.splitCount ? formatLap(channelStats.averageSplitMs) : "-")}
${trainingInsight(t("training.pit_stops"), String(channelStats.pitStopCount))}
${trainingInsight(t("training.total_pit_time"), channelStats.pitStopCount ? formatRaceClock(channelStats.totalPitMs) : "-")}
${trainingInsight(t("training.average_pit_stop"), channelStats.pitStopCount ? formatLap(channelStats.averagePitStopMs) : "-")}
${trainingInsight(t("training.best_pit_stop"), channelStats.pitStopCount ? formatLap(channelStats.bestPitStopMs) : "-")}
${channelStats.recentPitStops.length ? `
${escapeHtml(t("training.recent_pit_stops"))}
| ${escapeHtml(t("training.date"))} | ${escapeHtml(t("table.event"))} | ${escapeHtml(t("table.session"))} | ${escapeHtml(t("table.car"))} | ${escapeHtml(t("table.duration"))} |
${channelStats.recentPitStops.map((stop) => `
| ${escapeHtml(new Date(stop.enteredAt).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }))} |
${escapeHtml(stop.eventName || "-")} |
${escapeHtml(stop.sessionName || "-")} |
${escapeHtml(stop.carName || "-")} |
${formatLap(stop.pitStopMs)} |
`).join("")}
` : ""}
${escapeHtml(t("training.history"))}
${stats.personalBestGainMs ? `${escapeHtml(t("training.pb_improvement", { lap: formatLap(stats.personalBestGainMs) }))}` : ""}
| ${escapeHtml(t("training.date"))} | ${escapeHtml(t("training.laps"))} | ${escapeHtml(t("training.best_lap"))} | ${escapeHtml(t("training.average_lap"))} |
${dailyRows}
${escapeHtml(t("training.club_progress"))}${escapeHtml(t("training.club_progress_hint"))}
+
| ${escapeHtml(t("training.driver"))} | ${escapeHtml(t("training.laps"))} | ${escapeHtml(t("training.training_days"))} | ${escapeHtml(t("training.best_lap"))} | ${escapeHtml(t("training.next_target"))} |
${clubRows}
`;
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("");
renderView();
});
document.getElementById("training-driver")?.addEventListener("change", (event) => {
setSelectedDriverId(event.currentTarget.value);
setSelectedClassId("");
renderView();
});
document.getElementById("training-class")?.addEventListener("change", (event) => {
setSelectedClassId(event.currentTarget.value);
renderView();
});
document.getElementById("trainingHeroEnableProfile")?.addEventListener("click", () => {
replacePublicProfileShare(state, selectedProfileId, profileReferences);
saveState();
renderView();
});
document.getElementById("trainingHeroOpenProfile")?.addEventListener("click", () => {
window.open(publicProfileUrl, "_blank", "noopener,noreferrer");
});
document.getElementById("trainingHeroCopyProfile")?.addEventListener("click", async () => {
await copyText(publicProfileUrl);
});
document.querySelectorAll(".training-preset").forEach((button) => {
button.addEventListener("click", () => {
const input = document.querySelector('#training-goal-form input[name="targetLaps"]');
if (input instanceof HTMLInputElement) input.value = button.dataset.laps || "500";
});
});
document.getElementById("training-goal-form")?.addEventListener("submit", (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const targetLaps = Math.max(1, Math.round(Number(form.get("targetLaps") || 0) || 0));
state.training.goals.push({
id: uid("goal"),
driverId: selectedProfileId,
classId: selectedClassId,
targetLaps,
title: String(form.get("title") || "").trim(),
reward: String(form.get("reward") || "").trim(),
createdAt: new Date().toISOString(),
claimedAt: "",
});
saveState();
renderView();
});
goals.forEach((goal) => {
document.getElementById(`training-claim-${goal.id}`)?.addEventListener("click", () => {
state.training.goals = state.training.goals.map((item) => item.id === goal.id ? { ...item, claimedAt: new Date().toISOString() } : item);
saveState();
renderView();
});
document.getElementById(`training-delete-${goal.id}`)?.addEventListener("click", () => {
state.training.goals = state.training.goals.filter((item) => item.id !== goal.id);
saveState();
renderView();
});
});
document.getElementById("trainingPublicBaseSave")?.addEventListener("click", async () => {
const input = document.getElementById("trainingPublicBaseUrl");
const normalized = normalizePublicBaseUrl(input?.value);
if (input?.value && !normalized) {
document.getElementById("trainingPublicAddressStatus").textContent = t("training.public_url_invalid");
return;
}
state.settings.publicBaseUrl = normalized;
saveState();
await syncStateToBackend();
renderView();
});
document.getElementById("trainingPublicAddressDetect")?.addEventListener("click", async () => {
const status = document.getElementById("trainingPublicAddressStatus");
if (status) status.textContent = t("training.detecting_public_ip");
try {
const response = await fetch("/api/network/public-address", { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
const input = document.getElementById("trainingPublicBaseUrl");
if (input) input.value = payload.suggestedBaseUrl || "";
if (status) status.textContent = t("training.detected_public_ip", { address: payload.address || "-" });
} catch {
if (status) status.textContent = t("training.detect_public_ip_failed");
}
});
document.getElementById("trainingPublicProfileEnable")?.addEventListener("click", async () => {
replacePublicProfileShare(state, selectedProfileId, profileReferences);
saveState();
await syncStateToBackend();
renderView();
});
document.getElementById("trainingPublicProfileCopy")?.addEventListener("click", async () => {
await copyText(publicProfileUrl);
});
document.getElementById("trainingPublicProfileOpen")?.addEventListener("click", () => {
window.open(publicProfileUrl, "_blank", "noopener,noreferrer");
});
document.getElementById("trainingPublicProfileRotate")?.addEventListener("click", async () => {
if (!confirm(t("training.replace_link_confirm"))) return;
replacePublicProfileShare(state, selectedProfileId, profileReferences);
saveState();
await syncStateToBackend();
renderView();
});
document.getElementById("trainingPublicProfileDisable")?.addEventListener("click", async () => {
state.training.shares = state.training.shares.map((share) => profileReferences.has(String(share.driverId || "")) ? { ...share, enabled: false } : share);
saveState();
await syncStateToBackend();
renderView();
});
document.querySelectorAll(".training-club-driver").forEach((button) => {
button.addEventListener("click", () => {
setSelectedDriverId(button.dataset.driverId || "");
setSelectedClassId(button.dataset.classId || "");
renderView();
});
});
}
export function resolveTrainingProfile(profiles, selectedId) {
const reference = String(selectedId || "");
if (!reference) return null;
return (Array.isArray(profiles) ? profiles : []).find((profile) => (
profile.id === reference || profile.driverIds.includes(reference)
)) || null;
}
export function buildTrainingOverviewRows(state, profiles, buildDriverTrainingStats) {
return (Array.isArray(profiles) ? profiles : []).flatMap((profile) => {
const classIds = profile.classIds.length ? profile.classIds : [""];
return classIds.map((classId) => ({
profile,
driver: profile.representative,
classId,
className: state.classes.find((item) => String(item.id || "") === String(classId || ""))?.name || classId || "-",
stats: buildDriverTrainingStats(state, profile.id, classId),
}));
}).sort((left, right) => (
right.stats.lastLapAt - left.stats.lastLapAt
|| String(left.driver?.name || "").localeCompare(String(right.driver?.name || ""))
|| String(left.className).localeCompare(String(right.className))
));
}
export function buildTrainingProgressFeed(rows, limit = 6) {
return (Array.isArray(rows) ? rows : [])
.filter((row) => Number(row?.stats?.totalLaps || 0) > 0)
.sort((left, right) => Number(right.stats.lastLapAt || 0) - Number(left.stats.lastLapAt || 0))
.slice(0, Math.max(1, Number(limit || 6)))
.map((row) => {
const stats = row.stats;
if (stats.personalBestGainMs > 0) return { ...row, kind: "personal_best", value: stats.personalBestGainMs };
if (stats.trendSampleReady && stats.averageTrendMs > 0) return { ...row, kind: "average_gain", value: stats.averageTrendMs };
if (stats.bestFiveMinuteResult) return { ...row, kind: "five_minute", value: stats.bestFiveMinuteResult.lapCount };
if (stats.controlledLapStreak >= 5) return { ...row, kind: "controlled_streak", value: stats.controlledLapStreak };
return { ...row, kind: "practice_laps", value: stats.totalLaps };
});
}
function renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, uid, saveState, profiles, buildDriverTrainingStats, getNextTrainingMilestone, getClubTrainingGoalProgress, setSelectedDriverId, setSelectedClassId, renderView }) {
const rows = buildTrainingOverviewRows(state, profiles, buildDriverTrainingStats);
const progressFeed = buildTrainingProgressFeed(rows);
const ledger = Array.isArray(state.training?.ledger) ? state.training.ledger : [];
const activeProfiles = new Set(rows.filter((row) => row.stats.totalLaps > 0).map((row) => row.profile.id)).size;
const recordedSessions = new Set(ledger.map((entry) => String(entry.sessionId || "")).filter(Boolean)).size;
const clubGoal = state.training.clubGoal;
const clubGoalProgress = getClubTrainingGoalProgress(state.training);
const driverOptions = profiles.map((profile) => (
``
)).join("");
const progressFeedMarkup = progressFeed.length
? progressFeed.map((entry) => {
const message = entry.kind === "personal_best"
? t("training.feed_personal_best", { lap: formatLap(entry.value) })
: entry.kind === "average_gain"
? t("training.feed_average_gain", { lap: formatLap(entry.value) })
: entry.kind === "five_minute"
? t("training.feed_five_minute", { laps: entry.value })
: entry.kind === "controlled_streak"
? t("training.feed_controlled_streak", { laps: entry.value })
: t("training.feed_practice_laps", { laps: entry.value });
return `
`;
}).join("")
: `${escapeHtml(t("training.feed_empty"))}
`;
const tableRows = rows.map((row) => {
const milestone = getNextTrainingMilestone(row.stats);
const lastActivity = row.stats.lastLapAt
? new Date(row.stats.lastLapAt).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })
: "-";
return `
|
${escapeHtml(row.className)} |
${row.stats.totalLaps} |
${row.stats.trainingDays} |
${formatLap(row.stats.bestLapMs)} |
${escapeHtml(lastActivity)} |
${milestone.targetLaps} |
`;
}).join("");
const clubGoalMarkup = clubGoal && clubGoalProgress ? `
${escapeHtml(t(clubGoal.celebratedAt ? "training.club_goal_celebrated" : clubGoalProgress.reached ? "training.club_goal_reached" : "training.club_goal_active"))}
${escapeHtml(clubGoal.title || t("training.club_goal_default", { laps: clubGoal.targetLaps }))}
${clubGoalProgress.currentLaps} / ${clubGoalProgress.targetLaps}
${escapeHtml(t("training.club_goal_remaining", { laps: clubGoalProgress.remainingLaps }))}
${clubGoal.reward ? `
${escapeHtml(t("training.reward"))}: ${escapeHtml(clubGoal.reward)}
` : ""}
${clubGoalProgress.reached && !clubGoal.celebratedAt ? `` : ""}
` : `${escapeHtml(t("training.club_goal_empty"))}
`;
dom.view.innerHTML = `
${escapeHtml(t("training.eyebrow"))}
${escapeHtml(t("training.overview_title"))}
${escapeHtml(t("training.overview_intro"))}
${trainingStat(t("training.total_laps"), String(ledger.length), t("training.valid_laps"))}
${trainingStat(t("training.registered_drivers"), String(profiles.length), t("training.profile_count_note"))}
${trainingStat(t("training.active_drivers"), String(activeProfiles), t("training.with_training_data"))}
${trainingStat(t("training.sessions"), String(recordedSessions), t("training.recorded_sessions"))}
${clubGoalMarkup}
${escapeHtml(t(clubGoal ? "training.club_goal_new" : "training.club_goal_create"))}
${escapeHtml(t("training.club_goal_baseline_hint"))}
${escapeHtml(t("training.overview_class_hint"))}
| ${escapeHtml(t("training.driver"))} | ${escapeHtml(t("training.class"))} | ${escapeHtml(t("training.laps"))} | ${escapeHtml(t("training.training_days"))} | ${escapeHtml(t("training.best_lap"))} | ${escapeHtml(t("training.last_activity"))} | ${escapeHtml(t("training.next_target"))} |
${tableRows}
`;
document.getElementById("training-overview-driver")?.addEventListener("change", (event) => {
if (!event.currentTarget.value) return;
setSelectedDriverId(event.currentTarget.value);
setSelectedClassId("");
renderView();
});
document.getElementById("trainingClubGoalForm")?.addEventListener("submit", (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
state.training.clubGoal = {
id: uid("club_goal"),
title: String(form.get("title") || "").trim(),
reward: String(form.get("reward") || "").trim(),
targetLaps: Math.max(1, Math.round(Number(form.get("targetLaps") || 0) || 0)),
baselineLaps: ledger.length,
createdAt: new Date().toISOString(),
celebratedAt: "",
};
saveState();
renderView();
});
document.getElementById("trainingClubGoalCelebrate")?.addEventListener("click", () => {
state.training.clubGoal = { ...state.training.clubGoal, celebratedAt: new Date().toISOString() };
saveState();
renderView();
});
document.getElementById("trainingClubGoalDelete")?.addEventListener("click", () => {
state.training.clubGoal = null;
saveState();
renderView();
});
document.querySelectorAll(".training-club-driver").forEach((button) => {
button.addEventListener("click", () => {
setSelectedDriverId(button.dataset.driverId || "");
setSelectedClassId(button.dataset.classId || "");
renderView();
});
});
}
function trainingStat(label, value, note) {
return `${label}
${value}
${note}`;
}
function trainingInsight(label, value) {
return `${label}${value}
`;
}
function formatTrainingHighlight(highlight, { t, formatLap }) {
if (highlight.id === "personal_best") return t("training.highlight_personal_best", { lap: formatLap(highlight.value) });
if (highlight.id === "sector_gain") return t("training.highlight_sector_gain", { lap: formatLap(highlight.value) });
if (highlight.id === "strong_finish") return t("training.highlight_strong_finish", { count: highlight.value });
if (highlight.id === "clean_five_minute") return t("training.highlight_clean_five_minute", { count: highlight.value });
if (highlight.id === "pace_gain") return t("training.highlight_pace_gain", { lap: formatLap(highlight.value) });
if (highlight.id === "consistency_gain") return t("training.highlight_consistency_gain", { value: Number(highlight.value).toFixed(1) });
if (highlight.id === "controlled_streak") return t("training.highlight_controlled_streak", { laps: highlight.value });
if (highlight.id === "five_minute") return t("training.highlight_five_minute", { count: highlight.value });
if (highlight.id === "keep_building") return t("training.highlight_keep_building", { laps: highlight.value });
if (highlight.id === "ready") return t("training.highlight_ready");
return t("training.highlight_today_laps", { laps: highlight.value });
}
function normalizePublicBaseUrl(value) {
try {
const url = new URL(String(value || "").trim());
return ["http:", "https:"].includes(url.protocol) ? url.origin : "";
} catch {
return "";
}
}
function buildPublicProfileUrl(token, language, publicBaseUrl = "") {
const url = new URL(`/public-training/${encodeURIComponent(token)}`, publicBaseUrl || window.location.origin);
url.searchParams.set("lang", language === "en" ? "en" : "sv");
return url.toString();
}
function buildPublicProfileQrUrl(token, language, baseUrl) {
const params = new URLSearchParams({
lang: language === "en" ? "en" : "sv",
baseUrl,
});
return `/public-training/qr/${encodeURIComponent(token)}?${params.toString()}`;
}
function generateShareToken() {
const bytes = new Uint8Array(24);
crypto.getRandomValues(bytes);
return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
}
function replacePublicProfileShare(state, driverId, profileReferences = new Set([driverId])) {
const nextShare = {
driverId,
token: generateShareToken(),
enabled: true,
createdAt: new Date().toISOString(),
};
state.training.shares = [...state.training.shares.filter((share) => !profileReferences.has(String(share.driverId || ""))), nextShare];
}
async function copyText(value) {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value);
return;
} catch {
// HTTP LAN pages may not receive Clipboard API permission.
}
}
const input = document.createElement("textarea");
input.value = value;
input.style.position = "fixed";
input.style.opacity = "0";
document.body.appendChild(input);
input.select();
document.execCommand("copy");
input.remove();
}