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