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:
larssand
2026-09-09 15:05:49 +02:00
parent 3373f8dddd
commit 643902ff15
8 changed files with 242 additions and 25 deletions

View File

@@ -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", () => {