From 1c4a06b69eaacd81c05ca5e735c1489978bd17f7 Mon Sep 17 00:00:00 2001 From: larssand Date: Wed, 9 Sep 2026 20:43:47 +0200 Subject: [PATCH] Record completed training sessions, credit them, and add session-count goals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 10 ++ ROADMAP.md | 8 ++ package-lock.json | 4 +- package.json | 2 +- server.js | 16 ++++ server/public_training_profile.js | 102 ++++++++++++++++++-- src/app.js | 44 ++++++++- src/public_training.css | 42 +++++++++ src/public_training.js | 130 ++++++++++++++++++++++++-- src/styles.css | 42 +++++++++ src/training_logic.js | 117 ++++++++++++++++++++--- src/training_views.js | 121 +++++++++++++++++++++--- tests/public_training_profile.test.js | 115 +++++++++++++++++++++++ tests/training_logic.test.js | 99 +++++++++++++++++++- 14 files changed, 800 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85758bd..13c3286 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.7.0 - 2026-09-09 + +### Added + +- Completing a training session - the general practice timer, "Jämna varv", or "Stegträning" - is now automatically recorded to a permanent history, visible on both the driver's mobile profile ("Träningshistorik") and the admin Training page, so a driver (or coach) can look back at past sessions and see improvement over time. A completion is verified against the driver's real recorded laps before being saved, the same way live progress already is, so it can't be faked. Each completed session earns a prominent credit with an emoji badge, and both per-driver and club-wide goals can now target "complete N sessions" as an alternative to "N laps", using the same reward system as today. + +### Fixed + +- A goal targeting completed sessions always showed 0 progress regardless of how many sessions were actually completed, due to a pre-existing helper in the admin app that silently dropped the new argument needed to compute it. + ## 1.6.1 - 2026-09-09 ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index 4455d1d..7d4a845 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -117,6 +117,14 @@ Verified 2026-09-09: `buildStepTrainingProgress` in `src/training_logic.js` (uni 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. +**Exercise session history, completion credit, and session-count goals:** implemented 2026-09-09, per a follow-up request - the driver wanted to look back at guided-exercise/practice sessions they'd started and see improvement over time, get explicit recognition for a completed session (not just ongoing lap/pace stats), and be able to set a reward goal based on "complete N sessions" the same way a lap-count goal already works. +- [x] Completing any of the three self-service sessions (the general timer, "Jämna varv", "Stegträning") is auto-detected client-side and recorded as a permanent history entry - visible on both the driver's mobile profile ("Träningshistorik") and the admin Training page, so a coach can review it too. +- [x] A completion is verified server-side against the real ledger laps for that run, exactly the way live progress already is - the client can only *ask* for a completion to be recorded, never supply the outcome. A driver cannot fabricate a completed session (and therefore a reward) any more than they could fabricate a lap time. +- [x] A completed session earns a prominent, emoji-badged credit ("Du har genomfört 1 träningspass idag 🪜") shown first in the existing credits list, plus its own history row per completed session (kind, date, outcome summary). +- [x] Both per-driver goals and the club-wide shared goal can now target "N completed sessions of one exercise kind" as an alternative to "N laps", sharing the exact same reward/claim mechanism, editable from the same forms. + +Verified 2026-09-09: unit tests across `tests/training_logic.test.js` (the new `exerciseSessions` log's normalization/dedup, `countExerciseSessions`/`countDriverExerciseSessions`, both goal-progress functions' new sessions branch) and `tests/public_training_profile.test.js` (server-side completion verification rejecting an unearned claim for all three kinds, accepting a genuine one, refusing a duplicate for the same run, and confirming driverId/classId never appear in the client-facing history). Two real bugs found and fixed while building this: `ensureSessionResult`-adjacent `getTrainingGoalProgress` had a pre-existing two-argument wrapper in `app.js` that silently dropped the new third `exerciseSessionCount` argument, so every sessions-metric goal displayed 0 progress regardless of real completions, until a real-browser check caught the mismatch between a debug log (computed correctly) and the rendered card (stuck at 0); and the club-goal creation form's fields were unreachable in a real click test because its `
` disclosure needed to be opened first, same as the per-driver goal form's. A full headless-browser run against an isolated temp SQLite instance covered: the mobile page auto-detecting and recording a real step-training completion with zero extra clicks, the credit and history section both rendering correctly, a second reload not duplicating the recorded session, and the admin Training page correctly computing goal progress and creating both a per-driver and a club sessions-metric goal through their real forms. + ## 4. Results archive Implemented 2026-09-09. diff --git a/package-lock.json b/package-lock.json index ef12f10..f0f9007 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.6.1", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.6.1", + "version": "1.7.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index 904c032..35e304d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.6.1", + "version": "1.7.0", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/server.js b/server.js index e01120b..c453c28 100644 --- a/server.js +++ b/server.js @@ -472,6 +472,22 @@ app.post("/public-training/api/:shareToken/step-training", (req, res) => { } }); +app.post("/public-training/api/:shareToken/exercise-sessions", (req, res) => { + try { + const stored = readStoredAppState(); + const record = stored ? publicTrainingProfile.recordPublicExerciseSession(stored.state, String(req.params.shareToken || ""), req.body || {}) : null; + if (!record) { + res.status(404).json({ error: "Profile, class, or configuration not found, or the exercise was not actually completed" }); + 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, session: { kind: record.kind, completedAt: record.completedAt, summary: record.summary } }); + } 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(); diff --git a/server/public_training_profile.js b/server/public_training_profile.js index bccd1ea..53a0df9 100644 --- a/server/public_training_profile.js +++ b/server/public_training_profile.js @@ -94,9 +94,15 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp const classMarkers = (Array.isArray(state?.training?.markers) ? state.training.markers : []).filter((marker) => classDriverIds.has(String(marker?.driverId || "")) && String(marker?.classId || "") === classId); stats.paceBuckets = buildPublicTrainingPaceBuckets(classLaps, classMarkers); const channelStats = buildPublicTimingChannelStats(state, classDriverIds); + const classExerciseSessions = (Array.isArray(state?.training?.exerciseSessions) ? state.training.exerciseSessions : []) + .filter((entry) => classDriverIds.has(String(entry?.driverId || "")) && String(entry?.classId || "") === classId) + .sort((left, right) => Number(right?.startedAt || 0) - Number(left?.startedAt || 0)); const classGoals = (Array.isArray(state?.training?.goals) ? state.training.goals : []) .filter((goal) => profileReferences.has(String(goal?.driverId || "")) && (!goal?.classId || String(goal.classId) === classId)) - .map((goal) => buildPublicTrainingGoal(goal, stats.totalLaps)); + .map((goal) => buildPublicTrainingGoal( + goal, + goal?.metric === "sessions" ? classExerciseSessions.filter((entry) => entry.kind === goal.exerciseKind).length : 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) @@ -121,13 +127,16 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp evenLapsProgress: buildPublicEvenLapsProgress(classLaps, todayLaps, evenLapsConfig, evenLapsSince), stepTrainingConfig, stepTrainingProgress: buildPublicStepTrainingProgress(classLaps, stepTrainingConfig, stepTrainingSince), + exerciseSessionHistory: visibility.history + ? classExerciseSessions.slice(0, 20).map((entry) => ({ kind: entry.kind, completedAt: entry.completedAt, summary: entry.summary })) + : [], }; }); const activeClass = classProfiles[0] || { classId: "", className: "", brand: "", stats: filterPublicTrainingStats(buildPublicTrainingStats([]), visibility), nextMilestone: getPublicTrainingMilestone(0), channelStats: visibility.performance ? buildPublicTimingChannelStats({}, new Set()) : {}, - recommendation: getPublicTrainingRecommendation({ totalLaps: 0 }), goals: [], currentSession: null, + recommendation: getPublicTrainingRecommendation({ totalLaps: 0 }), goals: [], currentSession: null, exerciseSessionHistory: [], }; const settings = state?.settings && typeof state.settings === "object" ? state.settings : {}; @@ -151,6 +160,7 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp recommendation: activeClass.recommendation, goals: activeClass.goals, currentSession: activeClass.currentSession, + exerciseSessionHistory: activeClass.exerciseSessionHistory, }; } @@ -395,20 +405,93 @@ function savePublicStepTrainingConfig(state, shareToken, input) { return entry; } -function buildPublicTrainingGoal(goal, currentLaps) { - const targetLaps = Math.max(1, Math.round(Number(goal?.targetLaps || 1) || 1)); +function buildPublicTrainingGoal(goal, currentValue) { + const isSessions = goal?.metric === "sessions"; + const target = Math.max(1, Math.round(Number((isSessions ? goal?.targetSessions : goal?.targetLaps) || 1) || 1)); return { title: String(goal?.title || "").trim(), reward: String(goal?.reward || "").trim(), - targetLaps, - currentLaps, - remainingLaps: Math.max(0, targetLaps - currentLaps), - percent: Math.min(100, Math.max(0, (currentLaps / targetLaps) * 100)), - reached: currentLaps >= targetLaps, + metric: isSessions ? "sessions" : "laps", + exerciseKind: isSessions ? String(goal?.exerciseKind || "") : "", + targetLaps: target, + currentLaps: currentValue, + remainingLaps: Math.max(0, target - currentValue), + percent: Math.min(100, Math.max(0, (currentValue / target) * 100)), + reached: currentValue >= target, claimed: Boolean(goal?.claimedAt), }; } +// The driver's own browser is the one deciding *when* to call this (the moment it sees its own +// live progress cross the target), but never what the outcome was: every field of `summary` +// below is recomputed here from the real ledger laps using the same builders the live progress +// endpoints use, not trusted from the client - a driver can't fabricate a completed session (and +// the reward that might be tied to one) any more than they could fabricate a lap time today. +function recordPublicExerciseSession(state, shareToken, input, now = Date.now()) { + const classDriver = resolvePublicClassDriver(state, shareToken, input?.classId); + if (!classDriver) return null; + const classId = String(input.classId); + const kind = ["timer", "evenLaps", "stepTraining"].includes(String(input?.kind || "")) ? String(input.kind) : ""; + const startedAgoMs = Math.max(0, Math.round(Number(input?.startedAgoMs || 0))); + if (!kind || !startedAgoMs) return null; + const startedAt = now - startedAgoMs; + + const ledger = Array.isArray(state?.training?.ledger) ? state.training.ledger : []; + const classLaps = ledger + .filter((entry) => String(entry?.driverId || "") === classDriver.id && Number(entry?.lapMs || 0) > 500) + .map((entry) => ({ + timestamp: Math.max(0, Number(entry.timestamp || 0) || 0), + lapMs: Math.max(0, Math.round(Number(entry.lapMs || 0) || 0)), + excludedFromPace: Boolean(entry.excludedFromPace), + })) + .sort((left, right) => left.timestamp - right.timestamp); + + let summary = null; + if (kind === "timer") { + const windowMs = Math.max(0, Math.round(Number(input?.windowMs || 0))); + if (!windowMs || startedAgoMs < windowMs) return null; + const windowStats = buildPublicTrainingWindowStats(classLaps.filter((lap) => lap.timestamp >= startedAt && lap.timestamp <= startedAt + windowMs)); + if (!windowStats.laps) return null; + summary = { laps: windowStats.laps, bestLapMs: windowStats.bestLapMs, averageLapMs: windowStats.averageLapMs }; + } else if (kind === "evenLaps") { + const config = sanitizePublicEvenLapsConfig( + (Array.isArray(state?.training?.evenLaps) ? state.training.evenLaps : []).find((entry) => String(entry?.driverId || "") === classDriver.id && String(entry?.classId || "") === classId) + ); + if (!config) return null; + const progress = buildPublicEvenLapsProgress(classLaps, [], config, startedAt); + if (!progress || progress.currentStreak < config.lapCount) return null; + summary = { targetMs: config.targetMs, toleranceMs: config.toleranceMs, lapCount: config.lapCount, streak: progress.currentStreak }; + } else if (kind === "stepTraining") { + const config = sanitizePublicStepTrainingConfig( + (Array.isArray(state?.training?.stepTraining) ? state.training.stepTraining : []).find((entry) => String(entry?.driverId || "") === classDriver.id && String(entry?.classId || "") === classId) + ); + if (!config) return null; + const progress = buildPublicStepTrainingProgress(classLaps, config, startedAt); + if (!progress || !progress.complete) return null; + summary = { totalSteps: progress.totalSteps, totalLaps: progress.totalLaps, controlledComparison: progress.controlledComparison }; + } + if (!summary) return null; + + if (!state.training || typeof state.training !== "object") state.training = {}; + state.training.exerciseSessions = Array.isArray(state.training.exerciseSessions) ? state.training.exerciseSessions : []; + const existing = state.training.exerciseSessions.find((entry) => + entry.driverId === classDriver.id && entry.classId === classId && entry.kind === kind && Math.abs(Number(entry.startedAt || 0) - startedAt) < 5000 + ); + if (existing) return existing; + + const record = { + id: "exercise_session_public_" + Date.now() + "_" + Math.random().toString(36).slice(2, 9), + driverId: String(classDriver.id), + classId, + kind, + startedAt, + completedAt: new Date(now).toISOString(), + summary, + }; + state.training.exerciseSessions.push(record); + return record; +} + function buildPublicTrainingPaceBuckets(laps, markers, bucketMinutes = 30) { const bucketMs = Math.max(5, Number(bucketMinutes || 30)) * 60 * 1000; const buckets = new Map(); @@ -730,4 +813,5 @@ module.exports = { buildPublicStepTrainingProgress, savePublicEvenLapsConfig, savePublicStepTrainingConfig, + recordPublicExerciseSession, }; diff --git a/src/app.js b/src/app.js index 97fa43c..0db109f 100644 --- a/src/app.js +++ b/src/app.js @@ -24,7 +24,7 @@ import { renderEventManagerMarkup } from "./event_manager_view.js"; import { renderEventManagerView } from "./event_manager_controller.js"; import { createDefaultAmmcConfigHelper, getManagedWsUrlHelper, loadAmmcConfigFromBackendHelper, saveAmmcConfigToBackendHelper, refreshAmmcStatusHelper, startManagedAmmcHelper, stopManagedAmmcHelper, applyPersistedStateHelper, hydrateFromBackendHelper, scheduleBackendSyncHelper, syncStateToBackendHelper, pingBackendHelper, checkAppVersionHelper, startAppVersionPollingHelper, startOverlaySyncHelper, startBackgroundHydrationHelper, startOverlayRotationHelper, startOverlayLiveRefreshHelper, ensureAudioContextHelper, playPassingBeepHelper, playFinishSirenHelper, playLeaderCueHelper, playStartCueHelper, playBestLapCueHelper, pushOverlayEventHelper, getSpeechVoicesHelper, speakTextHelper, announcePassingHelper, announceRaceStartedHelper, announceRaceFinishedHelper, handleSessionTimerTickHelper, tickClockHelper } from "./runtime_services.js"; import { connectDecoderHelper, disconnectDecoderHelper, processDecoderMessageHelper } from "./decoder_runtime.js"; -import { normalizeTrainingState as normalizeTrainingStateLogic, syncTrainingLedger as syncTrainingLedgerLogic, buildDriverTrainingStats as buildDriverTrainingStatsLogic, buildDriverTimingChannelStats as buildDriverTimingChannelStatsLogic, buildDriverActivePracticeStats as buildDriverActivePracticeStatsLogic, buildEvenLapsProgress as buildEvenLapsProgressLogic, buildStepTrainingProgress as buildStepTrainingProgressLogic, buildTrainingRecommendation as buildTrainingRecommendationLogic, getTrainingGoalProgress as getTrainingGoalProgressLogic, getNextTrainingMilestone as getNextTrainingMilestoneLogic, buildTrainingMissions as buildTrainingMissionsLogic, buildTrainingHighlights as buildTrainingHighlightsLogic, getClubTrainingGoalProgress as getClubTrainingGoalProgressLogic, buildTrainingPaceBuckets as buildTrainingPaceBucketsLogic, buildTrainingWeekdayStats as buildTrainingWeekdayStatsLogic, buildTrainingStintAnalysis as buildTrainingStintAnalysisLogic, buildTrainingMarkerComparisons as buildTrainingMarkerComparisonsLogic } from "./training_logic.js"; +import { normalizeTrainingState as normalizeTrainingStateLogic, syncTrainingLedger as syncTrainingLedgerLogic, buildDriverTrainingStats as buildDriverTrainingStatsLogic, buildDriverTimingChannelStats as buildDriverTimingChannelStatsLogic, buildDriverActivePracticeStats as buildDriverActivePracticeStatsLogic, buildEvenLapsProgress as buildEvenLapsProgressLogic, buildStepTrainingProgress as buildStepTrainingProgressLogic, buildTrainingRecommendation as buildTrainingRecommendationLogic, getTrainingGoalProgress as getTrainingGoalProgressLogic, getNextTrainingMilestone as getNextTrainingMilestoneLogic, buildTrainingMissions as buildTrainingMissionsLogic, buildTrainingHighlights as buildTrainingHighlightsLogic, getClubTrainingGoalProgress as getClubTrainingGoalProgressLogic, countDriverExerciseSessions as countDriverExerciseSessionsLogic, countExerciseSessions as countExerciseSessionsLogic, buildTrainingPaceBuckets as buildTrainingPaceBucketsLogic, buildTrainingWeekdayStats as buildTrainingWeekdayStatsLogic, buildTrainingStintAnalysis as buildTrainingStintAnalysisLogic, buildTrainingMarkerComparisons as buildTrainingMarkerComparisonsLogic } from "./training_logic.js"; import { renderTrainingView } from "./training_views.js"; import { renderResultsArchiveView } from "./results_archive_view.js"; import { importDriversCsv, importCarsCsv } from "./csv_import.js"; @@ -324,7 +324,7 @@ const buildDriverActivePracticeStats = (trainingState, driverId, nowTs = Date.no const buildEvenLapsProgress = (trainingState, driverId, classId, config, nowTs = Date.now()) => buildEvenLapsProgressLogic(trainingState, driverId, classId, config, nowTs); const buildStepTrainingProgress = (trainingState, driverId, classId, config, nowTs = Date.now()) => buildStepTrainingProgressLogic(trainingState, driverId, classId, config, nowTs); const buildTrainingRecommendation = (stats) => buildTrainingRecommendationLogic(stats); -const getTrainingGoalProgress = (goal, stats) => getTrainingGoalProgressLogic(goal, stats); +const getTrainingGoalProgress = (goal, stats, exerciseSessionCount) => getTrainingGoalProgressLogic(goal, stats, exerciseSessionCount); const getNextTrainingMilestone = (stats) => getNextTrainingMilestoneLogic(stats); const renderArchive = () => renderResultsArchiveView({ state, dom, t, escapeHtml, formatLap, formatRaceClock, renderTable, @@ -333,7 +333,7 @@ const renderArchive = () => renderResultsArchiveView({ getArchiveFilters: () => archiveFilters, setArchiveFilters: (value) => { archiveFilters = value; }, getSelectedSessionId: () => archiveSelectedSessionId, setSelectedSessionId: (value) => { archiveSelectedSessionId = value; }, }); -const renderTraining = () => renderTrainingView({ state, dom, t, escapeHtml, formatLap, formatRaceClock, uid, saveState, syncStateToBackend, renderView, normalizeTrainingState, syncTrainingLedger, buildDriverTrainingStats, buildDriverTimingChannelStats: buildDriverTimingChannelStatsLogic, buildDriverActivePracticeStats, buildEvenLapsProgress, buildStepTrainingProgress, buildTrainingRecommendation, getTrainingGoalProgress, getNextTrainingMilestone, buildTrainingMissions: buildTrainingMissionsLogic, buildTrainingHighlights: buildTrainingHighlightsLogic, getClubTrainingGoalProgress: getClubTrainingGoalProgressLogic, buildTrainingPaceBuckets: buildTrainingPaceBucketsLogic, buildTrainingWeekdayStats: buildTrainingWeekdayStatsLogic, buildTrainingStintAnalysis: buildTrainingStintAnalysisLogic, buildTrainingMarkerComparisons: buildTrainingMarkerComparisonsLogic, getSelectedDriverId: () => selectedTrainingDriverId, setSelectedDriverId: (value) => { selectedTrainingDriverId = value; updateViewUrl("training", value, selectedTrainingClassId); }, getSelectedClassId: () => selectedTrainingClassId, setSelectedClassId: (value) => { selectedTrainingClassId = value; updateViewUrl("training", selectedTrainingDriverId, value); } }); +const renderTraining = () => renderTrainingView({ state, dom, t, escapeHtml, formatLap, formatRaceClock, uid, saveState, syncStateToBackend, renderView, normalizeTrainingState, syncTrainingLedger, buildDriverTrainingStats, buildDriverTimingChannelStats: buildDriverTimingChannelStatsLogic, buildDriverActivePracticeStats, buildEvenLapsProgress, buildStepTrainingProgress, buildTrainingRecommendation, getTrainingGoalProgress, getNextTrainingMilestone, buildTrainingMissions: buildTrainingMissionsLogic, buildTrainingHighlights: buildTrainingHighlightsLogic, getClubTrainingGoalProgress: getClubTrainingGoalProgressLogic, countDriverExerciseSessions: countDriverExerciseSessionsLogic, countExerciseSessions: countExerciseSessionsLogic, buildTrainingPaceBuckets: buildTrainingPaceBucketsLogic, buildTrainingWeekdayStats: buildTrainingWeekdayStatsLogic, buildTrainingStintAnalysis: buildTrainingStintAnalysisLogic, buildTrainingMarkerComparisons: buildTrainingMarkerComparisonsLogic, getSelectedDriverId: () => selectedTrainingDriverId, setSelectedDriverId: (value) => { selectedTrainingDriverId = value; updateViewUrl("training", value, selectedTrainingClassId); }, getSelectedClassId: () => selectedTrainingClassId, setSelectedClassId: (value) => { selectedTrainingClassId = value; updateViewUrl("training", selectedTrainingDriverId, value); } }); const applyCompetitorCorrection = (session, row, options = {}) => { const result = applyCompetitorCorrectionLogic(session, row, options, { ensureSessionResult, uid, t, formatLap, saveState }); if (!options.reset && ((Number(options.timeMsDelta || 0) > 0) || (Number(options.lapDelta || 0) < 0))) { @@ -551,6 +551,21 @@ const TRANSLATIONS = { "training.manage_goals_sharing_hint": "Skapa belöningsmål eller hantera förarens delningslänk.", "training.target_laps": "Antal varv", "training.quick_targets": "Snabbval för antal varv", + "training.goal_type": "Måltyp", + "training.goal_type_laps": "Antal varv", + "training.goal_type_evenLaps": "Genomförda Jämna varv-pass", + "training.goal_type_stepTraining": "Genomförda Stegträning-pass", + "training.goal_type_timer": "Genomförda träningspass (timer)", + "training.target_sessions": "Antal pass", + "training.exercise_kind_timer": "Träningspass", + "training.exercise_kind_evenLaps": "Jämna varv", + "training.exercise_kind_stepTraining": "Stegträning", + "training.exercise_history_title": "Genomförda träningspass", + "training.exercise_history_hint": "Jämna varv, Stegträning och vanliga träningspass som föraren genomfört - se utvecklingen över tid.", + "training.exercise_history_empty": "Inga genomförda träningspass ännu för den här föraren och klassen.", + "training.exercise_history_evenLaps": "Serie på {streak} varv i mål {lap}", + "training.exercise_history_stepTraining": "{steps} steg, {laps} varv", + "training.exercise_history_timer": "{laps} varv, snitt {lap}", "training.goal_name": "Namn på delmålet", "training.goal_name_placeholder": "Till exempel Bronsnivå", "training.reward": "Belöning", @@ -559,8 +574,10 @@ const TRANSLATIONS = { "training.goals": "Delmål och rewards", "training.no_goals": "Inga delmål finns för den här föraren ännu.", "training.default_goal_title": "Nå {laps} varv", + "training.default_goal_title_sessions": "Genomför {count} {kind}-pass", "training.goal_progress": "Framsteg mot delmål", "training.goal_remaining": "{laps} varv kvar", + "training.goal_remaining_sessions": "{count} pass kvar", "training.goal_reached": "Målet är nått", "training.goal_claimed": "Belöning utdelad", "training.claim_reward": "Markera belöning utdelad", @@ -770,7 +787,9 @@ const TRANSLATIONS = { "training.club_goal_reached": "Målet är nått", "training.club_goal_celebrated": "Målet är firat", "training.club_goal_default": "Tillsammans till {laps} varv", + "training.club_goal_default_sessions": "Tillsammans till {count} {kind}-pass", "training.club_goal_remaining": "{laps} klubbvarv kvar", + "training.club_goal_remaining_sessions": "{count} klubbpass kvar", "training.club_goal_empty": "Starta ett gemensamt mål där varje giltigt träningsvarv från alla förare räknas.", "training.club_goal_create": "Skapa klubbmål", "training.club_goal_new": "Starta ett nytt klubbmål", @@ -2008,6 +2027,21 @@ const TRANSLATIONS = { "training.manage_goals_sharing_hint": "Create reward goals or manage the driver's share link.", "training.target_laps": "Target laps", "training.quick_targets": "Quick lap targets", + "training.goal_type": "Goal type", + "training.goal_type_laps": "Number of laps", + "training.goal_type_evenLaps": "Completed Even laps sessions", + "training.goal_type_stepTraining": "Completed Step training sessions", + "training.goal_type_timer": "Completed practice sessions (timer)", + "training.target_sessions": "Number of sessions", + "training.exercise_kind_timer": "Practice session", + "training.exercise_kind_evenLaps": "Even laps", + "training.exercise_kind_stepTraining": "Step training", + "training.exercise_history_title": "Completed training sessions", + "training.exercise_history_hint": "Even laps, Step training and regular practice sessions the driver has completed - see progress over time.", + "training.exercise_history_empty": "No completed training sessions yet for this driver and class.", + "training.exercise_history_evenLaps": "{streak}-lap streak at target {lap}", + "training.exercise_history_stepTraining": "{steps} steps, {laps} laps", + "training.exercise_history_timer": "{laps} laps, average {lap}", "training.goal_name": "Milestone name", "training.goal_name_placeholder": "For example Bronze level", "training.reward": "Reward", @@ -2016,8 +2050,10 @@ const TRANSLATIONS = { "training.goals": "Milestones and rewards", "training.no_goals": "This driver has no milestones yet.", "training.default_goal_title": "Reach {laps} laps", + "training.default_goal_title_sessions": "Complete {count} {kind} sessions", "training.goal_progress": "Milestone progress", "training.goal_remaining": "{laps} laps remaining", + "training.goal_remaining_sessions": "{count} sessions remaining", "training.goal_reached": "Milestone reached", "training.goal_claimed": "Reward delivered", "training.claim_reward": "Mark reward delivered", @@ -2227,7 +2263,9 @@ const TRANSLATIONS = { "training.club_goal_reached": "Goal reached", "training.club_goal_celebrated": "Goal celebrated", "training.club_goal_default": "Together to {laps} laps", + "training.club_goal_default_sessions": "Together to {count} {kind} sessions", "training.club_goal_remaining": "{laps} club laps remaining", + "training.club_goal_remaining_sessions": "{count} club sessions remaining", "training.club_goal_empty": "Start a shared target where every valid practice lap from every driver contributes.", "training.club_goal_create": "Create club goal", "training.club_goal_new": "Start a new club goal", diff --git a/src/public_training.css b/src/public_training.css index 519f8ee..aafa857 100644 --- a/src/public_training.css +++ b/src/public_training.css @@ -417,6 +417,48 @@ button { font-size: 0.7rem; } +.profile-exercise-history { + display: grid; + gap: 8px; + margin: 12px 0 0; + padding: 0; + list-style: none; +} + +.profile-exercise-history-row { + display: flex; + align-items: center; + min-width: 0; + gap: 10px; + padding: 9px; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(255, 255, 255, 0.025); +} + +.profile-exercise-history-row > div { + min-width: 0; +} + +.profile-exercise-history-emoji { + flex: 0 0 auto; + font-size: 1.3rem; + line-height: 1; +} + +.profile-exercise-history-row strong, +.profile-exercise-history-row small { + display: block; + overflow: hidden; + text-overflow: ellipsis; +} + +.profile-exercise-history-row small { + margin-top: 2px; + color: var(--muted); + font-size: 0.72rem; +} + .profile-card-head { display: flex; align-items: center; diff --git a/src/public_training.js b/src/public_training.js index f97e0b6..687e144 100644 --- a/src/public_training.js +++ b/src/public_training.js @@ -27,7 +27,16 @@ const messages = { consistency: "Jämnhet senaste 30", nextLevel: "Nästa nivå", reachLaps: "Nå {laps} varv", + reachSessions: "Genomför {count} pass", lapsRemaining: "{laps} varv kvar", + timerSessionTitle: "Träningspass", + exerciseHistoryTitle: "Träningshistorik", + exerciseHistoryHint: "Genomförda träningspass - jämna varv, stegträning och vanliga pass - för att se utvecklingen över tid.", + exerciseHistoryEmpty: "Inga genomförda träningspass ännu.", + exerciseHistoryEvenLaps: "Serie på {streak} varv i mål {lap}", + exerciseHistoryStepTraining: "{steps} steg, {laps} varv", + exerciseHistoryTimer: "{laps} varv, snitt {lap}", + exerciseSessionsCredit: "Du har genomfört {count} träningspass idag - snyggt jobbat!", plan: "Dagens träningspass", timerSetup: "Ställ in passet", pushMinutes: "Maxfokus (min)", @@ -184,7 +193,16 @@ const messages = { consistency: "Latest 30 consistency", nextLevel: "Next level", reachLaps: "Reach {laps} laps", + reachSessions: "Complete {count} sessions", lapsRemaining: "{laps} laps remaining", + timerSessionTitle: "Practice session", + exerciseHistoryTitle: "Training history", + exerciseHistoryHint: "Completed training sessions - even laps, step training and regular sessions - to see progress over time.", + exerciseHistoryEmpty: "No completed training sessions yet.", + exerciseHistoryEvenLaps: "{streak}-lap streak at target {lap}", + exerciseHistoryStepTraining: "{steps} steps, {laps} laps", + exerciseHistoryTimer: "{laps} laps, average {lap}", + exerciseSessionsCredit: "You completed {count} training session(s) today - nice work!", plan: "Today's practice session", timerSetup: "Set session duration", pushMinutes: "Maximum focus (min)", @@ -374,6 +392,12 @@ function recommendationCopy(recommendation) { }; } +function exerciseKindMeta(kind) { + if (kind === "evenLaps") return { emoji: "🎯", titleKey: "evenLapsTitle" }; + if (kind === "stepTraining") return { emoji: "🪜", titleKey: "stepTrainingTitle" }; + return { emoji: "⏱️", titleKey: "timerSessionTitle" }; +} + function nextMilestoneForLaps(laps) { const presets = [100, 250, 500, 1000, 1500, 2500, 5000]; return presets.find((target) => target > laps) || (Math.floor(laps / 1000) + 1) * 1000; @@ -424,9 +448,11 @@ function getLocalDayKey(timestamp) { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; } -function buildProfileMotivation(stats, channelStats = {}) { +function buildProfileMotivation(stats, channelStats = {}, exerciseSessionHistory = []) { const todayKey = getLocalDayKey(Date.now()); const today = (Array.isArray(stats?.dailySummaries) ? stats.dailySummaries : []).find((day) => day.dateKey === todayKey); + const exerciseSessionsToday = (Array.isArray(exerciseSessionHistory) ? exerciseSessionHistory : []) + .filter((entry) => getLocalDayKey(new Date(entry.completedAt).getTime()) === todayKey); const activeToday = Boolean(today); const todayLaps = Number(today?.laps || 0); const controlled = activeToday ? Number(stats?.todayControlledLapStreak || 0) : 0; @@ -441,6 +467,9 @@ function buildProfileMotivation(stats, channelStats = {}) { completed: challenge.current >= challenge.target, })); const credits = []; + // Completing a guided exercise pass is worth calling out first - it's the thing the driver + // most deliberately chose to do, not just background pace/consistency drift. + if (exerciseSessionsToday.length) credits.push(t("exerciseSessionsCredit", { count: exerciseSessionsToday.length })); if (todayLaps) credits.push(t("todayLapsCredit", { laps: todayLaps })); if (stats?.trendSampleReady && Number(stats.averageTrendMs || 0) > 0) credits.push(t("paceCredit", { lap: formatLap(stats.averageTrendMs) })); if (stats?.trendSampleReady && Number(stats.consistencyTrendPct || 0) > 0.1) credits.push(t("consistencyCredit", { value: Number(stats.consistencyTrendPct).toFixed(1) })); @@ -494,10 +523,19 @@ function renderProfile(data) { 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 evenLapsRun = readExerciseRunState("evenlaps"); + const evenLapsRunning = Boolean(evenLapsRun); const stepTrainingConfig = activeClass?.stepTrainingConfig || null; const stepTrainingProgress = activeClass?.stepTrainingProgress || null; - const stepTrainingRunning = Boolean(readExerciseRunState("steptraining")); + const stepTrainingRun = readExerciseRunState("steptraining"); + const stepTrainingRunning = Boolean(stepTrainingRun); + const exerciseSessionHistory = activeClass?.exerciseSessionHistory || data.exerciseSessionHistory || []; + if (evenLapsRun && evenLapsConfig && evenLapsProgress?.currentStreak >= evenLapsConfig.lapCount) { + void maybeRecordExerciseCompletion("evenLaps", evenLapsRun.startedAt); + } + if (stepTrainingRun && stepTrainingProgress?.complete) { + void maybeRecordExerciseCompletion("stepTraining", stepTrainingRun.startedAt); + } const defaultStepTrainingRows = [ { effort: "calm", lapCount: 5 }, { effort: "controlled", lapCount: 5 }, { effort: "fast", lapCount: 5 }, { effort: "controlled", lapCount: 5 }, { effort: "calm", lapCount: 5 }, @@ -505,7 +543,7 @@ function renderProfile(data) { 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 motivation = buildProfileMotivation(stats, channelStats, exerciseSessionHistory); const recommendationText = recommendationCopy(recommendation); const timerState = readTimerState(); const timerConfig = getTimerConfig(timerState); @@ -546,17 +584,22 @@ function renderProfile(data) { ? `` : `
RC
`; const goalMarkup = goals.length - ? goals.map((goal) => ` + ? goals.map((goal) => { + const isSessions = goal.metric === "sessions"; + const meta = exerciseKindMeta(goal.exerciseKind); + const unitSuffix = isSessions ? ` ${meta.emoji} ${escapeHtml(t(meta.titleKey))}` : ""; + return `
- ${escapeHtml(goal.title || t("reachLaps", { laps: goal.targetLaps }))} - ${goal.currentLaps} / ${goal.targetLaps} + ${escapeHtml(goal.title || (isSessions ? t("reachSessions", { count: goal.targetLaps }) : t("reachLaps", { laps: goal.targetLaps })))} + ${goal.currentLaps} / ${goal.targetLaps}${unitSuffix}
${goal.reward ? `

${escapeHtml(t("reward"))}
${escapeHtml(goal.reward)}

` : ""} ${goal.claimed ? `${escapeHtml(t("rewardDelivered"))}` : goal.reached ? `${escapeHtml(t("goalReached"))}` : ""}
- `).join("") + `; + }).join("") : `

${escapeHtml(t("noGoals"))}

`; const historyRows = stats.dailySummaries.length ? stats.dailySummaries.slice(0, 12).map((day) => ` @@ -826,6 +869,35 @@ function renderProfile(data) {
+ +
+

${escapeHtml(t("exerciseHistoryTitle"))}

+
+
+

${escapeHtml(t("exerciseHistoryHint"))}

+ ${exerciseSessionHistory.length ? ` +
    + ${exerciseSessionHistory.map((entry) => { + const meta = exerciseKindMeta(entry.kind); + const date = new Date(entry.completedAt).toLocaleString(language, { dateStyle: "short", timeStyle: "short" }); + const detail = entry.kind === "evenLaps" + ? t("exerciseHistoryEvenLaps", { streak: entry.summary?.streak ?? "-", lap: formatLap(entry.summary?.targetMs) }) + : entry.kind === "stepTraining" + ? t("exerciseHistoryStepTraining", { steps: entry.summary?.totalSteps ?? "-", laps: entry.summary?.totalLaps ?? "-" }) + : t("exerciseHistoryTimer", { laps: entry.summary?.laps ?? "-", lap: formatLap(entry.summary?.averageLapMs) }); + return ` +
  • + ${meta.emoji} +
    + ${escapeHtml(t(meta.titleKey))} + ${escapeHtml(date)} · ${escapeHtml(detail)} +
    +
  • + `; + }).join("")} +
+ ` : `

${escapeHtml(t("exerciseHistoryEmpty"))}

`} +
+
`; const markerForm = document.getElementById("publicTrainingMarkerForm"); const draft = markerDrafts.get(selectedClassId); @@ -946,6 +1018,47 @@ function writePracticeSummaryDismissedFor(startedAtValue) { } catch {} } +// Remembers which run (by startedAt) of each of the three exercise kinds has already had a +// completion request sent for it, so a background re-render (this function runs on every +// renderProfile(), not just once) doesn't keep re-POSTing while the completed state persists. +function getExerciseRecordedStorageKey(kind) { + return `rc-training-${kind}-recorded:${shareToken}:${selectedClassId || "default"}`; +} + +function readExerciseRecordedFor(kind) { + try { + return localStorage.getItem(getExerciseRecordedStorageKey(kind)) || ""; + } catch { + return ""; + } +} + +function writeExerciseRecordedFor(kind, startedAtValue) { + try { + localStorage.setItem(getExerciseRecordedStorageKey(kind), String(startedAtValue)); + } catch {} +} + +// The driver's browser only ever *asks* the server to record a completion - the server +// independently recomputes the outcome from real ledger laps and refuses if it disagrees (see +// recordPublicExerciseSession in server/public_training_profile.js). This just avoids sending +// that request more than once per run. +async function maybeRecordExerciseCompletion(kind, startedAt, extra = {}) { + if (!startedAt || !selectedClassId) return; + if (readExerciseRecordedFor(kind) === String(startedAt)) return; + writeExerciseRecordedFor(kind, startedAt); + try { + const response = await fetch(`/public-training/api/${encodeURIComponent(shareToken)}/exercise-sessions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ classId: selectedClassId, kind, startedAgoMs: Math.max(0, Date.now() - startedAt), ...extra }), + }); + if (response.ok) await loadProfile(); + } catch { + // best-effort - a missed credit for one session is not worth retry complexity here + } +} + // 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 @@ -1123,6 +1236,7 @@ function updateTimerDisplay() { lastTimerPhase = phaseName; if (justFinished) { speak(t("finishCue")); + void maybeRecordExerciseCompletion("timer", state.startedAt, { windowMs: timerDurationMs }); // Show the practice summary the moment the pass actually finishes, rather than // waiting for the next background poll (up to 10s away) to pick it up. if (profileData) renderProfile(profileData); diff --git a/src/styles.css b/src/styles.css index ed687cb..5bb01db 100644 --- a/src/styles.css +++ b/src/styles.css @@ -769,6 +769,48 @@ body { white-space: nowrap; } +.training-exercise-history { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.training-exercise-history-row { + display: flex; + align-items: center; + min-width: 0; + gap: 10px; + padding: 9px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-elevated); +} + +.training-exercise-history-row > span { + flex: 0 0 auto; + font-size: 1.3rem; + line-height: 1; +} + +.training-exercise-history-row > div { + min-width: 0; +} + +.training-exercise-history-row strong, +.training-exercise-history-row small { + display: block; + overflow: hidden; + text-overflow: ellipsis; +} + +.training-exercise-history-row small { + margin-top: 2px; + color: var(--muted); + font-size: 0.78rem; +} + .training-club-goal > p { margin: 9px 0 0; color: var(--muted); diff --git a/src/training_logic.js b/src/training_logic.js index 365f85b..1187915 100644 --- a/src/training_logic.js +++ b/src/training_logic.js @@ -20,30 +20,46 @@ function getTrainingDriverIds(state, driverId, classId = "") { export function normalizeTrainingState(training, { uid }) { const source = training && typeof training === "object" ? training : {}; const clubGoalSource = source.clubGoal && typeof source.clubGoal === "object" ? source.clubGoal : null; - const clubGoal = clubGoalSource && Number(clubGoalSource.targetLaps || 0) > 0 + const clubGoalMetric = String(clubGoalSource?.metric || "") === "sessions" ? "sessions" : "laps"; + const clubGoalExerciseKind = ["timer", "evenLaps", "stepTraining"].includes(String(clubGoalSource?.exerciseKind || "")) ? String(clubGoalSource.exerciseKind) : ""; + const clubGoalHasTarget = clubGoalSource && (clubGoalMetric === "sessions" + ? Number(clubGoalSource.targetSessions || 0) > 0 && clubGoalExerciseKind + : Number(clubGoalSource.targetLaps || 0) > 0); + const clubGoal = clubGoalHasTarget ? { id: String(clubGoalSource.id || uid("club_goal")), title: String(clubGoalSource.title || "").trim(), reward: String(clubGoalSource.reward || "").trim(), targetLaps: Math.max(1, Math.round(Number(clubGoalSource.targetLaps || 0) || 0)), baselineLaps: Math.max(0, Math.round(Number(clubGoalSource.baselineLaps || 0) || 0)), + metric: clubGoalMetric, + exerciseKind: clubGoalMetric === "sessions" ? clubGoalExerciseKind : "", + targetSessions: Math.max(1, Math.round(Number(clubGoalSource.targetSessions || 0) || 0)), + baselineSessions: Math.max(0, Math.round(Number(clubGoalSource.baselineSessions || 0) || 0)), createdAt: String(clubGoalSource.createdAt || new Date().toISOString()), celebratedAt: clubGoalSource.celebratedAt ? String(clubGoalSource.celebratedAt) : "", } : null; const goals = Array.isArray(source.goals) ? source.goals - .map((goal) => ({ - id: String(goal?.id || uid("goal")), - driverId: String(goal?.driverId || ""), - classId: String(goal?.classId || ""), - title: String(goal?.title || "").trim(), - reward: String(goal?.reward || "").trim(), - targetLaps: Math.max(1, Math.round(Number(goal?.targetLaps || 0) || 0)), - createdAt: String(goal?.createdAt || new Date().toISOString()), - claimedAt: goal?.claimedAt ? String(goal.claimedAt) : "", - })) - .filter((goal) => goal.driverId && goal.targetLaps > 0) + .map((goal) => { + const metric = String(goal?.metric || "") === "sessions" ? "sessions" : "laps"; + const exerciseKind = ["timer", "evenLaps", "stepTraining"].includes(String(goal?.exerciseKind || "")) ? String(goal.exerciseKind) : ""; + return { + id: String(goal?.id || uid("goal")), + driverId: String(goal?.driverId || ""), + classId: String(goal?.classId || ""), + title: String(goal?.title || "").trim(), + reward: String(goal?.reward || "").trim(), + targetLaps: Math.max(1, Math.round(Number(goal?.targetLaps || 0) || 0)), + metric, + exerciseKind: metric === "sessions" ? exerciseKind : "", + targetSessions: Math.max(1, Math.round(Number(goal?.targetSessions || 0) || 0)), + createdAt: String(goal?.createdAt || new Date().toISOString()), + claimedAt: goal?.claimedAt ? String(goal.claimedAt) : "", + }; + }) + .filter((goal) => goal.driverId && (goal.metric === "sessions" ? goal.exerciseKind && goal.targetSessions > 0 : goal.targetLaps > 0)) : []; const seenLedgerKeys = new Set(); @@ -156,7 +172,29 @@ export function normalizeTrainingState(training, { uid }) { }) : []; - return { goals, ledger, shares, markers, clubGoal, evenLaps, stepTraining }; + const seenExerciseSessions = new Set(); + const exerciseSessions = Array.isArray(source.exerciseSessions) + ? source.exerciseSessions + .map((entry) => ({ + id: String(entry?.id || uid("exercise_session")), + driverId: String(entry?.driverId || ""), + classId: String(entry?.classId || ""), + kind: ["timer", "evenLaps", "stepTraining"].includes(String(entry?.kind || "")) ? String(entry.kind) : "", + startedAt: Math.max(0, Number(entry?.startedAt || 0) || 0), + completedAt: String(entry?.completedAt || new Date().toISOString()), + summary: entry?.summary && typeof entry.summary === "object" ? entry.summary : {}, + })) + .filter((entry) => { + if (!entry.driverId || !entry.classId || !entry.kind || !entry.startedAt) return false; + const key = `${entry.driverId}:${entry.classId}:${entry.kind}:${entry.startedAt}`; + if (seenExerciseSessions.has(key)) return false; + seenExerciseSessions.add(key); + return true; + }) + .sort((left, right) => right.startedAt - left.startedAt) + : []; + + return { goals, ledger, shares, markers, clubGoal, evenLaps, stepTraining, exerciseSessions }; } function getPassingSourceKey(sessionId, passing, index) { @@ -954,7 +992,42 @@ export function buildTrainingRecommendation(stats) { }; } -export function getTrainingGoalProgress(goal, stats) { +// Goals and the club goal share this same {currentLaps, targetLaps, ...} shape whether they +// track laps or completed guided-exercise sessions, so every existing progress-bar/reward +// renderer keeps working unchanged - callers branch on goal.metric only to pick the right +// label ("laps" vs "sessions"), never to reshape this result. +export function countExerciseSessions(exerciseSessions, { driverId = "", classId = "", kind = "" } = {}) { + return (Array.isArray(exerciseSessions) ? exerciseSessions : []).filter((entry) => + (!driverId || entry.driverId === driverId) && + (!classId || entry.classId === classId) && + (!kind || entry.kind === kind) + ).length; +} + +// Admin goals store driverId as the *profile* id (see the "training-goal-form" submit handler +// in training_views.js), the same value buildDriverTrainingStats resolves through +// getTrainingDriverIds - but exerciseSessions store the specific per-class driver row id +// (whichever the mobile link's completion request resolved to), the same as ledger entries do. +// This bridges the two the same way buildDriverTrainingStats already does for laps. +export function countDriverExerciseSessions(state, driverId, classId, kind) { + const driverIds = getTrainingDriverIds(state, driverId, classId); + return (Array.isArray(state?.training?.exerciseSessions) ? state.training.exerciseSessions : []) + .filter((entry) => driverIds.has(String(entry?.driverId || "")) && String(entry?.classId || "") === String(classId || "") && entry?.kind === kind) + .length; +} + +export function getTrainingGoalProgress(goal, stats, exerciseSessionCount = 0) { + if (goal?.metric === "sessions") { + const targetSessions = Math.max(1, Number(goal?.targetSessions || 1)); + const currentSessions = Math.max(0, Number(exerciseSessionCount || 0)); + return { + currentLaps: currentSessions, + targetLaps: targetSessions, + remainingLaps: Math.max(0, targetSessions - currentSessions), + percent: Math.min(100, Math.max(0, (currentSessions / targetSessions) * 100)), + reached: currentSessions >= targetSessions, + }; + } const targetLaps = Math.max(1, Number(goal?.targetLaps || 1)); const currentLaps = Math.max(0, Number(stats?.totalLaps || 0)); return { @@ -1008,7 +1081,21 @@ export function buildTrainingHighlights(stats) { export function getClubTrainingGoalProgress(training) { const goal = training?.clubGoal && typeof training.clubGoal === "object" ? training.clubGoal : null; - if (!goal || Number(goal.targetLaps || 0) <= 0) return null; + if (!goal) return null; + if (goal.metric === "sessions") { + if (!(Number(goal.targetSessions || 0) > 0) || !goal.exerciseKind) return null; + const totalSessions = countExerciseSessions(training?.exerciseSessions, { kind: goal.exerciseKind }); + const currentSessions = Math.max(0, totalSessions - Math.max(0, Number(goal.baselineSessions || 0))); + const targetSessions = Math.max(1, Number(goal.targetSessions || 1)); + return { + currentLaps: currentSessions, + targetLaps: targetSessions, + remainingLaps: Math.max(0, targetSessions - currentSessions), + percent: Math.min(100, Math.max(0, (currentSessions / targetSessions) * 100)), + reached: currentSessions >= targetSessions, + }; + } + if (!(Number(goal.targetLaps || 0) > 0)) return null; const totalLaps = Array.isArray(training?.ledger) ? training.ledger.length : 0; const currentLaps = Math.max(0, totalLaps - Math.max(0, Number(goal.baselineLaps || 0))); const targetLaps = Math.max(1, Number(goal.targetLaps || 1)); diff --git a/src/training_views.js b/src/training_views.js index f6cb60b..7d6d4e3 100644 --- a/src/training_views.js +++ b/src/training_views.js @@ -1,6 +1,8 @@ import { sortValuesAttribute } from "./list_sorting.js"; import { getDriverProfileGroups, getDriverProfileReferences } from "./driver_profile_logic.js"; +const EXERCISE_KIND_EMOJI = { timer: "⏱️", evenLaps: "🎯", stepTraining: "🪜" }; + function renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock }) { const paceRetention = row.endChangeMs == null ? "-" @@ -32,6 +34,7 @@ export function renderTrainingView(deps) { const moreStatsWasOpen = Boolean(document.getElementById("trainingMoreStats")?.open); const goalsSharingElement = document.getElementById("trainingGoalsSharing"); const goalsSharingWasOpen = goalsSharingElement ? goalsSharingElement.open : true; + const exerciseHistoryWasOpen = Boolean(document.getElementById("trainingExerciseHistory")?.open); const { state, dom, @@ -56,6 +59,8 @@ export function renderTrainingView(deps) { buildTrainingMissions, buildTrainingHighlights, getClubTrainingGoalProgress, + countDriverExerciseSessions, + countExerciseSessions, buildTrainingPaceBuckets, buildTrainingWeekdayStats, buildTrainingStintAnalysis, @@ -92,7 +97,7 @@ export function renderTrainingView(deps) { } if (!selectedProfileId || !selectedProfile) { - renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, uid, saveState, profiles, buildDriverTrainingStats, getNextTrainingMilestone, getClubTrainingGoalProgress, setSelectedDriverId, setSelectedClassId, renderView }); + renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, uid, saveState, profiles, buildDriverTrainingStats, getNextTrainingMilestone, getClubTrainingGoalProgress, countExerciseSessions, setSelectedDriverId, setSelectedClassId, renderView }); return; } @@ -135,6 +140,26 @@ export function renderTrainingView(deps) { profileReferences.has(String(goal.driverId || "")) && (!goal.classId || String(goal.classId) === selectedClassId) )); + const exerciseSessionHistory = (Array.isArray(state.training.exerciseSessions) ? state.training.exerciseSessions : []) + .filter((entry) => profileReferences.has(String(entry?.driverId || "")) && String(entry?.classId || "") === selectedClassId) + .slice(0, 15); + const exerciseHistoryMarkup = exerciseSessionHistory.length + ? exerciseSessionHistory.map((entry) => { + const meta = { timer: { emoji: EXERCISE_KIND_EMOJI.timer, label: t("training.exercise_kind_timer") }, evenLaps: { emoji: EXERCISE_KIND_EMOJI.evenLaps, label: t("training.exercise_kind_evenLaps") }, stepTraining: { emoji: EXERCISE_KIND_EMOJI.stepTraining, label: t("training.exercise_kind_stepTraining") } }[entry.kind] || { emoji: "", label: entry.kind }; + const date = new Date(entry.completedAt).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" }); + const detail = entry.kind === "evenLaps" + ? t("training.exercise_history_evenLaps", { streak: entry.summary?.streak ?? "-", lap: formatLap(entry.summary?.targetMs) }) + : entry.kind === "stepTraining" + ? t("training.exercise_history_stepTraining", { steps: entry.summary?.totalSteps ?? "-", laps: entry.summary?.totalLaps ?? "-" }) + : t("training.exercise_history_timer", { laps: entry.summary?.laps ?? "-", lap: formatLap(entry.summary?.averageLapMs) }); + return ` +
  • + ${meta.emoji} +
    ${escapeHtml(meta.label)}${escapeHtml(date)} - ${escapeHtml(detail)}
    +
  • + `; + }).join("") + : `

    ${escapeHtml(t("training.exercise_history_empty"))}

    `; const profileShare = (state.training.shares || []).find((share) => profileReferences.has(String(share.driverId || "")) && share.enabled); const publicBaseUrl = normalizePublicBaseUrl(state.settings?.publicBaseUrl); const localBaseUrl = window.location.origin; @@ -163,21 +188,27 @@ export function renderTrainingView(deps) { const goalMarkup = goals.length ? goals.map((goal) => { - const progress = getTrainingGoalProgress(goal, stats); + const isSessions = goal.metric === "sessions"; + const sessionCount = isSessions ? countDriverExerciseSessions(state, selectedProfileId, selectedClassId, goal.exerciseKind) : 0; + const progress = getTrainingGoalProgress(goal, stats, sessionCount); + const emoji = isSessions ? EXERCISE_KIND_EMOJI[goal.exerciseKind] || "" : ""; + const unitLabel = isSessions ? t(`training.exercise_kind_${goal.exerciseKind}`) : ""; const statusClass = goal.claimedAt ? "claimed" : progress.reached ? "reached" : "active"; const statusLabel = goal.claimedAt ? t("training.goal_claimed") : progress.reached ? t("training.goal_reached") + : isSessions + ? t("training.goal_remaining_sessions", { count: progress.remainingLaps }) : t("training.goal_remaining", { laps: progress.remainingLaps }); return `
    ${escapeHtml(statusLabel)} -

    ${escapeHtml(goal.title || t("training.default_goal_title", { laps: goal.targetLaps }))}

    +

    ${escapeHtml(goal.title || (isSessions ? t("training.default_goal_title_sessions", { count: goal.targetLaps, kind: unitLabel }) : t("training.default_goal_title", { laps: goal.targetLaps })))}

    - ${progress.currentLaps} / ${progress.targetLaps} + ${progress.currentLaps} / ${progress.targetLaps}${isSessions ? ` ${emoji}` : ""}
    @@ -523,6 +554,16 @@ export function renderTrainingView(deps) { ` : ""} +
    + + ${escapeHtml(t("training.exercise_history_title"))}${escapeHtml(t("training.exercise_history_hint"))} + + + +
    +
      ${exerciseHistoryMarkup}
    +
    +
    +
    @@ -534,10 +575,19 @@ export function renderTrainingView(deps) {

    ${escapeHtml(t("training.new_goal"))}

    - -
    + + +
    ${[500, 1000, 1500].map((value) => ``).join("")}
    + @@ -837,15 +887,32 @@ export function renderTrainingView(deps) { }); }); + const updateGoalTypeFields = () => { + const goalType = document.getElementById("trainingGoalType")?.value || "laps"; + const isSessions = goalType !== "laps"; + const lapsField = document.getElementById("trainingGoalLapsField"); + const lapsPresets = document.getElementById("trainingGoalLapsPresets"); + const sessionsField = document.getElementById("trainingGoalSessionsField"); + if (lapsField) lapsField.hidden = isSessions; + if (lapsPresets) lapsPresets.hidden = isSessions; + if (sessionsField) sessionsField.hidden = !isSessions; + }; + document.getElementById("trainingGoalType")?.addEventListener("change", updateGoalTypeFields); + updateGoalTypeFields(); + document.getElementById("training-goal-form")?.addEventListener("submit", (event) => { event.preventDefault(); const form = new FormData(event.currentTarget); - const targetLaps = Math.max(1, Math.round(Number(form.get("targetLaps") || 0) || 0)); + const goalType = String(form.get("goalType") || "laps"); + const isSessions = goalType !== "laps"; state.training.goals.push({ id: uid("goal"), driverId: selectedProfileId, classId: selectedClassId, - targetLaps, + targetLaps: Math.max(1, Math.round(Number(form.get("targetLaps") || 0) || 0)), + metric: isSessions ? "sessions" : "laps", + exerciseKind: isSessions ? goalType : "", + targetSessions: Math.max(1, Math.round(Number(form.get("targetSessions") || 0) || 0)), title: String(form.get("title") || "").trim(), reward: String(form.get("reward") || "").trim(), createdAt: new Date().toISOString(), @@ -979,7 +1046,7 @@ export function buildTrainingProgressFeed(rows, limit = 6) { }); } -function renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, uid, saveState, profiles, buildDriverTrainingStats, getNextTrainingMilestone, getClubTrainingGoalProgress, setSelectedDriverId, setSelectedClassId, renderView }) { +function renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, uid, saveState, profiles, buildDriverTrainingStats, getNextTrainingMilestone, getClubTrainingGoalProgress, countExerciseSessions, setSelectedDriverId, setSelectedClassId, renderView }) { const rows = buildTrainingOverviewRows(state, profiles, buildDriverTrainingStats); const progressFeed = buildTrainingProgressFeed(rows); const ledger = Array.isArray(state.training?.ledger) ? state.training.ledger : []; @@ -1026,17 +1093,20 @@ function renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, uid, sav `; }).join(""); + const clubGoalIsSessions = clubGoal?.metric === "sessions"; + const clubGoalEmoji = clubGoalIsSessions ? EXERCISE_KIND_EMOJI[clubGoal.exerciseKind] || "" : ""; + const clubGoalUnitLabel = clubGoalIsSessions ? t(`training.exercise_kind_${clubGoal.exerciseKind}`) : ""; const clubGoalMarkup = clubGoal && clubGoalProgress ? `
    ${escapeHtml(t(clubGoal.celebratedAt ? "training.club_goal_celebrated" : clubGoalProgress.reached ? "training.club_goal_reached" : "training.club_goal_active"))} -

    ${escapeHtml(clubGoal.title || t("training.club_goal_default", { laps: clubGoal.targetLaps }))}

    +

    ${escapeHtml(clubGoal.title || (clubGoalIsSessions ? t("training.club_goal_default_sessions", { count: clubGoal.targetSessions, kind: clubGoalUnitLabel }) : t("training.club_goal_default", { laps: clubGoal.targetLaps })))}

    - ${clubGoalProgress.currentLaps} / ${clubGoalProgress.targetLaps} + ${clubGoalProgress.currentLaps} / ${clubGoalProgress.targetLaps}${clubGoalIsSessions ? ` ${clubGoalEmoji}` : ""}
    -

    ${escapeHtml(t("training.club_goal_remaining", { laps: clubGoalProgress.remainingLaps }))}

    +

    ${escapeHtml(clubGoalIsSessions ? t("training.club_goal_remaining_sessions", { count: clubGoalProgress.remainingLaps }) : t("training.club_goal_remaining", { laps: clubGoalProgress.remainingLaps }))}

    ${clubGoal.reward ? `

    ${escapeHtml(t("training.reward"))}: ${escapeHtml(clubGoal.reward)}

    ` : ""}
    ${clubGoalProgress.reached && !clubGoal.celebratedAt ? `` : ""} @@ -1077,7 +1147,16 @@ function renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, uid, sav
    ${escapeHtml(t(clubGoal ? "training.club_goal_new" : "training.club_goal_create"))} - + + + @@ -1112,15 +1191,31 @@ function renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, uid, sav setSelectedClassId(""); renderView(); }); + const updateClubGoalTypeFields = () => { + const goalType = document.getElementById("trainingClubGoalType")?.value || "laps"; + const isSessions = goalType !== "laps"; + const lapsField = document.getElementById("trainingClubGoalLapsField"); + const sessionsField = document.getElementById("trainingClubGoalSessionsField"); + if (lapsField) lapsField.hidden = isSessions; + if (sessionsField) sessionsField.hidden = !isSessions; + }; + document.getElementById("trainingClubGoalType")?.addEventListener("change", updateClubGoalTypeFields); + updateClubGoalTypeFields(); document.getElementById("trainingClubGoalForm")?.addEventListener("submit", (event) => { event.preventDefault(); const form = new FormData(event.currentTarget); + const goalType = String(form.get("goalType") || "laps"); + const isSessions = goalType !== "laps"; state.training.clubGoal = { id: uid("club_goal"), title: String(form.get("title") || "").trim(), reward: String(form.get("reward") || "").trim(), targetLaps: Math.max(1, Math.round(Number(form.get("targetLaps") || 0) || 0)), baselineLaps: ledger.length, + metric: isSessions ? "sessions" : "laps", + exerciseKind: isSessions ? goalType : "", + targetSessions: Math.max(1, Math.round(Number(form.get("targetSessions") || 0) || 0)), + baselineSessions: countExerciseSessions(state.training.exerciseSessions, { kind: goalType }), createdAt: new Date().toISOString(), celebratedAt: "", }; diff --git a/tests/public_training_profile.test.js b/tests/public_training_profile.test.js index f5f68f3..d5e600c 100644 --- a/tests/public_training_profile.test.js +++ b/tests/public_training_profile.test.js @@ -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"); +}); diff --git a/tests/training_logic.test.js b/tests/training_logic.test.js index fe0e4d1..16ae68d 100644 --- a/tests/training_logic.test.js +++ b/tests/training_logic.test.js @@ -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];