diff --git a/README.md b/README.md index d6039e2..46a01f2 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,9 @@ The personal profile can also store `Club` and `Country`. These values are inher - best 3 consecutive laps plus best 5-minute result and its average pace - the best 5-minute result counts the final lap when it started before `5:00` (`300 s`), even when the result finishes after the cutoff - 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 +- a recent club progress feed ordered by activity rather than driver position or lap-count ranking - automatic progression levels at useful lap totals without manual setup - a configurable practice timer split into maximum-focus and controlled 80-90% blocks - custom lap milestones with quick targets at `500`, `1000`, and `1500` laps @@ -215,7 +218,7 @@ Each registered driver can have an optional personal mobile link: 3. Copy or open the generated link. 4. Use `Create new link` to revoke the previous URL, or `Disable profile` to turn sharing off. -The URL uses a 192-bit random bearer token rather than a driver ID. The same link provides a class selector with fully separate lap times and practice results for each linked class. The public API does not return transponder IDs, other driver names, race setup or admin state. +The URL uses a 192-bit random bearer token rather than a driver ID. The same link provides a class selector with fully separate lap times and practice results for each linked class. The public API does not return transponder IDs, other driver names, club ranking, race setup or admin state. The mobile page also includes a local practice timer where the driver chooses minutes for `Maximum focus` and `Controlled`. The default is `10 + 10`, while a five-minute heat can for example use `5 + 0`. While a shared Free Practice session keeps running, the card shows that driver's lap count, best lap and average since the personal timer started. Timer state and duration choices stay in that phone's browser, separately per driver and class, and do not modify race data. Durations are locked while a session runs and can be changed after finishing or resetting it. diff --git a/README.sv.md b/README.sv.md index 4fdfe50..561e3e7 100644 --- a/README.sv.md +++ b/README.sv.md @@ -183,6 +183,9 @@ Förarprofilen kan även lagra `Klubb` och `Land`. Värdena ärvs när ytterliga - bästa 3 sammanhängande varv samt bästa femminutersresultat med dess avg pace - femminutersresultatet räknar slutvarvet om det startade före `5:00` (`300 s`), även när målgången sker efter tidsgränsen - 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 +- klubbfeed sorterad på senaste aktivitet i stället för placering eller totalvarvsranking - automatiska utvecklingsnivåer vid lämpliga varvantal utan manuell konfiguration - rekommenderat träningspass med valbara minuter för maxfokus och kontrollerad körning på 80-90 procent - egna varvmål med snabbval på `500`, `1000` och `1500` varv @@ -204,7 +207,7 @@ Varje registrerad förare kan få en valfri personlig mobillänk: 3. Kopiera eller öppna länken som skapas. 4. Använd `Skapa ny länk` för att återkalla den gamla eller `Stäng av profil` för att stoppa delningen. -Länken använder en slumpad 192-bitars bearer-nyckel i stället för förar-ID. Samma länk visar profilens klasser i ett klassval, med helt separata varvtider och träningsresultat. Det publika API:t lämnar inte ut transponder-ID, andra förares namn, race setup eller admin-state. +Länken använder en slumpad 192-bitars bearer-nyckel i stället för förar-ID. Samma länk visar profilens klasser i ett klassval, med helt separata varvtider och träningsresultat. Det publika API:t lämnar inte ut transponder-ID, andra förares namn, klubbrankning, race setup eller admin-state. Mobilsidan har också en lokal träningstimer där föraren själv väljer minuter för `Maxfokus` och `Kontrollerad`. Standard är `10 + 10`, men ett femminutersheat kan exempelvis köras som `5 + 0`. Medan en gemensam Free Practice fortsätter vara igång visar kortet just den förarens antal varv, bästa varv och snitt sedan den personliga timern startades. Timer och tidsval sparas bara i mobilens webbläsare, separat per förare och klass, och ändrar ingen racedata. Tiderna låses medan passet pågår och kan ändras efter målgång eller nollställning. diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index eb42b37..ed0f73b 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -167,7 +167,7 @@ Owns: - enabled share-token lookup - profile resolution across several race-specific driver records - strict per-class lap filtering and aggregate statistics -- sanitized branding, class-specific goal, rank, and recommendation response +- sanitized branding plus class-specific goals and recommendation response - clock-skew-safe personal timer start/window normalization - bounded current-session lap, best-lap, and average calculations @@ -474,6 +474,8 @@ Owns: - best N consecutive laps and fixed-duration result calculation - five-minute (`300000 ms`) results include the final lap when its start is before the cutoff and expose average pace for the counted laps - 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 - rule-based practice recommendation selection - automatic and custom milestone progress calculation @@ -486,6 +488,10 @@ Rules: - invalid laps and lap times at or below `500 ms` are excluded - anonymous Open Practice transponders are excluded until registered as drivers - consistency is the P10-to-P90 spread of the latest 30 valid laps divided by their mean +- pace trend is `previous-window mean - current-window mean`, so a positive value means faster recent laps +- 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` - 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 @@ -506,6 +512,8 @@ Owns: - milestone/reward create, claim, and delete actions - 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 +- recent club progress feed ordered by latest activity, without position numbers or cross-driver performance scoring Mutations: - replaces or appends entries in `state.training.goals` @@ -2167,7 +2175,7 @@ Authorization: Sanitized response fields: - selected person's display name plus linked class names and brands - one `classProfiles` entry per linked class; no cross-class performance aggregate is emitted -- class-specific numeric club rank without other driver names +- no club rank or other driver names; the public profile focuses on personal development - class-specific totals, best/average time, consistency, controlled streak, stints, best-three, five-minute result, and daily summaries - optional class-specific `currentSession` aggregates when a valid start age (or backward-compatible recent `since` timestamp) is supplied - class-specific next automatic milestone, recommendation level, target lap, goals, and reward progress diff --git a/docs/security-performance-audit.md b/docs/security-performance-audit.md index 3756976..76146e8 100644 --- a/docs/security-performance-audit.md +++ b/docs/security-performance-audit.md @@ -9,7 +9,7 @@ This review covers the Node/Express backend, SQLite persistence, browser runtime ## Verification baseline - `npm audit --omit=dev`: 0 known vulnerabilities across 105 production dependencies -- `npm test`: 74 tests passing after the changes in this audit +- `npm test`: 77 tests passing after the changes in this audit - `npm run verify`: Express and SQLite runtime verified - Runtime target: Node.js 22-26 diff --git a/server/public_training_profile.js b/server/public_training_profile.js index f098c7d..065ad11 100644 --- a/server/public_training_profile.js +++ b/server/public_training_profile.js @@ -55,8 +55,6 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp .sort((left, right) => left.timestamp - right.timestamp); const stats = buildPublicTrainingStats(classLaps); const channelStats = buildPublicTimingChannelStats(state, classDriverIds); - const rankedTotals = getRankedTrainingTotals(ledger, drivers, classId); - const rankIndex = rankedTotals.findIndex(([profileId]) => profileId === driverProfile.id); 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)); @@ -65,8 +63,6 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp classId, className: String(classes.find((item) => String(item?.id || "") === classId)?.name || "").trim(), brand: brands.join(" / "), - clubRank: rankIndex >= 0 ? rankIndex + 1 : 0, - rankedDrivers: rankedTotals.length, stats, channelStats, nextMilestone: getPublicTrainingMilestone(stats.totalLaps), @@ -78,7 +74,7 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp }; }); const activeClass = classProfiles[0] || { - classId: "", className: "", brand: "", clubRank: 0, rankedDrivers: 0, + classId: "", className: "", brand: "", stats: buildPublicTrainingStats([]), nextMilestone: getPublicTrainingMilestone(0), channelStats: buildPublicTimingChannelStats({}, new Set()), recommendation: getPublicTrainingRecommendation({ totalLaps: 0 }), goals: [], currentSession: null, @@ -91,8 +87,6 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp name: String(driver.name || "Driver").trim(), brand: activeClass.brand, className: activeClass.className, - clubRank: activeClass.clubRank, - rankedDrivers: activeClass.rankedDrivers, }, branding: { clubName: String(settings.clubName || "JMK RB").trim(), @@ -176,20 +170,6 @@ function buildPublicTrainingGoal(goal, currentLaps) { }; } -function getRankedTrainingTotals(ledger, drivers, classId = "") { - const profileByDriverId = new Map(drivers - .filter((driver) => !classId || String(driver?.classId || "") === String(classId)) - .map((driver) => [String(driver?.id || ""), getPublicDriverProfileId(driver)])); - const totals = new Map(); - ledger.forEach((entry) => { - const driverId = String(entry?.driverId || ""); - const profileId = profileByDriverId.get(driverId); - if (!profileId || Number(entry?.lapMs || 0) <= 500) return; - totals.set(profileId, (totals.get(profileId) || 0) + 1); - }); - return [...totals.entries()].sort((left, right) => right[1] - left[1]); -} - function buildPublicTrainingStats(laps) { const lapTimes = laps.map((lap) => lap.lapMs); const recent = lapTimes.slice(-30); diff --git a/src/app.js b/src/app.js index bf7293f..01581fc 100644 --- a/src/app.js +++ b/src/app.js @@ -470,7 +470,7 @@ const TRANSLATIONS = { "training.enable_profile": "Aktivera mobilprofil", "training.replace_link_confirm": "Den gamla länken slutar fungera direkt. Vill du skapa en ny?", "training.club_progress": "Klubbens träningsöversikt", - "training.club_progress_hint": "Jämför totalvarv och nästa mål när det behövs.", + "training.club_progress_hint": "Följ aktivitet, totalvarv och nästa personliga mål utan inbördes ranking.", "training.next_target": "Nästa mål", "training.start_personal": "Öppna personlig träning", "training.copy_mobile_link": "Kopiera mobillänk", @@ -485,6 +485,40 @@ const TRANSLATIONS = { "training.best_three": "Bästa 3 sammanhängande", "training.best_five": "Bästa 5 min", "training.five_avg_pace": "Avg pace, bästa 5 min", + "training.your_progress": "Din egen kurva", + "training.recent_trend": "Utveckling senaste varven", + "training.pace_trend": "Snittempo", + "training.consistency_trend": "Jämnhet", + "training.trend_collecting": "Samlar jämförbart underlag", + "training.trend_collecting_short": "Minst 20 varv behövs", + "training.trend_faster": "{lap} snabbare per varv", + "training.trend_slower": "{lap} långsammare just nu", + "training.trend_stable": "Stabil nivå", + "training.consistency_better": "{value} procentenheter jämnare", + "training.consistency_building": "Fortsätt bygga jämnhet", + "training.trend_hint": "Jämför de senaste 30 giltiga varven med de föregående 30 i samma klass. Det här är personlig utveckling, inte en jämförelse mot andra förare.", + "training.personal_credit": "Framsteg som räknas", + "training.achievements": "Achievements", + "training.achievement_unlocked": "Upplåst", + "training.achievement_next": "Nästa delmål", + "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_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_controlled_5": "5 kontrollerade varv i rad", + "training.achievement_five_minute": "Fullföljd femminutersstint", + "training.progress_feed": "Klubbens senaste framsteg", + "training.progress_not_positions": "Utveckling, inte placering", + "training.feed_personal_best": "nytt PB, {lap} förbättring", + "training.feed_average_gain": "snittet förbättrat med {lap}", + "training.feed_five_minute": "fullföljde 5 minuter med {laps} varv", + "training.feed_controlled_streak": "{laps} kontrollerade varv i rad", + "training.feed_practice_laps": "{laps} giltiga träningsvarv", + "training.feed_empty": "När förare börjar träna visas deras personliga framsteg här.", "mode.track": "Sponsor Event", "mode.race": "Race", "dashboard.events": "Event", @@ -1362,6 +1396,8 @@ const TRANSLATIONS = { "guide.training_12": "12. Ett glapp över 2 minuter mellan passeringar startar automatiskt en ny stint. Ett långt pausvarv används som gräns men räknas inte som prestationsvarv i stintstatistiken.", "guide.training_13": "13. Bästa 5 min använder 300 sekunder. Ett slutvarv räknas om det startade före 5:00 även om målgången sker efter tiden, exempelvis 17/05:14.12:123. Avg pace visas för varven i just det resultatet.", "guide.training_14": "14. En personlig profil och mobillänk kan innehålla flera klasser, men 2WD/4WD-statistik, stints, varvtider och rekommendationer visas alltid separat per vald klass.", + "guide.training_15": "15. Utvecklingskortet jämför de senaste 30 giltiga varven med de föregående 30 inom samma klass. Positivt värde betyder snabbare snittempo eller mindre spridning; för litet underlag visas som insamling i stället för en osäker trend.", + "guide.training_16": "16. Achievements och klubbens framstegsfeed lyfter träningspass, varvmål, jämn körning och fullföljda femminutersstints. Feedens ordning följer senaste aktivitet och är inte en ranking mellan förare.", "overlay.title": "Overlay", "overlay.subtitle": "Extern leaderboard-skärm", "overlay.no_active": "Ingen aktiv session vald.", @@ -1592,7 +1628,7 @@ const TRANSLATIONS = { "training.enable_profile": "Enable mobile profile", "training.replace_link_confirm": "The old link will stop working immediately. Create a new one?", "training.club_progress": "Club practice overview", - "training.club_progress_hint": "Compare total laps and next targets when needed.", + "training.club_progress_hint": "Follow activity, total laps, and each personal target without driver ranking.", "training.next_target": "Next target", "training.start_personal": "Open personal training", "training.copy_mobile_link": "Copy mobile link", @@ -1607,6 +1643,40 @@ const TRANSLATIONS = { "training.best_three": "Best 3 consecutive", "training.best_five": "Best 5 min", "training.five_avg_pace": "Average pace, best 5 min", + "training.your_progress": "Your own curve", + "training.recent_trend": "Recent development", + "training.pace_trend": "Average pace", + "training.consistency_trend": "Consistency", + "training.trend_collecting": "Collecting comparable data", + "training.trend_collecting_short": "At least 20 laps required", + "training.trend_faster": "{lap} faster per lap", + "training.trend_slower": "{lap} slower right now", + "training.trend_stable": "Stable level", + "training.consistency_better": "{value} percentage points more consistent", + "training.consistency_building": "Keep building consistency", + "training.trend_hint": "Compares the latest 30 valid laps with the preceding 30 in the same class. This is personal development, not a comparison with other drivers.", + "training.personal_credit": "Progress that counts", + "training.achievements": "Achievements", + "training.achievement_unlocked": "Unlocked", + "training.achievement_next": "Next milestone", + "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_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_controlled_5": "5 controlled laps in a row", + "training.achievement_five_minute": "Completed five-minute stint", + "training.progress_feed": "Latest club progress", + "training.progress_not_positions": "Development, not position", + "training.feed_personal_best": "new PB, improved by {lap}", + "training.feed_average_gain": "average improved by {lap}", + "training.feed_five_minute": "completed 5 minutes with {laps} laps", + "training.feed_controlled_streak": "{laps} controlled laps in a row", + "training.feed_practice_laps": "{laps} valid practice laps", + "training.feed_empty": "Personal progress appears here when drivers start practicing.", "mode.track": "Track Event", "mode.race": "Race", "dashboard.events": "Events", @@ -2484,6 +2554,8 @@ const TRANSLATIONS = { "guide.training_12": "12. A gap longer than 2 minutes between passings automatically starts a new stint. A long return lap defines the boundary but is excluded from stint performance metrics.", "guide.training_13": "13. Best 5 min uses 300 seconds. A final lap counts when it started before 5:00 even if it finishes later, for example 17/05:14.12:123. Average pace is calculated from the laps in that exact result.", "guide.training_14": "14. One personal profile and mobile link can contain several classes, but 2WD/4WD statistics, stints, lap times, and recommendations are always shown separately for the selected class.", + "guide.training_15": "15. The development card compares the latest 30 valid laps with the preceding 30 in the same class. A positive value means faster average pace or a smaller spread; insufficient data is shown as collecting rather than an unreliable trend.", + "guide.training_16": "16. Achievements and the club progress feed recognize practice sessions, lap milestones, controlled driving, and completed five-minute stints. The feed follows recent activity and is not a ranking between drivers.", "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 8df2f68..2de1723 100644 --- a/src/misc_views.js +++ b/src/misc_views.js @@ -64,7 +64,7 @@ export function renderGuideView({ dom, t }) {
${renderGuidePanel("guide.dashboard_title", ["guide.dashboard_1", "guide.dashboard_2", "guide.dashboard_3", "guide.dashboard_4"], "", { t })} - ${renderGuidePanel("guide.training_title", ["guide.training_1", "guide.training_2", "guide.training_3", "guide.training_4", "guide.training_5", "guide.training_6", "guide.training_7", "guide.training_8", "guide.training_9", "guide.training_10", "guide.training_11", "guide.training_12", "guide.training_13", "guide.training_14"], "", { t })} + ${renderGuidePanel("guide.training_title", ["guide.training_1", "guide.training_2", "guide.training_3", "guide.training_4", "guide.training_5", "guide.training_6", "guide.training_7", "guide.training_8", "guide.training_9", "guide.training_10", "guide.training_11", "guide.training_12", "guide.training_13", "guide.training_14", "guide.training_15", "guide.training_16"], "", { t })}
diff --git a/src/public_training.js b/src/public_training.js index 2acf858..d6276ec 100644 --- a/src/public_training.js +++ b/src/public_training.js @@ -14,7 +14,6 @@ const messages = { loadError: "Kunde inte läsa träningsprofilen. Länken kan vara avstängd eller ersatt.", development: "Förarutveckling", class: "Klass", - rank: "Klubbrank {rank} av {count}", laps: "Totala varv", days: "Träningsdagar", best: "Bästa varv", @@ -73,7 +72,6 @@ const messages = { loadError: "The training profile could not be loaded. The link may be disabled or replaced.", development: "Driver development", class: "Class", - rank: "Club rank {rank} of {count}", laps: "Total laps", days: "Practice days", best: "Best lap", @@ -195,7 +193,6 @@ function renderProfile(data) { const timerConfig = getTimerConfig(timerState); const timerDurationMs = getTimerDurationMs(timerConfig); const timerLocked = Boolean(timerState?.startedAt && getTimerElapsed(timerState) < timerDurationMs); - const rank = activeClass?.clubRank && activeClass?.rankedDrivers ? t("rank", { rank: activeClass.clubRank, count: activeClass.rankedDrivers }) : ""; const logo = /^data:image\/(?:png|jpeg|webp);base64,/i.test(branding.logoDataUrl || "") ? `` : `
RC
`; @@ -243,7 +240,6 @@ function renderProfile(data) {
${classProfiles.length > 1 ? `` : activeClass?.className ? `${escapeHtml(activeClass.className)}` : ""} ${activeClass?.brand ? `${escapeHtml(activeClass.brand)}` : ""} - ${rank ? `${escapeHtml(rank)}` : ""}
diff --git a/src/styles.css b/src/styles.css index a6931a3..a6f5659 100644 --- a/src/styles.css +++ b/src/styles.css @@ -474,6 +474,131 @@ body { margin-top: 14px; } +.training-development-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + margin-top: 14px; +} + +.training-development-card { + padding: 18px; + border: 1px solid var(--line); + border-radius: 16px; + background: + linear-gradient(135deg, color-mix(in srgb, var(--accent) 8%, transparent), transparent 55%), + var(--panel); + box-shadow: var(--shadow); +} + +.training-development-card .training-section-title { + margin: 0 0 14px; +} + +.training-development-card .training-section-title h3, +.training-progress-panel .panel-header h3 { + margin: 4px 0 0; +} + +.training-development-card > p { + margin: 12px 0 0; + color: var(--muted); + font-size: 0.82rem; +} + +.training-trend-metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + overflow: hidden; + border: 1px solid var(--line); + border-radius: 12px; +} + +.training-trend-metrics > div { + display: grid; + gap: 5px; + padding: 13px; +} + +.training-trend-metrics > div + div { + border-left: 1px solid var(--line); +} + +.training-trend-metrics span, +.training-achievement small, +.training-progress-entry small { + color: var(--muted); + font-size: 0.74rem; +} + +.training-achievements { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.training-achievement { + display: flex; + align-items: center; + min-width: 0; + gap: 9px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 11px; + background: var(--surface-elevated); +} + +.training-achievement.is-next { + opacity: 0.68; +} + +.training-achievement-mark, +.training-progress-initial { + display: grid; + flex: 0 0 auto; + place-items: center; + width: 34px; + height: 34px; + border-radius: 10px; + background: color-mix(in srgb, var(--accent) 18%, var(--panel-2) 82%); + color: color-mix(in srgb, var(--accent) 55%, var(--text) 45%); + font-family: Orbitron, sans-serif; + font-size: 0.68rem; + font-weight: 700; +} + +.training-achievement.is-unlocked .training-achievement-mark { + background: color-mix(in srgb, var(--success) 20%, var(--panel-2) 80%); + color: color-mix(in srgb, var(--success) 62%, var(--text) 38%); +} + +.training-achievement strong, +.training-achievement small, +.training-progress-entry strong, +.training-progress-entry small { + display: block; + overflow: hidden; + text-overflow: ellipsis; +} + +.training-progress-feed { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 9px; +} + +.training-progress-entry.training-club-driver { + display: flex; + align-items: center; + min-width: 0; + gap: 11px; + padding: 11px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface-elevated); + text-align: left; +} + .training-insight-grid, .training-focus-grid { display: grid; @@ -2865,7 +2990,8 @@ select:focus { } .training-insight-grid, - .training-focus-grid { + .training-focus-grid, + .training-development-grid { grid-template-columns: 1fr; } @@ -2983,6 +3109,16 @@ select:focus { grid-template-columns: 1fr; } + .training-trend-metrics, + .training-achievements { + grid-template-columns: 1fr; + } + + .training-trend-metrics > div + div { + border-top: 1px solid var(--line); + border-left: 0; + } + .training-goals { grid-template-columns: 1fr; } diff --git a/src/training_logic.js b/src/training_logic.js index e246e21..d95ae83 100644 --- a/src/training_logic.js +++ b/src/training_logic.js @@ -136,6 +136,37 @@ function percentile(values, ratio) { return sorted[index]; } +function average(values) { + return values.length ? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length) : 0; +} + +function consistencySpread(values) { + const mean = average(values); + return mean ? ((percentile(values, 0.9) - percentile(values, 0.1)) / mean) * 100 : 0; +} + +export function buildTrainingAchievements(stats) { + const source = stats && typeof stats === "object" ? stats : {}; + const definitions = [ + ["first_session", Number(source.totalSessions || 0), 1], + ["laps_100", Number(source.totalLaps || 0), 100], + ["laps_500", Number(source.totalLaps || 0), 500], + ["laps_1000", Number(source.totalLaps || 0), 1000], + ["laps_1500", Number(source.totalLaps || 0), 1500], + ["days_3", Number(source.trainingDays || 0), 3], + ["days_10", Number(source.trainingDays || 0), 10], + ["controlled_5", Number(source.controlledLapStreak || 0), 5], + ["five_minute", source.bestFiveMinuteResult ? 1 : 0, 1], + ]; + return definitions.map(([id, current, target]) => ({ + id, + current: Math.max(0, current), + target, + percent: Math.min(100, Math.max(0, (current / target) * 100)), + unlocked: current >= target, + })); +} + function getLocalDayKey(timestamp) { const date = new Date(Number(timestamp || 0)); if (Number.isNaN(date.getTime())) return "unknown"; @@ -267,18 +298,16 @@ export function buildDriverTrainingStats(state, driverId, classId = "") { const laps = getDriverTrainingLaps(state, driverId, classId); const lapTimes = laps.map((lap) => lap.lapMs); const recentLapTimes = lapTimes.slice(-30); + const previousWindowLapTimes = lapTimes.slice(Math.max(0, lapTimes.length - 60), Math.max(0, lapTimes.length - 30)); const earlierLapTimes = lapTimes.slice(0, Math.max(0, lapTimes.length - recentLapTimes.length)); const totalLaps = laps.length; const bestLapMs = totalLaps ? Math.min(...lapTimes) : 0; const averageLapMs = totalLaps ? Math.round(lapTimes.reduce((sum, value) => sum + value, 0) / totalLaps) : 0; - const recentAverageMs = recentLapTimes.length - ? Math.round(recentLapTimes.reduce((sum, value) => sum + value, 0) / recentLapTimes.length) - : 0; - const recentP10Ms = percentile(recentLapTimes, 0.1); - const recentP90Ms = percentile(recentLapTimes, 0.9); - const consistencySpreadPct = recentAverageMs - ? ((recentP90Ms - recentP10Ms) / recentAverageMs) * 100 - : 0; + const recentAverageMs = average(recentLapTimes); + const previousRecentAverageMs = average(previousWindowLapTimes); + const consistencySpreadPct = consistencySpread(recentLapTimes); + const previousConsistencySpreadPct = consistencySpread(previousWindowLapTimes); + const trendSampleReady = recentLapTimes.length >= 10 && previousWindowLapTimes.length >= 10; const previousBestMs = earlierLapTimes.length ? Math.min(...earlierLapTimes) : 0; const recentBestMs = recentLapTimes.length ? Math.min(...recentLapTimes) : 0; const personalBestGainMs = previousBestMs && recentBestMs ? Math.max(0, previousBestMs - recentBestMs) : 0; @@ -293,18 +322,23 @@ export function buildDriverTrainingStats(state, driverId, classId = "") { const bestThreeConsecutive = getBestConsecutiveTrainingLaps(stints, 3); const bestFiveMinuteResult = getBestTrainingDurationResult(stints, 5 * 60 * 1000); - return { + const stats = { totalLaps, totalSessions: new Set(laps.map((lap) => lap.sessionId)).size, trainingDays: dailySummaries.length, bestLapMs, averageLapMs, recentAverageMs, + previousRecentAverageMs, + averageTrendMs: trendSampleReady ? previousRecentAverageMs - recentAverageMs : 0, recentBestMs, previousBestMs, personalBestGainMs, controlledLapStreak, consistencySpreadPct, + previousConsistencySpreadPct, + consistencyTrendPct: trendSampleReady ? previousConsistencySpreadPct - consistencySpreadPct : 0, + trendSampleReady, recentLapCount: recentLapTimes.length, lastLapAt: laps.at(-1)?.timestamp || 0, dailySummaries, @@ -312,6 +346,7 @@ export function buildDriverTrainingStats(state, driverId, classId = "") { bestThreeConsecutive, bestFiveMinuteResult, }; + return { ...stats, achievements: buildTrainingAchievements(stats) }; } export function buildDriverTimingChannelStats(state, driverId, classId = "") { diff --git a/src/training_views.js b/src/training_views.js index 214c1c6..7608491 100644 --- a/src/training_views.js +++ b/src/training_views.js @@ -137,15 +137,42 @@ export function renderTrainingView(deps) { `).join("") : `${escapeHtml(t("training.no_history"))}`; + const unlockedAchievements = (stats.achievements || []).filter((achievement) => achievement.unlocked); + const nextAchievement = (stats.achievements || []).find((achievement) => !achievement.unlocked); + const visibleAchievements = [...unlockedAchievements.slice(-5), ...(nextAchievement ? [nextAchievement] : [])]; + const achievementMarkup = visibleAchievements.length + ? visibleAchievements.map((achievement) => ` +
+ ${achievement.unlocked ? "OK" : `${Math.round(achievement.percent)}%`} +
+ ${escapeHtml(t(`training.achievement_${achievement.id}`))} + ${escapeHtml(t(achievement.unlocked ? "training.achievement_unlocked" : "training.achievement_next"))} +
+
+ `).join("") + : `

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

`; + const paceTrend = !stats.trendSampleReady + ? t("training.trend_collecting") + : stats.averageTrendMs > 0 + ? t("training.trend_faster", { lap: formatLap(stats.averageTrendMs) }) + : stats.averageTrendMs < 0 + ? t("training.trend_slower", { lap: formatLap(Math.abs(stats.averageTrendMs)) }) + : t("training.trend_stable"); + const consistencyTrend = !stats.trendSampleReady + ? t("training.trend_collecting_short") + : stats.consistencyTrendPct > 0.1 + ? t("training.consistency_better", { value: stats.consistencyTrendPct.toFixed(1) }) + : stats.consistencyTrendPct < -0.1 + ? t("training.consistency_building") + : t("training.trend_stable"); const clubRows = profiles .filter((profile) => !selectedClassId || profile.classIds.includes(selectedClassId)) .map((profile) => ({ profile, driver: profile.representative, stats: profile.id === selectedProfileId ? stats : buildDriverTrainingStats(state, profile.id, selectedClassId) })) - .sort((left, right) => right.stats.totalLaps - left.stats.totalLaps || String(left.driver?.name || "").localeCompare(String(right.driver?.name || ""))) - .map((entry, index) => { + .sort((left, right) => right.stats.lastLapAt - left.stats.lastLapAt || String(left.driver?.name || "").localeCompare(String(right.driver?.name || ""))) + .map((entry) => { const milestone = getNextTrainingMilestone(entry.stats); return ` - ${index + 1} ${entry.stats.totalLaps} ${entry.stats.trainingDays} @@ -209,6 +236,27 @@ export function renderTrainingView(deps) { ${trainingStat(t("training.consistency"), stats.recentLapCount ? `${stats.consistencySpreadPct.toFixed(1)}%` : "-", t("training.last_30_laps"))}
+
+
+
+
${escapeHtml(t("training.your_progress"))}

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

+ 30 + 30 ${escapeHtml(t("training.laps").toLowerCase())} +
+
+ ${trainingInsight(t("training.pace_trend"), paceTrend)} + ${trainingInsight(t("training.consistency_trend"), consistencyTrend)} +
+

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

+
+
+
+
${escapeHtml(t("training.personal_credit"))}

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

+ ${unlockedAchievements.length}/${(stats.achievements || []).length} +
+
${achievementMarkup}
+
+
+
@@ -347,7 +395,7 @@ export function renderTrainingView(deps) {
- + ${clubRows}
#${escapeHtml(t("training.driver"))}${escapeHtml(t("training.laps"))}${escapeHtml(t("training.training_days"))}${escapeHtml(t("training.best_lap"))}${escapeHtml(t("training.next_target"))}
${escapeHtml(t("training.driver"))}${escapeHtml(t("training.laps"))}${escapeHtml(t("training.training_days"))}${escapeHtml(t("training.best_lap"))}${escapeHtml(t("training.next_target"))}
@@ -470,28 +518,62 @@ export function buildTrainingOverviewRows(state, profiles, buildDriverTrainingSt stats: buildDriverTrainingStats(state, profile.id, classId), })); }).sort((left, right) => ( - right.stats.totalLaps - left.stats.totalLaps + right.stats.lastLapAt - left.stats.lastLapAt || String(left.driver?.name || "").localeCompare(String(right.driver?.name || "")) || String(left.className).localeCompare(String(right.className)) )); } +export function buildTrainingProgressFeed(rows, limit = 6) { + return (Array.isArray(rows) ? rows : []) + .filter((row) => Number(row?.stats?.totalLaps || 0) > 0) + .sort((left, right) => Number(right.stats.lastLapAt || 0) - Number(left.stats.lastLapAt || 0)) + .slice(0, Math.max(1, Number(limit || 6))) + .map((row) => { + const stats = row.stats; + if (stats.personalBestGainMs > 0) return { ...row, kind: "personal_best", value: stats.personalBestGainMs }; + if (stats.trendSampleReady && stats.averageTrendMs > 0) return { ...row, kind: "average_gain", value: stats.averageTrendMs }; + if (stats.bestFiveMinuteResult) return { ...row, kind: "five_minute", value: stats.bestFiveMinuteResult.lapCount }; + if (stats.controlledLapStreak >= 5) return { ...row, kind: "controlled_streak", value: stats.controlledLapStreak }; + return { ...row, kind: "practice_laps", value: stats.totalLaps }; + }); +} + function renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, profiles, buildDriverTrainingStats, getNextTrainingMilestone, setSelectedDriverId, setSelectedClassId, renderView }) { const rows = buildTrainingOverviewRows(state, profiles, buildDriverTrainingStats); + const progressFeed = buildTrainingProgressFeed(rows); const ledger = Array.isArray(state.training?.ledger) ? state.training.ledger : []; const activeProfiles = new Set(rows.filter((row) => row.stats.totalLaps > 0).map((row) => row.profile.id)).size; const recordedSessions = new Set(ledger.map((entry) => String(entry.sessionId || "")).filter(Boolean)).size; const driverOptions = profiles.map((profile) => ( `` )).join(""); - const tableRows = rows.map((row, index) => { + const progressFeedMarkup = progressFeed.length + ? progressFeed.map((entry) => { + const message = entry.kind === "personal_best" + ? t("training.feed_personal_best", { lap: formatLap(entry.value) }) + : entry.kind === "average_gain" + ? t("training.feed_average_gain", { lap: formatLap(entry.value) }) + : entry.kind === "five_minute" + ? t("training.feed_five_minute", { laps: entry.value }) + : entry.kind === "controlled_streak" + ? t("training.feed_controlled_streak", { laps: entry.value }) + : t("training.feed_practice_laps", { laps: entry.value }); + return ` + + `; + }).join("") + : `

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

`; + const tableRows = rows.map((row) => { const milestone = getNextTrainingMilestone(row.stats); const lastActivity = row.stats.lastLapAt ? new Date(row.stats.lastLapAt).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "-"; return ` - ${index + 1} ${escapeHtml(row.className)} ${row.stats.totalLaps} @@ -528,13 +610,18 @@ function renderTrainingOverview({ state, dom, t, escapeHtml, formatLap, profiles ${trainingStat(t("training.sessions"), String(recordedSessions), t("training.recorded_sessions"))}
+
+
${escapeHtml(t("training.progress_not_positions"))}

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

+
${progressFeedMarkup}
+
+

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

${rows.length}

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

- + ${tableRows}
#${escapeHtml(t("training.driver"))}${escapeHtml(t("training.class"))}${escapeHtml(t("training.laps"))}${escapeHtml(t("training.training_days"))}${escapeHtml(t("training.best_lap"))}${escapeHtml(t("training.last_activity"))}${escapeHtml(t("training.next_target"))}
${escapeHtml(t("training.driver"))}${escapeHtml(t("training.class"))}${escapeHtml(t("training.laps"))}${escapeHtml(t("training.training_days"))}${escapeHtml(t("training.best_lap"))}${escapeHtml(t("training.last_activity"))}${escapeHtml(t("training.next_target"))}
diff --git a/tests/public_training_profile.test.js b/tests/public_training_profile.test.js index 69c97cf..342cf30 100644 --- a/tests/public_training_profile.test.js +++ b/tests/public_training_profile.test.js @@ -88,8 +88,8 @@ test("public profile contains only the selected driver's sanitized aggregates", assert.equal(profile.profile.name, "Driver One"); assert.equal(profile.profile.brand, "Associated 2WD"); assert.equal(profile.profile.className, "2WD"); - assert.equal(profile.profile.clubRank, 1); - assert.equal(profile.profile.rankedDrivers, 2); + assert.equal("clubRank" in profile.profile, false); + assert.equal("rankedDrivers" in profile.profile, false); assert.equal(profile.branding.clubName, "Test Club"); assert.equal(profile.branding.logoDataUrl, ""); assert.equal(profile.stats.totalLaps, 4); diff --git a/tests/training_logic.test.js b/tests/training_logic.test.js index 159f66a..cbcc580 100644 --- a/tests/training_logic.test.js +++ b/tests/training_logic.test.js @@ -5,6 +5,7 @@ import { buildDriverActivePracticeStats, buildDriverTrainingStats, buildDriverTimingChannelStats, + buildTrainingAchievements, buildTrainingRecommendation, getBestConsecutiveTrainingLaps, getBestTrainingDurationResult, @@ -337,3 +338,36 @@ test("goal and automatic milestone progress are bounded", () => { assert.equal(getNextTrainingMilestone({ totalLaps: 250 }).targetLaps, 500); assert.equal(getNextTrainingMilestone({ totalLaps: 5200 }).targetLaps, 6000); }); + +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) => ({ + driverId: "driver_1", + sessionId: "practice_1", + timestamp: start + ((index + 1) * 18_000), + lapMs: index < 30 ? 18_000 : 17_000, + })); + const stats = buildDriverTrainingStats({ training: { ledger } }, "driver_1"); + + assert.equal(stats.trendSampleReady, true); + assert.equal(stats.previousRecentAverageMs, 18_000); + assert.equal(stats.recentAverageMs, 17_000); + assert.equal(stats.averageTrendMs, 1_000); + assert.equal(stats.consistencyTrendPct, 0); +}); + +test("automatic achievements expose unlocked and upcoming personal goals", () => { + const achievements = buildTrainingAchievements({ + totalSessions: 2, + totalLaps: 520, + trainingDays: 4, + controlledLapStreak: 7, + bestFiveMinuteResult: { lapCount: 17 }, + }); + + assert.equal(achievements.find((item) => item.id === "first_session").unlocked, true); + assert.equal(achievements.find((item) => item.id === "laps_500").unlocked, true); + assert.equal(achievements.find((item) => item.id === "laps_1000").percent, 52); + assert.equal(achievements.find((item) => item.id === "days_10").unlocked, false); + assert.equal(achievements.find((item) => item.id === "five_minute").unlocked, true); +}); diff --git a/tests/training_views.test.js b/tests/training_views.test.js index 4583b3e..164ba38 100644 --- a/tests/training_views.test.js +++ b/tests/training_views.test.js @@ -3,7 +3,7 @@ import assert from "node:assert/strict"; import { getDriverProfileGroups } from "../src/driver_profile_logic.js"; import { buildDriverTrainingStats } from "../src/training_logic.js"; -import { buildTrainingOverviewRows, resolveTrainingProfile } from "../src/training_views.js"; +import { buildTrainingOverviewRows, buildTrainingProgressFeed, resolveTrainingProfile } from "../src/training_views.js"; test("training opens without implicitly selecting the first driver", () => { const profiles = [{ id: "profile_1", driverIds: ["driver_1"] }]; @@ -33,3 +33,15 @@ test("training overview keeps each driver class on a separate statistics row", ( assert.equal(rows.find((row) => row.classId === "2wd").stats.totalLaps, 2); assert.equal(rows.find((row) => row.classId === "4wd").stats.totalLaps, 1); }); + +test("training progress feed highlights personal development instead of ranking drivers", () => { + const rows = [ + { profile: { id: "p1" }, driver: { name: "Alex" }, classId: "2wd", className: "2WD", stats: { totalLaps: 80, lastLapAt: 200, personalBestGainMs: 250 } }, + { profile: { id: "p2" }, driver: { name: "Kim" }, classId: "4wd", className: "4WD", stats: { totalLaps: 120, lastLapAt: 100, trendSampleReady: true, averageTrendMs: 400 } }, + ]; + const feed = buildTrainingProgressFeed(rows); + + assert.deepEqual(feed.map((entry) => entry.profile.id), ["p1", "p2"]); + assert.equal(feed[0].kind, "personal_best"); + assert.equal(feed[1].kind, "average_gain"); +});