export function normalizeTrainingState(training, { uid }) { const source = training && typeof training === "object" ? training : {}; const goals = Array.isArray(source.goals) ? source.goals .map((goal) => ({ id: String(goal?.id || uid("goal")), driverId: String(goal?.driverId || ""), 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) : []; return { goals, ledger }; } 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] }, { uid }); } export function getDriverTrainingLaps(state, driverId) { const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : []; return ledger .filter((entry) => String(entry?.driverId || "") === String(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 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); } export function buildDriverTrainingStats(state, driverId) { const laps = getDriverTrainingLaps(state, driverId); const lapTimes = laps.map((lap) => lap.lapMs); const recentLapTimes = lapTimes.slice(-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 = recentLapTimes.length ? Math.round(recentLapTimes.reduce((sum, value) => sum + value, 0) / recentLapTimes.length) : 0; const recentP10Ms = percentile(recentLapTimes, 0.1); const recentP90Ms = percentile(recentLapTimes, 0.9); const consistencySpreadPct = recentAverageMs ? ((recentP90Ms - recentP10Ms) / recentAverageMs) * 100 : 0; 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; let controlledLapStreak = 0; for (let index = lapTimes.length - 1; index >= 0; index -= 1) { if (!controlTargetMs || lapTimes[index] > controlTargetMs) break; controlledLapStreak += 1; } const dailySummaries = buildDailySummaries(laps); return { totalLaps, totalSessions: new Set(laps.map((lap) => lap.sessionId)).size, trainingDays: dailySummaries.length, bestLapMs, averageLapMs, recentAverageMs, recentBestMs, previousBestMs, personalBestGainMs, controlledLapStreak, consistencySpreadPct, recentLapCount: recentLapTimes.length, lastLapAt: laps.at(-1)?.timestamp || 0, dailySummaries, }; } 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); }