diff --git a/README.md b/README.md
index de6a978..f1e4f55 100644
--- a/README.md
+++ b/README.md
@@ -200,7 +200,10 @@ The personal profile can also store `Club` and `Country`. These values are inher
- daily history for the latest practice days and recent personal-best improvement
- personal development trend comparing the latest 30 valid laps with the preceding 30, always isolated per class
- automatic achievements for first practice, lap totals, practice days, controlled streaks, and completed five-minute stints
+- three optional daily challenges for valid laps, controlled consecutive laps, and a completed five-minute stint
+- a positive personal summary that recognizes today's volume, personal best, pace, consistency, and endurance without ranking drivers
- a recent club progress feed ordered by activity rather than driver position or lap-count ranking
+- a configurable shared club target that counts every new valid practice lap from all registered drivers from the moment the target is created
- a collapsed Extra statistics section with active 30-minute pace blocks based on a trimmed average
- class-specific setup markers for tires, suspension, car changes, or custom notes, including an automatic 30-minute before/after comparison
- detailed stint analysis with normal pace, best 10-lap race pace, mistakes, consistency, and start/end development
diff --git a/README.sv.md b/README.sv.md
index 55d8a0d..d69f819 100644
--- a/README.sv.md
+++ b/README.sv.md
@@ -189,7 +189,10 @@ Förarprofilen kan även lagra `Klubb` och `Land`. Värdena ärvs när ytterliga
- daglig historik för de senaste träningsdagarna och förbättring mot tidigare personbästa
- personlig utvecklingstrend som jämför de senaste 30 giltiga varven med föregående 30, alltid separat per klass
- automatiska achievements för första passet, varvmängd, träningsdagar, kontrollerade varvserier och fullföljd femminutersstint
+- tre frivilliga dagsutmaningar för giltiga varv, kontrollerade varv i följd och fullföljd femminutersstint
+- en positiv personlig summering som ger kredit för dagens mängd, personbästa, tempo, jämnhet och uthållighet utan att ranka förare
- klubbfeed sorterad på senaste aktivitet i stället för placering eller totalvarvsranking
+- konfigurerbart gemensamt klubbmål som räknar alla nya giltiga träningsvarv från registrerade förare från det att målet skapas
- en hopfällbar Extra statistik-del med aktiva 30-minutersblock baserade på trimmat snitt
- klasspecifika setupmarkörer för däck, dämpare, bilbyte eller egen notering med automatisk jämförelse 30 minuter före/efter
- detaljerad stintanalys med normaltempo, bästa race pace över 10 varv, misstag, konsekvens och utveckling mellan start/slut
diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md
index b0f5b3e..1837c31 100644
--- a/docs/javascript-architecture.md
+++ b/docs/javascript-architecture.md
@@ -476,11 +476,14 @@ Owns:
- daily summaries, controlled-lap streak, and recent personal-best gain
- signed personal pace and consistency trends from the latest 30 laps versus the preceding 30, with a minimum of 10 laps in each window
- derived automatic achievements for sessions, lap totals, training days, controlled streaks, and a completed five-minute result
+- derived optional daily missions for lap volume, controlled consecutive laps, and a completed five-minute stint
+- derived positive highlights for current activity, personal-best gain, pace, consistency, and endurance
- active 30-minute pace buckets using a trimmed average and omitting empty clock periods
- persisted class-specific setup markers and 30-minute before/after comparisons
- extended stint analysis with normal pace, best 10-lap race pace, mistake count, consistency, and start/end change
- rule-based practice recommendation selection
- automatic and custom milestone progress calculation
+- shared club-goal progress from a persisted ledger baseline
Rules:
- only passings with a matching `driverId` are included
@@ -495,6 +498,8 @@ Rules:
- consistency trend is `previous spread - current spread`, so a positive value means a smaller recent spread
- trend fields remain neutral until both comparison windows contain at least 10 valid laps
- achievements are derived on read and are not persisted; custom reward goals remain persisted in `state.training.goals`
+- daily missions and positive highlights are derived on read, reset by local calendar day, and remain isolated to the selected class
+- the shared club goal is persisted, but its progress is derived from `ledger.length - clubGoal.baselineLaps`
- every save replaces ledger rows only for session IDs that still exist in `resultsBySession`
- ledger rows for a deleted session are retained and become historical records
- `sourceKey` deduplicates repeated saves of the same session passing
@@ -519,13 +524,16 @@ Owns:
- mobile-responsive training markup
- compact live-practice card and primary personal-profile action for the selected driver
- personal trend and achievement cards that never combine classes
+- positive-credit and optional daily-challenge cards based on the selected driver's own development
- recent club progress feed ordered by latest activity, without position numbers or cross-driver performance scoring
+- shared club-goal create, celebrate, replace, and delete actions in the overview
- collapsed Extra statistics with pace heatmap, setup-marker actions, weekday pace, featured stints, and an expandable full stint table
Mutations:
- replaces or appends entries in `state.training.goals`
- creates, rotates, enables, or disables entries in `state.training.shares`
- creates and removes entries in `state.training.markers`
+- replaces or clears `state.training.clubGoal`
- always calls injected `saveState()` before rerender
Route behavior:
@@ -1767,7 +1775,8 @@ This bucket is the most dynamic part of state and is mutated heavily during live
goals: TrainingGoal[],
ledger: TrainingLap[],
shares: TrainingShare[],
- markers: TrainingMarker[]
+ markers: TrainingMarker[],
+ clubGoal: TrainingClubGoal | null
}
```
@@ -1797,6 +1806,22 @@ This bucket is the most dynamic part of state and is mutated heavily during live
}
```
+`TrainingClubGoal`:
+
+```js
+{
+ id: string,
+ title: string,
+ reward: string,
+ targetLaps: number,
+ baselineLaps: number, // ledger length when this club challenge starts
+ createdAt: string,
+ celebratedAt: string // empty until the reached goal is acknowledged
+}
+```
+
+Club-goal progress is intentionally aggregate-only. Creating a replacement goal captures the current ledger length as a new baseline; historical laps remain available for personal statistics but do not advance the new shared target.
+
Share rules:
- no share exists until an admin explicitly enables the selected personal profile
- only one normalized share entry is retained per personal profile
@@ -1856,6 +1881,7 @@ Migration behavior:
- the next `saveState()` imports valid registered-driver laps from all current session results
- invalid laps, anonymous transponders, and laps at or below `500 ms` are excluded
- older state without `training.markers` normalizes to an empty marker list
+- older state without `training.clubGoal` normalizes to `null`
## 15.9 `Settings`
diff --git a/src/app.js b/src/app.js
index 491ac08..fc92a7d 100644
--- a/src/app.js
+++ b/src/app.js
@@ -18,7 +18,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, 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, buildTrainingRecommendation as buildTrainingRecommendationLogic, getTrainingGoalProgress as getTrainingGoalProgressLogic, getNextTrainingMilestone as getNextTrainingMilestoneLogic, 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, 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 { renderTrainingView } from "./training_views.js";
import { importDriversCsv, importCarsCsv } from "./csv_import.js";
@@ -293,7 +293,7 @@ const buildDriverActivePracticeStats = (trainingState, driverId, nowTs = Date.no
const buildTrainingRecommendation = (stats) => buildTrainingRecommendationLogic(stats);
const getTrainingGoalProgress = (goal, stats) => getTrainingGoalProgressLogic(goal, stats);
const getNextTrainingMilestone = (stats) => getNextTrainingMilestoneLogic(stats);
-const renderTraining = () => renderTrainingView({ state, dom, t, escapeHtml, formatLap, formatRaceClock, uid, saveState, renderView, normalizeTrainingState, syncTrainingLedger, buildDriverTrainingStats, buildDriverTimingChannelStats: buildDriverTimingChannelStatsLogic, buildDriverActivePracticeStats, buildTrainingRecommendation, getTrainingGoalProgress, getNextTrainingMilestone, 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, renderView, normalizeTrainingState, syncTrainingLedger, buildDriverTrainingStats, buildDriverTimingChannelStats: buildDriverTimingChannelStatsLogic, buildDriverActivePracticeStats, 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 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))) {
@@ -543,13 +543,55 @@ const TRANSLATIONS = {
"training.achievement_empty": "Kör första passet för att börja samla achievements.",
"training.achievement_first_session": "Första träningspasset",
"training.achievement_laps_100": "100 träningsvarv",
+ "training.achievement_laps_250": "250 träningsvarv",
"training.achievement_laps_500": "500 träningsvarv",
"training.achievement_laps_1000": "1 000 träningsvarv",
"training.achievement_laps_1500": "1 500 träningsvarv",
"training.achievement_days_3": "3 träningsdagar",
"training.achievement_days_10": "10 träningsdagar",
+ "training.achievement_sessions_5": "5 fullföljda träningspass",
"training.achievement_controlled_5": "5 kontrollerade varv i rad",
+ "training.achievement_controlled_10": "10 kontrollerade varv i rad",
"training.achievement_five_minute": "Fullföljd femminutersstint",
+ "training.achievement_five_minutes_3": "3 fullföljda femminutersstintar",
+ "training.achievement_consistency_5": "Jämnhet under 5 procent",
+ "training.achievement_pace_gain": "Förbättrat snittempo",
+ "training.credit_eyebrow": "Din träning räknas",
+ "training.credit_today_title": "Bra jobbat, {driver}!",
+ "training.credit_today_body": "Varje genomfört varv bygger erfarenhet. Här är det du ska ta med dig från dagens körning.",
+ "training.credit_ready_title": "Redo när du är, {driver}",
+ "training.credit_ready_body": "Du behöver inte slå någon annan. Börja med ett lugnt varv och bygg vidare därifrån.",
+ "training.highlight_today_laps": "{laps} giltiga varv idag",
+ "training.highlight_personal_best": "Personbästat förbättrat med {lap}",
+ "training.highlight_pace_gain": "Snittempot förbättrat med {lap} per varv",
+ "training.highlight_consistency_gain": "{value} procentenheter jämnare körning",
+ "training.highlight_controlled_streak": "{laps} kontrollerade varv i rad idag",
+ "training.highlight_five_minute": "{count} fullföljd femminutersstint idag",
+ "training.highlight_keep_building": "{laps} varv har redan byggt din erfarenhet",
+ "training.highlight_ready": "Första giltiga varvet startar din utvecklingsresa",
+ "training.optional_challenges": "Valfritt, ingen press",
+ "training.daily_missions": "Dagens utmaningar",
+ "training.daily_missions_hint": "Tre små mål som nollställs varje dag. De ger kredit för mängd, kontroll och uthållighet i vald klass.",
+ "training.mission_laps_today": "Samla 20 varv",
+ "training.mission_controlled_today": "Kör 5 kontrollerade varv i rad",
+ "training.mission_five_minute_today": "Fullfölj en femminutersstint",
+ "training.mission_progress": "{current} / {target}",
+ "training.together": "Alla bidrar",
+ "training.club_goal": "Klubbens gemensamma mål",
+ "training.club_goal_active": "Gemensam utmaning",
+ "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_remaining": "{laps} klubbvarv 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",
+ "training.club_goal_name_placeholder": "Till exempel Klubben mot 10 000",
+ "training.club_goal_reward_placeholder": "Till exempel gemensam pizzakväll",
+ "training.club_goal_start": "Starta klubbmålet",
+ "training.club_goal_baseline_hint": "Målet börjar på noll när det sparas. Tidigare träningsvarv ligger kvar i statistiken men räknas inte i det nya klubbmålet.",
+ "training.club_goal_mark_celebrated": "Markera som firat",
+ "training.club_goal_delete": "Ta bort klubbmålet",
"training.progress_feed": "Klubbens senaste framsteg",
"training.progress_not_positions": "Utveckling, inte placering",
"training.feed_personal_best": "nytt PB, {lap} förbättring",
@@ -1441,6 +1483,9 @@ const TRANSLATIONS = {
"guide.training_18": "18. Före en biländring väljer du Ny setup, däck, dämpare, bil eller egen notering och klickar Markera ändring nu. Markören sparas endast för vald personlig profil och klass.",
"guide.training_19": "19. När det finns minst tre giltiga varv inom 30 minuter både före och efter markören visas förbättringen automatiskt. Jämför bara pass med liknande bana och förhållanden.",
"guide.training_20": "20. Stintanalysen använder glapp över två minuter som gräns. Normaltempo är trimmat snitt, misstag är varv över 15 procent långsammare än stintens median och Race pace 10 kräver tio sammanhängande varv.",
+ "guide.training_21": "21. Dagens utmaningar är frivilliga och nollställs per kalenderdag och vald klass: 20 giltiga varv, 5 kontrollerade varv i rad och en fullföljd femminutersstint. De påverkar aldrig raceresultat eller timing.",
+ "guide.training_22": "22. Träningskortet ger positiv kredit för dagens varv, PB, förbättrat snittempo, jämnhet och uthållighet. Kortet jämför bara föraren med den egna historiken inom vald klass.",
+ "guide.training_23": "23. Skapa ett klubbmål från Träningsöversikten. Målet börjar på noll när det sparas och summerar därefter alla giltiga registrerade träningsvarv. När målet nås kan det markeras som firat och ersättas med ett nytt.",
"overlay.title": "Overlay",
"overlay.subtitle": "Extern leaderboard-skärm",
"overlay.no_active": "Ingen aktiv session vald.",
@@ -1744,13 +1789,55 @@ const TRANSLATIONS = {
"training.achievement_empty": "Complete the first practice session to start collecting achievements.",
"training.achievement_first_session": "First practice session",
"training.achievement_laps_100": "100 practice laps",
+ "training.achievement_laps_250": "250 practice laps",
"training.achievement_laps_500": "500 practice laps",
"training.achievement_laps_1000": "1,000 practice laps",
"training.achievement_laps_1500": "1,500 practice laps",
"training.achievement_days_3": "3 practice days",
"training.achievement_days_10": "10 practice days",
+ "training.achievement_sessions_5": "5 completed practice sessions",
"training.achievement_controlled_5": "5 controlled laps in a row",
+ "training.achievement_controlled_10": "10 controlled laps in a row",
"training.achievement_five_minute": "Completed five-minute stint",
+ "training.achievement_five_minutes_3": "3 completed five-minute stints",
+ "training.achievement_consistency_5": "Consistency below 5 percent",
+ "training.achievement_pace_gain": "Improved average pace",
+ "training.credit_eyebrow": "Your practice counts",
+ "training.credit_today_title": "Good work, {driver}!",
+ "training.credit_today_body": "Every completed lap builds experience. These are today's wins.",
+ "training.credit_ready_title": "Ready when you are, {driver}",
+ "training.credit_ready_body": "You do not need to beat anyone else. Start with one calm lap and build from there.",
+ "training.highlight_today_laps": "{laps} valid laps today",
+ "training.highlight_personal_best": "Personal best improved by {lap}",
+ "training.highlight_pace_gain": "Average pace improved by {lap} per lap",
+ "training.highlight_consistency_gain": "{value} percentage points more consistent",
+ "training.highlight_controlled_streak": "{laps} controlled laps in a row today",
+ "training.highlight_five_minute": "{count} completed five-minute stint today",
+ "training.highlight_keep_building": "{laps} laps have already built your experience",
+ "training.highlight_ready": "Your first valid lap starts the development journey",
+ "training.optional_challenges": "Optional, no pressure",
+ "training.daily_missions": "Today's challenges",
+ "training.daily_missions_hint": "Three small goals reset every day. They recognize volume, control, and endurance in the selected class.",
+ "training.mission_laps_today": "Collect 20 laps",
+ "training.mission_controlled_today": "Run 5 controlled laps in a row",
+ "training.mission_five_minute_today": "Complete a five-minute stint",
+ "training.mission_progress": "{current} / {target}",
+ "training.together": "Everyone contributes",
+ "training.club_goal": "Shared club goal",
+ "training.club_goal_active": "Club challenge",
+ "training.club_goal_reached": "Goal reached",
+ "training.club_goal_celebrated": "Goal celebrated",
+ "training.club_goal_default": "Together to {laps} laps",
+ "training.club_goal_remaining": "{laps} club laps 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",
+ "training.club_goal_name_placeholder": "For example Club to 10,000",
+ "training.club_goal_reward_placeholder": "For example a shared pizza night",
+ "training.club_goal_start": "Start club goal",
+ "training.club_goal_baseline_hint": "The target starts at zero when saved. Earlier practice laps remain in statistics but do not count toward the new club goal.",
+ "training.club_goal_mark_celebrated": "Mark as celebrated",
+ "training.club_goal_delete": "Delete club goal",
"training.progress_feed": "Latest club progress",
"training.progress_not_positions": "Development, not position",
"training.feed_personal_best": "new PB, improved by {lap}",
@@ -2642,6 +2729,9 @@ const TRANSLATIONS = {
"guide.training_18": "18. Before changing the car, select New setup, tires, suspension, car, or a custom note and click Mark change now. The marker is stored only for the selected personal profile and class.",
"guide.training_19": "19. Once at least three valid laps exist within 30 minutes both before and after the marker, the change is compared automatically. Only compare runs with similar track conditions.",
"guide.training_20": "20. Stint analysis uses gaps longer than two minutes as boundaries. Normal pace is a trimmed average, mistakes are laps over 15 percent slower than the stint median, and Race pace 10 requires ten consecutive laps.",
+ "guide.training_21": "21. Today's challenges are optional and reset per calendar day and selected class: 20 valid laps, 5 controlled laps in a row, and one completed five-minute stint. They never affect race results or timing.",
+ "guide.training_22": "22. The motivation card recognizes today's laps, personal bests, improved average pace, consistency, and endurance. It only compares the driver with their own history in the selected class.",
+ "guide.training_23": "23. Create a club goal from Practice overview. The target starts at zero when saved and then aggregates every valid registered practice lap. Once reached, it can be marked as celebrated and replaced with a new goal.",
"overlay.title": "Overlay",
"overlay.subtitle": "External leaderboard screen",
"overlay.no_active": "No active session selected.",
diff --git a/src/misc_views.js b/src/misc_views.js
index 8d08c13..d74549c 100644
--- a/src/misc_views.js
+++ b/src/misc_views.js
@@ -64,7 +64,7 @@ export function renderGuideView({ dom, t }) {