Add motivational training challenges and shared club goals
This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user