Files
Live_RC/server/public_training_profile.js
larssand 627179b95a feat(layouts): switch track layout mid-practice and view stats per layout
A club can run one Open Practice all season and re-lay the track now and then.
Previously a session's layout was frozen at start and every training lap inherited
it, so a season-long practice stayed on its first layout forever, and changing layout
was hidden under Settings -> Appearance.

- Each practice lap now counts toward the layout active when it was driven; a running
  practice session moves onto a new layout when one is added
- "New layout" strip on Training stats, Race Day and the Dashboard, offered only around
  practice; a running race session blocks layout changes everywhere
- "Stats for" selector (current / older layout / before layouts / all layouts) on
  Training stats and in the driver's mobile profile (?layout= on the public API)
- A layout that has practice laps on it can no longer be deleted
- Fix: the buildDriverTrainingStats wrapper in app.js dropped extra arguments

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:24:44 +02:00

867 lines
43 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;
}
// Which layout the pace/record/consistency numbers are for - mirrors
// getDriverTrainingLapsForLayout in src/training_logic.js: "current", "all", "none"
// (laps from before any layout existed) or a known layout id. Anything else -> "current".
function normalizePublicTrainingLayout(value, state) {
const layout = String(value || "current");
if (["current", "all", "none"].includes(layout)) return layout;
const layouts = Array.isArray(state?.trackLayouts) ? state.trackLayouts : [];
return layouts.some((item) => String(item?.id || "") === layout) ? layout : "current";
}
function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp = 0, windowMs = 0, evenLapsSince = 0, stepTrainingSince = 0, layout = "current") {
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 selectedLayout = normalizePublicTrainingLayout(layout, state);
const layoutFilterId = selectedLayout === "current" ? currentLayoutId : selectedLayout === "none" ? "" : selectedLayout;
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 = selectedLayout === "all" ? classLaps : classLaps.filter((lap) => lap.trackLayoutId === layoutFilterId);
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,
trackLayouts: {
selected: selectedLayout,
currentId: currentLayoutId,
options: (Array.isArray(state?.trackLayouts) ? state.trackLayouts : [])
.slice()
.sort((a, b) => Number(a?.startedAt || 0) - Number(b?.startedAt || 0))
.map((item) => ({ id: String(item?.id || ""), label: String(item?.label || "").trim() })),
hasUntaggedLaps: ledger.some((entry) => profileReferences.has(String(entry?.driverId || "")) && !String(entry?.trackLayoutId || "")),
},
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 = {
normalizePublicTrainingLayout,
buildPublicTrainingProfile,
findPublicTrainingShare,
hasPublicTrainingShare,
normalizePublicTrainingSince,
normalizePublicTrainingWindow,
splitPublicTrainingStints,
getPublicBestConsecutiveLaps,
getPublicBestDurationResult,
addPublicTrainingMarker,
buildPublicEvenLapsProgress,
buildPublicStepTrainingProgress,
savePublicEvenLapsConfig,
savePublicStepTrainingConfig,
recordPublicExerciseSession,
};