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 ? `
+
+