diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b788f5..e9ecd69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.5.0 - 2026-09-09 + +### Added + +- A "Pass avslutat" (practice complete) summary now appears on the driver's personal mobile profile the moment their 5-minute practice timer finishes: how their pace and consistency compared to their recent sessions, any goal or lap-count milestone reached during that pass with its reward shown prominently, and one suggested next goal - scoped to their current class, with a plain explanation whenever there isn't enough data yet to judge pace or consistency. + ## 1.4.3 - 2026-09-09 ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index b442810..e480b28 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -79,13 +79,15 @@ Verified 2026-09-09: a browser test (`tests/browser_decoder_reconnect.js`) drops ## 3. Clearer practice follow-up -Acceptance: a driver receives a short, encouraging summary based on their own class-specific data, with one useful next goal. +Acceptance: a driver receives a short, encouraging summary based on their own class-specific data, with one useful next goal. Implemented 2026-09-09. -- [ ] Add an end-of-practice summary: pace, consistency, progress and achieved goals. -- [ ] Suggest one next training goal using existing calculations. -- [ ] Keep summaries separate by class and explain insufficient sample sizes. -- [ ] Include the summary in the personal mobile profile. -- [ ] Add coach/parent reward follow-up without turning practice into a public ranking. +- [x] Add an end-of-practice summary: pace, consistency, progress and achieved goals. Triggered the moment the personal 5-minute timer on the mobile profile finishes ("Passet är klart") - a "Pass avslutat" card appears immediately alongside the existing finish cue. +- [x] Suggest one next training goal using existing calculations. Reuses the existing pace-based recommendation (`buildTrainingRecommendation`/`getPublicTrainingRecommendation`); falls back to the next lap-count milestone when there isn't yet enough data for a pace recommendation. +- [x] Keep summaries separate by class and explain insufficient sample sizes. Scoped to the driver's currently selected class, same as the rest of the page; explicitly explains when there aren't enough laps (this pass, or overall) to judge pace or consistency, rather than showing a misleading number. +- [x] Include the summary in the personal mobile profile. It only exists there - the admin Training page remains the always-current dashboard it already was. +- [x] Add coach/parent reward follow-up without turning practice into a public ranking. Decided 2026-09-09 - rather than a new notification mechanism, when a goal is reached during the pass its existing reward text is surfaced prominently in the summary ("Du nådde X - be din coach/förälder om: [belöning]"), still private to the driver's own share link, never a cross-driver comparison. + +Verified 2026-09-09: unit tests (`tests/public_training_profile.test.js`) cover the new per-session consistency calculation added to `buildPublicTrainingWindowStats` (enough-laps vs. too-few-laps cases, with a hand-verified spread value). A real headless-browser check (`tests/browser_*` scratch pattern, isolated temp SQLite) seeded a driver mid-pass with a goal and a lap-count milestone both crossing exactly during the session, confirmed the summary reports the faster pace, the tight consistency, the goal, its reward text, and the milestone all correctly, confirmed a "Stäng" click dismisses it and the dismissal survives a reload of the same session, and confirmed a later, different session shows its own summary again. Found and fixed a real bug in the process: triggering the summary immediately (rather than waiting for the next background poll) called `renderProfile()` from inside `updateTimerDisplay()`, which itself calls back into `updateTimerDisplay()` - the reentrancy guard was updated *after* that call instead of before, so the stale guard let the finished-phase branch fire again on every reentry, recursing until the browser's call stack overflowed. A second headless check with a near-empty driver history confirmed the "not enough data" messaging renders cleanly instead. ### Guided practice exercises diff --git a/package-lock.json b/package-lock.json index dc66084..76f217c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.4.3", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.4.3", + "version": "1.5.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index 7854bed..eb717c6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.4.3", + "version": "1.5.0", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/server/public_training_profile.js b/server/public_training_profile.js index df540a5..bccd1ea 100644 --- a/server/public_training_profile.js +++ b/server/public_training_profile.js @@ -212,7 +212,11 @@ function buildPublicTrainingWindowStats(laps) { return { laps: laps.length, bestLapMs: lapTimes.length ? Math.min(...lapTimes) : 0, - averageLapMs: lapTimes.length ? Math.round(lapTimes.reduce((sum, lapMs) => sum + lapMs, 0) / lapTimes.length) : 0, + averageLapMs: publicTrainingAverage(lapTimes), + // A spread needs a handful of laps to mean anything - fewer than that, report it as + // unavailable (0 alongside consistencySampleReady:false) rather than a misleading number. + consistencySpreadPct: lapTimes.length >= 5 ? publicTrainingSpread(lapTimes) : 0, + consistencySampleReady: lapTimes.length >= 5, }; } @@ -430,22 +434,29 @@ function buildPublicTrainingPaceBuckets(laps, markers, bucketMinutes = 30) { }); } +function publicTrainingAverage(values) { + return values.length ? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length) : 0; +} + +function publicTrainingPercentile(values, ratio) { + const sorted = [...values].sort((left, right) => left - right); + return sorted.length + ? sorted[Math.min(sorted.length - 1, Math.max(0, Math.round((sorted.length - 1) * ratio)))] + : 0; +} + +function publicTrainingSpread(values) { + const mean = publicTrainingAverage(values); + return mean ? ((publicTrainingPercentile(values, 0.9) - publicTrainingPercentile(values, 0.1)) / mean) * 100 : 0; +} + function buildPublicTrainingStats(laps) { const paceLaps = laps.filter((lap) => !lap.excludedFromPace); const lapTimes = paceLaps.map((lap) => lap.lapMs); const recent = lapTimes.slice(-30); const previous = lapTimes.slice(Math.max(0, lapTimes.length - 60), Math.max(0, lapTimes.length - 30)); - const average = (values) => values.length ? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length) : 0; - const percentile = (values, ratio) => { - const sorted = [...values].sort((left, right) => left - right); - return sorted.length - ? sorted[Math.min(sorted.length - 1, Math.max(0, Math.round((sorted.length - 1) * ratio)))] - : 0; - }; - const spread = (values) => { - const mean = average(values); - return mean ? ((percentile(values, 0.9) - percentile(values, 0.1)) / mean) * 100 : 0; - }; + const average = publicTrainingAverage; + const spread = publicTrainingSpread; const recentAverageMs = average(recent); const previousRecentAverageMs = average(previous); const consistencySpreadPct = spread(recent); @@ -488,7 +499,7 @@ function buildPublicTrainingStats(laps) { currentStreak = controlTargetMs && lapMs <= controlTargetMs ? currentStreak + 1 : 0; controlledLapStreak = Math.max(controlledLapStreak, currentStreak); }); - const medianMs = percentile(values, 0.5); + const medianMs = publicTrainingPercentile(values, 0.5); const stintOutliers = values.filter((lapMs) => medianMs && lapMs > medianMs * 1.15).length; outlierLaps += stintOutliers; const comparisonSize = values.length >= 6 ? Math.max(3, Math.floor(values.length / 3)) : 0; diff --git a/src/public_training.css b/src/public_training.css index 7be22b1..519f8ee 100644 --- a/src/public_training.css +++ b/src/public_training.css @@ -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)); diff --git a/src/public_training.js b/src/public_training.js index 5706aa4..f97e0b6 100644 --- a/src/public_training.js +++ b/src/public_training.js @@ -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) { + ${practiceSummary ? ` +
+

${escapeHtml(t("practiceSummaryTitle"))}

+
+ ${practiceSummary.laps ? ` +
${escapeHtml(t("sessionLaps"))}: ${practiceSummary.laps}
+

${escapeHtml(practiceSummary.paceText)}

+

${escapeHtml(practiceSummary.consistencyText)}

+ ${practiceSummary.justReachedMilestoneLaps ? `

${escapeHtml(t("practiceSummaryMilestoneReached", { laps: practiceSummary.justReachedMilestoneLaps }))}

` : ""} + ${practiceSummary.justReachedGoals.map((goal) => ` +

${escapeHtml(t("practiceSummaryGoalReached", { title: goal.title || t("reachLaps", { laps: goal.targetLaps }), laps: goal.targetLaps }))}

+ ${goal.reward ? `

${escapeHtml(t("practiceSummaryRewardHint", { reward: goal.reward }))}

` : ""} + `).join("")} +
+ ${escapeHtml(t("practiceSummaryNextGoalTitle"))} +

${escapeHtml(practiceSummary.nextGoalTitle)}

+

${escapeHtml(practiceSummary.nextGoalBody)}

+
+ ` : `

${escapeHtml(practiceSummary.paceText)}

`} +
+ +
+
+
+ ` : ""} +
${escapeHtml(t("evenLapsEyebrow"))}

${escapeHtml(t("evenLapsTitle"))}

@@ -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); + } } } diff --git a/tests/public_training_profile.test.js b/tests/public_training_profile.test.js index 210adf0..f5f68f3 100644 --- a/tests/public_training_profile.test.js +++ b/tests/public_training_profile.test.js @@ -165,7 +165,7 @@ test("public profile contains only the selected driver's sanitized aggregates", assert.equal(profile.branding.logoDataUrl, ""); assert.equal(profile.stats.totalLaps, 4); assert.equal(profile.stats.bestLapMs, 16000); - assert.deepEqual(profile.currentSession, { laps: 2, bestLapMs: 16000, averageLapMs: 16250 }); + assert.deepEqual(profile.currentSession, { laps: 2, bestLapMs: 16000, averageLapMs: 16250, consistencySpreadPct: 0, consistencySampleReady: false }); assert.equal(profile.goals.length, 1); assert.equal(profile.goals[0].title, "Bronze"); assert.equal(profile.goals[0].percent, 80); @@ -236,7 +236,30 @@ test("public timer window freezes aggregates after the personal session", () => const now = Date.now(); const profile = buildPublicTrainingProfile(buildState(now), DRIVER_ONE_TOKEN, "", now - 30 * 60000, 20 * 60000); - assert.deepEqual(profile.currentSession, { laps: 1, bestLapMs: 19000, averageLapMs: 19000 }); + assert.deepEqual(profile.currentSession, { laps: 1, bestLapMs: 19000, averageLapMs: 19000, consistencySpreadPct: 0, consistencySampleReady: false }); +}); + +test("public timer window reports consistency once it has enough laps, and withholds it otherwise", () => { + const now = Date.now(); + const state = buildState(now); + const sessionStart = now - 5 * 60000; + state.training.ledger = [ + { driverId: "driver_1", sessionId: "free", timestamp: sessionStart + 10000, lapMs: 16000 }, + { driverId: "driver_1", sessionId: "free", timestamp: sessionStart + 20000, lapMs: 16100 }, + { driverId: "driver_1", sessionId: "free", timestamp: sessionStart + 30000, lapMs: 16200 }, + { driverId: "driver_1", sessionId: "free", timestamp: sessionStart + 40000, lapMs: 16300 }, + { driverId: "driver_1", sessionId: "free", timestamp: sessionStart + 50000, lapMs: 16400 }, + ]; + const profile = buildPublicTrainingProfile(state, DRIVER_ONE_TOKEN, "", sessionStart, 5 * 60000); + assert.equal(profile.currentSession.laps, 5); + assert.equal(profile.currentSession.consistencySampleReady, true); + // mean 16200, p10 (index 0) 16000, p90 (index 4) 16400: (16400-16000)/16200*100 + assert.ok(Math.abs(profile.currentSession.consistencySpreadPct - ((16400 - 16000) / 16200) * 100) < 0.001); + + const shortProfile = buildPublicTrainingProfile(state, DRIVER_ONE_TOKEN, "", sessionStart + 35000, 5 * 60000); + assert.equal(shortProfile.currentSession.laps, 2, "only the last two laps fall inside this shorter window"); + assert.equal(shortProfile.currentSession.consistencySampleReady, false, "fewer than 5 laps must not report a spread"); + assert.equal(shortProfile.currentSession.consistencySpreadPct, 0); }); test("public profile returns null when token or driver is unavailable", () => {