Files
Live_RC/server/public_training_profile.js
larssand 3aeb77a042 add stat
2026-08-24 21:28:38 +02:00

273 lines
12 KiB
JavaScript

function findPublicTrainingShare(state, shareToken) {
if (!shareToken || shareToken.length < 24) return null;
const shares = Array.isArray(state?.training?.shares) ? state.training.shares : [];
return shares.find((share) => share?.enabled !== false && String(share?.token || "") === shareToken) || null;
}
function hasPublicTrainingShare(state, shareToken) {
return Boolean(findPublicTrainingShare(state, shareToken));
}
function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp = 0, windowMs = 0) {
const share = findPublicTrainingShare(state, shareToken);
if (!share) return null;
const driverId = String(share.driverId || "");
const drivers = Array.isArray(state?.drivers) ? state.drivers : [];
const driver = drivers.find((item) => String(item?.id || "") === driverId);
if (!driver) return null;
const classes = Array.isArray(state?.classes) ? state.classes : [];
const driverClass = classes.find((item) => String(item?.id || "") === String(driver.classId || ""));
const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : [];
const driverLaps = ledger
.filter((entry) => String(entry?.driverId || "") === driverId && Number(entry?.lapMs || 0) > 500)
.map((entry) => ({
timestamp: Math.max(0, Number(entry.timestamp || 0) || 0),
lapMs: Math.max(0, Math.round(Number(entry.lapMs || 0) || 0)),
sessionId: String(entry.sessionId || ""),
}))
.sort((left, right) => left.timestamp - right.timestamp);
const stats = buildPublicTrainingStats(driverLaps);
const goals = (Array.isArray(state?.training?.goals) ? state.training.goals : [])
.filter((goal) => String(goal?.driverId || "") === driverId)
.map((goal) => buildPublicTrainingGoal(goal, stats.totalLaps));
const rankedTotals = getRankedTrainingTotals(ledger, new Set(drivers.map((item) => String(item?.id || "")).filter(Boolean)));
const rankIndex = rankedTotals.findIndex(([id]) => id === driverId);
const settings = state?.settings && typeof state.settings === "object" ? state.settings : {};
const currentSession = sinceTimestamp
? buildPublicTrainingWindowStats(driverLaps.filter((lap) => lap.timestamp >= sinceTimestamp && (!windowMs || lap.timestamp <= sinceTimestamp + windowMs)))
: null;
return {
updatedAt: String(updatedAt || ""),
profile: {
name: String(driver.name || "Driver").trim(),
brand: String(driver.brand || "").trim(),
className: String(driverClass?.name || "").trim(),
clubRank: rankIndex >= 0 ? rankIndex + 1 : 0,
rankedDrivers: rankedTotals.length,
},
branding: {
clubName: String(settings.clubName || "JMK RB RaceController").trim(),
clubTagline: String(settings.clubTagline || "RC Timing System").trim(),
logoDataUrl: normalizePublicLogoDataUrl(settings.logoDataUrl),
},
stats,
nextMilestone: getPublicTrainingMilestone(stats.totalLaps),
recommendation: getPublicTrainingRecommendation(stats),
goals,
currentSession,
};
}
function normalizePublicTrainingSince(value, startedAgoValue) {
const now = Date.now();
if (startedAgoValue !== undefined) {
const startedAgoMs = Math.round(Number(startedAgoValue));
if (Number.isFinite(startedAgoMs) && startedAgoMs >= 0 && startedAgoMs <= 24 * 60 * 60 * 1000) {
return now - startedAgoMs;
}
}
const timestamp = Math.round(Number(value || 0) || 0);
return timestamp >= now - 24 * 60 * 60 * 1000 && timestamp <= now + 60 * 1000 ? timestamp : 0;
}
function normalizePublicTrainingWindow(value) {
const windowMs = Math.round(Number(value || 0) || 0);
return Math.min(24 * 60 * 60 * 1000, Math.max(0, windowMs));
}
function buildPublicTrainingWindowStats(laps) {
const lapTimes = laps.map((lap) => Number(lap.lapMs || 0)).filter((lapMs) => lapMs > 500);
return {
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,
};
}
function buildPublicTrainingGoal(goal, currentLaps) {
const targetLaps = Math.max(1, Math.round(Number(goal?.targetLaps || 1) || 1));
return {
title: String(goal?.title || "").trim(),
reward: String(goal?.reward || "").trim(),
targetLaps,
currentLaps,
remainingLaps: Math.max(0, targetLaps - currentLaps),
percent: Math.min(100, Math.max(0, (currentLaps / targetLaps) * 100)),
reached: currentLaps >= targetLaps,
claimed: Boolean(goal?.claimedAt),
};
}
function getRankedTrainingTotals(ledger, registeredDriverIds) {
const totals = new Map();
ledger.forEach((entry) => {
const driverId = String(entry?.driverId || "");
if (!driverId || !registeredDriverIds.has(driverId) || Number(entry?.lapMs || 0) <= 500) return;
totals.set(driverId, (totals.get(driverId) || 0) + 1);
});
return [...totals.entries()].sort((left, right) => right[1] - left[1]);
}
function buildPublicTrainingStats(laps) {
const lapTimes = laps.map((lap) => lap.lapMs);
const recent = lapTimes.slice(-30);
const average = (values) => values.length ? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length) : 0;
const sortedRecent = [...recent].sort((left, right) => left - right);
const percentile = (ratio) => sortedRecent.length
? sortedRecent[Math.min(sortedRecent.length - 1, Math.max(0, Math.round((sortedRecent.length - 1) * ratio)))]
: 0;
const recentAverageMs = average(recent);
const daily = new Map();
laps.forEach((lap) => {
const date = new Date(lap.timestamp);
const dateKey = Number.isNaN(date.getTime()) ? "unknown" : `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
const day = daily.get(dateKey) || { dateKey, laps: 0, totalMs: 0, bestLapMs: 0, latestTimestamp: 0 };
day.laps += 1;
day.totalMs += lap.lapMs;
day.bestLapMs = day.bestLapMs ? Math.min(day.bestLapMs, lap.lapMs) : lap.lapMs;
day.latestTimestamp = Math.max(day.latestTimestamp, lap.timestamp);
daily.set(dateKey, day);
});
const dailySummaries = [...daily.values()]
.map((day) => ({
dateKey: day.dateKey,
laps: day.laps,
bestLapMs: day.bestLapMs,
latestTimestamp: day.latestTimestamp,
averageLapMs: day.laps ? Math.round(day.totalMs / day.laps) : 0,
}))
.sort((left, right) => right.latestTimestamp - left.latestTimestamp)
.slice(0, 30);
const bestLapMs = lapTimes.length ? Math.min(...lapTimes) : 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 stints = splitPublicTrainingStints(laps);
const bestThreeConsecutive = getPublicBestConsecutiveLaps(stints, 3);
const bestFiveMinuteResult = getPublicBestDurationResult(stints, 5 * 60 * 1000);
return {
totalLaps: laps.length,
totalSessions: new Set(laps.map((lap) => lap.sessionId).filter(Boolean)).size,
trainingDays: daily.size,
bestLapMs,
averageLapMs: average(lapTimes),
recentAverageMs,
recentLapCount: recent.length,
consistencySpreadPct: recentAverageMs ? ((percentile(0.9) - percentile(0.1)) / recentAverageMs) * 100 : 0,
controlledLapStreak,
dailySummaries,
stintCount: stints.length,
bestThreeConsecutive,
bestFiveMinuteResult,
};
}
function splitPublicTrainingStints(laps, breakThresholdMs = 2 * 60 * 1000) {
const stints = [];
(Array.isArray(laps) ? laps : []).forEach((lap) => {
const previousStint = stints.at(-1);
const previousLap = previousStint?.laps.at(-1);
const lapStartedAt = Math.max(0, Number(lap.timestamp || 0) - Number(lap.lapMs || 0));
const breakMs = previousLap ? Math.max(0, Number(lap.timestamp || 0) - Number(previousLap.timestamp || 0)) : 0;
const startsNewStint = !previousStint
|| String(previousLap.sessionId || "") !== String(lap.sessionId || "")
|| breakMs > breakThresholdMs;
const normalizedLap = {
...lap,
excludedFromPace: Boolean(previousLap && breakMs > breakThresholdMs && Number(lap.lapMs || 0) > breakThresholdMs),
};
if (startsNewStint) {
stints.push({ sessionId: String(lap.sessionId || ""), laps: [normalizedLap] });
} else {
previousStint.laps.push(normalizedLap);
}
});
return stints;
}
function getPublicBestConsecutiveLaps(stints, lapCount) {
let best = null;
(Array.isArray(stints) ? stints : []).forEach((stint) => {
const paceLaps = stint.laps.filter((lap) => !lap.excludedFromPace);
for (let index = 0; index <= paceLaps.length - lapCount; index += 1) {
const laps = paceLaps.slice(index, index + lapCount);
const totalMs = laps.reduce((sum, lap) => sum + Number(lap.lapMs || 0), 0);
const candidate = { lapCount, totalMs, averageLapMs: Math.round(totalMs / lapCount) };
if (!best || candidate.totalMs < best.totalMs) best = candidate;
}
});
return best;
}
function getPublicBestDurationResult(stints, targetMs) {
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, Number(firstLap.timestamp || 0) - Number(firstLap.lapMs || 0));
const cutoffAt = startedAt + targetMs;
const counted = [];
for (let index = startIndex; index < paceLaps.length; index += 1) {
const lap = paceLaps[index];
const lapStartedAt = Math.max(0, Number(lap.timestamp || 0) - Number(lap.lapMs || 0));
if (lapStartedAt >= cutoffAt) break;
counted.push(lap);
}
const lastLap = counted.at(-1);
if (!lastLap || Number(lastLap.timestamp || 0) < cutoffAt) continue;
const totalMs = Number(lastLap.timestamp || 0) - startedAt;
const lapCount = counted.length;
const candidate = {
targetMs,
lapCount,
totalMs,
averageLapMs: Math.round(counted.reduce((sum, lap) => sum + Number(lap.lapMs || 0), 0) / lapCount),
};
if (!best || candidate.lapCount > best.lapCount || (candidate.lapCount === best.lapCount && candidate.totalMs < best.totalMs)) best = candidate;
}
});
return best;
}
function normalizePublicLogoDataUrl(value) {
const logo = String(value || "").trim();
return logo.length <= 2_000_000 && /^data:image\/(?:png|jpeg|webp);base64,/i.test(logo) ? logo : "";
}
function getPublicTrainingMilestone(currentLaps) {
const presets = [100, 250, 500, 1000, 1500, 2500, 5000];
const targetLaps = presets.find((target) => target > currentLaps) || (Math.floor(currentLaps / 1000) + 1) * 1000;
return {
currentLaps,
targetLaps,
remainingLaps: Math.max(0, targetLaps - currentLaps),
percent: Math.min(100, Math.max(0, (currentLaps / targetLaps) * 100)),
};
}
function getPublicTrainingRecommendation(stats) {
if (stats.totalLaps < 10) return { level: "baseline", targetLapMs: 0 };
if (stats.recentLapCount >= 10 && stats.consistencySpreadPct > 8) {
return { level: "consistency", targetLapMs: Math.round(stats.bestLapMs * 1.08) };
}
return { level: "progression", targetLapMs: Math.round(stats.bestLapMs * 1.05) };
}
module.exports = {
buildPublicTrainingProfile,
findPublicTrainingShare,
hasPublicTrainingShare,
normalizePublicTrainingSince,
normalizePublicTrainingWindow,
splitPublicTrainingStints,
getPublicBestConsecutiveLaps,
getPublicBestDurationResult,
};