From a87c64a47cf47eda54729aa6e2e40c4c705a1bdd Mon Sep 17 00:00:00 2001 From: larssand Date: Thu, 27 Aug 2026 17:29:44 +0200 Subject: [PATCH] Improve decoder setup UI and add configurable training timers --- README.md | 8 +- README.sv.md | 8 +- src/app.js | 56 +++++++++++--- src/public_training.css | 49 ++++++++++++- src/public_training.js | 123 ++++++++++++++++++++++++++----- src/settings.js | 142 +++++++++++++++++++++++++++++++----- src/styles.css | 158 ++++++++++++++++++++++++++++++++++++++++ src/training_views.js | 6 +- 8 files changed, 493 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 2dafbb9..c52d4f6 100644 --- a/README.md +++ b/README.md @@ -177,9 +177,9 @@ Recommended deployment: 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. +The default view is intentionally simple: four key statistics, the next lap target, today's practice 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. +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 personal timer and profile link are unique per driver and class. The primary action at the top creates or opens that link. One person can have separate driver entries for classes such as 2WD and 4WD and link them to the same `Practice profile` under `Drivers`. The person then has one profile and one mobile link, while every performance statistic is selected and calculated separately per class. Multiple transponders within the same class are combined in that class's statistics. @@ -192,7 +192,7 @@ The personal profile can also store `Club` and `Country`. These values are inher - 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 +- a configurable practice timer split into maximum-focus and controlled 80-90% blocks - custom lap milestones with quick targets at `500`, `1000`, and `1500` laps - optional reward text and a delivered/redeemed state - responsive layout for opening the same server from a phone on the local network @@ -214,7 +214,7 @@ Each registered driver can have an optional personal mobile link: The URL uses a 192-bit random bearer token rather than a driver ID. The same link provides a class selector with fully separate lap times and practice results for each linked class. The public API does not return transponder IDs, other driver names, race setup or admin state. -The mobile page also includes a local 20-minute practice timer: 10 minutes maximum focus followed by 10 minutes controlled 80-90% driving. While a shared Free Practice session keeps running, the card shows that driver's lap count, best lap and average since the personal timer started. Timer state stays in that phone's browser and does not modify race data. +The mobile page also includes a local practice timer where the driver chooses minutes for `Maximum focus` and `Controlled`. The default is `10 + 10`, while a five-minute heat can for example use `5 + 0`. While a shared Free Practice session keeps running, the card shows that driver's lap count, best lap and average since the personal timer started. Timer state and duration choices stay in that phone's browser, separately per driver and class, and do not modify race data. Durations are locked while a session runs and can be changed after finishing or resetting it. For internet publishing, proxy only these paths: diff --git a/README.sv.md b/README.sv.md index 12f4ebc..4ae5599 100644 --- a/README.sv.md +++ b/README.sv.md @@ -166,9 +166,9 @@ Rekommenderad deploy: 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. +Grundvyn är avsiktligt enkel: fyra nyckeltal, nästa varvmål, dagens träningspass 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. +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 personliga timer och profillänk är unik per förare och klass. Primärknappen högst upp skapar eller öppnar länken. En person kan ha flera förarposter för exempelvis 2WD och 4WD och länka dem till samma `Träningsprofil` under `Förare`. Personen får då en profil och en mobillänk, men all prestandastatistik väljs och beräknas separat per klass. Flera transpondrar inom samma klass summeras däremot i den klassens statistik. @@ -181,7 +181,7 @@ Förarprofilen kan även lagra `Klubb` och `Land`. Värdena ärvs när ytterliga - 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 +- rekommenderat träningspass med valbara minuter för maxfokus och kontrollerad körning på 80-90 procent - egna varvmål med snabbval på `500`, `1000` och `1500` varv - valfri reward/belöning och markering när den har delats ut - responsiv layout som kan öppnas från mobilen via samma server i det lokala nätet @@ -203,7 +203,7 @@ Varje registrerad förare kan få en valfri personlig mobillänk: Länken använder en slumpad 192-bitars bearer-nyckel i stället för förar-ID. Samma länk visar profilens klasser i ett klassval, med helt separata varvtider och träningsresultat. Det publika API:t lämnar inte ut transponder-ID, andra förares namn, race setup eller admin-state. -Mobilsidan har också en lokal 20-minuterstimer: 10 minuter maxfokus följt av 10 minuter kontrollerad körning på 80-90 procent. Medan en gemensam Free Practice fortsätter vara igång visar kortet just den förarens antal varv, bästa varv och snitt sedan den personliga timern startades. Timern sparas bara i mobilens webbläsare och ändrar ingen racedata. +Mobilsidan har också en lokal träningstimer där föraren själv väljer minuter för `Maxfokus` och `Kontrollerad`. Standard är `10 + 10`, men ett femminutersheat kan exempelvis köras som `5 + 0`. Medan en gemensam Free Practice fortsätter vara igång visar kortet just den förarens antal varv, bästa varv och snitt sedan den personliga timern startades. Timer och tidsval sparas bara i mobilens webbläsare, separat per förare och klass, och ändrar ingen racedata. Tiderna låses medan passet pågår och kan ändras efter målgång eller nollställning. Vid extern publicering ska bara dessa paths proxas: diff --git a/src/app.js b/src/app.js index b7fa95f..241b7aa 100644 --- a/src/app.js +++ b/src/app.js @@ -308,13 +308,16 @@ const TRANSLATIONS = { "training.plan_baseline_title": "Bygg en baslinje", "training.plan_baseline_body": "Kör lugnt och jämnt tills minst 10 rena varv är registrerade. Fokusera på samma linje och bromspunkt varje varv.", "training.plan_consistency_title": "Höj din lägstanivå", - "training.plan_consistency_body": "Spridningen är hög. Kör först 10 minuter nära max, sedan 10 minuter på 80-90 procent där rena, repeterbara varv är viktigare än ett enstaka snabbt varv.", + "training.plan_consistency_body": "Spridningen är hög. Kör första blocket nära max, sedan ett kontrollerat block på 80-90 procent där rena, repeterbara varv är viktigare än ett enstaka snabbt varv.", "training.plan_progression_title": "Balansera fart och kontroll", - "training.plan_progression_body": "Du har en stabil grund. Kör 10 minuter med maxfokus och avsluta med 10 minuter kontrollerad körning där målet är en obruten serie jämna varv.", + "training.plan_progression_body": "Du har en stabil grund. Kör ett block med maxfokus och avsluta med ett kontrollerat block där målet är en obruten serie jämna varv.", "training.plan_target": "Kontrollmål omkring {lap}", "training.plan_no_target": "Registrera fler varv för tidsmål", "training.block_push": "Maxfokus: hitta gränsen", "training.block_control": "80-90%: höj lägstanivån", + "training.flexible_duration": "Valbar tid", + "training.block_one": "Block 1", + "training.block_two": "Block 2", "training.new_goal": "Nytt delmål", "training.manage_goals_sharing": "Mål och mobilprofil", "training.manage_goals_sharing_hint": "Skapa belöningsmål eller hantera förarens delningslänk.", @@ -361,7 +364,7 @@ const TRANSLATIONS = { "training.public_profile": "Mobilprofil för förare", "training.public_profile_active": "Aktiv", "training.public_profile_off": "Avstängd", - "training.public_profile_help": "Skapa en skrivskyddad mobilvy med den här förarens träningsstatistik, mål, belöningar och 20-minuterstimer.", + "training.public_profile_help": "Skapa en skrivskyddad mobilvy med den här förarens träningsstatistik, mål, belöningar och valbara träningstimer.", "training.public_profile_private": "Länken innehåller en unik hemlig nyckel. Den visar inte transponder-ID, övriga förare eller adminfunktioner.", "training.public_profile_link": "Personlig delningslänk", "training.copy_link": "Kopiera länk", @@ -800,6 +803,7 @@ const TRANSLATIONS = { "timing.last_message": "Senaste meddelande", "timing.main_decoder": "Main start/mål", "timing.split_decoder": "Mellantid", + "timing.pit_decoders": "Depå in/ut", "timing.pit_entry_decoder": "Depå in", "timing.pit_exit_decoder": "Depå ut", "timing.pit_active": "I DEPÅ", @@ -907,6 +911,20 @@ const TRANSLATIONS = { "settings.split_ws_url": "Mellantid WebSocket-url", "settings.enable_pit_decoders": "Aktivera depådecoders", "settings.decoder_roles_hint": "Klientens WebSocket-url ska använda backend-datorns IP, inte den fysiska decoderns IP. Main räknar varv; mellantid och depå gör det aldrig.", + "settings.decoder_setup_title": "Välj decoderinstallation", + "settings.decoder_setup_hint": "Välj ett snabbval så bockas rätt extrakanaler i. Main används alltid. Kontrollera URL:erna och klicka sedan Spara.", + "settings.decoder_layout_main": "1 decoder: Main", + "settings.decoder_layout_split": "2 decoders: Main + mellantid", + "settings.decoder_layout_pit": "3 decoders: Main + depå", + "settings.decoder_layout_full": "4 decoders: Komplett", + "settings.role_required": "Obligatorisk", + "settings.role_enabled": "Aktiverad", + "settings.role_disabled": "Avstängd", + "settings.main_role_hint": "Start/mål. Den enda kanalen som räknar varv.", + "settings.split_role_hint": "En extra fysisk decoder för mellantid. Räknar aldrig varv.", + "settings.pit_role_hint": "Kräver två extra fysiska decoders: en vid depå in och en vid depå ut.", + "settings.connection_title": "Backend och anslutning", + "settings.backend_url_hint": "Webb/API på datorn som kör npm start, normalt port 8081.", "settings.managed_channels_hint": "Fälten Decoder IP / host nedan är de fysiska decodrarnas adresser. Backend startar en AMMC-process per roll på unika portar: main 9000, mellantid 9001, depå in 9002 och depå ut 9003.", "settings.pit_entry_ws_url": "Depå in WebSocket-url", "settings.pit_exit_ws_url": "Depå ut WebSocket-url", @@ -1183,10 +1201,10 @@ const TRANSLATIONS = { "guide.training_5": "5. När målet nåtts kan belöningen markeras som utdelad. Direktlänken `/?view=training&driver=` kan bokmärkas på förarens mobil.", "guide.training_6": "6. Träningsloggen uppdateras av domarkorrigeringar medan sessionen finns, men behålls när sessionen senare raderas. Full dataexport i Inställningar inkluderar både logg och mål.", "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_8": "8. Mobilprofilen är skrivskyddad och visar bara vald förares statistik. Föraren väljer själv minuter för blocken Maxfokus och Kontrollerad körning; timern 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_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_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.", @@ -1344,13 +1362,16 @@ const TRANSLATIONS = { "training.plan_baseline_title": "Build a baseline", "training.plan_baseline_body": "Drive calmly and consistently until at least 10 clean laps are recorded. Repeat the same line and braking point every lap.", "training.plan_consistency_title": "Raise your minimum level", - "training.plan_consistency_body": "Lap spread is high. Run 10 minutes near maximum pace, then 10 minutes at 80-90 percent where clean, repeatable laps matter more than one fast lap.", + "training.plan_consistency_body": "Lap spread is high. Run the first block near maximum pace, then a controlled block at 80-90 percent where clean, repeatable laps matter more than one fast lap.", "training.plan_progression_title": "Balance pace and control", - "training.plan_progression_body": "You have a stable baseline. Run 10 minutes at maximum focus and finish with 10 controlled minutes aiming for an unbroken sequence of consistent laps.", + "training.plan_progression_body": "You have a stable baseline. Run one block at maximum focus and finish with a controlled block aiming for an unbroken sequence of consistent laps.", "training.plan_target": "Control target around {lap}", "training.plan_no_target": "Record more laps to calculate a target", "training.block_push": "Maximum focus: find the limit", "training.block_control": "80-90%: raise the floor", + "training.flexible_duration": "Custom time", + "training.block_one": "Block 1", + "training.block_two": "Block 2", "training.new_goal": "New milestone", "training.manage_goals_sharing": "Goals and mobile profile", "training.manage_goals_sharing_hint": "Create reward goals or manage the driver's share link.", @@ -1397,7 +1418,7 @@ const TRANSLATIONS = { "training.public_profile": "Driver mobile profile", "training.public_profile_active": "Active", "training.public_profile_off": "Disabled", - "training.public_profile_help": "Create a read-only mobile view with this driver's practice statistics, goals, rewards and 20-minute timer.", + "training.public_profile_help": "Create a read-only mobile view with this driver's practice statistics, goals, rewards and configurable practice timer.", "training.public_profile_private": "The link contains a unique secret key. It does not show transponder IDs, other drivers or admin controls.", "training.public_profile_link": "Personal share link", "training.copy_link": "Copy link", @@ -1836,6 +1857,7 @@ const TRANSLATIONS = { "timing.last_message": "Last message", "timing.main_decoder": "Main start/finish", "timing.split_decoder": "Split timing", + "timing.pit_decoders": "Pit entry/exit", "timing.pit_entry_decoder": "Pit entry", "timing.pit_exit_decoder": "Pit exit", "timing.pit_active": "IN PIT", @@ -1943,6 +1965,20 @@ const TRANSLATIONS = { "settings.split_ws_url": "Split WebSocket URL", "settings.enable_pit_decoders": "Enable pit decoders", "settings.decoder_roles_hint": "Client WebSocket URLs must use the backend host IP, not a physical decoder IP. Main counts laps; split and pit channels never do.", + "settings.decoder_setup_title": "Choose decoder setup", + "settings.decoder_setup_hint": "Choose a preset to enable the correct extra channels. Main is always used. Check the URLs, then click Save.", + "settings.decoder_layout_main": "1 decoder: Main", + "settings.decoder_layout_split": "2 decoders: Main + split", + "settings.decoder_layout_pit": "3 decoders: Main + pit", + "settings.decoder_layout_full": "4 decoders: Full system", + "settings.role_required": "Required", + "settings.role_enabled": "Enabled", + "settings.role_disabled": "Disabled", + "settings.main_role_hint": "Start/finish. The only channel that counts laps.", + "settings.split_role_hint": "One additional physical decoder for split timing. It never counts laps.", + "settings.pit_role_hint": "Requires two additional physical decoders: one at pit entry and one at pit exit.", + "settings.connection_title": "Backend and connection", + "settings.backend_url_hint": "Web/API on the computer running npm start, normally port 8081.", "settings.managed_channels_hint": "Decoder IP / host fields below are physical decoder addresses. The backend starts one AMMC process per role on unique ports: main 9000, split 9001, pit entry 9002, and pit exit 9003.", "settings.pit_entry_ws_url": "Pit entry WebSocket URL", "settings.pit_exit_ws_url": "Pit exit WebSocket URL", @@ -2219,10 +2255,10 @@ const TRANSLATIONS = { "guide.training_5": "5. When a target is reached, mark its reward as delivered. Bookmark `/?view=training&driver=` on the driver's phone for direct access.", "guide.training_6": "6. The training ledger follows judging corrections while the source session exists, but remains when that session is later deleted. Full data export in Settings includes both ledger and goals.", "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_8": "8. The mobile profile is read-only and only shows the selected driver's statistics. The driver chooses minutes for Maximum focus and Controlled driving; the timer shows that driver's laps, best lap and average during the 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_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_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.", diff --git a/src/public_training.css b/src/public_training.css index 9543e3c..7d4584a 100644 --- a/src/public_training.css +++ b/src/public_training.css @@ -280,8 +280,55 @@ button { line-height: 1.55; } +.profile-timer-setup { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 9px; + margin: 18px 0 4px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 12px; +} + +.profile-timer-setup legend { + padding: 0 6px; + color: var(--muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.profile-timer-setup label { + display: grid; + gap: 5px; + color: var(--muted); + font-size: 0.75rem; + font-weight: 700; +} + +.profile-timer-setup input { + width: 100%; + padding: 9px 10px; + border: 1px solid var(--line); + border-radius: 9px; + background: rgba(255, 255, 255, 0.035); + color: var(--text); + font: inherit; +} + +.profile-timer-setup small { + grid-column: 1 / -1; + color: var(--muted); + line-height: 1.35; +} + +.profile-timer-setup:disabled { + opacity: 0.62; +} + .profile-timer { - margin: 18px 0 14px; + margin: 14px 0; font-family: Orbitron, sans-serif; font-size: clamp(2.5rem, 12vw, 5.2rem); line-height: 1; diff --git a/src/public_training.js b/src/public_training.js index 4d556ca..2acf858 100644 --- a/src/public_training.js +++ b/src/public_training.js @@ -4,8 +4,8 @@ const requestedLanguage = new URLSearchParams(window.location.search).get("lang" const language = requestedLanguage === "en" || requestedLanguage === "sv" ? requestedLanguage : String(navigator.language || "sv").toLowerCase().startsWith("sv") ? "sv" : "en"; -const TIMER_DURATION_MS = 20 * 60 * 1000; -const TIMER_PHASE_MS = 10 * 60 * 1000; +const DEFAULT_TIMER_CONFIG = Object.freeze({ pushMinutes: 10, controlMinutes: 10 }); +const MAX_TIMER_BLOCK_MINUTES = 120; let selectedClassId = new URLSearchParams(window.location.search).get("class") || ""; const messages = { @@ -22,15 +22,20 @@ const messages = { nextLevel: "Nästa nivå", reachLaps: "Nå {laps} varv", lapsRemaining: "{laps} varv kvar", - plan: "Dagens 20-minuterspass", + plan: "Dagens träningspass", + timerSetup: "Ställ in passet", + pushMinutes: "Maxfokus (min)", + controlMinutes: "Kontrollerad (min)", + timerSetupHint: "Minst ett block måste vara 1 minut. Tiderna sparas för den här föraren och klassen.", + timerLockedHint: "Nollställ eller slutför passet för att ändra tiderna.", sessionStats: "Det här passet", sessionLaps: "Varv", baselineTitle: "Bygg en baslinje", baselineBody: "Kör lugnt och jämnt tills du har minst 10 rena varv. Upprepa samma linje och bromspunkt.", consistencyTitle: "Höj din lägstanivå", - consistencyBody: "Kör 10 minuter nära max, sedan 10 minuter på 80-90 procent med fokus på rena, repeterbara varv.", + consistencyBody: "Kör första blocket nära max, sedan ett kontrollerat block på 80-90 procent med fokus på rena, repeterbara varv.", progressionTitle: "Balansera fart och kontroll", - progressionBody: "Kör 10 minuter med maxfokus och avsluta med 10 kontrollerade minuter utan misstag.", + progressionBody: "Kör ett block med maxfokus och avsluta med ett kontrollerat block utan misstag.", target: "Kontrollmål {lap}", ready: "Redo att börja", push: "Maxfokus: hitta gränsen", @@ -76,15 +81,20 @@ const messages = { nextLevel: "Next level", reachLaps: "Reach {laps} laps", lapsRemaining: "{laps} laps remaining", - plan: "Today's 20-minute session", + plan: "Today's practice session", + timerSetup: "Set session duration", + pushMinutes: "Maximum focus (min)", + controlMinutes: "Controlled (min)", + timerSetupHint: "At least one block must be 1 minute. Times are saved for this driver and class.", + timerLockedHint: "Reset or finish the session to change the times.", sessionStats: "This session", sessionLaps: "Laps", baselineTitle: "Build a baseline", baselineBody: "Drive calmly and consistently until at least 10 clean laps are recorded. Repeat the same line and braking point.", consistencyTitle: "Raise your minimum level", - consistencyBody: "Run 10 minutes near maximum pace, then 10 minutes at 80-90 percent with clean, repeatable laps.", + consistencyBody: "Run the first block near maximum pace, then a controlled block at 80-90 percent with clean, repeatable laps.", progressionTitle: "Balance pace and control", - progressionBody: "Run 10 minutes at maximum focus and finish with 10 controlled minutes without mistakes.", + progressionBody: "Run one block at maximum focus and finish with a controlled block without mistakes.", target: "Control target {lap}", ready: "Ready to start", push: "Maximum focus: find the limit", @@ -181,6 +191,10 @@ function renderProfile(data) { const goals = activeClass?.goals || data.goals || []; const currentSession = activeClass?.currentSession || data.currentSession; const recommendationText = recommendationCopy(recommendation); + const timerState = readTimerState(); + const timerConfig = getTimerConfig(timerState); + const timerDurationMs = getTimerDurationMs(timerConfig); + const timerLocked = Boolean(timerState?.startedAt && getTimerElapsed(timerState) < timerDurationMs); const rank = activeClass?.clubRank && activeClass?.rankedDrivers ? t("rank", { rank: activeClass.clubRank, count: activeClass.rankedDrivers }) : ""; const logo = /^data:image\/(?:png|jpeg|webp);base64,/i.test(branding.logoDataUrl || "") ? `` @@ -258,7 +272,13 @@ function renderProfile(data) { ${recommendation.targetLapMs ? escapeHtml(t("target", { lap: formatLap(recommendation.targetLapMs) })) : ""}

${escapeHtml(recommendationText.title)}

${escapeHtml(recommendationText.body)}

-
20:00
+
+ ${escapeHtml(t("timerSetup"))} + + + ${escapeHtml(t(timerLocked ? "timerLockedHint" : "timerSetupHint"))} +
+
${formatTimer(timerDurationMs)}
${escapeHtml(t("ready"))}
${currentSession ? `
${escapeHtml(t("sessionStats"))}
@@ -334,6 +354,38 @@ function readTimerState() { } } +function normalizeTimerConfig(value) { + const clampMinutes = (minutes) => Math.max(0, Math.min(MAX_TIMER_BLOCK_MINUTES, Math.round(Number(minutes) || 0))); + const config = { + pushMinutes: clampMinutes(value?.pushMinutes), + controlMinutes: clampMinutes(value?.controlMinutes), + }; + return config.pushMinutes + config.controlMinutes > 0 ? config : { ...DEFAULT_TIMER_CONFIG }; +} + +function readTimerConfig() { + try { + return normalizeTimerConfig(JSON.parse(localStorage.getItem(getTimerConfigStorageKey()) || "null") || DEFAULT_TIMER_CONFIG); + } catch { + return { ...DEFAULT_TIMER_CONFIG }; + } +} + +function writeTimerConfig(value) { + const config = normalizeTimerConfig(value); + localStorage.setItem(getTimerConfigStorageKey(), JSON.stringify(config)); + return config; +} + +function getTimerConfig(state = null) { + if (state && (state.pushMinutes != null || state.controlMinutes != null)) return normalizeTimerConfig(state); + return readTimerConfig(); +} + +function getTimerDurationMs(config) { + return (Number(config.pushMinutes || 0) + Number(config.controlMinutes || 0)) * 60 * 1000; +} + function writeTimerState(value) { if (value) localStorage.setItem(getTimerStorageKey(), JSON.stringify(value)); else localStorage.removeItem(getTimerStorageKey()); @@ -343,6 +395,10 @@ function getTimerStorageKey() { return `rc-training-timer:${shareToken}:${selectedClassId || "default"}`; } +function getTimerConfigStorageKey() { + return `rc-training-timer-config:${shareToken}:${selectedClassId || "default"}`; +} + function getTimerElapsed(state, now = Date.now()) { if (!state?.startedAt) return 0; const endpoint = state.pausedAt || now; @@ -350,15 +406,33 @@ function getTimerElapsed(state, now = Date.now()) { } function bindTimerControls() { + const saveTimerSetup = () => { + const current = readTimerState(); + if (current?.startedAt && getTimerElapsed(current) < getTimerDurationMs(getTimerConfig(current))) return; + if (current?.startedAt) writeTimerState(null); + const config = writeTimerConfig({ + pushMinutes: document.getElementById("profilePushMinutes")?.value, + controlMinutes: document.getElementById("profileControlMinutes")?.value, + }); + const pushInput = document.getElementById("profilePushMinutes"); + const controlInput = document.getElementById("profileControlMinutes"); + if (pushInput) pushInput.value = String(config.pushMinutes); + if (controlInput) controlInput.value = String(config.controlMinutes); + updateTimerDisplay(); + }; + document.getElementById("profilePushMinutes")?.addEventListener("change", saveTimerSetup); + document.getElementById("profileControlMinutes")?.addEventListener("change", saveTimerSetup); document.getElementById("profileTimerStart")?.addEventListener("click", () => { const current = readTimerState(); + const currentDurationMs = getTimerDurationMs(getTimerConfig(current)); if (current?.pausedAt) { current.pausedTotalMs = Number(current.pausedTotalMs || 0) + Date.now() - current.pausedAt; current.pausedAt = 0; writeTimerState(current); - } else if (!current || getTimerElapsed(current) >= TIMER_DURATION_MS) { - writeTimerState({ startedAt: Date.now(), pausedAt: 0, pausedTotalMs: 0 }); - lastTimerPhase = "push"; + } else if (!current || getTimerElapsed(current) >= currentDurationMs) { + const config = readTimerConfig(); + writeTimerState({ startedAt: Date.now(), pausedAt: 0, pausedTotalMs: 0, ...config }); + lastTimerPhase = config.pushMinutes > 0 ? "push" : "control"; void loadProfile(); } startTimerLoop(); @@ -366,7 +440,7 @@ function bindTimerControls() { }); document.getElementById("profileTimerPause")?.addEventListener("click", () => { const current = readTimerState(); - if (current?.startedAt && !current.pausedAt && getTimerElapsed(current) < TIMER_DURATION_MS) { + if (current?.startedAt && !current.pausedAt && getTimerElapsed(current) < getTimerDurationMs(getTimerConfig(current))) { current.pausedAt = Date.now(); writeTimerState(current); } @@ -391,18 +465,31 @@ function updateTimerDisplay() { const phase = document.getElementById("profileTimerPhase"); const startButton = document.getElementById("profileTimerStart"); const pauseButton = document.getElementById("profileTimerPause"); + const timerSetup = document.getElementById("profileTimerSetup"); + const timerSetupHint = document.getElementById("profileTimerSetupHint"); if (!timer || !phase || !startButton || !pauseButton) return; const state = readTimerState(); + const config = getTimerConfig(state); + const timerDurationMs = getTimerDurationMs(config); + const pushDurationMs = Number(config.pushMinutes || 0) * 60 * 1000; const elapsed = getTimerElapsed(state); - const remaining = Math.max(0, TIMER_DURATION_MS - elapsed); - const phaseName = !state?.startedAt ? "ready" : remaining <= 0 ? "finished" : elapsed < TIMER_PHASE_MS ? "push" : "control"; - timer.textContent = formatTimer(remaining || (phaseName === "ready" ? TIMER_DURATION_MS : 0)); + const remaining = Math.max(0, timerDurationMs - elapsed); + const phaseName = !state?.startedAt + ? "ready" + : remaining <= 0 + ? "finished" + : config.pushMinutes > 0 && elapsed < pushDurationMs + ? "push" + : "control"; + timer.textContent = formatTimer(remaining || (phaseName === "ready" ? timerDurationMs : 0)); phase.textContent = t(phaseName); startButton.textContent = state?.pausedAt ? t("resume") : !state?.startedAt || remaining <= 0 ? t("start") : t("running"); startButton.disabled = Boolean(state?.startedAt && !state.pausedAt && remaining > 0); pauseButton.disabled = !state?.startedAt || Boolean(state.pausedAt) || remaining <= 0; + if (timerSetup) timerSetup.disabled = Boolean(state?.startedAt && remaining > 0); + if (timerSetupHint) timerSetupHint.textContent = t(state?.startedAt && remaining > 0 ? "timerLockedHint" : "timerSetupHint"); if (state?.startedAt && !state.pausedAt && phaseName !== lastTimerPhase) { - if (lastTimerPhase === "push" && phaseName === "control") speak(t("switchCue")); + if (lastTimerPhase === "push" && phaseName === "control" && config.controlMinutes > 0) speak(t("switchCue")); if (phaseName === "finished") speak(t("finishCue")); lastTimerPhase = phaseName; } @@ -424,7 +511,7 @@ async function loadProfile() { const now = Date.now(); const startedAgoMs = Math.max(0, now - timerState.startedAt); const currentPauseMs = timerState.pausedAt ? Math.max(0, now - timerState.pausedAt) : 0; - const maximumWindowMs = TIMER_DURATION_MS + Number(timerState.pausedTotalMs || 0) + currentPauseMs; + const maximumWindowMs = getTimerDurationMs(getTimerConfig(timerState)) + Number(timerState.pausedTotalMs || 0) + currentPauseMs; apiUrl.searchParams.set("startedAgoMs", String(startedAgoMs)); apiUrl.searchParams.set("windowMs", String(Math.min(startedAgoMs, maximumWindowMs))); } diff --git a/src/settings.js b/src/settings.js index e7a3469..e45a6a3 100644 --- a/src/settings.js +++ b/src/settings.js @@ -56,23 +56,99 @@ export function renderSettings(deps) { dom.view.innerHTML = `

${t("settings.decoder")}

-
-

${t("settings.decoder_roles_hint")}

- - - - - - - - - - + +
+
+ ${t("settings.decoder_setup_title")} +

${t("settings.decoder_setup_hint")}

+
+
+ + + + +
+
+ +

${t("settings.decoder_roles_hint")}

+
+
+
+
+ ${t("timing.main_decoder")} + ${t("settings.role_required")} +
+

${t("settings.main_role_hint")}

+
+ +
+ +
+
+
+ ${t("timing.split_decoder")} + +
+ +

${t("settings.split_role_hint")}

+
+ +
+ +
+
+
+ ${t("timing.pit_decoders")} + +
+ +

${t("settings.pit_role_hint")}

+
+
+ + +
+
+
+ +
+
+ ${t("settings.connection_title")} +

${t("settings.backend_url_hint")}

+
+ + +
+ + +
+

${t("settings.expected_json")}: {"msg":"PASSING", "transponder":232323, "rtc_time":"..."}

@@ -346,6 +422,38 @@ export function renderSettings(deps) { state.settings.speechPitch = Math.max(0.7, Math.min(1.3, Number(form.get("speechPitch") || 1) || 1)); }; + const splitDecoderToggle = document.getElementById("settingsSplitEnabled"); + const pitDecoderToggle = document.getElementById("settingsPitEnabled"); + const updateDecoderLayoutUi = () => { + const splitEnabled = Boolean(splitDecoderToggle?.checked); + const pitEnabled = Boolean(pitDecoderToggle?.checked); + document.getElementById("settingsSplitCard")?.classList.toggle("is-disabled", !splitEnabled); + document.getElementById("settingsPitCard")?.classList.toggle("is-disabled", !pitEnabled); + document.querySelector('[data-decoder-state="split"]')?.replaceChildren( + document.createTextNode(t(splitEnabled ? "settings.role_enabled" : "settings.role_disabled")) + ); + document.querySelector('[data-decoder-state="pit"]')?.replaceChildren( + document.createTextNode(t(pitEnabled ? "settings.role_enabled" : "settings.role_disabled")) + ); + const activeLayout = splitEnabled && pitEnabled ? "full" : splitEnabled ? "split" : pitEnabled ? "pit" : "main"; + document.querySelectorAll("[data-decoder-layout]").forEach((button) => { + button.classList.toggle("is-active", button.dataset.decoderLayout === activeLayout); + button.setAttribute("aria-pressed", String(button.dataset.decoderLayout === activeLayout)); + }); + }; + + splitDecoderToggle?.addEventListener("change", updateDecoderLayoutUi); + pitDecoderToggle?.addEventListener("change", updateDecoderLayoutUi); + document.querySelectorAll("[data-decoder-layout]").forEach((button) => { + button.addEventListener("click", () => { + const layout = button.dataset.decoderLayout; + if (splitDecoderToggle) splitDecoderToggle.checked = layout === "split" || layout === "full"; + if (pitDecoderToggle) pitDecoderToggle.checked = layout === "pit" || layout === "full"; + updateDecoderLayoutUi(); + }); + }); + updateDecoderLayoutUi(); + document.getElementById("settingsForm")?.addEventListener("submit", (e) => { e.preventDefault(); const form = new FormData(e.currentTarget); diff --git a/src/styles.css b/src/styles.css index 62581e9..b7dc47b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -872,6 +872,147 @@ body { gap: 10px; } +.decoder-settings-form { + display: grid; + gap: 16px; +} + +.decoder-layout-picker, +.decoder-connection-card { + display: grid; + gap: 12px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface-card); +} + +.decoder-layout-picker .hint, +.decoder-connection-card .hint, +.decoder-role-header p { + margin: 4px 0 0; +} + +.decoder-layout-options { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.decoder-layout-button { + min-height: 44px; + text-align: left; +} + +.decoder-layout-button.is-active { + border-color: var(--accent-2); + background: linear-gradient(145deg, color-mix(in srgb, var(--accent) 24%, var(--panel)), var(--panel-2)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-2) 35%, transparent); +} + +.decoder-role-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.decoder-role-card { + display: grid; + align-content: start; + gap: 14px; + min-width: 0; + padding: 14px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface-elevated); + transition: border-color 160ms ease, opacity 160ms ease; +} + +.decoder-role-card-main { + border-color: color-mix(in srgb, var(--ok) 50%, var(--line)); +} + +.decoder-role-card.is-disabled { + border-style: dashed; +} + +.decoder-role-card.is-disabled .decoder-role-body { + opacity: 0.52; +} + +.decoder-role-header { + display: grid; + gap: 9px; +} + +.decoder-role-header > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.decoder-role-header p { + min-height: 2.5em; + color: var(--muted); + font-size: 0.84rem; + line-height: 1.4; +} + +.decoder-role-badge { + padding: 3px 7px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.decoder-role-badge.is-required { + border-color: color-mix(in srgb, var(--ok) 45%, var(--line)); + color: var(--ok); +} + +.decoder-role-toggle { + min-height: 38px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 9px; + background: var(--surface-soft); +} + +.decoder-role-body, +.decoder-role-body > label { + display: grid; + gap: 6px; +} + +.decoder-pit-fields { + gap: 10px; +} + +.decoder-connection-card { + grid-template-columns: minmax(180px, 0.8fr) minmax(260px, 1.2fr) minmax(170px, 0.55fr) auto; + align-items: end; +} + +.decoder-connection-card > label { + display: grid; + gap: 6px; +} + +.decoder-reconnect-toggle { + min-height: 40px; + align-self: end; +} + +.decoder-settings-actions { + display: flex; + gap: 8px; +} + .settings-grid-toggles .toggle { min-height: 40px; } @@ -2726,6 +2867,15 @@ select:focus { .training-focus-grid { grid-template-columns: 1fr; } + + .decoder-role-grid, + .decoder-connection-card { + grid-template-columns: 1fr; + } + + .decoder-role-header p { + min-height: 0; + } } @media (max-width: 820px) { @@ -2753,6 +2903,14 @@ select:focus { grid-column: auto; } + .decoder-layout-options { + grid-template-columns: 1fr; + } + + .decoder-settings-actions { + flex-direction: column; + } + .dashboard-workflow-grid, .open-practice-quickstart-body, .open-practice-quickstart-form { diff --git a/src/training_views.js b/src/training_views.js index 33fb987..f609e6e 100644 --- a/src/training_views.js +++ b/src/training_views.js @@ -214,14 +214,14 @@ export function renderTrainingView(deps) {
-

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

20 min
+

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

${escapeHtml(t("training.flexible_duration"))}
${escapeHtml(t(recommendation.titleKey))}

${escapeHtml(targetText)}

${escapeHtml(t(recommendation.bodyKey))}

-
10 min${escapeHtml(t("training.block_push"))}
-
10 min${escapeHtml(t("training.block_control"))}
+
${escapeHtml(t("training.block_one"))}${escapeHtml(t("training.block_push"))}
+
${escapeHtml(t("training.block_two"))}${escapeHtml(t("training.block_control"))}