add more train stats per driver
This commit is contained in:
@@ -70,7 +70,25 @@ export function normalizeTrainingState(training, { uid }) {
|
||||
})
|
||||
: [];
|
||||
|
||||
return { goals, ledger, shares };
|
||||
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 };
|
||||
}
|
||||
|
||||
function getPassingSourceKey(sessionId, passing, index) {
|
||||
@@ -111,7 +129,7 @@ export function syncTrainingLedger(training, state, { uid }) {
|
||||
});
|
||||
});
|
||||
|
||||
return normalizeTrainingState({ goals: normalized.goals, ledger: [...retained, ...current], shares: normalized.shares }, { uid });
|
||||
return normalizeTrainingState({ goals: normalized.goals, ledger: [...retained, ...current], shares: normalized.shares, markers: normalized.markers }, { uid });
|
||||
}
|
||||
|
||||
export function getDriverTrainingLaps(state, driverId, classId = "") {
|
||||
@@ -145,6 +163,137 @@ function consistencySpread(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 = [
|
||||
|
||||
Reference in New Issue
Block a user