Files
Live_RC/server/public_training_profile.js
larssand a7272d3844 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>
2026-09-10 14:37:32 +02:00

845 lines
41 KiB
JavaScript

// 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 : [];
return shares.find((share) => share?.enabled !== false && String(share?.token || "") === shareToken) || null;
}
function hasPublicTrainingShare(state, shareToken) {
return Boolean(findPublicTrainingShare(state, shareToken));
}
function getPublicDriverProfileId(driver) {
const explicitId = String(driver?.profileId || "").trim();
if (explicitId) return explicitId;
const name = String(driver?.name || "").trim().toLocaleLowerCase().replace(/\s+/g, " ");
return name ? `legacy:${name}` : `driver:${String(driver?.id || "unknown")}`;
}
function getPublicDriverProfile(drivers, reference) {
const normalizedReference = String(reference || "");
const anchor = drivers.find((driver) => (
String(driver?.id || "") === normalizedReference
|| getPublicDriverProfileId(driver) === normalizedReference
));
if (!anchor) return null;
const profileId = getPublicDriverProfileId(anchor);
return {
id: profileId,
drivers: drivers.filter((driver) => getPublicDriverProfileId(driver) === profileId),
};
}
const PUBLIC_TRAINING_SECTION_KEYS = ["progress", "achievements", "performance", "goals", "history"];
function normalizePublicTrainingVisibility(value) {
const source = value && typeof value === "object" ? value : {};
return Object.fromEntries(PUBLIC_TRAINING_SECTION_KEYS.map((key) => [key, source[key] !== false]));
}
function filterPublicTrainingStats(stats, visibility) {
const filtered = {
totalLaps: stats.totalLaps, totalSessions: stats.totalSessions, trainingDays: stats.trainingDays,
bestLapMs: stats.bestLapMs, averageLapMs: stats.averageLapMs, recentLapCount: stats.recentLapCount,
consistencySpreadPct: stats.consistencySpreadPct,
};
if (visibility.progress) Object.assign(filtered, {
recentAverageMs: stats.recentAverageMs, previousRecentAverageMs: stats.previousRecentAverageMs,
averageTrendMs: stats.averageTrendMs, previousConsistencySpreadPct: stats.previousConsistencySpreadPct,
consistencyTrendPct: stats.consistencyTrendPct, trendSampleReady: stats.trendSampleReady,
controlledLapStreak: stats.controlledLapStreak, todayControlledLapStreak: stats.todayControlledLapStreak,
todayStrongFinishStints: stats.todayStrongFinishStints, todayCleanFiveMinuteStints: stats.todayCleanFiveMinuteStints,
todayCompletedFiveMinuteStints: stats.todayCompletedFiveMinuteStints, paceHistory: stats.dailySummaries.slice(0, 12),
});
if (visibility.achievements) filtered.achievements = stats.achievements;
if (visibility.performance) Object.assign(filtered, {
controlZoneMinMs: stats.controlZoneMinMs, controlZoneMaxMs: stats.controlZoneMaxMs,
outlierLaps: stats.outlierLaps, strongFinishStints: stats.strongFinishStints,
cleanFiveMinuteStints: stats.cleanFiveMinuteStints, stintCount: stats.stintCount,
stintSummaries: stats.stintSummaries, bestThreeConsecutive: stats.bestThreeConsecutive,
bestFiveMinuteResult: stats.bestFiveMinuteResult, paceBuckets: stats.paceBuckets,
});
if (visibility.history) filtered.dailySummaries = stats.dailySummaries;
return filtered;
}
function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp = 0, windowMs = 0, evenLapsSince = 0, stepTrainingSince = 0) {
const share = findPublicTrainingShare(state, shareToken);
if (!share) return null;
const visibility = normalizePublicTrainingVisibility(share.visibility);
const profileReference = String(share.driverId || "");
const drivers = Array.isArray(state?.drivers) ? state.drivers : [];
const driverProfile = getPublicDriverProfile(drivers, profileReference);
if (!driverProfile?.drivers.length) return null;
const driver = driverProfile.drivers[0];
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) => {
const classDrivers = driverProfile.drivers.filter((item) => String(item.classId || "") === classId);
const classDriverIds = new Set(classDrivers.map((item) => String(item.id || "")));
const classLaps = ledger
.filter((entry) => classDriverIds.has(String(entry?.driverId || "")) && Number(entry?.lapMs || 0) > 500)
.map((entry) => ({
timestamp: Math.max(0, Number(entry.timestamp || 0) || 0),
lapMs: Math.max(0, Math.round(Number(entry.lapMs || 0) || 0)),
sessionId: String(entry.sessionId || ""),
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);
// 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);
const classExerciseSessions = (Array.isArray(state?.training?.exerciseSessions) ? state.training.exerciseSessions : [])
.filter((entry) => classDriverIds.has(String(entry?.driverId || "")) && String(entry?.classId || "") === classId)
.sort((left, right) => Number(right?.startedAt || 0) - Number(left?.startedAt || 0));
const classGoals = (Array.isArray(state?.training?.goals) ? state.training.goals : [])
.filter((goal) => profileReferences.has(String(goal?.driverId || "")) && (!goal?.classId || String(goal.classId) === classId))
.map((goal) => buildPublicTrainingGoal(
goal,
goal?.metric === "sessions" ? classExerciseSessions.filter((entry) => entry.kind === goal.exerciseKind).length : stats.totalLaps
));
const brands = [...new Set(classDrivers.map((item) => String(item.brand || "").trim()).filter(Boolean))];
const evenLapsConfig = sanitizePublicEvenLapsConfig(
(Array.isArray(state?.training?.evenLaps) ? state.training.evenLaps : []).find((entry) => classDriverIds.has(String(entry?.driverId || "")) && String(entry?.classId || "") === classId)
);
const stepTrainingConfig = sanitizePublicStepTrainingConfig(
(Array.isArray(state?.training?.stepTraining) ? state.training.stepTraining : []).find((entry) => classDriverIds.has(String(entry?.driverId || "")) && String(entry?.classId || "") === classId)
);
const todayLaps = classLaps.filter((lap) => isPublicTrainingToday(lap.timestamp));
return {
classId,
className: String(classes.find((item) => String(item?.id || "") === classId)?.name || "").trim(),
brand: brands.join(" / "),
stats: filterPublicTrainingStats(stats, visibility),
channelStats: visibility.performance ? channelStats : {},
nextMilestone: getPublicTrainingMilestone(stats.totalLaps),
recommendation: getPublicTrainingRecommendation(stats),
goals: visibility.goals ? classGoals : [],
currentSession: sinceTimestamp
? buildPublicTrainingWindowStats(classLaps.filter((lap) => lap.timestamp >= sinceTimestamp && (!windowMs || lap.timestamp <= sinceTimestamp + windowMs)))
: null,
evenLapsConfig,
evenLapsProgress: buildPublicEvenLapsProgress(classLaps, todayLaps, evenLapsConfig, evenLapsSince),
stepTrainingConfig,
stepTrainingProgress: buildPublicStepTrainingProgress(classLaps, stepTrainingConfig, stepTrainingSince),
exerciseSessionHistory: visibility.history
? classExerciseSessions.slice(0, 20).map((entry) => ({ kind: entry.kind, completedAt: entry.completedAt, summary: entry.summary }))
: [],
};
});
const activeClass = classProfiles[0] || {
classId: "", className: "", brand: "",
stats: filterPublicTrainingStats(buildPublicTrainingStats([]), visibility), nextMilestone: getPublicTrainingMilestone(0),
channelStats: visibility.performance ? buildPublicTimingChannelStats({}, new Set()) : {},
recommendation: getPublicTrainingRecommendation({ totalLaps: 0 }), goals: [], currentSession: null, exerciseSessionHistory: [],
};
const settings = state?.settings && typeof state.settings === "object" ? state.settings : {};
return {
updatedAt: String(updatedAt || ""),
visibility,
profile: {
name: String(driver.name || "Driver").trim(),
brand: activeClass.brand,
className: activeClass.className,
},
branding: {
clubName: String(settings.clubName || "JMK RB").trim(),
clubTagline: String(settings.clubTagline || "Live Timing & Race Control").trim(),
logoDataUrl: normalizePublicLogoDataUrl(settings.logoDataUrl),
},
classProfiles,
stats: activeClass.stats,
channelStats: activeClass.channelStats,
nextMilestone: activeClass.nextMilestone,
recommendation: activeClass.recommendation,
goals: activeClass.goals,
currentSession: activeClass.currentSession,
exerciseSessionHistory: activeClass.exerciseSessionHistory,
};
}
function buildPublicTimingChannelStats(state, classDriverIds) {
const splits = [];
const pitTimes = [];
Object.values(state?.resultsBySession || {}).forEach((result) => {
(Array.isArray(result?.splits) ? result.splits : []).forEach((split) => {
const splitMs = Math.round(Number(split?.splitMs || 0) || 0);
if (classDriverIds.has(String(split?.driverId || "")) && splitMs > 0) splits.push({ splitMs, timestamp: Number(split?.timestamp || 0) });
});
(Array.isArray(result?.pitStops) ? result.pitStops : []).forEach((stop) => {
const pitStopMs = Math.round(Number(stop?.pitStopMs || 0) || 0);
if (classDriverIds.has(String(stop?.driverId || "")) && pitStopMs > 0) pitTimes.push(pitStopMs);
});
});
const average = (values) => values.length
? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length)
: 0;
splits.sort((left, right) => left.timestamp - right.timestamp);
const splitTimes = splits.map((split) => split.splitMs);
let runningBestSplitMs = 0;
let latestSplitGainMs = 0;
splits.forEach((split) => {
if (runningBestSplitMs && split.splitMs < runningBestSplitMs) latestSplitGainMs = runningBestSplitMs - split.splitMs;
if (!runningBestSplitMs || split.splitMs < runningBestSplitMs) runningBestSplitMs = split.splitMs;
});
return {
splitCount: splitTimes.length,
bestSplitMs: splitTimes.length ? Math.min(...splitTimes) : 0,
averageSplitMs: average(splitTimes),
latestSplitGainMs,
pitStopCount: pitTimes.length,
totalPitMs: pitTimes.reduce((sum, value) => sum + value, 0),
bestPitStopMs: pitTimes.length ? Math.min(...pitTimes) : 0,
averagePitStopMs: average(pitTimes),
};
}
function normalizePublicTrainingSince(value, startedAgoValue) {
const now = Date.now();
if (startedAgoValue !== undefined) {
const startedAgoMs = Math.round(Number(startedAgoValue));
if (Number.isFinite(startedAgoMs) && startedAgoMs >= 0 && startedAgoMs <= 24 * 60 * 60 * 1000) {
return now - startedAgoMs;
}
}
const timestamp = Math.round(Number(value || 0) || 0);
return timestamp >= now - 24 * 60 * 60 * 1000 && timestamp <= now + 60 * 1000 ? timestamp : 0;
}
function normalizePublicTrainingWindow(value) {
const windowMs = Math.round(Number(value || 0) || 0);
return Math.min(24 * 60 * 60 * 1000, Math.max(0, windowMs));
}
function buildPublicTrainingWindowStats(laps) {
const lapTimes = laps.filter((lap) => !lap.excludedFromPace).map((lap) => Number(lap.lapMs || 0)).filter((lapMs) => lapMs > 500);
return {
laps: laps.length,
bestLapMs: lapTimes.length ? Math.min(...lapTimes) : 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,
};
}
function isPublicTrainingToday(timestamp) {
const date = new Date(Number(timestamp || 0));
if (Number.isNaN(date.getTime())) return false;
const now = new Date();
return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate();
}
function sanitizePublicEvenLapsConfig(entry) {
if (!entry) return null;
return {
targetMs: Math.max(0, Math.round(Number(entry.targetMs || 0))),
toleranceMs: Math.max(0, Math.round(Number(entry.toleranceMs || 0))),
lapCount: Math.max(1, Math.round(Number(entry.lapCount || 5))),
};
}
function sanitizePublicStepTrainingConfig(entry) {
if (!entry || !Array.isArray(entry.steps) || !entry.steps.length) return null;
return {
steps: entry.steps.map((step) => ({
effort: ["calm", "controlled", "fast"].includes(String(step?.effort || "")) ? String(step.effort) : "calm",
lapCount: Math.max(0, Math.round(Number(step?.lapCount || 0) || 0)),
})).filter((step) => step.lapCount > 0),
};
}
// Mirrors buildEvenLapsProgress in src/training_logic.js (the admin implementation), but
// takes an already-sanitized, class-scoped lap array instead of doing its own state/session
// lookups: server/public_training_profile.js never sends individual lap rows to the client,
// only the computed progress, so this must stay a server-side re-implementation rather than
// a shared import from the browser-only admin module.
function buildPublicEvenLapsProgress(classLaps, todayLaps, config, sinceTimestamp) {
if (!config || !(config.targetMs > 0)) return null;
const withinTolerance = (lapMs) => Math.abs(lapMs - config.targetMs) <= config.toleranceMs;
const longestStreak = (laps) => {
let longest = 0;
let current = 0;
laps.forEach((lap) => {
if (!lap.excludedFromPace && withinTolerance(lap.lapMs)) {
current += 1;
longest = Math.max(longest, current);
} else {
current = 0;
}
});
return longest;
};
const liveLaps = sinceTimestamp ? classLaps.filter((lap) => lap.timestamp >= sinceTimestamp) : [];
let currentStreak = 0;
for (let index = liveLaps.length - 1; index >= 0; index -= 1) {
if (!liveLaps[index].excludedFromPace && withinTolerance(liveLaps[index].lapMs)) currentStreak += 1;
else break;
}
const bestStreakToday = Math.max(longestStreak(todayLaps), currentStreak);
const lastLap = liveLaps.at(-1) || null;
return {
active: Boolean(sinceTimestamp),
targetMs: config.targetMs,
toleranceMs: config.toleranceMs,
lapCount: config.lapCount,
currentStreak,
bestStreakToday,
lastLapMs: lastLap ? lastLap.lapMs : null,
lastLapWithinTolerance: lastLap ? (!lastLap.excludedFromPace && withinTolerance(lastLap.lapMs)) : null,
};
}
// Mirrors buildStepTrainingProgress in src/training_logic.js - see the note above
// buildPublicEvenLapsProgress for why this is a separate server-side implementation.
function buildPublicStepTrainingProgress(classLaps, config, sinceTimestamp) {
const steps = config?.steps || [];
if (!steps.length) return null;
const liveLaps = sinceTimestamp ? classLaps.filter((lap) => lap.timestamp >= sinceTimestamp) : [];
const totalLaps = steps.reduce((sum, step) => sum + step.lapCount, 0);
let cursor = 0;
const stepStates = steps.map((step, index) => {
const stepLaps = liveLaps.slice(cursor, cursor + step.lapCount);
cursor += step.lapCount;
const paceValues = stepLaps.filter((lap) => !lap.excludedFromPace).map((lap) => lap.lapMs);
return {
index,
effort: step.effort,
lapCount: step.lapCount,
lapsCompleted: stepLaps.length,
done: stepLaps.length >= step.lapCount,
averageLapMs: paceValues.length ? Math.round(paceValues.reduce((sum, value) => sum + value, 0) / paceValues.length) : null,
};
});
const complete = liveLaps.length >= totalLaps;
const currentStepIndex = complete ? steps.length - 1 : stepStates.findIndex((step) => !step.done);
const currentStepState = stepStates[currentStepIndex] || null;
const controlledSteps = stepStates.filter((step) => step.effort === "controlled" && step.averageLapMs != null);
const controlledComparison = controlledSteps.length >= 2
? {
firstAvgMs: controlledSteps[0].averageLapMs,
lastAvgMs: controlledSteps.at(-1).averageLapMs,
diffMs: controlledSteps.at(-1).averageLapMs - controlledSteps[0].averageLapMs,
}
: null;
return {
active: Boolean(sinceTimestamp),
totalSteps: steps.length,
totalLaps,
completedLaps: Math.min(liveLaps.length, totalLaps),
complete,
currentStepIndex,
currentStep: currentStepState && {
index: currentStepState.index,
effort: currentStepState.effort,
lapCount: currentStepState.lapCount,
lapsCompleted: currentStepState.lapsCompleted,
lapsRemaining: Math.max(0, currentStepState.lapCount - currentStepState.lapsCompleted),
},
steps: stepStates,
controlledComparison,
};
}
function resolvePublicClassDriver(state, shareToken, classId) {
const share = findPublicTrainingShare(state, shareToken);
if (!share) return null;
const drivers = Array.isArray(state?.drivers) ? state.drivers : [];
const profile = getPublicDriverProfile(drivers, share.driverId);
return profile?.drivers.find((driver) => String(driver?.classId || "") === String(classId || "")) || null;
}
function savePublicEvenLapsConfig(state, shareToken, input) {
const classDriver = resolvePublicClassDriver(state, shareToken, input?.classId);
if (!classDriver) return null;
const classId = String(input.classId);
const targetMs = Math.max(0, Math.round(Number(input?.targetMs || 0)));
const toleranceMs = Math.max(0, Math.round(Number(input?.toleranceMs || 0)));
const lapCount = Math.max(1, Math.round(Number(input?.lapCount || 0) || 5));
if (!(targetMs > 0)) return null;
if (!state.training || typeof state.training !== "object") state.training = {};
state.training.evenLaps = Array.isArray(state.training.evenLaps) ? state.training.evenLaps : [];
const existing = state.training.evenLaps.find((entry) => String(entry?.driverId || "") === String(classDriver.id) && String(entry?.classId || "") === classId);
const updatedAt = new Date().toISOString();
if (existing) {
existing.targetMs = targetMs;
existing.toleranceMs = toleranceMs;
existing.lapCount = lapCount;
existing.updatedAt = updatedAt;
return existing;
}
const entry = { id: "even_laps_public_" + Date.now() + "_" + Math.random().toString(36).slice(2, 9), driverId: String(classDriver.id), classId, targetMs, toleranceMs, lapCount, updatedAt };
state.training.evenLaps.push(entry);
return entry;
}
function savePublicStepTrainingConfig(state, shareToken, input) {
const classDriver = resolvePublicClassDriver(state, shareToken, input?.classId);
if (!classDriver) return null;
const classId = String(input.classId);
const steps = Array.isArray(input?.steps)
? input.steps.map((step) => ({
effort: ["calm", "controlled", "fast"].includes(String(step?.effort || "")) ? String(step.effort) : "calm",
lapCount: Math.max(0, Math.round(Number(step?.lapCount || 0) || 0)),
})).filter((step) => step.lapCount > 0)
: [];
if (!steps.length) return null;
if (!state.training || typeof state.training !== "object") state.training = {};
state.training.stepTraining = Array.isArray(state.training.stepTraining) ? state.training.stepTraining : [];
const existing = state.training.stepTraining.find((entry) => String(entry?.driverId || "") === String(classDriver.id) && String(entry?.classId || "") === classId);
const updatedAt = new Date().toISOString();
if (existing) {
existing.steps = steps;
existing.updatedAt = updatedAt;
return existing;
}
const entry = { id: "step_training_public_" + Date.now() + "_" + Math.random().toString(36).slice(2, 9), driverId: String(classDriver.id), classId, steps, updatedAt };
state.training.stepTraining.push(entry);
return entry;
}
function buildPublicTrainingGoal(goal, currentValue) {
const isSessions = goal?.metric === "sessions";
const target = Math.max(1, Math.round(Number((isSessions ? goal?.targetSessions : goal?.targetLaps) || 1) || 1));
return {
title: String(goal?.title || "").trim(),
reward: String(goal?.reward || "").trim(),
metric: isSessions ? "sessions" : "laps",
exerciseKind: isSessions ? String(goal?.exerciseKind || "") : "",
targetLaps: target,
currentLaps: currentValue,
remainingLaps: Math.max(0, target - currentValue),
percent: Math.min(100, Math.max(0, (currentValue / target) * 100)),
reached: currentValue >= target,
claimed: Boolean(goal?.claimedAt),
};
}
// The driver's own browser is the one deciding *when* to call this (the moment it sees its own
// live progress cross the target), but never what the outcome was: every field of `summary`
// below is recomputed here from the real ledger laps using the same builders the live progress
// endpoints use, not trusted from the client - a driver can't fabricate a completed session (and
// the reward that might be tied to one) any more than they could fabricate a lap time today.
function recordPublicExerciseSession(state, shareToken, input, now = Date.now()) {
const classDriver = resolvePublicClassDriver(state, shareToken, input?.classId);
if (!classDriver) return null;
const classId = String(input.classId);
const kind = ["timer", "evenLaps", "stepTraining"].includes(String(input?.kind || "")) ? String(input.kind) : "";
const startedAgoMs = Math.max(0, Math.round(Number(input?.startedAgoMs || 0)));
if (!kind || !startedAgoMs) return null;
const startedAt = now - startedAgoMs;
const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : [];
const classLaps = ledger
.filter((entry) => String(entry?.driverId || "") === classDriver.id && Number(entry?.lapMs || 0) > 500)
.map((entry) => ({
timestamp: Math.max(0, Number(entry.timestamp || 0) || 0),
lapMs: Math.max(0, Math.round(Number(entry.lapMs || 0) || 0)),
excludedFromPace: Boolean(entry.excludedFromPace),
}))
.sort((left, right) => left.timestamp - right.timestamp);
let summary = null;
if (kind === "timer") {
const windowMs = Math.max(0, Math.round(Number(input?.windowMs || 0)));
if (!windowMs || startedAgoMs < windowMs) return null;
const windowStats = buildPublicTrainingWindowStats(classLaps.filter((lap) => lap.timestamp >= startedAt && lap.timestamp <= startedAt + windowMs));
if (!windowStats.laps) return null;
summary = { laps: windowStats.laps, bestLapMs: windowStats.bestLapMs, averageLapMs: windowStats.averageLapMs };
} else if (kind === "evenLaps") {
const config = sanitizePublicEvenLapsConfig(
(Array.isArray(state?.training?.evenLaps) ? state.training.evenLaps : []).find((entry) => String(entry?.driverId || "") === classDriver.id && String(entry?.classId || "") === classId)
);
if (!config) return null;
const progress = buildPublicEvenLapsProgress(classLaps, [], config, startedAt);
if (!progress || progress.currentStreak < config.lapCount) return null;
summary = { targetMs: config.targetMs, toleranceMs: config.toleranceMs, lapCount: config.lapCount, streak: progress.currentStreak };
} else if (kind === "stepTraining") {
const config = sanitizePublicStepTrainingConfig(
(Array.isArray(state?.training?.stepTraining) ? state.training.stepTraining : []).find((entry) => String(entry?.driverId || "") === classDriver.id && String(entry?.classId || "") === classId)
);
if (!config) return null;
const progress = buildPublicStepTrainingProgress(classLaps, config, startedAt);
if (!progress || !progress.complete) return null;
summary = { totalSteps: progress.totalSteps, totalLaps: progress.totalLaps, controlledComparison: progress.controlledComparison };
}
if (!summary) return null;
if (!state.training || typeof state.training !== "object") state.training = {};
state.training.exerciseSessions = Array.isArray(state.training.exerciseSessions) ? state.training.exerciseSessions : [];
const existing = state.training.exerciseSessions.find((entry) =>
entry.driverId === classDriver.id && entry.classId === classId && entry.kind === kind && Math.abs(Number(entry.startedAt || 0) - startedAt) < 5000
);
if (existing) return existing;
const record = {
id: "exercise_session_public_" + Date.now() + "_" + Math.random().toString(36).slice(2, 9),
driverId: String(classDriver.id),
classId,
kind,
startedAt,
completedAt: new Date(now).toISOString(),
summary,
};
state.training.exerciseSessions.push(record);
return record;
}
function buildPublicTrainingPaceBuckets(laps, markers, bucketMinutes = 30) {
const bucketMs = Math.max(5, Number(bucketMinutes || 30)) * 60 * 1000;
const buckets = new Map();
laps.filter((lap) => !lap.excludedFromPace).forEach((lap) => {
const date = new Date(lap.timestamp);
const minuteBlock = bucketMs / 60000;
const startTs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), Math.floor(date.getMinutes() / minuteBlock) * minuteBlock, 0, 0).getTime();
const bucket = buckets.get(startTs) || { startTs, values: [] };
bucket.values.push(lap.lapMs);
buckets.set(startTs, bucket);
});
return [...buckets.values()].sort((left, right) => right.startTs - left.startTs).slice(0, 24).map((bucket) => {
const sorted = [...bucket.values].sort((left, right) => left - right);
const trim = sorted.length >= 5 ? Math.max(1, Math.floor(sorted.length * 0.1)) : 0;
const kept = trim ? sorted.slice(trim, sorted.length - trim) : sorted;
return {
startTs: bucket.startTs,
lapCount: bucket.values.length,
paceMs: kept.length ? Math.round(kept.reduce((sum, value) => sum + value, 0) / kept.length) : 0,
bestLapMs: bucket.values.length ? Math.min(...bucket.values) : 0,
markerCount: markers.filter((marker) => Number(marker?.timestamp || 0) >= bucket.startTs && Number(marker?.timestamp || 0) < bucket.startTs + bucketMs).length,
};
});
}
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;
}
// `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));
const average = publicTrainingAverage;
const spread = publicTrainingSpread;
const recentAverageMs = average(recent);
const previousRecentAverageMs = average(previous);
const consistencySpreadPct = spread(recent);
const previousConsistencySpreadPct = spread(previous);
const trendSampleReady = recent.length >= 10 && previous.length >= 10;
const daily = new Map();
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 };
day.laps += 1;
day.totalMs += lap.lapMs;
day.bestLapMs = day.bestLapMs ? Math.min(day.bestLapMs, lap.lapMs) : lap.lapMs;
day.latestTimestamp = Math.max(day.latestTimestamp, lap.timestamp);
daily.set(dateKey, day);
});
const dailySummaries = [...daily.values()]
.map((day) => ({
dateKey: day.dateKey,
laps: day.laps,
bestLapMs: day.bestLapMs,
latestTimestamp: day.latestTimestamp,
averageLapMs: day.laps ? Math.round(day.totalMs / day.laps) : 0,
}))
.sort((left, right) => right.latestTimestamp - left.latestTimestamp)
.slice(0, 30);
const bestLapMs = lapTimes.length ? Math.min(...lapTimes) : 0;
const controlTargetMs = bestLapMs ? Math.round(bestLapMs * 1.08) : 0;
const stints = splitPublicTrainingStints(layoutLaps);
const summarizeStints = (items) => {
let controlledLapStreak = 0;
let outlierLaps = 0;
let strongFinishStints = 0;
let cleanFiveMinuteStints = 0;
let completedFiveMinuteStints = 0;
items.forEach((stint) => {
let currentStreak = 0;
const values = stint.laps.filter((lap) => !lap.excludedFromPace).map((lap) => lap.lapMs);
values.forEach((lapMs) => {
currentStreak = controlTargetMs && lapMs <= controlTargetMs ? currentStreak + 1 : 0;
controlledLapStreak = Math.max(controlledLapStreak, currentStreak);
});
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;
const startPaceMs = comparisonSize ? average(values.slice(0, comparisonSize)) : 0;
const endPaceMs = comparisonSize ? average(values.slice(-comparisonSize)) : 0;
if (comparisonSize && endPaceMs <= startPaceMs * 1.01) strongFinishStints += 1;
const fiveMinuteResult = getPublicBestDurationResult([stint], 5 * 60 * 1000);
if (fiveMinuteResult) completedFiveMinuteStints += 1;
if (!stintOutliers && fiveMinuteResult) cleanFiveMinuteStints += 1;
});
return { controlledLapStreak, outlierLaps, strongFinishStints, cleanFiveMinuteStints, completedFiveMinuteStints };
};
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(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;
})));
const bestThreeConsecutive = getPublicBestConsecutiveLaps(stints, 3);
const bestFiveMinuteResult = getPublicBestDurationResult(stints, 5 * 60 * 1000);
const stintSummaries = stints.map((stint, index) => {
const paceLaps = stint.laps.filter((lap) => !lap.excludedFromPace);
const values = paceLaps.map((lap) => lap.lapMs);
const startedAt = Math.max(0, Number(stint.laps[0]?.timestamp || 0) - Number(stint.laps[0]?.lapMs || 0));
const endedAt = Number(stint.laps.at(-1)?.timestamp || startedAt);
return {
number: index + 1, startedAt, lapCount: stint.laps.length,
bestLapMs: values.length ? Math.min(...values) : 0, averageLapMs: average(values),
durationMs: Math.max(0, endedAt - startedAt),
};
}).sort((left, right) => right.startedAt - left.startedAt).slice(0, 8);
const stats = {
totalLaps: allLaps.length,
totalSessions: new Set(allLaps.map((lap) => lap.sessionId).filter(Boolean)).size,
trainingDays: daily.size,
bestLapMs,
averageLapMs: average(lapTimes),
recentAverageMs,
previousRecentAverageMs,
averageTrendMs: trendSampleReady ? previousRecentAverageMs - recentAverageMs : 0,
recentLapCount: recent.length,
consistencySpreadPct,
previousConsistencySpreadPct,
consistencyTrendPct: trendSampleReady ? previousConsistencySpreadPct - consistencySpreadPct : 0,
trendSampleReady,
controlZoneMinMs: bestLapMs,
controlZoneMaxMs: controlTargetMs,
controlledLapStreak: stintSummary.controlledLapStreak,
outlierLaps: stintSummary.outlierLaps,
strongFinishStints: stintSummary.strongFinishStints,
cleanFiveMinuteStints: stintSummary.cleanFiveMinuteStints,
todayControlledLapStreak: todayStintSummary.controlledLapStreak,
todayStrongFinishStints: todayStintSummary.strongFinishStints,
todayCleanFiveMinuteStints: todayStintSummary.cleanFiveMinuteStints,
todayCompletedFiveMinuteStints: todayStintSummary.completedFiveMinuteStints,
dailySummaries,
stintCount: stints.length,
stintSummaries,
bestThreeConsecutive,
bestFiveMinuteResult,
};
return { ...stats, achievements: buildPublicTrainingAchievements(stats) };
}
function buildPublicTrainingAchievements(stats) {
const definitions = [
["first_session", Number(stats.totalSessions || 0), 1],
["laps_100", Number(stats.totalLaps || 0), 100],
["laps_500", Number(stats.totalLaps || 0), 500],
["laps_1000", Number(stats.totalLaps || 0), 1000],
["laps_1500", Number(stats.totalLaps || 0), 1500],
["days_3", Number(stats.trainingDays || 0), 3],
["days_10", Number(stats.trainingDays || 0), 10],
["controlled_5", Number(stats.controlledLapStreak || 0), 5],
["strong_finish", Number(stats.strongFinishStints || 0), 1],
["clean_five_minute", Number(stats.cleanFiveMinuteStints || 0), 1],
["five_minute", stats.bestFiveMinuteResult ? 1 : 0, 1],
];
return definitions.map(([id, current, target]) => ({
id,
percent: Math.min(100, Math.max(0, (current / target) * 100)),
unlocked: current >= target,
}));
}
function splitPublicTrainingStints(laps, breakThresholdMs = 2 * 60 * 1000) {
const streams = new Map();
(Array.isArray(laps) ? laps : []).forEach((lap) => {
const normalizedLap = {
...lap,
sessionId: String(lap.sessionId || ""),
sourceId: String(lap.sourceId || lap.driverId || "default"),
timestamp: Number(lap.timestamp || 0),
lapMs: Number(lap.lapMs || 0),
};
const key = `${normalizedLap.sessionId}:${normalizedLap.sourceId}`;
if (!streams.has(key)) streams.set(key, []);
streams.get(key).push(normalizedLap);
});
const stints = [];
streams.forEach((stream) => {
let current = null;
stream.sort((left, right) => left.timestamp - right.timestamp).forEach((lap) => {
const previousLap = current?.laps.at(-1);
const breakMs = previousLap ? Math.max(0, lap.timestamp - previousLap.timestamp) : 0;
const detectedBreak = Boolean(previousLap && breakMs > breakThresholdMs);
const explicitBoundary = Boolean(lap.stintBoundary);
const normalizedLap = {
...lap,
excludedFromPace: Boolean(lap.excludedFromPace || explicitBoundary || (detectedBreak && lap.lapMs > breakThresholdMs)),
stintBoundary: Boolean(explicitBoundary || detectedBreak),
};
if (!current || detectedBreak || explicitBoundary) {
current = { sessionId: lap.sessionId, sourceId: lap.sourceId, laps: [normalizedLap] };
stints.push(current);
} else {
current.laps.push(normalizedLap);
}
});
});
return stints;
}
function getPublicBestConsecutiveLaps(stints, lapCount) {
let best = null;
(Array.isArray(stints) ? stints : []).forEach((stint) => {
const paceLaps = stint.laps.filter((lap) => !lap.excludedFromPace);
for (let index = 0; index <= paceLaps.length - lapCount; index += 1) {
const laps = paceLaps.slice(index, index + lapCount);
const totalMs = laps.reduce((sum, lap) => sum + Number(lap.lapMs || 0), 0);
const candidate = { lapCount, totalMs, averageLapMs: Math.round(totalMs / lapCount) };
if (!best || candidate.totalMs < best.totalMs) best = candidate;
}
});
return best;
}
function getPublicBestDurationResult(stints, targetMs) {
let best = null;
(Array.isArray(stints) ? stints : []).forEach((stint) => {
const paceLaps = stint.laps.filter((lap) => !lap.excludedFromPace);
for (let startIndex = 0; startIndex < paceLaps.length; startIndex += 1) {
const firstLap = paceLaps[startIndex];
const startedAt = Math.max(0, Number(firstLap.timestamp || 0) - Number(firstLap.lapMs || 0));
const cutoffAt = startedAt + targetMs;
const counted = [];
for (let index = startIndex; index < paceLaps.length; index += 1) {
const lap = paceLaps[index];
const lapStartedAt = Math.max(0, Number(lap.timestamp || 0) - Number(lap.lapMs || 0));
if (lapStartedAt >= cutoffAt) break;
counted.push(lap);
}
const lastLap = counted.at(-1);
if (!lastLap || Number(lastLap.timestamp || 0) < cutoffAt) continue;
const totalMs = Number(lastLap.timestamp || 0) - startedAt;
if (totalMs > targetMs + Number(lastLap.lapMs || 0)) continue;
const lapCount = counted.length;
const candidate = {
targetMs,
lapCount,
totalMs,
averageLapMs: Math.round(counted.reduce((sum, lap) => sum + Number(lap.lapMs || 0), 0) / lapCount),
};
if (!best || candidate.lapCount > best.lapCount || (candidate.lapCount === best.lapCount && candidate.totalMs < best.totalMs)) best = candidate;
}
});
return best;
}
function addPublicTrainingMarker(state, shareToken, input, timestamp = Date.now()) {
const share = findPublicTrainingShare(state, shareToken);
if (!share) return null;
const drivers = Array.isArray(state?.drivers) ? state.drivers : [];
const profile = getPublicDriverProfile(drivers, share.driverId);
const classId = String(input?.classId || "");
const classDriver = profile?.drivers.find((driver) => String(driver?.classId || "") === classId);
if (!classDriver) return null;
const type = ["setup", "tires", "suspension", "car", "note"].includes(String(input?.type || "")) ? String(input.type) : "car";
const marker = {
id: "training_marker_public_" + Math.round(timestamp) + "_" + Math.random().toString(36).slice(2, 9),
driverId: String(classDriver.id || ""), classId, sessionId: "", timestamp: Math.round(timestamp), type,
note: String(input?.note || "").trim().slice(0, 160), createdAt: new Date(timestamp).toISOString(),
};
if (!state.training || typeof state.training !== "object") state.training = {};
state.training.markers = [...(Array.isArray(state.training.markers) ? state.training.markers : []), marker];
return marker;
}
function normalizePublicLogoDataUrl(value) {
const logo = String(value || "").trim();
return logo.length <= 2_000_000 && /^data:image\/(?:png|jpeg|webp);base64,/i.test(logo) ? logo : "";
}
function getPublicTrainingMilestone(currentLaps) {
const presets = [100, 250, 500, 1000, 1500, 2500, 5000];
const targetLaps = presets.find((target) => target > currentLaps) || (Math.floor(currentLaps / 1000) + 1) * 1000;
return {
currentLaps,
targetLaps,
remainingLaps: Math.max(0, targetLaps - currentLaps),
percent: Math.min(100, Math.max(0, (currentLaps / targetLaps) * 100)),
};
}
function getPublicTrainingRecommendation(stats) {
if (stats.totalLaps < 10) return { level: "baseline", targetLapMs: 0 };
if (stats.recentLapCount >= 10 && stats.consistencySpreadPct > 8) {
return { level: "consistency", targetLapMs: Math.round(stats.bestLapMs * 1.08) };
}
return { level: "progression", targetLapMs: Math.round(stats.bestLapMs * 1.05) };
}
module.exports = {
buildPublicTrainingProfile,
findPublicTrainingShare,
hasPublicTrainingShare,
normalizePublicTrainingSince,
normalizePublicTrainingWindow,
splitPublicTrainingStints,
getPublicBestConsecutiveLaps,
getPublicBestDurationResult,
addPublicTrainingMarker,
buildPublicEvenLapsProgress,
buildPublicStepTrainingProgress,
savePublicEvenLapsConfig,
savePublicStepTrainingConfig,
recordPublicExerciseSession,
};