Add motivational training challenges and shared club goals

This commit is contained in:
larssand
2026-08-30 09:16:11 +02:00
parent 27dcbd92aa
commit 694aba5f3d
11 changed files with 799 additions and 13 deletions

View File

@@ -43,6 +43,9 @@ export function renderTrainingView(deps) {
buildTrainingRecommendation,
getTrainingGoalProgress,
getNextTrainingMilestone,
buildTrainingMissions,
buildTrainingHighlights,
getClubTrainingGoalProgress,
buildTrainingPaceBuckets,
buildTrainingWeekdayStats,
buildTrainingStintAnalysis,
@@ -79,7 +82,7 @@ export function renderTrainingView(deps) {
}
if (!selectedProfileId || !selectedProfile) {
renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, profiles, buildDriverTrainingStats, getNextTrainingMilestone, setSelectedDriverId, setSelectedClassId, renderView });
renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, uid, saveState, profiles, buildDriverTrainingStats, getNextTrainingMilestone, getClubTrainingGoalProgress, setSelectedDriverId, setSelectedClassId, renderView });
return;
}
@@ -96,6 +99,8 @@ export function renderTrainingView(deps) {
const activePractice = buildDriverActivePracticeStats(state, selectedProfileId, Date.now(), selectedClassId);
const recommendation = buildTrainingRecommendation(stats);
const nextMilestone = getNextTrainingMilestone(stats);
const missions = buildTrainingMissions(stats);
const highlights = buildTrainingHighlights(stats);
const paceBuckets = buildTrainingPaceBuckets(state, selectedProfileId, selectedClassId, 30).slice(-24);
const weekdayStats = buildTrainingWeekdayStats(state, selectedProfileId, selectedClassId);
const stintAnalysis = buildTrainingStintAnalysis(state, selectedProfileId, selectedClassId);
@@ -184,6 +189,19 @@ export function renderTrainingView(deps) {
</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
@@ -319,6 +337,23 @@ export function renderTrainingView(deps) {
${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">
@@ -703,12 +738,14 @@ export function buildTrainingProgressFeed(rows, limit = 6) {
});
}
function renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, profiles, buildDriverTrainingStats, getNextTrainingMilestone, setSelectedDriverId, setSelectedClassId, renderView }) {
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("");
@@ -748,6 +785,24 @@ function renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, profiles
</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">
@@ -774,6 +829,23 @@ function renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, profiles
${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>
@@ -799,6 +871,31 @@ function renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, profiles
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 || "");
@@ -816,6 +913,17 @@ 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 === "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 buildPublicProfileUrl(token, language) {
const url = new URL(`/public-training/${encodeURIComponent(token)}`, window.location.origin);
url.searchParams.set("lang", language === "en" ? "en" : "sv");