import { getDriverProfile, getDriverProfileReferences } from "./driver_profile_logic.js"; function getTrainingDriverIds(state, driverId, classId = "") { const profile = getDriverProfile(state, driverId); if (!profile) return getDriverProfileReferences(state, driverId); const normalizedClassId = String(classId || ""); return new Set(profile.drivers .filter((driver) => !normalizedClassId || String(driver.classId || "") === normalizedClassId) .map((driver) => String(driver.id || "")) .filter(Boolean)); } export function normalizeTrainingState(training, { uid }) { const source = training && typeof training === "object" ? training : {}; const clubGoalSource = source.clubGoal && typeof source.clubGoal === "object" ? source.clubGoal : null; const clubGoal = clubGoalSource && Number(clubGoalSource.targetLaps || 0) > 0 ? { id: String(clubGoalSource.id || uid("club_goal")), title: String(clubGoalSource.title || "").trim(), reward: String(clubGoalSource.reward || "").trim(), targetLaps: Math.max(1, Math.round(Number(clubGoalSource.targetLaps || 0) || 0)), baselineLaps: Math.max(0, Math.round(Number(clubGoalSource.baselineLaps || 0) || 0)), createdAt: String(clubGoalSource.createdAt || new Date().toISOString()), celebratedAt: clubGoalSource.celebratedAt ? String(clubGoalSource.celebratedAt) : "", } : null; const goals = Array.isArray(source.goals) ? source.goals .map((goal) => ({ id: String(goal?.id || uid("goal")), driverId: String(goal?.driverId || ""), classId: String(goal?.classId || ""), title: String(goal?.title || "").trim(), reward: String(goal?.reward || "").trim(), targetLaps: Math.max(1, Math.round(Number(goal?.targetLaps || 0) || 0)), createdAt: String(goal?.createdAt || new Date().toISOString()), claimedAt: goal?.claimedAt ? String(goal.claimedAt) : "", })) .filter((goal) => goal.driverId && goal.targetLaps > 0) : []; const seenLedgerKeys = new Set(); const ledger = Array.isArray(source.ledger) ? source.ledger .map((entry) => { const timestamp = Math.max(0, Number(entry?.timestamp || 0) || 0); return { id: String(entry?.id || uid("training_lap")), sourceKey: String(entry?.sourceKey || ""), sessionId: String(entry?.sessionId || ""), sessionName: String(entry?.sessionName || "").trim(), eventId: String(entry?.eventId || ""), eventName: String(entry?.eventName || "").trim(), driverId: String(entry?.driverId || ""), timestamp, lapMs: Math.max(0, Math.round(Number(entry?.lapMs || 0) || 0)), recordedAt: String(entry?.recordedAt || (timestamp ? new Date(timestamp).toISOString() : new Date().toISOString())), }; }) .filter((entry) => { const key = entry.sourceKey || entry.id; if (!entry.driverId || entry.lapMs <= 500 || seenLedgerKeys.has(key)) return false; seenLedgerKeys.add(key); return true; }) .sort((left, right) => left.timestamp - right.timestamp) : []; const seenShareDrivers = new Set(); const shares = Array.isArray(source.shares) ? source.shares .map((share) => ({ driverId: String(share?.driverId || ""), token: String(share?.token || "").trim(), enabled: share?.enabled !== false, createdAt: String(share?.createdAt || new Date().toISOString()), })) .filter((share) => { if (!share.driverId || share.token.length < 24 || seenShareDrivers.has(share.driverId)) return false; seenShareDrivers.add(share.driverId); return true; }) : []; const markers = Array.isArray(source.markers) ? source.markers .map((marker) => ({ id: String(marker?.id || uid("training_marker")), driverId: String(marker?.driverId || ""), classId: String(marker?.classId || ""), sessionId: String(marker?.sessionId || ""), timestamp: Math.max(0, Number(marker?.timestamp || 0) || 0), type: ["setup", "tires", "suspension", "car", "note"].includes(String(marker?.type || "").toLowerCase()) ? String(marker.type).toLowerCase() : "setup", note: String(marker?.note || "").trim(), createdAt: String(marker?.createdAt || new Date().toISOString()), })) .filter((marker) => marker.driverId && marker.classId && marker.timestamp > 0) .sort((left, right) => left.timestamp - right.timestamp) : []; return { goals, ledger, shares, markers, clubGoal }; } function getPassingSourceKey(sessionId, passing, index) { const competitor = String(passing?.competitorKey || passing?.driverId || passing?.transponder || "unknown"); return `${sessionId}:${Number(passing?.timestamp || 0) || 0}:${competitor}:${index}`; } export function syncTrainingLedger(training, state, { uid }) { const normalized = normalizeTrainingState(training, { uid }); const results = state?.resultsBySession && typeof state.resultsBySession === "object" ? state.resultsBySession : {}; const currentSessionIds = new Set(Object.keys(results)); const retained = normalized.ledger.filter((entry) => !currentSessionIds.has(entry.sessionId)); const sessions = new Map((Array.isArray(state?.sessions) ? state.sessions : []).map((session) => [String(session.id), session])); const events = new Map((Array.isArray(state?.events) ? state.events : []).map((event) => [String(event.id), event])); const current = []; Object.entries(results).forEach(([sessionId, result]) => { const session = sessions.get(String(sessionId)); const event = session ? events.get(String(session.eventId || "")) : null; (Array.isArray(result?.passings) ? result.passings : []).forEach((passing, index) => { const driverId = String(passing?.driverId || ""); const lapMs = Math.round(Number(passing?.lapMs || 0) || 0); if (!driverId || passing?.validLap === false || lapMs <= 500) return; const timestamp = Math.max(0, Number(passing?.timestamp || 0) || 0); const sourceKey = getPassingSourceKey(sessionId, passing, index); current.push({ id: `training_${sourceKey}`, sourceKey, sessionId: String(sessionId), sessionName: String(session?.name || "").trim(), eventId: String(session?.eventId || ""), eventName: String(event?.name || "").trim(), driverId, timestamp, lapMs, recordedAt: timestamp ? new Date(timestamp).toISOString() : new Date().toISOString(), }); }); }); return normalizeTrainingState({ goals: normalized.goals, ledger: [...retained, ...current], shares: normalized.shares, markers: normalized.markers, clubGoal: normalized.clubGoal, }, { uid }); } export function getDriverTrainingLaps(state, driverId, classId = "") { const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : []; const profileReferences = getTrainingDriverIds(state, driverId, classId); return ledger .filter((entry) => profileReferences.has(String(entry?.driverId || "")) && Number(entry?.lapMs || 0) > 500) .map((entry) => ({ sessionId: String(entry.sessionId || ""), sessionName: String(entry.sessionName || ""), eventName: String(entry.eventName || ""), timestamp: Number(entry.timestamp || 0) || 0, lapMs: Number(entry.lapMs || 0), })) .sort((left, right) => left.timestamp - right.timestamp); } function percentile(values, ratio) { if (!values.length) return 0; const sorted = [...values].sort((left, right) => left - right); const index = Math.min(sorted.length - 1, Math.max(0, Math.round((sorted.length - 1) * ratio))); return sorted[index]; } function average(values) { return values.length ? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length) : 0; } function consistencySpread(values) { const mean = average(values); return mean ? ((percentile(values, 0.9) - percentile(values, 0.1)) / mean) * 100 : 0; } function trimmedAverage(values, trimRatio = 0.1) { if (!values.length) return 0; const sorted = [...values].sort((left, right) => left - right); const trimCount = sorted.length >= 5 ? Math.max(1, Math.floor(sorted.length * trimRatio)) : 0; const kept = trimCount ? sorted.slice(trimCount, sorted.length - trimCount) : sorted; return average(kept); } export function getDriverTrainingMarkers(state, driverId, classId = "") { const profileReferences = getTrainingDriverIds(state, driverId, classId); const normalizedClassId = String(classId || ""); return (Array.isArray(state?.training?.markers) ? state.training.markers : []) .filter((marker) => ( profileReferences.has(String(marker?.driverId || "")) && (!normalizedClassId || String(marker?.classId || "") === normalizedClassId) )) .map((marker) => ({ ...marker })) .sort((left, right) => Number(left.timestamp || 0) - Number(right.timestamp || 0)); } export function buildTrainingPaceBuckets(state, driverId, classId = "", bucketMinutes = 30) { const laps = getDriverTrainingLaps(state, driverId, classId); const bucketMs = Math.max(5, Number(bucketMinutes || 30)) * 60 * 1000; const buckets = new Map(); laps.forEach((lap) => { const date = new Date(lap.timestamp); const bucketMinutesValue = bucketMs / 60000; const startTs = new Date( date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), Math.floor(date.getMinutes() / bucketMinutesValue) * bucketMinutesValue, 0, 0 ).getTime(); const key = String(startTs); const bucket = buckets.get(key) || { startTs, endTs: startTs + bucketMs, laps: [], sessionIds: new Set() }; bucket.laps.push(lap); if (lap.sessionId) bucket.sessionIds.add(lap.sessionId); buckets.set(key, bucket); }); const markers = getDriverTrainingMarkers(state, driverId, classId); return [...buckets.values()] .sort((left, right) => left.startTs - right.startTs) .map((bucket) => { const values = bucket.laps.map((lap) => lap.lapMs); return { startTs: bucket.startTs, endTs: bucket.endTs, lapCount: values.length, paceMs: trimmedAverage(values), medianMs: percentile(values, 0.5), bestLapMs: values.length ? Math.min(...values) : 0, sessionIds: [...bucket.sessionIds], markers: markers.filter((marker) => marker.timestamp >= bucket.startTs && marker.timestamp < bucket.endTs), }; }); } export function buildTrainingWeekdayStats(state, driverId, classId = "") { const groups = Array.from({ length: 7 }, (_, weekday) => ({ weekday, laps: [] })); getDriverTrainingLaps(state, driverId, classId).forEach((lap) => groups[new Date(lap.timestamp).getDay()].laps.push(lap.lapMs)); return groups.map((group) => ({ weekday: group.weekday, lapCount: group.laps.length, paceMs: trimmedAverage(group.laps), bestLapMs: group.laps.length ? Math.min(...group.laps) : 0, })); } export function buildTrainingMarkerComparisons(state, driverId, classId = "", windowMinutes = 30) { const laps = getDriverTrainingLaps(state, driverId, classId); const windowMs = Math.max(5, Number(windowMinutes || 30)) * 60 * 1000; return getDriverTrainingMarkers(state, driverId, classId).map((marker) => { const comparableLaps = marker.sessionId ? laps.filter((lap) => lap.sessionId === marker.sessionId) : laps; const before = comparableLaps.filter((lap) => lap.timestamp < marker.timestamp && lap.timestamp >= marker.timestamp - windowMs); const after = comparableLaps.filter((lap) => lap.timestamp >= marker.timestamp && lap.timestamp < marker.timestamp + windowMs); const beforePaceMs = before.length >= 3 ? trimmedAverage(before.map((lap) => lap.lapMs)) : 0; const afterPaceMs = after.length >= 3 ? trimmedAverage(after.map((lap) => lap.lapMs)) : 0; return { ...marker, beforeLapCount: before.length, afterLapCount: after.length, beforePaceMs, afterPaceMs, improvementMs: beforePaceMs && afterPaceMs ? beforePaceMs - afterPaceMs : null, }; }); } function getBestConsecutiveAverage(laps, count) { let best = 0; for (let index = 0; index <= laps.length - count; index += 1) { const candidate = average(laps.slice(index, index + count).map((lap) => lap.lapMs)); if (!best || candidate < best) best = candidate; } return best; } export function buildTrainingStintAnalysis(state, driverId, classId = "") { const stints = splitTrainingStints(getDriverTrainingLaps(state, driverId, classId)); const rows = stints.map((stint, index) => { const laps = stint.laps.filter((lap) => !lap.excludedFromPace); const values = laps.map((lap) => lap.lapMs); const medianMs = percentile(values, 0.5); const mistakeThresholdMs = medianMs ? medianMs * 1.15 : 0; const mistakes = values.filter((lapMs) => mistakeThresholdMs && lapMs > mistakeThresholdMs).length; const comparisonSize = values.length >= 6 ? Math.max(3, Math.floor(values.length / 3)) : 0; const startPaceMs = comparisonSize ? trimmedAverage(values.slice(0, comparisonSize)) : 0; const endPaceMs = comparisonSize ? trimmedAverage(values.slice(-comparisonSize)) : 0; return { id: `${stint.sessionId}:${stint.startedAt}:${index}`, number: index + 1, sessionId: stint.sessionId, startedAt: stint.startedAt, endedAt: stint.endedAt, durationMs: stint.durationMs, lapCount: values.length, bestLapMs: values.length ? Math.min(...values) : 0, paceMs: trimmedAverage(values), racePace10Ms: values.length >= 10 ? getBestConsecutiveAverage(laps, 10) : 0, mistakes, mistakePct: values.length ? (mistakes / values.length) * 100 : 0, consistencyPct: values.length >= 3 ? consistencySpread(values) : null, startPaceMs, endPaceMs, endChangeMs: startPaceMs && endPaceMs ? startPaceMs - endPaceMs : null, insufficient: values.length < 3, }; }); const eligible = rows.filter((row) => !row.insufficient); const best = eligible.reduce((current, row) => (!current || row.paceMs < current.paceMs ? row : current), null); return { rows: rows.sort((left, right) => right.startedAt - left.startedAt), best }; } export function buildTrainingAchievements(stats) { const source = stats && typeof stats === "object" ? stats : {}; const definitions = [ ["first_session", Number(source.totalSessions || 0), 1], ["laps_100", Number(source.totalLaps || 0), 100], ["laps_250", Number(source.totalLaps || 0), 250], ["laps_500", Number(source.totalLaps || 0), 500], ["laps_1000", Number(source.totalLaps || 0), 1000], ["laps_1500", Number(source.totalLaps || 0), 1500], ["sessions_5", Number(source.totalSessions || 0), 5], ["days_3", Number(source.trainingDays || 0), 3], ["days_10", Number(source.trainingDays || 0), 10], ["controlled_5", Number(source.controlledLapStreak || 0), 5], ["controlled_10", Number(source.controlledLapStreak || 0), 10], ["five_minute", source.bestFiveMinuteResult ? 1 : 0, 1], ["five_minutes_3", Number(source.completedFiveMinuteStints || 0), 3], ["consistency_5", source.recentLapCount >= 30 && source.consistencySpreadPct <= 5 ? 1 : 0, 1], ["pace_gain", source.trendSampleReady && source.averageTrendMs > 0 ? 1 : 0, 1], ]; return definitions.map(([id, current, target]) => ({ id, current: Math.max(0, current), target, percent: Math.min(100, Math.max(0, (current / target) * 100)), unlocked: current >= target, })); } function getLocalDayKey(timestamp) { const date = new Date(Number(timestamp || 0)); if (Number.isNaN(date.getTime())) return "unknown"; const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } function buildDailySummaries(laps) { const byDay = new Map(); laps.forEach((lap) => { const key = getLocalDayKey(lap.timestamp); const current = byDay.get(key) || { dateKey: key, laps: 0, totalMs: 0, bestLapMs: 0, latestTimestamp: 0 }; current.laps += 1; current.totalMs += lap.lapMs; current.bestLapMs = current.bestLapMs ? Math.min(current.bestLapMs, lap.lapMs) : lap.lapMs; current.latestTimestamp = Math.max(current.latestTimestamp, lap.timestamp); byDay.set(key, current); }); return [...byDay.values()] .map((day) => ({ ...day, averageLapMs: day.laps ? Math.round(day.totalMs / day.laps) : 0 })) .sort((left, right) => right.latestTimestamp - left.latestTimestamp); } function getControlledLapStreak(lapTimes, controlTargetMs) { let streak = 0; for (let index = lapTimes.length - 1; index >= 0; index -= 1) { if (!controlTargetMs || lapTimes[index] > controlTargetMs) break; streak += 1; } return streak; } export function splitTrainingStints(laps, breakThresholdMs = 2 * 60 * 1000) { const sorted = (Array.isArray(laps) ? laps : []) .filter((lap) => Number(lap?.lapMs || 0) > 500 && Number(lap?.timestamp || 0) > 0) .map((lap) => ({ ...lap, timestamp: Number(lap.timestamp), lapMs: Number(lap.lapMs) })) .sort((left, right) => left.timestamp - right.timestamp); const stints = []; sorted.forEach((lap) => { const previousStint = stints.at(-1); const previousLap = previousStint?.laps.at(-1); const lapStartedAt = Math.max(0, lap.timestamp - lap.lapMs); const breakMs = previousLap ? Math.max(0, lap.timestamp - previousLap.timestamp) : 0; const startsNewStint = !previousStint || String(previousLap.sessionId || "") !== String(lap.sessionId || "") || breakMs > breakThresholdMs; const normalizedLap = { ...lap, excludedFromPace: Boolean(previousLap && breakMs > breakThresholdMs && lap.lapMs > breakThresholdMs), }; if (startsNewStint) { stints.push({ sessionId: String(lap.sessionId || ""), startedAt: lapStartedAt, endedAt: lap.timestamp, durationMs: lap.lapMs, laps: [normalizedLap], }); return; } previousStint.laps.push(normalizedLap); previousStint.endedAt = lap.timestamp; previousStint.durationMs = Math.max(0, previousStint.endedAt - previousStint.startedAt); }); return stints; } export function getBestConsecutiveTrainingLaps(stints, lapCount = 3) { const count = Math.max(1, Math.round(Number(lapCount || 1) || 1)); let best = null; (Array.isArray(stints) ? stints : []).forEach((stint) => { const paceLaps = stint.laps.filter((lap) => !lap.excludedFromPace); for (let index = 0; index <= paceLaps.length - count; index += 1) { const laps = paceLaps.slice(index, index + count); const totalMs = laps.reduce((sum, lap) => sum + lap.lapMs, 0); const candidate = { lapCount: count, totalMs, averageLapMs: Math.round(totalMs / count), startedAt: Math.max(0, laps[0].timestamp - laps[0].lapMs), endedAt: laps.at(-1).timestamp, sessionId: String(stint.sessionId || ""), }; if (!best || candidate.totalMs < best.totalMs) best = candidate; } }); return best; } export function getBestTrainingDurationResult(stints, targetMs = 5 * 60 * 1000) { const target = Math.max(1, Math.round(Number(targetMs || 0) || 0)); 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, firstLap.timestamp - firstLap.lapMs); const cutoffAt = startedAt + target; const counted = []; for (let index = startIndex; index < paceLaps.length; index += 1) { const lap = paceLaps[index]; const lapStartedAt = Math.max(0, lap.timestamp - lap.lapMs); if (lapStartedAt >= cutoffAt) break; counted.push(lap); } const lastLap = counted.at(-1); if (!lastLap || lastLap.timestamp < cutoffAt) continue; const totalMs = Math.max(0, lastLap.timestamp - startedAt); const lapCount = counted.length; const candidate = { targetMs: target, lapCount, totalMs, averageLapMs: lapCount ? Math.round(counted.reduce((sum, lap) => sum + lap.lapMs, 0) / lapCount) : 0, startedAt, endedAt: lastLap.timestamp, sessionId: String(stint.sessionId || ""), }; if (!best || candidate.lapCount > best.lapCount || (candidate.lapCount === best.lapCount && candidate.totalMs < best.totalMs)) { best = candidate; } } }); return best; } export function buildDriverTrainingStats(state, driverId, classId = "") { const laps = getDriverTrainingLaps(state, driverId, classId); const lapTimes = laps.map((lap) => lap.lapMs); const recentLapTimes = lapTimes.slice(-30); const previousWindowLapTimes = lapTimes.slice(Math.max(0, lapTimes.length - 60), Math.max(0, lapTimes.length - 30)); const earlierLapTimes = lapTimes.slice(0, Math.max(0, lapTimes.length - recentLapTimes.length)); const totalLaps = laps.length; const bestLapMs = totalLaps ? Math.min(...lapTimes) : 0; const averageLapMs = totalLaps ? Math.round(lapTimes.reduce((sum, value) => sum + value, 0) / totalLaps) : 0; const recentAverageMs = average(recentLapTimes); const previousRecentAverageMs = average(previousWindowLapTimes); const consistencySpreadPct = consistencySpread(recentLapTimes); const previousConsistencySpreadPct = consistencySpread(previousWindowLapTimes); const trendSampleReady = recentLapTimes.length >= 10 && previousWindowLapTimes.length >= 10; const previousBestMs = earlierLapTimes.length ? Math.min(...earlierLapTimes) : 0; const recentBestMs = recentLapTimes.length ? Math.min(...recentLapTimes) : 0; const personalBestGainMs = previousBestMs && recentBestMs ? Math.max(0, previousBestMs - recentBestMs) : 0; const controlTargetMs = bestLapMs ? Math.round(bestLapMs * 1.08) : 0; const controlledLapStreak = getControlledLapStreak(lapTimes, controlTargetMs); const dailySummaries = buildDailySummaries(laps); const stints = splitTrainingStints(laps); const completedFiveMinuteStints = stints.filter((stint) => getBestTrainingDurationResult([stint], 5 * 60 * 1000)).length; const bestThreeConsecutive = getBestConsecutiveTrainingLaps(stints, 3); const bestFiveMinuteResult = getBestTrainingDurationResult(stints, 5 * 60 * 1000); const todayKey = getLocalDayKey(Date.now()); const todayLaps = laps.filter((lap) => getLocalDayKey(lap.timestamp) === todayKey); const todayStints = splitTrainingStints(todayLaps); const todayLapTimes = todayLaps.map((lap) => lap.lapMs); const stats = { totalLaps, totalSessions: new Set(laps.map((lap) => lap.sessionId)).size, trainingDays: dailySummaries.length, bestLapMs, averageLapMs, recentAverageMs, previousRecentAverageMs, averageTrendMs: trendSampleReady ? previousRecentAverageMs - recentAverageMs : 0, recentBestMs, previousBestMs, personalBestGainMs, controlledLapStreak, todayLaps: todayLaps.length, todayBestLapMs: todayLapTimes.length ? Math.min(...todayLapTimes) : 0, todayAverageLapMs: average(todayLapTimes), todayControlledLapStreak: getControlledLapStreak(todayLapTimes, controlTargetMs), todayCompletedFiveMinuteStints: todayStints.filter((stint) => getBestTrainingDurationResult([stint], 5 * 60 * 1000)).length, consistencySpreadPct, previousConsistencySpreadPct, consistencyTrendPct: trendSampleReady ? previousConsistencySpreadPct - consistencySpreadPct : 0, trendSampleReady, recentLapCount: recentLapTimes.length, lastLapAt: laps.at(-1)?.timestamp || 0, dailySummaries, stintCount: stints.length, completedFiveMinuteStints, bestThreeConsecutive, bestFiveMinuteResult, }; return { ...stats, achievements: buildTrainingAchievements(stats) }; } export function buildDriverTimingChannelStats(state, driverId, classId = "") { const profileReferences = getTrainingDriverIds(state, driverId, classId); const sessions = new Map((Array.isArray(state?.sessions) ? state.sessions : []).map((session) => [String(session.id || ""), session])); const events = new Map((Array.isArray(state?.events) ? state.events : []).map((event) => [String(event.id || ""), event])); const splits = []; const pitStops = []; Object.entries(state?.resultsBySession || {}).forEach(([sessionId, result]) => { const session = sessions.get(String(sessionId)); const event = session ? events.get(String(session.eventId || "")) : null; (Array.isArray(result?.splits) ? result.splits : []).forEach((split) => { if (!profileReferences.has(String(split?.driverId || "")) || Number(split?.splitMs || 0) <= 0) return; splits.push({ ...split, sessionId: String(sessionId), sessionName: String(session?.name || ""), eventName: String(event?.name || ""), splitMs: Math.round(Number(split.splitMs)), }); }); (Array.isArray(result?.pitStops) ? result.pitStops : []).forEach((stop) => { if (!profileReferences.has(String(stop?.driverId || "")) || Number(stop?.pitStopMs || 0) <= 0) return; pitStops.push({ ...stop, sessionId: String(sessionId), sessionName: String(session?.name || ""), eventName: String(event?.name || ""), pitStopMs: Math.round(Number(stop.pitStopMs)), }); }); }); splits.sort((left, right) => Number(left.timestamp || 0) - Number(right.timestamp || 0)); pitStops.sort((left, right) => Number(left.enteredAt || 0) - Number(right.enteredAt || 0)); const splitTimes = splits.map((split) => split.splitMs); const pitTimes = pitStops.map((stop) => stop.pitStopMs); return { splitCount: splits.length, splitSessions: new Set(splits.map((split) => split.sessionId)).size, bestSplitMs: splitTimes.length ? Math.min(...splitTimes) : 0, averageSplitMs: splitTimes.length ? Math.round(splitTimes.reduce((sum, value) => sum + value, 0) / splitTimes.length) : 0, lastSplitMs: splitTimes.at(-1) || 0, pitStopCount: pitStops.length, pitSessions: new Set(pitStops.map((stop) => stop.sessionId)).size, totalPitMs: pitTimes.reduce((sum, value) => sum + value, 0), averagePitStopMs: pitTimes.length ? Math.round(pitTimes.reduce((sum, value) => sum + value, 0) / pitTimes.length) : 0, bestPitStopMs: pitTimes.length ? Math.min(...pitTimes) : 0, longestPitStopMs: pitTimes.length ? Math.max(...pitTimes) : 0, recentPitStops: pitStops.slice(-10).reverse(), }; } export function buildDriverActivePracticeStats(state, driverId, nowTs = Date.now(), classId = "") { const session = (Array.isArray(state?.sessions) ? state.sessions : []) .find((item) => String(item?.id || "") === String(state?.activeSessionId || "")); const practiceTypes = new Set(["open_practice", "free_practice", "practice"]); if (!session || session.status !== "running" || !practiceTypes.has(String(session.type || "").toLowerCase())) return null; const result = state?.resultsBySession?.[session.id]; const startedAt = Math.max(0, Number(session.startedAt || 0) || 0); const profileReferences = getTrainingDriverIds(state, driverId, classId); const laps = (Array.isArray(result?.passings) ? result.passings : []) .filter((passing) => ( profileReferences.has(String(passing?.driverId || "")) && passing?.validLap !== false && Number(passing?.lapMs || 0) > 500 && (!startedAt || Number(passing?.timestamp || 0) >= startedAt) )) .map((passing) => ({ timestamp: Math.max(0, Number(passing.timestamp || 0) || 0), lapMs: Math.round(Number(passing.lapMs || 0) || 0), })) .sort((left, right) => left.timestamp - right.timestamp); const lapTimes = laps.map((lap) => lap.lapMs); return { sessionId: String(session.id || ""), sessionName: String(session.name || "").trim(), startedAt, elapsedMs: startedAt ? Math.max(0, Number(nowTs || 0) - startedAt) : 0, laps: lapTimes.length, bestLapMs: lapTimes.length ? Math.min(...lapTimes) : 0, averageLapMs: lapTimes.length ? Math.round(lapTimes.reduce((sum, lapMs) => sum + lapMs, 0) / lapTimes.length) : 0, lastLapMs: lapTimes.at(-1) || 0, }; } export function buildTrainingRecommendation(stats) { if (!stats || stats.totalLaps < 10) { return { level: "baseline", titleKey: "training.plan_baseline_title", bodyKey: "training.plan_baseline_body", targetLapMs: 0, }; } if (stats.recentLapCount >= 10 && stats.consistencySpreadPct > 8) { return { level: "consistency", titleKey: "training.plan_consistency_title", bodyKey: "training.plan_consistency_body", targetLapMs: Math.round(stats.bestLapMs * 1.08), }; } return { level: "progression", titleKey: "training.plan_progression_title", bodyKey: "training.plan_progression_body", targetLapMs: Math.round(stats.bestLapMs * 1.05), }; } export function getTrainingGoalProgress(goal, stats) { const targetLaps = Math.max(1, Number(goal?.targetLaps || 1)); const currentLaps = Math.max(0, Number(stats?.totalLaps || 0)); return { currentLaps, targetLaps, remainingLaps: Math.max(0, targetLaps - currentLaps), percent: Math.min(100, Math.max(0, (currentLaps / targetLaps) * 100)), reached: currentLaps >= targetLaps, }; } export function getNextTrainingMilestone(stats) { const currentLaps = Math.max(0, Number(stats?.totalLaps || 0)); const presets = [100, 250, 500, 1000, 1500, 2500, 5000]; const targetLaps = presets.find((target) => target > currentLaps) || (Math.floor(currentLaps / 1000) + 1) * 1000; return getTrainingGoalProgress({ targetLaps }, stats); } export function buildTrainingMissions(stats) { const source = stats && typeof stats === "object" ? stats : {}; const definitions = [ ["laps_today", Number(source.todayLaps || 0), 20], ["controlled_today", Number(source.todayControlledLapStreak || 0), 5], ["five_minute_today", Number(source.todayCompletedFiveMinuteStints || 0), 1], ]; return definitions.map(([id, current, target]) => ({ id, current: Math.max(0, current), target, percent: Math.min(100, Math.max(0, (current / target) * 100)), completed: current >= target, })); } export function buildTrainingHighlights(stats) { const source = stats && typeof stats === "object" ? stats : {}; const highlights = []; if (source.todayLaps > 0) highlights.push({ id: "today_laps", value: Number(source.todayLaps) }); if (source.personalBestGainMs > 0) highlights.push({ id: "personal_best", value: Number(source.personalBestGainMs) }); if (source.trendSampleReady && source.averageTrendMs > 0) highlights.push({ id: "pace_gain", value: Number(source.averageTrendMs) }); if (source.trendSampleReady && source.consistencyTrendPct > 0.1) highlights.push({ id: "consistency_gain", value: Number(source.consistencyTrendPct) }); if (source.todayControlledLapStreak >= 5) highlights.push({ id: "controlled_streak", value: Number(source.todayControlledLapStreak) }); if (source.todayCompletedFiveMinuteStints > 0) highlights.push({ id: "five_minute", value: Number(source.todayCompletedFiveMinuteStints) }); if (!highlights.length && source.totalLaps > 0) highlights.push({ id: "keep_building", value: Number(source.totalLaps) }); if (!highlights.length) highlights.push({ id: "ready", value: 0 }); return highlights.slice(0, 3); } export function getClubTrainingGoalProgress(training) { const goal = training?.clubGoal && typeof training.clubGoal === "object" ? training.clubGoal : null; if (!goal || Number(goal.targetLaps || 0) <= 0) return null; const totalLaps = Array.isArray(training?.ledger) ? training.ledger.length : 0; const currentLaps = Math.max(0, totalLaps - Math.max(0, Number(goal.baselineLaps || 0))); const targetLaps = Math.max(1, Number(goal.targetLaps || 1)); return { currentLaps, targetLaps, remainingLaps: Math.max(0, targetLaps - currentLaps), percent: Math.min(100, Math.max(0, (currentLaps / targetLaps) * 100)), reached: currentLaps >= targetLaps, }; }