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

A driver completing the practice timer, "Jämna varv", or "Stegträning"
now gets a permanent history entry instead of the outcome only existing
as a one-off notification - visible on both their mobile profile and
the admin Training page, so progress over past sessions is actually
reviewable. Each completion earns a prominent emoji-badged credit.

The completion is verified server-side against the driver's real
recorded laps before being saved, reusing the same progress builders
live tracking already uses - the client can only ask for a completion
to be checked and recorded, never supply the outcome itself, so a
driver can't fabricate a completed session (and the reward tied to it)
any more than they could fabricate a lap time.

Both per-driver and club-wide goals can now target "complete N
sessions of one exercise kind" as an alternative to "N laps", sharing
the existing reward/claim mechanism and forms.

Fixes a real bug found while building this: a pre-existing two-argument
wrapper around getTrainingGoalProgress in app.js silently dropped the
new third argument, so every sessions-metric goal displayed 0 progress
regardless of how many sessions were actually completed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-09 20:43:47 +02:00
parent bed0dfd9f2
commit 1c4a06b69e
14 changed files with 800 additions and 52 deletions

View File

@@ -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",