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 }) {
${escapeHtml(t("training.intro"))}
- +${escapeHtml(t("training.live_practice_hint"))}
+