Expose guided training exercises on the driver's mobile profile link

"Jämna varv" and "Stegträning" previously only lived on the admin
Training page. A driver's own personal mobile profile link now offers
the same self-service configure/start/stop flow already used for the
5-minute practice timer there, with live progress computed server-side
from the driver's own share token so raw lap rows and driver IDs never
reach the client.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-09 09:37:38 +02:00
parent 01e1165748
commit 2846b4c0fe
9 changed files with 573 additions and 6 deletions

View File

@@ -62,7 +62,7 @@ function filterPublicTrainingStats(stats, visibility) {
return filtered;
}
function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp = 0, windowMs = 0) {
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);
@@ -98,6 +98,13 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp
.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(),
@@ -110,6 +117,10 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp
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] || {
@@ -205,6 +216,181 @@ function buildPublicTrainingWindowStats(laps) {
};
}
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 {
@@ -529,4 +715,8 @@ module.exports = {
getPublicBestConsecutiveLaps,
getPublicBestDurationResult,
addPublicTrainingMarker,
buildPublicEvenLapsProgress,
buildPublicStepTrainingProgress,
savePublicEvenLapsConfig,
savePublicStepTrainingConfig,
};