718 lines
32 KiB
JavaScript
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,
|
|
};
|
|
}
|