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

@@ -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);