diff --git a/README.md b/README.md index 057e337..d013616 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ Recommended deployment: - built-in step-by-step guide - inline descriptions for all `Race Format` fields - `Follow-up time` after scheduled race time before a heat fully closes - - `Min lap time` and `Max lap time` filtering for shortcuts/false hits and better stint/stat logic + - `Min lap time` rejects shortcuts/false hits; `Max lap time` starts a new statistics stint without removing the race lap - seeding modes for practice/qualifying: best N laps total, average, or consecutive - selectable qualifying points tables and tie-break rules - `Race Format` presets for short technical tracks, club races, IFMAR-style layouts, and endurance @@ -210,7 +210,7 @@ Recommended deployment: - session start types: `Mass start`, `Position start`, `Staggered` - `Timing` shows grid/order for the active `Position start` session - leaderboard shows `gap to leader`, `gap ahead`, and `own delta` - - `Recent Passings` shows invalid laps such as `Short lap` and `Over max lap` + - `Recent Passings` distinguishes invalid `Short lap` passings from counted long laps that start a new stint - manual corrections in `Timing -> Details`: `+1/-1 lap`, `+1/+5/-1 sec`, reset - practice/qualifying seeding on best `2` or `3` laps - Persistence: @@ -239,7 +239,7 @@ The personal profile can also store `Club` and `Country`. These values are inher - durable lifetime valid-lap ledger synchronized from stored session results - practice days, recorded sessions, personal best, overall/recent averages, controlled-lap streak, and recent consistency spread -- automatic stint boundaries after a passing gap longer than 2 minutes +- automatic stint boundaries at the configured `Max lap time`, with a 2-minute fallback for legacy/unconfigured data - 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 @@ -357,20 +357,20 @@ Erik Eriksson;654321;4WD;Associated;; - `Min lap time`: - laps faster than the threshold are ignored as shortcuts or false hits - `Max lap time`: - - laps slower than the threshold are not counted as valid laps - - also used to split stints and improve statistics + - a slower passing starts a new statistics stint + - the transition lap still counts in race results, but is excluded from pace and five-minute averages Example: - track around `16 sec/lap` - `Min lap time = 11 sec` -- `Max lap time = 60 sec` +- `Max lap time = 120 sec` to begin a new stint after a two-minute interruption ### Invalid passings - `Timing -> Recent Passings` shows both valid and invalid passings - displayed statuses include: - - `Short lap` - - `Over max lap` -- invalid passings are visually highlighted + - `Short lap` for an invalid shortcut/false hit + - `Long lap · counted · new stint` when max is exceeded +- invalid passings are visually highlighted; counted long laps remain in the race result ### Manual corrections - open `Timing` diff --git a/README.sv.md b/README.sv.md index ff417e1..d355985 100644 --- a/README.sv.md +++ b/README.sv.md @@ -148,7 +148,7 @@ Rekommenderad deploy: - inbyggd guide för hur man skapar race steg för steg - beskrivningar direkt i alla fält under `Raceformat` - `Follow-up tid` efter ordinarie racetid innan heatet stängs - - `Min varvtid` och `Max varvtid` för att filtrera shortcuts/felträffar och styra bättre statistik/stintlogik + - `Min varvtid` underkänner shortcuts/felträffar; `Max varvtid` startar en ny statistikstint utan att ta bort racevarvet - seedmetoder för practice/kval: bästa N varv som summa, snitt eller konsekutiva varv - valbar kval-poängtabell och tie-break-regler - presets i `Raceformat` för kort teknisk bana, klubbrace, IFMAR-liknande upplägg och endurance @@ -201,7 +201,7 @@ Rekommenderad deploy: - Sessioninställningar för `Mass start`, `Position start`, `Staggered` - `Timing` visar grid/startordning för aktiv `Position start`-session - leaderboard visar både `gap till ledaren`, `gap till bilen framför` och `eget delta` mot förra varvet - - `Senaste passeringar` visar nu även ogiltiga varv med status `För kort varv` eller `Över maxvarv` + - `Senaste passeringar` skiljer ogiltiga `För kort varv` från räknade långa varv som startar en ny stint - manuella korrigeringar i `Tidtagning -> Detaljer`: `+1/-1 varv`, `+1/+5/-1 sek`, `Nollställ korrigering` - Practice/Kval kan seedas på bästa `2` eller `3` varv i sessionsinställningar - Persistens: @@ -228,7 +228,7 @@ Förarprofilen kan även lagra `Klubb` och `Land`. Värdena ärvs när ytterliga - beständig logg med totalt antal giltiga varv synkad från sparade sessionsresultat - träningsdagar, antal sessioner, personbästa, total-/30-varvssnitt, kontrollerad varvserie och spridning -- automatisk ny stint efter ett glapp på mer än 2 minuter mellan passeringar +- automatisk ny stint vid inställd `Max varvtid`, med 2 minuter som reservregel för äldre eller okonfigurerad data - 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 @@ -346,20 +346,20 @@ Erik Eriksson;654321;4WD;Associated;; - `Min varvtid`: - varv snabbare än gränsen ignoreras som shortcut eller felträff - `Max varvtid`: - - varv långsammare än gränsen räknas inte som giltigt varv - - används också för att bryta stintar och förbättra statistik + - en långsammare passering startar en ny statistikstint + - övergångsvarvet räknas fortfarande i raceresultatet men utesluts ur pace- och femminuterssnitt Praktiskt exempel: - bana runt `16 sek/varv` - `Min varvtid = 11 sek` -- `Max varvtid = 60 sek` +- `Max varvtid = 120 sek` för att börja en ny stint efter två minuters avbrott ### Ogiltiga passeringar - `Tidtagning -> Senaste passeringar` visar nu både giltiga och ogiltiga passeringar - status som visas: - - `För kort varv` - - `Över maxvarv` -- ogiltiga passeringar markeras visuellt i listan + - `För kort varv` för en ogiltig shortcut/felträff + - `Långt varv · räknat · ny stint` när maxgränsen passeras +- ogiltiga passeringar markeras visuellt; räknade långa varv ligger kvar i raceresultatet ### Manuella korrigeringar - öppna `Tidtagning` diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 6e261ac..2d38f58 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -527,7 +527,7 @@ Owns: - valid driver-lap aggregation across the training ledger, scoped by personal profile and class - lifetime/session/day/best-lap/average/consistency calculations - active Open/Free/regular Practice statistics scoped to one personal profile, one class, and the current session start -- stint splitting after passing gaps greater than `120000 ms` +- 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 @@ -548,7 +548,7 @@ Rules: - statistics combine linked driver records only when their `classId` matches the selected class - 2WD and 4WD lap times, stints, best-three, five-minute results, and recommendations are never combined - legacy driver records without `profileId` are grouped by normalized driver name until explicitly linked or separated in the Drivers UI -- invalid laps and lap times at or below `500 ms` are excluded +- invalid laps and lap times at or below `500 ms` are excluded; counted max-boundary laps remain in the ledger but are excluded from pace calculations - 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 @@ -561,8 +561,8 @@ Rules: - ledger rows for a deleted session are retained and become historical records - `sourceKey` deduplicates repeated saves of the same session passing - a session change always starts a new stint -- a passing gap strictly greater than 2 minutes starts a new stint; exactly 2 minutes remains in the current stint -- a boundary lap whose `lapMs` contains the full break is excluded from pace metrics +- a passing with `stintBoundary: true` starts a new stint at that passing; a gap strictly greater than 2 minutes is the fallback for old data +- a boundary lap whose `lapMs` contains the full break is counted in race results but excluded from pace and duration metrics - 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 @@ -1030,6 +1030,8 @@ Browser host 192.168.1.100 uses ws://192.168.1.100:9000 through :9003 Invariants: - only `main` may change `entry.laps`, `entry.lastTimestamp`, or append to `result.passings` +- `minLapMs` is a hard validity threshold: a faster passing is stored as `below_min` and does not increment laps +- `maxLapMs` is a statistics threshold: a slower passing increments race laps, carries `above_max_stint`, and starts a new pace stint - split timing is measured from the latest main timestamp, or mass-start timestamp before the first completed lap - a duplicate pit-entry signal while `entry.pitActive` is true is ignored - pit exit without a prior pit entry is ignored @@ -1814,6 +1816,9 @@ Practical shape: manualTimeAdjustmentMs?: number, invalidPending?: boolean, invalidReason?: string, + validationWarning?: "above_max_stint" | "", + excludedFromPace?: boolean, + stintBoundary?: boolean, ...runtime-derived leaderboard fields } }, @@ -2440,8 +2445,8 @@ npm test - recommendation branch selection - custom goal and automatic milestone progress - selected-profile/class active-practice isolation, current-start filtering, and non-practice rejection -- two-minute stint boundaries, cross-session isolation, and boundary-lap exclusion -- best-three consecutive calculation and five-minute final-lap/average-pace behavior +- configured max-lap and two-minute fallback stint boundaries, cross-session/source isolation, and boundary-lap exclusion +- best-three consecutive calculation and five-minute final-lap/average-pace behavior, including a sanity bound that prevents impossible combined results - trimmed 30-minute pace buckets and weekday aggregation - class-isolated setup-marker before/after comparison - short-stint suppression, 10-lap race pace, and mistake detection @@ -2482,6 +2487,7 @@ npm test - split and pit roles never incrementing normal laps - pit-entry attribution surviving an endurance driver/transponder change - one WebSocket per enabled decoder role +- minimum-lap shortcut rejection and maximum-lap counted stint boundaries - decoder lifecycle events not rerendering and replacing the Settings form `tests/audio_runtime.test.js` covers: diff --git a/server/public_training_profile.js b/server/public_training_profile.js index 67cd295..5cf2a6a 100644 --- a/server/public_training_profile.js +++ b/server/public_training_profile.js @@ -51,6 +51,9 @@ function buildPublicTrainingProfile(state, shareToken, updatedAt, sinceTimestamp timestamp: Math.max(0, Number(entry.timestamp || 0) || 0), lapMs: Math.max(0, Math.round(Number(entry.lapMs || 0) || 0)), sessionId: String(entry.sessionId || ""), + sourceId: String(entry.sourceId || entry.driverId || ""), + excludedFromPace: Boolean(entry.excludedFromPace), + stintBoundary: Boolean(entry.stintBoundary), })) .sort((left, right) => left.timestamp - right.timestamp); const stats = buildPublicTrainingStats(classLaps); @@ -148,9 +151,9 @@ function normalizePublicTrainingWindow(value) { } function buildPublicTrainingWindowStats(laps) { - const lapTimes = laps.map((lap) => Number(lap.lapMs || 0)).filter((lapMs) => lapMs > 500); + const lapTimes = laps.filter((lap) => !lap.excludedFromPace).map((lap) => Number(lap.lapMs || 0)).filter((lapMs) => lapMs > 500); return { - laps: lapTimes.length, + laps: laps.length, bestLapMs: lapTimes.length ? Math.min(...lapTimes) : 0, averageLapMs: lapTimes.length ? Math.round(lapTimes.reduce((sum, lapMs) => sum + lapMs, 0) / lapTimes.length) : 0, }; @@ -171,7 +174,8 @@ function buildPublicTrainingGoal(goal, currentLaps) { } function buildPublicTrainingStats(laps) { - const lapTimes = laps.map((lap) => lap.lapMs); + const paceLaps = laps.filter((lap) => !lap.excludedFromPace); + const lapTimes = paceLaps.map((lap) => lap.lapMs); const recent = lapTimes.slice(-30); const previous = lapTimes.slice(Math.max(0, lapTimes.length - 60), Math.max(0, lapTimes.length - 30)); const average = (values) => values.length ? Math.round(values.reduce((sum, value) => sum + value, 0) / values.length) : 0; @@ -191,7 +195,7 @@ function buildPublicTrainingStats(laps) { const previousConsistencySpreadPct = spread(previous); const trendSampleReady = recent.length >= 10 && previous.length >= 10; const daily = new Map(); - laps.forEach((lap) => { + paceLaps.forEach((lap) => { const date = new Date(lap.timestamp); const dateKey = Number.isNaN(date.getTime()) ? "unknown" : `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; const day = daily.get(dateKey) || { dateKey, laps: 0, totalMs: 0, bestLapMs: 0, latestTimestamp: 0 }; @@ -265,24 +269,40 @@ function buildPublicTrainingAchievements(stats) { } function splitPublicTrainingStints(laps, breakThresholdMs = 2 * 60 * 1000) { - const stints = []; + const streams = new Map(); (Array.isArray(laps) ? laps : []).forEach((lap) => { - const previousStint = stints.at(-1); - const previousLap = previousStint?.laps.at(-1); - const lapStartedAt = Math.max(0, Number(lap.timestamp || 0) - Number(lap.lapMs || 0)); - const breakMs = previousLap ? Math.max(0, Number(lap.timestamp || 0) - Number(previousLap.timestamp || 0)) : 0; - const startsNewStint = !previousStint - || String(previousLap.sessionId || "") !== String(lap.sessionId || "") - || breakMs > breakThresholdMs; const normalizedLap = { ...lap, - excludedFromPace: Boolean(previousLap && breakMs > breakThresholdMs && Number(lap.lapMs || 0) > breakThresholdMs), + sessionId: String(lap.sessionId || ""), + sourceId: String(lap.sourceId || lap.driverId || "default"), + timestamp: Number(lap.timestamp || 0), + lapMs: Number(lap.lapMs || 0), }; - if (startsNewStint) { - stints.push({ sessionId: String(lap.sessionId || ""), laps: [normalizedLap] }); - } else { - previousStint.laps.push(normalizedLap); - } + const key = `${normalizedLap.sessionId}:${normalizedLap.sourceId}`; + if (!streams.has(key)) streams.set(key, []); + streams.get(key).push(normalizedLap); + }); + + const stints = []; + streams.forEach((stream) => { + let current = null; + stream.sort((left, right) => left.timestamp - right.timestamp).forEach((lap) => { + const previousLap = current?.laps.at(-1); + const breakMs = previousLap ? Math.max(0, lap.timestamp - previousLap.timestamp) : 0; + const detectedBreak = Boolean(previousLap && breakMs > breakThresholdMs); + const explicitBoundary = Boolean(lap.stintBoundary); + const normalizedLap = { + ...lap, + excludedFromPace: Boolean(lap.excludedFromPace || explicitBoundary || (detectedBreak && lap.lapMs > breakThresholdMs)), + stintBoundary: Boolean(explicitBoundary || detectedBreak), + }; + if (!current || detectedBreak || explicitBoundary) { + current = { sessionId: lap.sessionId, sourceId: lap.sourceId, laps: [normalizedLap] }; + stints.push(current); + } else { + current.laps.push(normalizedLap); + } + }); }); return stints; } @@ -319,6 +339,7 @@ function getPublicBestDurationResult(stints, targetMs) { const lastLap = counted.at(-1); if (!lastLap || Number(lastLap.timestamp || 0) < cutoffAt) continue; const totalMs = Number(lastLap.timestamp || 0) - startedAt; + if (totalMs > targetMs + Number(lastLap.lapMs || 0)) continue; const lapCount = counted.length; const candidate = { targetMs, diff --git a/src/app.js b/src/app.js index 836971c..38d6ddf 100644 --- a/src/app.js +++ b/src/app.js @@ -251,6 +251,7 @@ const renderRaceDay = () => renderRaceDayView({ finishSession: finishOperationalSession, pauseSession: pauseOperationalSession, resumeSession: resumeOperationalSession, + saveLapWindow: saveOperationalLapWindow, navigateTo: navigateToView, getStartLightState: () => raceAudioEngine.getStartLightState(), }); @@ -393,6 +394,11 @@ const TRANSLATIONS = { "race_day.check_duplicate_transponders": "Dubbla transpondrar", "race_day.check_grid": "Startgrid", "race_day.check_audio": "Raceljud", + "race_day.lap_window_edit": "Justera varvgränser", + "race_day.lap_window_hint": "Min underkänner shortcuts. Max avslutar statistikstinten men varvet räknas fortfarande i racet. Gäller nya passeringar.", + "race_day.min_lap_sec": "Min sek", + "race_day.max_lap_sec": "Max sek", + "race_day.lap_window_invalid": "Max måste vara större än min och båda måste vara positiva.", "race_day.backup_failed_confirm": "Automatisk backup misslyckades: {msg}\n\nStarta ändå?", "race_day.start_failed": "Sessionen kunde inte startas.", "nav.group_start": "Start", @@ -534,8 +540,11 @@ const TRANSLATIONS = { "training.stint_slower": "{lap} långsammare", "training.stint_same": "Oförändrat", "training.stint_analysis": "Stintanalys", - "training.stint_analysis_hint": "En paus över två minuter startar en ny stint. Kortare stintar visas men rankas inte.", + "training.stint_analysis_hint": "En passering över inställd max varvtid, eller en paus över två minuter utan maxgräns, startar en ny stint. Övergångsvarvet räknas i race men inte i pace. 5-min resultat visar 300 sekunder plus sista påbörjade varvet.", "training.best_stint": "Bästa stint {stint}: {laps} varv, {pace}", + "training.stint_five_minute": "5-min resultat", + "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.split_passings": "Mellantidspasseringar", @@ -999,7 +1008,7 @@ const TRANSLATIONS = { "events.min_lap_time": "Min varvtid (sek)", "events.min_lap_time_hint": "Varv snabbare än detta ignoreras som shortcut eller felträff.", "events.max_lap_time": "Max varvtid (sek)", - "events.max_lap_time_hint": "Varv långsammare än detta räknas inte som giltigt varv och bryter lap-basen för nästa varv.", + "events.max_lap_time_hint": "En passering över max startar en ny statistikstint och tas inte med i pace-snittet. Varvet räknas fortfarande i race och resultat.", "events.race_driver_scope": "Race i denna klass använder alla förare i vald klass om sessionen inte har egen deltagarlista.", "events.reserve_bump_slots_hint": "Reserverar tomma platser i högre finaler så bumpade förare kan flyttas in utan att skriva över seedade platser.", "events.team_race": "Lagrace", @@ -1142,6 +1151,7 @@ const TRANSLATIONS = { "timing.valid_passing": "Giltigt varv", "timing.invalid_short": "För kort varv", "timing.invalid_long": "Över maxvarv", + "timing.long_lap_stint": "Långt varv · räknat · ny stint", "judging.title": "Domarvy", "judging.active_session": "Aktiv session", "judging.no_active_session": "Ingen aktiv session vald.", @@ -1495,7 +1505,7 @@ const TRANSLATIONS = { "guide.race_format_7": "Källa för finaler avgör om finalerna seedas från practice eller kvalrankingen.", "guide.race_format_8": "Follow-up tid ger en extra uppsamlingsperiod efter ordinarie racetid innan heatet verkligen stängs.", "guide.race_format_9": "Min varvtid filtrerar bort shortcuts och felträffar. Exempel: på en 16-sekundersbana kan du sätta 11 sekunder som min-gräns.", - "guide.race_format_10": "Max varvtid stoppar långa felvarv från att räknas och används också för att bryta stintar och förbättra statistik. Exempel: 60 sekunder.", + "guide.race_format_10": "Max varvtid är en statistikgräns. En längre passering avslutar stinten och utesluts ur pace-snittet, men varvet räknas fortfarande i race. Exempel: 120 sekunder ger en ny stint efter två minuters uppehåll.", "guide.race_format_11": "Preset låter dig snabbt fylla raceformat med vettiga grundvärden för kort teknisk bana, klubbrace, IFMAR-liknande upplägg eller endurance.", "guide.race_format_11a": "I Grundläge döljer endurance-presetet de flesta kval- och finalfälten för att hålla fokus på Team Race. Öppna Avancerat om eventet även ska ha stödheat eller vanliga finaler.", "guide.race_format_12": "Du kan applicera preset och sedan justera enskilda fält manuellt innan du sparar raceformatet.", @@ -1519,7 +1529,7 @@ const TRANSLATIONS = { "guide.team_5": "Skapa en session med typ Team Race och sätt tiden, t.ex. 240 minuter för 4 timmar.", "guide.team_6": "Starta sessionen i Tidtagning. Alla passeringar från lagets medlemmar summeras till lagets totalvarv.", "guide.validation_title": "Ogiltiga varv, follow-up och manuella korrigeringar", - "guide.validation_1": "Senaste passeringar visar nu både giltiga och ogiltiga varv. För korta varv markeras som För kort varv och för långa som Över maxvarv.", + "guide.validation_1": "Senaste passeringar visar ogiltiga shortcuts som För kort varv. Passeringar över max markeras Långt varv · räknat · ny stint och ligger kvar i raceresultatet.", "guide.validation_2": "Ogiltiga kortvarv under min-gränsen räknas inte alls i leaderboard eller statistik.", "guide.validation_3": "Ogiltiga långvarv över max-gränsen räknas inte som varv, men de kan bryta lap-basen så nästa giltiga varv börjar om korrekt.", "guide.validation_4": "När ordinarie tid är slut kan sessionen gå in i Follow-up aktiv om du har satt Follow-up tid i raceformat eller sessionen.", @@ -1559,7 +1569,7 @@ const TRANSLATIONS = { "guide.training_10": "10. När Open Practice, Free Practice eller Practice pågår visar Träning ett livekort för vald förare med varv, bästa varv, snitt och senaste varv. Alla förare kör fortfarande i samma gemensamma session.", "guide.training_11": "11. Knappen högst upp skapar eller öppnar den valda förarens personliga mobillänk. Föraren ställer in och startar sin egen timer i mobilen utan att starta om eller påverka den gemensamma practice-sessionen. Tiden sparas separat per förare och klass.", "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_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. Stinttabellen visar 5-min resultat separat från hela stintlängden, och olika transponderkällor blandas aldrig i samma resultat.", "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.", @@ -1706,6 +1716,11 @@ const TRANSLATIONS = { "race_day.check_duplicate_transponders": "Duplicate transponders", "race_day.check_grid": "Start grid", "race_day.check_audio": "Race audio", + "race_day.lap_window_edit": "Adjust lap limits", + "race_day.lap_window_hint": "Min rejects shortcuts. Max ends the statistics stint while the lap still counts in the race. Applies to new passings.", + "race_day.min_lap_sec": "Min sec", + "race_day.max_lap_sec": "Max sec", + "race_day.lap_window_invalid": "Max must be greater than min and both values must be positive.", "race_day.backup_failed_confirm": "Automatic backup failed: {msg}\n\nStart anyway?", "race_day.start_failed": "The session could not be started.", "nav.group_start": "Start", @@ -1847,8 +1862,11 @@ const TRANSLATIONS = { "training.stint_slower": "{lap} slower", "training.stint_same": "Unchanged", "training.stint_analysis": "Stint analysis", - "training.stint_analysis_hint": "A break longer than two minutes starts a new stint. Shorter stints are shown but not ranked.", + "training.stint_analysis_hint": "A passing over the configured maximum lap time, or a break over two minutes without a max limit, starts a new stint. The transition lap counts in the race but not in pace. The 5-minute result covers 300 seconds plus the final started lap.", "training.best_stint": "Best stint {stint}: {laps} laps, {pace}", + "training.stint_five_minute": "5-minute result", + "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.split_passings": "Split passings", @@ -2312,7 +2330,7 @@ const TRANSLATIONS = { "events.min_lap_time": "Min lap time (sec)", "events.min_lap_time_hint": "Laps faster than this are ignored as shortcuts or false hits.", "events.max_lap_time": "Max lap time (sec)", - "events.max_lap_time_hint": "Laps slower than this are not counted as valid laps and reset the lap base for the next lap.", + "events.max_lap_time_hint": "A passing over max starts a new statistics stint and is excluded from pace averages. The lap still counts in race results.", "events.race_driver_scope": "Race mode uses all drivers in the event class unless a session has its own participant list.", "events.reserve_bump_slots_hint": "Reserve empty slots in higher finals so bumped drivers can be inserted without overwriting seeded spots.", "events.team_race": "Team Race", @@ -2455,6 +2473,7 @@ const TRANSLATIONS = { "timing.valid_passing": "Valid lap", "timing.invalid_short": "Short lap", "timing.invalid_long": "Over max lap", + "timing.long_lap_stint": "Long lap · counted · new stint", "judging.title": "Judging view", "judging.active_session": "Active session", "judging.no_active_session": "No active session selected.", @@ -2808,7 +2827,7 @@ const TRANSLATIONS = { "guide.race_format_7": "Source for finals decides whether finals are seeded from practice or qualifying standings.", "guide.race_format_8": "Follow-up time adds an extra collection period after the scheduled race time before the heat is actually closed.", "guide.race_format_9": "Min lap time filters out shortcuts and false hits. Example: on a 16-second track you can set 11 seconds as the minimum.", - "guide.race_format_10": "Max lap time stops long false laps from counting and is also used to split stints and improve driver statistics. Example: 60 seconds.", + "guide.race_format_10": "Max lap time is a statistics boundary. A longer passing ends the stint and is excluded from pace averages, but the lap still counts in the race. Example: 120 seconds starts a new stint after a two-minute break.", "guide.race_format_11": "Preset lets you quickly fill the race format with sensible defaults for a short technical track, club race, IFMAR-like setup or endurance.", "guide.race_format_11a": "In Basic mode, the endurance preset hides most qualifying and finals fields so the UI stays focused on Team Race. Open Advanced if the event also needs support heats or normal finals.", "guide.race_format_12": "You can apply a preset and then adjust individual fields manually before saving the race format.", @@ -2832,7 +2851,7 @@ const TRANSLATIONS = { "guide.team_5": "Create a session with type Team Race and set the time, for example 240 minutes for 4 hours.", "guide.team_6": "Start the session in Timing. All passings from the team's members are added to the team's total laps.", "guide.validation_title": "Invalid laps, follow-up and manual corrections", - "guide.validation_1": "Recent Passings now shows both valid and invalid laps. Short laps are marked as Short lap and long laps as Over max lap.", + "guide.validation_1": "Recent Passings shows invalid shortcuts as Short lap. Passings over max are marked Long lap · counted · new stint and remain in the race result.", "guide.validation_2": "Invalid short laps under the minimum threshold do not count in the leaderboard or statistics.", "guide.validation_3": "Invalid long laps over the maximum threshold do not count as laps, but they can reset the lap base so the next valid lap starts correctly.", "guide.validation_4": "When the scheduled time ends, the session can enter Follow-up active if Follow-up time has been configured in race format or on the session.", @@ -2872,7 +2891,7 @@ const TRANSLATIONS = { "guide.training_10": "10. While Open Practice, Free Practice, or Practice is running, Training shows a live card for the selected driver with laps, best lap, average, and latest lap. Every driver still uses the same shared session.", "guide.training_11": "11. The top action creates or opens the selected driver's personal mobile link. The driver configures and starts an individual timer on the phone without restarting or changing the shared practice session. Time is stored separately per driver and class.", "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_13": "13. Best 5 min uses 300 seconds. A final lap counts when it started before 5:00 even if it finishes later. The stint table shows the 5-minute result separately from full stint duration, and different transponder sources are never mixed into one 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.", @@ -3602,6 +3621,17 @@ async function startOperationalSession(session, callbacks = {}) { } } +function saveOperationalLapWindow(session, minLapSec, maxLapSec) { + const event = state.events.find((item) => item.id === session?.eventId); + const minLapMs = Math.round(Number(minLapSec) * 1000); + const maxLapMs = Math.round(Number(maxLapSec) * 1000); + if (!event || !Number.isFinite(minLapMs) || !Number.isFinite(maxLapMs) || minLapMs <= 0 || maxLapMs <= minLapMs) return false; + event.raceConfig = { ...(event.raceConfig || {}), minLapMs, maxLapMs }; + saveState(); + renderView(); + return true; +} + function finishOperationalSession(session) { if (!session) return false; cancelRaceStart(); diff --git a/src/decoder_runtime.js b/src/decoder_runtime.js index fdfa381..e4c516b 100644 --- a/src/decoder_runtime.js +++ b/src/decoder_runtime.js @@ -408,12 +408,13 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { const { minLapMs, maxLapMs } = getSessionLapWindow(session); let validLap = true; let invalidReason = ""; + let validationWarning = ""; if (minLapMs > 0 && lapMs > 0 && lapMs < minLapMs) { validLap = false; invalidReason = "below_min"; } else if (Number.isFinite(maxLapMs) && maxLapMs > 0 && lapMs > maxLapMs) { - validLap = false; - invalidReason = "above_max"; + // A long lap remains part of the race result, but starts a new statistics stint. + validationWarning = "above_max_stint"; } const passing = { @@ -432,6 +433,9 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { lapMs, validLap, invalidReason, + validationWarning, + excludedFromPace: validationWarning === "above_max_stint", + stintBoundary: validationWarning === "above_max_stint", strength: msg.strength, loopId: String(msg.loop_id || ""), resend: Boolean(msg.resend), @@ -439,9 +443,6 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") { if (!validLap) { result.passings.push(passing); - if (invalidReason === "above_max") { - entry.lastTimestamp = timestamp; - } persistPassingToBackend(session.id, passing); saveState(); return true; diff --git a/src/race_day_view.js b/src/race_day_view.js index 933c730..cd8c64a 100644 --- a/src/race_day_view.js +++ b/src/race_day_view.js @@ -30,11 +30,12 @@ export function renderRaceDayView(deps) { getEventName, getSessionTypeLabel, getStatusLabel, getStartModeLabel, getSessionTiming, getSessionGridEntries, buildRaceDayQueue, buildRaceDayPreflight, backendAvailable, selectSession, startSession, finishSession, pauseSession, resumeSession, - navigateTo, getStartLightState, + saveLapWindow, navigateTo, getStartLightState, } = deps; const queue = buildRaceDayQueue(state); const session = queue.current; const preflight = buildRaceDayPreflight(state, session, { backendAvailable }); + const lapWindowCheck = preflight.checks.find((item) => item.id === "lap_window") || null; const timing = session ? getSessionTiming(session) : null; const clock = !timing ? "-" : timing.followUpActive ? formatCountdown(timing.followUpRemainingMs) @@ -94,6 +95,18 @@ export function renderRaceDayView(deps) {
${preflight.checks.map((item) => `
${t(`race_day.check_${item.id}`)}${renderPreflightDetail(item, { t, formatLap, escapeHtml })}
`).join("")}
+ ${session?.mode === "race" && lapWindowCheck ? ` +
+ ${t("race_day.lap_window_edit")} +

${t("race_day.lap_window_hint")}

+
+ + +
+ + +
` + : ""} @@ -110,6 +123,22 @@ export function renderRaceDayView(deps) { const choose = (sessionId) => sessionId && selectSession(sessionId); document.getElementById("raceDayActivate")?.addEventListener("click", () => choose(session?.id)); document.querySelectorAll("[data-race-day-select]").forEach((node) => node.addEventListener("click", () => choose(node.dataset.raceDaySelect))); + document.getElementById("raceDayLapWindowForm")?.addEventListener("submit", (event) => { + event.preventDefault(); + if (!session) return; + const form = new FormData(event.currentTarget); + const minLapSec = Number(form.get("minLapSec")); + const maxLapSec = Number(form.get("maxLapSec")); + const error = document.getElementById("raceDayLapWindowError"); + if (!Number.isFinite(minLapSec) || !Number.isFinite(maxLapSec) || minLapSec <= 0 || maxLapSec <= minLapSec) { + if (error) { + error.textContent = t("race_day.lap_window_invalid"); + error.hidden = false; + } + return; + } + saveLapWindow(session, minLapSec, maxLapSec); + }); document.getElementById("raceDayStart")?.addEventListener("click", async () => { if (!session || !preflight.ready) return; if (!isSelected) selectSession(session.id, { render: false }); diff --git a/src/styles.css b/src/styles.css index 03c41f3..fabbd20 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3890,6 +3890,14 @@ select:focus { .race-day-check strong { font-size: 0.78rem; } .race-day-check small { margin-top: 1px; color: var(--muted); font-size: 0.68rem; } +.race-day-lap-window { display: grid; gap: 8px; margin: 12px; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-card); } +.race-day-lap-window > strong { font-size: 0.82rem; } +.race-day-lap-window > p { margin: 0; font-size: 0.7rem; } +.race-day-lap-window > div { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } +.race-day-lap-window label { display: grid; gap: 4px; color: var(--muted); font-size: 0.68rem; font-weight: 700; } +.race-day-lap-window input { width: 100%; } +.race-day-lap-window .btn { justify-self: start; } + .race-day-grid-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 8px; } .race-day-grid-list article { display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 2px 8px; align-items: center; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-card); } .race-day-grid-list article > span { grid-row: 1 / 3; font: 700 1rem Orbitron, sans-serif; color: var(--accent); } diff --git a/src/timing_logic.js b/src/timing_logic.js index 51a3d21..dd81fe1 100644 --- a/src/timing_logic.js +++ b/src/timing_logic.js @@ -61,6 +61,9 @@ export function getVisiblePassings(result, { isCountedPassing }) { export function getPassingValidationLabel(passing, { t }) { + if (passing?.validationWarning === "above_max_stint") { + return t("timing.long_lap_stint"); + } if (passing?.validLap === false) { if (passing.invalidReason === "below_min") { return t("timing.invalid_short"); diff --git a/src/training_logic.js b/src/training_logic.js index eeeb32c..528cfb6 100644 --- a/src/training_logic.js +++ b/src/training_logic.js @@ -52,6 +52,9 @@ export function normalizeTrainingState(training, { uid }) { eventId: String(entry?.eventId || ""), eventName: String(entry?.eventName || "").trim(), driverId: String(entry?.driverId || ""), + sourceId: String(entry?.sourceId || entry?.driverId || ""), + excludedFromPace: Boolean(entry?.excludedFromPace), + stintBoundary: Boolean(entry?.stintBoundary), timestamp, lapMs: Math.max(0, Math.round(Number(entry?.lapMs || 0) || 0)), recordedAt: String(entry?.recordedAt || (timestamp ? new Date(timestamp).toISOString() : new Date().toISOString())), @@ -134,6 +137,9 @@ export function syncTrainingLedger(training, state, { uid }) { eventId: String(session?.eventId || ""), eventName: String(event?.name || "").trim(), driverId, + sourceId: String(passing?.competitorKey || passing?.carId || passing?.transponder || driverId), + excludedFromPace: Boolean(passing?.excludedFromPace || passing?.validationWarning === "above_max_stint"), + stintBoundary: Boolean(passing?.stintBoundary || passing?.validationWarning === "above_max_stint"), timestamp, lapMs, recordedAt: timestamp ? new Date(timestamp).toISOString() : new Date().toISOString(), @@ -159,6 +165,10 @@ export function getDriverTrainingLaps(state, driverId, classId = "") { sessionId: String(entry.sessionId || ""), sessionName: String(entry.sessionName || ""), eventName: String(entry.eventName || ""), + driverId: String(entry.driverId || ""), + sourceId: String(entry.sourceId || entry.driverId || ""), + excludedFromPace: Boolean(entry.excludedFromPace), + stintBoundary: Boolean(entry.stintBoundary), timestamp: Number(entry.timestamp || 0) || 0, lapMs: Number(entry.lapMs || 0), })) @@ -202,7 +212,7 @@ export function getDriverTrainingMarkers(state, driverId, classId = "") { } export function buildTrainingPaceBuckets(state, driverId, classId = "", bucketMinutes = 30) { - const laps = getDriverTrainingLaps(state, driverId, classId); + const laps = getDriverTrainingLaps(state, driverId, classId).filter((lap) => !lap.excludedFromPace); const bucketMs = Math.max(5, Number(bucketMinutes || 30)) * 60 * 1000; const buckets = new Map(); laps.forEach((lap) => { @@ -238,7 +248,9 @@ export function buildTrainingPaceBuckets(state, driverId, classId = "", bucketMi export function buildTrainingWeekdayStats(state, driverId, classId = "") { const groups = Array.from({ length: 7 }, (_, weekday) => ({ weekday, laps: [] })); - getDriverTrainingLaps(state, driverId, classId).forEach((lap) => groups[new Date(lap.timestamp).getDay()].laps.push(lap.lapMs)); + getDriverTrainingLaps(state, driverId, classId) + .filter((lap) => !lap.excludedFromPace) + .forEach((lap) => groups[new Date(lap.timestamp).getDay()].laps.push(lap.lapMs)); return groups.map((group) => ({ weekday: group.weekday, lapCount: group.laps.length, @@ -248,7 +260,7 @@ export function buildTrainingWeekdayStats(state, driverId, classId = "") { } export function buildTrainingMarkerComparisons(state, driverId, classId = "", windowMinutes = 30) { - const laps = getDriverTrainingLaps(state, driverId, classId); + const laps = getDriverTrainingLaps(state, driverId, classId).filter((lap) => !lap.excludedFromPace); const windowMs = Math.max(5, Number(windowMinutes || 30)) * 60 * 1000; return getDriverTrainingMarkers(state, driverId, classId).map((marker) => { const comparableLaps = marker.sessionId ? laps.filter((lap) => lap.sessionId === marker.sessionId) : laps; @@ -298,6 +310,7 @@ export function buildTrainingStintAnalysis(state, driverId, classId = "") { bestLapMs: values.length ? Math.min(...values) : 0, 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, consistencyPct: values.length >= 3 ? consistencySpread(values) : null, @@ -307,9 +320,11 @@ export function buildTrainingStintAnalysis(state, driverId, classId = "") { insufficient: values.length < 3, }; }); - const eligible = rows.filter((row) => !row.insufficient); + const visibleRows = rows.filter((row) => row.lapCount > 0); + visibleRows.forEach((row, index) => { row.number = index + 1; }); + const eligible = visibleRows.filter((row) => !row.insufficient); const best = eligible.reduce((current, row) => (!current || row.paceMs < current.paceMs ? row : current), null); - return { rows: rows.sort((left, right) => right.startedAt - left.startedAt), best }; + return { rows: visibleRows.sort((left, right) => right.startedAt - left.startedAt), best }; } export function buildTrainingAchievements(stats) { @@ -375,42 +390,62 @@ function getControlledLapStreak(lapTimes, controlTargetMs) { } export function splitTrainingStints(laps, breakThresholdMs = 2 * 60 * 1000) { - const sorted = (Array.isArray(laps) ? laps : []) + const normalized = (Array.isArray(laps) ? laps : []) .filter((lap) => Number(lap?.lapMs || 0) > 500 && Number(lap?.timestamp || 0) > 0) - .map((lap) => ({ ...lap, timestamp: Number(lap.timestamp), lapMs: Number(lap.lapMs) })) - .sort((left, right) => left.timestamp - right.timestamp); - const stints = []; - - sorted.forEach((lap) => { - const previousStint = stints.at(-1); - const previousLap = previousStint?.laps.at(-1); - const lapStartedAt = Math.max(0, lap.timestamp - lap.lapMs); - const breakMs = previousLap ? Math.max(0, lap.timestamp - previousLap.timestamp) : 0; - const startsNewStint = !previousStint - || String(previousLap.sessionId || "") !== String(lap.sessionId || "") - || breakMs > breakThresholdMs; - const normalizedLap = { + .map((lap) => ({ ...lap, - excludedFromPace: Boolean(previousLap && breakMs > breakThresholdMs && lap.lapMs > breakThresholdMs), - }; + sessionId: String(lap.sessionId || ""), + sourceId: String(lap.sourceId || lap.driverId || lap.competitorKey || lap.transponder || "default"), + timestamp: Number(lap.timestamp), + lapMs: Number(lap.lapMs), + })); + const streams = new Map(); - if (startsNewStint) { - stints.push({ - sessionId: String(lap.sessionId || ""), - startedAt: lapStartedAt, - endedAt: lap.timestamp, - durationMs: lap.lapMs, - laps: [normalizedLap], - }); - return; - } - - previousStint.laps.push(normalizedLap); - previousStint.endedAt = lap.timestamp; - previousStint.durationMs = Math.max(0, previousStint.endedAt - previousStint.startedAt); + normalized.forEach((lap) => { + const streamKey = `${lap.sessionId}:${lap.sourceId}`; + if (!streams.has(streamKey)) streams.set(streamKey, []); + streams.get(streamKey).push(lap); }); - return stints; + const stints = []; + streams.forEach((streamLaps) => { + const sorted = streamLaps.sort((left, right) => left.timestamp - right.timestamp); + let currentStint = null; + + sorted.forEach((lap) => { + const previousLap = currentStint?.laps.at(-1); + const lapStartedAt = Math.max(0, lap.timestamp - lap.lapMs); + const breakMs = previousLap ? Math.max(0, lap.timestamp - previousLap.timestamp) : 0; + const detectedBreak = Boolean(previousLap && breakMs > breakThresholdMs); + const explicitBoundary = Boolean(lap.stintBoundary); + const startsNewStint = !currentStint || detectedBreak || explicitBoundary; + const normalizedLap = { + ...lap, + excludedFromPace: Boolean(lap.excludedFromPace || explicitBoundary || (detectedBreak && lap.lapMs > breakThresholdMs)), + stintBoundary: Boolean(explicitBoundary || detectedBreak), + }; + + if (startsNewStint) { + const boundaryLap = Boolean(normalizedLap.excludedFromPace && (explicitBoundary || detectedBreak)); + currentStint = { + sessionId: lap.sessionId, + sourceId: lap.sourceId, + startedAt: boundaryLap ? lap.timestamp : lapStartedAt, + endedAt: lap.timestamp, + durationMs: boundaryLap ? 0 : lap.lapMs, + laps: [normalizedLap], + }; + stints.push(currentStint); + return; + } + + currentStint.laps.push(normalizedLap); + currentStint.endedAt = lap.timestamp; + currentStint.durationMs = Math.max(0, currentStint.endedAt - currentStint.startedAt); + }); + }); + + return stints.sort((left, right) => left.startedAt - right.startedAt); } export function getBestConsecutiveTrainingLaps(stints, lapCount = 3) { @@ -457,6 +492,7 @@ export function getBestTrainingDurationResult(stints, targetMs = 5 * 60 * 1000) const lastLap = counted.at(-1); if (!lastLap || lastLap.timestamp < cutoffAt) continue; const totalMs = Math.max(0, lastLap.timestamp - startedAt); + if (totalMs > target + lastLap.lapMs) continue; const lapCount = counted.length; const candidate = { targetMs: target, @@ -478,13 +514,14 @@ export function getBestTrainingDurationResult(stints, targetMs = 5 * 60 * 1000) export function buildDriverTrainingStats(state, driverId, classId = "") { const laps = getDriverTrainingLaps(state, driverId, classId); - const lapTimes = laps.map((lap) => lap.lapMs); + const paceLaps = laps.filter((lap) => !lap.excludedFromPace); + const lapTimes = paceLaps.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 bestLapMs = lapTimes.length ? Math.min(...lapTimes) : 0; + const averageLapMs = average(lapTimes); const recentAverageMs = average(recentLapTimes); const previousRecentAverageMs = average(previousWindowLapTimes); const consistencySpreadPct = consistencySpread(recentLapTimes); @@ -495,7 +532,7 @@ export function buildDriverTrainingStats(state, driverId, classId = "") { const personalBestGainMs = previousBestMs && recentBestMs ? Math.max(0, previousBestMs - recentBestMs) : 0; const controlTargetMs = bestLapMs ? Math.round(bestLapMs * 1.08) : 0; const controlledLapStreak = getControlledLapStreak(lapTimes, controlTargetMs); - const dailySummaries = buildDailySummaries(laps); + const dailySummaries = buildDailySummaries(paceLaps); const stints = splitTrainingStints(laps); const completedFiveMinuteStints = stints.filter((stint) => getBestTrainingDurationResult([stint], 5 * 60 * 1000)).length; const bestThreeConsecutive = getBestConsecutiveTrainingLaps(stints, 3); @@ -503,7 +540,7 @@ export function buildDriverTrainingStats(state, driverId, classId = "") { const todayKey = getLocalDayKey(Date.now()); const todayLaps = laps.filter((lap) => getLocalDayKey(lap.timestamp) === todayKey); const todayStints = splitTrainingStints(todayLaps); - const todayLapTimes = todayLaps.map((lap) => lap.lapMs); + const todayLapTimes = todayLaps.filter((lap) => !lap.excludedFromPace).map((lap) => lap.lapMs); const stats = { totalLaps, @@ -609,19 +646,20 @@ export function buildDriverActivePracticeStats(state, driverId, nowTs = Date.now .map((passing) => ({ 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"), })) .sort((left, right) => left.timestamp - right.timestamp); - const lapTimes = laps.map((lap) => lap.lapMs); + const paceLapTimes = laps.filter((lap) => !lap.excludedFromPace).map((lap) => lap.lapMs); return { sessionId: String(session.id || ""), sessionName: String(session.name || "").trim(), startedAt, elapsedMs: startedAt ? Math.max(0, Number(nowTs || 0) - startedAt) : 0, - laps: lapTimes.length, - bestLapMs: lapTimes.length ? Math.min(...lapTimes) : 0, - averageLapMs: lapTimes.length ? Math.round(lapTimes.reduce((sum, lapMs) => sum + lapMs, 0) / lapTimes.length) : 0, - lastLapMs: lapTimes.at(-1) || 0, + laps: laps.length, + bestLapMs: paceLapTimes.length ? Math.min(...paceLapTimes) : 0, + averageLapMs: average(paceLapTimes), + lastLapMs: laps.at(-1)?.lapMs || 0, }; } diff --git a/src/training_views.js b/src/training_views.js index a17ab55..146b38d 100644 --- a/src/training_views.js +++ b/src/training_views.js @@ -19,6 +19,7 @@ function renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock }) { ${row.mistakes} (${row.mistakePct.toFixed(0)}%) ${escapeHtml(endChange)} ${row.consistencyPct == null ? escapeHtml(t("training.insufficient_data")) : `${row.consistencyPct.toFixed(1)}%`} + ${row.fiveMinuteResult ? `${row.fiveMinuteResult.lapCount}/${formatRaceClock(row.fiveMinuteResult.totalMs)} · ${t("training.average_short")} ${formatLap(row.fiveMinuteResult.averageLapMs)}` : "-"} ${formatRaceClock(row.durationMs)} `; } @@ -260,10 +261,10 @@ export function renderTrainingView(deps) { : `

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

`; const featuredStints = [stintAnalysis.best, ...stintAnalysis.rows.slice(0, 3)] .filter((row, index, rows) => row && rows.findIndex((item) => item.id === row.id) === index); - const stintColumns = `${escapeHtml(t("training.stint"))}${escapeHtml(t("training.start"))}${escapeHtml(t("training.laps"))}${escapeHtml(t("training.best_lap"))}${escapeHtml(t("training.trimmed_pace"))}${escapeHtml(t("training.race_pace_10"))}${escapeHtml(t("training.mistakes"))}${escapeHtml(t("training.start_end"))}${escapeHtml(t("training.consistency"))}${escapeHtml(t("table.duration"))}`; + const stintColumns = `${escapeHtml(t("training.stint"))}${escapeHtml(t("training.start"))}${escapeHtml(t("training.laps"))}${escapeHtml(t("training.best_lap"))}${escapeHtml(t("training.trimmed_pace"))}${escapeHtml(t("training.race_pace_10"))}${escapeHtml(t("training.mistakes"))}${escapeHtml(t("training.start_end"))}${escapeHtml(t("training.consistency"))}${escapeHtml(t("training.stint_five_minute"))}${escapeHtml(t("training.stint_duration"))}`; const featuredStintRows = featuredStints.length ? featuredStints.map((row) => renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock })).join("") - : `${escapeHtml(t("training.no_stints"))}`; + : `${escapeHtml(t("training.no_stints"))}`; const allStintRows = stintAnalysis.rows.map((row) => renderStintRow(row, { t, escapeHtml, formatLap, formatRaceClock })).join(""); const markerMarkup = markerComparisons.length ? markerComparisons.map((marker) => { diff --git a/tests/decoder_runtime.test.js b/tests/decoder_runtime.test.js index db4fbfb..33e12e5 100644 --- a/tests/decoder_runtime.test.js +++ b/tests/decoder_runtime.test.js @@ -4,12 +4,12 @@ import assert from "node:assert/strict"; import { connectDecoderHelper, processDecoderMessageHelper } from "../src/decoder_runtime.js"; import { ensureSessionResult } from "../src/timing_logic.js"; -function createHarness() { +function createHarness(options = {}) { const session = { id: "session_1", eventId: "event_1", mode: "race", - type: "team_race", + type: options.sessionType || "team_race", status: "running", startMode: "mass", startedAt: 1_000, @@ -36,7 +36,10 @@ function createHarness() { getActiveSession: () => session, ensureSessionResult: (sessionId) => ensureSessionResult(sessionId, { state }), normalizeStartMode: () => "mass", - getSessionLapWindow: () => ({ minLapMs: 0, maxLapMs: Number.POSITIVE_INFINITY }), + getSessionLapWindow: () => ({ + minLapMs: Number(options.minLapMs || 0), + maxLapMs: options.maxLapMs == null ? Number.POSITIVE_INFINITY : Number(options.maxLapMs), + }), findEventTeamForPassing: (event, driverId) => event.teams.find((team) => team.driverIds.includes(driverId)) || null, persistPassingToBackend: () => {}, pushOverlayEvent: (type, label) => overlayEvents.push({ type, label }), @@ -74,6 +77,28 @@ test("split decoder records intermediate time without changing main lap state", assert.equal(result.passings.length, 1); }); +test("minimum lap time rejects a shortcut without adding a lap", () => { + const harness = createHarness({ minLapMs: 10_000, maxLapMs: 120_000 }); + harness.passing(6_000, "main"); + + const result = harness.state.resultsBySession.session_1; + assert.equal(result.competitors.team_team_1.laps, 0); + assert.equal(result.passings[0].validLap, false); + assert.equal(result.passings[0].invalidReason, "below_min"); +}); + +test("maximum lap time starts a statistics stint but still counts the race lap", () => { + const harness = createHarness({ minLapMs: 10_000, maxLapMs: 60_000 }); + harness.passing(121_001, "main"); + + const result = harness.state.resultsBySession.session_1; + assert.equal(result.competitors.team_team_1.laps, 1); + assert.equal(result.passings[0].validLap, true); + assert.equal(result.passings[0].validationWarning, "above_max_stint"); + assert.equal(result.passings[0].excludedFromPace, true); + assert.equal(result.passings[0].stintBoundary, true); +}); + test("pit entry and exit create one timed stop without adding laps", () => { const harness = createHarness(); harness.passing(17_000, "main"); diff --git a/tests/public_training_profile.test.js b/tests/public_training_profile.test.js index 39abef6..6543d09 100644 --- a/tests/public_training_profile.test.js +++ b/tests/public_training_profile.test.js @@ -167,6 +167,35 @@ test("public timer query normalization bounds age and window values", () => { assert.equal(normalizePublicTrainingWindow(String(25 * 60 * 60 * 1000)), 24 * 60 * 60 * 1000); }); +test("public profile counts a max-boundary lap but excludes it from pace", () => { + const now = Date.now(); + const state = buildState(now); + state.training.ledger = [ + { driverId: "driver_1", sourceId: "tp_1", sessionId: "practice", timestamp: now - 127_000, lapMs: 37_000 }, + { driverId: "driver_1", sourceId: "tp_1", sessionId: "practice", timestamp: now - 90_000, lapMs: 37_000 }, + { driverId: "driver_1", sourceId: "tp_1", sessionId: "practice", timestamp: now, lapMs: 90_000, excludedFromPace: true, stintBoundary: true }, + ]; + + const profile = buildPublicTrainingProfile(state, DRIVER_ONE_TOKEN, ""); + assert.equal(profile.stats.totalLaps, 3); + assert.equal(profile.stats.bestLapMs, 37_000); + assert.equal(profile.stats.averageLapMs, 37_000); + assert.equal(profile.stats.stintCount, 2); +}); + +test("public five-minute result never combines simultaneous transponder sources", () => { + const laps = ["tp_a", "tp_b"].flatMap((sourceId) => Array.from({ length: 9 }, (_, index) => ({ + sourceId, + sessionId: "practice_1", + timestamp: (index + 1) * 37_000, + lapMs: 37_000, + }))); + const result = getPublicBestDurationResult(splitPublicTrainingStints(laps), 300_000); + + assert.equal(result.lapCount, 9); + assert.equal(result.totalMs, 333_000); +}); + test("public profile duration result includes the lap started before five minutes", () => { const laps = Array.from({ length: 16 }, (_, index) => ({ sessionId: "practice_1", diff --git a/tests/training_logic.test.js b/tests/training_logic.test.js index 60f0efd..d9e7d4c 100644 --- a/tests/training_logic.test.js +++ b/tests/training_logic.test.js @@ -88,7 +88,7 @@ test("ledger sync retains archived laps and rebuilds current session laps", () = resultsBySession: { live: { passings: [ - { driverId: "driver_1", competitorKey: "driver_1", lapMs: 16500, timestamp: 3000, validLap: true }, + { driverId: "driver_1", competitorKey: "driver_1", lapMs: 16500, timestamp: 3000, validLap: true, validationWarning: "above_max_stint", excludedFromPace: true, stintBoundary: true }, { driverId: "driver_1", competitorKey: "driver_1", lapMs: 9000, timestamp: 4000, validLap: false }, { driverId: "", competitorKey: "anonymous", lapMs: 17000, timestamp: 5000, validLap: true }, { driverId: "driver_1", competitorKey: "driver_1", lapMs: 500, timestamp: 6000, validLap: true }, @@ -106,6 +106,8 @@ test("ledger sync retains archived laps and rebuilds current session laps", () = assert.equal(liveLap.lapMs, 16500); assert.equal(liveLap.sessionName, "Free Practice"); assert.equal(liveLap.eventName, "Club practice"); + assert.equal(liveLap.excludedFromPace, true); + assert.equal(liveLap.stintBoundary, true); assert.equal(synced.goals.length, 1); assert.equal(synced.shares.length, 1); assert.equal(synced.markers.length, 1); @@ -137,6 +139,24 @@ test("driver statistics remain isolated and aggregate days and sessions", () => assert.equal(stats.dailySummaries[1].laps, 2); }); +test("counted stint-boundary laps do not distort driver pace averages", () => { + const state = { + training: { + ledger: [ + { driverId: "driver_1", sourceId: "tp_1", sessionId: "practice", timestamp: 37_000, lapMs: 37_000 }, + { driverId: "driver_1", sourceId: "tp_1", sessionId: "practice", timestamp: 74_000, lapMs: 37_000 }, + { driverId: "driver_1", sourceId: "tp_1", sessionId: "practice", timestamp: 164_000, lapMs: 90_000, excludedFromPace: true, stintBoundary: true }, + ], + }, + }; + + const stats = buildDriverTrainingStats(state, "driver_1"); + assert.equal(stats.totalLaps, 3); + assert.equal(stats.bestLapMs, 37_000); + assert.equal(stats.averageLapMs, 37_000); + assert.equal(stats.stintCount, 2); +}); + test("split and pit statistics aggregate by personal profile and selected class", () => { const state = { drivers: [ @@ -294,9 +314,25 @@ test("training stints split after a break over two minutes or a session change", assert.equal(stints[0].laps.length, 2); assert.equal(stints[1].laps.length, 1); assert.equal(stints[1].laps[0].excludedFromPace, true); + assert.equal(stints[1].durationMs, 0); + assert.equal(stints[1].startedAt, 200_001); assert.equal(stints[2].sessionId, "practice_2"); }); +test("configured maximum lap time creates a new pace stint below the fallback break", () => { + const stints = splitTrainingStints([ + { sessionId: "practice_1", sourceId: "tp_1", timestamp: 37_000, lapMs: 37_000 }, + { sessionId: "practice_1", sourceId: "tp_1", timestamp: 74_000, lapMs: 37_000 }, + { sessionId: "practice_1", sourceId: "tp_1", timestamp: 164_000, lapMs: 90_000, excludedFromPace: true, stintBoundary: true }, + { sessionId: "practice_1", sourceId: "tp_1", timestamp: 201_000, lapMs: 37_000 }, + ]); + + assert.equal(stints.length, 2); + assert.equal(stints[1].startedAt, 164_000); + assert.equal(stints[1].durationMs, 37_000); + assert.equal(stints[1].laps.filter((lap) => !lap.excludedFromPace).length, 1); +}); + test("best consecutive laps never cross a stint boundary", () => { const stints = splitTrainingStints([ { sessionId: "practice_1", timestamp: 18_000, lapMs: 18_000 }, @@ -328,6 +364,25 @@ test("best five-minute result counts a final lap started before the cutoff and r assert.equal(result.endedAt, 314_123); }); +test("best five-minute result never combines simultaneous transponder sources", () => { + const laps = ["transponder_a", "transponder_b"].flatMap((sourceId) => ( + Array.from({ length: 9 }, (_, index) => ({ + driverId: sourceId, + sourceId, + sessionId: "practice_1", + timestamp: (index + 1) * 37_000, + lapMs: 37_000, + })) + )); + const stints = splitTrainingStints(laps); + const result = getBestTrainingDurationResult(stints, 300_000); + + assert.equal(stints.length, 2); + assert.equal(result.lapCount, 9); + assert.equal(result.totalMs, 333_000); + assert.ok(result.totalMs <= result.targetMs + 37_000); +}); + test("a stint shorter than the target duration has no five-minute result", () => { const stints = splitTrainingStints([ { sessionId: "practice_1", timestamp: 18_000, lapMs: 18_000 },