Add persistent driver training history and progression
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user