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 }) {
${escapeHtml(t("training.live_practice_hint"))}
@@ -510,7 +515,10 @@ export function renderTrainingView(deps) {