diff --git a/README.md b/README.md index 1202d2d..599a63d 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,14 @@ JMK RB RaceController is an RC timing and race-control system with support for s - `Event` = sponsor events with shared cars/transponders - `Race Setup` = competition races with personal driver transponders - Race Setup includes: + - grouped `Participants` and `Teams` sections in `Manage` + - a four-step `Create Race Wizard` for new races + - `Manage` split into `Setup`, `Format`, `Generation`, and `Live / results` + - Basic mode now reacts to the selected preset, including a cleaner endurance view + - `Basic / Advanced` mode for `Race Format` + - a right-side race summary card + - separate `Race actions` for generation, reseeding and bump-up + - reorganized in-app `Guide` with overview cards and two-column sections - explicit race participant selection - practice standings - qualifying standings with `points` or `best result` @@ -115,6 +123,30 @@ JMK RB RaceController is an RC timing and race-control system with support for s - `SV` - `EN` + +## Create Race Wizard +- `Race Setup` now starts with a four-step wizard for new races: + - `Basics` + - `Participants` + - `Session plan` + - `Confirm` +- The wizard is intended to create the first race structure quickly: + - choose class and preset + - scope valid race drivers + - create practice, qualifying, and/or team-race sessions automatically +- Finals are still generated later from `Race Actions` after practice or qualifying is complete. + +## Manage workflow +- After the race is created, `Manage` is split into four clear stages: + - `Setup` + - `Format` + - `Generation` + - `Live / results` +- `Setup` handles race participants and teams. +- `Format` handles practice/qualifying, finals, validation, presets, and advanced settings. In Basic mode, endurance hides most qualifying/finals fields until Advanced is opened. +- `Generation` is kept separate so actions like qualifying generation, reseeding, finals generation, and bump-up do not clutter the format form. +- `Live / results` is where grid, standings, print, PDF, and finals matrix are used once the structure is ready. + ## Race features ### Follow-up time diff --git a/README.sv.md b/README.sv.md index 9004cc6..b794d9f 100644 --- a/README.sv.md +++ b/README.sv.md @@ -32,7 +32,15 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he - UI-separering: - `Event` = sponsor-event med delade bilar/transpondrar - `Race Setup` = riktiga race med personlig transponder per förare - - `Race Setup` innehåller nu även: +- `Race Setup` innehåller nu även: + - grupperade sektioner för `Deltagare` och `Lag` i `Hantera` + - en fyrstegs `Create Race Wizard` för nya race + - `Hantera` uppdelad i `Setup`, `Format`, `Generering` och `Live / resultat` + - Grundläge reagerar nu på valt preset, inklusive renare endurance-vy + - `Grundläge / Avancerat` för `Raceformat` + - en sammanfattningspanel till höger + - separata `Race actions` för generering, reseeding och bump-up + - omgjord inbyggd `Guide` med översiktskort och tvåkolumnslayout - välj exakt vilka förare som är med i racet - practice-ranking - kval-ranking med `poäng` eller `bästa resultat` @@ -104,6 +112,30 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he - AMMC + npm setup på Windows och Linux - Språkval i UI: `SV` / `EN` + +## Create Race Wizard +- `Race Setup` startar nu med en fyrstegs wizard för nya race: + - `Grunddata` + - `Deltagare` + - `Sessionsplan` + - `Bekräfta` +- Wizarden är till för att snabbt skapa första grundstrukturen: + - välj klass och preset + - välj vilka förare som faktiskt ska vara giltiga i racet + - skapa practice, kval och/eller team race automatiskt +- Finaler skapas fortfarande senare från `Race actions` när practice eller kval är färdiga. + +## Hantera-flödet +- Efter att racet skapats är `Hantera` uppdelat i fyra tydliga steg: + - `Setup` + - `Format` + - `Generering` + - `Live / resultat` +- `Setup` används för racedeltagare och lag. +- `Format` används för practice/kval, finaler, validering, presets och avancerade val. I Grundläge döljer endurance de flesta kval-/finalfält tills du öppnar Avancerat. +- `Generering` ligger separat så knappar för kval, reseeding, finaler och bump-up inte blandas ihop med raceformatet. +- `Live / resultat` är där grid, standings, print, PDF och finalmatris används när upplägget väl är klart. + ## Nya racefunktioner ### Follow-up time diff --git a/src/app.js b/src/app.js index 72717d2..89e312d 100644 --- a/src/app.js +++ b/src/app.js @@ -124,6 +124,30 @@ const TRANSLATIONS = { "events.bump_reserved_note": "Om bump används kan finalgeneratorn reservera platser i högre finaler redan från start.", "events.actions": "Åtgärder", "events.manage_title": "Hantera", + "events.wizard_hint": "Bygg upp racet i fyra steg: grunddata, deltagare, sessionsplan och bekräftelse. Finjustering sker sedan i Hantera.", + "events.wizard_step_1": "Grunddata", + "events.wizard_step_2": "Deltagare", + "events.wizard_step_3": "Sessionsplan", + "events.wizard_step_4": "Bekräfta", + "events.wizard_no_class_drivers": "Inga förare finns i vald klass ännu. Lägg upp förare först eller byt klass.", + "events.wizard_create": "Skapa race", + "events.wizard_use_practice": "Skapa practice-sessioner", + "events.wizard_practice_sessions": "Antal practice-sessioner", + "events.wizard_use_qualifying": "Skapa kvalomgångar", + "events.wizard_qualifying_rounds": "Antal kvalomgångar", + "events.wizard_use_team_race": "Skapa endurance / Team Race-session", + "events.wizard_team_duration": "Team Race-längd (min)", + "events.wizard_finals_note": "Finaler skapas senare från kvalrankingen via Race actions, inte direkt i wizard-steget.", + "events.wizard_summary_title": "Nytt race", + "events.wizard_summary_sessions": "Sessioner som skapas", + "events.manage_step_setup": "1. Setup", + "events.manage_step_setup_hint": "Deltagare och lag för just detta race.", + "events.manage_step_format": "2. Format", + "events.manage_step_format_hint": "Practice, kval, finaler och validering.", + "events.manage_step_generate": "3. Generering", + "events.manage_step_generate_hint": "Skapa kval/finaler, reseeda och bump-up.", + "events.manage_step_live": "4. Live / resultat", + "events.manage_step_live_hint": "Grid, standings, print och PDF.", "events.branding": "Branding för detta event", "events.branding_note": "Lämna fält tomma för att ärva global branding från Inställningar. Logo används i overlay och bäddas in i PDF-export när den kan konverteras.", "events.brand_name": "Brandnamn", @@ -152,6 +176,44 @@ const TRANSLATIONS = { "events.seed_best_laps_hint": "0 = av, 2 eller 3 för practice/kval-seedning", "events.race_format": "Raceformat", "events.race_format_intro": "Ställ in hur kval och finaler ska skapas och rankas för just detta race.", + "events.setup_mode_basic": "Grundläge", + "events.setup_mode_advanced": "Avancerat", + "events.practice_block": "Practice / kval", + "events.practice_block_hint": "Bygg seedning, kvalomgångar och poäng/tie-break här.", + "events.finals_block": "Finaler", + "events.finals_block_hint": "Styr A/B/C-finaler, starttyp, leg och bump-up.", + "events.rules_block": "Validering och avslut", + "events.rules_block_hint": "Min/max-varv och follow-up påverkar både livekörning och statistik.", + "events.race_summary": "Sammanfattning", + "events.race_summary_hint": "Snabb kontroll av vad som faktiskt kommer att skapas och köras.", + "events.race_summary_preset": "Preset", + "events.race_summary_participants": "Deltagare", + "events.race_summary_created_sessions": "Skapade sessioner", + "events.race_summary_qualifying": "Kvalupplägg", + "events.race_summary_finals": "Finalupplägg", + "events.race_summary_validation": "Varvfönster", + "events.race_summary_follow_up": "Follow-up", + "events.race_actions_title": "Race actions", + "events.race_actions_hint": "Generering och reseeding ligger separat från formatinställningarna så setupen blir lättare att läsa.", + "events.context_standard_title": "Klubbrace-läge", + "events.context_standard_hint": "Börja med preset och Grundläge. När deltagare och tider ser rätt ut använder du Race actions för kval, reseeding och finaler.", + "events.context_endurance_title": "Endurance-läge", + "events.context_endurance_hint": "I Grundläge döljs kval- och finalfälten här eftersom endurance normalt byggs runt Team Race. Öppna Avancerat om eventet även ska ha stödheat eller finaler.", + "events.context_rules_title": "Valideringsexempel", + "events.context_rules_hint": "Exempel på kort teknisk bana: 11s min / 60s max. Justera efter verklig varvtid så statistik och stintar blir rimliga.", + "guide.race_wizard_title": "Create Race Wizard", + "guide.race_wizard_1": "1. Börja i Race Setup och använd wizarden när du skapar ett nytt race, i stället för att bygga allt direkt i Hantera.", + "guide.race_wizard_2": "2. Steg 1 sätter namn, datum, klass och preset. Presetet fyller rimliga standardvärden innan du finjusterar något.", + "guide.race_wizard_3": "3. Steg 2 väljer exakt vilka förare som ska vara giltiga i racet. De blir då race-specifika deltagare och filter för timing/seedning.", + "guide.race_wizard_4": "4. Steg 3 väljer vilka sessioner som ska skapas direkt: practice, kval och/eller team race beroende på preset.", + "guide.race_wizard_5": "5. Wizarden skapar de första sessionerna automatiskt. Finaler skapas senare från Race actions när kval eller practice är klara.", + "guide.race_wizard_6": "6. Efter skapandet går du vidare till Hantera för raceformat, generering, grid, standings och utskrift.", + "guide.manage_steps_title": "Hantera race i fyra steg", + "guide.manage_steps_1": "1. Setup: välj racedeltagare och bygg eventuella lag för team race eller endurance.", + "guide.manage_steps_2": "2. Format: justera Race Format i Grundläge eller Avancerat. Practice/kval, finaler och validering ligger i egna block.", + "guide.manage_steps_3": "3. Generering: skapa kval, reseeda kommande heat, skapa finaler och applicera bump-ups från en separat Race actions-panel.", + "guide.manage_steps_4": "4. Live / resultat: använd grid editor, standings, finalmatris, print och PDF när tävlingen väl är byggd.", + "guide.manage_steps_5": "5. Sammanfattningskortet till höger fungerar som snabb kontroll av upplägget innan du börjar generera eller köra race.", "events.qualifying_scoring_hint": "Välj om kval ska rankas på poäng per omgång eller bästa enskilda resultat.", "events.qualifying_scoring": "Kval-scoring", "events.qualifying_scoring_points": "Poäng per runda", @@ -505,6 +567,9 @@ const TRANSLATIONS = { "common.unknown_event": "Okänt event", "common.no_rows": "Inga rader", "common.no_entries": "Inga poster.", + "common.previous": "Tillbaka", + "common.next": "Nästa", + "common.reset": "Återställ", "status.ready": "redo", "status.running": "pågår", "status.finished": "klar", @@ -539,7 +604,11 @@ const TRANSLATIONS = { "edit.event_name": "Redigera eventnamn", "edit.event_date": "Redigera eventdatum (YYYY-MM-DD)", "guide.title": "Guide och dokumentation", - "guide.intro": "Här finns steg-för-steg för sponsor-event (10 personer, 4 bilar), vanligt race, samt AMMC-installation på Windows/Linux. Guiden beskriver också var AMMC faktiskt körs i Managed AMMC-läget.", + "guide.intro": "Här finns steg-för-steg för sponsor-event, vanligt race och nya Create Race Wizard i Race Setup. Guiden beskriver också hur Hantera-flödet är uppdelat samt var AMMC faktiskt körs i Managed AMMC-läget.", + "guide.card_sponsor_blurb": "Delade bilar, roterande förare och heat/finaler för sponsor- och prova-på-event.", + "guide.card_race_blurb": "Personliga transpondrar, kval/finaler, raceformat och seedning för riktiga tävlingsrace.", + "guide.card_team_blurb": "Lagrace och endurance med team, stintlogg och summerade varv över lång tid.", + "guide.card_decoder_blurb": "AMMC, backend, WebSocket, Windows/Linux och lokal SQLite-lagring.", "guide.sponsor_title": "Skapa Sponsor Event: 10 personer, 4 bilar", "guide.sponsor_1": "1. Lägg upp 4 bilar i sidan Bilar med unikt transponder-ID. Lägg gärna också in märke/modell i brandfältet.", "guide.sponsor_2": "2. Lägg upp 10 förare i sidan Förare. Du kan också spara team/märke i brandfältet och filtrera listan på det senare.", @@ -552,6 +621,7 @@ const TRANSLATIONS = { "guide.race_2": "2. Skapa event med läge Race.", "guide.race_3": "3. Klicka Hantera på racet och markera exakt vilka förare som ska få vara med i just detta race.", "guide.race_4": "4. Gå igenom Raceformat och välj antal kvalrundor, förare per heat, tider, starttyp och hur finaler ska seedas.", + "guide.race_4a": "4a. Börja i Grundläge med en preset. Öppna Avancerat först när du behöver tie-break, poängtabell, reservplatser eller finjusterad seedning.", "guide.race_5": "5. Kör practice om du vill använda practice som första seedning.", "guide.race_6": "6. Klicka Skapa kval från practice för att bygga kvalheat från practice-ranking eller klasslista.", "guide.race_7": "7. Kör kvalomgångarna. Om du vill omfördela kommande heat efter aktuell ranking klickar du Reseeda kommande kval.", @@ -559,6 +629,7 @@ const TRANSLATIONS = { "guide.race_9": "9. Kör finalerna. Om bump används klickar du Applicera bump-ups mellan mains när en lägre final är färdig.", "guide.race_10": "10. Använd Finalmatris och Skriv ut startlistor/resultat för att kontrollera och dela upplägget.", "guide.race_format_title": "Förklaring av Raceformat", + "guide.race_format_0": "Raceformat är uppdelat i block för Practice/Kval, Finaler och Validering. Till höger visas en sammanfattning av upplägget innan du genererar heat och finaler.", "guide.race_format_1": "Kval-scoring styr om ranking byggs på poäng per kvalomgång eller bästa enskilda kvalresultat.", "guide.race_format_2": "Antal kvalrundor och Förare per kvalheat styr hur många kvalheat som skapas och hur de fylls.", "guide.race_format_3": "Kvaltid, Kval-start och Räknade kvalrundor styr hur kvalen körs och vilka rundor som räknas.", @@ -570,6 +641,7 @@ const TRANSLATIONS = { "guide.race_format_9": "Min varvtid filtrerar bort shortcuts och felträffar. Exempel: på en 16-sekundersbana kan du sätta 11 sekunder som min-gräns.", "guide.race_format_10": "Max varvtid stoppar långa felvarv från att räknas och används också för att bryta stintar och förbättra statistik. Exempel: 60 sekunder.", "guide.race_format_11": "Preset låter dig snabbt fylla raceformat med vettiga grundvärden för kort teknisk bana, klubbrace, IFMAR-liknande upplägg eller endurance.", + "guide.race_format_11a": "I Grundläge döljer endurance-presetet de flesta kval- och finalfälten för att hålla fokus på Team Race. Öppna Avancerat om eventet även ska ha stödheat eller vanliga finaler.", "guide.race_format_12": "Du kan applicera preset och sedan justera enskilda fält manuellt innan du sparar raceformatet.", "guide.race_format_13": "Spara klubb-preset lagrar dina egna lokala raceformat så du kan återanvända dem på samma installation utan att bygga om allt varje gång.", "guide.race_format_14": "Klubb-presetar kan också exporteras och importeras från Inställningar om du vill flytta dem mellan olika servrar eller laptops.", @@ -766,6 +838,30 @@ const TRANSLATIONS = { "events.bump_reserved_note": "If bump-up is used, finals can reserve slots in upper mains from the start.", "events.actions": "Actions", "events.manage_title": "Manage", + "events.wizard_hint": "Build the race in four steps: basics, participants, session plan and confirmation. Fine tuning happens later in Manage.", + "events.wizard_step_1": "Basics", + "events.wizard_step_2": "Participants", + "events.wizard_step_3": "Session plan", + "events.wizard_step_4": "Confirm", + "events.wizard_no_class_drivers": "No drivers exist in the selected class yet. Add drivers first or switch class.", + "events.wizard_create": "Create race", + "events.wizard_use_practice": "Create practice sessions", + "events.wizard_practice_sessions": "Practice session count", + "events.wizard_use_qualifying": "Create qualifying rounds", + "events.wizard_qualifying_rounds": "Qualifying rounds", + "events.wizard_use_team_race": "Create endurance / Team Race session", + "events.wizard_team_duration": "Team Race duration (min)", + "events.wizard_finals_note": "Finals are generated later from qualifying standings via Race actions, not directly in the wizard.", + "events.wizard_summary_title": "New race", + "events.wizard_summary_sessions": "Sessions to create", + "events.manage_step_setup": "1. Setup", + "events.manage_step_setup_hint": "Participants and teams for this race.", + "events.manage_step_format": "2. Format", + "events.manage_step_format_hint": "Practice, qualifying, finals and validation.", + "events.manage_step_generate": "3. Generation", + "events.manage_step_generate_hint": "Generate qualifying/finals, reseed and bump-up.", + "events.manage_step_live": "4. Live / results", + "events.manage_step_live_hint": "Grid, standings, print and PDF.", "events.branding": "Branding for this event", "events.branding_note": "Leave fields empty to inherit global branding from Settings. The logo is used in overlay and embedded in PDF exports when it can be converted.", "events.brand_name": "Brand name", @@ -794,6 +890,44 @@ const TRANSLATIONS = { "events.seed_best_laps_hint": "0 = off, 2 or 3 for practice/qualifying seeding", "events.race_format": "Race format", "events.race_format_intro": "Set how qualifying and finals should be generated and scored for this race.", + "events.setup_mode_basic": "Basic", + "events.setup_mode_advanced": "Advanced", + "events.practice_block": "Practice / qualifying", + "events.practice_block_hint": "Build seeding, qualifying rounds and points/tie-break rules here.", + "events.finals_block": "Finals", + "events.finals_block_hint": "Control A/B/C finals, start mode, legs and bump-up here.", + "events.rules_block": "Validation and finish logic", + "events.rules_block_hint": "Min/max lap and follow-up affect both live timing and statistics.", + "events.race_summary": "Summary", + "events.race_summary_hint": "Quick check of what will actually be generated and raced.", + "events.race_summary_preset": "Preset", + "events.race_summary_participants": "Participants", + "events.race_summary_created_sessions": "Created sessions", + "events.race_summary_qualifying": "Qualifying setup", + "events.race_summary_finals": "Finals setup", + "events.race_summary_validation": "Lap window", + "events.race_summary_follow_up": "Follow-up", + "events.race_actions_title": "Race actions", + "events.race_actions_hint": "Generation and reseeding live separately from the format fields so setup is easier to read.", + "events.context_standard_title": "Club race mode", + "events.context_standard_hint": "Start with a preset and Basic mode. When participants and timings look right, use Race Actions for qualifying, reseeding and finals.", + "events.context_endurance_title": "Endurance mode", + "events.context_endurance_hint": "Basic mode hides most qualifying and finals fields here because endurance is normally built around Team Race. Open Advanced if the event also needs support heats or finals.", + "events.context_rules_title": "Validation example", + "events.context_rules_hint": "Example for a short technical track: 11s min / 60s max. Adjust to the real lap pace so stats and stints remain meaningful.", + "guide.race_wizard_title": "Create Race Wizard", + "guide.race_wizard_1": "1. Start in Race Setup and use the wizard when creating a new race instead of building everything directly in Manage.", + "guide.race_wizard_2": "2. Step 1 sets name, date, class and preset. The preset fills sensible defaults before you fine-tune anything.", + "guide.race_wizard_3": "3. Step 2 selects exactly which drivers should be valid for this race. They then become the race-specific participant scope for timing and seeding.", + "guide.race_wizard_4": "4. Step 3 chooses which sessions should be created immediately: practice, qualifying and/or team race depending on the preset.", + "guide.race_wizard_5": "5. The wizard creates the initial sessions automatically. Finals are generated later from Race Actions after qualifying or practice is complete.", + "guide.race_wizard_6": "6. After creation, continue in Manage for race format, generation, grid, standings and print/export.", + "guide.manage_steps_title": "Manage Race In Four Steps", + "guide.manage_steps_1": "1. Setup: choose race participants and build any teams for team race or endurance.", + "guide.manage_steps_2": "2. Format: adjust Race Format in Basic or Advanced mode. Practice/qualifying, finals and validation are split into separate blocks.", + "guide.manage_steps_3": "3. Generation: create qualifying, reseed upcoming heats, generate finals and apply bump-ups from a separate Race Actions panel.", + "guide.manage_steps_4": "4. Live / results: use the grid editor, standings, finals matrix, print and PDF after the competition structure is built.", + "guide.manage_steps_5": "5. The summary card on the right acts as a quick sanity check before you start generating or running races.", "events.qualifying_scoring_hint": "Choose whether qualifying should rank by round points or by the single best result.", "events.qualifying_scoring": "Qualifying scoring", "events.qualifying_scoring_points": "Points per round", @@ -1147,6 +1281,9 @@ const TRANSLATIONS = { "common.unknown_event": "Unknown Event", "common.no_rows": "No rows", "common.no_entries": "No entries.", + "common.previous": "Back", + "common.next": "Next", + "common.reset": "Reset", "status.ready": "ready", "status.running": "running", "status.finished": "finished", @@ -1181,7 +1318,11 @@ const TRANSLATIONS = { "edit.event_name": "Edit event name", "edit.event_date": "Edit event date (YYYY-MM-DD)", "guide.title": "Guide and Documentation", - "guide.intro": "Step-by-step setup for sponsor events (10 drivers, 4 cars), normal race mode, and AMMC on Windows/Linux. The guide also explains where Managed AMMC actually runs.", + "guide.intro": "Step-by-step setup for sponsor events, regular race mode and the new Create Race Wizard in Race Setup. The guide also explains how the Manage flow is split up and where Managed AMMC actually runs.", + "guide.card_sponsor_blurb": "Shared cars, rotating drivers and heat/final flow for sponsor or try-out events.", + "guide.card_race_blurb": "Personal transponders, qualifying/finals, race format and seeding for competition racing.", + "guide.card_team_blurb": "Team race and endurance with teams, stint logs and total laps over long sessions.", + "guide.card_decoder_blurb": "AMMC, backend, WebSocket, Windows/Linux and local SQLite storage.", "guide.sponsor_title": "Create Sponsor Event: 10 drivers, 4 cars", "guide.sponsor_1": "1. Add 4 cars in Cars with unique transponder IDs. You can also store brand/model in the brand field.", "guide.sponsor_2": "2. Add 10 drivers in Drivers. You can also store team/brand in the brand field and filter by it later.", @@ -1194,6 +1335,7 @@ const TRANSLATIONS = { "guide.race_2": "2. Create event in Race mode.", "guide.race_3": "3. Click Manage on the race and select exactly which drivers should be valid for this race.", "guide.race_4": "4. Go through Race Format and choose qualifying rounds, drivers per heat, times, start mode and how finals should be seeded.", + "guide.race_4a": "4a. Start in Basic mode with a preset. Open Advanced only when you need tie-breaks, custom points, reserved bump slots or fine-grained seeding.", "guide.race_5": "5. Run practice if you want to use practice as the first seeding step.", "guide.race_6": "6. Click Generate qualifying from practice to build qualifying heats from practice standings or the class list.", "guide.race_7": "7. Run the qualifying rounds. If you want to reshuffle upcoming heats from current standings, click Reseed upcoming qualifying.", @@ -1201,6 +1343,7 @@ const TRANSLATIONS = { "guide.race_9": "9. Run the finals. If bump-up is used, click Apply bump-ups between mains after a lower final is completed.", "guide.race_10": "10. Use Final Matrix and Print start lists/results to verify and share the setup.", "guide.race_format_title": "Race Format Explained", + "guide.race_format_0": "Race Format is split into blocks for Practice/Qualifying, Finals and Validation. A summary card on the right shows what will be generated before you create heats and finals.", "guide.race_format_1": "Qualifying scoring decides whether ranking is built from round points or the single best qualifying result.", "guide.race_format_2": "Qualifying rounds and Drivers per qualifying heat control how many heats are created and how they are filled.", "guide.race_format_3": "Qualifying duration, Qualifying start and Counted qualifying rounds control how qualifying is run and which rounds count.", @@ -1212,6 +1355,7 @@ const TRANSLATIONS = { "guide.race_format_9": "Min lap time filters out shortcuts and false hits. Example: on a 16-second track you can set 11 seconds as the minimum.", "guide.race_format_10": "Max lap time stops long false laps from counting and is also used to split stints and improve driver statistics. Example: 60 seconds.", "guide.race_format_11": "Preset lets you quickly fill the race format with sensible defaults for a short technical track, club race, IFMAR-like setup or endurance.", + "guide.race_format_11a": "In Basic mode, the endurance preset hides most qualifying and finals fields so the UI stays focused on Team Race. Open Advanced if the event also needs support heats or normal finals.", "guide.race_format_12": "You can apply a preset and then adjust individual fields manually before saving the race format.", "guide.race_format_13": "Save club preset stores your own local race formats so you can reuse them on the same installation without rebuilding everything each time.", "guide.race_format_14": "Club presets can also be exported and imported from Settings if you want to move them between different servers or laptops.", @@ -1329,6 +1473,9 @@ let judgingLogFilter = "all"; let quickAddDraft = null; let driverBrandFilter = ""; let carBrandFilter = ""; +let raceFormatAdvanced = false; +let raceWizardStep = 1; +let raceWizardDraft = null; let overlaySyncTimer = null; let overlayRotationTimer = null; let overlayLiveRefreshTimer = null; @@ -3189,10 +3336,16 @@ function renderRaceSetup() { function renderEventWorkspace(mode) { const isRaceMode = mode === "race"; + if (isRaceMode) { + ensureRaceWizardDraft(); + } const filteredEvents = state.events.filter((event) => event.mode === mode); const classOptions = state.classes .map((c) => ``) .join(""); + const wizardClassOptions = state.classes + .map((c) => ``) + .join(""); const editingEvent = filteredEvents.find((event) => event.id === selectedEventEditId) || null; dom.view.innerHTML = ` @@ -3200,13 +3353,34 @@ function renderEventWorkspace(mode) {

${t(isRaceMode ? "events.create_race" : "events.create")}

${t(isRaceMode ? "events.race_only_intro" : "events.track_only_intro")}

+ ${isRaceMode ? `

${t("events.wizard_hint")}

` : ""}
-
- - - - -
+ ${ + isRaceMode + ? ` +
+ ${renderRaceWizardSteps()} +
+
+ ${renderRaceWizardContent(raceWizardDraft, wizardClassOptions, getDriversForClass(raceWizardDraft.classId), getRaceWizardPreset(raceWizardDraft.presetId))} +
+ + ` + : ` +
+ + + + +
+ ` + }
@@ -3270,20 +3444,182 @@ function renderEventWorkspace(mode) { } `; - document.getElementById("eventForm")?.addEventListener("submit", (e) => { - e.preventDefault(); - const form = new FormData(e.currentTarget); - const event = { - id: uid("event"), - name: String(form.get("name")).trim(), - date: String(form.get("date")), - classId: String(form.get("classId")), - mode, + if (isRaceMode) { + const persistWizardStepOne = () => { + const form = document.getElementById("raceWizardStepForm"); + if (!(form instanceof HTMLFormElement)) { + return true; + } + const data = new FormData(form); + const nextName = String(data.get("name") || "").trim(); + const nextDate = String(data.get("date") || "").trim(); + const nextClassId = String(data.get("classId") || "").trim(); + const nextPresetId = String(data.get("presetId") || "club_qualifying").trim() || "club_qualifying"; + if (!nextName || !nextDate || !nextClassId) { + return false; + } + const classChanged = raceWizardDraft.classId !== nextClassId; + raceWizardDraft.name = nextName; + raceWizardDraft.date = nextDate; + raceWizardDraft.classId = nextClassId; + if (classChanged) { + raceWizardDraft.driverIds = getDriversForClass(nextClassId).map((driver) => driver.id); + } + if (raceWizardDraft.presetId !== nextPresetId) { + applyRaceWizardPresetDefaults(raceWizardDraft, nextPresetId); + } + return true; }; - state.events.push(normalizeEvent(event)); - saveState(); - renderView(); - }); + + const persistWizardParticipants = () => { + raceWizardDraft.driverIds = Array.from(document.querySelectorAll(".wizard-participant:checked")).map((node) => node.value); + return true; + }; + + const persistWizardPlan = () => { + const form = document.getElementById("raceWizardPlanForm"); + if (!(form instanceof HTMLFormElement)) { + return true; + } + const data = new FormData(form); + raceWizardDraft.createPractice = data.get("createPractice") === "on"; + raceWizardDraft.practiceSessions = Math.max(0, Number(data.get("practiceSessions") || 0) || 0); + raceWizardDraft.createQualifying = data.get("createQualifying") === "on"; + raceWizardDraft.qualifyingRounds = Math.max(0, Number(data.get("qualifyingRounds") || 0) || 0); + raceWizardDraft.createTeamRace = data.get("createTeamRace") === "on"; + raceWizardDraft.teamRaceDurationMin = Math.max(1, Number(data.get("teamRaceDurationMin") || 1) || 1); + return raceWizardDraft.createPractice || raceWizardDraft.createQualifying || raceWizardDraft.createTeamRace; + }; + + document.getElementById("raceWizardReset")?.addEventListener("click", () => { + raceWizardDraft = applyRaceWizardPresetDefaults(buildDefaultRaceWizardDraft(), "club_qualifying"); + raceWizardStep = 1; + renderView(); + }); + + document.getElementById("raceWizardPrev")?.addEventListener("click", () => { + if (raceWizardStep === 2) { + persistWizardParticipants(); + } + if (raceWizardStep === 3) { + persistWizardPlan(); + } + raceWizardStep = Math.max(1, raceWizardStep - 1); + renderView(); + }); + + document.getElementById("raceWizardNext")?.addEventListener("click", () => { + let valid = true; + if (raceWizardStep === 1) { + valid = persistWizardStepOne(); + } else if (raceWizardStep === 2) { + valid = persistWizardParticipants(); + } else if (raceWizardStep === 3) { + valid = persistWizardPlan(); + } + if (!valid) { + return; + } + raceWizardStep = Math.min(4, raceWizardStep + 1); + renderView(); + }); + + const wizardBasicsForm = document.getElementById("raceWizardStepForm"); + const syncWizardBasicsDraft = () => { + if (!(wizardBasicsForm instanceof HTMLFormElement)) { + return; + } + const data = new FormData(wizardBasicsForm); + raceWizardDraft.name = String(data.get("name") || "").trim(); + raceWizardDraft.date = String(data.get("date") || raceWizardDraft.date || "").trim(); + }; + + wizardBasicsForm?.querySelector('[name="classId"]')?.addEventListener("change", (event) => { + syncWizardBasicsDraft(); + const nextClassId = String(event.currentTarget?.value || "").trim(); + if (!nextClassId) { + return; + } + const classChanged = raceWizardDraft.classId !== nextClassId; + raceWizardDraft.classId = nextClassId; + if (classChanged) { + raceWizardDraft.driverIds = getDriversForClass(nextClassId).map((driver) => driver.id); + } + renderView(); + }); + + wizardBasicsForm?.querySelector('[name="presetId"]')?.addEventListener("change", (event) => { + syncWizardBasicsDraft(); + const nextPresetId = String(event.currentTarget?.value || "club_qualifying").trim() || "club_qualifying"; + applyRaceWizardPresetDefaults(raceWizardDraft, nextPresetId); + renderView(); + }); + + const wizardPlanForm = document.getElementById("raceWizardPlanForm"); + const toggleWizardPlanField = (toggleName, fieldName) => { + const toggle = wizardPlanForm?.querySelector(`[name="${toggleName}"]`); + const field = wizardPlanForm?.querySelector(`[name="${fieldName}"]`); + if (!(toggle instanceof HTMLInputElement) || !(field instanceof HTMLInputElement)) { + return; + } + const applyDisabledState = () => { + field.disabled = !toggle.checked; + }; + applyDisabledState(); + toggle.addEventListener("change", applyDisabledState); + }; + toggleWizardPlanField("createPractice", "practiceSessions"); + toggleWizardPlanField("createQualifying", "qualifyingRounds"); + toggleWizardPlanField("createTeamRace", "teamRaceDurationMin"); + + document.getElementById("wizardSelectAllParticipants")?.addEventListener("click", () => { + raceWizardDraft.driverIds = getDriversForClass(raceWizardDraft.classId).map((driver) => driver.id); + renderView(); + }); + + document.getElementById("wizardClearParticipants")?.addEventListener("click", () => { + raceWizardDraft.driverIds = []; + renderView(); + }); + + document.getElementById("raceWizardCreate")?.addEventListener("click", () => { + const selectedDrivers = raceWizardDraft.driverIds.length ? raceWizardDraft.driverIds : getDriversForClass(raceWizardDraft.classId).map((driver) => driver.id); + const event = normalizeEvent({ + id: uid("event"), + name: raceWizardDraft.name.trim(), + date: raceWizardDraft.date, + classId: raceWizardDraft.classId, + mode, + }); + applyRaceFormatPreset(event, raceWizardDraft.presetId); + event.raceConfig.driverIds = selectedDrivers; + event.raceConfig.participantsConfigured = true; + state.events.push(event); + buildRaceSessionsFromWizard(event, raceWizardDraft).forEach((session) => state.sessions.push(session)); + selectedTeamEditId = null; + selectedSessionEditId = null; + raceWizardDraft = applyRaceWizardPresetDefaults(buildDefaultRaceWizardDraft(), "club_qualifying"); + raceWizardStep = 1; + saveState(); + renderView(); + renderEventManager(event.id); + }); + } else { + document.getElementById("eventForm")?.addEventListener("submit", (e) => { + e.preventDefault(); + const form = new FormData(e.currentTarget); + const event = { + id: uid("event"), + name: String(form.get("name")).trim(), + date: String(form.get("date")), + classId: String(form.get("classId")), + mode, + }; + state.events.push(normalizeEvent(event)); + saveState(); + renderView(); + }); + } filteredEvents.forEach((e) => { document.getElementById(`event-edit-${e.id}`)?.addEventListener("click", () => { @@ -3399,6 +3735,11 @@ function renderEventManager(eventId) { const editingSession = sessions.find((session) => session.id === selectedSessionEditId) || null; const racePresets = getRaceFormatPresets(); const selectedPreset = racePresets.find((preset) => preset.id === event.raceConfig.presetId) || racePresets[0]; + const isEndurancePreset = event.mode === "race" && selectedPreset?.id === "endurance"; + const showBasicQualifyingFields = raceFormatAdvanced || !isEndurancePreset; + const showBasicFinalFields = raceFormatAdvanced || !isEndurancePreset; + const selectedParticipantCount = event.mode === "race" ? (event.raceConfig.participantsConfigured ? (event.raceConfig.driverIds || []).length : raceDrivers.length) : 0; + const raceSummaryItems = event.mode === "race" ? getRaceSummaryItems(event, sessions, raceDrivers, selectedPreset) : []; const gridSessions = event.mode === "race" ? sessions.filter((session) => normalizeStartMode(session.startMode) === "position") : []; if (selectedGridSessionId && !gridSessions.some((session) => session.id === selectedGridSessionId)) { selectedGridSessionId = ""; @@ -3556,33 +3897,61 @@ function renderEventManager(eventId) { event.mode === "race" ? `
-

${t("events.select_participants")}

-
-
- - -
-
- ${raceDrivers - .map((driver) => { - const checked = event.raceConfig.participantsConfigured - ? (event.raceConfig.driverIds || []).includes(driver.id) - : true; - return ` - - `; - }) - .join("")} -
+
+
+ ${t("events.manage_step_setup")} +

${t("events.manage_step_setup_hint")}

+
+
+ ${t("events.manage_step_format")} +

${t("events.manage_step_format_hint")}

+
+
+ ${t("events.manage_step_generate")} +

${t("events.manage_step_generate_hint")}

+
+
+ ${t("events.manage_step_live")} +

${t("events.manage_step_live_hint")}

+
-
-

${t("events.teams")}

-
+
+
+
+

${t("events.select_participants")}

+ ${selectedParticipantCount} +
+
+
+ + +
+
+ ${raceDrivers + .map((driver) => { + const checked = event.raceConfig.participantsConfigured + ? (event.raceConfig.driverIds || []).includes(driver.id) + : true; + return ` + + `; + }) + .join("")} +
+
+
+ +
+
+

${t("events.teams")}

+ ${raceTeams.length} +
+

${t("events.team_race_intro")}

${t("events.team_steps")}

@@ -3660,171 +4029,259 @@ function renderEventManager(eventId) {
+

${t("events.race_format")}

${t("events.race_format_intro")}

- - ${renderRaceFormatField( - "events.race_preset", - "events.race_preset_hint", - `` - )} -
- ${t("events.preset_name")} - -
- - - +
+
+
+
+ ${t("events.race_format")} +
${t("events.race_actions_hint")}
+
+
+ + +
+ +
+

${t("events.practice_block")}

+

${t("events.practice_block_hint")}

+
+ ${renderRaceFormatContextCard(isEndurancePreset ? "events.context_endurance_title" : "events.context_standard_title", isEndurancePreset ? "events.context_endurance_hint" : "events.context_standard_hint")} + ${renderRaceFormatField( + "events.race_preset", + "events.race_preset_hint", + `` + )} + ${raceFormatAdvanced + ? ` +
+ ${t("events.preset_name")} + ${t("events.race_preset_hint")} + +
+ + + +
+
+ ` + : ` +
+ ${t("events.race_preset")} + ${t("events.race_preset_hint")} +
+ +
+
+ `} + ${showBasicQualifyingFields ? renderRaceFormatField( + "events.qualifying_scoring", + "events.qualifying_scoring_hint", + `` + ) : ""} + ${showBasicQualifyingFields ? renderRaceFormatField( + "events.qualifying_rounds", + "events.qualifying_rounds_hint", + `` + ) : ""} + ${showBasicQualifyingFields ? renderRaceFormatField( + "events.cars_per_heat", + "events.cars_per_heat_hint", + `` + ) : ""} + ${showBasicQualifyingFields ? renderRaceFormatField( + "events.qual_duration", + "events.qual_duration_hint", + `` + ) : ""} + ${showBasicQualifyingFields ? renderRaceFormatField( + "events.qual_start_mode", + "events.qual_start_mode_hint", + `` + ) : ""} + ${raceFormatAdvanced + ? renderRaceFormatField( + "events.qual_seed_laps", + "events.qual_seed_laps_hint", + `` + ) + : ""} + ${raceFormatAdvanced + ? renderRaceFormatField( + "events.qual_seed_method", + "events.qual_seed_method_hint", + `` + ) + : ""} + ${raceFormatAdvanced + ? renderRaceFormatField( + "events.counted_qual_rounds", + "events.counted_qual_rounds_hint", + `` + ) + : ""} + ${raceFormatAdvanced + ? renderRaceFormatField( + "events.qual_points_table", + "events.qual_points_table_hint", + `` + ) + : ""} + ${raceFormatAdvanced + ? renderRaceFormatField( + "events.qual_tie_break", + "events.qual_tie_break_hint", + `` + ) + : ""} +
+
+
+

${t("events.finals_block")}

+

${t("events.finals_block_hint")}

+
+ ${renderRaceFormatContextCard(isEndurancePreset ? "events.context_endurance_title" : "events.context_standard_title", isEndurancePreset ? "events.context_endurance_hint" : "events.context_standard_hint")} + ${showBasicFinalFields ? renderRaceFormatField( + "events.cars_per_final", + "events.cars_per_final_hint", + `` + ) : ""} + ${showBasicFinalFields ? renderRaceFormatField( + "events.final_legs", + "events.final_legs_hint", + `` + ) : ""} + ${raceFormatAdvanced + ? renderRaceFormatField( + "events.counted_final_legs", + "events.counted_final_legs_hint", + `` + ) + : ""} + ${showBasicFinalFields ? renderRaceFormatField( + "events.final_duration", + "events.final_duration_hint", + `` + ) : ""} + ${showBasicFinalFields ? renderRaceFormatField( + "events.final_start_mode", + "events.final_start_mode_hint", + `` + ) : ""} + ${showBasicFinalFields ? renderRaceFormatField( + "events.bump_count", + "events.bump_count_hint", + `` + ) : ""} + ${showBasicFinalFields ? renderRaceFormatField( + "events.source_for_finals", + "events.finals_source_hint", + `` + ) : ""} + ${raceFormatAdvanced + ? renderRaceFormatField( + "events.reserve_bump_slots", + "events.reserve_bump_slots_hint", + ``, + { checkbox: true } + ) + : ""} +
+
+
+

${t("events.rules_block")}

+

${t("events.rules_block_hint")}

+
+ ${renderRaceFormatContextCard("events.context_rules_title", "events.context_rules_hint")} + ${renderRaceFormatField( + "events.follow_up_sec", + "events.follow_up_sec_hint", + `` + )} + ${renderRaceFormatField( + "events.min_lap_time", + "events.min_lap_time_hint", + `` + )} + ${renderRaceFormatField( + "events.max_lap_time", + "events.max_lap_time_hint", + `` + )} +
+ ${t("events.actions")} + ${t("events.race_driver_scope")} + ${t("events.bump_reserved_note")} +
+
+
+
+ +
+
- ${renderRaceFormatField( - "events.qualifying_scoring", - "events.qualifying_scoring_hint", - `` - )} - ${renderRaceFormatField( - "events.qualifying_rounds", - "events.qualifying_rounds_hint", - `` - )} - ${renderRaceFormatField( - "events.cars_per_heat", - "events.cars_per_heat_hint", - `` - )} - ${renderRaceFormatField( - "events.qual_duration", - "events.qual_duration_hint", - `` - )} - ${renderRaceFormatField( - "events.qual_start_mode", - "events.qual_start_mode_hint", - `` - )} - ${renderRaceFormatField( - "events.qual_seed_laps", - "events.qual_seed_laps_hint", - `` - )} - ${renderRaceFormatField( - "events.qual_seed_method", - "events.qual_seed_method_hint", - `` - )} - ${renderRaceFormatField( - "events.counted_qual_rounds", - "events.counted_qual_rounds_hint", - `` - )} - ${renderRaceFormatField( - "events.qual_points_table", - "events.qual_points_table_hint", - `` - )} - ${renderRaceFormatField( - "events.qual_tie_break", - "events.qual_tie_break_hint", - `` - )} - ${renderRaceFormatField( - "events.cars_per_final", - "events.cars_per_final_hint", - `` - )} - ${renderRaceFormatField( - "events.final_legs", - "events.final_legs_hint", - `` - )} - ${renderRaceFormatField( - "events.counted_final_legs", - "events.counted_final_legs_hint", - `` - )} - ${renderRaceFormatField( - "events.final_duration", - "events.final_duration_hint", - `` - )} - ${renderRaceFormatField( - "events.final_start_mode", - "events.final_start_mode_hint", - `` - )} - ${renderRaceFormatField( - "events.follow_up_sec", - "events.follow_up_sec_hint", - `` - )} - ${renderRaceFormatField( - "events.min_lap_time", - "events.min_lap_time_hint", - `` - )} - ${renderRaceFormatField( - "events.max_lap_time", - "events.max_lap_time_hint", - `` - )} - ${renderRaceFormatField( - "events.bump_count", - "events.bump_count_hint", - `` - )} - ${renderRaceFormatField( - "events.reserve_bump_slots", - "events.reserve_bump_slots_hint", - ``, - { checkbox: true } - )} - ${renderRaceFormatField( - "events.source_for_finals", - "events.finals_source_hint", - `` - )} - - -
-

${t("events.race_driver_scope")}

-

${t("events.bump_reserved_note")}

-
+ +
+
+

${t("events.race_actions_title")}

+

${t("events.race_actions_hint")}

+
@@ -4351,6 +4808,16 @@ function renderEventManager(eventId) { renderEventManager(eventId); }); + document.getElementById("raceFormatBasicToggle")?.addEventListener("click", () => { + raceFormatAdvanced = false; + renderEventManager(eventId); + }); + + document.getElementById("raceFormatAdvancedToggle")?.addEventListener("click", () => { + raceFormatAdvanced = true; + renderEventManager(eventId); + }); + document.getElementById("raceFormatForm")?.addEventListener("submit", (e) => { e.preventDefault(); const form = new FormData(e.currentTarget); @@ -5272,186 +5739,83 @@ function renderQuickAddPanel(session) { `; } +function renderGuidePanel(titleKey, itemKeys, extras = "") { + return ` +
+

${t(titleKey)}

+
+
    + ${itemKeys.map((key) => `
  • ${t(key)}
  • `).join("")} +
+ ${extras} +
+
+ `; +} + +function renderGuideOverviewCard(titleKey, blurbKey) { + return ` +
+ ${t(titleKey)} +

${t(blurbKey)}

+
+ `; +} + function renderGuide() { dom.view.innerHTML = `

${t("guide.title")}

${t("guide.intro")}

+
+ ${renderGuideOverviewCard("guide.sponsor_title", "guide.card_sponsor_blurb")} + ${renderGuideOverviewCard("guide.race_title", "guide.card_race_blurb")} + ${renderGuideOverviewCard("guide.team_title", "guide.card_team_blurb")} + ${renderGuideOverviewCard("guide.windows_title", "guide.card_decoder_blurb")} +
-
-

${t("guide.sponsor_title")}

-
-
    -
  • ${t("guide.sponsor_1")}
  • -
  • ${t("guide.sponsor_2")}
  • -
  • ${t("guide.sponsor_3")}
  • -
  • ${t("guide.sponsor_4")}
  • -
  • ${t("guide.sponsor_5")}
  • -
  • ${t("guide.sponsor_6")}
  • -
-
-
+
+ ${renderGuidePanel("guide.sponsor_title", ["guide.sponsor_1", "guide.sponsor_2", "guide.sponsor_3", "guide.sponsor_4", "guide.sponsor_5", "guide.sponsor_6"])} + ${renderGuidePanel("guide.race_wizard_title", ["guide.race_wizard_1", "guide.race_wizard_2", "guide.race_wizard_3", "guide.race_wizard_4", "guide.race_wizard_5", "guide.race_wizard_6"])} +
-
-

${t("guide.race_title")}

-
-
    -
  • ${t("guide.race_1")}
  • -
  • ${t("guide.race_2")}
  • -
  • ${t("guide.race_3")}
  • -
  • ${t("guide.race_4")}
  • -
  • ${t("guide.race_5")}
  • -
  • ${t("guide.race_6")}
  • -
  • ${t("guide.race_7")}
  • -
  • ${t("guide.race_8")}
  • -
  • ${t("guide.race_9")}
  • -
  • ${t("guide.race_10")}
  • -
-
-
+
+ ${renderGuidePanel("guide.race_title", ["guide.race_1", "guide.race_2", "guide.race_3", "guide.race_4", "guide.race_4a", "guide.race_5", "guide.race_6", "guide.race_7", "guide.race_8", "guide.race_9", "guide.race_10"])} + ${renderGuidePanel("guide.manage_steps_title", ["guide.manage_steps_1", "guide.manage_steps_2", "guide.manage_steps_3", "guide.manage_steps_4", "guide.manage_steps_5"])} +
-
-

${t("guide.race_format_title")}

-
-
    -
  • ${t("guide.race_format_1")}
  • -
  • ${t("guide.race_format_2")}
  • -
  • ${t("guide.race_format_3")}
  • -
  • ${t("guide.race_format_4")}
  • -
  • ${t("guide.race_format_5")}
  • -
  • ${t("guide.race_format_6")}
  • -
  • ${t("guide.race_format_7")}
  • -
  • ${t("guide.race_format_8")}
  • -
  • ${t("guide.race_format_9")}
  • -
  • ${t("guide.race_format_10")}
  • -
  • ${t("guide.race_format_11")}
  • -
  • ${t("guide.race_format_12")}
  • -
  • ${t("guide.race_format_13")}
  • -
  • ${t("guide.race_format_14")}
  • -
-
-
+
+ ${renderGuidePanel("guide.race_format_title", ["guide.race_format_0", "guide.race_format_1", "guide.race_format_2", "guide.race_format_3", "guide.race_format_4", "guide.race_format_5", "guide.race_format_6", "guide.race_format_7", "guide.race_format_8", "guide.race_format_9", "guide.race_format_10", "guide.race_format_11", "guide.race_format_11a", "guide.race_format_12", "guide.race_format_13", "guide.race_format_14"])} + ${renderGuidePanel("guide.validation_title", ["guide.validation_1", "guide.validation_2", "guide.validation_3", "guide.validation_4", "guide.validation_5", "guide.validation_6", "guide.validation_7", "guide.validation_8"])} +
-
-

${t("guide.free_practice_title")}

-
-
    -
  • ${t("guide.free_practice_1")}
  • -
  • ${t("guide.free_practice_2")}
  • -
  • ${t("guide.free_practice_3")}
  • -
-
-
+
+ ${renderGuidePanel("guide.free_practice_title", ["guide.free_practice_1", "guide.free_practice_2", "guide.free_practice_3"])} + ${renderGuidePanel("guide.open_practice_title", ["guide.open_practice_1", "guide.open_practice_2", "guide.open_practice_3"])} +
-
-

${t("guide.open_practice_title")}

-
-
    -
  • ${t("guide.open_practice_1")}
  • -
  • ${t("guide.open_practice_2")}
  • -
  • ${t("guide.open_practice_3")}
  • -
-
-
+
+ ${renderGuidePanel("guide.team_title", ["guide.team_1", "guide.team_2", "guide.team_3", "guide.team_4", "guide.team_5", "guide.team_6"])} + ${renderGuidePanel("guide.qualifying_title", ["guide.qualifying_1", "guide.qualifying_2", "guide.qualifying_3", "guide.qualifying_4", "guide.qualifying_5"])} +
-
-

${t("guide.team_title")}

-
-
    -
  • ${t("guide.team_1")}
  • -
  • ${t("guide.team_2")}
  • -
  • ${t("guide.team_3")}
  • -
  • ${t("guide.team_4")}
  • -
  • ${t("guide.team_5")}
  • -
  • ${t("guide.team_6")}
  • -
-
-
+
+ ${renderGuidePanel("guide.dashboard_title", ["guide.dashboard_1", "guide.dashboard_2", "guide.dashboard_3"])} + ${renderGuidePanel("guide.host_title", ["guide.host_1", "guide.host_2", "guide.host_3", "guide.host_4", "guide.host_5"])} +
-
-

${t("guide.validation_title")}

-
-
    -
  • ${t("guide.validation_1")}
  • -
  • ${t("guide.validation_2")}
  • -
  • ${t("guide.validation_3")}
  • -
  • ${t("guide.validation_4")}
  • -
  • ${t("guide.validation_5")}
  • -
  • ${t("guide.validation_6")}
  • -
  • ${t("guide.validation_7")}
  • -
  • ${t("guide.validation_8")}
  • -
-
-
- -
-

${t("guide.qualifying_title")}

-
-
    -
  • ${t("guide.qualifying_1")}
  • -
  • ${t("guide.qualifying_2")}
  • -
  • ${t("guide.qualifying_3")}
  • -
  • ${t("guide.qualifying_4")}
  • -
  • ${t("guide.qualifying_5")}
  • -
-
-
- -
-

${t("guide.dashboard_title")}

-
-
    -
  • ${t("guide.dashboard_1")}
  • -
  • ${t("guide.dashboard_2")}
  • -
  • ${t("guide.dashboard_3")}
  • -
-
-
- -
-

${t("guide.host_title")}

-
-
    -
  • ${t("guide.host_1")}
  • -
  • ${t("guide.host_2")}
  • -
  • ${t("guide.host_3")}
  • -
  • ${t("guide.host_4")}
  • -
  • ${t("guide.host_5")}
  • -
-
-
- -
-

${t("guide.windows_title")}

-
-
    -
  • ${t("guide.windows_1")}
  • -
  • ${t("guide.windows_2")}
  • -
  • ${t("guide.windows_3")}
  • -
  • ${t("guide.windows_4")}
  • -
  • ${t("guide.windows_5")}
  • -
-
-
- -
-

${t("guide.linux_title")}

-
-
    -
  • ${t("guide.linux_1")}
  • -
  • ${t("guide.linux_2")}
  • -
  • ${t("guide.linux_3")}
  • -
-
-
+
+ ${renderGuidePanel("guide.windows_title", ["guide.windows_1", "guide.windows_2", "guide.windows_3", "guide.windows_4", "guide.windows_5"])} + ${renderGuidePanel("guide.linux_title", ["guide.linux_1", "guide.linux_2", "guide.linux_3"])} +

${t("guide.sqlite_title")}

-
    +
    • ${t("guide.sqlite_1")}
    • ${t("guide.sqlite_2")}
    @@ -7330,6 +7694,243 @@ function renderRaceFormatField(labelKey, hintKey, controlHtml, options = {}) { `; } +function renderRaceFormatContextCard(titleKey, hintKey) { + return ` +
    + ${t(titleKey)} + ${t(hintKey)} +
    + `; +} + +function getRaceSummaryItems(event, sessions, raceDrivers, selectedPreset) { + const cfg = event.raceConfig || {}; + const participantCount = cfg.participantsConfigured ? (cfg.driverIds || []).length : raceDrivers.length; + const practiceCount = sessions.filter((session) => ["practice", "free_practice", "open_practice"].includes(session.type)).length; + const qualCount = sessions.filter((session) => session.type === "qualification").length; + const finalCount = sessions.filter((session) => session.type === "final").length; + const teamCount = sessions.filter((session) => session.type === "team_race").length; + const scoringLabel = cfg.qualifyingScoring === "best" ? t("events.qualifying_scoring_best") : t("events.qualifying_scoring_points"); + const lapWindow = `${((cfg.minLapMs || 0) / 1000).toFixed(1)}s / ${((cfg.maxLapMs || 0) / 1000).toFixed(1)}s`; + return [ + { label: t("events.race_summary_preset"), value: selectedPreset?.label || t("events.preset_custom") }, + { label: t("events.race_summary_participants"), value: String(participantCount || 0) }, + { label: t("events.race_summary_created_sessions"), value: `P ${practiceCount} · Q ${qualCount} · F ${finalCount} · T ${teamCount}` }, + { label: t("events.race_summary_qualifying"), value: `${cfg.qualifyingRounds || 0} x ${cfg.qualDurationMin || 0} min · ${cfg.carsPerHeat || 0}/heat · ${scoringLabel}` }, + { label: t("events.race_summary_finals"), value: `${cfg.carsPerFinal || 0}/main · ${cfg.finalLegs || 0} leg · ${getStartModeLabel(cfg.finalStartMode || "position")}` }, + { label: t("events.race_summary_validation"), value: lapWindow }, + { label: t("events.race_summary_follow_up"), value: `${cfg.followUpSec || 0}s` }, + ]; +} + +function getDriversForClass(classId) { + return state.drivers.filter((driver) => !classId || driver.classId === classId); +} + +function buildDefaultRaceWizardDraft() { + const classId = state.classes[0]?.id || ""; + const presetId = "club_qualifying"; + const classDrivers = getDriversForClass(classId); + return { + name: "", + date: new Date().toISOString().slice(0, 10), + classId, + presetId, + driverIds: classDrivers.map((driver) => driver.id), + createPractice: true, + practiceSessions: 1, + createQualifying: true, + qualifyingRounds: 4, + createTeamRace: false, + teamRaceDurationMin: 240, + }; +} + +function getRaceWizardPreset(presetId) { + return getRaceFormatPresets().find((preset) => preset.id === presetId) || getRaceFormatPresets()[0]; +} + +function applyRaceWizardPresetDefaults(draft, presetId) { + const preset = getRaceWizardPreset(presetId); + const endurance = preset.id === "endurance"; + draft.presetId = preset.id; + draft.createPractice = !endurance; + draft.practiceSessions = endurance ? 0 : Math.max(1, Number(draft.practiceSessions || 1)); + draft.createQualifying = !endurance; + draft.qualifyingRounds = Math.max(1, Number(preset.values?.qualifyingRounds || draft.qualifyingRounds || 3)); + draft.createTeamRace = endurance; + draft.teamRaceDurationMin = Math.max(1, Number(preset.values?.finalDurationMin || draft.teamRaceDurationMin || 240)); + return draft; +} + +function ensureRaceWizardDraft() { + if (!raceWizardDraft) { + raceWizardDraft = applyRaceWizardPresetDefaults(buildDefaultRaceWizardDraft(), "club_qualifying"); + } + if (!state.classes.some((item) => item.id === raceWizardDraft.classId)) { + raceWizardDraft.classId = state.classes[0]?.id || ""; + raceWizardDraft.driverIds = getDriversForClass(raceWizardDraft.classId).map((driver) => driver.id); + } + if (!raceWizardDraft.date) { + raceWizardDraft.date = new Date().toISOString().slice(0, 10); + } +} + +function buildRaceSession(eventId, name, type, durationMin, overrides = {}) { + return normalizeSession({ + id: uid("session"), + eventId, + name, + type, + durationMin, + maxCars: null, + mode: "race", + status: "ready", + startedAt: null, + endedAt: null, + finishedByTimer: false, + assignments: [], + driverIds: overrides.driverIds || [], + startMode: overrides.startMode || "mass", + seedBestLapCount: overrides.seedBestLapCount || 0, + seedMethod: overrides.seedMethod || "best_sum", + followUpSec: overrides.followUpSec || 0, + }); +} + +function getRaceWizardSessionPlan(draft) { + const plan = []; + if (draft.createPractice) { + plan.push(`${Math.max(1, Number(draft.practiceSessions || 1))} x ${t("session.practice")}`); + } + if (draft.createQualifying) { + plan.push(`${Math.max(1, Number(draft.qualifyingRounds || 1))} x ${t("session.qualification")}`); + } + if (draft.createTeamRace) { + plan.push(`${t("events.team_race")} ${Math.max(1, Number(draft.teamRaceDurationMin || 1))} min`); + } + return plan; +} + +function renderRaceWizardSteps() { + const steps = [t("events.wizard_step_1"), t("events.wizard_step_2"), t("events.wizard_step_3"), t("events.wizard_step_4")]; + return steps + .map( + (label, index) => ` +
    + ${index + 1} + ${escapeHtml(label)} +
    + ` + ) + .join(""); +} + +function renderRaceWizardContent(draft, classOptions, wizardDrivers, preset) { + if (raceWizardStep === 1) { + return ` +
    + + + + +
    + `; + } + if (raceWizardStep === 2) { + return ` +
    + + +
    + ${wizardDrivers.length ? ` +
    + ${wizardDrivers + .map( + (driver) => ` + + ` + ) + .join("")} +
    + ` : `

    ${t("events.wizard_no_class_drivers")}

    `} + `; + } + if (raceWizardStep === 3) { + const isEndurance = preset.id === "endurance"; + return ` +
    + + + ${!isEndurance ? ` + + + ` : ` + + + `} +
    +

    ${t("events.wizard_finals_note")}

    + `; + } + const selectedClassName = getClassName(draft.classId); + const selectedDrivers = draft.driverIds.length ? draft.driverIds.length : wizardDrivers.length; + return ` +
    +
    + ${t("events.wizard_summary_title")} + ${escapeHtml(draft.name || "-")} +
    +
    + ${t("table.class")} + ${escapeHtml(selectedClassName || "-")} +
    +
    + ${t("events.race_summary_preset")} + ${escapeHtml(preset?.label || t("events.preset_custom"))} +
    +
    + ${t("events.race_summary_participants")} + ${selectedDrivers} +
    +
    + ${t("events.wizard_summary_sessions")} + ${escapeHtml(getRaceWizardSessionPlan(draft).join(" • ") || "-")} +
    +
    + `; +} + function formatLap(ms) { if (!ms && ms !== 0) { return "-"; @@ -9094,6 +9695,47 @@ function buildTrackSession(eventId, name, type, durationMin) { }); } +function buildRaceSessionsFromWizard(event, draft) { + const driverIds = event.raceConfig.driverIds || []; + const created = []; + const practiceCount = Math.max(0, Number(draft.practiceSessions || 0) || 0); + const qualRounds = Math.max(0, Number(draft.qualifyingRounds || 0) || 0); + if (draft.createPractice && practiceCount > 0) { + for (let index = 1; index <= practiceCount; index += 1) { + created.push( + buildRaceSession(event.id, `${t("session.practice")} ${index}`, "practice", event.raceConfig.qualDurationMin, { + driverIds, + startMode: "mass", + followUpSec: 0, + }) + ); + } + } + if (draft.createQualifying && qualRounds > 0) { + for (let index = 1; index <= qualRounds; index += 1) { + created.push( + buildRaceSession(event.id, `${t("session.qualification")} ${index}`, "qualification", event.raceConfig.qualDurationMin, { + driverIds, + startMode: event.raceConfig.qualStartMode, + seedBestLapCount: event.raceConfig.qualSeedLapCount, + seedMethod: event.raceConfig.qualSeedMethod, + followUpSec: event.raceConfig.followUpSec, + }) + ); + } + } + if (draft.createTeamRace) { + created.push( + buildRaceSession(event.id, t("events.team_race"), "team_race", Math.max(1, Number(draft.teamRaceDurationMin || event.raceConfig.finalDurationMin || 1)), { + driverIds, + startMode: "mass", + followUpSec: event.raceConfig.followUpSec, + }) + ); + } + return created; +} + function getSelectedAssignmentSessionId() { const form = document.getElementById("assignForm"); if (!(form instanceof HTMLFormElement)) { diff --git a/src/styles.css b/src/styles.css index 61c5c01..d083b46 100644 --- a/src/styles.css +++ b/src/styles.css @@ -252,6 +252,10 @@ body { align-items: center; } +.panel-header-with-pill { + gap: 10px; +} + .panel-header h3 { margin: 0; font-size: 1rem; @@ -346,6 +350,217 @@ body { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.btn.is-active { + border-color: #d30702; + box-shadow: inset 0 0 0 1px rgba(242, 13, 7, 0.25); +} + +.race-wizard-steps, +.manage-step-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.wizard-step, +.manage-step-card { + padding: 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.025); + display: grid; + gap: 6px; +} + +.wizard-step span { + width: 26px; + height: 26px; + border-radius: 999px; + display: grid; + place-items: center; + border: 1px solid var(--line); + font-weight: 800; +} + +.wizard-step strong, +.manage-step-card strong { + font-size: 0.92rem; +} + +.wizard-step-active { + border-color: rgba(225, 6, 0, 0.55); + background: rgba(225, 6, 0, 0.08); +} + +.wizard-step-complete span, +.wizard-step-active span { + border-color: #d30702; + background: rgba(225, 6, 0, 0.16); +} + +.manage-step-card p { + margin: 0; + color: var(--muted); + line-height: 1.45; +} + +.race-wizard-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; +} + +.wizard-summary-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.wizard-summary-item-wide { + grid-column: span 2; +} + +.race-stage-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); + gap: 14px; +} + +.race-setup-shell { + display: grid; + grid-template-columns: minmax(0, 1.75fr) minmax(260px, 0.95fr); + gap: 14px; + align-items: start; +} + +.race-setup-main, +.race-format-sections, +.race-summary-list { + display: grid; + gap: 12px; +} + +.race-setup-modebar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.race-setup-toggle { + gap: 8px; +} + +.race-format-section, +.race-summary-card, +.race-actions-panel { + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.025); + overflow: hidden; +} + +.race-format-section .panel-header, +.race-summary-card .panel-header, +.race-actions-panel .panel-header { + background: rgba(255, 255, 255, 0.02); +} + +.race-format-grid { + gap: 10px; +} + +.race-preset-actions-card, +.race-format-note-card, +.race-format-context-card { + align-content: start; +} + +.race-format-context-card { + grid-column: span 2; + border-color: rgba(225, 6, 0, 0.22); + background: rgba(225, 6, 0, 0.06); +} + +.race-preset-actions-card-compact .actions-inline { + justify-content: flex-start; +} + +.race-format-save-row { + display: flex; + justify-content: flex-end; +} + +.race-summary-item { + display: grid; + gap: 4px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); +} + +.race-summary-item span { + color: var(--muted); + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.race-summary-item strong { + font-size: 0.92rem; + line-height: 1.35; +} + +.panel-header-inline { + padding: 0; + border: 0; + background: transparent; +} + +.guide-overview-grid, +.guide-section-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.guide-overview-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.guide-overview-card { + padding: 14px; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.03); + display: grid; + gap: 8px; +} + +.guide-overview-card strong { + font-size: 0.95rem; +} + +.guide-overview-card p { + margin: 0; + color: var(--muted); + line-height: 1.5; +} + +.guide-list { + margin: 0; + padding-left: 18px; + display: grid; + gap: 8px; +} + +.guide-list li { + line-height: 1.5; +} + input, select { width: 100%; @@ -1424,6 +1639,13 @@ select:focus { grid-template-columns: 1fr; } + .race-setup-modebar, + .race-format-save-row, + .race-wizard-footer { + align-items: stretch; + flex-direction: column; + } + .topbar { flex-direction: column; align-items: flex-start;