diff --git a/README.md b/README.md index 3255b7a..fe9466d 100644 --- a/README.md +++ b/README.md @@ -245,13 +245,15 @@ 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 +- optional personal stable-pace voice feedback after five controlled laps in one practice stint, using the configured driver spoken name and a per-driver cooldown - 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 - one collapsed More 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 +- detailed stint analysis with normal pace, best 10-lap race pace, outlier laps, pace retention, and strong finishes +- personal control zone, stint-scoped flow streaks, clean five-minute runs, and latest split-time improvement - 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 diff --git a/README.sv.md b/README.sv.md index 57ebb87..4dffde7 100644 --- a/README.sv.md +++ b/README.sv.md @@ -234,13 +234,15 @@ 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 +- valbar personlig röstfeedback efter fem kontrollerade varv i samma practice-stint, med förarens uppläsningsnamn och cooldown per förare - 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 gemensam hopfällbar Mer 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 +- detaljerad stintanalys med normaltempo, bästa race pace över 10 varv, avvikande varv, tempohållning och starka avslutningar +- personlig kontrollzon, flytserier inom samma stint, rena femminuterspass och senaste förbättring av mellantid - 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 diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 81c5c71..2046e3a 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -534,14 +534,14 @@ Owns: - stint splitting at persisted decoder `stintBoundary` markers, with gaps greater than `120000 ms` as a legacy fallback - 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 +- daily summaries, stint-scoped controlled-lap streak, personal control zone, 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 automatic achievements for sessions, lap totals, training days, controlled streaks, strong finishes, and clean/completed five-minute results - 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 +- derived positive highlights for current activity, personal-best and split-time gains, pace, consistency, strong finishes, 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 +- extended stint analysis with normal pace, best 10-lap race pace, outlier count, consistency, start/end pace retention, and strong-finish detection - rule-based practice recommendation selection - automatic and custom milestone progress calculation - shared club-goal progress from a persisted ledger baseline @@ -570,7 +570,9 @@ Rules: - duration-result ranking compares lap count first and elapsed result time second - a duration result is valid only after crossing the target and includes a final lap whose start is before the target cutoff - a stint needs at least 3 valid pace laps for consistency/ranking and at least 10 for Race pace 10 -- a stint mistake is a lap more than 15 percent slower than that stint's median +- an outlier lap is more than 15 percent slower than that stint's median +- the personal control zone spans the class-specific personal best through 8 percent slower; its flow streak cannot cross a stint, source, or session boundary +- a strong finish requires at least 6 pace laps and closing-third pace no slower than 1 percent above opening-third pace - setup comparisons need at least 3 valid laps in each 30-minute side of the marker ### `src/training_views.js` @@ -588,7 +590,7 @@ Owns: - 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 -- one collapsed More statistics section containing pace heatmap, setup-marker actions, weekday pace, stint analysis, timing channels, and history +- one collapsed More statistics section containing pace heatmap, setup-marker actions, weekday pace, control-zone/outlier/strong-finish totals, stint analysis, timing channels, and history Mutations: - replaces or appends entries in `state.training.goals` diff --git a/rc-race-sound-pack/sound_manifest.json b/rc-race-sound-pack/sound_manifest.json index 8aa9c70..2dc4dee 100644 --- a/rc-race-sound-pack/sound_manifest.json +++ b/rc-race-sound-pack/sound_manifest.json @@ -8,6 +8,7 @@ "start_tone": { "effect": "effects/start_beep.wav" }, "transponder_pass": { "effect": "effects/transponder_pass.wav" }, "transponder_detected": { "effect": "effects/transponder_pass.wav" }, + "training_stable_pace": { "voice": "training_stable_pace" }, "entering_pits": { "effect": "effects/transponder_pass.wav" }, "leaving_pits": { "effect": "effects/transponder_pass.wav" }, "personal_best": { "effect": "effects/personal_best.wav" }, @@ -46,6 +47,7 @@ "race_1_minute": "Race starts in one minute.", "race_30_seconds": "Thirty seconds to start.", "race_10_seconds": "Ten seconds to start.", "drivers_ready": "Drivers, stand by.", "start_sequence": "Starting on the sound of the tone.", "false_start": "False start.", "restart_race": "Prepare to restart the race.", "personal_best": "Personal best. {driverName}. {lapTime}.", "fastest_lap": "Fastest lap. {driverName}. {lapTime}.", + "training_stable_pace": "{driverName}, good pace and consistent driving.", "track_record": "New track record. {driverName}. {lapTime}.", "new_leader": "New race leader. {driverName}.", "final_lap": "Final lap.", "one_minute_remaining": "One minute remaining.", "thirty_seconds_remaining": "Thirty seconds remaining.", "ten_seconds_remaining": "Ten seconds remaining.", "time_expired": "Time has expired. Finish the lap you are on.", "race_finished": "Race is finished.", @@ -66,6 +68,7 @@ "race_1_minute": "Start om en minut.", "race_30_seconds": "Trettio sekunder till start.", "race_10_seconds": "Tio sekunder till start.", "drivers_ready": "Förare, gör er redo.", "start_sequence": "Start på signalen.", "false_start": "Felaktig start.", "restart_race": "Förbered omstart.", "personal_best": "Personbästa. {driverName}. {lapTime}.", "fastest_lap": "Snabbaste varv. {driverName}. {lapTime}.", + "training_stable_pace": "{driverName}, bra tempo och stabil körning.", "track_record": "Nytt banrekord. {driverName}. {lapTime}.", "new_leader": "Ny ledare. {driverName}.", "final_lap": "Sista varvet.", "one_minute_remaining": "En minut kvar.", "thirty_seconds_remaining": "Trettio sekunder kvar.", "ten_seconds_remaining": "Tio sekunder kvar.", "time_expired": "Tiden är slut. Slutför pågående varv.", "race_finished": "Racet är avslutat.", diff --git a/server/public_training_profile.js b/server/public_training_profile.js index 5cf2a6a..31fcf35 100644 --- a/server/public_training_profile.js +++ b/server/public_training_profile.js @@ -107,12 +107,12 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp } function buildPublicTimingChannelStats(state, classDriverIds) { - const splitTimes = []; + const splits = []; const pitTimes = []; Object.values(state?.resultsBySession || {}).forEach((result) => { (Array.isArray(result?.splits) ? result.splits : []).forEach((split) => { const splitMs = Math.round(Number(split?.splitMs || 0) || 0); - if (classDriverIds.has(String(split?.driverId || "")) && splitMs > 0) splitTimes.push(splitMs); + if (classDriverIds.has(String(split?.driverId || "")) && splitMs > 0) splits.push({ splitMs, timestamp: Number(split?.timestamp || 0) }); }); (Array.isArray(result?.pitStops) ? result.pitStops : []).forEach((stop) => { const pitStopMs = Math.round(Number(stop?.pitStopMs || 0) || 0); @@ -122,10 +122,19 @@ function buildPublicTimingChannelStats(state, classDriverIds) { const average = (values) => values.length ? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length) : 0; + splits.sort((left, right) => left.timestamp - right.timestamp); + const splitTimes = splits.map((split) => split.splitMs); + let runningBestSplitMs = 0; + let latestSplitGainMs = 0; + splits.forEach((split) => { + if (runningBestSplitMs && split.splitMs < runningBestSplitMs) latestSplitGainMs = runningBestSplitMs - split.splitMs; + if (!runningBestSplitMs || split.splitMs < runningBestSplitMs) runningBestSplitMs = split.splitMs; + }); return { splitCount: splitTimes.length, bestSplitMs: splitTimes.length ? Math.min(...splitTimes) : 0, averageSplitMs: average(splitTimes), + latestSplitGainMs, pitStopCount: pitTimes.length, totalPitMs: pitTimes.reduce((sum, value) => sum + value, 0), bestPitStopMs: pitTimes.length ? Math.min(...pitTimes) : 0, @@ -217,12 +226,41 @@ function buildPublicTrainingStats(laps) { .slice(0, 30); const bestLapMs = lapTimes.length ? Math.min(...lapTimes) : 0; const controlTargetMs = bestLapMs ? Math.round(bestLapMs * 1.08) : 0; - let controlledLapStreak = 0; - for (let index = lapTimes.length - 1; index >= 0; index -= 1) { - if (!controlTargetMs || lapTimes[index] > controlTargetMs) break; - controlledLapStreak += 1; - } const stints = splitPublicTrainingStints(laps); + const summarizeStints = (items) => { + let controlledLapStreak = 0; + let outlierLaps = 0; + let strongFinishStints = 0; + let cleanFiveMinuteStints = 0; + let completedFiveMinuteStints = 0; + items.forEach((stint) => { + let currentStreak = 0; + const values = stint.laps.filter((lap) => !lap.excludedFromPace).map((lap) => lap.lapMs); + values.forEach((lapMs) => { + currentStreak = controlTargetMs && lapMs <= controlTargetMs ? currentStreak + 1 : 0; + controlledLapStreak = Math.max(controlledLapStreak, currentStreak); + }); + const medianMs = percentile(values, 0.5); + const stintOutliers = values.filter((lapMs) => medianMs && lapMs > medianMs * 1.15).length; + outlierLaps += stintOutliers; + const comparisonSize = values.length >= 6 ? Math.max(3, Math.floor(values.length / 3)) : 0; + const startPaceMs = comparisonSize ? average(values.slice(0, comparisonSize)) : 0; + const endPaceMs = comparisonSize ? average(values.slice(-comparisonSize)) : 0; + if (comparisonSize && endPaceMs <= startPaceMs * 1.01) strongFinishStints += 1; + const fiveMinuteResult = getPublicBestDurationResult([stint], 5 * 60 * 1000); + if (fiveMinuteResult) completedFiveMinuteStints += 1; + if (!stintOutliers && fiveMinuteResult) cleanFiveMinuteStints += 1; + }); + return { controlledLapStreak, outlierLaps, strongFinishStints, cleanFiveMinuteStints, completedFiveMinuteStints }; + }; + const stintSummary = summarizeStints(stints); + const today = new Date(); + const todayKey = [today.getFullYear(), String(today.getMonth() + 1).padStart(2, "0"), String(today.getDate()).padStart(2, "0")].join("-"); + const todayStintSummary = summarizeStints(splitPublicTrainingStints(laps.filter((lap) => { + const date = new Date(lap.timestamp); + const dateKey = [date.getFullYear(), String(date.getMonth() + 1).padStart(2, "0"), String(date.getDate()).padStart(2, "0")].join("-"); + return !Number.isNaN(date.getTime()) && dateKey === todayKey; + }))); const bestThreeConsecutive = getPublicBestConsecutiveLaps(stints, 3); const bestFiveMinuteResult = getPublicBestDurationResult(stints, 5 * 60 * 1000); @@ -240,7 +278,16 @@ function buildPublicTrainingStats(laps) { previousConsistencySpreadPct, consistencyTrendPct: trendSampleReady ? previousConsistencySpreadPct - consistencySpreadPct : 0, trendSampleReady, - controlledLapStreak, + controlZoneMinMs: bestLapMs, + controlZoneMaxMs: controlTargetMs, + controlledLapStreak: stintSummary.controlledLapStreak, + outlierLaps: stintSummary.outlierLaps, + strongFinishStints: stintSummary.strongFinishStints, + cleanFiveMinuteStints: stintSummary.cleanFiveMinuteStints, + todayControlledLapStreak: todayStintSummary.controlledLapStreak, + todayStrongFinishStints: todayStintSummary.strongFinishStints, + todayCleanFiveMinuteStints: todayStintSummary.cleanFiveMinuteStints, + todayCompletedFiveMinuteStints: todayStintSummary.completedFiveMinuteStints, dailySummaries, stintCount: stints.length, bestThreeConsecutive, @@ -259,6 +306,8 @@ function buildPublicTrainingAchievements(stats) { ["days_3", Number(stats.trainingDays || 0), 3], ["days_10", Number(stats.trainingDays || 0), 10], ["controlled_5", Number(stats.controlledLapStreak || 0), 5], + ["strong_finish", Number(stats.strongFinishStints || 0), 1], + ["clean_five_minute", Number(stats.cleanFiveMinuteStints || 0), 1], ["five_minute", stats.bestFiveMinuteResult ? 1 : 0, 1], ]; return definitions.map(([id, current, target]) => ({ diff --git a/src/app.js b/src/app.js index 4eaae94..bd462d5 100644 --- a/src/app.js +++ b/src/app.js @@ -500,7 +500,10 @@ const TRANSLATIONS = { "training.next_level_target": "Nå {laps} varv", "training.average_lap": "Snittvarv", "training.recent_average": "Snitt senaste 30", - "training.controlled_streak": "Kontrollerad serie", + "training.controlled_streak": "Flytserie", + "training.control_zone": "Kontrollzon", + "training.outlier_laps": "Avvikande varv", + "training.strong_finishes": "Starka avslutningar", "training.lap_count": "{laps} varv", "training.last_activity": "Senaste aktivitet", "training.no_activity": "Ingen aktivitet ännu", @@ -532,8 +535,9 @@ const TRANSLATIONS = { "training.start": "Start", "training.trimmed_pace": "Normaltempo", "training.race_pace_10": "Race pace 10", - "training.mistakes": "Misstag", - "training.start_end": "Start/slut", + "training.mistakes": "Avvikande varv", + "training.start_end": "Tempohållning", + "training.strong_finish": "Stark avslutning", "training.insufficient_data": "För lite data", "training.no_stints": "Inga stintar finns ännu.", "training.stint_faster": "{lap} snabbare", @@ -546,9 +550,10 @@ const TRANSLATIONS = { "training.stint_duration": "Stintlängd", "training.average_short": "snitt", "training.show_all_stints": "Visa alla {count} stintar", - "training.stats_method": "Normaltempo använder trimmat snitt. Misstag är varv mer än 15% långsammare än stintens median. Minst 3 varv krävs för konsekvens och 10 för Race pace 10.", + "training.stats_method": "Normaltempo använder trimmat snitt. Avvikande varv är varv mer än 15% långsammare än stintens median. Stark avslutning betyder att sluttempot är minst 99% av starttempot. Minst 3 varv krävs för jämnhet och 10 för Race pace 10.", "training.split_passings": "Mellantidspasseringar", "training.best_split": "Bästa mellantid", + "training.latest_sector_gain": "Senaste sektorförbättring", "training.average_split": "Snitt mellantid", "training.pit_stops": "Depåstopp", "training.total_pit_time": "Total depåtid", @@ -618,6 +623,8 @@ const TRANSLATIONS = { "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_strong_finish": "Stark avslutning", + "training.achievement_clean_five_minute": "Fem minuter utan avvikande varv", "training.achievement_pace_gain": "Förbättrat snittempo", "training.credit_eyebrow": "Din träning räknas", "training.credit_today_title": "Bra jobbat, {driver}!", @@ -630,6 +637,9 @@ const TRANSLATIONS = { "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_sector_gain": "Sektortiden förbättrad med {lap}", + "training.highlight_strong_finish": "{count} stark avslutning idag", + "training.highlight_clean_five_minute": "{count} femminuterspass utan avvikande varv", "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", @@ -1576,10 +1586,12 @@ const TRANSLATIONS = { "guide.training_17": "17. Öppna Mer statistik för tempo per aktivt 30-minutersblock, setupändringar, stintanalys, mellantider, depåstopp och historik. Tomma tider döljs och tempot använder trimmat snitt.", "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_20": "20. Stintanalysen använder glapp över två minuter som gräns. Normaltempo är trimmat snitt, avvikande varv ä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.", + "guide.training_24": "24. Ljudhändelsen training stable pace ger valbar personlig röstfeedback efter fem kontrollerade varv i samma practice-stint. Den använder förarens uppläsningsnamn, har två minuters cooldown och aktiveras separat under Raceljud och utrop.", + "guide.training_25": "25. Kontrollzonen går från personbästa till 8 procent långsammare. Flytserier bryts vid avvikande varv, ny stint eller ny session. Stark avslutning, rena femminuterspass och förbättrade mellantider ger positiv feedback men påverkar aldrig race-resultat.", "overlay.title": "Overlay", "overlay.subtitle": "Extern leaderboard-skärm", "overlay.no_active": "Ingen aktiv session vald.", @@ -1822,7 +1834,10 @@ const TRANSLATIONS = { "training.next_level_target": "Reach {laps} laps", "training.average_lap": "Average lap", "training.recent_average": "Latest 30 average", - "training.controlled_streak": "Controlled streak", + "training.controlled_streak": "Flow streak", + "training.control_zone": "Control zone", + "training.outlier_laps": "Outlier laps", + "training.strong_finishes": "Strong finishes", "training.lap_count": "{laps} laps", "training.last_activity": "Latest activity", "training.no_activity": "No activity yet", @@ -1854,8 +1869,9 @@ const TRANSLATIONS = { "training.start": "Start", "training.trimmed_pace": "Normal pace", "training.race_pace_10": "Race pace 10", - "training.mistakes": "Mistakes", - "training.start_end": "Start/end", + "training.mistakes": "Outlier laps", + "training.start_end": "Pace retention", + "training.strong_finish": "Strong finish", "training.insufficient_data": "Not enough data", "training.no_stints": "No stints are available yet.", "training.stint_faster": "{lap} faster", @@ -1868,9 +1884,10 @@ const TRANSLATIONS = { "training.stint_duration": "Stint duration", "training.average_short": "avg", "training.show_all_stints": "Show all {count} stints", - "training.stats_method": "Normal pace uses a trimmed average. A mistake is a lap over 15% slower than the stint median. At least 3 laps are required for consistency and 10 for Race pace 10.", + "training.stats_method": "Normal pace uses a trimmed average. An outlier lap is more than 15% slower than the stint median. A strong finish means the closing pace is at least 99% of the starting pace. At least 3 laps are required for consistency and 10 for Race pace 10.", "training.split_passings": "Split passings", "training.best_split": "Best split", + "training.latest_sector_gain": "Latest sector improvement", "training.average_split": "Average split", "training.pit_stops": "Pit stops", "training.total_pit_time": "Total pit time", @@ -1940,6 +1957,8 @@ const TRANSLATIONS = { "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_strong_finish": "Strong finish", + "training.achievement_clean_five_minute": "Five minutes without outlier laps", "training.achievement_pace_gain": "Improved average pace", "training.credit_eyebrow": "Your practice counts", "training.credit_today_title": "Good work, {driver}!", @@ -1952,6 +1971,9 @@ const TRANSLATIONS = { "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_sector_gain": "Sector time improved by {lap}", + "training.highlight_strong_finish": "{count} strong finish today", + "training.highlight_clean_five_minute": "{count} five-minute run without outlier laps", "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", @@ -2898,10 +2920,12 @@ const TRANSLATIONS = { "guide.training_17": "17. Open More statistics for active 30-minute pace, setup changes, stint analysis, split timing, pit stops, and history. Empty periods are hidden and pace uses a trimmed average.", "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_20": "20. Stint analysis uses gaps longer than two minutes as boundaries. Normal pace is a trimmed average, outlier laps are more than 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.", + "guide.training_24": "24. The training stable pace audio event provides optional personal voice feedback after five controlled laps in one practice stint. It uses the driver spoken name, has a two-minute cooldown, and can be enabled separately under Race sounds and announcements.", + "guide.training_25": "25. The control zone runs from the personal best to 8 percent slower. Flow streaks reset on an outlier lap, a new stint, or a new session. Strong finishes, clean five-minute runs, and improved split times provide positive feedback but never affect race results.", "overlay.title": "Overlay", "overlay.subtitle": "External leaderboard screen", "overlay.no_active": "No active session selected.", diff --git a/src/decoder_runtime.js b/src/decoder_runtime.js index e4c516b..0fa1d48 100644 --- a/src/decoder_runtime.js +++ b/src/decoder_runtime.js @@ -1,3 +1,21 @@ +import { getControlledLapStreak } from "./training_logic.js"; + +const PRACTICE_SESSION_TYPES = new Set(["open_practice", "free_practice", "practice"]); + +function getCurrentPracticePaceLaps(result, competitorKey) { + const laps = []; + const passings = Array.isArray(result?.passings) ? result.passings : []; + for (let index = passings.length - 1; index >= 0; index -= 1) { + const passing = passings[index]; + if (String(passing?.competitorKey || "") !== String(competitorKey || "")) continue; + if (passing.validLap === false || passing.excludedFromPace || passing.stintBoundary) break; + const lapMs = Number(passing.lapMs || 0); + if (lapMs <= 500) break; + laps.unshift(lapMs); + } + return laps; +} + function parseRtcTime(value) { if (!value || typeof value !== "string") { return null; @@ -469,6 +487,19 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { const leaderboard = buildLeaderboard(session); const currentPosition = leaderboard.findIndex((row) => row.key === key); const audioVariables = { driverName: entry.speechName || entry.displayName || entry.driverName, lapTime: formatLap(lapMs) }; + if (entry.driverId && PRACTICE_SESSION_TYPES.has(String(session.type || "").toLowerCase()) && !validationWarning) { + const controlTargetMs = entry.bestLapMs ? Math.round(entry.bestLapMs * 1.08) : 0; + const controlledLapStreak = getControlledLapStreak(getCurrentPracticePaceLaps(result, key), controlTargetMs); + if (controlledLapStreak >= 5 && controlledLapStreak % 5 === 0) { + triggerRaceAudio("training_stable_pace", { + variables: { ...audioVariables, laps: controlledLapStreak }, + driverId: entry.driverId, + competitorKey: key, + scope: entry.driverId, + logData: { raceId: session.eventId, sessionId: session.id, driverId: entry.driverId, lapNumber: entry.laps, lapTime: lapMs }, + }); + } + } if (previousPersonalBest && lapMs < previousPersonalBest) triggerRaceAudio("personal_best", { variables: audioVariables, driverId: entry.driverId, competitorKey: key, logData: { raceId: session.eventId, driverId: entry.driverId, lapNumber: entry.laps, lapTime: lapMs } }); if (previousSessionFastest && lapMs < previousSessionFastest) triggerRaceAudio("fastest_lap", { variables: audioVariables, driverId: entry.driverId, competitorKey: key, logData: { raceId: session.eventId, driverId: entry.driverId, lapNumber: entry.laps, lapTime: lapMs } }); if (previousTrackRecord && lapMs < previousTrackRecord) triggerRaceAudio("track_record", { variables: audioVariables, driverId: entry.driverId, competitorKey: key, logData: { raceId: session.eventId, driverId: entry.driverId, lapNumber: entry.laps, lapTime: lapMs } }); diff --git a/src/misc_views.js b/src/misc_views.js index b8eb3dc..a253161 100644 --- a/src/misc_views.js +++ b/src/misc_views.js @@ -69,7 +69,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", "guide.training_15", "guide.training_16", "guide.training_17", "guide.training_18", "guide.training_19", "guide.training_20", "guide.training_21", "guide.training_22", "guide.training_23"], "", { 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", "guide.training_17", "guide.training_18", "guide.training_19", "guide.training_20", "guide.training_21", "guide.training_22", "guide.training_23", "guide.training_24", "guide.training_25"], "", { t })}
diff --git a/src/public_training.js b/src/public_training.js index bd89af9..e22325f 100644 --- a/src/public_training.js +++ b/src/public_training.js @@ -64,6 +64,10 @@ const messages = { fiveAverage: "Avg pace, bästa 5 min", splitPassings: "Mellantidspasseringar", bestSplit: "Bästa mellantid", + latestSectorGain: "Senaste sektorförbättring", + controlZone: "Kontrollzon", + outlierLaps: "Avvikande varv", + strongFinishes: "Starka avslutningar", pitStops: "Depåstopp", totalPit: "Total depåtid", recentProgress: "Din senaste utveckling", @@ -89,6 +93,8 @@ const messages = { achievement_five_minute: "Fullföljd femminutersstint", achievement_five_minutes_3: "3 fullföljda femminutersstintar", achievement_consistency_5: "Jämnhet under 5 procent", + achievement_strong_finish: "Stark avslutning", + achievement_clean_five_minute: "Fem minuter utan avvikande varv", achievement_pace_gain: "Förbättrat snittempo", creditTitle: "Din träning räknas", creditBody: "Varje genomfört varv bygger erfarenhet. Du jämförs med din egen utveckling, inte med andra förare.", @@ -97,6 +103,9 @@ const messages = { consistencyCredit: "{value} procentenheter jämnare körning", controlledCredit: "{laps} kontrollerade varv i rad", fiveMinuteCredit: "Du har fullföljt en femminutersstint", + sectorCredit: "Sektortiden förbättrad med {lap}", + strongFinishCredit: "Stark avslutning med bibehållet tempo", + cleanFiveCredit: "Fem minuter utan avvikande varv", buildingCredit: "{laps} varv har redan byggt din erfarenhet", readyCredit: "Första giltiga varvet startar din utvecklingsresa", challenges: "Små utmaningar", @@ -161,6 +170,10 @@ const messages = { fiveAverage: "Average pace, best 5 min", splitPassings: "Split passings", bestSplit: "Best split", + latestSectorGain: "Latest sector improvement", + controlZone: "Control zone", + outlierLaps: "Outlier laps", + strongFinishes: "Strong finishes", pitStops: "Pit stops", totalPit: "Total pit time", recentProgress: "Your recent development", @@ -186,6 +199,8 @@ const messages = { achievement_five_minute: "Completed five-minute stint", achievement_five_minutes_3: "3 completed five-minute stints", achievement_consistency_5: "Consistency below 5 percent", + achievement_strong_finish: "Strong finish", + achievement_clean_five_minute: "Five minutes without outlier laps", achievement_pace_gain: "Improved average pace", creditTitle: "Your practice counts", creditBody: "Every completed lap builds experience. You are compared with your own development, not other drivers.", @@ -194,6 +209,9 @@ const messages = { consistencyCredit: "{value} percentage points more consistent", controlledCredit: "{laps} controlled laps in a row", fiveMinuteCredit: "You completed a five-minute stint", + sectorCredit: "Sector time improved by {lap}", + strongFinishCredit: "Strong finish with pace maintained", + cleanFiveCredit: "Five minutes without outlier laps", buildingCredit: "{laps} laps have already built your experience", readyCredit: "Your first valid lap starts the development journey", challenges: "Small challenges", @@ -260,13 +278,13 @@ function getLocalDayKey(timestamp) { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; } -function buildProfileMotivation(stats) { +function buildProfileMotivation(stats, channelStats = {}) { const todayKey = getLocalDayKey(Date.now()); const today = (Array.isArray(stats?.dailySummaries) ? stats.dailySummaries : []).find((day) => day.dateKey === todayKey); const activeToday = Boolean(today); const todayLaps = Number(today?.laps || 0); - const controlled = activeToday ? Number(stats?.controlledLapStreak || 0) : 0; - const completedFive = stats?.bestFiveMinuteResult ? 1 : 0; + const controlled = activeToday ? Number(stats?.todayControlledLapStreak || 0) : 0; + const completedFive = activeToday ? Number(stats?.todayCompletedFiveMinuteStints || 0) : 0; const challenges = [ { id: "Laps", current: todayLaps, target: 20 }, { id: "Controlled", current: controlled, target: 5 }, @@ -282,6 +300,9 @@ function buildProfileMotivation(stats) { if (stats?.trendSampleReady && Number(stats.consistencyTrendPct || 0) > 0.1) credits.push(t("consistencyCredit", { value: Number(stats.consistencyTrendPct).toFixed(1) })); if (controlled >= 5) credits.push(t("controlledCredit", { laps: controlled })); if (completedFive) credits.push(t("fiveMinuteCredit")); + if (Number(channelStats.latestSplitGainMs || 0) > 0) credits.push(t("sectorCredit", { lap: formatLap(channelStats.latestSplitGainMs) })); + if (Number(stats?.todayStrongFinishStints || 0) > 0) credits.push(t("strongFinishCredit")); + if (Number(stats?.todayCleanFiveMinuteStints || 0) > 0) credits.push(t("cleanFiveCredit")); if (!credits.length && Number(stats?.totalLaps || 0) > 0) credits.push(t("buildingCredit", { laps: stats.totalLaps })); if (!credits.length) credits.push(t("readyCredit")); return { challenges, credits: credits.slice(0, 3) }; @@ -299,7 +320,7 @@ function renderProfile(data) { const recommendation = activeClass?.recommendation || data.recommendation; const goals = activeClass?.goals || data.goals || []; const currentSession = activeClass?.currentSession || data.currentSession; - const motivation = buildProfileMotivation(stats); + const motivation = buildProfileMotivation(stats, channelStats); const recommendationText = recommendationCopy(recommendation); const timerState = readTimerState(); const timerConfig = getTimerConfig(timerState); @@ -462,11 +483,15 @@ function renderProfile(data) {

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

${escapeHtml(t("stints"))}${Number(stats.stintCount || 0)}
+
${escapeHtml(t("controlZone"))}${stats.controlZoneMaxMs ? `${formatLap(stats.controlZoneMinMs)} - ${formatLap(stats.controlZoneMaxMs)}` : "-"}
+
${escapeHtml(t("outlierLaps"))}${Number(stats.outlierLaps || 0)}
+
${escapeHtml(t("strongFinishes"))}${Number(stats.strongFinishStints || 0)}
${escapeHtml(t("bestThree"))}${stats.bestThreeConsecutive ? `3/${formatRaceResultTime(stats.bestThreeConsecutive.totalMs)}` : "-"}
${escapeHtml(t("bestFive"))}${stats.bestFiveMinuteResult ? `${stats.bestFiveMinuteResult.lapCount}/${formatRaceResultTime(stats.bestFiveMinuteResult.totalMs)}` : "-"}
${escapeHtml(t("fiveAverage"))}${stats.bestFiveMinuteResult ? formatLap(stats.bestFiveMinuteResult.averageLapMs) : "-"}
${escapeHtml(t("splitPassings"))}${Number(channelStats.splitCount || 0)}
${escapeHtml(t("bestSplit"))}${channelStats.splitCount ? formatLap(channelStats.bestSplitMs) : "-"}
+
${escapeHtml(t("latestSectorGain"))}${channelStats.latestSplitGainMs ? formatLap(channelStats.latestSplitGainMs) : "-"}
${escapeHtml(t("pitStops"))}${Number(channelStats.pitStopCount || 0)}
${escapeHtml(t("totalPit"))}${channelStats.pitStopCount ? formatRaceResultTime(channelStats.totalPitMs) : "-"}
diff --git a/src/race_audio.js b/src/race_audio.js index 23a0c96..7d2afad 100644 --- a/src/race_audio.js +++ b/src/race_audio.js @@ -13,6 +13,7 @@ export const RACE_AUDIO_EVENT_DEFINITIONS = Object.freeze([ event("personal_best", "race", "low", true, "effects/personal_best.wav", 1500), event("fastest_lap", "race", "normal", true, "effects/fastest_lap_trophy.wav", 1500), event("track_record", "race", "normal", true, "effects/track_record_fanfare.wav", 2000), + event("training_stable_pace", "training", "low", true, "", 120000), event("new_leader", "race", "normal", true, "", 1000), event("position_gain", "race", "low", false, "effects/position_gain.wav", 750), event("position_loss", "race", "low", false, "effects/position_loss.wav", 750), diff --git a/src/training_logic.js b/src/training_logic.js index 528cfb6..b9b6285 100644 --- a/src/training_logic.js +++ b/src/training_logic.js @@ -288,17 +288,47 @@ function getBestConsecutiveAverage(laps, count) { return best; } +function getLongestControlledLapStreak(stints, controlTargetMs) { + let longest = 0; + (Array.isArray(stints) ? stints : []).forEach((stint) => { + let current = 0; + (Array.isArray(stint?.laps) ? stint.laps : []).forEach((lap) => { + if (lap.excludedFromPace || !controlTargetMs || Number(lap.lapMs || 0) > controlTargetMs) { + current = 0; + return; + } + current += 1; + longest = Math.max(longest, current); + }); + }); + return longest; +} + +function getTrainingStintMetrics(stint) { + const laps = (Array.isArray(stint?.laps) ? stint.laps : []).filter((lap) => !lap.excludedFromPace); + const values = laps.map((lap) => Number(lap.lapMs || 0)).filter((lapMs) => lapMs > 500); + const medianMs = percentile(values, 0.5); + const outlierThresholdMs = medianMs ? medianMs * 1.15 : 0; + const outlierLaps = values.filter((lapMs) => outlierThresholdMs && lapMs > outlierThresholdMs).length; + const comparisonSize = values.length >= 6 ? Math.max(3, Math.floor(values.length / 3)) : 0; + const startPaceMs = comparisonSize ? trimmedAverage(values.slice(0, comparisonSize)) : 0; + const endPaceMs = comparisonSize ? trimmedAverage(values.slice(-comparisonSize)) : 0; + return { + laps, + values, + outlierLaps, + comparisonSize, + startPaceMs, + endPaceMs, + endChangeMs: startPaceMs && endPaceMs ? startPaceMs - endPaceMs : null, + strongFinish: Boolean(comparisonSize && endPaceMs <= startPaceMs * 1.01), + }; +} + export function buildTrainingStintAnalysis(state, driverId, classId = "") { const stints = splitTrainingStints(getDriverTrainingLaps(state, driverId, classId)); const rows = stints.map((stint, index) => { - const laps = stint.laps.filter((lap) => !lap.excludedFromPace); - const values = laps.map((lap) => lap.lapMs); - const medianMs = percentile(values, 0.5); - const mistakeThresholdMs = medianMs ? medianMs * 1.15 : 0; - const mistakes = values.filter((lapMs) => mistakeThresholdMs && lapMs > mistakeThresholdMs).length; - const comparisonSize = values.length >= 6 ? Math.max(3, Math.floor(values.length / 3)) : 0; - const startPaceMs = comparisonSize ? trimmedAverage(values.slice(0, comparisonSize)) : 0; - const endPaceMs = comparisonSize ? trimmedAverage(values.slice(-comparisonSize)) : 0; + const { laps, values, outlierLaps, startPaceMs, endPaceMs, endChangeMs, strongFinish } = getTrainingStintMetrics(stint); return { id: `${stint.sessionId}:${stint.startedAt}:${index}`, number: index + 1, @@ -311,12 +341,13 @@ export function buildTrainingStintAnalysis(state, driverId, classId = "") { paceMs: trimmedAverage(values), racePace10Ms: values.length >= 10 ? getBestConsecutiveAverage(laps, 10) : 0, fiveMinuteResult: getBestTrainingDurationResult([stint], 5 * 60 * 1000), - mistakes, - mistakePct: values.length ? (mistakes / values.length) * 100 : 0, + mistakes: outlierLaps, + mistakePct: values.length ? (outlierLaps / values.length) * 100 : 0, consistencyPct: values.length >= 3 ? consistencySpread(values) : null, startPaceMs, endPaceMs, - endChangeMs: startPaceMs && endPaceMs ? startPaceMs - endPaceMs : null, + endChangeMs, + strongFinish, insufficient: values.length < 3, }; }); @@ -344,6 +375,8 @@ export function buildTrainingAchievements(stats) { ["five_minute", source.bestFiveMinuteResult ? 1 : 0, 1], ["five_minutes_3", Number(source.completedFiveMinuteStints || 0), 3], ["consistency_5", source.recentLapCount >= 30 && source.consistencySpreadPct <= 5 ? 1 : 0, 1], + ["strong_finish", Number(source.strongFinishStints || 0), 1], + ["clean_five_minute", Number(source.cleanFiveMinuteStints || 0), 1], ["pace_gain", source.trendSampleReady && source.averageTrendMs > 0 ? 1 : 0, 1], ]; return definitions.map(([id, current, target]) => ({ @@ -380,7 +413,7 @@ function buildDailySummaries(laps) { .sort((left, right) => right.latestTimestamp - left.latestTimestamp); } -function getControlledLapStreak(lapTimes, controlTargetMs) { +export function getControlledLapStreak(lapTimes, controlTargetMs) { let streak = 0; for (let index = lapTimes.length - 1; index >= 0; index -= 1) { if (!controlTargetMs || lapTimes[index] > controlTargetMs) break; @@ -530,16 +563,22 @@ export function buildDriverTrainingStats(state, driverId, classId = "") { 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; + const controlZoneMinMs = bestLapMs; const controlTargetMs = bestLapMs ? Math.round(bestLapMs * 1.08) : 0; - const controlledLapStreak = getControlledLapStreak(lapTimes, controlTargetMs); const dailySummaries = buildDailySummaries(paceLaps); const stints = splitTrainingStints(laps); + const stintMetrics = stints.map((stint) => ({ stint, ...getTrainingStintMetrics(stint) })); + const controlledLapStreak = getLongestControlledLapStreak(stints, controlTargetMs); const completedFiveMinuteStints = stints.filter((stint) => getBestTrainingDurationResult([stint], 5 * 60 * 1000)).length; + const cleanFiveMinuteStints = stintMetrics.filter(({ stint, outlierLaps }) => !outlierLaps && getBestTrainingDurationResult([stint], 5 * 60 * 1000)).length; + const strongFinishStints = stintMetrics.filter((metrics) => metrics.strongFinish).length; + const outlierLaps = stintMetrics.reduce((sum, metrics) => sum + metrics.outlierLaps, 0); const bestThreeConsecutive = getBestConsecutiveTrainingLaps(stints, 3); const bestFiveMinuteResult = getBestTrainingDurationResult(stints, 5 * 60 * 1000); const todayKey = getLocalDayKey(Date.now()); const todayLaps = laps.filter((lap) => getLocalDayKey(lap.timestamp) === todayKey); const todayStints = splitTrainingStints(todayLaps); + const todayStintMetrics = todayStints.map((stint) => ({ stint, ...getTrainingStintMetrics(stint) })); const todayLapTimes = todayLaps.filter((lap) => !lap.excludedFromPace).map((lap) => lap.lapMs); const stats = { @@ -554,12 +593,20 @@ export function buildDriverTrainingStats(state, driverId, classId = "") { recentBestMs, previousBestMs, personalBestGainMs, + controlZoneMinMs, + controlZoneMaxMs: controlTargetMs, controlledLapStreak, + outlierLaps, + strongFinishStints, + cleanFiveMinuteStints, todayLaps: todayLaps.length, todayBestLapMs: todayLapTimes.length ? Math.min(...todayLapTimes) : 0, todayAverageLapMs: average(todayLapTimes), - todayControlledLapStreak: getControlledLapStreak(todayLapTimes, controlTargetMs), + todayControlledLapStreak: getLongestControlledLapStreak(todayStints, controlTargetMs), + todayOutlierLaps: todayStintMetrics.reduce((sum, metrics) => sum + metrics.outlierLaps, 0), + todayStrongFinishStints: todayStintMetrics.filter((metrics) => metrics.strongFinish).length, todayCompletedFiveMinuteStints: todayStints.filter((stint) => getBestTrainingDurationResult([stint], 5 * 60 * 1000)).length, + todayCleanFiveMinuteStints: todayStintMetrics.filter(({ stint, outlierLaps: count }) => !count && getBestTrainingDurationResult([stint], 5 * 60 * 1000)).length, consistencySpreadPct, previousConsistencySpreadPct, consistencyTrendPct: trendSampleReady ? previousConsistencySpreadPct - consistencySpreadPct : 0, @@ -611,12 +658,24 @@ export function buildDriverTimingChannelStats(state, driverId, classId = "") { pitStops.sort((left, right) => Number(left.enteredAt || 0) - Number(right.enteredAt || 0)); const splitTimes = splits.map((split) => split.splitMs); const pitTimes = pitStops.map((stop) => stop.pitStopMs); + let runningBestSplitMs = 0; + let latestSplitGainMs = 0; + let latestSplitGainAt = 0; + splits.forEach((split) => { + if (runningBestSplitMs && split.splitMs < runningBestSplitMs) { + latestSplitGainMs = runningBestSplitMs - split.splitMs; + latestSplitGainAt = Number(split.timestamp || 0); + } + if (!runningBestSplitMs || split.splitMs < runningBestSplitMs) runningBestSplitMs = split.splitMs; + }); return { splitCount: splits.length, splitSessions: new Set(splits.map((split) => split.sessionId)).size, bestSplitMs: splitTimes.length ? Math.min(...splitTimes) : 0, averageSplitMs: splitTimes.length ? Math.round(splitTimes.reduce((sum, value) => sum + value, 0) / splitTimes.length) : 0, lastSplitMs: splitTimes.at(-1) || 0, + latestSplitGainMs, + latestSplitGainAt, pitStopCount: pitStops.length, pitSessions: new Set(pitStops.map((stop) => stop.sessionId)).size, totalPitMs: pitTimes.reduce((sum, value) => sum + value, 0), @@ -644,12 +703,18 @@ export function buildDriverActivePracticeStats(state, driverId, nowTs = Date.now && (!startedAt || Number(passing?.timestamp || 0) >= startedAt) )) .map((passing) => ({ + sessionId: String(session.id || ""), + sourceId: String(passing.competitorKey || passing.carId || passing.transponder || passing.driverId || "default"), timestamp: Math.max(0, Number(passing.timestamp || 0) || 0), lapMs: Math.round(Number(passing.lapMs || 0) || 0), excludedFromPace: Boolean(passing.excludedFromPace || passing.validationWarning === "above_max_stint"), + stintBoundary: Boolean(passing.stintBoundary || passing.validationWarning === "above_max_stint"), })) .sort((left, right) => left.timestamp - right.timestamp); const paceLapTimes = laps.filter((lap) => !lap.excludedFromPace).map((lap) => lap.lapMs); + const bestLapMs = paceLapTimes.length ? Math.min(...paceLapTimes) : 0; + const controlZoneMaxMs = bestLapMs ? Math.round(bestLapMs * 1.08) : 0; + const activeStints = splitTrainingStints(laps); return { sessionId: String(session.id || ""), @@ -657,9 +722,12 @@ export function buildDriverActivePracticeStats(state, driverId, nowTs = Date.now startedAt, elapsedMs: startedAt ? Math.max(0, Number(nowTs || 0) - startedAt) : 0, laps: laps.length, - bestLapMs: paceLapTimes.length ? Math.min(...paceLapTimes) : 0, + bestLapMs, averageLapMs: average(paceLapTimes), lastLapMs: laps.at(-1)?.lapMs || 0, + controlZoneMinMs: bestLapMs, + controlZoneMaxMs, + controlledLapStreak: getLongestControlledLapStreak(activeStints, controlZoneMaxMs), }; } @@ -730,6 +798,9 @@ export function buildTrainingHighlights(stats) { const highlights = []; if (source.todayLaps > 0) highlights.push({ id: "today_laps", value: Number(source.todayLaps) }); if (source.personalBestGainMs > 0) highlights.push({ id: "personal_best", value: Number(source.personalBestGainMs) }); + if (source.latestSplitGainMs > 0) highlights.push({ id: "sector_gain", value: Number(source.latestSplitGainMs) }); + if (source.todayStrongFinishStints > 0) highlights.push({ id: "strong_finish", value: Number(source.todayStrongFinishStints) }); + if (source.todayCleanFiveMinuteStints > 0) highlights.push({ id: "clean_five_minute", value: Number(source.todayCleanFiveMinuteStints) }); if (source.trendSampleReady && source.averageTrendMs > 0) highlights.push({ id: "pace_gain", value: Number(source.averageTrendMs) }); if (source.trendSampleReady && source.consistencyTrendPct > 0.1) highlights.push({ id: "consistency_gain", value: Number(source.consistencyTrendPct) }); if (source.todayControlledLapStreak >= 5) highlights.push({ id: "controlled_streak", value: Number(source.todayControlledLapStreak) }); diff --git a/src/training_views.js b/src/training_views.js index 146b38d..ab62bc3 100644 --- a/src/training_views.js +++ b/src/training_views.js @@ -1,13 +1,16 @@ import { getDriverProfileGroups, getDriverProfileReferences } from "./driver_profile_logic.js"; function renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock }) { - const endChange = row.endChangeMs == null + const paceRetention = row.endChangeMs == null ? "-" : row.endChangeMs > 0 ? t("training.stint_faster", { lap: formatLap(row.endChangeMs) }) : row.endChangeMs < 0 ? t("training.stint_slower", { lap: formatLap(Math.abs(row.endChangeMs)) }) : t("training.stint_same"); + const endChange = row.strongFinish + ? `${paceRetention} · ${t("training.strong_finish")}` + : paceRetention; return ` ${row.number} @@ -101,7 +104,7 @@ export function renderTrainingView(deps) { const recommendation = buildTrainingRecommendation(stats); const nextMilestone = getNextTrainingMilestone(stats); const missions = buildTrainingMissions(stats); - const highlights = buildTrainingHighlights(stats); + const highlights = buildTrainingHighlights({ ...stats, latestSplitGainMs: channelStats.latestSplitGainMs }); const paceBuckets = buildTrainingPaceBuckets(state, selectedProfileId, selectedClassId, 30).slice(-24); const weekdayStats = buildTrainingWeekdayStats(state, selectedProfileId, selectedClassId); const stintAnalysis = buildTrainingStintAnalysis(state, selectedProfileId, selectedClassId); @@ -326,6 +329,8 @@ export function renderTrainingView(deps) { ${trainingInsight(t("training.best_lap"), formatLap(activePractice.bestLapMs))} ${trainingInsight(t("training.average_lap"), formatLap(activePractice.averageLapMs))} ${trainingInsight(t("training.latest_lap"), formatLap(activePractice.lastLapMs))} + ${trainingInsight(t("training.control_zone"), activePractice.controlZoneMaxMs ? `${formatLap(activePractice.controlZoneMinMs)} - ${formatLap(activePractice.controlZoneMaxMs)}` : "-")} + ${trainingInsight(t("training.controlled_streak"), t("training.lap_count", { laps: activePractice.controlledLapStreak }))}

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

@@ -510,7 +515,10 @@ export function renderTrainingView(deps) {
${trainingInsight(t("training.average_lap"), formatLap(stats.averageLapMs))} ${trainingInsight(t("training.recent_average"), formatLap(stats.recentAverageMs))} + ${trainingInsight(t("training.control_zone"), stats.controlZoneMaxMs ? `${formatLap(stats.controlZoneMinMs)} - ${formatLap(stats.controlZoneMaxMs)}` : "-")} ${trainingInsight(t("training.controlled_streak"), t("training.lap_count", { laps: stats.controlledLapStreak }))} + ${trainingInsight(t("training.outlier_laps"), String(stats.outlierLaps))} + ${trainingInsight(t("training.strong_finishes"), String(stats.strongFinishStints))} ${trainingInsight(t("training.last_activity"), lastActivity)} ${trainingInsight(t("training.stints"), String(stats.stintCount))} ${trainingInsight(t("training.best_three"), stats.bestThreeConsecutive ? `3/${formatRaceClock(stats.bestThreeConsecutive.totalMs)}` : "-")} @@ -518,6 +526,7 @@ export function renderTrainingView(deps) { ${trainingInsight(t("training.five_avg_pace"), stats.bestFiveMinuteResult ? formatLap(stats.bestFiveMinuteResult.averageLapMs) : "-")} ${trainingInsight(t("training.split_passings"), String(channelStats.splitCount))} ${trainingInsight(t("training.best_split"), channelStats.splitCount ? formatLap(channelStats.bestSplitMs) : "-")} + ${trainingInsight(t("training.latest_sector_gain"), channelStats.latestSplitGainMs ? formatLap(channelStats.latestSplitGainMs) : "-")} ${trainingInsight(t("training.average_split"), channelStats.splitCount ? formatLap(channelStats.averageSplitMs) : "-")} ${trainingInsight(t("training.pit_stops"), String(channelStats.pitStopCount))} ${trainingInsight(t("training.total_pit_time"), channelStats.pitStopCount ? formatRaceClock(channelStats.totalPitMs) : "-")} @@ -907,6 +916,9 @@ function trainingInsight(label, value) { function formatTrainingHighlight(highlight, { t, formatLap }) { if (highlight.id === "personal_best") return t("training.highlight_personal_best", { lap: formatLap(highlight.value) }); + if (highlight.id === "sector_gain") return t("training.highlight_sector_gain", { lap: formatLap(highlight.value) }); + if (highlight.id === "strong_finish") return t("training.highlight_strong_finish", { count: highlight.value }); + if (highlight.id === "clean_five_minute") return t("training.highlight_clean_five_minute", { count: highlight.value }); if (highlight.id === "pace_gain") return t("training.highlight_pace_gain", { lap: formatLap(highlight.value) }); if (highlight.id === "consistency_gain") return t("training.highlight_consistency_gain", { value: Number(highlight.value).toFixed(1) }); if (highlight.id === "controlled_streak") return t("training.highlight_controlled_streak", { laps: highlight.value }); diff --git a/tests/decoder_runtime.test.js b/tests/decoder_runtime.test.js index 33e12e5..ab7a262 100644 --- a/tests/decoder_runtime.test.js +++ b/tests/decoder_runtime.test.js @@ -60,6 +60,18 @@ function createHarness(options = {}) { return { state, session, passing, overlayEvents, audioEvents, getSaveCount: () => saveCount }; } +test("five controlled practice laps trigger one stable-pace voice cue", () => { + const harness = createHarness({ sessionType: "open_practice" }); + for (let lap = 1; lap <= 5; lap += 1) harness.passing(1_000 + lap * 20_000, "main"); + assert.equal(harness.audioEvents.filter((event) => event === "training_stable_pace").length, 1); +}); + +test("stable-pace training cue is never emitted during a race", () => { + const harness = createHarness({ sessionType: "team_race" }); + for (let lap = 1; lap <= 5; lap += 1) harness.passing(1_000 + lap * 20_000, "main"); + assert.equal(harness.audioEvents.includes("training_stable_pace"), false); +}); + test("split decoder records intermediate time without changing main lap state", () => { const harness = createHarness(); assert.equal(harness.passing(17_000, "main"), true); diff --git a/tests/public_training_profile.test.js b/tests/public_training_profile.test.js index 6543d09..1318cae 100644 --- a/tests/public_training_profile.test.js +++ b/tests/public_training_profile.test.js @@ -105,6 +105,7 @@ test("public profile contains only the selected driver's sanitized aggregates", splitCount: 2, bestSplitMs: 7000, averageSplitMs: 7100, + latestSplitGainMs: 200, pitStopCount: 1, totalPitMs: 12500, bestPitStopMs: 12500, diff --git a/tests/race_audio.test.js b/tests/race_audio.test.js index 1dc17ef..96840e7 100644 --- a/tests/race_audio.test.js +++ b/tests/race_audio.test.js @@ -88,7 +88,7 @@ test("event catalog covers every supported race-audio event", () => { const keys = new Set(RACE_AUDIO_EVENT_DEFINITIONS.map((item) => item.key)); [ "grid_open", "grid_closed", "race_1_minute", "race_30_seconds", "race_10_seconds", "drivers_ready", "start_sequence", "start_tone", "false_start", "restart_race", - "transponder_pass", "lap_completed", "personal_best", "fastest_lap", "track_record", "new_leader", "position_gain", "position_loss", "final_lap", "one_minute_remaining", "thirty_seconds_remaining", "ten_seconds_remaining", "time_expired", "race_finished", + "transponder_pass", "lap_completed", "personal_best", "fastest_lap", "track_record", "training_stable_pace", "new_leader", "position_gain", "position_loss", "final_lap", "one_minute_remaining", "thirty_seconds_remaining", "ten_seconds_remaining", "time_expired", "race_finished", "track_call", "track_clear", "yellow_flag", "red_flag", "race_paused", "race_resumed", "marshal_warning", "marshal_required", "penalty", "stop_and_go", "drive_through", "disqualified", "pit_lane_open", "pit_lane_closed", "entering_pits", "leaving_pits", "pit_speed_warning", "qualifying_start", "qualifying_remaining", "qualifying_finished", "provisional_pole", "pole_position", diff --git a/tests/training_logic.test.js b/tests/training_logic.test.js index d9e7d4c..b936fe3 100644 --- a/tests/training_logic.test.js +++ b/tests/training_logic.test.js @@ -134,7 +134,8 @@ test("driver statistics remain isolated and aggregate days and sessions", () => assert.equal(stats.trainingDays, 2); assert.equal(stats.bestLapMs, 16500); assert.equal(stats.averageLapMs, 17167); - assert.equal(stats.controlledLapStreak, 2); + // A flow streak never carries over into another session or stint. + assert.equal(stats.controlledLapStreak, 1); assert.equal(stats.dailySummaries[0].laps, 1); assert.equal(stats.dailySummaries[1].laps, 2); }); @@ -193,6 +194,7 @@ test("split and pit statistics aggregate by personal profile and selected class" assert.equal(stats.splitCount, 2); assert.equal(stats.bestSplitMs, 7900); assert.equal(stats.averageSplitMs, 8000); + assert.equal(stats.latestSplitGainMs, 200); assert.equal(stats.pitStopCount, 2); assert.equal(stats.totalPitMs, 22000); assert.equal(stats.bestPitStopMs, 9500); @@ -292,6 +294,9 @@ test("active practice stats include only the selected driver's current valid lap bestLapMs: 16_000, averageLapMs: 16_500, lastLapMs: 17_000, + controlZoneMinMs: 16_000, + controlZoneMaxMs: 17_280, + controlledLapStreak: 2, }); }); @@ -391,6 +396,31 @@ test("a stint shorter than the target duration has no five-minute result", () => assert.equal(getBestTrainingDurationResult(stints, 300_000), null); }); +test("training progress recognizes a clean five-minute stint and strong finish", () => { + const ledger = Array.from({ length: 16 }, (_, index) => ({ + driverId: "driver_1", + sourceId: "transponder_1", + sessionId: "practice_1", + timestamp: (index + 1) * 20_000, + lapMs: 20_000, + })); + const state = { training: { ledger } }; + const stats = buildDriverTrainingStats(state, "driver_1"); + const analysis = buildTrainingStintAnalysis(state, "driver_1"); + + assert.equal(stats.controlZoneMinMs, 20_000); + assert.equal(stats.controlZoneMaxMs, 21_600); + assert.equal(stats.controlledLapStreak, 16); + assert.equal(stats.outlierLaps, 0); + assert.equal(stats.strongFinishStints, 1); + assert.equal(stats.cleanFiveMinuteStints, 1); + assert.equal(stats.achievements.find((item) => item.id === "strong_finish").unlocked, true); + assert.equal(stats.achievements.find((item) => item.id === "clean_five_minute").unlocked, true); + assert.equal(analysis.rows[0].strongFinish, true); + assert.equal(analysis.rows[0].fiveMinuteResult.lapCount, 15); + assert.equal(analysis.rows[0].fiveMinuteResult.totalMs, 300_000); +}); + test("recommendations select baseline, consistency, and progression", () => { assert.equal(buildTrainingRecommendation({ totalLaps: 9 }).level, "baseline"); assert.deepEqual(