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:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user