Add end-of-practice summary to the driver's mobile profile
When a driver's personal 5-minute practice timer finishes, a "Pass avslutat" card now appears immediately: how this pass's pace and consistency compared to their recent sessions, any goal or lap-count milestone crossed during the pass with its reward text made prominent, and one suggested next goal (reusing the existing pace recommendation, falling back to the next milestone when there isn't enough data yet). Scoped to the driver's current class, and explicit about insufficient sample sizes instead of showing a misleading number. Adds a per-session consistency calculation to buildPublicTrainingWindowStats server-side; everything else needed (stats, goals, recommendation, milestone) was already sent to the client, so the summary itself is computed there from data already in hand - no raw laps or driver IDs added to what the public endpoint exposes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -531,6 +531,42 @@ button {
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.profile-summary-card {
|
||||
border-color: color-mix(in srgb, var(--gold) 45%, var(--line) 55%);
|
||||
background:
|
||||
radial-gradient(circle at 92% 8%, rgba(245, 200, 76, 0.16), transparent 42%),
|
||||
color-mix(in srgb, var(--surface) 94%, transparent);
|
||||
}
|
||||
|
||||
.profile-summary-card p {
|
||||
margin: 8px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.profile-summary-highlight {
|
||||
color: var(--gold);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.profile-summary-reward {
|
||||
padding: 9px 11px;
|
||||
border: 1px solid color-mix(in srgb, var(--gold) 45%, var(--line) 55%);
|
||||
border-radius: 10px;
|
||||
background: rgba(245, 200, 76, 0.08);
|
||||
}
|
||||
|
||||
.profile-summary-next-goal {
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.profile-summary-next-goal h3 {
|
||||
margin: 5px 0 0;
|
||||
font-family: Orbitron, sans-serif;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.profile-timer-setup {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
@@ -64,6 +64,21 @@ const messages = {
|
||||
updated: "Uppdaterad {time}",
|
||||
switchCue: "Byt till kontrollerad körning",
|
||||
finishCue: "Träningspasset är klart",
|
||||
practiceSummaryTitle: "Pass avslutat",
|
||||
practiceSummaryNoLaps: "Inga varv registrerades under passet - kolla att transpondern fungerade och kör igen.",
|
||||
practiceSummaryPaceFaster: "Du körde i snitt {lap} snabbare än innan.",
|
||||
practiceSummaryPaceSlower: "Du körde i snitt {lap} långsammare än innan - ingen fara, alla pass är olika.",
|
||||
practiceSummaryPaceStable: "Ditt tempo var i linje med tidigare pass.",
|
||||
practiceSummaryPaceInsufficient: "Kör några fler pass så kan vi visa hur ditt tempo utvecklas.",
|
||||
practiceSummaryConsistencyGood: "Mycket jämna varv den här gången ({pct}%).",
|
||||
practiceSummaryConsistencyOk: "Ganska jämna varv ({pct}%).",
|
||||
practiceSummaryConsistencyRough: "Varven varierade en del ({pct}%) - sikta på jämnare tempo nästa gång.",
|
||||
practiceSummaryConsistencyInsufficient: "För få varv den här passet för att bedöma jämnheten.",
|
||||
practiceSummaryGoalReached: "Du nådde \"{title}\" ({laps} varv)!",
|
||||
practiceSummaryMilestoneReached: "Du passerade {laps} varv totalt!",
|
||||
practiceSummaryRewardHint: "Belöning: {reward} - visa den här sidan för din coach eller förälder.",
|
||||
practiceSummaryNextGoalTitle: "Nästa mål",
|
||||
practiceSummaryClose: "Stäng",
|
||||
performance: "Stintstatistik",
|
||||
paceChart: "Tempoutveckling",
|
||||
paceChartHint: "Senaste träningsdagarnas snittempo i vald klass. Lägre är snabbare.",
|
||||
@@ -206,6 +221,21 @@ const messages = {
|
||||
updated: "Updated {time}",
|
||||
switchCue: "Switch to controlled driving",
|
||||
finishCue: "The training session is complete",
|
||||
practiceSummaryTitle: "Practice complete",
|
||||
practiceSummaryNoLaps: "No laps were recorded during this pass - check the transponder and try again.",
|
||||
practiceSummaryPaceFaster: "You averaged {lap} faster than before.",
|
||||
practiceSummaryPaceSlower: "You averaged {lap} slower than before - that's fine, every pass is different.",
|
||||
practiceSummaryPaceStable: "Your pace matched your recent sessions.",
|
||||
practiceSummaryPaceInsufficient: "Run a few more sessions and we can show how your pace is developing.",
|
||||
practiceSummaryConsistencyGood: "Very even laps this time ({pct}%).",
|
||||
practiceSummaryConsistencyOk: "Fairly even laps ({pct}%).",
|
||||
practiceSummaryConsistencyRough: "Your laps varied a bit ({pct}%) - aim for a steadier pace next time.",
|
||||
practiceSummaryConsistencyInsufficient: "Not enough laps this pass to judge consistency.",
|
||||
practiceSummaryGoalReached: "You reached \"{title}\" ({laps} laps)!",
|
||||
practiceSummaryMilestoneReached: "You passed {laps} total laps!",
|
||||
practiceSummaryRewardHint: "Reward: {reward} - show this page to your coach or parent.",
|
||||
practiceSummaryNextGoalTitle: "Next goal",
|
||||
practiceSummaryClose: "Close",
|
||||
performance: "Stint statistics",
|
||||
paceChart: "Pace development",
|
||||
paceChartHint: "Average pace for recent practice days in the selected class. Lower is faster.",
|
||||
@@ -344,6 +374,50 @@ function recommendationCopy(recommendation) {
|
||||
};
|
||||
}
|
||||
|
||||
function nextMilestoneForLaps(laps) {
|
||||
const presets = [100, 250, 500, 1000, 1500, 2500, 5000];
|
||||
return presets.find((target) => target > laps) || (Math.floor(laps / 1000) + 1) * 1000;
|
||||
}
|
||||
|
||||
// Turns the already-fetched, already-sanitized profile data into a one-time "practice just
|
||||
// ended" recap: pace and consistency for this pass specifically, anything crossed *during*
|
||||
// this pass (not all-time), one suggested next goal, and the reward text made prominent -
|
||||
// never a comparison against other drivers.
|
||||
function buildPracticeSummary({ currentSession, stats, goals, recommendation, nextMilestone }) {
|
||||
const laps = currentSession?.laps || 0;
|
||||
if (!laps) return { laps: 0, paceText: t("practiceSummaryNoLaps"), consistencyText: "", justReachedGoals: [], justReachedMilestoneLaps: null, nextGoalTitle: "", nextGoalBody: "" };
|
||||
|
||||
const paceText = !stats.trendSampleReady || laps < 3
|
||||
? t("practiceSummaryPaceInsufficient")
|
||||
: (() => {
|
||||
const diffMs = Number(stats.previousRecentAverageMs || 0) - Number(currentSession.averageLapMs || 0);
|
||||
if (Math.abs(diffMs) < 50) return t("practiceSummaryPaceStable");
|
||||
return diffMs > 0
|
||||
? t("practiceSummaryPaceFaster", { lap: formatLap(diffMs) })
|
||||
: t("practiceSummaryPaceSlower", { lap: formatLap(Math.abs(diffMs)) });
|
||||
})();
|
||||
|
||||
const consistencyText = !currentSession.consistencySampleReady
|
||||
? t("practiceSummaryConsistencyInsufficient")
|
||||
: (() => {
|
||||
const pct = Number(currentSession.consistencySpreadPct || 0);
|
||||
const key = pct <= 5 ? "practiceSummaryConsistencyGood" : pct <= 8 ? "practiceSummaryConsistencyOk" : "practiceSummaryConsistencyRough";
|
||||
return t(key, { pct: pct.toFixed(1) });
|
||||
})();
|
||||
|
||||
const lapsBeforeSession = Math.max(0, Number(stats.totalLaps || 0) - laps);
|
||||
const justReachedGoals = (Array.isArray(goals) ? goals : []).filter((goal) => goal.reached && lapsBeforeSession < goal.targetLaps);
|
||||
const milestoneBeforeTarget = nextMilestoneForLaps(lapsBeforeSession);
|
||||
const justReachedMilestoneLaps = milestoneBeforeTarget <= Number(stats.totalLaps || 0) ? milestoneBeforeTarget : null;
|
||||
|
||||
const recommendationText = recommendationCopy(recommendation);
|
||||
const useMilestoneAsNextGoal = recommendation?.level === "baseline" && nextMilestone?.remainingLaps;
|
||||
const nextGoalTitle = useMilestoneAsNextGoal ? t("reachLaps", { laps: nextMilestone.targetLaps }) : recommendationText.title;
|
||||
const nextGoalBody = useMilestoneAsNextGoal ? t("lapsRemaining", { laps: nextMilestone.remainingLaps }) : recommendationText.body;
|
||||
|
||||
return { laps, paceText, consistencyText, justReachedGoals, justReachedMilestoneLaps, nextGoalTitle, nextGoalBody };
|
||||
}
|
||||
|
||||
function getLocalDayKey(timestamp) {
|
||||
const date = new Date(Number(timestamp || 0));
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
@@ -437,6 +511,9 @@ function renderProfile(data) {
|
||||
const timerConfig = getTimerConfig(timerState);
|
||||
const timerDurationMs = getTimerDurationMs(timerConfig);
|
||||
const timerLocked = Boolean(timerState?.startedAt && getTimerElapsed(timerState) < timerDurationMs);
|
||||
const timerJustFinished = Boolean(timerState?.startedAt && getTimerElapsed(timerState) >= timerDurationMs);
|
||||
const showPracticeSummary = timerJustFinished && String(timerState.startedAt) !== readPracticeSummaryDismissedFor();
|
||||
const practiceSummary = showPracticeSummary ? buildPracticeSummary({ currentSession, stats, goals, recommendation, nextMilestone }) : null;
|
||||
const achievements = Array.isArray(stats.achievements) ? stats.achievements : [];
|
||||
const unlockedAchievements = achievements.filter((achievement) => achievement.unlocked);
|
||||
const nextAchievement = achievements.find((achievement) => !achievement.unlocked);
|
||||
@@ -599,6 +676,32 @@ function renderProfile(data) {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
${practiceSummary ? `
|
||||
<section class="profile-card profile-summary-card" style="margin-top:14px">
|
||||
<div class="profile-card-head"><h2>${escapeHtml(t("practiceSummaryTitle"))}</h2></div>
|
||||
<div class="profile-card-body">
|
||||
${practiceSummary.laps ? `
|
||||
<div class="profile-session-heading">${escapeHtml(t("sessionLaps"))}: ${practiceSummary.laps}</div>
|
||||
<p>${escapeHtml(practiceSummary.paceText)}</p>
|
||||
<p>${escapeHtml(practiceSummary.consistencyText)}</p>
|
||||
${practiceSummary.justReachedMilestoneLaps ? `<p class="profile-summary-highlight">${escapeHtml(t("practiceSummaryMilestoneReached", { laps: practiceSummary.justReachedMilestoneLaps }))}</p>` : ""}
|
||||
${practiceSummary.justReachedGoals.map((goal) => `
|
||||
<p class="profile-summary-highlight">${escapeHtml(t("practiceSummaryGoalReached", { title: goal.title || t("reachLaps", { laps: goal.targetLaps }), laps: goal.targetLaps }))}</p>
|
||||
${goal.reward ? `<p class="profile-summary-reward">${escapeHtml(t("practiceSummaryRewardHint", { reward: goal.reward }))}</p>` : ""}
|
||||
`).join("")}
|
||||
<div class="profile-summary-next-goal">
|
||||
<span class="profile-mini-label">${escapeHtml(t("practiceSummaryNextGoalTitle"))}</span>
|
||||
<h3>${escapeHtml(practiceSummary.nextGoalTitle)}</h3>
|
||||
<p class="profile-plan-copy">${escapeHtml(practiceSummary.nextGoalBody)}</p>
|
||||
</div>
|
||||
` : `<p class="profile-empty">${escapeHtml(practiceSummary.paceText)}</p>`}
|
||||
<div class="profile-actions">
|
||||
<button id="profileSummaryClose" class="profile-button" type="button">${escapeHtml(t("practiceSummaryClose"))}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
` : ""}
|
||||
|
||||
<section class="profile-card profile-exercise-card" style="margin-top:14px">
|
||||
<div class="profile-card-head"><div><span class="profile-mini-label">${escapeHtml(t("evenLapsEyebrow"))}</span><h2>${escapeHtml(t("evenLapsTitle"))}</h2></div></div>
|
||||
<div class="profile-card-body">
|
||||
@@ -821,6 +924,28 @@ function getTimerConfigStorageKey() {
|
||||
return `rc-training-timer-config:${shareToken}:${selectedClassId || "default"}`;
|
||||
}
|
||||
|
||||
// Remembers which timer session (identified by its startedAt) the driver has already
|
||||
// dismissed the practice summary for, so it doesn't reappear on every later reload of the
|
||||
// same finished session, but naturally shows again once a new session starts and finishes.
|
||||
function getPracticeSummaryStorageKey() {
|
||||
return `rc-training-practice-summary-dismissed:${shareToken}:${selectedClassId || "default"}`;
|
||||
}
|
||||
|
||||
function readPracticeSummaryDismissedFor() {
|
||||
try {
|
||||
return localStorage.getItem(getPracticeSummaryStorageKey()) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function writePracticeSummaryDismissedFor(startedAtValue) {
|
||||
try {
|
||||
if (startedAtValue) localStorage.setItem(getPracticeSummaryStorageKey(), String(startedAtValue));
|
||||
else localStorage.removeItem(getPracticeSummaryStorageKey());
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// The two guided exercises track progress by laps completed since the driver tapped
|
||||
// "start", not by elapsed time, so unlike the timer above there is no pause/duration to
|
||||
// manage locally - just a start timestamp (or none) per class, mirrored to the server as
|
||||
@@ -897,6 +1022,11 @@ function bindTimerControls() {
|
||||
updateTimerDisplay();
|
||||
void loadProfile();
|
||||
});
|
||||
document.getElementById("profileSummaryClose")?.addEventListener("click", () => {
|
||||
const current = readTimerState();
|
||||
writePracticeSummaryDismissedFor(current?.startedAt || "");
|
||||
renderProfile(profileData);
|
||||
});
|
||||
startTimerLoop();
|
||||
}
|
||||
|
||||
@@ -986,8 +1116,17 @@ function updateTimerDisplay() {
|
||||
if (timerSetupHint) timerSetupHint.textContent = t(state?.startedAt && remaining > 0 ? "timerLockedHint" : "timerSetupHint");
|
||||
if (state?.startedAt && !state.pausedAt && phaseName !== lastTimerPhase) {
|
||||
if (lastTimerPhase === "push" && phaseName === "control" && config.controlMinutes > 0) speak(t("switchCue"));
|
||||
if (phaseName === "finished") speak(t("finishCue"));
|
||||
const justFinished = phaseName === "finished";
|
||||
// Update the reentrancy guard before triggering a render: renderProfile() calls back
|
||||
// into updateTimerDisplay() synchronously (see below), which would otherwise see the
|
||||
// stale lastTimerPhase, take this branch again, and recurse without end.
|
||||
lastTimerPhase = phaseName;
|
||||
if (justFinished) {
|
||||
speak(t("finishCue"));
|
||||
// Show the practice summary the moment the pass actually finishes, rather than
|
||||
// waiting for the next background poll (up to 10s away) to pick it up.
|
||||
if (profileData) renderProfile(profileData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user