When a driver's personal 5-minute practice timer finishes, a "Pass avslutat" card now appears immediately: how this pass's pace and consistency compared to their recent sessions, any goal or lap-count milestone crossed during the pass with its reward text made prominent, and one suggested next goal (reusing the existing pace recommendation, falling back to the next milestone when there isn't enough data yet). Scoped to the driver's current class, and explicit about insufficient sample sizes instead of showing a misleading number. Adds a per-session consistency calculation to buildPublicTrainingWindowStats server-side; everything else needed (stats, goals, recommendation, milestone) was already sent to the client, so the summary itself is computed there from data already in hand - no raw laps or driver IDs added to what the public endpoint exposes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
734 lines
35 KiB
JavaScript
734 lines
35 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, paceBuckets: stats.paceBuckets,
|
|
});
|
|
if (visibility.history) filtered.dailySummaries = stats.dailySummaries;
|
|
return filtered;
|
|
}
|
|
|
|
function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp = 0, windowMs = 0, evenLapsSince = 0, stepTrainingSince = 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 classMarkers = (Array.isArray(state?.training?.markers) ? state.training.markers : []).filter((marker) => classDriverIds.has(String(marker?.driverId || "")) && String(marker?.classId || "") === classId);
|
|
stats.paceBuckets = buildPublicTrainingPaceBuckets(classLaps, classMarkers);
|
|
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))];
|
|
const evenLapsConfig = sanitizePublicEvenLapsConfig(
|
|
(Array.isArray(state?.training?.evenLaps) ? state.training.evenLaps : []).find((entry) => classDriverIds.has(String(entry?.driverId || "")) && String(entry?.classId || "") === classId)
|
|
);
|
|
const stepTrainingConfig = sanitizePublicStepTrainingConfig(
|
|
(Array.isArray(state?.training?.stepTraining) ? state.training.stepTraining : []).find((entry) => classDriverIds.has(String(entry?.driverId || "")) && String(entry?.classId || "") === classId)
|
|
);
|
|
const todayLaps = classLaps.filter((lap) => isPublicTrainingToday(lap.timestamp));
|
|
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,
|
|
evenLapsConfig,
|
|
evenLapsProgress: buildPublicEvenLapsProgress(classLaps, todayLaps, evenLapsConfig, evenLapsSince),
|
|
stepTrainingConfig,
|
|
stepTrainingProgress: buildPublicStepTrainingProgress(classLaps, stepTrainingConfig, stepTrainingSince),
|
|
};
|
|
});
|
|
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: publicTrainingAverage(lapTimes),
|
|
// A spread needs a handful of laps to mean anything - fewer than that, report it as
|
|
// unavailable (0 alongside consistencySampleReady:false) rather than a misleading number.
|
|
consistencySpreadPct: lapTimes.length >= 5 ? publicTrainingSpread(lapTimes) : 0,
|
|
consistencySampleReady: lapTimes.length >= 5,
|
|
};
|
|
}
|
|
|
|
function isPublicTrainingToday(timestamp) {
|
|
const date = new Date(Number(timestamp || 0));
|
|
if (Number.isNaN(date.getTime())) return false;
|
|
const now = new Date();
|
|
return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth() && date.getDate() === now.getDate();
|
|
}
|
|
|
|
function sanitizePublicEvenLapsConfig(entry) {
|
|
if (!entry) return null;
|
|
return {
|
|
targetMs: Math.max(0, Math.round(Number(entry.targetMs || 0))),
|
|
toleranceMs: Math.max(0, Math.round(Number(entry.toleranceMs || 0))),
|
|
lapCount: Math.max(1, Math.round(Number(entry.lapCount || 5))),
|
|
};
|
|
}
|
|
|
|
function sanitizePublicStepTrainingConfig(entry) {
|
|
if (!entry || !Array.isArray(entry.steps) || !entry.steps.length) return null;
|
|
return {
|
|
steps: entry.steps.map((step) => ({
|
|
effort: ["calm", "controlled", "fast"].includes(String(step?.effort || "")) ? String(step.effort) : "calm",
|
|
lapCount: Math.max(0, Math.round(Number(step?.lapCount || 0) || 0)),
|
|
})).filter((step) => step.lapCount > 0),
|
|
};
|
|
}
|
|
|
|
// Mirrors buildEvenLapsProgress in src/training_logic.js (the admin implementation), but
|
|
// takes an already-sanitized, class-scoped lap array instead of doing its own state/session
|
|
// lookups: server/public_training_profile.js never sends individual lap rows to the client,
|
|
// only the computed progress, so this must stay a server-side re-implementation rather than
|
|
// a shared import from the browser-only admin module.
|
|
function buildPublicEvenLapsProgress(classLaps, todayLaps, config, sinceTimestamp) {
|
|
if (!config || !(config.targetMs > 0)) return null;
|
|
const withinTolerance = (lapMs) => Math.abs(lapMs - config.targetMs) <= config.toleranceMs;
|
|
const longestStreak = (laps) => {
|
|
let longest = 0;
|
|
let current = 0;
|
|
laps.forEach((lap) => {
|
|
if (!lap.excludedFromPace && withinTolerance(lap.lapMs)) {
|
|
current += 1;
|
|
longest = Math.max(longest, current);
|
|
} else {
|
|
current = 0;
|
|
}
|
|
});
|
|
return longest;
|
|
};
|
|
const liveLaps = sinceTimestamp ? classLaps.filter((lap) => lap.timestamp >= sinceTimestamp) : [];
|
|
let currentStreak = 0;
|
|
for (let index = liveLaps.length - 1; index >= 0; index -= 1) {
|
|
if (!liveLaps[index].excludedFromPace && withinTolerance(liveLaps[index].lapMs)) currentStreak += 1;
|
|
else break;
|
|
}
|
|
const bestStreakToday = Math.max(longestStreak(todayLaps), currentStreak);
|
|
const lastLap = liveLaps.at(-1) || null;
|
|
return {
|
|
active: Boolean(sinceTimestamp),
|
|
targetMs: config.targetMs,
|
|
toleranceMs: config.toleranceMs,
|
|
lapCount: config.lapCount,
|
|
currentStreak,
|
|
bestStreakToday,
|
|
lastLapMs: lastLap ? lastLap.lapMs : null,
|
|
lastLapWithinTolerance: lastLap ? (!lastLap.excludedFromPace && withinTolerance(lastLap.lapMs)) : null,
|
|
};
|
|
}
|
|
|
|
// Mirrors buildStepTrainingProgress in src/training_logic.js - see the note above
|
|
// buildPublicEvenLapsProgress for why this is a separate server-side implementation.
|
|
function buildPublicStepTrainingProgress(classLaps, config, sinceTimestamp) {
|
|
const steps = config?.steps || [];
|
|
if (!steps.length) return null;
|
|
const liveLaps = sinceTimestamp ? classLaps.filter((lap) => lap.timestamp >= sinceTimestamp) : [];
|
|
const totalLaps = steps.reduce((sum, step) => sum + step.lapCount, 0);
|
|
let cursor = 0;
|
|
const stepStates = steps.map((step, index) => {
|
|
const stepLaps = liveLaps.slice(cursor, cursor + step.lapCount);
|
|
cursor += step.lapCount;
|
|
const paceValues = stepLaps.filter((lap) => !lap.excludedFromPace).map((lap) => lap.lapMs);
|
|
return {
|
|
index,
|
|
effort: step.effort,
|
|
lapCount: step.lapCount,
|
|
lapsCompleted: stepLaps.length,
|
|
done: stepLaps.length >= step.lapCount,
|
|
averageLapMs: paceValues.length ? Math.round(paceValues.reduce((sum, value) => sum + value, 0) / paceValues.length) : null,
|
|
};
|
|
});
|
|
const complete = liveLaps.length >= totalLaps;
|
|
const currentStepIndex = complete ? steps.length - 1 : stepStates.findIndex((step) => !step.done);
|
|
const currentStepState = stepStates[currentStepIndex] || null;
|
|
const controlledSteps = stepStates.filter((step) => step.effort === "controlled" && step.averageLapMs != null);
|
|
const controlledComparison = controlledSteps.length >= 2
|
|
? {
|
|
firstAvgMs: controlledSteps[0].averageLapMs,
|
|
lastAvgMs: controlledSteps.at(-1).averageLapMs,
|
|
diffMs: controlledSteps.at(-1).averageLapMs - controlledSteps[0].averageLapMs,
|
|
}
|
|
: null;
|
|
return {
|
|
active: Boolean(sinceTimestamp),
|
|
totalSteps: steps.length,
|
|
totalLaps,
|
|
completedLaps: Math.min(liveLaps.length, totalLaps),
|
|
complete,
|
|
currentStepIndex,
|
|
currentStep: currentStepState && {
|
|
index: currentStepState.index,
|
|
effort: currentStepState.effort,
|
|
lapCount: currentStepState.lapCount,
|
|
lapsCompleted: currentStepState.lapsCompleted,
|
|
lapsRemaining: Math.max(0, currentStepState.lapCount - currentStepState.lapsCompleted),
|
|
},
|
|
steps: stepStates,
|
|
controlledComparison,
|
|
};
|
|
}
|
|
|
|
function resolvePublicClassDriver(state, shareToken, classId) {
|
|
const share = findPublicTrainingShare(state, shareToken);
|
|
if (!share) return null;
|
|
const drivers = Array.isArray(state?.drivers) ? state.drivers : [];
|
|
const profile = getPublicDriverProfile(drivers, share.driverId);
|
|
return profile?.drivers.find((driver) => String(driver?.classId || "") === String(classId || "")) || null;
|
|
}
|
|
|
|
function savePublicEvenLapsConfig(state, shareToken, input) {
|
|
const classDriver = resolvePublicClassDriver(state, shareToken, input?.classId);
|
|
if (!classDriver) return null;
|
|
const classId = String(input.classId);
|
|
const targetMs = Math.max(0, Math.round(Number(input?.targetMs || 0)));
|
|
const toleranceMs = Math.max(0, Math.round(Number(input?.toleranceMs || 0)));
|
|
const lapCount = Math.max(1, Math.round(Number(input?.lapCount || 0) || 5));
|
|
if (!(targetMs > 0)) return null;
|
|
if (!state.training || typeof state.training !== "object") state.training = {};
|
|
state.training.evenLaps = Array.isArray(state.training.evenLaps) ? state.training.evenLaps : [];
|
|
const existing = state.training.evenLaps.find((entry) => String(entry?.driverId || "") === String(classDriver.id) && String(entry?.classId || "") === classId);
|
|
const updatedAt = new Date().toISOString();
|
|
if (existing) {
|
|
existing.targetMs = targetMs;
|
|
existing.toleranceMs = toleranceMs;
|
|
existing.lapCount = lapCount;
|
|
existing.updatedAt = updatedAt;
|
|
return existing;
|
|
}
|
|
const entry = { id: "even_laps_public_" + Date.now() + "_" + Math.random().toString(36).slice(2, 9), driverId: String(classDriver.id), classId, targetMs, toleranceMs, lapCount, updatedAt };
|
|
state.training.evenLaps.push(entry);
|
|
return entry;
|
|
}
|
|
|
|
function savePublicStepTrainingConfig(state, shareToken, input) {
|
|
const classDriver = resolvePublicClassDriver(state, shareToken, input?.classId);
|
|
if (!classDriver) return null;
|
|
const classId = String(input.classId);
|
|
const steps = Array.isArray(input?.steps)
|
|
? input.steps.map((step) => ({
|
|
effort: ["calm", "controlled", "fast"].includes(String(step?.effort || "")) ? String(step.effort) : "calm",
|
|
lapCount: Math.max(0, Math.round(Number(step?.lapCount || 0) || 0)),
|
|
})).filter((step) => step.lapCount > 0)
|
|
: [];
|
|
if (!steps.length) return null;
|
|
if (!state.training || typeof state.training !== "object") state.training = {};
|
|
state.training.stepTraining = Array.isArray(state.training.stepTraining) ? state.training.stepTraining : [];
|
|
const existing = state.training.stepTraining.find((entry) => String(entry?.driverId || "") === String(classDriver.id) && String(entry?.classId || "") === classId);
|
|
const updatedAt = new Date().toISOString();
|
|
if (existing) {
|
|
existing.steps = steps;
|
|
existing.updatedAt = updatedAt;
|
|
return existing;
|
|
}
|
|
const entry = { id: "step_training_public_" + Date.now() + "_" + Math.random().toString(36).slice(2, 9), driverId: String(classDriver.id), classId, steps, updatedAt };
|
|
state.training.stepTraining.push(entry);
|
|
return entry;
|
|
}
|
|
|
|
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 buildPublicTrainingPaceBuckets(laps, markers, bucketMinutes = 30) {
|
|
const bucketMs = Math.max(5, Number(bucketMinutes || 30)) * 60 * 1000;
|
|
const buckets = new Map();
|
|
laps.filter((lap) => !lap.excludedFromPace).forEach((lap) => {
|
|
const date = new Date(lap.timestamp);
|
|
const minuteBlock = bucketMs / 60000;
|
|
const startTs = new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), Math.floor(date.getMinutes() / minuteBlock) * minuteBlock, 0, 0).getTime();
|
|
const bucket = buckets.get(startTs) || { startTs, values: [] };
|
|
bucket.values.push(lap.lapMs);
|
|
buckets.set(startTs, bucket);
|
|
});
|
|
return [...buckets.values()].sort((left, right) => right.startTs - left.startTs).slice(0, 24).map((bucket) => {
|
|
const sorted = [...bucket.values].sort((left, right) => left - right);
|
|
const trim = sorted.length >= 5 ? Math.max(1, Math.floor(sorted.length * 0.1)) : 0;
|
|
const kept = trim ? sorted.slice(trim, sorted.length - trim) : sorted;
|
|
return {
|
|
startTs: bucket.startTs,
|
|
lapCount: bucket.values.length,
|
|
paceMs: kept.length ? Math.round(kept.reduce((sum, value) => sum + value, 0) / kept.length) : 0,
|
|
bestLapMs: bucket.values.length ? Math.min(...bucket.values) : 0,
|
|
markerCount: markers.filter((marker) => Number(marker?.timestamp || 0) >= bucket.startTs && Number(marker?.timestamp || 0) < bucket.startTs + bucketMs).length,
|
|
};
|
|
});
|
|
}
|
|
|
|
function publicTrainingAverage(values) {
|
|
return values.length ? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length) : 0;
|
|
}
|
|
|
|
function publicTrainingPercentile(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;
|
|
}
|
|
|
|
function publicTrainingSpread(values) {
|
|
const mean = publicTrainingAverage(values);
|
|
return mean ? ((publicTrainingPercentile(values, 0.9) - publicTrainingPercentile(values, 0.1)) / mean) * 100 : 0;
|
|
}
|
|
|
|
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 = publicTrainingAverage;
|
|
const spread = publicTrainingSpread;
|
|
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 = publicTrainingPercentile(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 addPublicTrainingMarker(state, shareToken, input, timestamp = Date.now()) {
|
|
const share = findPublicTrainingShare(state, shareToken);
|
|
if (!share) return null;
|
|
const drivers = Array.isArray(state?.drivers) ? state.drivers : [];
|
|
const profile = getPublicDriverProfile(drivers, share.driverId);
|
|
const classId = String(input?.classId || "");
|
|
const classDriver = profile?.drivers.find((driver) => String(driver?.classId || "") === classId);
|
|
if (!classDriver) return null;
|
|
const type = ["setup", "tires", "suspension", "car", "note"].includes(String(input?.type || "")) ? String(input.type) : "car";
|
|
const marker = {
|
|
id: "training_marker_public_" + Math.round(timestamp) + "_" + Math.random().toString(36).slice(2, 9),
|
|
driverId: String(classDriver.id || ""), classId, sessionId: "", timestamp: Math.round(timestamp), type,
|
|
note: String(input?.note || "").trim().slice(0, 160), createdAt: new Date(timestamp).toISOString(),
|
|
};
|
|
if (!state.training || typeof state.training !== "object") state.training = {};
|
|
state.training.markers = [...(Array.isArray(state.training.markers) ? state.training.markers : []), marker];
|
|
return marker;
|
|
}
|
|
|
|
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,
|
|
addPublicTrainingMarker,
|
|
buildPublicEvenLapsProgress,
|
|
buildPublicStepTrainingProgress,
|
|
savePublicEvenLapsConfig,
|
|
savePublicStepTrainingConfig,
|
|
};
|