1052 lines
58 KiB
JavaScript
1052 lines
58 KiB
JavaScript
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 `
|
|
<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>${row.fiveMinuteResult ? `${row.fiveMinuteResult.lapCount}/${formatRaceClock(row.fiveMinuteResult.totalMs)} · ${t("training.average_short")} ${formatLap(row.fiveMinuteResult.averageLapMs)}` : "-"}</td>
|
|
<td>${formatRaceClock(row.durationMs)}</td>
|
|
</tr>`;
|
|
}
|
|
|
|
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 = `
|
|
<section class="panel training-empty">
|
|
<div class="panel-header"><h3>${escapeHtml(t("training.no_drivers_title"))}</h3></div>
|
|
<div class="panel-body"><p>${escapeHtml(t("training.no_drivers_body"))}</p></div>
|
|
</section>
|
|
`;
|
|
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 = `<option value="">${escapeHtml(t("training.overview_title"))}</option>` + 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 `<option value="${escapeHtml(profile.id)}" ${profile.id === selectedProfileId ? "selected" : ""}>${escapeHtml(label)}</option>`;
|
|
})
|
|
.join("");
|
|
const classOptions = selectedProfile.classIds
|
|
.map((classId) => {
|
|
const className = state.classes.find((item) => item.id === classId)?.name || classId;
|
|
return `<option value="${escapeHtml(classId)}" ${classId === selectedClassId ? "selected" : ""}>${escapeHtml(className)}</option>`;
|
|
})
|
|
.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 `
|
|
<article class="training-goal ${statusClass}">
|
|
<div class="training-goal-head">
|
|
<div>
|
|
<span class="training-kicker">${escapeHtml(statusLabel)}</span>
|
|
<h4>${escapeHtml(goal.title || t("training.default_goal_title", { laps: goal.targetLaps }))}</h4>
|
|
</div>
|
|
<strong>${progress.currentLaps} / ${progress.targetLaps}</strong>
|
|
</div>
|
|
<div class="training-progress" aria-label="${escapeHtml(t("training.goal_progress"))}">
|
|
<span style="width:${progress.percent.toFixed(2)}%"></span>
|
|
</div>
|
|
${goal.reward ? `<p><b>${escapeHtml(t("training.reward"))}:</b> ${escapeHtml(goal.reward)}</p>` : ""}
|
|
<div class="actions">
|
|
${progress.reached && !goal.claimedAt ? `<button class="btn btn-primary btn-mini" id="training-claim-${escapeHtml(goal.id)}">${escapeHtml(t("training.claim_reward"))}</button>` : ""}
|
|
<button class="btn btn-danger btn-mini" id="training-delete-${escapeHtml(goal.id)}">${escapeHtml(t("common.delete"))}</button>
|
|
</div>
|
|
</article>
|
|
`;
|
|
}).join("")
|
|
: `<p class="hint">${escapeHtml(t("training.no_goals"))}</p>`;
|
|
|
|
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) => `
|
|
<tr>
|
|
<td>${escapeHtml(new Date(day.latestTimestamp).toLocaleDateString(undefined, { dateStyle: "medium" }))}</td>
|
|
<td>${day.laps}</td>
|
|
<td>${formatLap(day.bestLapMs)}</td>
|
|
<td>${formatLap(day.averageLapMs)}</td>
|
|
</tr>
|
|
`).join("")
|
|
: `<tr><td colspan="4">${escapeHtml(t("training.no_history"))}</td></tr>`;
|
|
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) => `
|
|
<article class="training-achievement ${achievement.unlocked ? "is-unlocked" : "is-next"}">
|
|
<span class="training-achievement-mark">${achievement.unlocked ? "OK" : `${Math.round(achievement.percent)}%`}</span>
|
|
<div>
|
|
<strong>${escapeHtml(t(`training.achievement_${achievement.id}`))}</strong>
|
|
<small>${escapeHtml(t(achievement.unlocked ? "training.achievement_unlocked" : "training.achievement_next"))}</small>
|
|
</div>
|
|
</article>
|
|
`).join("")
|
|
: `<p class="hint">${escapeHtml(t("training.achievement_empty"))}</p>`;
|
|
const highlightMarkup = highlights.map((highlight) => `
|
|
<li><span>+</span><strong>${escapeHtml(formatTrainingHighlight(highlight, { t, formatLap }))}</strong></li>
|
|
`).join("");
|
|
const missionMarkup = missions.map((mission) => `
|
|
<article class="training-mission ${mission.completed ? "is-complete" : ""}">
|
|
<span class="training-mission-check">${mission.completed ? "OK" : `${Math.round(mission.percent)}%`}</span>
|
|
<div>
|
|
<strong>${escapeHtml(t(`training.mission_${mission.id}`))}</strong>
|
|
<small>${escapeHtml(t("training.mission_progress", { current: mission.current, target: mission.target }))}</small>
|
|
<div class="training-progress"><span style="width:${mission.percent.toFixed(2)}%"></span></div>
|
|
</div>
|
|
</article>
|
|
`).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 `
|
|
<tr class="${entry.profile.id === selectedProfileId ? "is-selected" : ""}">
|
|
<td><button class="training-club-driver" data-driver-id="${escapeHtml(entry.profile.id)}" data-class-id="${escapeHtml(selectedClassId)}" type="button">${escapeHtml(entry.driver?.name || "-")}</button></td>
|
|
<td>${entry.stats.totalLaps}</td>
|
|
<td>${entry.stats.trainingDays}</td>
|
|
<td>${formatLap(entry.stats.bestLapMs)}</td>
|
|
<td>${milestone.targetLaps}</td>
|
|
</tr>
|
|
`;
|
|
}).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("training.stint_five_minute"))}</th><th>${escapeHtml(t("training.stint_duration"))}</th></tr>`;
|
|
const featuredStintRows = featuredStints.length
|
|
? featuredStints.map((row) => renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock })).join("")
|
|
: `<tr><td colspan="11">${escapeHtml(t("training.no_stints"))}</td></tr>`;
|
|
const allStintRows = stintAnalysis.rows.map((row) => renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock })).join("");
|
|
const 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>
|
|
<span class="training-kicker">${escapeHtml(t("training.eyebrow"))}</span>
|
|
<h3>${escapeHtml(selectedDriver.name)}</h3>
|
|
<p>${escapeHtml(t("training.intro"))}</p>
|
|
</div>
|
|
<div class="training-hero-controls">
|
|
<label class="training-driver-select">
|
|
<span>${escapeHtml(t("training.driver"))}</span>
|
|
<select id="training-driver">${driverOptions}</select>
|
|
</label>
|
|
<label class="training-driver-select">
|
|
<span>${escapeHtml(t("training.class"))}</span>
|
|
<select id="training-class">${classOptions}</select>
|
|
</label>
|
|
<div class="actions training-hero-actions">
|
|
<button class="btn" id="trainingBackOverview" type="button">${escapeHtml(t("training.back_overview"))}</button>
|
|
${profileShare ? `
|
|
<button class="btn btn-primary" id="trainingHeroOpenProfile" type="button">${escapeHtml(t("training.start_personal"))}</button>
|
|
<button class="btn" id="trainingHeroCopyProfile" type="button">${escapeHtml(t("training.copy_mobile_link"))}</button>
|
|
` : `
|
|
<button class="btn btn-primary" id="trainingHeroEnableProfile" type="button">${escapeHtml(t("training.create_personal_link"))}</button>
|
|
`}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
${activePractice ? `
|
|
<section class="training-live-card">
|
|
<div class="training-live-head">
|
|
<div>
|
|
<span class="training-kicker">${escapeHtml(t("training.live_now"))}</span>
|
|
<h3>${escapeHtml(activePractice.sessionName || t("training.active_practice"))}</h3>
|
|
</div>
|
|
<span class="badge badge-online">${escapeHtml(t("training.shared_practice"))}</span>
|
|
</div>
|
|
<div class="training-live-stats">
|
|
${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 }))}
|
|
</div>
|
|
<p>${escapeHtml(t("training.live_practice_hint"))}</p>
|
|
</section>
|
|
` : ""}
|
|
|
|
<div class="grid cols-4 training-stats">
|
|
${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"))}
|
|
</div>
|
|
|
|
<div class="training-motivation-grid">
|
|
<section class="training-credit-card">
|
|
<span class="training-kicker">${escapeHtml(t("training.credit_eyebrow"))}</span>
|
|
<h3>${escapeHtml(t(stats.todayLaps > 0 ? "training.credit_today_title" : "training.credit_ready_title", { driver: selectedDriver.name }))}</h3>
|
|
<p>${escapeHtml(t(stats.todayLaps > 0 ? "training.credit_today_body" : "training.credit_ready_body"))}</p>
|
|
<ul>${highlightMarkup}</ul>
|
|
</section>
|
|
<section class="training-missions-card">
|
|
<div class="training-section-title">
|
|
<div><span class="training-kicker">${escapeHtml(t("training.optional_challenges"))}</span><h3>${escapeHtml(t("training.daily_missions"))}</h3></div>
|
|
<span class="badge success">${missions.filter((mission) => mission.completed).length}/${missions.length}</span>
|
|
</div>
|
|
<p>${escapeHtml(t("training.daily_missions_hint"))}</p>
|
|
<div class="training-missions">${missionMarkup}</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div class="training-development-grid">
|
|
<section class="training-development-card">
|
|
<div class="training-section-title">
|
|
<div><span class="training-kicker">${escapeHtml(t("training.your_progress"))}</span><h3>${escapeHtml(t("training.recent_trend"))}</h3></div>
|
|
<span class="badge">30 + 30 ${escapeHtml(t("training.laps").toLowerCase())}</span>
|
|
</div>
|
|
<div class="training-trend-metrics">
|
|
${trainingInsight(t("training.pace_trend"), paceTrend)}
|
|
${trainingInsight(t("training.consistency_trend"), consistencyTrend)}
|
|
</div>
|
|
<p>${escapeHtml(t("training.trend_hint"))}</p>
|
|
</section>
|
|
<section class="training-development-card">
|
|
<div class="training-section-title">
|
|
<div><span class="training-kicker">${escapeHtml(t("training.personal_credit"))}</span><h3>${escapeHtml(t("training.achievements"))}</h3></div>
|
|
<span class="badge success">${unlockedAchievements.length}/${(stats.achievements || []).length}</span>
|
|
</div>
|
|
<div class="training-achievements">${achievementMarkup}</div>
|
|
</section>
|
|
</div>
|
|
|
|
<div class="training-focus-grid">
|
|
<section class="training-milestone-card">
|
|
<div>
|
|
<span class="training-kicker">${escapeHtml(t("training.next_level"))}</span>
|
|
<h3>${escapeHtml(t("training.next_level_target", { laps: nextMilestone.targetLaps }))}</h3>
|
|
<p>${escapeHtml(t("training.goal_remaining", { laps: nextMilestone.remainingLaps }))}</p>
|
|
</div>
|
|
<strong>${nextMilestone.currentLaps} / ${nextMilestone.targetLaps}</strong>
|
|
<div class="training-progress"><span style="width:${nextMilestone.percent.toFixed(2)}%"></span></div>
|
|
</section>
|
|
<section class="panel training-plan">
|
|
<div class="panel-header"><h3>${escapeHtml(t("training.recommendation"))}</h3><span class="badge">${escapeHtml(t("training.flexible_duration"))}</span></div>
|
|
<div class="panel-body">
|
|
<span class="training-kicker">${escapeHtml(t(recommendation.titleKey))}</span>
|
|
<h3>${escapeHtml(targetText)}</h3>
|
|
<p>${escapeHtml(t(recommendation.bodyKey))}</p>
|
|
<div class="training-blocks">
|
|
<div><strong>${escapeHtml(t("training.block_one"))}</strong><span>${escapeHtml(t("training.block_push"))}</span></div>
|
|
<div><strong>${escapeHtml(t("training.block_two"))}</strong><span>${escapeHtml(t("training.block_control"))}</span></div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
${goals.length ? `
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${escapeHtml(t("training.goals"))}</h3><span class="badge">${goals.length}</span></div>
|
|
<div class="panel-body training-goals">${goalMarkup}</div>
|
|
</section>
|
|
` : ""}
|
|
|
|
<div class="training-secondary">
|
|
<details class="training-disclosure" id="trainingGoalsSharing" ${goalsSharingWasOpen ? "open" : ""}>
|
|
<summary>
|
|
<span><strong>${escapeHtml(t("training.manage_goals_sharing"))}</strong><small>${escapeHtml(t("training.manage_goals_sharing_hint"))}</small></span>
|
|
<span class="training-disclosure-arrow">+</span>
|
|
</summary>
|
|
<div class="training-disclosure-body">
|
|
<div class="training-layout">
|
|
<section class="training-simple-section">
|
|
<h3>${escapeHtml(t("training.new_goal"))}</h3>
|
|
<form id="training-goal-form" class="form-grid training-goal-form">
|
|
<label>${escapeHtml(t("training.target_laps"))}<input type="number" name="targetLaps" min="1" step="1" value="500" required /></label>
|
|
<div class="training-presets" aria-label="${escapeHtml(t("training.quick_targets"))}">
|
|
${[500, 1000, 1500].map((value) => `<button type="button" class="btn btn-mini training-preset" data-laps="${value}">${value}</button>`).join("")}
|
|
</div>
|
|
<label>${escapeHtml(t("training.goal_name"))}<input name="title" placeholder="${escapeHtml(t("training.goal_name_placeholder"))}" /></label>
|
|
<label>${escapeHtml(t("training.reward"))}<input name="reward" placeholder="${escapeHtml(t("training.reward_placeholder"))}" /></label>
|
|
<button class="btn btn-primary" type="submit">${escapeHtml(t("training.add_goal"))}</button>
|
|
</form>
|
|
</section>
|
|
<section class="training-simple-section training-share-panel">
|
|
<div class="training-simple-head">
|
|
<h3>${escapeHtml(t("training.public_profile"))}</h3>
|
|
<span class="badge ${profileShare ? "badge-online" : "badge-offline"}">${escapeHtml(t(profileShare ? "training.public_profile_active" : "training.public_profile_off"))}</span>
|
|
</div>
|
|
<div class="training-share-compact">
|
|
<p>${escapeHtml(t("training.public_profile_help"))}</p>
|
|
<label class="training-share-url">
|
|
<span>${escapeHtml(t("training.public_base_url"))}</span>
|
|
<input id="trainingPublicBaseUrl" type="url" value="${escapeHtml(publicBaseUrl)}" placeholder="https://training.example.se" />
|
|
<small>${escapeHtml(t("training.public_base_url_hint"))}</small>
|
|
</label>
|
|
<div class="actions">
|
|
<button class="btn" id="trainingPublicBaseSave" type="button">${escapeHtml(t("training.save_public_url"))}</button>
|
|
<button class="btn" id="trainingPublicAddressDetect" type="button">${escapeHtml(t("training.detect_public_ip"))}</button>
|
|
</div>
|
|
<p class="hint" id="trainingPublicAddressStatus"></p>
|
|
${profileShare ? `
|
|
<label class="training-share-url">
|
|
<span>${escapeHtml(t("training.public_profile_link"))}</span>
|
|
<input id="trainingPublicProfileUrl" value="${escapeHtml(publicProfileUrl)}" readonly />
|
|
</label>
|
|
<div class="training-profile-qr-list">
|
|
<div class="training-profile-qr">
|
|
<strong>${escapeHtml(t("training.local_profile_qr"))}</strong>
|
|
<img src="${escapeHtml(localQrUrl)}" alt="${escapeHtml(t("training.local_profile_qr"))}" />
|
|
<small>${escapeHtml(t("training.local_profile_qr_hint"))}</small>
|
|
</div>
|
|
${publicQrUrl ? `
|
|
<div class="training-profile-qr">
|
|
<strong>${escapeHtml(t("training.public_profile_qr"))}</strong>
|
|
<img src="${escapeHtml(publicQrUrl)}" alt="${escapeHtml(t("training.public_profile_qr"))}" />
|
|
<small>${escapeHtml(new URL(publicProfileUrl).host)}</small>
|
|
</div>
|
|
` : ""}
|
|
</div>
|
|
<small class="training-profile-qr-hint">${escapeHtml(t("training.public_profile_qr_hint"))}</small>
|
|
<div class="actions">
|
|
<button class="btn btn-primary" id="trainingPublicProfileCopy" type="button">${escapeHtml(t("training.copy_link"))}</button>
|
|
<button class="btn" id="trainingPublicProfileOpen" type="button">${escapeHtml(t("training.open_profile"))}</button>
|
|
<button class="btn" id="trainingPublicProfileRotate" type="button">${escapeHtml(t("training.replace_link"))}</button>
|
|
<button class="btn btn-danger" id="trainingPublicProfileDisable" type="button">${escapeHtml(t("training.disable_profile"))}</button>
|
|
</div>
|
|
` : `
|
|
<button class="btn btn-primary" id="trainingPublicProfileEnable" type="button">${escapeHtml(t("training.enable_profile"))}</button>
|
|
`}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
|
|
<details class="training-disclosure" id="trainingMoreStats" ${moreStatsWasOpen ? "open" : ""}>
|
|
<summary>
|
|
<span><strong>${escapeHtml(t("training.detailed_stats"))}</strong><small>${escapeHtml(t("training.detailed_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>
|
|
<section class="training-insights">
|
|
${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) : "-")}
|
|
</section>
|
|
${channelStats.recentPitStops.length ? `
|
|
<div class="training-section-title"><h3>${escapeHtml(t("training.recent_pit_stops"))}</h3></div>
|
|
<div class="table-wrap training-history-wrap">
|
|
<table class="training-history-table">
|
|
<thead><tr><th>${escapeHtml(t("training.date"))}</th><th>${escapeHtml(t("table.event"))}</th><th>${escapeHtml(t("table.session"))}</th><th>${escapeHtml(t("table.car"))}</th><th>${escapeHtml(t("table.duration"))}</th></tr></thead>
|
|
<tbody>${channelStats.recentPitStops.map((stop) => `
|
|
<tr>
|
|
<td>${escapeHtml(new Date(stop.enteredAt).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }))}</td>
|
|
<td>${escapeHtml(stop.eventName || "-")}</td>
|
|
<td>${escapeHtml(stop.sessionName || "-")}</td>
|
|
<td>${escapeHtml(stop.carName || "-")}</td>
|
|
<td>${formatLap(stop.pitStopMs)}</td>
|
|
</tr>
|
|
`).join("")}</tbody>
|
|
</table>
|
|
</div>
|
|
` : ""}
|
|
<div class="training-section-title">
|
|
<h3>${escapeHtml(t("training.history"))}</h3>
|
|
${stats.personalBestGainMs ? `<span class="badge success">${escapeHtml(t("training.pb_improvement", { lap: formatLap(stats.personalBestGainMs) }))}</span>` : ""}
|
|
</div>
|
|
<div class="table-wrap training-history-wrap">
|
|
<table class="training-history-table">
|
|
<thead><tr><th>${escapeHtml(t("training.date"))}</th><th>${escapeHtml(t("training.laps"))}</th><th>${escapeHtml(t("training.best_lap"))}</th><th>${escapeHtml(t("training.average_lap"))}</th></tr></thead>
|
|
<tbody>${dailyRows}</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
|
|
<details class="training-disclosure">
|
|
<summary>
|
|
<span><strong>${escapeHtml(t("training.club_progress"))}</strong><small>${escapeHtml(t("training.club_progress_hint"))}</small></span>
|
|
<span class="training-disclosure-arrow">+</span>
|
|
</summary>
|
|
<div class="training-disclosure-body table-wrap training-history-wrap">
|
|
<table class="training-history-table training-club-table">
|
|
<thead><tr><th>${escapeHtml(t("training.driver"))}</th><th>${escapeHtml(t("training.laps"))}</th><th>${escapeHtml(t("training.training_days"))}</th><th>${escapeHtml(t("training.best_lap"))}</th><th>${escapeHtml(t("training.next_target"))}</th></tr></thead>
|
|
<tbody>${clubRows}</tbody>
|
|
</table>
|
|
</div>
|
|
</details>
|
|
</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("");
|
|
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) => (
|
|
`<option value="${escapeHtml(profile.id)}">${escapeHtml(profile.representative?.name || "-")}</option>`
|
|
)).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 `
|
|
<button class="training-progress-entry training-club-driver" data-driver-id="${escapeHtml(entry.profile.id)}" data-class-id="${escapeHtml(entry.classId)}" type="button">
|
|
<span class="training-progress-initial">${escapeHtml(String(entry.driver?.name || "?").trim().charAt(0).toUpperCase() || "?")}</span>
|
|
<span><strong>${escapeHtml(entry.driver?.name || "-")}</strong><small>${escapeHtml(entry.className)} · ${escapeHtml(message)}</small></span>
|
|
</button>
|
|
`;
|
|
}).join("")
|
|
: `<p class="hint">${escapeHtml(t("training.feed_empty"))}</p>`;
|
|
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 `
|
|
<tr>
|
|
<td><button class="training-club-driver" data-driver-id="${escapeHtml(row.profile.id)}" data-class-id="${escapeHtml(row.classId)}" type="button">${escapeHtml(row.driver?.name || "-")}</button></td>
|
|
<td>${escapeHtml(row.className)}</td>
|
|
<td>${row.stats.totalLaps}</td>
|
|
<td>${row.stats.trainingDays}</td>
|
|
<td>${formatLap(row.stats.bestLapMs)}</td>
|
|
<td>${escapeHtml(lastActivity)}</td>
|
|
<td>${milestone.targetLaps}</td>
|
|
</tr>
|
|
`;
|
|
}).join("");
|
|
const clubGoalMarkup = clubGoal && clubGoalProgress ? `
|
|
<div class="training-club-goal ${clubGoalProgress.reached ? "is-reached" : ""}">
|
|
<div class="training-club-goal-head">
|
|
<div>
|
|
<span class="training-kicker">${escapeHtml(t(clubGoal.celebratedAt ? "training.club_goal_celebrated" : clubGoalProgress.reached ? "training.club_goal_reached" : "training.club_goal_active"))}</span>
|
|
<h3>${escapeHtml(clubGoal.title || t("training.club_goal_default", { laps: clubGoal.targetLaps }))}</h3>
|
|
</div>
|
|
<strong>${clubGoalProgress.currentLaps} / ${clubGoalProgress.targetLaps}</strong>
|
|
</div>
|
|
<div class="training-progress"><span style="width:${clubGoalProgress.percent.toFixed(2)}%"></span></div>
|
|
<p>${escapeHtml(t("training.club_goal_remaining", { laps: clubGoalProgress.remainingLaps }))}</p>
|
|
${clubGoal.reward ? `<p><b>${escapeHtml(t("training.reward"))}:</b> ${escapeHtml(clubGoal.reward)}</p>` : ""}
|
|
<div class="actions">
|
|
${clubGoalProgress.reached && !clubGoal.celebratedAt ? `<button class="btn btn-primary" id="trainingClubGoalCelebrate" type="button">${escapeHtml(t("training.club_goal_mark_celebrated"))}</button>` : ""}
|
|
<button class="btn btn-danger" id="trainingClubGoalDelete" type="button">${escapeHtml(t("training.club_goal_delete"))}</button>
|
|
</div>
|
|
</div>
|
|
` : `<p class="hint">${escapeHtml(t("training.club_goal_empty"))}</p>`;
|
|
|
|
dom.view.innerHTML = `
|
|
<section class="training-hero training-overview-hero">
|
|
<div>
|
|
<span class="training-kicker">${escapeHtml(t("training.eyebrow"))}</span>
|
|
<h3>${escapeHtml(t("training.overview_title"))}</h3>
|
|
<p>${escapeHtml(t("training.overview_intro"))}</p>
|
|
</div>
|
|
<div class="training-hero-controls">
|
|
<label class="training-driver-select">
|
|
<span>${escapeHtml(t("training.driver"))}</span>
|
|
<select id="training-overview-driver">
|
|
<option value="">${escapeHtml(t("training.select_driver"))}</option>
|
|
${driverOptions}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="grid cols-4 training-stats">
|
|
${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"))}
|
|
</div>
|
|
|
|
<section class="panel mt-16 training-club-goal-panel">
|
|
<div class="panel-header"><div><span class="training-kicker">${escapeHtml(t("training.together"))}</span><h3>${escapeHtml(t("training.club_goal"))}</h3></div></div>
|
|
<div class="panel-body">
|
|
${clubGoalMarkup}
|
|
<details class="training-club-goal-editor">
|
|
<summary>${escapeHtml(t(clubGoal ? "training.club_goal_new" : "training.club_goal_create"))}</summary>
|
|
<form id="trainingClubGoalForm" class="form-grid training-club-goal-form">
|
|
<label>${escapeHtml(t("training.target_laps"))}<input type="number" name="targetLaps" min="1" step="1" value="10000" required /></label>
|
|
<label>${escapeHtml(t("training.goal_name"))}<input name="title" maxlength="80" placeholder="${escapeHtml(t("training.club_goal_name_placeholder"))}" /></label>
|
|
<label>${escapeHtml(t("training.reward"))}<input name="reward" maxlength="120" placeholder="${escapeHtml(t("training.club_goal_reward_placeholder"))}" /></label>
|
|
<button class="btn btn-primary" type="submit">${escapeHtml(t("training.club_goal_start"))}</button>
|
|
</form>
|
|
<p class="hint">${escapeHtml(t("training.club_goal_baseline_hint"))}</p>
|
|
</details>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel mt-16 training-progress-panel">
|
|
<div class="panel-header"><div><span class="training-kicker">${escapeHtml(t("training.progress_not_positions"))}</span><h3>${escapeHtml(t("training.progress_feed"))}</h3></div></div>
|
|
<div class="panel-body training-progress-feed">${progressFeedMarkup}</div>
|
|
</section>
|
|
|
|
<section class="panel mt-16">
|
|
<div class="panel-header"><h3>${escapeHtml(t("training.club_progress"))}</h3><span class="badge">${rows.length}</span></div>
|
|
<div class="panel-body">
|
|
<p class="hint">${escapeHtml(t("training.overview_class_hint"))}</p>
|
|
<div class="table-wrap training-history-wrap">
|
|
<table class="training-history-table training-club-table">
|
|
<thead><tr><th>${escapeHtml(t("training.driver"))}</th><th>${escapeHtml(t("training.class"))}</th><th>${escapeHtml(t("training.laps"))}</th><th>${escapeHtml(t("training.training_days"))}</th><th>${escapeHtml(t("training.best_lap"))}</th><th>${escapeHtml(t("training.last_activity"))}</th><th>${escapeHtml(t("training.next_target"))}</th></tr></thead>
|
|
<tbody>${tableRows}</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
`;
|
|
|
|
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 `<article class="stat-card"><p>${label}</p><h3>${value}</h3><small>${note}</small></article>`;
|
|
}
|
|
|
|
function trainingInsight(label, value) {
|
|
return `<div><span>${label}</span><strong>${value}</strong></div>`;
|
|
}
|
|
|
|
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();
|
|
}
|