Record completed training sessions, credit them, and add session-count goals

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>
This commit is contained in:
larssand
2026-09-09 20:43:47 +02:00
parent bed0dfd9f2
commit 1c4a06b69e
14 changed files with 800 additions and 52 deletions

View File

@@ -14,6 +14,7 @@ const {
addPublicTrainingMarker,
savePublicEvenLapsConfig,
savePublicStepTrainingConfig,
recordPublicExerciseSession,
} = require("../server/public_training_profile.js");
const DRIVER_ONE_TOKEN = "driver-one-token-12345678901234567890";
@@ -321,3 +322,117 @@ test("public profile duration result includes the lap started before five minute
assert.equal(result.totalMs, 314_123);
assert.equal(result.averageLapMs, 18_478);
});
function buildExerciseSessionState(now) {
return {
drivers: [{ id: "driver_1", profileId: "profile_1", name: "Driver One", classId: "class_1", transponder: "secret-transponder-one" }],
classes: [{ id: "class_1", name: "2WD" }],
settings: {},
training: {
shares: [{ driverId: "driver_1", token: DRIVER_ONE_TOKEN, enabled: true }],
ledger: [],
},
resultsBySession: {},
};
}
test("recording a completed step-training session verifies it against real ledger laps, never trusting the client's claim", () => {
const now = Date.now();
const state = buildExerciseSessionState(now);
state.training.stepTraining = [{ driverId: "driver_1", classId: "class_1", steps: [{ effort: "calm", lapCount: 2 }, { effort: "controlled", lapCount: 2 }] }];
const sessionStart = now - 60000;
// A driver who has NOT actually completed the sequence (only 1 of the required 4 laps) cannot
// have a completion recorded just by asking for one.
state.training.ledger = [{ driverId: "driver_1", sessionId: "free", timestamp: sessionStart + 5000, lapMs: 16000 }];
assert.equal(recordPublicExerciseSession(state, DRIVER_ONE_TOKEN, { classId: "class_1", kind: "stepTraining", startedAgoMs: now - sessionStart }, now), null);
assert.equal((state.training.exerciseSessions || []).length, 0);
// Once the real ledger actually has all 4 laps, the same request succeeds and the summary is
// computed from that real data, not anything the client sent.
state.training.ledger.push(
{ driverId: "driver_1", sessionId: "free", timestamp: sessionStart + 15000, lapMs: 16100 },
{ driverId: "driver_1", sessionId: "free", timestamp: sessionStart + 25000, lapMs: 20000 },
{ driverId: "driver_1", sessionId: "free", timestamp: sessionStart + 35000, lapMs: 20200 },
);
const record = recordPublicExerciseSession(state, DRIVER_ONE_TOKEN, { classId: "class_1", kind: "stepTraining", startedAgoMs: now - sessionStart }, now);
assert.ok(record);
assert.equal(record.kind, "stepTraining");
assert.equal(record.driverId, "driver_1");
assert.equal(record.summary.totalSteps, 2);
assert.equal(record.summary.totalLaps, 4);
assert.deepEqual(record.summary.controlledComparison, null, "only one controlled step exists, so there is nothing to compare");
// Calling it again for the same run must not create a second history entry.
const again = recordPublicExerciseSession(state, DRIVER_ONE_TOKEN, { classId: "class_1", kind: "stepTraining", startedAgoMs: now - sessionStart }, now);
assert.equal(again.id, record.id);
assert.equal(state.training.exerciseSessions.length, 1);
});
test("recording a completed even-laps session requires the target streak to have genuinely been reached", () => {
const now = Date.now();
const state = buildExerciseSessionState(now);
state.training.evenLaps = [{ driverId: "driver_1", classId: "class_1", targetMs: 16000, toleranceMs: 300, lapCount: 3 }];
const sessionStart = now - 60000;
// Only 2 laps within tolerance so far - not yet a streak of 3.
state.training.ledger = [
{ driverId: "driver_1", sessionId: "free", timestamp: sessionStart + 5000, lapMs: 16000 },
{ driverId: "driver_1", sessionId: "free", timestamp: sessionStart + 15000, lapMs: 16100 },
];
assert.equal(recordPublicExerciseSession(state, DRIVER_ONE_TOKEN, { classId: "class_1", kind: "evenLaps", startedAgoMs: now - sessionStart }, now), null);
state.training.ledger.push({ driverId: "driver_1", sessionId: "free", timestamp: sessionStart + 25000, lapMs: 15900 });
const record = recordPublicExerciseSession(state, DRIVER_ONE_TOKEN, { classId: "class_1", kind: "evenLaps", startedAgoMs: now - sessionStart }, now);
assert.ok(record);
assert.equal(record.summary.streak, 3);
assert.equal(record.summary.lapCount, 3);
});
test("recording a completed timer session requires real elapsed time and at least one lap", () => {
const now = Date.now();
const state = buildExerciseSessionState(now);
const sessionStart = now - 300000;
state.training.ledger = [{ driverId: "driver_1", sessionId: "free", timestamp: sessionStart + 10000, lapMs: 16000 }];
// Claiming a 5-minute session finished after only 1 minute of real elapsed time is rejected.
assert.equal(recordPublicExerciseSession(state, DRIVER_ONE_TOKEN, { classId: "class_1", kind: "timer", startedAgoMs: 60000, windowMs: 300000 }, now), null);
const record = recordPublicExerciseSession(state, DRIVER_ONE_TOKEN, { classId: "class_1", kind: "timer", startedAgoMs: now - sessionStart, windowMs: 300000 }, now);
assert.ok(record);
assert.equal(record.summary.laps, 1);
assert.equal(record.summary.bestLapMs, 16000);
});
test("recording an exercise session for an unknown class or invalid kind is rejected", () => {
const now = Date.now();
const state = buildExerciseSessionState(now);
assert.equal(recordPublicExerciseSession(state, DRIVER_ONE_TOKEN, { classId: "unknown_class", kind: "timer", startedAgoMs: 60000, windowMs: 60000 }, now), null);
assert.equal(recordPublicExerciseSession(state, DRIVER_ONE_TOKEN, { classId: "class_1", kind: "not_a_real_kind", startedAgoMs: 60000 }, now), null);
assert.equal(recordPublicExerciseSession(state, DISABLED_TOKEN, { classId: "class_1", kind: "timer", startedAgoMs: 60000, windowMs: 60000 }, now), null);
});
test("a goal targeting completed sessions computes progress from real exerciseSessions, and history/driverId stay out of the public response", () => {
const now = Date.now();
const state = buildExerciseSessionState(now);
state.training.goals = [{ driverId: "driver_1", classId: "class_1", metric: "sessions", exerciseKind: "stepTraining", targetSessions: 2, title: "Coffee mug", reward: "Mug" }];
state.training.exerciseSessions = [
{ id: "ex_1", driverId: "driver_1", classId: "class_1", kind: "stepTraining", startedAt: now - 100000, completedAt: new Date(now - 90000).toISOString(), summary: { totalSteps: 2, totalLaps: 4 } },
{ id: "ex_2", driverId: "driver_1", classId: "class_1", kind: "evenLaps", startedAt: now - 50000, completedAt: new Date(now - 40000).toISOString(), summary: { streak: 5 } },
];
const profile = buildPublicTrainingProfile(state, DRIVER_ONE_TOKEN, "");
const classOne = profile.classProfiles.find((item) => item.classId === "class_1");
assert.equal(classOne.goals[0].metric, "sessions");
assert.equal(classOne.goals[0].exerciseKind, "stepTraining");
assert.equal(classOne.goals[0].currentLaps, 1, "only the stepTraining session counts toward this goal, not the evenLaps one");
assert.equal(classOne.goals[0].targetLaps, 2);
assert.equal(classOne.goals[0].reached, false);
assert.equal(classOne.exerciseSessionHistory.length, 2);
assert.deepEqual(classOne.exerciseSessionHistory.map((entry) => entry.kind).sort(), ["evenLaps", "stepTraining"]);
const serialized = JSON.stringify(classOne.exerciseSessionHistory);
assert.ok(!serialized.includes("driver_1"), "exercise session history must never expose driverId");
assert.ok(!serialized.includes("class_1"), "exercise session history must never expose classId");
});

View File

@@ -15,6 +15,8 @@ import {
buildTrainingRecommendation,
buildTrainingStintAnalysis,
buildTrainingWeekdayStats,
countExerciseSessions,
countDriverExerciseSessions,
getBestConsecutiveTrainingLaps,
getBestTrainingDurationResult,
getDriverTrainingLaps,
@@ -37,6 +39,14 @@ test("normalization removes invalid and duplicate training records", () => {
goals: [
{ id: "goal_1", driverId: "driver_1", targetLaps: 500, title: " Bronze ", reward: " Badge " },
{ id: "goal_invalid", driverId: "", targetLaps: 100 },
{ id: "goal_sessions", driverId: "driver_1", classId: "class_1", metric: "sessions", exerciseKind: "stepTraining", targetSessions: 10, title: " Coffee mug " },
{ id: "goal_sessions_no_kind", driverId: "driver_1", metric: "sessions", targetSessions: 10 },
],
exerciseSessions: [
{ id: "ex_1", driverId: "driver_1", classId: "class_1", kind: "stepTraining", startedAt: 1000, completedAt: "2026-09-09T10:00:00.000Z", summary: { stepsCompleted: 5 } },
{ id: "ex_duplicate", driverId: "driver_1", classId: "class_1", kind: "stepTraining", startedAt: 1000, completedAt: "2026-09-09T10:05:00.000Z", summary: {} },
{ id: "ex_invalid_kind", driverId: "driver_1", classId: "class_1", kind: "unknown", startedAt: 2000 },
{ id: "ex_no_driver", driverId: "", classId: "class_1", kind: "evenLaps", startedAt: 3000 },
],
ledger: [
{ id: "lap_1", sourceKey: "source_1", driverId: "driver_1", lapMs: 16000, timestamp: 1000 },
@@ -62,9 +72,17 @@ test("normalization removes invalid and duplicate training records", () => {
},
}, { uid: createUid() });
assert.equal(normalized.goals.length, 1);
assert.equal(normalized.goals.length, 2, "the sessions goal without an exerciseKind is dropped, same as a laps goal with no target");
assert.equal(normalized.goals[0].title, "Bronze");
assert.equal(normalized.goals[0].reward, "Badge");
assert.equal(normalized.goals[0].metric, "laps");
const sessionsGoal = normalized.goals.find((goal) => goal.id === "goal_sessions");
assert.equal(sessionsGoal.metric, "sessions");
assert.equal(sessionsGoal.exerciseKind, "stepTraining");
assert.equal(sessionsGoal.targetSessions, 10);
assert.equal(normalized.exerciseSessions.length, 1, "the duplicate same driver/class/kind/startedAt session, the invalid kind, and the driver-less entry are all dropped");
assert.equal(normalized.exerciseSessions[0].id, "ex_1");
assert.deepEqual(normalized.exerciseSessions[0].summary, { stepsCompleted: 5 });
assert.deepEqual(normalized.ledger.map((lap) => lap.id), ["lap_1"]);
assert.deepEqual(normalized.shares.map((share) => share.token), ["a".repeat(24)]);
assert.deepEqual(normalized.shares[0].visibility, { progress: true, achievements: true, performance: true, goals: true, history: false });
@@ -75,6 +93,21 @@ test("normalization removes invalid and duplicate training records", () => {
assert.equal(normalized.clubGoal.baselineLaps, 120);
});
test("a club goal can also target completed sessions of one exercise kind, club-wide", () => {
const withSessions = normalizeTrainingState({
clubGoal: { title: "Mug run", reward: "Mug", metric: "sessions", exerciseKind: "evenLaps", targetSessions: 50, baselineSessions: 5 },
}, { uid: createUid() });
assert.equal(withSessions.clubGoal.metric, "sessions");
assert.equal(withSessions.clubGoal.exerciseKind, "evenLaps");
assert.equal(withSessions.clubGoal.targetSessions, 50);
assert.equal(withSessions.clubGoal.baselineSessions, 5);
const missingKind = normalizeTrainingState({
clubGoal: { title: "Invalid", metric: "sessions", targetSessions: 50 },
}, { uid: createUid() });
assert.equal(missingKind.clubGoal, null, "a sessions club goal without an exerciseKind is dropped, same as a laps goal with no target");
});
test("ledger sync retains archived laps and rebuilds current session laps", () => {
const training = {
goals: [{ id: "goal_1", driverId: "driver_1", targetLaps: 100 }],
@@ -604,6 +637,52 @@ test("goal and automatic milestone progress are bounded", () => {
assert.equal(getNextTrainingMilestone({ totalLaps: 5200 }).targetLaps, 6000);
});
test("a goal can target completed guided-exercise sessions instead of laps", () => {
const goal = { metric: "sessions", exerciseKind: "stepTraining", targetSessions: 10 };
assert.deepEqual(getTrainingGoalProgress(goal, { totalLaps: 999999 }, 4), {
currentLaps: 4,
targetLaps: 10,
remainingLaps: 6,
percent: 40,
reached: false,
});
assert.equal(getTrainingGoalProgress(goal, {}, 10).reached, true);
assert.equal(getTrainingGoalProgress(goal, {}, 15).percent, 100, "percent is bounded at 100 past the target");
});
test("countExerciseSessions scopes by driver, class and kind independently", () => {
const sessions = [
{ driverId: "driver_1", classId: "class_1", kind: "stepTraining" },
{ driverId: "driver_1", classId: "class_1", kind: "stepTraining" },
{ driverId: "driver_1", classId: "class_1", kind: "evenLaps" },
{ driverId: "driver_1", classId: "class_2", kind: "stepTraining" },
{ driverId: "driver_2", classId: "class_1", kind: "stepTraining" },
];
assert.equal(countExerciseSessions(sessions, { driverId: "driver_1", classId: "class_1", kind: "stepTraining" }), 2);
assert.equal(countExerciseSessions(sessions, { driverId: "driver_1", classId: "class_1", kind: "evenLaps" }), 1);
assert.equal(countExerciseSessions(sessions, { driverId: "driver_1", classId: "class_2", kind: "stepTraining" }), 1);
assert.equal(countExerciseSessions(sessions, { kind: "stepTraining" }), 4, "an unscoped driver/class counts club-wide");
assert.equal(countExerciseSessions(undefined, { kind: "stepTraining" }), 0);
});
test("countDriverExerciseSessions bridges a profile id (how admin goals store driverId) to the specific driver row id (how exerciseSessions store it)", () => {
const state = {
drivers: [{ id: "driver_1", profileId: "profile_1", name: "Driver One", classId: "class_1" }],
training: {
exerciseSessions: [
{ driverId: "driver_1", classId: "class_1", kind: "stepTraining" },
{ driverId: "driver_1", classId: "class_1", kind: "stepTraining" },
{ driverId: "driver_1", classId: "class_1", kind: "evenLaps" },
{ driverId: "driver_2", classId: "class_1", kind: "stepTraining" },
],
},
};
assert.equal(countDriverExerciseSessions(state, "profile_1", "class_1", "stepTraining"), 2, "resolves through the profile id, the same id an admin-created goal stores");
assert.equal(countDriverExerciseSessions(state, "profile_1", "class_1", "evenLaps"), 1);
assert.equal(countDriverExerciseSessions(state, "profile_1", "class_2", "stepTraining"), 0, "wrong class");
assert.equal(countDriverExerciseSessions(state, "driver_2", "class_1", "stepTraining"), 1, "a driver with no profile entry falls back to matching its own id directly");
});
test("development trend compares the latest 30 laps with the preceding 30", () => {
const start = new Date(2026, 0, 10, 12, 0, 0).getTime();
const ledger = Array.from({ length: 60 }, (_, index) => ({
@@ -678,6 +757,24 @@ test("club goal progress starts at its saved baseline and is bounded", () => {
assert.equal(getClubTrainingGoalProgress({ ledger: [] }), null);
});
test("club goal progress can also target completed guided-exercise sessions, club-wide", () => {
const exerciseSessions = [
{ driverId: "driver_1", classId: "class_1", kind: "evenLaps" },
{ driverId: "driver_2", classId: "class_2", kind: "evenLaps" },
{ driverId: "driver_1", classId: "class_1", kind: "stepTraining" },
];
const progress = getClubTrainingGoalProgress({
exerciseSessions,
clubGoal: { metric: "sessions", exerciseKind: "evenLaps", targetSessions: 5, baselineSessions: 1 },
});
assert.equal(progress.currentLaps, 1, "2 evenLaps sessions club-wide minus baseline 1");
assert.equal(progress.targetLaps, 5);
assert.equal(progress.reached, false);
assert.equal(getClubTrainingGoalProgress({ exerciseSessions: [], clubGoal: { metric: "sessions", exerciseKind: "evenLaps", targetSessions: 5 } }).currentLaps, 0, "no matching sessions yet still computes normally, just at zero");
assert.equal(getClubTrainingGoalProgress({ exerciseSessions, clubGoal: { metric: "sessions", targetSessions: 5 } }), null, "a sessions goal without an exerciseKind is invalid");
});
test("pace buckets use a trimmed average and keep classes isolated", () => {
const start = new Date(2026, 7, 23, 12, 0, 0).getTime();
const twoWheelLaps = [10_000, ...Array.from({ length: 10 }, () => 20_000), 60_000];