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 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), })) .sort((left, right) => left.timestamp - right.timestamp); const stats = buildPublicTrainingStats(classLaps); 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; } 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 = 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(); paceLaps.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(laps); 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(laps.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: laps.length, totalSessions: new Set(laps.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, };