224 lines
9.2 KiB
JavaScript
224 lines
9.2 KiB
JavaScript
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);
|
|
}
|