diff --git a/README.md b/README.md index 53b7600..f9ddc8a 100644 --- a/README.md +++ b/README.md @@ -179,8 +179,13 @@ The `Training` menu provides a non-race progression view for registered drivers: The default view is intentionally simple: four key statistics, the next lap target, today's 20-minute plan and active rewards. Goal administration, mobile sharing, detailed history and the club comparison remain available in collapsed sections when needed. +When Open Practice, Free Practice, or Practice is running, the selected driver gets a compact live card with current valid laps, best lap, average, and latest lap. The session remains shared by everyone; only the mobile 20-minute timer and personal profile link are unique per driver. The primary action at the top creates or opens that link. + - 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 +- 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 - automatic progression levels at useful lap totals without manual setup - a 20-minute recommendation split into maximum-focus and controlled 80-90% blocks @@ -367,6 +372,13 @@ Example: - judging log can be exported per session as JSON - supports `Undo latest` and per-log-row undo for multiple recent manual actions +## Future improvements +- Optional neural text-to-speech for more natural start, finish, and driver-name announcements. +- Preferred design: generate speech through the backend, cache short audio files under `data/tts/`, and play cached audio without network delay during a race. +- Keep provider credentials on the backend only. Never expose API keys to the browser or public overlay. +- Retain the current system voice and passing beep as offline fallbacks. +- Support uploaded or prerecorded WAV/MP3 clips for fixed start and finish announcements. + ## Windows installation Use 64-bit Windows 11 and Node.js 22 or 24 LTS. The easiest supported setup is: diff --git a/README.sv.md b/README.sv.md index a369783..168b959 100644 --- a/README.sv.md +++ b/README.sv.md @@ -143,7 +143,7 @@ Rekommenderad deploy: - Live race-kontroll: - Nedräkning under pågående session - Auto-finish vid tidslut med status `Race is finished` - - Leaderboard-sortering: varv först, därefter närmast måltid för sessionen (t.ex. 5 min = 600s) + - Leaderboard-sortering: varv först, därefter närmast måltid för sessionen (t.ex. 5 min = 300s) - Konfigurerbart browserljud för start, mål och passing (`blipp` eller tala förarnamn) - Valbar systemröst, talhastighet/tonhöjd och separat fonetiskt uttalsnamn per förare - Sessioninställningar för `Mass start`, `Position start`, `Staggered` @@ -168,8 +168,13 @@ Menyn `Träning` ger förarna utveckling och delmål även mellan tävlingarna: Grundvyn är avsiktligt enkel: fyra nyckeltal, nästa varvmål, dagens 20-minuterspass och aktiva belöningar. Administration av mål, mobildelning, detaljhistorik och klubbjämförelse finns kvar i utfällbara sektioner när de behövs. +När Open Practice, Free Practice eller Practice pågår får vald förare ett kompakt livekort med aktuella giltiga varv, bästa varv, snitt och senaste varv. Sessionen är fortfarande gemensam för alla; endast mobilens 20-minuterstimer och den personliga profillänken är unik per förare. Primärknappen högst upp skapar eller öppnar länken. + - 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 +- 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 - automatiska utvecklingsnivåer vid lämpliga varvantal utan manuell konfiguration - rekommenderat 20-minuterspass uppdelat i maxfokus och kontrollerad körning på 80-90 procent @@ -356,6 +361,13 @@ Praktiskt exempel: - domarloggen kan exporteras per session som JSON - både `Ångra senaste` och undo-knapp per loggrad finns för flera manuella åtgärder +## Framtida förbättringar +- Valfri neural text-till-tal för naturligare uppläsning av start, mål och förarnamn. +- Rekommenderad lösning: generera tal via backend, cacha korta ljudfiler under `data/tts/` och spela cachat ljud utan nätverksfördröjning under racet. +- Leverantörens API-nyckel ska endast finnas i backend och aldrig exponeras i webbläsaren eller publik overlay. +- Nuvarande systemröst och passing-blipp behålls som offline-fallback. +- Uppladdade eller förinspelade WAV/MP3-filer kan användas för fasta start- och målmeddelanden. + ## Windows installation Använd 64-bitars Windows 11 och Node.js 22 eller 24 LTS. Enklaste installationen är: diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 26c2e7d..c32b9df 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -433,6 +433,10 @@ Owns: - durable ledger synchronization from `resultsBySession` - valid driver-lap aggregation across the training ledger - lifetime/session/day/best-lap/average/consistency calculations +- active Open/Free/regular Practice statistics scoped to one driver and the current session start +- stint splitting after passing gaps greater than `120000 ms` +- 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 - rule-based practice recommendation selection - automatic and custom milestone progress calculation @@ -445,6 +449,11 @@ Rules: - every save replaces ledger rows only for session IDs that still exist in `resultsBySession` - ledger rows for a deleted session are retained and become historical records - `sourceKey` deduplicates repeated saves of the same session passing +- 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 +- 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 ### `src/training_views.js` Type: @@ -456,6 +465,7 @@ Owns: - progressive disclosure for goal/share administration, detailed history, and club comparison - milestone/reward create, claim, and delete actions - mobile-responsive training markup +- compact live-practice card and primary personal-profile action for the selected driver Mutations: - replaces or appends entries in `state.training.goals` @@ -2081,6 +2091,9 @@ npm test - per-driver isolation and day/session aggregation - recommendation branch selection - custom goal and automatic milestone progress +- selected-driver 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 `tests/public_training_profile.test.js` covers: - enabled/disabled/unknown share-token handling @@ -2132,6 +2145,12 @@ The suite uses Node's built-in `node:test` runner and does not add a test-framew ## 19. Recommended Future Additions If the project grows further, the next useful additions to this document are: +- optional neural TTS provider integration for natural start, finish, and driver-name announcements +- backend-only provider credentials with no API key material in frontend state, exports, or public overlays +- server-side speech generation and content-addressed cache under `data/tts/` so race playback never waits for a cloud request +- cached audio invalidation when language, voice, phrase, or a driver's `speechName` changes +- offline fallback order: cached neural audio -> uploaded WAV/MP3 -> browser system voice -> passing beep +- provider health/status and a pre-generate-all-names action in Settings before race day - exact `resultsBySession` competitor row schema - exact `settings` schema snapshot - SQLite table contract section diff --git a/server/public_training_profile.js b/server/public_training_profile.js index ece6041..382dc44 100644 --- a/server/public_training_profile.js +++ b/server/public_training_profile.js @@ -147,6 +147,9 @@ function buildPublicTrainingStats(laps) { if (!controlTargetMs || lapTimes[index] > controlTargetMs) break; controlledLapStreak += 1; } + const stints = splitPublicTrainingStints(laps); + const bestThreeConsecutive = getPublicBestConsecutiveLaps(stints, 3); + const bestFiveMinuteResult = getPublicBestDurationResult(stints, 5 * 60 * 1000); return { totalLaps: laps.length, @@ -159,9 +162,80 @@ function buildPublicTrainingStats(laps) { consistencySpreadPct: recentAverageMs ? ((percentile(0.9) - percentile(0.1)) / recentAverageMs) * 100 : 0, controlledLapStreak, dailySummaries, + stintCount: stints.length, + bestThreeConsecutive, + bestFiveMinuteResult, }; } +function splitPublicTrainingStints(laps, breakThresholdMs = 2 * 60 * 1000) { + const stints = []; + (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), + }; + if (startsNewStint) { + stints.push({ sessionId: String(lap.sessionId || ""), laps: [normalizedLap] }); + } else { + previousStint.laps.push(normalizedLap); + } + }); + return stints; +} + +function getPublicBestConsecutiveLaps(stints, lapCount) { + let best = null; + (Array.isArray(stints) ? stints : []).forEach((stint) => { + const paceLaps = stint.laps.filter((lap) => !lap.excludedFromPace); + for (let index = 0; index <= paceLaps.length - lapCount; index += 1) { + const laps = paceLaps.slice(index, index + lapCount); + const totalMs = laps.reduce((sum, lap) => sum + Number(lap.lapMs || 0), 0); + const candidate = { lapCount, totalMs, averageLapMs: Math.round(totalMs / lapCount) }; + if (!best || candidate.totalMs < best.totalMs) best = candidate; + } + }); + return best; +} + +function getPublicBestDurationResult(stints, targetMs) { + let best = null; + (Array.isArray(stints) ? stints : []).forEach((stint) => { + const paceLaps = stint.laps.filter((lap) => !lap.excludedFromPace); + for (let startIndex = 0; startIndex < paceLaps.length; startIndex += 1) { + const firstLap = paceLaps[startIndex]; + const startedAt = Math.max(0, Number(firstLap.timestamp || 0) - Number(firstLap.lapMs || 0)); + const cutoffAt = startedAt + targetMs; + const counted = []; + for (let index = startIndex; index < paceLaps.length; index += 1) { + const lap = paceLaps[index]; + const lapStartedAt = Math.max(0, Number(lap.timestamp || 0) - Number(lap.lapMs || 0)); + if (lapStartedAt >= cutoffAt) break; + counted.push(lap); + } + const lastLap = counted.at(-1); + if (!lastLap || Number(lastLap.timestamp || 0) < cutoffAt) continue; + const totalMs = Number(lastLap.timestamp || 0) - startedAt; + const lapCount = counted.length; + const candidate = { + targetMs, + lapCount, + totalMs, + averageLapMs: Math.round(counted.reduce((sum, lap) => sum + Number(lap.lapMs || 0), 0) / lapCount), + }; + if (!best || candidate.lapCount > best.lapCount || (candidate.lapCount === best.lapCount && candidate.totalMs < best.totalMs)) best = candidate; + } + }); + return best; +} + function normalizePublicLogoDataUrl(value) { const logo = String(value || "").trim(); return logo.length <= 2_000_000 && /^data:image\/(?:png|jpeg|webp);base64,/i.test(logo) ? logo : ""; @@ -192,4 +266,7 @@ module.exports = { hasPublicTrainingShare, normalizePublicTrainingSince, normalizePublicTrainingWindow, + splitPublicTrainingStints, + getPublicBestConsecutiveLaps, + getPublicBestDurationResult, }; diff --git a/src/app.js b/src/app.js index 68cbec5..7f3ba42 100644 --- a/src/app.js +++ b/src/app.js @@ -17,7 +17,7 @@ import { renderEventManagerMarkup } from "./event_manager_view.js"; import { renderEventManagerView } from "./event_manager_controller.js"; import { createDefaultAmmcConfigHelper, getManagedWsUrlHelper, loadAmmcConfigFromBackendHelper, saveAmmcConfigToBackendHelper, refreshAmmcStatusHelper, startManagedAmmcHelper, stopManagedAmmcHelper, applyPersistedStateHelper, hydrateFromBackendHelper, scheduleBackendSyncHelper, syncStateToBackendHelper, pingBackendHelper, checkAppVersionHelper, startAppVersionPollingHelper, startOverlaySyncHelper, startOverlayRotationHelper, startOverlayLiveRefreshHelper, ensureAudioContextHelper, playPassingBeepHelper, playFinishSirenHelper, playLeaderCueHelper, playStartCueHelper, playBestLapCueHelper, pushOverlayEventHelper, getSpeechVoicesHelper, speakTextHelper, announcePassingHelper, announceRaceStartedHelper, announceRaceFinishedHelper, handleSessionTimerTickHelper, tickClockHelper } from "./runtime_services.js"; import { connectDecoderHelper, disconnectDecoderHelper, processDecoderMessageHelper } from "./decoder_runtime.js"; -import { normalizeTrainingState as normalizeTrainingStateLogic, syncTrainingLedger as syncTrainingLedgerLogic, buildDriverTrainingStats as buildDriverTrainingStatsLogic, buildTrainingRecommendation as buildTrainingRecommendationLogic, getTrainingGoalProgress as getTrainingGoalProgressLogic, getNextTrainingMilestone as getNextTrainingMilestoneLogic } from "./training_logic.js"; +import { normalizeTrainingState as normalizeTrainingStateLogic, syncTrainingLedger as syncTrainingLedgerLogic, buildDriverTrainingStats as buildDriverTrainingStatsLogic, buildDriverActivePracticeStats as buildDriverActivePracticeStatsLogic, buildTrainingRecommendation as buildTrainingRecommendationLogic, getTrainingGoalProgress as getTrainingGoalProgressLogic, getNextTrainingMilestone as getNextTrainingMilestoneLogic } from "./training_logic.js"; import { renderTrainingView } from "./training_views.js"; @@ -206,10 +206,11 @@ const syncTrainingLedger = () => { return state.training; }; const buildDriverTrainingStats = (trainingState, driverId) => buildDriverTrainingStatsLogic(trainingState, driverId); +const buildDriverActivePracticeStats = (trainingState, driverId, nowTs = Date.now()) => buildDriverActivePracticeStatsLogic(trainingState, driverId, nowTs); const buildTrainingRecommendation = (stats) => buildTrainingRecommendationLogic(stats); const getTrainingGoalProgress = (goal, stats) => getTrainingGoalProgressLogic(goal, stats); const getNextTrainingMilestone = (stats) => getNextTrainingMilestoneLogic(stats); -const renderTraining = () => renderTrainingView({ state, dom, t, escapeHtml, formatLap, uid, saveState, renderView, normalizeTrainingState, syncTrainingLedger, buildDriverTrainingStats, buildTrainingRecommendation, getTrainingGoalProgress, getNextTrainingMilestone, getSelectedDriverId: () => selectedTrainingDriverId, setSelectedDriverId: (value) => { selectedTrainingDriverId = value; updateViewUrl("training", value); } }); +const renderTraining = () => renderTrainingView({ state, dom, t, escapeHtml, formatLap, formatRaceClock, uid, saveState, renderView, normalizeTrainingState, syncTrainingLedger, buildDriverTrainingStats, buildDriverActivePracticeStats, buildTrainingRecommendation, getTrainingGoalProgress, getNextTrainingMilestone, getSelectedDriverId: () => selectedTrainingDriverId, setSelectedDriverId: (value) => { selectedTrainingDriverId = value; updateViewUrl("training", value); } }); const applyCompetitorCorrection = (session, row, options = {}) => applyCompetitorCorrectionLogic(session, row, options, { ensureSessionResult, uid, t, formatLap, saveState }); const recalculateCompetitorFromPassings = (session, rowKey) => recalculateCompetitorFromPassingsLogic(session, rowKey, { ensureSessionResult, getCompetitorPassings, isCountedPassing }); const invalidateCompetitorLastLap = (session, row) => invalidateCompetitorLastLapLogic(session, row, { ensureSessionResult, getCompetitorPassings, isCountedPassing, recalculateCompetitorFromPassings, uid, t, formatLap, saveState }); @@ -361,6 +362,19 @@ const TRANSLATIONS = { "training.club_progress": "Klubbens träningsöversikt", "training.club_progress_hint": "Jämför totalvarv och nästa mål när det behövs.", "training.next_target": "Nästa mål", + "training.start_personal": "Öppna personlig träning", + "training.copy_mobile_link": "Kopiera mobillänk", + "training.create_personal_link": "Skapa personlig träningslänk", + "training.live_now": "Live just nu", + "training.active_practice": "Aktiv träning", + "training.shared_practice": "Gemensam practice", + "training.session_laps": "Varv i passet", + "training.latest_lap": "Senaste varv", + "training.live_practice_hint": "Alla kan fortsätta köra i samma Open/Free Practice. Kortet visar bara den valda förarens giltiga varv sedan den aktuella sessionen startades.", + "training.stints": "Stints", + "training.best_three": "Bästa 3 sammanhängande", + "training.best_five": "Bästa 5 min", + "training.five_avg_pace": "Avg pace, bästa 5 min", "mode.track": "Sponsor Event", "mode.race": "Race", "dashboard.events": "Event", @@ -1117,6 +1131,10 @@ const TRANSLATIONS = { "guide.training_7": "7. Öppna Mål och mobilprofil och klicka Aktivera mobilprofil. Kopiera den personliga länken och öppna den på förarens eller förälderns mobil.", "guide.training_8": "8. Mobilprofilen är skrivskyddad och visar bara vald förares statistik. Den lokala 20-minuterstimern växlar från 10 minuter maxfokus till 10 minuter kontrollerad körning och visar förarens varv, bästa varv och snitt under det egna passet.", "guide.training_9": "9. Klicka Skapa ny länk eller Stäng av profil om länken spridits fel. Publicera bara `/public-training/*`, `/src/public_training.js` och `/src/public_training.css` externt, aldrig hela admin-sidan eller `/api/state`.", + "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 startar sin egen 20-minuterstimer i mobilen utan att starta om eller påverka den gemensamma practice-sessionen.", + "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.", "overlay.title": "Overlay", "overlay.subtitle": "Extern leaderboard-skärm", "overlay.no_active": "Ingen aktiv session vald.", @@ -1312,6 +1330,19 @@ const TRANSLATIONS = { "training.club_progress": "Club practice overview", "training.club_progress_hint": "Compare total laps and next targets when needed.", "training.next_target": "Next target", + "training.start_personal": "Open personal training", + "training.copy_mobile_link": "Copy mobile link", + "training.create_personal_link": "Create personal training link", + "training.live_now": "Live now", + "training.active_practice": "Active practice", + "training.shared_practice": "Shared practice", + "training.session_laps": "Session laps", + "training.latest_lap": "Latest lap", + "training.live_practice_hint": "Everyone can keep driving in the same Open/Free Practice. This card shows only the selected driver's valid laps since the current session started.", + "training.stints": "Stints", + "training.best_three": "Best 3 consecutive", + "training.best_five": "Best 5 min", + "training.five_avg_pace": "Average pace, best 5 min", "mode.track": "Track Event", "mode.race": "Race", "dashboard.events": "Events", @@ -2068,6 +2099,10 @@ const TRANSLATIONS = { "guide.training_7": "7. Open Goals and mobile profile, then click Enable mobile profile. Copy the personal link and open it on the driver's or parent's phone.", "guide.training_8": "8. The mobile profile is read-only and only shows the selected driver's statistics. Its local 20-minute timer changes from 10 minutes maximum focus to 10 minutes controlled driving and shows the driver's laps, best lap and average during that personal session.", "guide.training_9": "9. Click Create new link or Disable profile if the link is shared incorrectly. Expose only `/public-training/*`, `/src/public_training.js` and `/src/public_training.css` externally, never the full admin site or `/api/state`.", + "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 starts an individual 20-minute timer on the phone without restarting or changing the shared practice session.", + "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.", "overlay.title": "Overlay", "overlay.subtitle": "External leaderboard screen", "overlay.no_active": "No active session selected.", diff --git a/src/misc_views.js b/src/misc_views.js index 58515d7..d005714 100644 --- a/src/misc_views.js +++ b/src/misc_views.js @@ -64,7 +64,7 @@ export function renderGuideView({ dom, t }) {
${renderGuidePanel("guide.dashboard_title", ["guide.dashboard_1", "guide.dashboard_2", "guide.dashboard_3", "guide.dashboard_4"], "", { t })} - ${renderGuidePanel("guide.training_title", ["guide.training_1", "guide.training_2", "guide.training_3", "guide.training_4", "guide.training_5", "guide.training_6", "guide.training_7", "guide.training_8", "guide.training_9"], "", { 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"], "", { t })}
diff --git a/src/public_training.css b/src/public_training.css index 16c73b5..802926d 100644 --- a/src/public_training.css +++ b/src/public_training.css @@ -323,6 +323,35 @@ button { text-overflow: ellipsis; } +.profile-performance-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.profile-performance-stats > div { + display: grid; + gap: 6px; + padding: 16px; + border-right: 1px solid var(--line); +} + +.profile-performance-stats > div:last-child { + border-right: 0; +} + +.profile-performance-stats span { + color: var(--muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.profile-performance-stats strong { + font-family: Orbitron, sans-serif; + overflow-wrap: anywhere; +} + .profile-actions { display: flex; flex-wrap: wrap; @@ -451,6 +480,22 @@ button { .profile-grid { grid-template-columns: 1fr; } + + .profile-performance-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .profile-performance-stats > div { + border-bottom: 1px solid var(--line); + } + + .profile-performance-stats > div:nth-child(2n) { + border-right: 0; + } + + .profile-performance-stats > div:nth-last-child(-n + 2) { + border-bottom: 0; + } } @media (max-width: 420px) { diff --git a/src/public_training.js b/src/public_training.js index 8f303e8..5fa032c 100644 --- a/src/public_training.js +++ b/src/public_training.js @@ -52,6 +52,11 @@ const messages = { updated: "Uppdaterad {time}", switchCue: "Byt till kontrollerad körning", finishCue: "Träningspasset är klart", + performance: "Stintstatistik", + stints: "Stints", + bestThree: "Bästa 3 sammanhängande", + bestFive: "Bästa 5 min", + fiveAverage: "Avg pace, bästa 5 min", }, en: { loading: "Loading training profile...", @@ -96,6 +101,11 @@ const messages = { updated: "Updated {time}", switchCue: "Switch to controlled driving", finishCue: "The training session is complete", + performance: "Stint statistics", + stints: "Stints", + bestThree: "Best 3 consecutive", + bestFive: "Best 5 min", + fiveAverage: "Average pace, best 5 min", }, }; @@ -126,6 +136,15 @@ function formatLap(ms) { return `${minutes}:${String(seconds).padStart(2, "0")}.${String(millis).padStart(3, "0")}`; } +function formatRaceResultTime(ms) { + const value = Math.max(0, Math.floor(Number(ms || 0))); + const minutes = Math.floor(value / 60000); + const seconds = Math.floor((value % 60000) / 1000); + const centiseconds = Math.floor((value % 1000) / 10); + const millis = Math.floor(value % 1000); + return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(centiseconds).padStart(2, "0")}:${String(millis).padStart(3, "0")}`; +} + function formatTimer(ms) { const seconds = Math.max(0, Math.ceil(ms / 1000)); return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`; @@ -236,6 +255,16 @@ function renderProfile(data) {
+
+

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

+
+
${escapeHtml(t("stints"))}${Number(stats.stintCount || 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) : "-"}
+
+
+ ${goals.length ? `

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

diff --git a/src/styles.css b/src/styles.css index faed6d8..5274404 100644 --- a/src/styles.css +++ b/src/styles.css @@ -399,6 +399,76 @@ body { font-size: 0.82rem; } +.training-hero-controls { + display: grid; + justify-items: end; + gap: 10px; +} + +.training-hero-actions { + justify-content: flex-end; +} + +.training-live-card { + margin-top: 14px; + padding: 16px 18px; + border: 1px solid color-mix(in srgb, var(--success) 52%, var(--line) 48%); + border-radius: 16px; + background: + linear-gradient(110deg, color-mix(in srgb, var(--success) 12%, transparent), transparent 48%), + var(--panel); + box-shadow: var(--shadow); +} + +.training-live-head { + display: flex; + align-items: start; + justify-content: space-between; + gap: 16px; +} + +.training-live-head h3, +.training-live-card > p { + margin: 4px 0 0; +} + +.training-live-card > p { + color: var(--muted); + font-size: 0.88rem; +} + +.training-live-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + margin: 14px 0 10px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 12px; +} + +.training-live-stats > div { + display: grid; + gap: 4px; + padding: 10px 12px; + border-right: 1px solid var(--line); +} + +.training-live-stats > div:last-child { + border-right: 0; +} + +.training-live-stats span { + color: var(--muted); + font-size: 0.72rem; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.training-live-stats strong { + font-family: Orbitron, sans-serif; + font-size: 1rem; +} + .training-stats { margin-top: 14px; } @@ -2705,6 +2775,40 @@ select:focus { min-width: 0; } + .training-hero-controls { + justify-items: stretch; + } + + .training-hero-actions { + justify-content: stretch; + } + + .training-hero-actions .btn { + flex: 1; + } + + .training-live-head { + align-items: stretch; + flex-direction: column; + } + + .training-live-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .training-live-stats > div { + border-right: 1px solid var(--line); + border-bottom: 1px solid var(--line); + } + + .training-live-stats > div:nth-child(2n) { + border-right: 0; + } + + .training-live-stats > div:nth-last-child(-n + 2) { + border-bottom: 0; + } + .training-blocks { grid-template-columns: 1fr; } diff --git a/src/training_logic.js b/src/training_logic.js index c62a7a8..0416424 100644 --- a/src/training_logic.js +++ b/src/training_logic.js @@ -147,6 +147,108 @@ function buildDailySummaries(laps) { .sort((left, right) => right.latestTimestamp - left.latestTimestamp); } +export function splitTrainingStints(laps, breakThresholdMs = 2 * 60 * 1000) { + const sorted = (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 = { + ...lap, + excludedFromPace: Boolean(previousLap && breakMs > breakThresholdMs && lap.lapMs > breakThresholdMs), + }; + + 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); + }); + + return stints; +} + +export function getBestConsecutiveTrainingLaps(stints, lapCount = 3) { + const count = Math.max(1, Math.round(Number(lapCount || 1) || 1)); + let best = null; + (Array.isArray(stints) ? stints : []).forEach((stint) => { + const paceLaps = stint.laps.filter((lap) => !lap.excludedFromPace); + for (let index = 0; index <= paceLaps.length - count; index += 1) { + const laps = paceLaps.slice(index, index + count); + const totalMs = laps.reduce((sum, lap) => sum + lap.lapMs, 0); + const candidate = { + lapCount: count, + totalMs, + averageLapMs: Math.round(totalMs / count), + startedAt: Math.max(0, laps[0].timestamp - laps[0].lapMs), + endedAt: laps.at(-1).timestamp, + sessionId: String(stint.sessionId || ""), + }; + if (!best || candidate.totalMs < best.totalMs) best = candidate; + } + }); + return best; +} + +export function getBestTrainingDurationResult(stints, targetMs = 5 * 60 * 1000) { + const target = Math.max(1, Math.round(Number(targetMs || 0) || 0)); + let best = null; + + (Array.isArray(stints) ? stints : []).forEach((stint) => { + const paceLaps = stint.laps.filter((lap) => !lap.excludedFromPace); + for (let startIndex = 0; startIndex < paceLaps.length; startIndex += 1) { + const firstLap = paceLaps[startIndex]; + const startedAt = Math.max(0, firstLap.timestamp - firstLap.lapMs); + const cutoffAt = startedAt + target; + const counted = []; + + for (let index = startIndex; index < paceLaps.length; index += 1) { + const lap = paceLaps[index]; + const lapStartedAt = Math.max(0, lap.timestamp - lap.lapMs); + if (lapStartedAt >= cutoffAt) break; + counted.push(lap); + } + + const lastLap = counted.at(-1); + if (!lastLap || lastLap.timestamp < cutoffAt) continue; + const totalMs = Math.max(0, lastLap.timestamp - startedAt); + const lapCount = counted.length; + const candidate = { + targetMs: target, + lapCount, + totalMs, + averageLapMs: lapCount ? Math.round(counted.reduce((sum, lap) => sum + lap.lapMs, 0) / lapCount) : 0, + startedAt, + endedAt: lastLap.timestamp, + sessionId: String(stint.sessionId || ""), + }; + if (!best || candidate.lapCount > best.lapCount || (candidate.lapCount === best.lapCount && candidate.totalMs < best.totalMs)) { + best = candidate; + } + } + }); + + return best; +} + export function buildDriverTrainingStats(state, driverId) { const laps = getDriverTrainingLaps(state, driverId); const lapTimes = laps.map((lap) => lap.lapMs); @@ -173,6 +275,9 @@ export function buildDriverTrainingStats(state, driverId) { controlledLapStreak += 1; } const dailySummaries = buildDailySummaries(laps); + const stints = splitTrainingStints(laps); + const bestThreeConsecutive = getBestConsecutiveTrainingLaps(stints, 3); + const bestFiveMinuteResult = getBestTrainingDurationResult(stints, 5 * 60 * 1000); return { totalLaps, @@ -189,6 +294,43 @@ export function buildDriverTrainingStats(state, driverId) { recentLapCount: recentLapTimes.length, lastLapAt: laps.at(-1)?.timestamp || 0, dailySummaries, + stintCount: stints.length, + bestThreeConsecutive, + bestFiveMinuteResult, + }; +} + +export function buildDriverActivePracticeStats(state, driverId, nowTs = Date.now()) { + const session = (Array.isArray(state?.sessions) ? state.sessions : []) + .find((item) => String(item?.id || "") === String(state?.activeSessionId || "")); + const practiceTypes = new Set(["open_practice", "free_practice", "practice"]); + if (!session || session.status !== "running" || !practiceTypes.has(String(session.type || "").toLowerCase())) return null; + + const result = state?.resultsBySession?.[session.id]; + const startedAt = Math.max(0, Number(session.startedAt || 0) || 0); + const laps = (Array.isArray(result?.passings) ? result.passings : []) + .filter((passing) => ( + String(passing?.driverId || "") === String(driverId || "") + && passing?.validLap !== false + && Number(passing?.lapMs || 0) > 500 + && (!startedAt || Number(passing?.timestamp || 0) >= startedAt) + )) + .map((passing) => ({ + timestamp: Math.max(0, Number(passing.timestamp || 0) || 0), + lapMs: Math.round(Number(passing.lapMs || 0) || 0), + })) + .sort((left, right) => left.timestamp - right.timestamp); + const lapTimes = laps.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, }; } diff --git a/src/training_views.js b/src/training_views.js index d1fdcce..e8f0392 100644 --- a/src/training_views.js +++ b/src/training_views.js @@ -5,12 +5,14 @@ export function renderTrainingView(deps) { t, escapeHtml, formatLap, + formatRaceClock, uid, saveState, renderView, normalizeTrainingState, syncTrainingLedger, buildDriverTrainingStats, + buildDriverActivePracticeStats, buildTrainingRecommendation, getTrainingGoalProgress, getNextTrainingMilestone, @@ -39,6 +41,7 @@ export function renderTrainingView(deps) { const selectedDriver = drivers.find((driver) => driver.id === selectedDriverId); const stats = buildDriverTrainingStats(state, selectedDriverId); + const activePractice = buildDriverActivePracticeStats(state, selectedDriverId); const recommendation = buildTrainingRecommendation(stats); const nextMilestone = getNextTrainingMilestone(stats); const goals = state.training.goals.filter((goal) => goal.driverId === selectedDriverId); @@ -119,12 +122,41 @@ export function renderTrainingView(deps) {

${escapeHtml(selectedDriver.name)}

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

- +
+ +
+ ${profileShare ? ` + + + ` : ` + + `} +
+
+ ${activePractice ? ` +
+
+
+ ${escapeHtml(t("training.live_now"))} +

${escapeHtml(activePractice.sessionName || t("training.active_practice"))}

+
+ ${escapeHtml(t("training.shared_practice"))} +
+
+ ${trainingInsight(t("training.session_laps"), String(activePractice.laps))} + ${trainingInsight(t("training.best_lap"), formatLap(activePractice.bestLapMs))} + ${trainingInsight(t("training.average_lap"), formatLap(activePractice.averageLapMs))} + ${trainingInsight(t("training.latest_lap"), formatLap(activePractice.lastLapMs))} +
+

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

+
+ ` : ""} +
${trainingStat(t("training.total_laps"), String(stats.totalLaps), t("training.valid_laps"))} ${trainingStat(t("training.training_days"), String(stats.trainingDays), t("training.sessions_count", { sessions: stats.totalSessions }))} @@ -221,6 +253,10 @@ export function renderTrainingView(deps) { ${trainingInsight(t("training.recent_average"), formatLap(stats.recentAverageMs))} ${trainingInsight(t("training.controlled_streak"), t("training.lap_count", { laps: stats.controlledLapStreak }))} ${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)}` : "-")} + ${trainingInsight(t("training.best_five"), stats.bestFiveMinuteResult ? `${stats.bestFiveMinuteResult.lapCount}/${formatRaceClock(stats.bestFiveMinuteResult.totalMs)}` : "-")} + ${trainingInsight(t("training.five_avg_pace"), stats.bestFiveMinuteResult ? formatLap(stats.bestFiveMinuteResult.averageLapMs) : "-")}

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

@@ -255,6 +291,18 @@ export function renderTrainingView(deps) { renderView(); }); + document.getElementById("trainingHeroEnableProfile")?.addEventListener("click", () => { + replacePublicProfileShare(state, selectedDriverId); + saveState(); + renderView(); + }); + document.getElementById("trainingHeroOpenProfile")?.addEventListener("click", () => { + window.open(publicProfileUrl, "_blank", "noopener,noreferrer"); + }); + document.getElementById("trainingHeroCopyProfile")?.addEventListener("click", async () => { + await copyText(publicProfileUrl); + }); + document.querySelectorAll(".training-preset").forEach((button) => { button.addEventListener("click", () => { const input = document.querySelector('#training-goal-form input[name="targetLaps"]'); diff --git a/tests/public_training_profile.test.js b/tests/public_training_profile.test.js index 1510320..e8a0040 100644 --- a/tests/public_training_profile.test.js +++ b/tests/public_training_profile.test.js @@ -7,8 +7,10 @@ const { buildPublicTrainingProfile, findPublicTrainingShare, hasPublicTrainingShare, + getPublicBestDurationResult, normalizePublicTrainingSince, normalizePublicTrainingWindow, + splitPublicTrainingStints, } = require("../server/public_training_profile.js"); const DRIVER_ONE_TOKEN = "driver-one-token-12345678901234567890"; @@ -109,3 +111,17 @@ test("public timer query normalization bounds age and window values", () => { assert.equal(normalizePublicTrainingWindow("-100"), 0); assert.equal(normalizePublicTrainingWindow(String(25 * 60 * 60 * 1000)), 24 * 60 * 60 * 1000); }); + +test("public profile duration result includes the lap started before five minutes", () => { + const laps = Array.from({ length: 16 }, (_, index) => ({ + sessionId: "practice_1", + timestamp: (index + 1) * 18_000, + lapMs: 18_000, + })); + laps.push({ sessionId: "practice_1", timestamp: 314_123, lapMs: 26_123 }); + const result = getPublicBestDurationResult(splitPublicTrainingStints(laps), 300_000); + + assert.equal(result.lapCount, 17); + assert.equal(result.totalMs, 314_123); + assert.equal(result.averageLapMs, 18_478); +}); diff --git a/tests/training_logic.test.js b/tests/training_logic.test.js index 5776418..12a7a87 100644 --- a/tests/training_logic.test.js +++ b/tests/training_logic.test.js @@ -2,12 +2,16 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + buildDriverActivePracticeStats, buildDriverTrainingStats, buildTrainingRecommendation, + getBestConsecutiveTrainingLaps, + getBestTrainingDurationResult, getDriverTrainingLaps, getNextTrainingMilestone, getTrainingGoalProgress, normalizeTrainingState, + splitTrainingStints, syncTrainingLedger, } from "../src/training_logic.js"; @@ -105,6 +109,97 @@ test("driver statistics remain isolated and aggregate days and sessions", () => assert.equal(stats.dailySummaries[1].laps, 2); }); +test("active practice stats include only the selected driver's current valid laps", () => { + const startedAt = 10_000; + const state = { + activeSessionId: "practice_1", + sessions: [{ id: "practice_1", name: "Wednesday Open Practice", type: "open_practice", status: "running", startedAt }], + resultsBySession: { + practice_1: { + passings: [ + { driverId: "driver_1", timestamp: 9_000, lapMs: 15_000, validLap: true }, + { driverId: "driver_1", timestamp: 20_000, lapMs: 16_000, validLap: true }, + { driverId: "driver_1", timestamp: 37_000, lapMs: 17_000, validLap: true }, + { driverId: "driver_1", timestamp: 45_000, lapMs: 8_000, validLap: false }, + { driverId: "driver_2", timestamp: 54_000, lapMs: 16_500, validLap: true }, + ], + }, + }, + }; + + assert.deepEqual(buildDriverActivePracticeStats(state, "driver_1", 70_000), { + sessionId: "practice_1", + sessionName: "Wednesday Open Practice", + startedAt, + elapsedMs: 60_000, + laps: 2, + bestLapMs: 16_000, + averageLapMs: 16_500, + lastLapMs: 17_000, + }); +}); + +test("active practice stats ignore races and stopped practice sessions", () => { + const base = { activeSessionId: "session_1", resultsBySession: {} }; + assert.equal(buildDriverActivePracticeStats({ ...base, sessions: [{ id: "session_1", type: "final", status: "running" }] }, "driver_1"), null); + assert.equal(buildDriverActivePracticeStats({ ...base, sessions: [{ id: "session_1", type: "free_practice", status: "finished" }] }, "driver_1"), null); +}); + +test("training stints split after a break over two minutes or a session change", () => { + const laps = [ + { sessionId: "practice_1", timestamp: 18_000, lapMs: 18_000 }, + { sessionId: "practice_1", timestamp: 36_000, lapMs: 18_000 }, + { sessionId: "practice_1", timestamp: 200_001, lapMs: 164_001 }, + { sessionId: "practice_2", timestamp: 218_001, lapMs: 18_000 }, + ]; + const stints = splitTrainingStints(laps); + + assert.equal(stints.length, 3); + 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[2].sessionId, "practice_2"); +}); + +test("best consecutive laps never cross a stint boundary", () => { + const stints = splitTrainingStints([ + { sessionId: "practice_1", timestamp: 18_000, lapMs: 18_000 }, + { sessionId: "practice_1", timestamp: 35_000, lapMs: 17_000 }, + { sessionId: "practice_1", timestamp: 51_000, lapMs: 16_000 }, + { sessionId: "practice_1", timestamp: 66_000, lapMs: 15_000 }, + { sessionId: "practice_1", timestamp: 201_001, lapMs: 15_000 }, + ]); + const best = getBestConsecutiveTrainingLaps(stints, 3); + + assert.equal(best.totalMs, 48_000); + assert.equal(best.averageLapMs, 16_000); + assert.equal(best.startedAt, 18_000); +}); + +test("best five-minute result counts a final lap started before the cutoff and reports its pace", () => { + const laps = Array.from({ length: 16 }, (_, index) => ({ + sessionId: "practice_1", + timestamp: (index + 1) * 18_000, + lapMs: 18_000, + })); + laps.push({ sessionId: "practice_1", timestamp: 314_123, lapMs: 26_123 }); + const result = getBestTrainingDurationResult(splitTrainingStints(laps), 300_000); + + assert.equal(result.lapCount, 17); + assert.equal(result.totalMs, 314_123); + assert.equal(result.averageLapMs, 18_478); + assert.equal(result.startedAt, 0); + assert.equal(result.endedAt, 314_123); +}); + +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 }, + { sessionId: "practice_1", timestamp: 36_000, lapMs: 18_000 }, + ]); + assert.equal(getBestTrainingDurationResult(stints, 300_000), null); +}); + test("recommendations select baseline, consistency, and progression", () => { assert.equal(buildTrainingRecommendation({ totalLaps: 9 }).level, "baseline"); assert.deepEqual(