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_title"))}

${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("training.recommendation"))}

    ${escapeHtml(t("training.flexible_duration"))}
    ${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.goals"))}

    ${goals.length}
    ${goalMarkup}
    ` : ""}
    ${escapeHtml(t("training.manage_goals_sharing"))}${escapeHtml(t("training.manage_goals_sharing_hint"))} +

    ${escapeHtml(t("training.new_goal"))}

    ${[500, 1000, 1500].map((value) => ``).join("")}

    ${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( ${escapeHtml(t("training.local_profile_qr_hint"))}
    ${publicQrUrl ? `
    ${escapeHtml(t("training.public_profile_qr"))} ${escapeHtml(t( ${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.add_marker"))}

    ${escapeHtml(t("training.add_marker_hint"))}

    ${markerMarkup}

    ${escapeHtml(t("training.weekday_pace"))}

    ${escapeHtml(t("training.weekday_hint"))}

    ${weekdayMarkup}

    ${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"))}

    ${channelStats.recentPitStops.map((stop) => ` `).join("")}
    ${escapeHtml(t("training.date"))}${escapeHtml(t("table.event"))}${escapeHtml(t("table.session"))}${escapeHtml(t("table.car"))}${escapeHtml(t("table.duration"))}
    ${escapeHtml(new Date(stop.enteredAt).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }))} ${escapeHtml(stop.eventName || "-")} ${escapeHtml(stop.sessionName || "-")} ${escapeHtml(stop.carName || "-")} ${formatLap(stop.pitStopMs)}
    ` : ""}

    ${escapeHtml(t("training.history"))}

    ${stats.personalBestGainMs ? `${escapeHtml(t("training.pb_improvement", { lap: formatLap(stats.personalBestGainMs) }))}` : ""}
    ${dailyRows}
    ${escapeHtml(t("training.date"))}${escapeHtml(t("training.laps"))}${escapeHtml(t("training.best_lap"))}${escapeHtml(t("training.average_lap"))}
    ${escapeHtml(t("training.club_progress"))}${escapeHtml(t("training.club_progress_hint"))} +
    ${clubRows}
    ${escapeHtml(t("training.driver"))}${escapeHtml(t("training.laps"))}${escapeHtml(t("training.training_days"))}${escapeHtml(t("training.best_lap"))}${escapeHtml(t("training.next_target"))}
    `; 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"))}
    ${escapeHtml(t("training.together"))}

    ${escapeHtml(t("training.club_goal"))}

    ${clubGoalMarkup}
    ${escapeHtml(t(clubGoal ? "training.club_goal_new" : "training.club_goal_create"))}

    ${escapeHtml(t("training.club_goal_baseline_hint"))}

    ${escapeHtml(t("training.progress_not_positions"))}

    ${escapeHtml(t("training.progress_feed"))}

    ${progressFeedMarkup}

    ${escapeHtml(t("training.club_progress"))}

    ${rows.length}

    ${escapeHtml(t("training.overview_class_hint"))}

    ${tableRows}
    ${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"))}
    `; 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(); }