Scope driver training-profile stats by track layout too
The user pointed out that track layout changes matter most for Open Practice/training, not races - so the admin Training page and the driver's own mobile profile (server/public_training_profile.js, the actual thing most viewed after practice) needed the same layout scoping already applied to the live "track record" audio cue. Every stat now splits into two groups: lifetime/commitment numbers (total laps, sessions, training days, milestone badges) still read a driver's whole history, since a track rebuild doesn't erase past practice. Pace/record/consistency numbers (personal best, recent-pace trend, consistency spread, the "stable pace" control zone, stint achievements, "best stint") now read only laps tagged with whichever layout is currently active. Each ledger entry is tagged with trackLayoutId at the moment it's synced from a session, so it survives the source session later being deleted. Verified with new unit tests covering both implementations and a real headless-browser run confirming both the admin page and the actual public mobile profile show the current-layout best lap (never a faster but incomparable old-layout one) while keeping the correct full lifetime lap count in both places. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,18 @@
|
||||
// Mirrors getTrackLayoutIdAt in src/track_layouts_logic.js - see the note above
|
||||
// buildPublicEvenLapsProgress for why this file re-implements rather than imports from
|
||||
// the browser-only admin module (this is CommonJS, that module is ESM).
|
||||
function getCurrentTrackLayoutId(state, atMs = Date.now()) {
|
||||
const layouts = Array.isArray(state?.trackLayouts) ? state.trackLayouts : [];
|
||||
let current = "";
|
||||
layouts
|
||||
.slice()
|
||||
.sort((a, b) => Number(a?.startedAt || 0) - Number(b?.startedAt || 0))
|
||||
.forEach((layout) => {
|
||||
if (Number(layout?.startedAt || 0) <= atMs) current = String(layout?.id || "");
|
||||
});
|
||||
return current;
|
||||
}
|
||||
|
||||
function findPublicTrainingShare(state, shareToken) {
|
||||
if (!shareToken || shareToken.length < 24) return null;
|
||||
const shares = Array.isArray(state?.training?.shares) ? state.training.shares : [];
|
||||
@@ -74,6 +89,7 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp
|
||||
|
||||
const classes = Array.isArray(state?.classes) ? state.classes : [];
|
||||
const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : [];
|
||||
const currentLayoutId = getCurrentTrackLayoutId(state);
|
||||
const profileReferences = new Set([driverProfile.id, ...driverProfile.drivers.map((item) => String(item.id || ""))]);
|
||||
const profileClassIds = [...new Set(driverProfile.drivers.map((item) => String(item.classId || "")).filter(Boolean))];
|
||||
const classProfiles = profileClassIds.map((classId) => {
|
||||
@@ -88,9 +104,15 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp
|
||||
sourceId: String(entry.sourceId || entry.driverId || ""),
|
||||
excludedFromPace: Boolean(entry.excludedFromPace),
|
||||
stintBoundary: Boolean(entry.stintBoundary),
|
||||
trackLayoutId: String(entry.trackLayoutId || ""),
|
||||
}))
|
||||
.sort((left, right) => left.timestamp - right.timestamp);
|
||||
const stats = buildPublicTrainingStats(classLaps);
|
||||
// Lifetime totals (laps/sessions/days/milestones) read every lap ever recorded - a
|
||||
// track rebuild doesn't erase past practice. Pace/record/consistency numbers instead
|
||||
// read only laps tagged with today's active layout, so a personal best from a track
|
||||
// that no longer exists can't be shown as current. See getCurrentTrackLayoutId above.
|
||||
const classLayoutLaps = classLaps.filter((lap) => lap.trackLayoutId === currentLayoutId);
|
||||
const stats = buildPublicTrainingStats(classLaps, classLayoutLaps);
|
||||
const classMarkers = (Array.isArray(state?.training?.markers) ? state.training.markers : []).filter((marker) => classDriverIds.has(String(marker?.driverId || "")) && String(marker?.classId || "") === classId);
|
||||
stats.paceBuckets = buildPublicTrainingPaceBuckets(classLaps, classMarkers);
|
||||
const channelStats = buildPublicTimingChannelStats(state, classDriverIds);
|
||||
@@ -533,8 +555,13 @@ function publicTrainingSpread(values) {
|
||||
return mean ? ((publicTrainingPercentile(values, 0.9) - publicTrainingPercentile(values, 0.1)) / mean) * 100 : 0;
|
||||
}
|
||||
|
||||
function buildPublicTrainingStats(laps) {
|
||||
const paceLaps = laps.filter((lap) => !lap.excludedFromPace);
|
||||
// `allLaps` feeds lifetime/commitment stats (total laps, sessions, training days) - a
|
||||
// track rebuild doesn't erase past practice. `layoutLaps` (defaulting to allLaps, so every
|
||||
// other caller of this function stays exactly as before) feeds every pace/record/
|
||||
// consistency stat instead, scoped to whichever track layout is active right now.
|
||||
function buildPublicTrainingStats(allLaps, layoutLaps = allLaps) {
|
||||
const historyPaceLaps = allLaps.filter((lap) => !lap.excludedFromPace);
|
||||
const paceLaps = layoutLaps.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));
|
||||
@@ -546,7 +573,7 @@ function buildPublicTrainingStats(laps) {
|
||||
const previousConsistencySpreadPct = spread(previous);
|
||||
const trendSampleReady = recent.length >= 10 && previous.length >= 10;
|
||||
const daily = new Map();
|
||||
paceLaps.forEach((lap) => {
|
||||
historyPaceLaps.forEach((lap) => {
|
||||
const date = new Date(lap.timestamp);
|
||||
const dateKey = Number.isNaN(date.getTime()) ? "unknown" : `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
const day = daily.get(dateKey) || { dateKey, laps: 0, totalMs: 0, bestLapMs: 0, latestTimestamp: 0 };
|
||||
@@ -568,7 +595,7 @@ function buildPublicTrainingStats(laps) {
|
||||
.slice(0, 30);
|
||||
const bestLapMs = lapTimes.length ? Math.min(...lapTimes) : 0;
|
||||
const controlTargetMs = bestLapMs ? Math.round(bestLapMs * 1.08) : 0;
|
||||
const stints = splitPublicTrainingStints(laps);
|
||||
const stints = splitPublicTrainingStints(layoutLaps);
|
||||
const summarizeStints = (items) => {
|
||||
let controlledLapStreak = 0;
|
||||
let outlierLaps = 0;
|
||||
@@ -598,7 +625,7 @@ function buildPublicTrainingStats(laps) {
|
||||
const stintSummary = summarizeStints(stints);
|
||||
const today = new Date();
|
||||
const todayKey = [today.getFullYear(), String(today.getMonth() + 1).padStart(2, "0"), String(today.getDate()).padStart(2, "0")].join("-");
|
||||
const todayStintSummary = summarizeStints(splitPublicTrainingStints(laps.filter((lap) => {
|
||||
const todayStintSummary = summarizeStints(splitPublicTrainingStints(layoutLaps.filter((lap) => {
|
||||
const date = new Date(lap.timestamp);
|
||||
const dateKey = [date.getFullYear(), String(date.getMonth() + 1).padStart(2, "0"), String(date.getDate()).padStart(2, "0")].join("-");
|
||||
return !Number.isNaN(date.getTime()) && dateKey === todayKey;
|
||||
@@ -618,8 +645,8 @@ function buildPublicTrainingStats(laps) {
|
||||
}).sort((left, right) => right.startedAt - left.startedAt).slice(0, 8);
|
||||
|
||||
const stats = {
|
||||
totalLaps: laps.length,
|
||||
totalSessions: new Set(laps.map((lap) => lap.sessionId).filter(Boolean)).size,
|
||||
totalLaps: allLaps.length,
|
||||
totalSessions: new Set(allLaps.map((lap) => lap.sessionId).filter(Boolean)).size,
|
||||
trainingDays: daily.size,
|
||||
bestLapMs,
|
||||
averageLapMs: average(lapTimes),
|
||||
|
||||
Reference in New Issue
Block a user