A driver completing the practice timer, "Jämna varv", or "Stegträning" now gets a permanent history entry instead of the outcome only existing as a one-off notification - visible on both their mobile profile and the admin Training page, so progress over past sessions is actually reviewable. Each completion earns a prominent emoji-badged credit. The completion is verified server-side against the driver's real recorded laps before being saved, reusing the same progress builders live tracking already uses - the client can only ask for a completion to be checked and recorded, never supply the outcome itself, so a driver can't fabricate a completed session (and the reward tied to it) any more than they could fabricate a lap time. Both per-driver and club-wide goals can now target "complete N sessions of one exercise kind" as an alternative to "N laps", sharing the existing reward/claim mechanism and forms. Fixes a real bug found while building this: a pre-existing two-argument wrapper around getTrainingGoalProgress in app.js silently dropped the new third argument, so every sessions-metric goal displayed 0 progress regardless of how many sessions were actually completed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
818 lines
40 KiB
JavaScript
818 lines
40 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 classExerciseSessions = (Array.isArray(state?.training?.exerciseSessions) ? state.training.exerciseSessions : [])
|
|
.filter((entry) => classDriverIds.has(String(entry?.driverId || "")) && String(entry?.classId || "") === classId)
|
|
.sort((left, right) => Number(right?.startedAt || 0) - Number(left?.startedAt || 0));
|
|
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,
|
|
goal?.metric === "sessions" ? classExerciseSessions.filter((entry) => entry.kind === goal.exerciseKind).length : 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),
|
|
exerciseSessionHistory: visibility.history
|
|
? classExerciseSessions.slice(0, 20).map((entry) => ({ kind: entry.kind, completedAt: entry.completedAt, summary: entry.summary }))
|
|
: [],
|
|
};
|
|
});
|
|
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, exerciseSessionHistory: [],
|
|
};
|
|
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,
|
|
exerciseSessionHistory: activeClass.exerciseSessionHistory,
|
|
};
|
|
}
|
|
|
|
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, currentValue) {
|
|
const isSessions = goal?.metric === "sessions";
|
|
const target = Math.max(1, Math.round(Number((isSessions ? goal?.targetSessions : goal?.targetLaps) || 1) || 1));
|
|
return {
|
|
title: String(goal?.title || "").trim(),
|
|
reward: String(goal?.reward || "").trim(),
|
|
metric: isSessions ? "sessions" : "laps",
|
|
exerciseKind: isSessions ? String(goal?.exerciseKind || "") : "",
|
|
targetLaps: target,
|
|
currentLaps: currentValue,
|
|
remainingLaps: Math.max(0, target - currentValue),
|
|
percent: Math.min(100, Math.max(0, (currentValue / target) * 100)),
|
|
reached: currentValue >= target,
|
|
claimed: Boolean(goal?.claimedAt),
|
|
};
|
|
}
|
|
|
|
// The driver's own browser is the one deciding *when* to call this (the moment it sees its own
|
|
// live progress cross the target), but never what the outcome was: every field of `summary`
|
|
// below is recomputed here from the real ledger laps using the same builders the live progress
|
|
// endpoints use, not trusted from the client - a driver can't fabricate a completed session (and
|
|
// the reward that might be tied to one) any more than they could fabricate a lap time today.
|
|
function recordPublicExerciseSession(state, shareToken, input, now = Date.now()) {
|
|
const classDriver = resolvePublicClassDriver(state, shareToken, input?.classId);
|
|
if (!classDriver) return null;
|
|
const classId = String(input.classId);
|
|
const kind = ["timer", "evenLaps", "stepTraining"].includes(String(input?.kind || "")) ? String(input.kind) : "";
|
|
const startedAgoMs = Math.max(0, Math.round(Number(input?.startedAgoMs || 0)));
|
|
if (!kind || !startedAgoMs) return null;
|
|
const startedAt = now - startedAgoMs;
|
|
|
|
const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : [];
|
|
const classLaps = ledger
|
|
.filter((entry) => String(entry?.driverId || "") === classDriver.id && 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)),
|
|
excludedFromPace: Boolean(entry.excludedFromPace),
|
|
}))
|
|
.sort((left, right) => left.timestamp - right.timestamp);
|
|
|
|
let summary = null;
|
|
if (kind === "timer") {
|
|
const windowMs = Math.max(0, Math.round(Number(input?.windowMs || 0)));
|
|
if (!windowMs || startedAgoMs < windowMs) return null;
|
|
const windowStats = buildPublicTrainingWindowStats(classLaps.filter((lap) => lap.timestamp >= startedAt && lap.timestamp <= startedAt + windowMs));
|
|
if (!windowStats.laps) return null;
|
|
summary = { laps: windowStats.laps, bestLapMs: windowStats.bestLapMs, averageLapMs: windowStats.averageLapMs };
|
|
} else if (kind === "evenLaps") {
|
|
const config = sanitizePublicEvenLapsConfig(
|
|
(Array.isArray(state?.training?.evenLaps) ? state.training.evenLaps : []).find((entry) => String(entry?.driverId || "") === classDriver.id && String(entry?.classId || "") === classId)
|
|
);
|
|
if (!config) return null;
|
|
const progress = buildPublicEvenLapsProgress(classLaps, [], config, startedAt);
|
|
if (!progress || progress.currentStreak < config.lapCount) return null;
|
|
summary = { targetMs: config.targetMs, toleranceMs: config.toleranceMs, lapCount: config.lapCount, streak: progress.currentStreak };
|
|
} else if (kind === "stepTraining") {
|
|
const config = sanitizePublicStepTrainingConfig(
|
|
(Array.isArray(state?.training?.stepTraining) ? state.training.stepTraining : []).find((entry) => String(entry?.driverId || "") === classDriver.id && String(entry?.classId || "") === classId)
|
|
);
|
|
if (!config) return null;
|
|
const progress = buildPublicStepTrainingProgress(classLaps, config, startedAt);
|
|
if (!progress || !progress.complete) return null;
|
|
summary = { totalSteps: progress.totalSteps, totalLaps: progress.totalLaps, controlledComparison: progress.controlledComparison };
|
|
}
|
|
if (!summary) return null;
|
|
|
|
if (!state.training || typeof state.training !== "object") state.training = {};
|
|
state.training.exerciseSessions = Array.isArray(state.training.exerciseSessions) ? state.training.exerciseSessions : [];
|
|
const existing = state.training.exerciseSessions.find((entry) =>
|
|
entry.driverId === classDriver.id && entry.classId === classId && entry.kind === kind && Math.abs(Number(entry.startedAt || 0) - startedAt) < 5000
|
|
);
|
|
if (existing) return existing;
|
|
|
|
const record = {
|
|
id: "exercise_session_public_" + Date.now() + "_" + Math.random().toString(36).slice(2, 9),
|
|
driverId: String(classDriver.id),
|
|
classId,
|
|
kind,
|
|
startedAt,
|
|
completedAt: new Date(now).toISOString(),
|
|
summary,
|
|
};
|
|
state.training.exerciseSessions.push(record);
|
|
return record;
|
|
}
|
|
|
|
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,
|
|
recordPublicExerciseSession,
|
|
};
|