From 643902ff15b3adaaee18134b04728423f305c9e6 Mon Sep 17 00:00:00 2001 From: larssand Date: Wed, 9 Sep 2026 15:05:49 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 6 ++ ROADMAP.md | 14 +-- package-lock.json | 4 +- package.json | 2 +- server/public_training_profile.js | 37 ++++--- src/public_training.css | 36 +++++++ src/public_training.js | 141 +++++++++++++++++++++++++- tests/public_training_profile.test.js | 27 ++++- 8 files changed, 242 insertions(+), 25 deletions(-) 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", () => {