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

@@ -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;