Files
Live_RC/server/public_training_profile.js
2026-09-04 16:17:45 +02:00

486 lines
22 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 getPublicDriverProfileId(driver) {
const explicitId = String(driver?.profileId || "").trim();
if (explicitId) return explicitId;
const name = String(driver?.name || "").trim().toLocaleLowerCase().replace(/\s+/g, " ");
return name ? `legacy:${name}` : `driver:${String(driver?.id || "unknown")}`;
}
function getPublicDriverProfile(drivers, reference) {
const normalizedReference = String(reference || "");
const anchor = drivers.find((driver) => (
String(driver?.id || "") === normalizedReference
|| getPublicDriverProfileId(driver) === normalizedReference
));
if (!anchor) return null;
const profileId = getPublicDriverProfileId(anchor);
return {
id: profileId,
drivers: drivers.filter((driver) => getPublicDriverProfileId(driver) === profileId),
};
}
const PUBLIC_TRAINING_SECTION_KEYS = ["progress", "achievements", "performance", "goals", "history"];
function normalizePublicTrainingVisibility(value) {
const source = value && typeof value === "object" ? value : {};
return Object.fromEntries(PUBLIC_TRAINING_SECTION_KEYS.map((key) => [key, source[key] !== false]));
}
function filterPublicTrainingStats(stats, visibility) {
const filtered = {
totalLaps: stats.totalLaps, totalSessions: stats.totalSessions, trainingDays: stats.trainingDays,
bestLapMs: stats.bestLapMs, averageLapMs: stats.averageLapMs, recentLapCount: stats.recentLapCount,
consistencySpreadPct: stats.consistencySpreadPct,
};
if (visibility.progress) Object.assign(filtered, {
recentAverageMs: stats.recentAverageMs, previousRecentAverageMs: stats.previousRecentAverageMs,
averageTrendMs: stats.averageTrendMs, previousConsistencySpreadPct: stats.previousConsistencySpreadPct,
consistencyTrendPct: stats.consistencyTrendPct, trendSampleReady: stats.trendSampleReady,
controlledLapStreak: stats.controlledLapStreak, todayControlledLapStreak: stats.todayControlledLapStreak,
todayStrongFinishStints: stats.todayStrongFinishStints, todayCleanFiveMinuteStints: stats.todayCleanFiveMinuteStints,
todayCompletedFiveMinuteStints: stats.todayCompletedFiveMinuteStints, paceHistory: stats.dailySummaries.slice(0, 12),
});
if (visibility.achievements) filtered.achievements = stats.achievements;
if (visibility.performance) Object.assign(filtered, {
controlZoneMinMs: stats.controlZoneMinMs, controlZoneMaxMs: stats.controlZoneMaxMs,
outlierLaps: stats.outlierLaps, strongFinishStints: stats.strongFinishStints,
cleanFiveMinuteStints: stats.cleanFiveMinuteStints, stintCount: stats.stintCount,
stintSummaries: stats.stintSummaries, bestThreeConsecutive: stats.bestThreeConsecutive,
bestFiveMinuteResult: stats.bestFiveMinuteResult,
});
if (visibility.history) filtered.dailySummaries = stats.dailySummaries;
return filtered;
}
function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp = 0, windowMs = 0) {
const share = findPublicTrainingShare(state, shareToken);
if (!share) return null;
const visibility = normalizePublicTrainingVisibility(share.visibility);
const profileReference = String(share.driverId || "");
const drivers = Array.isArray(state?.drivers) ? state.drivers : [];
const driverProfile = getPublicDriverProfile(drivers, profileReference);
if (!driverProfile?.drivers.length) return null;
const driver = driverProfile.drivers[0];
const classes = Array.isArray(state?.classes) ? state.classes : [];
const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : [];
const profileReferences = new Set([driverProfile.id, ...driverProfile.drivers.map((item) => String(item.id || ""))]);
const profileClassIds = [...new Set(driverProfile.drivers.map((item) => String(item.classId || "")).filter(Boolean))];
const classProfiles = profileClassIds.map((classId) => {
const classDrivers = driverProfile.drivers.filter((item) => String(item.classId || "") === classId);
const classDriverIds = new Set(classDrivers.map((item) => String(item.id || "")));
const classLaps = ledger
.filter((entry) => classDriverIds.has(String(entry?.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 || ""),
sourceId: String(entry.sourceId || entry.driverId || ""),
excludedFromPace: Boolean(entry.excludedFromPace),
stintBoundary: Boolean(entry.stintBoundary),
}))
.sort((left, right) => left.timestamp - right.timestamp);
const stats = buildPublicTrainingStats(classLaps);
const channelStats = buildPublicTimingChannelStats(state, classDriverIds);
const classGoals = (Array.isArray(state?.training?.goals) ? state.training.goals : [])
.filter((goal) => profileReferences.has(String(goal?.driverId || "")) && (!goal?.classId || String(goal.classId) === classId))
.map((goal) => buildPublicTrainingGoal(goal, stats.totalLaps));
const brands = [...new Set(classDrivers.map((item) => String(item.brand || "").trim()).filter(Boolean))];
return {
classId,
className: String(classes.find((item) => String(item?.id || "") === classId)?.name || "").trim(),
brand: brands.join(" / "),
stats: filterPublicTrainingStats(stats, visibility),
channelStats: visibility.performance ? channelStats : {},
nextMilestone: getPublicTrainingMilestone(stats.totalLaps),
recommendation: getPublicTrainingRecommendation(stats),
goals: visibility.goals ? classGoals : [],
currentSession: sinceTimestamp
? buildPublicTrainingWindowStats(classLaps.filter((lap) => lap.timestamp >= sinceTimestamp && (!windowMs || lap.timestamp <= sinceTimestamp + windowMs)))
: null,
};
});
const activeClass = classProfiles[0] || {
classId: "", className: "", brand: "",
stats: filterPublicTrainingStats(buildPublicTrainingStats([]), visibility), nextMilestone: getPublicTrainingMilestone(0),
channelStats: visibility.performance ? buildPublicTimingChannelStats({}, new Set()) : {},
recommendation: getPublicTrainingRecommendation({ totalLaps: 0 }), goals: [], currentSession: null,
};
const settings = state?.settings && typeof state.settings === "object" ? state.settings : {};
return {
updatedAt: String(updatedAt || ""),
visibility,
profile: {
name: String(driver.name || "Driver").trim(),
brand: activeClass.brand,
className: activeClass.className,
},
branding: {
clubName: String(settings.clubName || "JMK RB").trim(),
clubTagline: String(settings.clubTagline || "Live Timing & Race Control").trim(),
logoDataUrl: normalizePublicLogoDataUrl(settings.logoDataUrl),
},
classProfiles,
stats: activeClass.stats,
channelStats: activeClass.channelStats,
nextMilestone: activeClass.nextMilestone,
recommendation: activeClass.recommendation,
goals: activeClass.goals,
currentSession: activeClass.currentSession,
};
}
function buildPublicTimingChannelStats(state, classDriverIds) {
const splits = [];
const pitTimes = [];
Object.values(state?.resultsBySession || {}).forEach((result) => {
(Array.isArray(result?.splits) ? result.splits : []).forEach((split) => {
const splitMs = Math.round(Number(split?.splitMs || 0) || 0);
if (classDriverIds.has(String(split?.driverId || "")) && splitMs > 0) splits.push({ splitMs, timestamp: Number(split?.timestamp || 0) });
});
(Array.isArray(result?.pitStops) ? result.pitStops : []).forEach((stop) => {
const pitStopMs = Math.round(Number(stop?.pitStopMs || 0) || 0);
if (classDriverIds.has(String(stop?.driverId || "")) && pitStopMs > 0) pitTimes.push(pitStopMs);
});
});
const average = (values) => values.length
? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length)
: 0;
splits.sort((left, right) => left.timestamp - right.timestamp);
const splitTimes = splits.map((split) => split.splitMs);
let runningBestSplitMs = 0;
let latestSplitGainMs = 0;
splits.forEach((split) => {
if (runningBestSplitMs && split.splitMs < runningBestSplitMs) latestSplitGainMs = runningBestSplitMs - split.splitMs;
if (!runningBestSplitMs || split.splitMs < runningBestSplitMs) runningBestSplitMs = split.splitMs;
});
return {
splitCount: splitTimes.length,
bestSplitMs: splitTimes.length ? Math.min(...splitTimes) : 0,
averageSplitMs: average(splitTimes),
latestSplitGainMs,
pitStopCount: pitTimes.length,
totalPitMs: pitTimes.reduce((sum, value) => sum + value, 0),
bestPitStopMs: pitTimes.length ? Math.min(...pitTimes) : 0,
averagePitStopMs: average(pitTimes),
};
}
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.filter((lap) => !lap.excludedFromPace).map((lap) => Number(lap.lapMs || 0)).filter((lapMs) => lapMs > 500);
return {
laps: laps.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 buildPublicTrainingStats(laps) {
const paceLaps = laps.filter((lap) => !lap.excludedFromPace);
const lapTimes = paceLaps.map((lap) => lap.lapMs);
const recent = lapTimes.slice(-30);
const previous = lapTimes.slice(Math.max(0, lapTimes.length - 60), Math.max(0, lapTimes.length - 30));
const average = (values) => values.length ? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length) : 0;
const percentile = (values, ratio) => {
const sorted = [...values].sort((left, right) => left - right);
return sorted.length
? sorted[Math.min(sorted.length - 1, Math.max(0, Math.round((sorted.length - 1) * ratio)))]
: 0;
};
const spread = (values) => {
const mean = average(values);
return mean ? ((percentile(values, 0.9) - percentile(values, 0.1)) / mean) * 100 : 0;
};
const recentAverageMs = average(recent);
const previousRecentAverageMs = average(previous);
const consistencySpreadPct = spread(recent);
const previousConsistencySpreadPct = spread(previous);
const trendSampleReady = recent.length >= 10 && previous.length >= 10;
const daily = new Map();
paceLaps.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;
const stints = splitPublicTrainingStints(laps);
const summarizeStints = (items) => {
let controlledLapStreak = 0;
let outlierLaps = 0;
let strongFinishStints = 0;
let cleanFiveMinuteStints = 0;
let completedFiveMinuteStints = 0;
items.forEach((stint) => {
let currentStreak = 0;
const values = stint.laps.filter((lap) => !lap.excludedFromPace).map((lap) => lap.lapMs);
values.forEach((lapMs) => {
currentStreak = controlTargetMs && lapMs <= controlTargetMs ? currentStreak + 1 : 0;
controlledLapStreak = Math.max(controlledLapStreak, currentStreak);
});
const medianMs = percentile(values, 0.5);
const stintOutliers = values.filter((lapMs) => medianMs && lapMs > medianMs * 1.15).length;
outlierLaps += stintOutliers;
const comparisonSize = values.length >= 6 ? Math.max(3, Math.floor(values.length / 3)) : 0;
const startPaceMs = comparisonSize ? average(values.slice(0, comparisonSize)) : 0;
const endPaceMs = comparisonSize ? average(values.slice(-comparisonSize)) : 0;
if (comparisonSize && endPaceMs <= startPaceMs * 1.01) strongFinishStints += 1;
const fiveMinuteResult = getPublicBestDurationResult([stint], 5 * 60 * 1000);
if (fiveMinuteResult) completedFiveMinuteStints += 1;
if (!stintOutliers && fiveMinuteResult) cleanFiveMinuteStints += 1;
});
return { controlledLapStreak, outlierLaps, strongFinishStints, cleanFiveMinuteStints, completedFiveMinuteStints };
};
const stintSummary = summarizeStints(stints);
const today = new Date();
const todayKey = [today.getFullYear(), String(today.getMonth() + 1).padStart(2, "0"), String(today.getDate()).padStart(2, "0")].join("-");
const todayStintSummary = summarizeStints(splitPublicTrainingStints(laps.filter((lap) => {
const date = new Date(lap.timestamp);
const dateKey = [date.getFullYear(), String(date.getMonth() + 1).padStart(2, "0"), String(date.getDate()).padStart(2, "0")].join("-");
return !Number.isNaN(date.getTime()) && dateKey === todayKey;
})));
const bestThreeConsecutive = getPublicBestConsecutiveLaps(stints, 3);
const bestFiveMinuteResult = getPublicBestDurationResult(stints, 5 * 60 * 1000);
const stintSummaries = stints.map((stint, index) => {
const paceLaps = stint.laps.filter((lap) => !lap.excludedFromPace);
const values = paceLaps.map((lap) => lap.lapMs);
const startedAt = Math.max(0, Number(stint.laps[0]?.timestamp || 0) - Number(stint.laps[0]?.lapMs || 0));
const endedAt = Number(stint.laps.at(-1)?.timestamp || startedAt);
return {
number: index + 1, startedAt, lapCount: stint.laps.length,
bestLapMs: values.length ? Math.min(...values) : 0, averageLapMs: average(values),
durationMs: Math.max(0, endedAt - startedAt),
};
}).sort((left, right) => right.startedAt - left.startedAt).slice(0, 8);
const stats = {
totalLaps: laps.length,
totalSessions: new Set(laps.map((lap) => lap.sessionId).filter(Boolean)).size,
trainingDays: daily.size,
bestLapMs,
averageLapMs: average(lapTimes),
recentAverageMs,
previousRecentAverageMs,
averageTrendMs: trendSampleReady ? previousRecentAverageMs - recentAverageMs : 0,
recentLapCount: recent.length,
consistencySpreadPct,
previousConsistencySpreadPct,
consistencyTrendPct: trendSampleReady ? previousConsistencySpreadPct - consistencySpreadPct : 0,
trendSampleReady,
controlZoneMinMs: bestLapMs,
controlZoneMaxMs: controlTargetMs,
controlledLapStreak: stintSummary.controlledLapStreak,
outlierLaps: stintSummary.outlierLaps,
strongFinishStints: stintSummary.strongFinishStints,
cleanFiveMinuteStints: stintSummary.cleanFiveMinuteStints,
todayControlledLapStreak: todayStintSummary.controlledLapStreak,
todayStrongFinishStints: todayStintSummary.strongFinishStints,
todayCleanFiveMinuteStints: todayStintSummary.cleanFiveMinuteStints,
todayCompletedFiveMinuteStints: todayStintSummary.completedFiveMinuteStints,
dailySummaries,
stintCount: stints.length,
stintSummaries,
bestThreeConsecutive,
bestFiveMinuteResult,
};
return { ...stats, achievements: buildPublicTrainingAchievements(stats) };
}
function buildPublicTrainingAchievements(stats) {
const definitions = [
["first_session", Number(stats.totalSessions || 0), 1],
["laps_100", Number(stats.totalLaps || 0), 100],
["laps_500", Number(stats.totalLaps || 0), 500],
["laps_1000", Number(stats.totalLaps || 0), 1000],
["laps_1500", Number(stats.totalLaps || 0), 1500],
["days_3", Number(stats.trainingDays || 0), 3],
["days_10", Number(stats.trainingDays || 0), 10],
["controlled_5", Number(stats.controlledLapStreak || 0), 5],
["strong_finish", Number(stats.strongFinishStints || 0), 1],
["clean_five_minute", Number(stats.cleanFiveMinuteStints || 0), 1],
["five_minute", stats.bestFiveMinuteResult ? 1 : 0, 1],
];
return definitions.map(([id, current, target]) => ({
id,
percent: Math.min(100, Math.max(0, (current / target) * 100)),
unlocked: current >= target,
}));
}
function splitPublicTrainingStints(laps, breakThresholdMs = 2 * 60 * 1000) {
const streams = new Map();
(Array.isArray(laps) ? laps : []).forEach((lap) => {
const normalizedLap = {
...lap,
sessionId: String(lap.sessionId || ""),
sourceId: String(lap.sourceId || lap.driverId || "default"),
timestamp: Number(lap.timestamp || 0),
lapMs: Number(lap.lapMs || 0),
};
const key = `${normalizedLap.sessionId}:${normalizedLap.sourceId}`;
if (!streams.has(key)) streams.set(key, []);
streams.get(key).push(normalizedLap);
});
const stints = [];
streams.forEach((stream) => {
let current = null;
stream.sort((left, right) => left.timestamp - right.timestamp).forEach((lap) => {
const previousLap = current?.laps.at(-1);
const breakMs = previousLap ? Math.max(0, lap.timestamp - previousLap.timestamp) : 0;
const detectedBreak = Boolean(previousLap && breakMs > breakThresholdMs);
const explicitBoundary = Boolean(lap.stintBoundary);
const normalizedLap = {
...lap,
excludedFromPace: Boolean(lap.excludedFromPace || explicitBoundary || (detectedBreak && lap.lapMs > breakThresholdMs)),
stintBoundary: Boolean(explicitBoundary || detectedBreak),
};
if (!current || detectedBreak || explicitBoundary) {
current = { sessionId: lap.sessionId, sourceId: lap.sourceId, laps: [normalizedLap] };
stints.push(current);
} else {
current.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;
if (totalMs > targetMs + Number(lastLap.lapMs || 0)) continue;
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,
};