Add persistent driver training history and progression

This commit is contained in:
larssand
2026-08-24 16:06:36 +02:00
parent 79a8cb24b8
commit c2ed00861a
8 changed files with 424 additions and 36 deletions

View File

@@ -14,28 +14,89 @@ export function normalizeTrainingState(training, { uid }) {
.filter((goal) => goal.driverId && goal.targetLaps > 0)
: [];
return { goals };
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 laps = [];
Object.entries(state?.resultsBySession || {}).forEach(([sessionId, result]) => {
(Array.isArray(result?.passings) ? result.passings : []).forEach((passing) => {
const lapMs = Number(passing?.lapMs || 0);
if (
String(passing?.driverId || "") === String(driverId || "") &&
passing?.validLap !== false &&
lapMs > 500
) {
laps.push({
sessionId,
timestamp: Number(passing.timestamp || 0) || 0,
lapMs,
});
}
});
});
return laps.sort((left, right) => left.timestamp - right.timestamp);
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) {
@@ -45,10 +106,36 @@ function percentile(values, 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;
@@ -60,16 +147,32 @@ export function buildDriverTrainingStats(state, driverId) {
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,
};
}
@@ -111,3 +214,10 @@ export function getTrainingGoalProgress(goal, stats) {
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);
}