Files
Live_RC/src/training_logic.js

718 lines
32 KiB
JavaScript

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,
};
}