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:
@@ -8,6 +8,12 @@ The application version is maintained in `package.json` and displayed in the adm
|
||||
|
||||
No changes recorded yet.
|
||||
|
||||
## 1.4.0 - 2026-09-09
|
||||
|
||||
### Added
|
||||
|
||||
- "Jämna varv" and "Stegträning" can now be configured, started and stopped directly from the driver's own personal mobile profile link, the same way the existing self-service practice timer already works there - independent of whether an admin has the Training page open. Live progress (streak, today's best, tolerance-based lap coloring, step pills) is computed server-side from the driver's own share token, so raw lap rows and driver IDs never reach the mobile page - only sanitized aggregates, matching the existing public-profile security boundary. Each exercise tracks its own elapsed time independently of the general 5-minute practice timer and of each other.
|
||||
|
||||
## 1.3.0 - 2026-09-09
|
||||
|
||||
### Added
|
||||
|
||||
@@ -75,7 +75,7 @@ Acceptance: a driver receives a short, encouraging summary based on their own cl
|
||||
|
||||
### Guided practice exercises
|
||||
|
||||
Acceptance: two structured, configurable exercises run inside an already-active Open Practice session, per driver and class, without stopping other drivers or creating a new session. Named for what they help a driver do, not for who supposedly likes them - "Jämna varv" ("Even laps", formerly "Konsistens-jakten") and "Stegträning" ("Step training", formerly "Pyramiden") - and focused on succeeding rather than performing perfectly.
|
||||
Acceptance: two structured, configurable exercises run inside an already-active Open Practice session, per driver and class, without stopping other drivers or creating a new session. Named for what they help a driver do, not for who supposedly likes them - "Jämna varv" ("Even laps", formerly "Konsistens-jakten") and "Stegträning" ("Step training", formerly "Pyramiden") - and focused on succeeding rather than performing perfectly. Available both from the admin Training page and from the driver's own personal mobile profile link, so the driver can configure, start and stop their own exercise independently of whether an admin session has it open.
|
||||
|
||||
**Jämna varv (even laps):** implemented 2026-09-09.
|
||||
- [x] Driver configures target lap time, tolerance and lap count; suggested default 5 laps / ±0.5s - 15 laps / 0.2s is frustrating for a beginner, but stays adjustable for anyone who wants it stricter.
|
||||
@@ -94,6 +94,13 @@ Verified 2026-09-09: `buildEvenLapsProgress` in `src/training_logic.js` (unit-te
|
||||
|
||||
Verified 2026-09-09: `buildStepTrainingProgress` in `src/training_logic.js` (unit-tested for step advancement, pace-exclusion handling, and the controlled-step comparison) plus a manual browser check covering the default pyramid, mid-exercise progress, reconfiguring to a shorter sequence, and the completed-sequence comparison text.
|
||||
|
||||
**Mobile profile availability:** implemented 2026-09-09.
|
||||
- [x] Both exercises can be configured, started and stopped directly from the driver's own personal mobile profile link (`public-training.html`), the same way the existing self-service practice timer already works there - independent of whether an admin has the Training page open.
|
||||
- [x] Progress (current streak/step, today's best, tolerance-based lap coloring, step pills) is shown live on the mobile page itself, computed server-side from the driver's own share token so raw lap rows and driver IDs never reach the client - only sanitized aggregates, matching the existing public-profile security boundary.
|
||||
- [x] The exercise's own elapsed time is tracked independently of the general 5-minute practice timer and of each other (separate `evenLapsStartedAgoMs`/`stepTrainingStartedAgoMs`, mirroring the existing `startedAgoMs` clock-skew-safe pattern) so starting/stopping one does not affect the other or the general timer.
|
||||
|
||||
Verified 2026-09-09: unit tests in `tests/public_training_profile.test.js` cover `savePublicEvenLapsConfig`/`savePublicStepTrainingConfig` (auth via share token, class-membership checks, invalid-input rejection) and the sanitized progress builders, including an explicit assertion that driver IDs never appear in the serialized response. A full end-to-end headless-browser run against an isolated temp SQLite instance (never the installation database) exercised the real mobile page: saving a config via the new `POST /public-training/api/:shareToken/even-laps` and `/step-training` endpoints, starting/stopping via the UI buttons, and confirming live progress (streak count, tolerance-based lap coloring, step pills) renders correctly from seeded lap data - with zero uncaught JS errors and zero raw driverId/transponder leakage in the API response.
|
||||
|
||||
## 4. Results archive
|
||||
|
||||
- [ ] Search completed sessions by date, class and driver without Race Setup.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "rc-racecore",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rc-racecore",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.11.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rc-racecore",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence",
|
||||
"engines": {
|
||||
|
||||
38
server.js
38
server.js
@@ -360,7 +360,11 @@ app.get("/public-training/api/:shareToken", (req, res) => {
|
||||
const stored = readStoredAppState();
|
||||
const sinceTimestamp = publicTrainingProfile.normalizePublicTrainingSince(req.query.since, req.query.startedAgoMs);
|
||||
const windowMs = publicTrainingProfile.normalizePublicTrainingWindow(req.query.windowMs);
|
||||
const profile = stored ? publicTrainingProfile.buildPublicTrainingProfile(stored.state, String(req.params.shareToken || ""), stored.updatedAt, sinceTimestamp, windowMs) : null;
|
||||
// Separate "since" markers for the two guided exercises: a driver may run one, both, or
|
||||
// neither independently of the general practice timer above, each started at its own time.
|
||||
const evenLapsSince = publicTrainingProfile.normalizePublicTrainingSince(req.query.evenLapsSince, req.query.evenLapsStartedAgoMs);
|
||||
const stepTrainingSince = publicTrainingProfile.normalizePublicTrainingSince(req.query.stepTrainingSince, req.query.stepTrainingStartedAgoMs);
|
||||
const profile = stored ? publicTrainingProfile.buildPublicTrainingProfile(stored.state, String(req.params.shareToken || ""), stored.updatedAt, sinceTimestamp, windowMs, evenLapsSince, stepTrainingSince) : null;
|
||||
if (!profile) {
|
||||
res.status(404).json({ error: "Profile not found" });
|
||||
return;
|
||||
@@ -388,6 +392,38 @@ app.post("/public-training/api/:shareToken/markers", (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/public-training/api/:shareToken/even-laps", (req, res) => {
|
||||
try {
|
||||
const stored = readStoredAppState();
|
||||
const entry = stored ? publicTrainingProfile.savePublicEvenLapsConfig(stored.state, String(req.params.shareToken || ""), req.body || {}) : null;
|
||||
if (!entry) {
|
||||
res.status(404).json({ error: "Profile, class, or configuration not found" });
|
||||
return;
|
||||
}
|
||||
const nowIso = new Date().toISOString();
|
||||
db.prepare("INSERT INTO app_state (id, state_json, updated_at) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json, updated_at = excluded.updated_at").run(JSON.stringify(stored.state), nowIso);
|
||||
res.status(201).json({ ok: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/public-training/api/:shareToken/step-training", (req, res) => {
|
||||
try {
|
||||
const stored = readStoredAppState();
|
||||
const entry = stored ? publicTrainingProfile.savePublicStepTrainingConfig(stored.state, String(req.params.shareToken || ""), req.body || {}) : null;
|
||||
if (!entry) {
|
||||
res.status(404).json({ error: "Profile, class, or configuration not found" });
|
||||
return;
|
||||
}
|
||||
const nowIso = new Date().toISOString();
|
||||
db.prepare("INSERT INTO app_state (id, state_json, updated_at) VALUES (1, ?, ?) ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json, updated_at = excluded.updated_at").run(JSON.stringify(stored.state), nowIso);
|
||||
res.status(201).json({ ok: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/public-training/qr/:shareToken", async (req, res) => {
|
||||
try {
|
||||
const stored = readStoredAppState();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -436,6 +436,64 @@ button {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.is-positive {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.is-negative {
|
||||
color: #ff8f89;
|
||||
}
|
||||
|
||||
.profile-step-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin: 10px 0 12px;
|
||||
}
|
||||
|
||||
.profile-step-pill {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.profile-step-pill.is-current {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.profile-step-pill.is-done {
|
||||
border-color: color-mix(in srgb, var(--green) 55%, var(--line) 45%);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.profile-steptraining-form {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.profile-steptraining-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 1fr) 90px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-steptraining-row select,
|
||||
.profile-steptraining-row input {
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.profile-progress {
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
@@ -1164,6 +1222,15 @@ button {
|
||||
padding: 7px 8px;
|
||||
}
|
||||
|
||||
.profile-steptraining-row select,
|
||||
.profile-steptraining-row input {
|
||||
padding: 7px 8px;
|
||||
}
|
||||
|
||||
.profile-step-pill {
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.profile-timer {
|
||||
margin: 8px 0;
|
||||
font-size: clamp(1.8rem, 8vw, 2.4rem);
|
||||
|
||||
@@ -79,6 +79,21 @@ const messages = {
|
||||
markerSetup: "Ny setup", markerTires: "Däck", markerSuspension: "Dämpare", markerCar: "Bil", markerNoteType: "Anteckning",
|
||||
markerSaved: "Biländringen är sparad.", markerError: "Kunde inte spara biländringen.",
|
||||
blockLaps: "{laps} varv", blockMarkers: "{count} ändring(ar)",
|
||||
evenLapsTitle: "Jämna varv", evenLapsEyebrow: "Övning",
|
||||
evenLapsHint: "Ställ in måltid och tolerans, spara, och starta när du är redo att köra.",
|
||||
evenLapsTargetLabel: "Måltid (sek)", evenLapsToleranceLabel: "Tolerans ± (sek)", evenLapsLapCountLabel: "Antal varv i serien",
|
||||
evenLapsSuggestionHint: "Förslag baserat på ditt bästa varv: {lap}",
|
||||
evenLapsStreak: "{current}/{target} jämna varv", evenLapsNewStreak: "Ny serie – försök slå din bästa",
|
||||
evenLapsStartHint: "Kör ditt första varv för att starta en serie.",
|
||||
evenLapsBestTodayLabel: "Bästa serie idag", evenLapsNotStarted: "Inte startad ännu.",
|
||||
stepTrainingTitle: "Stegträning", stepTrainingEyebrow: "Övning",
|
||||
stepTrainingHint: "Bygg din stegserie, spara, och starta när du är redo. Standard är pyramiden: lugnt, kontrollerat, snabbt, kontrollerat, lugnt.",
|
||||
stepTrainingComplete: "Serien klar – bra jobbat!",
|
||||
stepTrainingCurrent: "Steg {step}/{total}: {effort}", stepTrainingRemaining: "{laps} varv kvar",
|
||||
stepTrainingCompare: "Kontrollerat steg 1: {first} · Kontrollerat steg 2: {last} ({diff})",
|
||||
stepTrainingNotStarted: "Inte startad ännu.",
|
||||
effortCalm: "Lugnt", effortControlled: "Kontrollerat", effortFast: "Snabbt",
|
||||
saveSetup: "Spara", stop: "Stoppa",
|
||||
stints: "Stints",
|
||||
bestThree: "Bästa 3 sammanhängande",
|
||||
bestFive: "Bästa 5 min",
|
||||
@@ -206,6 +221,21 @@ const messages = {
|
||||
markerSetup: "New setup", markerTires: "Tires", markerSuspension: "Suspension", markerCar: "Car", markerNoteType: "Note",
|
||||
markerSaved: "The car change was saved.", markerError: "The car change could not be saved.",
|
||||
blockLaps: "{laps} laps", blockMarkers: "{count} change(s)",
|
||||
evenLapsTitle: "Even laps", evenLapsEyebrow: "Exercise",
|
||||
evenLapsHint: "Set a target and tolerance, save, and start when you're ready to run.",
|
||||
evenLapsTargetLabel: "Target (sec)", evenLapsToleranceLabel: "Tolerance ± (sec)", evenLapsLapCountLabel: "Laps in the streak",
|
||||
evenLapsSuggestionHint: "Suggested from your best lap: {lap}",
|
||||
evenLapsStreak: "{current}/{target} even laps", evenLapsNewStreak: "New streak - try to beat your best",
|
||||
evenLapsStartHint: "Run your first lap to start a streak.",
|
||||
evenLapsBestTodayLabel: "Best streak today", evenLapsNotStarted: "Not started yet.",
|
||||
stepTrainingTitle: "Step training", stepTrainingEyebrow: "Exercise",
|
||||
stepTrainingHint: "Build your step sequence, save, and start when ready. The default is the pyramid: calm, controlled, fast, controlled, calm.",
|
||||
stepTrainingComplete: "Sequence complete - nice work!",
|
||||
stepTrainingCurrent: "Step {step}/{total}: {effort}", stepTrainingRemaining: "{laps} laps remaining",
|
||||
stepTrainingCompare: "Controlled step 1: {first} · Controlled step 2: {last} ({diff})",
|
||||
stepTrainingNotStarted: "Not started yet.",
|
||||
effortCalm: "Calm", effortControlled: "Controlled", effortFast: "Fast",
|
||||
saveSetup: "Save", stop: "Stop",
|
||||
stints: "Stints",
|
||||
bestThree: "Best 3 consecutive",
|
||||
bestFive: "Best 5 min",
|
||||
@@ -387,6 +417,20 @@ function renderProfile(data) {
|
||||
const recommendation = activeClass?.recommendation || data.recommendation;
|
||||
const goals = activeClass?.goals || data.goals || [];
|
||||
const currentSession = activeClass?.currentSession || data.currentSession;
|
||||
const evenLapsConfig = activeClass?.evenLapsConfig || null;
|
||||
const evenLapsProgress = activeClass?.evenLapsProgress || null;
|
||||
const evenLapsSuggestedTargetMs = stats.bestLapMs ? Math.round(stats.bestLapMs * 1.05) : 0;
|
||||
const evenLapsRunning = Boolean(readExerciseRunState("evenlaps"));
|
||||
const stepTrainingConfig = activeClass?.stepTrainingConfig || null;
|
||||
const stepTrainingProgress = activeClass?.stepTrainingProgress || null;
|
||||
const stepTrainingRunning = Boolean(readExerciseRunState("steptraining"));
|
||||
const defaultStepTrainingRows = [
|
||||
{ effort: "calm", lapCount: 5 }, { effort: "controlled", lapCount: 5 }, { effort: "fast", lapCount: 5 },
|
||||
{ effort: "controlled", lapCount: 5 }, { effort: "calm", lapCount: 5 },
|
||||
];
|
||||
const stepTrainingFormRows = stepTrainingConfig?.steps?.length
|
||||
? [...stepTrainingConfig.steps, ...Array(Math.max(0, 5 - stepTrainingConfig.steps.length)).fill({ effort: "calm", lapCount: 0 })].slice(0, 5)
|
||||
: defaultStepTrainingRows;
|
||||
const motivation = buildProfileMotivation(stats, channelStats);
|
||||
const recommendationText = recommendationCopy(recommendation);
|
||||
const timerState = readTimerState();
|
||||
@@ -555,6 +599,84 @@ function renderProfile(data) {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="profile-card profile-exercise-card" style="margin-top:14px">
|
||||
<div class="profile-card-head"><div><span class="profile-mini-label">${escapeHtml(t("evenLapsEyebrow"))}</span><h2>${escapeHtml(t("evenLapsTitle"))}</h2></div></div>
|
||||
<div class="profile-card-body">
|
||||
${evenLapsConfig && evenLapsProgress?.active ? `
|
||||
<div class="profile-session-heading">
|
||||
${escapeHtml(evenLapsProgress.currentStreak > 0
|
||||
? t("evenLapsStreak", { current: evenLapsProgress.currentStreak, target: evenLapsConfig.lapCount })
|
||||
: evenLapsProgress.bestStreakToday > 0
|
||||
? t("evenLapsNewStreak")
|
||||
: t("evenLapsStartHint"))}
|
||||
</div>
|
||||
<div class="profile-session-stats">
|
||||
<div><span>${escapeHtml(t("evenLapsBestTodayLabel"))}</span><strong>${evenLapsProgress.bestStreakToday}</strong></div>
|
||||
${evenLapsProgress.lastLapMs != null ? `<div><span>${escapeHtml(t("sessionLaps"))}</span><strong class="${evenLapsProgress.lastLapWithinTolerance ? "is-positive" : "is-negative"}">${formatLap(evenLapsProgress.lastLapMs)}</strong></div>` : ""}
|
||||
</div>
|
||||
` : `<p class="profile-empty">${escapeHtml(evenLapsConfig ? t("evenLapsNotStarted") : t("evenLapsHint"))}</p>`}
|
||||
<fieldset id="profileEvenLapsSetup" class="profile-timer-setup">
|
||||
<legend>${escapeHtml(t("evenLapsHint"))}</legend>
|
||||
<label><span>${escapeHtml(t("evenLapsTargetLabel"))}</span><input id="profileEvenLapsTarget" type="number" min="0.1" step="0.1" value="${evenLapsConfig ? (evenLapsConfig.targetMs / 1000).toFixed(1) : evenLapsSuggestedTargetMs ? (evenLapsSuggestedTargetMs / 1000).toFixed(1) : ""}" placeholder="${evenLapsSuggestedTargetMs ? (evenLapsSuggestedTargetMs / 1000).toFixed(1) : ""}" /></label>
|
||||
<label><span>${escapeHtml(t("evenLapsToleranceLabel"))}</span><input id="profileEvenLapsTolerance" type="number" min="0.1" step="0.1" value="${evenLapsConfig ? (evenLapsConfig.toleranceMs / 1000).toFixed(1) : "0.5"}" /></label>
|
||||
<label><span>${escapeHtml(t("evenLapsLapCountLabel"))}</span><input id="profileEvenLapsLapCount" type="number" min="1" step="1" value="${evenLapsConfig ? evenLapsConfig.lapCount : 5}" /></label>
|
||||
</fieldset>
|
||||
${evenLapsSuggestedTargetMs && !evenLapsConfig ? `<small>${escapeHtml(t("evenLapsSuggestionHint", { lap: formatLap(evenLapsSuggestedTargetMs) }))}</small>` : ""}
|
||||
<div class="profile-actions">
|
||||
<button id="profileEvenLapsSave" class="profile-button" type="button">${escapeHtml(t("saveSetup"))}</button>
|
||||
<button id="profileEvenLapsStart" class="profile-button profile-button-primary" type="button" ${evenLapsRunning ? "disabled" : ""}>${escapeHtml(t("start"))}</button>
|
||||
<button id="profileEvenLapsStop" class="profile-button" type="button" ${evenLapsRunning ? "" : "disabled"}>${escapeHtml(t("stop"))}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="profile-card profile-exercise-card" style="margin-top:14px">
|
||||
<div class="profile-card-head"><div><span class="profile-mini-label">${escapeHtml(t("stepTrainingEyebrow"))}</span><h2>${escapeHtml(t("stepTrainingTitle"))}</h2></div></div>
|
||||
<div class="profile-card-body">
|
||||
${stepTrainingConfig && stepTrainingProgress?.active ? `
|
||||
${stepTrainingProgress.complete
|
||||
? `<div class="profile-session-heading"><strong>${escapeHtml(t("stepTrainingComplete"))}</strong></div>`
|
||||
: stepTrainingProgress.currentStep
|
||||
? `<div class="profile-session-heading">
|
||||
${escapeHtml(t("stepTrainingCurrent", { step: stepTrainingProgress.currentStepIndex + 1, total: stepTrainingProgress.totalSteps, effort: t(`effort${stepTrainingProgress.currentStep.effort.charAt(0).toUpperCase()}${stepTrainingProgress.currentStep.effort.slice(1)}`) }))}
|
||||
<strong>${escapeHtml(t("stepTrainingRemaining", { laps: stepTrainingProgress.currentStep.lapsRemaining }))}</strong>
|
||||
</div>`
|
||||
: ""}
|
||||
<div class="profile-step-pills">
|
||||
${stepTrainingProgress.steps.map((step) => `
|
||||
<span class="profile-step-pill ${step.done ? "is-done" : step.index === stepTrainingProgress.currentStepIndex ? "is-current" : ""}">
|
||||
${escapeHtml(t(`effort${step.effort.charAt(0).toUpperCase()}${step.effort.slice(1)}`))} · ${step.lapsCompleted}/${step.lapCount}
|
||||
</span>
|
||||
`).join("")}
|
||||
</div>
|
||||
${stepTrainingProgress.controlledComparison ? `
|
||||
<small>${escapeHtml(t("stepTrainingCompare", {
|
||||
first: formatLap(stepTrainingProgress.controlledComparison.firstAvgMs),
|
||||
last: formatLap(stepTrainingProgress.controlledComparison.lastAvgMs),
|
||||
diff: `${stepTrainingProgress.controlledComparison.diffMs >= 0 ? "+" : ""}${(stepTrainingProgress.controlledComparison.diffMs / 1000).toFixed(2)}s`,
|
||||
}))}</small>
|
||||
` : ""}
|
||||
` : `<p class="profile-empty">${escapeHtml(stepTrainingConfig ? t("stepTrainingNotStarted") : t("stepTrainingHint"))}</p>`}
|
||||
<div class="profile-steptraining-form">
|
||||
${stepTrainingFormRows.map((row, index) => `
|
||||
<div class="profile-steptraining-row">
|
||||
<select id="profileStepEffort${index}">
|
||||
<option value="calm" ${row.effort === "calm" ? "selected" : ""}>${escapeHtml(t("effortCalm"))}</option>
|
||||
<option value="controlled" ${row.effort === "controlled" ? "selected" : ""}>${escapeHtml(t("effortControlled"))}</option>
|
||||
<option value="fast" ${row.effort === "fast" ? "selected" : ""}>${escapeHtml(t("effortFast"))}</option>
|
||||
</select>
|
||||
<input id="profileStepLaps${index}" type="number" min="0" step="1" value="${row.lapCount || ""}" placeholder="0" />
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
<div class="profile-actions">
|
||||
<button id="profileStepTrainingSave" class="profile-button" type="button">${escapeHtml(t("saveSetup"))}</button>
|
||||
<button id="profileStepTrainingStart" class="profile-button profile-button-primary" type="button" ${stepTrainingRunning ? "disabled" : ""}>${escapeHtml(t("start"))}</button>
|
||||
<button id="profileStepTrainingStop" class="profile-button" type="button" ${stepTrainingRunning ? "" : "disabled"}>${escapeHtml(t("stop"))}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<details class="profile-card profile-disclosure profile-performance ${visibility.performance ? "" : "is-hidden"}" data-persist-open="performance" ${openSections.has("performance") ? "open" : ""} style="margin-top:14px">
|
||||
<summary class="profile-card-head"><h2>${escapeHtml(t("performance"))}</h2><span>+</span></summary>
|
||||
<form id="publicTrainingMarkerForm" class="profile-marker-form" data-class-id="${escapeHtml(selectedClassId)}">
|
||||
@@ -638,6 +760,7 @@ function renderProfile(data) {
|
||||
});
|
||||
bindTimerControls();
|
||||
updateTimerDisplay();
|
||||
bindExerciseControls();
|
||||
}
|
||||
|
||||
function renderStat(label, value) {
|
||||
@@ -698,6 +821,29 @@ function getTimerConfigStorageKey() {
|
||||
return `rc-training-timer-config:${shareToken}:${selectedClassId || "default"}`;
|
||||
}
|
||||
|
||||
// The two guided exercises track progress by laps completed since the driver tapped
|
||||
// "start", not by elapsed time, so unlike the timer above there is no pause/duration to
|
||||
// manage locally - just a start timestamp (or none) per class, mirrored to the server as
|
||||
// "started ago" on each poll so it can compute the sanitized streak/step progress.
|
||||
function getExerciseRunStorageKey(kind) {
|
||||
return `rc-training-${kind}-run:${shareToken}:${selectedClassId || "default"}`;
|
||||
}
|
||||
|
||||
function readExerciseRunState(kind) {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(getExerciseRunStorageKey(kind)) || "null");
|
||||
return parsed && typeof parsed === "object" && parsed.startedAt ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeExerciseRunState(kind, value) {
|
||||
const key = getExerciseRunStorageKey(kind);
|
||||
if (value) localStorage.setItem(key, JSON.stringify(value));
|
||||
else localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
function getTimerElapsed(state, now = Date.now()) {
|
||||
if (!state?.startedAt) return 0;
|
||||
const endpoint = state.pausedAt || now;
|
||||
@@ -754,6 +900,57 @@ function bindTimerControls() {
|
||||
startTimerLoop();
|
||||
}
|
||||
|
||||
function bindExerciseControls() {
|
||||
document.getElementById("profileEvenLapsSave")?.addEventListener("click", async () => {
|
||||
const targetMs = Math.round(Number(document.getElementById("profileEvenLapsTarget")?.value || 0) * 1000);
|
||||
const toleranceMs = Math.round(Number(document.getElementById("profileEvenLapsTolerance")?.value || 0) * 1000);
|
||||
const lapCount = Math.max(1, Math.round(Number(document.getElementById("profileEvenLapsLapCount")?.value || 0) || 5));
|
||||
if (!(targetMs > 0) || !(toleranceMs >= 0)) return;
|
||||
const button = document.getElementById("profileEvenLapsSave");
|
||||
if (button) button.disabled = true;
|
||||
try {
|
||||
await fetch(`/public-training/api/${encodeURIComponent(shareToken)}/even-laps`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ classId: selectedClassId, targetMs, toleranceMs, lapCount }) });
|
||||
await loadProfile();
|
||||
} finally {
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
});
|
||||
document.getElementById("profileEvenLapsStart")?.addEventListener("click", () => {
|
||||
writeExerciseRunState("evenlaps", { startedAt: Date.now() });
|
||||
void loadProfile();
|
||||
});
|
||||
document.getElementById("profileEvenLapsStop")?.addEventListener("click", () => {
|
||||
writeExerciseRunState("evenlaps", null);
|
||||
void loadProfile();
|
||||
});
|
||||
|
||||
document.getElementById("profileStepTrainingSave")?.addEventListener("click", async () => {
|
||||
const steps = [0, 1, 2, 3, 4]
|
||||
.map((index) => ({
|
||||
effort: String(document.getElementById(`profileStepEffort${index}`)?.value || "calm"),
|
||||
lapCount: Math.max(0, Math.round(Number(document.getElementById(`profileStepLaps${index}`)?.value || 0) || 0)),
|
||||
}))
|
||||
.filter((step) => step.lapCount > 0);
|
||||
if (!steps.length) return;
|
||||
const button = document.getElementById("profileStepTrainingSave");
|
||||
if (button) button.disabled = true;
|
||||
try {
|
||||
await fetch(`/public-training/api/${encodeURIComponent(shareToken)}/step-training`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ classId: selectedClassId, steps }) });
|
||||
await loadProfile();
|
||||
} finally {
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
});
|
||||
document.getElementById("profileStepTrainingStart")?.addEventListener("click", () => {
|
||||
writeExerciseRunState("steptraining", { startedAt: Date.now() });
|
||||
void loadProfile();
|
||||
});
|
||||
document.getElementById("profileStepTrainingStop")?.addEventListener("click", () => {
|
||||
writeExerciseRunState("steptraining", null);
|
||||
void loadProfile();
|
||||
});
|
||||
}
|
||||
|
||||
function startTimerLoop() {
|
||||
if (timerHandle) clearInterval(timerHandle);
|
||||
timerHandle = setInterval(updateTimerDisplay, 250);
|
||||
@@ -842,6 +1039,10 @@ async function loadProfile(background = false) {
|
||||
apiUrl.searchParams.set("startedAgoMs", String(startedAgoMs));
|
||||
apiUrl.searchParams.set("windowMs", String(Math.min(startedAgoMs, maximumWindowMs)));
|
||||
}
|
||||
const evenLapsRun = readExerciseRunState("evenlaps");
|
||||
if (evenLapsRun) apiUrl.searchParams.set("evenLapsStartedAgoMs", String(Math.max(0, Date.now() - evenLapsRun.startedAt)));
|
||||
const stepTrainingRun = readExerciseRunState("steptraining");
|
||||
if (stepTrainingRun) apiUrl.searchParams.set("stepTrainingStartedAgoMs", String(Math.max(0, Date.now() - stepTrainingRun.startedAt)));
|
||||
const response = await fetch(apiUrl, { cache: "no-store", signal: controller.signal });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
|
||||
@@ -12,6 +12,8 @@ const {
|
||||
normalizePublicTrainingWindow,
|
||||
splitPublicTrainingStints,
|
||||
addPublicTrainingMarker,
|
||||
savePublicEvenLapsConfig,
|
||||
savePublicStepTrainingConfig,
|
||||
} = require("../server/public_training_profile.js");
|
||||
|
||||
const DRIVER_ONE_TOKEN = "driver-one-token-12345678901234567890";
|
||||
@@ -92,6 +94,64 @@ test("public mobile marker is restricted to the shared profile and selected clas
|
||||
assert.equal(addPublicTrainingMarker(state, DISABLED_TOKEN, { classId: "class_1" }, 123458), null);
|
||||
});
|
||||
|
||||
test("a driver can save their own even-laps and step-training exercise from their mobile link", () => {
|
||||
const state = buildState();
|
||||
const evenLaps = savePublicEvenLapsConfig(state, DRIVER_ONE_TOKEN, { classId: "class_2", targetMs: 14000, toleranceMs: 300, lapCount: 6 });
|
||||
assert.deepEqual({ driverId: evenLaps.driverId, classId: evenLaps.classId, targetMs: evenLaps.targetMs, toleranceMs: evenLaps.toleranceMs, lapCount: evenLaps.lapCount }, { driverId: "driver_1_4wd", classId: "class_2", targetMs: 14000, toleranceMs: 300, lapCount: 6 });
|
||||
assert.equal(state.training.evenLaps.length, 1);
|
||||
|
||||
// Saving again for the same driver+class updates in place rather than adding a duplicate.
|
||||
savePublicEvenLapsConfig(state, DRIVER_ONE_TOKEN, { classId: "class_2", targetMs: 13500, toleranceMs: 250, lapCount: 5 });
|
||||
assert.equal(state.training.evenLaps.length, 1);
|
||||
assert.equal(state.training.evenLaps[0].targetMs, 13500);
|
||||
|
||||
// A different driver's token, or a class the driver isn't in, must not be able to write it.
|
||||
assert.equal(savePublicEvenLapsConfig(state, DRIVER_TWO_TOKEN, { classId: "class_2", targetMs: 10000, toleranceMs: 300, lapCount: 5 }), null);
|
||||
assert.equal(savePublicEvenLapsConfig(state, DRIVER_ONE_TOKEN, { classId: "unknown_class", targetMs: 10000, toleranceMs: 300, lapCount: 5 }), null);
|
||||
assert.equal(savePublicEvenLapsConfig(state, DRIVER_ONE_TOKEN, { classId: "class_2", targetMs: 0, toleranceMs: 300, lapCount: 5 }), null, "a non-positive target is rejected");
|
||||
|
||||
const stepTraining = savePublicStepTrainingConfig(state, DRIVER_ONE_TOKEN, { classId: "class_1", steps: [{ effort: "calm", lapCount: 3 }, { effort: "controlled", lapCount: 2 }] });
|
||||
assert.equal(stepTraining.driverId, "driver_1");
|
||||
assert.deepEqual(stepTraining.steps, [{ effort: "calm", lapCount: 3 }, { effort: "controlled", lapCount: 2 }]);
|
||||
assert.equal(savePublicStepTrainingConfig(state, DRIVER_TWO_TOKEN, { classId: "class_2", steps: [{ effort: "calm", lapCount: 3 }] }), null, "driver_2 is not in class_2, unlike class_1 which they share with driver_1");
|
||||
assert.equal(savePublicStepTrainingConfig(state, DRIVER_ONE_TOKEN, { classId: "class_1", steps: [] }), null, "a sequence with no laps is rejected");
|
||||
});
|
||||
|
||||
test("public profile computes even-laps and step-training progress from their own since-marker, never leaking raw lap rows", () => {
|
||||
const now = Date.now();
|
||||
const state = buildState(now);
|
||||
state.training.evenLaps = [{ id: "e1", driverId: "driver_1", classId: "class_1", targetMs: 16000, toleranceMs: 500, lapCount: 5 }];
|
||||
state.training.stepTraining = [{ id: "s1", driverId: "driver_1", classId: "class_1", steps: [{ effort: "calm", lapCount: 1 }, { effort: "controlled", lapCount: 1 }] }];
|
||||
// buildState's class_1 ledger for driver_1, oldest to newest: 19000, 18000, 16500, 16000
|
||||
// (the 500ms lap is dropped by the ">500ms" pace filter applied before classLaps is built).
|
||||
// A since-marker far enough back captures all four.
|
||||
const profile = buildPublicTrainingProfile(state, DRIVER_ONE_TOKEN, "", 0, 0, now - 2_000_000, now - 2_000_000);
|
||||
const classOne = profile.classProfiles.find((item) => item.classId === "class_1");
|
||||
|
||||
assert.deepEqual(classOne.evenLapsConfig, { targetMs: 16000, toleranceMs: 500, lapCount: 5 });
|
||||
// 16000 and 16500 are both within 16000+/-500 of the target; 18000 before them is not.
|
||||
assert.equal(classOne.evenLapsProgress.currentStreak, 2);
|
||||
assert.equal(classOne.evenLapsProgress.active, true);
|
||||
|
||||
assert.deepEqual(classOne.stepTrainingConfig, { steps: [{ effort: "calm", lapCount: 1 }, { effort: "controlled", lapCount: 1 }] });
|
||||
assert.equal(classOne.stepTrainingProgress.complete, true, "both configured laps (out of four recorded since the marker) are filled");
|
||||
assert.equal(classOne.stepTrainingProgress.steps[0].averageLapMs, 19000);
|
||||
assert.equal(classOne.stepTrainingProgress.steps[1].averageLapMs, 18000);
|
||||
|
||||
const serialized = JSON.stringify(profile);
|
||||
assert.equal(serialized.includes("driver_1"), false, "the internal driver id must never reach the public payload, including in the new exercise fields");
|
||||
});
|
||||
|
||||
test("public profile omits exercise progress and configuration when the driver has not configured or started one", () => {
|
||||
const now = Date.now();
|
||||
const profile = buildPublicTrainingProfile(buildState(now), DRIVER_ONE_TOKEN, "", 0, 0);
|
||||
const classOne = profile.classProfiles.find((item) => item.classId === "class_1");
|
||||
assert.equal(classOne.evenLapsConfig, null);
|
||||
assert.equal(classOne.evenLapsProgress, null);
|
||||
assert.equal(classOne.stepTrainingConfig, null);
|
||||
assert.equal(classOne.stepTrainingProgress, null);
|
||||
});
|
||||
|
||||
test("public profile contains only the selected driver's sanitized aggregates", () => {
|
||||
const now = Date.now();
|
||||
const profile = buildPublicTrainingProfile(buildState(now), DRIVER_ONE_TOKEN, "2026-08-24T12:00:00.000Z", now - 30000, 30000);
|
||||
|
||||
Reference in New Issue
Block a user