Files
Live_RC/src/training_views.js
2026-08-25 20:00:55 +02:00

491 lines
25 KiB
JavaScript

import { getDriverProfileGroups, getDriverProfileReferences } from "./driver_profile_logic.js";
export function renderTrainingView(deps) {
const {
state,
dom,
t,
escapeHtml,
formatLap,
formatRaceClock,
uid,
saveState,
renderView,
normalizeTrainingState,
syncTrainingLedger,
buildDriverTrainingStats,
buildDriverTimingChannelStats,
buildDriverActivePracticeStats,
buildTrainingRecommendation,
getTrainingGoalProgress,
getNextTrainingMilestone,
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 = profiles.find((profile) => profile.id === selectedProfileId || profile.driverIds.includes(selectedProfileId));
if (!selectedProfile) {
selectedProfile = profiles[0] || null;
selectedProfileId = selectedProfile?.id || "";
setSelectedDriverId(selectedProfileId);
} else if (selectedProfileId !== selectedProfile.id) {
selectedProfileId = selectedProfile.id;
setSelectedDriverId(selectedProfileId);
}
if (!selectedProfileId || !selectedProfile) {
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;
}
let selectedClassId = String(getSelectedClassId() || "");
if (!selectedProfile.classIds.includes(selectedClassId)) {
selectedClassId = selectedProfile.classIds[0] || "";
setSelectedClassId(selectedClassId);
}
const selectedDriver = selectedProfile.representative;
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 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 publicProfileUrl = profileShare ? buildPublicProfileUrl(profileShare.token, state.settings?.language) : "";
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 `<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 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.totalLaps - left.stats.totalLaps || String(left.driver?.name || "").localeCompare(String(right.driver?.name || "")))
.map((entry, index) => {
const milestone = getNextTrainingMilestone(entry.stats);
return `
<tr class="${entry.profile.id === selectedProfileId ? "is-selected" : ""}">
<td>${index + 1}</td>
<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("");
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">
${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))}
</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-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">20 min</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>10 min</strong><span>${escapeHtml(t("training.block_push"))}</span></div>
<div><strong>10 min</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">
<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>
${profileShare ? `
<label class="training-share-url">
<span>${escapeHtml(t("training.public_profile_link"))}</span>
<input id="trainingPublicProfileUrl" value="${escapeHtml(publicProfileUrl)}" readonly />
</label>
<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">
<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">
<section class="training-insights">
${trainingInsight(t("training.average_lap"), formatLap(stats.averageLapMs))}
${trainingInsight(t("training.recent_average"), formatLap(stats.recentAverageMs))}
${trainingInsight(t("training.controlled_streak"), t("training.lap_count", { laps: stats.controlledLapStreak }))}
${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.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>#</th><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("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("trainingPublicProfileEnable")?.addEventListener("click", () => {
replacePublicProfileShare(state, selectedProfileId, profileReferences);
saveState();
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", () => {
if (!confirm(t("training.replace_link_confirm"))) return;
replacePublicProfileShare(state, selectedProfileId, profileReferences);
saveState();
renderView();
});
document.getElementById("trainingPublicProfileDisable")?.addEventListener("click", () => {
state.training.shares = state.training.shares.map((share) => profileReferences.has(String(share.driverId || "")) ? { ...share, enabled: false } : share);
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 buildPublicProfileUrl(token, language) {
const url = new URL(`/public-training/${encodeURIComponent(token)}`, window.location.origin);
url.searchParams.set("lang", language === "en" ? "en" : "sv");
return url.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();
}