Files
Live_RC/src/app.js
2026-03-15 15:23:41 +01:00

7605 lines
298 KiB
JavaScript

const NAV_ITEMS = [
{ id: "dashboard", titleKey: "nav.dashboard", subtitleKey: "nav.dashboard_sub" },
{ id: "events", titleKey: "nav.events", subtitleKey: "nav.events_sub" },
{ id: "race_setup", titleKey: "nav.race_setup", subtitleKey: "nav.race_setup_sub" },
{ id: "overlay", titleKey: "nav.overlay", subtitleKey: "nav.overlay_sub" },
{ id: "classes", titleKey: "nav.classes", subtitleKey: "nav.classes_sub" },
{ id: "drivers", titleKey: "nav.drivers", subtitleKey: "nav.drivers_sub" },
{ id: "cars", titleKey: "nav.cars", subtitleKey: "nav.cars_sub" },
{ id: "timing", titleKey: "nav.timing", subtitleKey: "nav.timing_sub" },
{ id: "settings", titleKey: "nav.settings", subtitleKey: "nav.settings_sub" },
{ id: "guide", titleKey: "nav.guide", subtitleKey: "nav.guide_sub" },
];
const SESSION_TYPES = ["open_practice", "free_practice", "practice", "qualification", "heat", "final", "team_race"];
const STORAGE_KEY = "rc_timing_control_v1";
const DEFAULT_LANGUAGE = "sv";
const TRANSLATIONS = {
sv: {
"nav.dashboard": "Översikt",
"nav.dashboard_sub": "Status och liveinformation",
"nav.events": "Event",
"nav.events_sub": "Sponsor-event och delade bilar",
"nav.race_setup": "Race Setup",
"nav.race_setup_sub": "Tävlingsrace och heatupplägg",
"nav.overlay": "Overlay",
"nav.overlay_sub": "Extern leaderboard-skärm",
"nav.classes": "Klasser",
"nav.classes_sub": "Hantera tävlingsklasser",
"nav.drivers": "Förare",
"nav.drivers_sub": "Förare och personliga transpondrar",
"nav.cars": "Bilar",
"nav.cars_sub": "Bilar med fasta transpondrar",
"nav.timing": "Tidtagning",
"nav.timing_sub": "Live timing-board",
"nav.settings": "Inställningar",
"nav.settings_sub": "Decoder, backend och lagring",
"nav.guide": "Guide",
"nav.guide_sub": "Dokumentation och uppstart",
"ui.language": "Språk",
"brand.title": "JMK RB",
"brand.subtitle": "Live Event",
"ui.no_active_session": "Ingen aktiv session",
"ui.event": "Event",
"ui.decoder_online": "Decoder online",
"ui.decoder_offline": "Decoder offline",
"mode.track": "Sponsor Event",
"mode.race": "Race",
"dashboard.events": "Event",
"dashboard.drivers": "Förare",
"dashboard.cars": "Bilar",
"dashboard.passings": "Passeringar",
"dashboard.created": "Skapade",
"dashboard.registered": "Registrerade",
"dashboard.track_fleet": "Banans bilar",
"dashboard.captured": "Mottagna",
"dashboard.live_session": "Live-session",
"dashboard.idle": "inaktiv",
"dashboard.duration": "Tid",
"dashboard.no_session": "Ingen session är aktiv. Gå till Event eller Tidtagning för att starta.",
"dashboard.quick_actions": "Snabbval",
"dashboard.create_event": "Skapa Event",
"dashboard.open_timing": "Öppna Tidtagning",
"dashboard.connect_decoder": "Anslut Decoder",
"dashboard.recent_sessions": "Senaste sessioner",
"dashboard.free_practice": "Fri träning",
"dashboard.open_practice": "Open Practice",
"dashboard.live_board": "Live Board",
"dashboard.decoder_feed": "Decoder-feed",
"dashboard.backend_link": "Backend-länk",
"dashboard.audio_profile": "Ljudprofil",
"dashboard.live_note": "Snabb driftpanel för anslutning, overlay och ljud. Djupare konfig ligger kvar under Inställningar.",
"session.none_yet": "Inga sessioner ännu.",
"classes.create": "Skapa klass",
"classes.placeholder": "Klassnamn (t.ex. 2WD Buggy)",
"classes.add": "Lägg till klass",
"classes.title": "Klasser",
"drivers.create": "Skapa förare",
"drivers.name_placeholder": "Förarnamn",
"drivers.transponder_placeholder": "Personlig transponder (valfritt)",
"drivers.add": "Lägg till förare",
"drivers.title": "Förare",
"cars.create": "Skapa bil",
"cars.name_placeholder": "Bilnamn eller nummer",
"cars.transponder_placeholder": "Bilens transponder",
"cars.add": "Lägg till bil",
"cars.title": "Bilar",
"events.create": "Skapa event",
"events.create_race": "Skapa race",
"events.name_placeholder": "Eventnamn",
"events.add": "Lägg till event",
"events.add_race": "Lägg till race",
"events.mode_race_option": "Race (förare med egen transponder)",
"events.mode_track_option": "Sponsor Event (delade bilar)",
"events.title": "Event",
"events.race_title": "Race",
"events.track_only_intro": "Här skapar du sponsor-event med delade bilar/transpondrar.",
"events.race_only_intro": "Här skapar du riktiga race med personlig transponder per förare.",
"events.manage": "Hantera",
"events.edit": "Redigera",
"events.sessions": "Sessioner",
"events.participants": "Deltagare",
"events.select_participants": "Välj racedeltagare",
"events.select_all_participants": "Markera alla",
"events.clear_participants": "Rensa deltagare",
"events.reseed_qualifying": "Reseeda kommande kval",
"events.reseed_done": "Kommande kval heat reseedade från aktuell ranking.",
"events.no_reseed_done": "Inga kommande kval kunde reseedas.",
"events.reseed_locked": "{count} heat hoppades över eftersom manuell grid är låst.",
"events.reserve_bump_slots": "Reservera bump-platser i finaler",
"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.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",
"events.brand_tagline": "Brandtext",
"events.brand_footer": "PDF-footer",
"events.brand_theme": "PDF-tema",
"events.brand_logo": "Eventlogo",
"events.branding_use_global": "Använd globalt standardtema",
"events.branding_save": "Spara branding",
"events.session_name": "Sessionsnamn",
"events.duration_placeholder": "Längd (min)",
"events.max_cars_placeholder": "Max bilar (valfritt)",
"events.start_mode": "Starttyp",
"events.seed_best_laps": "Seedning bästa varv",
"events.stagger_gap_sec": "Stagger-gap (sek)",
"events.session_settings": "Sessioninställningar",
"events.edit_session": "Inställningar",
"events.start_mode_mass": "Mass-start",
"events.start_mode_position": "Positionsstart",
"events.start_mode_staggered": "Staggered / individuell start",
"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.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",
"events.qualifying_scoring_best": "Bästa runda / rank",
"events.qualifying_rounds": "Antal kvalrundor",
"events.qualifying_rounds_hint": "Hur många kvalomgångar som ska skapas totalt.",
"events.cars_per_heat": "Förare per kvalheat",
"events.cars_per_heat_hint": "Hur många förare som placeras i varje kvalheat.",
"events.qual_duration": "Kvaltid (min)",
"events.qual_duration_hint": "Längd per kvalheat i minuter.",
"events.qual_start_mode": "Kval-start",
"events.qual_start_mode_hint": "Mass, position eller staggered för kvalomgångarna.",
"events.counted_qual_rounds": "Räknade kvalrundor",
"events.counted_qual_rounds_hint": "Hur många av kvalrundorna som räknas i slutrankingen.",
"events.cars_per_final": "Förare per final",
"events.cars_per_final_hint": "Max antal förare i varje A/B/C-final.",
"events.final_legs": "Final-heat per final",
"events.final_legs_hint": "Hur många finalheat som ska skapas per main.",
"events.counted_final_legs": "Räknade finalheat",
"events.counted_final_legs_hint": "Hur många finalheat som ska räknas i sammanlagd finalranking.",
"events.final_duration": "Finaltid (min)",
"events.final_duration_hint": "Längd per finalheat i minuter.",
"events.final_start_mode": "Final-start",
"events.final_start_mode_hint": "Starttyp för finaler, ofta positionsstart.",
"events.bump_count": "Bump-up per final",
"events.bump_count_hint": "Antal förare som kan flyttas upp från lägre final till nästa main.",
"events.save_race_format": "Spara raceformat",
"events.open_grid": "Grid",
"events.grid_editor": "Grid-editor",
"events.grid_editor_hint": "Dra förare upp eller ner för att ändra startordningen manuellt för positionsstart.",
"events.grid_reset": "Återställ från deltagarlista",
"events.grid_lock": "Lås grid",
"events.grid_unlock": "Lås upp grid",
"events.grid_locked": "Manuell grid är låst mot auto-reseed.",
"events.grid_unlocked": "Grid följer auto-seed/reseed tills du låser den.",
"events.grid_empty": "Ingen grid att redigera ännu.",
"events.print_heat_sheet": "Skriv ut heatsheet",
"events.export_heat_sheet": "Exportera heatsheet",
"events.pdf_heat_sheet": "PDF heatsheet",
"events.free_practice_note": "Free Practice visar löpande varvtider och används inte för seedning.",
"events.open_practice_note": "Open Practice visar alla inkommande transpondrar löpande. Om ingen förare matchar visas bara transpondern.",
"events.generate_qualifying": "Skapa kval från practice",
"events.clear_generated_qualifying": "Rensa genererade kval",
"events.generate_finals": "Skapa finaler från kval",
"events.clear_generated_finals": "Rensa genererade finaler",
"events.apply_bumps": "Applicera bump-ups",
"events.practice_standings": "Practice-ranking",
"events.qualifying_standings": "Kval-ranking",
"events.final_standings": "Final-ranking",
"events.generated_qualifying": "Kvalheat skapade från ranking.",
"events.finals_generated": "Finaler skapade från ranking.",
"events.bumps_applied": "Bump-ups applicerade till nästa final.",
"events.no_bumps_applied": "Inga bump-ups kunde appliceras ännu.",
"events.no_practice_results": "Inga practice-resultat ännu.",
"events.no_qualifying_results": "Inga kval-resultat ännu.",
"events.no_final_results": "Inga final-resultat ännu.",
"events.final_matrix": "Finalmatris",
"events.print_startlists": "Skriv ut startlistor",
"events.print_results": "Skriv ut resultat",
"events.pdf_startlists": "PDF startlistor",
"events.pdf_results": "PDF resultat",
"events.reserved_slot": "Reserverad bump-plats",
"events.position_grid": "Grid / startordning",
"events.start_lists": "Startlistor",
"events.no_final_matrix": "Inga finaler skapade ännu.",
"events.results_overview": "Resultatöversikt",
"events.main": "Main",
"events.slot": "Ruta",
"events.leg_status": "Heatstatus",
"events.source_for_finals": "Källa för finaler",
"events.finals_from_qualifying": "Kval-ranking",
"events.finals_from_practice": "Practice-ranking",
"events.finals_source_hint": "Välj om finalerna ska seedas från practice eller kval.",
"events.min_lap_time": "Min varvtid (sek)",
"events.min_lap_time_hint": "Varv snabbare än detta ignoreras som shortcut eller felträff.",
"events.max_lap_time": "Max varvtid (sek)",
"events.max_lap_time_hint": "Varv långsammare än detta räknas inte som giltigt varv och bryter lap-basen för nästa varv.",
"events.race_driver_scope": "Race i denna klass använder alla förare i vald klass om sessionen inte har egen deltagarlista.",
"events.reserve_bump_slots_hint": "Reserverar tomma platser i högre finaler så bumpade förare kan flyttas in utan att skriva över seedade platser.",
"events.team_race": "Lagrace",
"events.team_race_intro": "Skapa lag för långlopp. Alla passeringar från lagets förare eller bilar summeras till lagets totalvarv i Team Race-sessioner.",
"events.team_name": "Lagnamn",
"events.add_team": "Lägg till lag",
"events.teams": "Lag",
"events.team_drivers": "Lagförare",
"events.team_cars": "Lagbilar",
"events.team_hint": "Välj minst en förare eller bil per lag. Team Race-sessioner summerar lagets totala varv under hela körtiden, t.ex. 4 timmar.",
"events.team_steps": "1. Skriv lagnamn. 2. Kryssa förare och/eller bilar här under. 3. Klicka Lägg till lag. 4. Använd Redigera lag för ändringar efteråt.",
"events.team_form_drivers": "Markera lagförare innan du sparar laget.",
"events.team_form_cars": "Markera lagbilar innan du sparar laget.",
"events.team_driver_fallback": "Inga förare matchade race-klassen eller deltagarlistan. Visar alla förare som fallback.",
"events.no_teams": "Inga lag skapade ännu.",
"events.team_standings": "Lagställning",
"events.no_team_results": "Inga teamresultat ännu.",
"events.edit_team": "Redigera lag",
"events.team_stint_log": "Stint- och förarbyteslogg",
"events.team_report": "Lagrapport",
"events.print_team_results": "Skriv ut lagrapport",
"events.pdf_team_results": "PDF lagrapport",
"events.add_session": "Lägg till session",
"events.set_active": "Sätt aktiv",
"events.assignments": "Tilldelningar",
"events.na": "ej relevant",
"events.sponsor_tools": "Sponsorverktyg",
"events.qual_rounds": "Kvalrundor",
"events.heat_rounds": "Heat-rundor",
"events.final_rounds": "Finalrundor",
"events.round_duration": "Rundtid (min)",
"events.create_rounds": "Skapa rundor",
"events.tp_rule": "Samma transponder kan återanvändas mellan sessioner (Heat 1 -> Heat 2 -> Final 1). I en pågående session måste alla aktiva bilar ha unikt transponder-ID.",
"events.assign_title": "Tilldelningar (Förare -> Bil)",
"events.assign": "Tilldela",
"events.auto_assign": "Auto-tilldela vald session",
"events.clear_assign": "Rensa vald session",
"events.no_assignments": "Inga tilldelningar",
"events.duplicate_car": "Den bilen är redan tilldelad i denna session.",
"events.duplicate_driver": "Den föraren är redan tilldelad i denna session.",
"events.duplicate_tp": "Dubblett-transponder i samma session är inte tillåtet. Återanvänd i nästa heat/final.",
"timing.decoder_connection": "Decoder-anslutning",
"timing.connect": "Anslut decoder",
"timing.disconnect": "Koppla ner",
"timing.simulate": "Simulera passering",
"timing.status": "Status",
"timing.connected": "Ansluten",
"timing.disconnected": "Frånkopplad",
"timing.last_message": "Senaste meddelande",
"timing.control": "Sessionkontroll",
"timing.speaker_panel": "Speaker-panel",
"timing.speaker_panel_hint": "Dessa växlar slår av/på cues direkt för pågående session och overlay utan att lämna Tidtagning.",
"timing.select_session": "Välj session",
"timing.set_active": "Sätt aktiv",
"timing.start": "Starta",
"timing.stop": "Stoppa",
"timing.reset": "Nollställ data",
"timing.total_passings": "Totala passeringar",
"timing.started": "Startad",
"timing.remaining": "Nedräkning",
"timing.elapsed": "Körtid",
"timing.race_finished": "Race is finished",
"timing.no_active": "Ingen aktiv session vald.",
"timing.leaderboard": "Live leaderboard",
"timing.recent_passings": "Senaste passeringar",
"timing.no_laps": "Inga varv ännu.",
"timing.no_session_selected": "Ingen session vald.",
"timing.no_passings": "Inga passeringar registrerade.",
"timing.details": "Detaljer",
"timing.add_driver": "Lägg till förare",
"timing.add_car": "Lägg till bil",
"timing.quick_add_hint": "Snabbregistrera transponder",
"timing.quick_add_title": "Snabbregistrering",
"timing.quick_add_driver_title": "Lägg till förare från transponder",
"timing.quick_add_car_title": "Lägg till bil från transponder",
"timing.open_overlay": "Öppna overlay",
"timing.open_speaker_overlay": "Speaker overlay",
"timing.open_results_overlay": "Result overlay",
"timing.open_tv_overlay": "TV overlay",
"timing.open_team_overlay": "Team overlay",
"timing.close_details": "Stang",
"timing.detail_title": "Leaderboard-detaljer",
"timing.lap_history": "Varvhistorik",
"timing.no_lap_history": "Inga varv att visa.",
"timing.total_time": "Total tid",
"timing.clear_confirm": "Rensa all tiddata för denna session?",
"timing.prompt_transponder": "Transponder",
"timing.first_crossing_start": "Första crossing satte personlig start",
"timing.seeding_mode": "Seedning",
"timing.position_grid_hint": "Griden visar startordningen för positionsstart i aktiv session.",
"settings.decoder": "Decoder",
"settings.auto_reconnect": "Auto-återanslut",
"settings.save": "Spara",
"settings.connect_now": "Anslut nu",
"settings.expected_json": "Förväntat AMMC JSON-format",
"settings.managed_ammc": "Hanterad AMMC",
"settings.managed_ammc_sub": "Starta lokal AMMC från backend på denna maskin.",
"settings.enable_managed": "Aktivera hanterad AMMC",
"settings.auto_start_ammc": "Auto-starta AMMC när backend startar",
"settings.decoder_host": "Decoder IP / host",
"settings.ws_port": "AMMC WebSocket-port",
"settings.executable_path": "AMMC binär",
"settings.working_dir": "Arbetskatalog (valfritt)",
"settings.extra_args": "Extra argument (valfritt)",
"settings.save_ammc": "Spara AMMC",
"settings.start_ammc": "Starta AMMC",
"settings.stop_ammc": "Stoppa AMMC",
"settings.refresh_ammc": "Uppdatera status",
"settings.ammc_status": "AMMC-status",
"settings.running": "Kör",
"settings.stopped": "Stoppad",
"settings.server_platform": "Server-OS",
"settings.pid": "PID",
"settings.started_at": "Startad",
"settings.stopped_at": "Stoppad",
"settings.last_error": "Senaste fel",
"settings.output": "Senaste AMMC-logg",
"settings.executable_found": "Binär hittad",
"settings.executable_missing": "Binär saknas",
"settings.bundled_hint": "Bundlad standardbana i appen används automatiskt om den finns.",
"settings.use_server_ws": "Använd serverns WS-url",
"settings.audio": "Ljud",
"settings.audio_enabled": "Aktivera ljud i browsern",
"settings.speaker_passing_cue": "Speaker-cue vid passing",
"settings.speaker_leader_cue": "Speaker-cue vid ny ledare",
"settings.speaker_finish_cue": "Speaker-cue vid finish",
"settings.speaker_bestlap_cue": "Speaker-cue vid nytt bästa varv",
"settings.speaker_top3_cue": "Speaker-cue vid topp 3-ändring",
"settings.speaker_start_cue": "Speaker-cue vid sessionstart",
"settings.passing_sound": "Passing-ljud",
"settings.passing_sound_off": "Av",
"settings.passing_sound_beep": "Blipp",
"settings.passing_sound_name": "Säg förarnamn",
"settings.finish_voice": "Spela finish-siren",
"settings.test_audio": "Testa ljud",
"settings.audio_note": "Browsern kräver oftast ett klick först innan ljud/tal tillåts.",
"settings.branding": "Klubbinfo / PDF",
"settings.club_name": "Klubbnamn",
"settings.club_tagline": "Klubbtext",
"settings.pdf_footer": "PDF-footer",
"settings.pdf_theme": "PDF-tema",
"settings.pdf_theme_classic": "Classic",
"settings.pdf_theme_minimal": "Minimal",
"settings.pdf_theme_motorsport": "Motorsport",
"settings.logo": "Logo / overlay",
"settings.logo_upload": "Ladda logo",
"settings.logo_clear": "Rensa logo",
"settings.logo_note": "Logon visas i overlay. PDF-export försöker bädda in loggan automatiskt via backend.",
"settings.storage": "Lagring",
"settings.backend_url": "Backend URL",
"settings.backend_status": "Backend-status",
"settings.online": "Online",
"settings.offline": "Offline",
"settings.last_sync": "Senaste synk",
"settings.test_backend": "Testa backend",
"settings.sync_now": "Synka nu",
"settings.export_json": "Exportera JSON",
"table.name": "Namn",
"table.class": "Klass",
"table.transponder": "Transponder",
"table.delete": "Ta bort",
"table.car": "Bil",
"table.date": "Datum",
"table.mode": "Läge",
"table.start_mode": "Start",
"table.seeding": "Seedning",
"table.score": "Poäng",
"table.session": "Session",
"table.type": "Typ",
"table.duration": "Tid",
"table.status": "Status",
"table.time": "Tid",
"table.driver": "Förare",
"table.loop": "Loop",
"table.strength": "Signal",
"table.pos": "Pos",
"table.laps": "Varv",
"table.last_lap": "Senaste varv",
"table.best_lap": "Bästa varv",
"table.gap": "Gap",
"table.event": "Event",
"table.result": "Resultat",
"table.lap": "Varv",
"table.leader_gap": "Gap ledare",
"table.ahead_gap": "Gap fram",
"table.own_delta": "Eget delta",
"common.delete": "Ta bort",
"common.cancel": "Avbryt",
"common.save": "Spara",
"common.edit": "Redigera",
"common.unknown_driver": "Okänd förare",
"common.unknown_car": "Okänd bil",
"common.unknown": "Okänd",
"common.unassigned_driver": "Otilldelad förare",
"common.driver_car": "Förarbil",
"common.unknown_event": "Okänt event",
"common.no_rows": "Inga rader",
"common.no_entries": "Inga poster.",
"status.ready": "redo",
"status.running": "pågår",
"status.finished": "klar",
"status.leader": "LEDARE",
"status.seeded": "SEED",
"status.free_practice": "FREE",
"status.open_practice": "OPEN",
"session.open_practice": "open practice",
"session.free_practice": "fri träning",
"session.practice": "träning",
"session.qualification": "kval",
"session.heat": "heat",
"session.final": "final",
"session.team_race": "lagrace",
"validation.no_assignments": "Ingen förar-/biltilldelning i denna session.",
"validation.missing_tp": "En eller flera tilldelade bilar saknar transponder-ID.",
"validation.duplicate_tp": "Dubblett-transponder i session: {ids}.",
"validation.invalid_date": "Datum måste vara i format YYYY-MM-DD.",
"validation.invalid_selection": "Välj ett giltigt alternativ.",
"validation.required_name": "Namn måste fyllas i.",
"validation.required_transponder": "Transponder måste fyllas i.",
"validation.required_date": "Datum måste fyllas i.",
"validation.required_duration": "Duration måste vara minst 1 minut.",
"edit.class_name": "Redigera klassnamn",
"edit.driver_name": "Redigera förarnamn",
"edit.driver_class": "Redigera förarklass",
"edit.new_driver_name": "Namn på ny förare",
"edit.driver_transponder": "Redigera personlig transponder (kan vara tom)",
"edit.car_name": "Redigera bilnamn",
"edit.new_car_name": "Namn på ny bil",
"edit.car_transponder": "Redigera bilens transponder",
"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.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.",
"guide.sponsor_2": "2. Lägg upp 10 förare i sidan Förare.",
"guide.sponsor_3": "3. Skapa event med läge Sponsor Event.",
"guide.sponsor_4": "4. Klicka Hantera på eventet och skapa rundor (kval/heat/final).",
"guide.sponsor_5": "5. Tilldela 4 förare till 4 bilar i Heat 1, byt förare till Heat 2/3 osv.",
"guide.sponsor_6": "6. I Tidtagning: välj session, Sätt aktiv, Starta, Stoppa.",
"guide.race_title": "Skapa vanligt race (förare har egna transpondrar)",
"guide.race_1": "1. Lägg in förare med personlig transponder.",
"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_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.",
"guide.race_8": "8. Klicka Skapa finaler från kval när kvalen är färdiga.",
"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_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.",
"guide.race_format_4": "Förare per final, Final-heat per final och Räknade finalheat styr hur A/B/C-finalerna byggs och räknas.",
"guide.race_format_5": "Finaltid och Final-start styr varje finalleg, ofta med positionsstart.",
"guide.race_format_6": "Bump-up per final och Reservera bump-platser används om förare ska kunna flyttas från lägre final till nästa main.",
"guide.race_format_7": "Källa för finaler avgör om finalerna seedas från practice eller kvalrankingen.",
"guide.free_practice_title": "Free Practice",
"guide.free_practice_1": "Använd sessionstypen fri träning när du bara vill visa löpande varvtider.",
"guide.free_practice_2": "Free Practice påverkar inte seedning till kval eller finaler.",
"guide.free_practice_3": "Leaderboarden visar varv, senaste varv, bästa varv, gap till framförvarande och eget delta mot föregående varv.",
"guide.open_practice_title": "Open Practice",
"guide.open_practice_1": "Använd Open Practice när du vill att systemet bara ska lista alla transpondrar som kommer in.",
"guide.open_practice_2": "Om transpondern inte matchar en registrerad förare visas transpondernumret som namn.",
"guide.open_practice_3": "Open Practice påverkar inte seedning, kval eller finaler.",
"guide.team_title": "Lagrace / Endurance",
"guide.team_1": "Gå till Race Setup och skapa ett race i rätt klass.",
"guide.team_2": "Öppna Hantera och gå till sektionen Lag.",
"guide.team_3": "Skriv lagnamn och kryssa förare och/eller bilar i samma teamblock innan du klickar Lägg till lag.",
"guide.team_4": "Efter att laget skapats kan du klicka Redigera lag för att ändra förare eller bilar.",
"guide.team_5": "Skapa en session med typ Team Race och sätt tiden, t.ex. 240 minuter för 4 timmar.",
"guide.team_6": "Starta sessionen i Tidtagning. Alla passeringar från lagets medlemmar summeras till lagets totalvarv.",
"overlay.title": "Overlay",
"overlay.subtitle": "Extern leaderboard-skärm",
"overlay.no_active": "Ingen aktiv session vald.",
"overlay.last_passings": "Senaste passeringar",
"overlay.window_title": "JMK RB Live Event Overlay",
"overlay.mode_leaderboard": "Leaderboard",
"overlay.mode_speaker": "Speaker",
"overlay.mode_results": "Resultat",
"overlay.mode_tv": "TV",
"overlay.mode_team": "Team",
"overlay.fastest_lap": "Snabbaste varv",
"overlay.fullscreen": "Fullscreen",
"overlay.leaderboard_live": "Live leaderboard",
"overlay.rotating_panel": "Displaypanel",
"overlay.next_predicted_lap": "Nästa varv",
"overlay.event_markers": "Eventmarkörer",
"overlay.team_battle": "Lagkamp",
"overlay.active_member": "Aktiv förare/bil",
"overlay.top_three": "Topp 3",
"guide.host_title": "Hur Managed AMMC körs",
"guide.host_1": "1. AMMC körs alltid på samma maskin som `npm start` eller `node server.js` körs på.",
"guide.host_2": "2. Om du bara surfar in från en laptop/webbläsare startas ingen process där. Webbläsaren styr bara backend via HTTP.",
"guide.host_3": "3. Kör backend på Linux-servern -> Linux-binären används: `AMMC/linux_x86-64/ammc-amb`.",
"guide.host_4": "4. Kör backend på Windows-burken -> Windows-binären används: `AMMC/windows64/ammc-amb.exe`.",
"guide.host_5": "5. Fältet `AMMC binär` i Settings är sökvägen på hosten där backend kör, inte på klient-laptopen.",
"guide.windows_title": "Windows + AMMC + npm",
"guide.windows_1": "1. Installera Node.js LTS och Visual C++ Runtime 2015-2022 på hosten som ska köra `live_event`.",
"guide.windows_2": "2. Standardbinär för Managed AMMC på Windows-host: `AMMC/windows64/ammc-amb.exe`.",
"guide.windows_3": "3. Kör `npm start` på Windows-hosten. Då är det där AMMC startas om du använder Managed AMMC.",
"guide.windows_4": "4. I Settings: `Decoder IP / host` = decoderns IP, t.ex. `192.168.1.11`.",
"guide.windows_5": "5. I appen: `Backend URL` = http://<windows-host>:8081, `WebSocket URL` = ws://<windows-host>:9000.",
"guide.linux_title": "Linux + npm",
"guide.linux_1": "1. Installera Node 22 LTS, build-essential och python3 på Linux-hosten.",
"guide.linux_2": "2. Standardbinär för Managed AMMC på Linux-host: `AMMC/linux_x86-64/ammc-amb`.",
"guide.linux_3": "3. Kör `npm install`, `npm start`. Servern lyssnar på 0.0.0.0:8081. Öppna ev. brandvägg: `sudo ufw allow 8081/tcp` och använd `ws://<linux-host>:9000` i klienten.",
"guide.sqlite_title": "SQLite-lagring",
"guide.sqlite_1": "Databasfil: data/rc_timing.sqlite",
"guide.sqlite_2": "API: /api/state och /api/passings",
"guide.ammc_ref": "AMMC referens: https://www.ammconverter.eu/docs/intro/quick-start/",
"error.backend_offline": "Backend offline: {msg}",
"error.sync_failed": "Synk misslyckades: {msg}",
"error.health_failed": "Hälsokontroll misslyckades: {msg}",
"error.ws_invalid": "Ogiltig WebSocket URL: {msg}",
"error.decoder_connection": "Decoder-anslutningsfel.",
"error.passing_save_failed": "Sparning av passering misslyckades: {msg}",
"error.ammc_load_failed": "Kunde inte läsa AMMC-status: {msg}",
"error.ammc_save_failed": "Kunde inte spara AMMC-konfig: {msg}",
"error.ammc_start_failed": "Kunde inte starta AMMC: {msg}",
"error.ammc_stop_failed": "Kunde inte stoppa AMMC: {msg}",
"error.print_blocked": "Popup blockerad. Tillåt popup-fönster för att skriva ut.",
"error.pdf_export_failed": "PDF-export misslyckades: {msg}"
},
en: {
"nav.dashboard": "Dashboard",
"nav.dashboard_sub": "Overview and live status",
"nav.events": "Events",
"nav.events_sub": "Sponsor events and shared cars",
"nav.race_setup": "Race Setup",
"nav.race_setup_sub": "Competition race and heat setup",
"nav.overlay": "Overlay",
"nav.overlay_sub": "External leaderboard screen",
"nav.classes": "Classes",
"nav.classes_sub": "Manage competition classes",
"nav.drivers": "Drivers",
"nav.drivers_sub": "Drivers and personal transponders",
"nav.cars": "Cars",
"nav.cars_sub": "Track cars with fixed transponders",
"nav.timing": "Timing",
"nav.timing_sub": "Live timing board",
"nav.settings": "Settings",
"nav.settings_sub": "Decoder, backend and storage",
"nav.guide": "Guide",
"nav.guide_sub": "Documentation and setup",
"ui.language": "Language",
"brand.title": "JMK RB",
"brand.subtitle": "Live Event",
"ui.no_active_session": "No Active Session",
"ui.event": "Event",
"ui.decoder_online": "Decoder Online",
"ui.decoder_offline": "Decoder Offline",
"mode.track": "Track Event",
"mode.race": "Race",
"dashboard.events": "Events",
"dashboard.drivers": "Drivers",
"dashboard.cars": "Cars",
"dashboard.passings": "Passings",
"dashboard.created": "Created",
"dashboard.registered": "Registered",
"dashboard.track_fleet": "Track Fleet",
"dashboard.captured": "Captured",
"dashboard.live_session": "Live Session",
"dashboard.idle": "idle",
"dashboard.duration": "Duration",
"dashboard.no_session": "No session is active. Go to Events or Timing to create/start one.",
"dashboard.quick_actions": "Quick Actions",
"dashboard.create_event": "Create Event",
"dashboard.open_timing": "Open Timing Board",
"dashboard.connect_decoder": "Connect Decoder",
"dashboard.recent_sessions": "Recent Sessions",
"dashboard.free_practice": "Free Practice",
"dashboard.open_practice": "Open Practice",
"dashboard.live_board": "Live Board",
"dashboard.decoder_feed": "Decoder feed",
"dashboard.backend_link": "Backend link",
"dashboard.audio_profile": "Audio profile",
"dashboard.live_note": "Quick operations panel for connection, overlay and audio. Deeper configuration remains under Settings.",
"session.none_yet": "No sessions yet.",
"classes.create": "Create Class",
"classes.placeholder": "Class name (e.g. 2WD Buggy)",
"classes.add": "Add Class",
"classes.title": "Classes",
"drivers.create": "Create Driver",
"drivers.name_placeholder": "Driver name",
"drivers.transponder_placeholder": "Personal transponder (optional)",
"drivers.add": "Add Driver",
"drivers.title": "Drivers",
"cars.create": "Create Track Car",
"cars.name_placeholder": "Car name or number",
"cars.transponder_placeholder": "Car transponder",
"cars.add": "Add Car",
"cars.title": "Cars",
"events.create": "Create Event",
"events.create_race": "Create Race",
"events.name_placeholder": "Event name",
"events.add": "Add Event",
"events.add_race": "Add Race",
"events.mode_race_option": "Race (driver transponders)",
"events.mode_track_option": "Track Event (shared cars)",
"events.title": "Events",
"events.race_title": "Races",
"events.track_only_intro": "Create sponsor events with shared cars/transponders here.",
"events.race_only_intro": "Create proper races with personal driver transponders here.",
"events.manage": "Manage",
"events.edit": "Edit",
"events.sessions": "Sessions",
"events.participants": "Participants",
"events.select_participants": "Select race participants",
"events.select_all_participants": "Select all",
"events.clear_participants": "Clear participants",
"events.reseed_qualifying": "Reseed upcoming qualifying",
"events.reseed_done": "Upcoming qualifying heats reseeded from current standings.",
"events.no_reseed_done": "No upcoming qualifying heats could be reseeded.",
"events.reseed_locked": "{count} heats were skipped because manual grid is locked.",
"events.reserve_bump_slots": "Reserve bump slots in finals",
"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.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",
"events.brand_tagline": "Brand tagline",
"events.brand_footer": "PDF footer",
"events.brand_theme": "PDF theme",
"events.brand_logo": "Event logo",
"events.branding_use_global": "Use global default theme",
"events.branding_save": "Save branding",
"events.session_name": "Session name",
"events.duration_placeholder": "Duration (min)",
"events.max_cars_placeholder": "Max cars (optional)",
"events.start_mode": "Start mode",
"events.seed_best_laps": "Best laps for seeding",
"events.stagger_gap_sec": "Stagger gap (sec)",
"events.session_settings": "Session settings",
"events.edit_session": "Settings",
"events.start_mode_mass": "Mass start",
"events.start_mode_position": "Position start",
"events.start_mode_staggered": "Staggered / individual start",
"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.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",
"events.qualifying_scoring_best": "Best round / rank",
"events.qualifying_rounds": "Qualifying rounds",
"events.qualifying_rounds_hint": "How many qualifying rounds should be generated in total.",
"events.cars_per_heat": "Drivers per qualifying heat",
"events.cars_per_heat_hint": "How many drivers go into each qualifying heat.",
"events.qual_duration": "Qualifying duration (min)",
"events.qual_duration_hint": "Length of each qualifying heat in minutes.",
"events.qual_start_mode": "Qualifying start",
"events.qual_start_mode_hint": "Mass, position or staggered for qualifying rounds.",
"events.counted_qual_rounds": "Counted qualifying rounds",
"events.counted_qual_rounds_hint": "How many qualifying rounds count toward the final ranking.",
"events.cars_per_final": "Drivers per final",
"events.cars_per_final_hint": "Maximum number of drivers in each A/B/C final.",
"events.final_legs": "Final heats per main",
"events.final_legs_hint": "How many final legs should be generated per main.",
"events.counted_final_legs": "Counted final heats",
"events.counted_final_legs_hint": "How many final legs count in the combined final standings.",
"events.final_duration": "Final duration (min)",
"events.final_duration_hint": "Length of each final leg in minutes.",
"events.final_start_mode": "Final start",
"events.final_start_mode_hint": "Start mode for finals, often position start.",
"events.bump_count": "Bump-up per main",
"events.bump_count_hint": "How many drivers can move up from a lower final into the next main.",
"events.save_race_format": "Save race format",
"events.open_grid": "Grid",
"events.grid_editor": "Grid editor",
"events.grid_editor_hint": "Drag drivers up or down to change the manual start order for position start.",
"events.grid_reset": "Reset from participant list",
"events.grid_lock": "Lock grid",
"events.grid_unlock": "Unlock grid",
"events.grid_locked": "Manual grid is locked against auto-reseed.",
"events.grid_unlocked": "Grid follows auto seed/reseed until you lock it.",
"events.grid_empty": "No grid available to edit yet.",
"events.print_heat_sheet": "Print heat sheet",
"events.export_heat_sheet": "Export heat sheet",
"events.pdf_heat_sheet": "PDF heat sheet",
"events.free_practice_note": "Free Practice shows rolling lap times and is not used for seeding.",
"events.open_practice_note": "Open Practice shows all incoming transponders live. If no driver matches, only the transponder is shown.",
"events.generate_qualifying": "Generate qualifying from practice",
"events.clear_generated_qualifying": "Clear generated qualifying",
"events.generate_finals": "Generate finals from qualifying",
"events.clear_generated_finals": "Clear generated finals",
"events.apply_bumps": "Apply bump-ups",
"events.practice_standings": "Practice standings",
"events.qualifying_standings": "Qualifying standings",
"events.final_standings": "Final standings",
"events.generated_qualifying": "Qualifying heats generated from standings.",
"events.finals_generated": "Finals generated from standings.",
"events.bumps_applied": "Bump-ups applied to the next main.",
"events.no_bumps_applied": "No bump-ups could be applied yet.",
"events.no_practice_results": "No practice results yet.",
"events.no_qualifying_results": "No qualifying results yet.",
"events.no_final_results": "No final results yet.",
"events.final_matrix": "Final matrix",
"events.print_startlists": "Print start lists",
"events.print_results": "Print results",
"events.pdf_startlists": "PDF start lists",
"events.pdf_results": "PDF results",
"events.reserved_slot": "Reserved bump slot",
"events.position_grid": "Grid / start order",
"events.start_lists": "Start lists",
"events.no_final_matrix": "No finals generated yet.",
"events.results_overview": "Results overview",
"events.main": "Main",
"events.slot": "Slot",
"events.leg_status": "Leg status",
"events.source_for_finals": "Source for finals",
"events.finals_from_qualifying": "Qualifying standings",
"events.finals_from_practice": "Practice standings",
"events.finals_source_hint": "Choose whether finals should be seeded from practice or qualifying.",
"events.min_lap_time": "Min lap time (sec)",
"events.min_lap_time_hint": "Laps faster than this are ignored as shortcuts or false hits.",
"events.max_lap_time": "Max lap time (sec)",
"events.max_lap_time_hint": "Laps slower than this are not counted as valid laps and reset the lap base for the next lap.",
"events.race_driver_scope": "Race mode uses all drivers in the event class unless a session has its own participant list.",
"events.reserve_bump_slots_hint": "Reserve empty slots in higher finals so bumped drivers can be inserted without overwriting seeded spots.",
"events.team_race": "Team Race",
"events.team_race_intro": "Create endurance teams. All passings from the team's drivers or cars are added to the team's total laps in Team Race sessions.",
"events.team_name": "Team name",
"events.add_team": "Add team",
"events.teams": "Teams",
"events.team_drivers": "Team drivers",
"events.team_cars": "Team cars",
"events.team_hint": "Select at least one driver or car per team. Team Race sessions sum the team's total laps across the whole race duration, for example 4 hours.",
"events.team_steps": "1. Enter the team name. 2. Tick drivers and/or cars below. 3. Click Add team. 4. Use Edit team for later changes.",
"events.team_form_drivers": "Select team drivers before saving the team.",
"events.team_form_cars": "Select team cars before saving the team.",
"events.team_driver_fallback": "No drivers matched the race class or participant list. Showing all drivers as fallback.",
"events.no_teams": "No teams created yet.",
"events.team_standings": "Team standings",
"events.no_team_results": "No team results yet.",
"events.edit_team": "Edit team",
"events.team_stint_log": "Stint and driver-change log",
"events.team_report": "Team report",
"events.print_team_results": "Print team report",
"events.pdf_team_results": "PDF team report",
"events.add_session": "Add Session",
"events.set_active": "Set Active",
"events.assignments": "Assignments",
"events.na": "n/a",
"events.sponsor_tools": "Sponsor Event Tools",
"events.qual_rounds": "Qualification rounds",
"events.heat_rounds": "Heat rounds",
"events.final_rounds": "Final rounds",
"events.round_duration": "Round duration (min)",
"events.create_rounds": "Create Rounds",
"events.tp_rule": "Same transponder can be reused across sessions (Heat 1 -> Heat 2 -> Final 1). In a running session, each active car must have a unique transponder.",
"events.assign_title": "Track Assignments (Driver -> Car)",
"events.assign": "Assign",
"events.auto_assign": "Auto Assign Selected Session",
"events.clear_assign": "Clear Selected Session",
"events.no_assignments": "No assignments",
"events.duplicate_car": "That car is already assigned in this session.",
"events.duplicate_driver": "That driver is already assigned in this session.",
"events.duplicate_tp": "Duplicate transponder in same session is not allowed. Reuse in next heat/final.",
"timing.decoder_connection": "Decoder Connection",
"timing.connect": "Connect Decoder",
"timing.disconnect": "Disconnect",
"timing.simulate": "Simulate Passing",
"timing.status": "Status",
"timing.connected": "Connected",
"timing.disconnected": "Disconnected",
"timing.last_message": "Last message",
"timing.control": "Session Control",
"timing.speaker_panel": "Speaker panel",
"timing.speaker_panel_hint": "These toggles enable or disable cues live for the current session and overlay without leaving Timing.",
"timing.select_session": "Select session",
"timing.set_active": "Set Active",
"timing.start": "Start",
"timing.stop": "Stop",
"timing.reset": "Reset Data",
"timing.total_passings": "Total passings",
"timing.started": "Started",
"timing.remaining": "Countdown",
"timing.elapsed": "Elapsed",
"timing.race_finished": "Race is finished",
"timing.no_active": "No active session selected.",
"timing.leaderboard": "Live Leaderboard",
"timing.recent_passings": "Recent Passings",
"timing.no_laps": "No laps yet.",
"timing.no_session_selected": "No session selected.",
"timing.no_passings": "No passings recorded.",
"timing.details": "Details",
"timing.add_driver": "Add driver",
"timing.add_car": "Add car",
"timing.quick_add_hint": "Quick-register transponder",
"timing.quick_add_title": "Quick add",
"timing.quick_add_driver_title": "Add driver from transponder",
"timing.quick_add_car_title": "Add car from transponder",
"timing.open_overlay": "Open overlay",
"timing.open_speaker_overlay": "Speaker overlay",
"timing.open_results_overlay": "Results overlay",
"timing.open_tv_overlay": "TV overlay",
"timing.open_team_overlay": "Team overlay",
"timing.close_details": "Close",
"timing.detail_title": "Leaderboard details",
"timing.lap_history": "Lap history",
"timing.no_lap_history": "No laps to show.",
"timing.total_time": "Total time",
"timing.clear_confirm": "Clear all timing data for this session?",
"timing.prompt_transponder": "Transponder",
"timing.first_crossing_start": "First crossing set personal start",
"timing.seeding_mode": "Seeding",
"timing.position_grid_hint": "The grid shows the start order for position start in the active session.",
"settings.decoder": "Decoder",
"settings.auto_reconnect": "Auto reconnect",
"settings.save": "Save",
"settings.connect_now": "Connect Now",
"settings.expected_json": "Expected AMMC JSON format",
"settings.managed_ammc": "Managed AMMC",
"settings.managed_ammc_sub": "Start local AMMC from the backend on this machine.",
"settings.enable_managed": "Enable managed AMMC",
"settings.auto_start_ammc": "Auto-start AMMC when backend starts",
"settings.decoder_host": "Decoder IP / host",
"settings.ws_port": "AMMC WebSocket port",
"settings.executable_path": "AMMC executable",
"settings.working_dir": "Working directory (optional)",
"settings.extra_args": "Extra arguments (optional)",
"settings.save_ammc": "Save AMMC",
"settings.start_ammc": "Start AMMC",
"settings.stop_ammc": "Stop AMMC",
"settings.refresh_ammc": "Refresh status",
"settings.ammc_status": "AMMC status",
"settings.running": "Running",
"settings.stopped": "Stopped",
"settings.server_platform": "Server OS",
"settings.pid": "PID",
"settings.started_at": "Started at",
"settings.stopped_at": "Stopped at",
"settings.last_error": "Last error",
"settings.output": "Recent AMMC log",
"settings.executable_found": "Executable found",
"settings.executable_missing": "Executable missing",
"settings.bundled_hint": "The bundled app path is used automatically when present.",
"settings.use_server_ws": "Use server WS URL",
"settings.audio": "Audio",
"settings.audio_enabled": "Enable browser audio",
"settings.speaker_passing_cue": "Speaker cue on passing",
"settings.speaker_leader_cue": "Speaker cue on new leader",
"settings.speaker_finish_cue": "Speaker cue on finish",
"settings.speaker_bestlap_cue": "Speaker cue on new best lap",
"settings.speaker_top3_cue": "Speaker cue on top 3 change",
"settings.speaker_start_cue": "Speaker cue on session start",
"settings.passing_sound": "Passing sound",
"settings.passing_sound_off": "Off",
"settings.passing_sound_beep": "Beep",
"settings.passing_sound_name": "Speak driver name",
"settings.finish_voice": "Play finish siren",
"settings.test_audio": "Test audio",
"settings.audio_note": "Browsers usually require a click first before sound/speech is allowed.",
"settings.branding": "Club Info / PDF",
"settings.club_name": "Club name",
"settings.club_tagline": "Club tagline",
"settings.pdf_footer": "PDF footer",
"settings.pdf_theme": "PDF theme",
"settings.pdf_theme_classic": "Classic",
"settings.pdf_theme_minimal": "Minimal",
"settings.pdf_theme_motorsport": "Motorsport",
"settings.logo": "Logo / overlay",
"settings.logo_upload": "Upload logo",
"settings.logo_clear": "Clear logo",
"settings.logo_note": "The logo is shown in overlay. PDF export attempts to embed the logo automatically via the backend.",
"settings.storage": "Storage",
"settings.backend_url": "Backend URL",
"settings.backend_status": "Backend status",
"settings.online": "Online",
"settings.offline": "Offline",
"settings.last_sync": "Last sync",
"settings.test_backend": "Test Backend",
"settings.sync_now": "Sync Now",
"settings.export_json": "Export JSON",
"table.name": "Name",
"table.class": "Class",
"table.transponder": "Transponder",
"table.delete": "Delete",
"table.car": "Car",
"table.date": "Date",
"table.mode": "Mode",
"table.start_mode": "Start",
"table.seeding": "Seeding",
"table.score": "Score",
"table.session": "Session",
"table.type": "Type",
"table.duration": "Duration",
"table.status": "Status",
"table.time": "Time",
"table.driver": "Driver",
"table.loop": "Loop",
"table.strength": "Strength",
"table.pos": "Pos",
"table.laps": "Laps",
"table.last_lap": "Last Lap",
"table.best_lap": "Best Lap",
"table.gap": "Gap",
"table.event": "Event",
"table.result": "Result",
"table.lap": "Lap",
"table.leader_gap": "Leader gap",
"table.ahead_gap": "Gap ahead",
"table.own_delta": "Own delta",
"common.delete": "Delete",
"common.cancel": "Cancel",
"common.save": "Save",
"common.edit": "Edit",
"common.unknown_driver": "Unknown Driver",
"common.unknown_car": "Unknown Car",
"common.unknown": "Unknown",
"common.unassigned_driver": "Unassigned Driver",
"common.driver_car": "Driver Car",
"common.unknown_event": "Unknown Event",
"common.no_rows": "No rows",
"common.no_entries": "No entries.",
"status.ready": "ready",
"status.running": "running",
"status.finished": "finished",
"status.leader": "LEADER",
"status.seeded": "SEED",
"status.free_practice": "FREE",
"status.open_practice": "OPEN",
"session.open_practice": "open practice",
"session.free_practice": "free practice",
"session.practice": "practice",
"session.qualification": "qualification",
"session.heat": "heat",
"session.final": "final",
"session.team_race": "team race",
"validation.no_assignments": "No driver/car assignments in this session.",
"validation.missing_tp": "One or more assigned cars are missing transponder ID.",
"validation.duplicate_tp": "Duplicate transponder(s) in session: {ids}.",
"validation.invalid_date": "Date must be in YYYY-MM-DD format.",
"validation.invalid_selection": "Select a valid option.",
"validation.required_name": "Name is required.",
"validation.required_transponder": "Transponder is required.",
"validation.required_date": "Date is required.",
"validation.required_duration": "Duration must be at least 1 minute.",
"edit.class_name": "Edit class name",
"edit.driver_name": "Edit driver name",
"edit.driver_class": "Edit driver class",
"edit.new_driver_name": "New driver name",
"edit.driver_transponder": "Edit personal transponder (can be empty)",
"edit.car_name": "Edit car name",
"edit.new_car_name": "New car name",
"edit.car_transponder": "Edit car transponder",
"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.sponsor_title": "Create Sponsor Event: 10 drivers, 4 cars",
"guide.sponsor_1": "1. Add 4 cars in Cars with unique transponder IDs.",
"guide.sponsor_2": "2. Add 10 drivers in Drivers.",
"guide.sponsor_3": "3. Create event in Track Event mode.",
"guide.sponsor_4": "4. Click Manage and create rounds (qualification/heat/final).",
"guide.sponsor_5": "5. Assign 4 drivers to 4 cars in Heat 1, rotate drivers for Heat 2/3, then finals.",
"guide.sponsor_6": "6. In Timing: select session, Set Active, Start, Stop.",
"guide.race_title": "Create regular race (driver transponders)",
"guide.race_1": "1. Add drivers with personal transponder IDs.",
"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_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.",
"guide.race_8": "8. Click Generate finals from qualifying when qualifying is done.",
"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_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.",
"guide.race_format_4": "Drivers per final, Final heats per main and Counted final heats control how A/B/C finals are built and scored.",
"guide.race_format_5": "Final duration and Final start control each final leg, often with position start.",
"guide.race_format_6": "Bump-up per main and Reserve bump slots are used if drivers should move from a lower final into the next main.",
"guide.race_format_7": "Source for finals decides whether finals are seeded from practice or qualifying standings.",
"guide.free_practice_title": "Free Practice",
"guide.free_practice_1": "Use the free practice session type when you only want to show live lap times.",
"guide.free_practice_2": "Free Practice does not affect seeding for qualifying or finals.",
"guide.free_practice_3": "The leaderboard shows laps, last lap, best lap, gap to the car ahead and your own delta versus the previous lap.",
"guide.open_practice_title": "Open Practice",
"guide.open_practice_1": "Use Open Practice when you want the system to simply list every transponder that comes in.",
"guide.open_practice_2": "If the transponder does not match a registered driver, the transponder number is shown as the name.",
"guide.open_practice_3": "Open Practice does not affect seeding, qualifying or finals.",
"guide.team_title": "Team Race / Endurance",
"guide.team_1": "Go to Race Setup and create a race in the correct class.",
"guide.team_2": "Open Manage and go to the Teams section.",
"guide.team_3": "Enter the team name and tick drivers and/or cars in the same team block before you click Add team.",
"guide.team_4": "After the team is created, click Edit team to change drivers or cars.",
"guide.team_5": "Create a session with type Team Race and set the time, for example 240 minutes for 4 hours.",
"guide.team_6": "Start the session in Timing. All passings from the team's members are added to the team's total laps.",
"overlay.title": "Overlay",
"overlay.subtitle": "External leaderboard screen",
"overlay.no_active": "No active session selected.",
"overlay.last_passings": "Recent passings",
"overlay.window_title": "JMK RB Live Event Overlay",
"overlay.mode_leaderboard": "Leaderboard",
"overlay.mode_speaker": "Speaker",
"overlay.mode_results": "Results",
"overlay.mode_tv": "TV",
"overlay.mode_team": "Team",
"overlay.fastest_lap": "Fastest Lap",
"overlay.fullscreen": "Fullscreen",
"overlay.leaderboard_live": "Live leaderboard",
"overlay.rotating_panel": "Display panel",
"overlay.next_predicted_lap": "Next lap",
"overlay.event_markers": "Event markers",
"overlay.team_battle": "Team battle",
"overlay.active_member": "Active driver/car",
"overlay.top_three": "Top 3",
"guide.host_title": "How Managed AMMC Runs",
"guide.host_1": "1. AMMC always runs on the same machine where `npm start` or `node server.js` is running.",
"guide.host_2": "2. If you only browse from a laptop/browser, no process is started there. The browser only controls the backend over HTTP.",
"guide.host_3": "3. Run the backend on Linux -> the Linux binary is used: `AMMC/linux_x86-64/ammc-amb`.",
"guide.host_4": "4. Run the backend on Windows -> the Windows binary is used: `AMMC/windows64/ammc-amb.exe`.",
"guide.host_5": "5. The `AMMC executable` field in Settings is a path on the backend host, not on the client laptop.",
"guide.windows_title": "Windows + AMMC + npm",
"guide.windows_1": "1. Install Node.js LTS and Visual C++ Runtime 2015-2022 on the host that will run `live_event`.",
"guide.windows_2": "2. Default Managed AMMC binary on a Windows host: `AMMC/windows64/ammc-amb.exe`.",
"guide.windows_3": "3. Run `npm start` on the Windows host. Managed AMMC starts there if enabled.",
"guide.windows_4": "4. In Settings: `Decoder IP / host` = decoder IP, for example `192.168.1.11`.",
"guide.windows_5": "5. In app: `Backend URL` = http://<windows-host>:8081, `WebSocket URL` = ws://<windows-host>:9000.",
"guide.linux_title": "Linux + npm",
"guide.linux_1": "1. Install Node 22 LTS, build-essential and python3 on the Linux host.",
"guide.linux_2": "2. Default Managed AMMC binary on a Linux host: `AMMC/linux_x86-64/ammc-amb`.",
"guide.linux_3": "3. Run `npm install`, `npm start`. Server listens on 0.0.0.0:8081. Open firewall if needed: `sudo ufw allow 8081/tcp` and use `ws://<linux-host>:9000` in the client.",
"guide.sqlite_title": "SQLite storage",
"guide.sqlite_1": "Database file: data/rc_timing.sqlite",
"guide.sqlite_2": "API endpoints: /api/state and /api/passings",
"guide.ammc_ref": "AMMC reference: https://www.ammconverter.eu/docs/intro/quick-start/",
"error.backend_offline": "Backend offline: {msg}",
"error.sync_failed": "Sync failed: {msg}",
"error.health_failed": "Health check failed: {msg}",
"error.ws_invalid": "Invalid WebSocket URL: {msg}",
"error.decoder_connection": "Decoder connection error.",
"error.passing_save_failed": "Passing save failed: {msg}",
"error.ammc_load_failed": "Could not load AMMC status: {msg}",
"error.ammc_save_failed": "Could not save AMMC config: {msg}",
"error.ammc_start_failed": "Could not start AMMC: {msg}",
"error.ammc_stop_failed": "Could not stop AMMC: {msg}",
"error.print_blocked": "Popup blocked. Allow popups to print.",
"error.pdf_export_failed": "PDF export failed: {msg}"
}
};
const urlParams = new URLSearchParams(window.location.search);
const overlayMode = urlParams.get("view") === "overlay";
const overlayViewMode = ["leaderboard", "speaker", "results", "tv", "team"].includes(String(urlParams.get("overlayMode") || "").toLowerCase())
? String(urlParams.get("overlayMode")).toLowerCase()
: "leaderboard";
const state = loadState();
let currentView = overlayMode ? "overlay" : "dashboard";
let wsClient = null;
let reconnectTimer = null;
let backendSyncTimer = null;
let appVersionPollTimer = null;
let baselineAppVersion = "";
let selectedClassEditId = null;
let selectedLeaderboardKey = null;
let selectedGridSessionId = null;
let selectedDriverEditId = null;
let selectedCarEditId = null;
let selectedEventEditId = null;
let selectedSessionEditId = null;
let selectedTeamEditId = null;
let quickAddDraft = null;
let overlaySyncTimer = null;
let overlayRotationTimer = null;
let overlayLiveRefreshTimer = null;
let overlayRotationIndex = 0;
let overlayEvents = [];
let lastOverlayLeaderKeyBySession = {};
let lastOverlayTop3BySession = {};
let lastOverlayBestLapByKey = {};
let activeModalEscapeHandler = null;
const backend = {
available: false,
lastSyncAt: null,
lastError: "",
};
let audioCtx = null;
let lastFinishAnnouncementSessionId = null;
const ammc = {
config: createDefaultAmmcConfig(),
status: null,
lastError: "",
loaded: false,
};
const dom = {
nav: document.getElementById("nav"),
view: document.getElementById("view"),
pageTitle: document.getElementById("pageTitle"),
pageSubtitle: document.getElementById("pageSubtitle"),
activeSessionChip: document.getElementById("activeSessionChip"),
connectionBadge: document.getElementById("connectionBadge"),
clock: document.getElementById("clock"),
};
init();
async function init() {
document.body.classList.toggle("overlay-mode", overlayMode);
seedDefaultData();
await hydrateFromBackend();
await loadAmmcConfigFromBackend();
renderNav();
renderView();
setupLanguageControl();
updateHeaderState();
updateConnectionBadge();
tickClock();
setInterval(tickClock, 1000);
startAppVersionPolling();
if (overlayMode) {
startOverlaySync();
startOverlayRotation();
startOverlayLiveRefresh();
if (state.settings.wsUrl) {
connectDecoder();
}
}
}
function seedDefaultData() {
if (!state.classes.length) {
state.classes.push(
{ id: uid("class"), name: "Stock 17.5T" },
{ id: uid("class"), name: "Modified" }
);
}
if (!state.settings.wsUrl) {
state.settings.wsUrl = "ws://127.0.0.1:9000";
}
if (!state.settings.backendUrl) {
state.settings.backendUrl = getDefaultBackendUrl();
}
if (!state.settings.language) {
state.settings.language = DEFAULT_LANGUAGE;
}
if (typeof state.settings.audioEnabled !== "boolean") {
state.settings.audioEnabled = true;
}
if (!state.settings.passingSoundMode) {
state.settings.passingSoundMode = "beep";
}
if (typeof state.settings.finishVoiceEnabled !== "boolean") {
state.settings.finishVoiceEnabled = true;
}
if (typeof state.settings.speakerPassingCueEnabled !== "boolean") {
state.settings.speakerPassingCueEnabled = false;
}
if (typeof state.settings.speakerLeaderCueEnabled !== "boolean") {
state.settings.speakerLeaderCueEnabled = true;
}
if (typeof state.settings.speakerFinishCueEnabled !== "boolean") {
state.settings.speakerFinishCueEnabled = true;
}
if (!state.settings.clubName) {
state.settings.clubName = "JMK RB";
}
if (!state.settings.clubTagline) {
state.settings.clubTagline = "Live Event";
}
if (!state.settings.pdfFooter) {
state.settings.pdfFooter = "Generated by JMK RB Live Event";
}
if (!state.settings.pdfTheme) {
state.settings.pdfTheme = "classic";
}
if (typeof state.settings.speakerBestLapCueEnabled !== "boolean") {
state.settings.speakerBestLapCueEnabled = true;
}
if (typeof state.settings.speakerTop3CueEnabled !== "boolean") {
state.settings.speakerTop3CueEnabled = false;
}
if (typeof state.settings.speakerSessionStartCueEnabled !== "boolean") {
state.settings.speakerSessionStartCueEnabled = true;
}
if (!state.settings.logoDataUrl) {
state.settings.logoDataUrl = "";
}
state.events = state.events.map((event) => normalizeEvent(event));
state.sessions = state.sessions.map((session) => normalizeSession(session));
saveState({ skipBackend: true });
}
function loadState() {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
try {
const parsed = JSON.parse(raw);
return {
classes: parsed.classes || [],
drivers: parsed.drivers || [],
cars: parsed.cars || [],
events: parsed.events || [],
sessions: parsed.sessions || [],
resultsBySession: parsed.resultsBySession || {},
activeSessionId: parsed.activeSessionId || null,
settings: {
wsUrl: parsed.settings?.wsUrl || "ws://127.0.0.1:9000",
backendUrl: parsed.settings?.backendUrl || getDefaultBackendUrl(),
language: parsed.settings?.language || DEFAULT_LANGUAGE,
autoReconnect: parsed.settings?.autoReconnect !== false,
audioEnabled: parsed.settings?.audioEnabled !== false,
passingSoundMode: parsed.settings?.passingSoundMode || "beep",
finishVoiceEnabled: parsed.settings?.finishVoiceEnabled !== false,
speakerPassingCueEnabled: parsed.settings?.speakerPassingCueEnabled === true,
speakerLeaderCueEnabled: parsed.settings?.speakerLeaderCueEnabled !== false,
speakerFinishCueEnabled: parsed.settings?.speakerFinishCueEnabled !== false,
speakerBestLapCueEnabled: parsed.settings?.speakerBestLapCueEnabled !== false,
speakerTop3CueEnabled: parsed.settings?.speakerTop3CueEnabled === true,
speakerSessionStartCueEnabled: parsed.settings?.speakerSessionStartCueEnabled !== false,
clubName: parsed.settings?.clubName || "JMK RB",
clubTagline: parsed.settings?.clubTagline || "Live Event",
pdfFooter: parsed.settings?.pdfFooter || "Generated by JMK RB Live Event",
pdfTheme: parsed.settings?.pdfTheme || "classic",
logoDataUrl: parsed.settings?.logoDataUrl || "",
},
decoder: {
connected: false,
lastMessageAt: null,
lastError: "",
},
};
} catch {
// fall through to defaults
}
}
return {
classes: [],
drivers: [],
cars: [],
events: [],
sessions: [],
resultsBySession: {},
activeSessionId: null,
settings: {
wsUrl: "ws://127.0.0.1:9000",
backendUrl: getDefaultBackendUrl(),
language: DEFAULT_LANGUAGE,
autoReconnect: true,
audioEnabled: true,
passingSoundMode: "beep",
finishVoiceEnabled: true,
speakerPassingCueEnabled: false,
speakerLeaderCueEnabled: true,
speakerFinishCueEnabled: true,
speakerBestLapCueEnabled: true,
speakerTop3CueEnabled: false,
speakerSessionStartCueEnabled: true,
clubName: "JMK RB",
clubTagline: "Live Event",
pdfFooter: "Generated by JMK RB Live Event",
pdfTheme: "classic",
logoDataUrl: "",
},
decoder: {
connected: false,
lastMessageAt: null,
lastError: "",
},
};
}
function saveState(options = {}) {
const persistable = buildPersistableState();
localStorage.setItem(STORAGE_KEY, JSON.stringify(persistable));
if (!options.skipBackend) {
scheduleBackendSync();
}
}
function uid(prefix) {
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
}
function currentLanguage() {
return state.settings.language === "en" ? "en" : "sv";
}
function t(key, vars = {}) {
const lang = currentLanguage();
const dict = TRANSLATIONS[lang] || TRANSLATIONS.en;
const template = dict[key] ?? TRANSLATIONS.en[key] ?? key;
return String(template).replace(/\{(\w+)\}/g, (_, token) => String(vars[token] ?? ""));
}
function setupLanguageControl() {
const label = document.getElementById("languageLabel");
if (label) {
label.textContent = t("ui.language");
}
const brandTitle = document.getElementById("brandTitle");
if (brandTitle) {
brandTitle.textContent = t("brand.title");
}
const brandSubtitle = document.getElementById("brandSubtitle");
if (brandSubtitle) {
brandSubtitle.textContent = t("brand.subtitle");
}
const select = document.getElementById("languageSelect");
if (!(select instanceof HTMLSelectElement)) {
return;
}
select.value = currentLanguage();
select.onchange = () => {
state.settings.language = select.value === "en" ? "en" : "sv";
saveState();
renderNav();
renderView();
updateConnectionBadge();
updateHeaderState();
setupLanguageControl();
};
}
function buildPersistableState() {
return {
classes: state.classes,
drivers: state.drivers,
cars: state.cars,
events: state.events,
sessions: state.sessions.map((session) => normalizeSession(session)),
resultsBySession: state.resultsBySession,
activeSessionId: state.activeSessionId,
settings: state.settings,
};
}
function getDefaultBackendUrl() {
if (window.location.protocol.startsWith("http") && window.location.hostname) {
return `${window.location.protocol}//${window.location.hostname}:8081`;
}
return "http://127.0.0.1:8081";
}
function getBackendUrl() {
return String(state.settings.backendUrl || getDefaultBackendUrl()).replace(/\/+$/, "");
}
function createDefaultAmmcConfig() {
return {
managedEnabled: false,
autoStart: false,
decoderHost: "",
wsPort: 9000,
executablePath: "",
workingDirectory: "",
extraArgs: "",
};
}
function getManagedWsUrl() {
const port = Number(ammc.config?.wsPort || 9000);
try {
const backendUrl = new URL(getBackendUrl());
return `ws://${backendUrl.hostname}:${port}`;
} catch {
return `ws://127.0.0.1:${port}`;
}
}
async function loadAmmcConfigFromBackend() {
try {
const res = await fetch(`${getBackendUrl()}/api/ammc/config`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const payload = await res.json();
ammc.config = {
...createDefaultAmmcConfig(),
...(payload.config || {}),
};
ammc.status = payload.status || null;
ammc.lastError = "";
ammc.loaded = true;
} catch (error) {
ammc.lastError = t("error.ammc_load_failed", { msg: error instanceof Error ? error.message : String(error) });
ammc.loaded = false;
}
}
async function saveAmmcConfigToBackend(config) {
try {
const res = await fetch(`${getBackendUrl()}/api/ammc/config`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(config),
});
const payload = await res.json();
if (!res.ok) {
throw new Error(payload.error || `HTTP ${res.status}`);
}
ammc.config = {
...createDefaultAmmcConfig(),
...(payload.config || {}),
};
ammc.status = payload.status || ammc.status;
ammc.lastError = "";
} catch (error) {
ammc.lastError = t("error.ammc_save_failed", { msg: error instanceof Error ? error.message : String(error) });
}
}
async function refreshAmmcStatus() {
try {
const res = await fetch(`${getBackendUrl()}/api/ammc/status`);
const payload = await res.json();
if (!res.ok) {
throw new Error(payload.error || `HTTP ${res.status}`);
}
ammc.status = payload;
ammc.lastError = "";
ammc.loaded = true;
} catch (error) {
ammc.lastError = t("error.ammc_load_failed", { msg: error instanceof Error ? error.message : String(error) });
}
}
async function startManagedAmmc() {
try {
const res = await fetch(`${getBackendUrl()}/api/ammc/start`, {
method: "POST",
});
const payload = await res.json();
if (!res.ok) {
throw new Error(payload.error || `HTTP ${res.status}`);
}
ammc.status = payload.status || null;
ammc.lastError = "";
} catch (error) {
ammc.lastError = t("error.ammc_start_failed", { msg: error instanceof Error ? error.message : String(error) });
}
}
async function stopManagedAmmc() {
try {
const res = await fetch(`${getBackendUrl()}/api/ammc/stop`, {
method: "POST",
});
const payload = await res.json();
if (!res.ok) {
throw new Error(payload.error || `HTTP ${res.status}`);
}
ammc.status = payload.status || null;
ammc.lastError = "";
} catch (error) {
ammc.lastError = t("error.ammc_stop_failed", { msg: error instanceof Error ? error.message : String(error) });
}
}
async function hydrateFromBackend() {
try {
const res = await fetch(`${getBackendUrl()}/api/state`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const payload = await res.json();
if (payload && payload.state && typeof payload.state === "object") {
applyPersistedState(payload.state);
saveState({ skipBackend: true });
backend.lastSyncAt = payload.updatedAt || new Date().toISOString();
}
backend.available = true;
backend.lastError = "";
} catch (error) {
backend.available = false;
backend.lastError = t("error.backend_offline", { msg: error instanceof Error ? error.message : String(error) });
}
}
function applyPersistedState(persisted) {
state.classes = persisted.classes || [];
state.drivers = persisted.drivers || [];
state.cars = persisted.cars || [];
state.events = (persisted.events || []).map((event) => normalizeEvent(event));
state.sessions = (persisted.sessions || []).map((session) => normalizeSession(session));
state.resultsBySession = persisted.resultsBySession || {};
state.activeSessionId = persisted.activeSessionId || null;
state.settings = {
wsUrl: persisted.settings?.wsUrl || state.settings.wsUrl || "ws://127.0.0.1:9000",
backendUrl: persisted.settings?.backendUrl || state.settings.backendUrl || getDefaultBackendUrl(),
language: persisted.settings?.language || state.settings.language || DEFAULT_LANGUAGE,
autoReconnect: persisted.settings?.autoReconnect !== false,
audioEnabled: persisted.settings?.audioEnabled !== false,
passingSoundMode: persisted.settings?.passingSoundMode || state.settings.passingSoundMode || "beep",
finishVoiceEnabled: persisted.settings?.finishVoiceEnabled !== false,
speakerPassingCueEnabled: persisted.settings?.speakerPassingCueEnabled === true,
speakerLeaderCueEnabled: persisted.settings?.speakerLeaderCueEnabled !== false,
speakerFinishCueEnabled: persisted.settings?.speakerFinishCueEnabled !== false,
speakerBestLapCueEnabled: persisted.settings?.speakerBestLapCueEnabled !== false,
speakerTop3CueEnabled: persisted.settings?.speakerTop3CueEnabled === true,
speakerSessionStartCueEnabled: persisted.settings?.speakerSessionStartCueEnabled !== false,
clubName: persisted.settings?.clubName || state.settings.clubName || "JMK RB",
clubTagline: persisted.settings?.clubTagline || state.settings.clubTagline || "Live Event",
pdfFooter: persisted.settings?.pdfFooter || state.settings.pdfFooter || "Generated by JMK RB Live Event",
pdfTheme: persisted.settings?.pdfTheme || state.settings.pdfTheme || "classic",
logoDataUrl: persisted.settings?.logoDataUrl || state.settings.logoDataUrl || "",
};
}
function normalizeSession(session) {
return {
...session,
startMode: session?.startMode || "mass",
staggerGapSec: Number(session?.staggerGapSec || 5) || 5,
seedBestLapCount: Math.max(0, Number(session?.seedBestLapCount || 0) || 0),
driverIds: Array.isArray(session?.driverIds) ? session.driverIds : [],
manualGridIds: Array.isArray(session?.manualGridIds) ? session.manualGridIds : [],
gridCustomized: Boolean(session?.gridCustomized),
reservedBumpSlots: Math.max(0, Number(session?.reservedBumpSlots || 0) || 0),
generated: Boolean(session?.generated),
assignments: Array.isArray(session?.assignments) ? session.assignments : [],
};
}
function normalizeRaceTeam(team) {
return {
id: String(team?.id || uid("team")),
name: String(team?.name || "").trim(),
driverIds: Array.isArray(team?.driverIds) ? team.driverIds.filter(Boolean) : [],
carIds: Array.isArray(team?.carIds) ? team.carIds.filter(Boolean) : [],
};
}
function normalizeEvent(event) {
return {
...event,
branding: normalizeBrandingConfig(event?.branding),
raceConfig: {
qualifyingScoring: event?.raceConfig?.qualifyingScoring === "best" ? "best" : "points",
qualifyingRounds: Math.max(1, Number(event?.raceConfig?.qualifyingRounds || 3) || 3),
carsPerHeat: Math.max(2, Number(event?.raceConfig?.carsPerHeat || 8) || 8),
qualDurationMin: Math.max(1, Number(event?.raceConfig?.qualDurationMin || 5) || 5),
qualStartMode: normalizeStartMode(event?.raceConfig?.qualStartMode || "staggered"),
countedQualRounds: Math.max(1, Number(event?.raceConfig?.countedQualRounds || 1) || 1),
carsPerFinal: Math.max(2, Number(event?.raceConfig?.carsPerFinal || 8) || 8),
finalLegs: Math.max(1, Number(event?.raceConfig?.finalLegs || 1) || 1),
countedFinalLegs: Math.max(1, Number(event?.raceConfig?.countedFinalLegs || 1) || 1),
finalDurationMin: Math.max(1, Number(event?.raceConfig?.finalDurationMin || 5) || 5),
finalStartMode: normalizeStartMode(event?.raceConfig?.finalStartMode || "position"),
minLapMs: Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0),
maxLapMs: Math.max(0, Number(event?.raceConfig?.maxLapMs || 60000) || 60000),
bumpCount: Math.max(0, Number(event?.raceConfig?.bumpCount || 0) || 0),
reserveBumpSlots: event?.raceConfig?.reserveBumpSlots !== false,
driverIds: Array.isArray(event?.raceConfig?.driverIds) ? event.raceConfig.driverIds : [],
participantsConfigured: Boolean(event?.raceConfig?.participantsConfigured),
finalsSource: event?.raceConfig?.finalsSource === "practice" ? "practice" : "qualifying",
teams: Array.isArray(event?.raceConfig?.teams) ? event.raceConfig.teams.map((team) => normalizeRaceTeam(team)).filter((team) => team.name) : [],
},
};
}
function normalizeBrandingConfig(branding) {
const theme = ["classic", "minimal", "motorsport"].includes(String(branding?.pdfTheme || "").toLowerCase())
? String(branding.pdfTheme).toLowerCase()
: "";
return {
brandName: String(branding?.brandName || "").trim(),
brandTagline: String(branding?.brandTagline || "").trim(),
pdfFooter: String(branding?.pdfFooter || "").trim(),
pdfTheme: theme,
logoDataUrl: String(branding?.logoDataUrl || "").trim(),
};
}
function resolveEventBranding(event) {
const local = normalizeBrandingConfig(event?.branding);
return {
brandName: local.brandName || state.settings.clubName || "JMK RB",
brandTagline: local.brandTagline || state.settings.clubTagline || "Live Event",
pdfFooter: local.pdfFooter || state.settings.pdfFooter || "Generated by JMK RB Live Event",
pdfTheme: local.pdfTheme || state.settings.pdfTheme || "classic",
logoDataUrl: local.logoDataUrl || state.settings.logoDataUrl || "",
};
}
function scheduleBackendSync() {
clearTimeout(backendSyncTimer);
backendSyncTimer = setTimeout(() => {
syncStateToBackend();
}, 350);
}
async function syncStateToBackend() {
try {
const res = await fetch(`${getBackendUrl()}/api/state`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(buildPersistableState()),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
backend.available = true;
backend.lastError = "";
backend.lastSyncAt = new Date().toISOString();
} catch (error) {
backend.available = false;
backend.lastError = t("error.sync_failed", { msg: error instanceof Error ? error.message : String(error) });
}
}
async function pingBackend() {
try {
const res = await fetch(`${getBackendUrl()}/api/health`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
backend.available = true;
backend.lastError = "";
} catch (error) {
backend.available = false;
backend.lastError = t("error.health_failed", { msg: error instanceof Error ? error.message : String(error) });
}
}
function startAppVersionPolling() {
if (!window.location.protocol.startsWith("http")) {
return;
}
clearInterval(appVersionPollTimer);
checkAppVersion();
appVersionPollTimer = setInterval(checkAppVersion, 3000);
}
async function checkAppVersion() {
try {
const res = await fetch(`${getBackendUrl()}/api/app-version`, { cache: "no-store" });
if (!res.ok) {
return;
}
const payload = await res.json();
const key = `${payload.revision}:${payload.updatedAt}`;
if (!baselineAppVersion) {
baselineAppVersion = key;
return;
}
if (key !== baselineAppVersion) {
window.location.reload();
}
} catch {
// silent - normal when backend is temporarily unavailable
}
}
function startOverlaySync() {
clearInterval(overlaySyncTimer);
overlaySyncTimer = setInterval(async () => {
await hydrateFromBackend();
if (currentView === "overlay") {
renderView();
}
}, 800);
}
function startOverlayRotation() {
clearInterval(overlayRotationTimer);
overlayRotationTimer = setInterval(() => {
overlayRotationIndex = (overlayRotationIndex + 1) % 3;
if (currentView === "overlay" && overlayViewMode === "leaderboard") {
renderView();
}
}, 8000);
}
function startOverlayLiveRefresh() {
clearInterval(overlayLiveRefreshTimer);
overlayLiveRefreshTimer = setInterval(() => {
if (currentView === "overlay" && ["leaderboard", "tv", "team"].includes(overlayViewMode)) {
renderOverlay();
}
}, 250);
}
function renderNav() {
if (overlayMode) {
dom.nav.innerHTML = "";
return;
}
dom.nav.innerHTML = "";
NAV_ITEMS.forEach((item) => {
const button = document.createElement("button");
button.className = `nav-item ${item.id === currentView ? "active" : ""}`;
button.textContent = t(item.titleKey);
button.addEventListener("click", () => {
currentView = item.id;
renderNav();
renderView();
});
dom.nav.appendChild(button);
});
}
function renderView() {
clearModalEscapeHandler();
const navMeta = NAV_ITEMS.find((x) => x.id === currentView);
dom.pageTitle.textContent = navMeta ? t(navMeta.titleKey) : "";
dom.pageSubtitle.textContent = navMeta ? t(navMeta.subtitleKey) : "";
const languageLabel = document.getElementById("languageLabel");
if (languageLabel) {
languageLabel.textContent = t("ui.language");
}
switch (currentView) {
case "dashboard":
renderDashboard();
break;
case "events":
renderEvents();
break;
case "race_setup":
renderRaceSetup();
break;
case "classes":
renderClasses();
break;
case "drivers":
renderDrivers();
break;
case "cars":
renderCars();
break;
case "timing":
renderTiming();
break;
case "overlay":
renderOverlay();
break;
case "settings":
renderSettings();
break;
case "guide":
renderGuide();
break;
default:
renderDashboard();
}
updateHeaderState();
}
function clearModalEscapeHandler() {
if (activeModalEscapeHandler) {
document.removeEventListener("keydown", activeModalEscapeHandler);
activeModalEscapeHandler = null;
}
}
function bindModalShell(overlayId, onClose, focusSelector = 'input, select, textarea, button') {
const overlay = document.getElementById(overlayId);
if (!overlay) {
clearModalEscapeHandler();
return;
}
const focusTarget = overlay.querySelector(focusSelector);
window.setTimeout(() => {
if (focusTarget instanceof HTMLElement) {
focusTarget.focus();
if (focusTarget instanceof HTMLInputElement) {
focusTarget.select();
}
}
}, 0);
clearModalEscapeHandler();
activeModalEscapeHandler = (event) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
}
};
document.addEventListener("keydown", activeModalEscapeHandler);
}
function setFormError(errorId, message) {
const errorNode = document.getElementById(errorId);
if (!errorNode) {
return;
}
errorNode.textContent = message || "";
errorNode.hidden = !message;
}
function updateHeaderState() {
const session = getActiveSession();
if (!session) {
dom.activeSessionChip.textContent = t("ui.no_active_session");
return;
}
const event = state.events.find((e) => e.id === session.eventId);
dom.activeSessionChip.textContent = `${event?.name || t("ui.event")}${getSessionTypeLabel(session.type).toUpperCase()}${getStatusLabel(
session.status
).toUpperCase()}`;
}
function updateConnectionBadge() {
const isOnline = state.decoder.connected;
dom.connectionBadge.textContent = isOnline ? t("ui.decoder_online") : t("ui.decoder_offline");
dom.connectionBadge.className = `badge ${isOnline ? "badge-online" : "badge-offline"}`;
}
function tickClock() {
dom.clock.textContent = new Date().toLocaleString(currentLanguage() === "sv" ? "sv-SE" : "en-US");
const timerState = handleSessionTimerTick();
const active = getActiveSession();
if (currentView === "timing" && active && (active.status === "running" || timerState.changed)) {
renderView();
}
if (currentView === "dashboard" && timerState.changed) {
renderView();
}
if (currentView === "overlay" && active) {
renderView();
}
if (timerState.changed) {
updateHeaderState();
}
}
function ensureAudioContext() {
if (!state.settings.audioEnabled) {
return null;
}
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) {
return null;
}
if (!audioCtx) {
audioCtx = new Ctx();
}
if (audioCtx.state === "suspended") {
audioCtx.resume().catch(() => {});
}
return audioCtx;
}
function playPassingBeep() {
const ctx = ensureAudioContext();
if (!ctx) {
return;
}
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "square";
osc.frequency.setValueAtTime(1320, ctx.currentTime);
gain.gain.setValueAtTime(0.001, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.08, ctx.currentTime + 0.01);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.14);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.16);
}
function playFinishSiren() {
const ctx = ensureAudioContext();
if (!ctx) {
return;
}
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "sawtooth";
gain.gain.setValueAtTime(0.001, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.12, ctx.currentTime + 0.03);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 1.2);
osc.frequency.setValueAtTime(720, ctx.currentTime);
osc.frequency.linearRampToValueAtTime(1280, ctx.currentTime + 0.28);
osc.frequency.linearRampToValueAtTime(720, ctx.currentTime + 0.56);
osc.frequency.linearRampToValueAtTime(1280, ctx.currentTime + 0.84);
osc.frequency.linearRampToValueAtTime(720, ctx.currentTime + 1.12);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 1.22);
}
function playLeaderCue() {
const ctx = ensureAudioContext();
if (!ctx) {
return;
}
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "triangle";
gain.gain.setValueAtTime(0.001, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.09, ctx.currentTime + 0.01);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.26);
osc.frequency.setValueAtTime(880, ctx.currentTime);
osc.frequency.linearRampToValueAtTime(1320, ctx.currentTime + 0.12);
osc.frequency.linearRampToValueAtTime(1760, ctx.currentTime + 0.24);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.28);
}
function playStartCue() {
const ctx = ensureAudioContext();
if (!ctx) {
return;
}
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "triangle";
gain.gain.setValueAtTime(0.001, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.08, ctx.currentTime + 0.01);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
osc.frequency.setValueAtTime(520, ctx.currentTime);
osc.frequency.linearRampToValueAtTime(1040, ctx.currentTime + 0.4);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.42);
}
function playBestLapCue() {
const ctx = ensureAudioContext();
if (!ctx) {
return;
}
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = "sine";
gain.gain.setValueAtTime(0.001, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.07, ctx.currentTime + 0.01);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.22);
osc.frequency.setValueAtTime(1540, ctx.currentTime);
osc.frequency.linearRampToValueAtTime(1980, ctx.currentTime + 0.2);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.24);
}
function pushOverlayEvent(type, label) {
overlayEvents.unshift({
id: uid("overlay"),
type,
label,
ts: Date.now(),
});
if (overlayEvents.length > 12) {
overlayEvents = overlayEvents.slice(0, 12);
}
if (overlayMode && currentView === "overlay" && overlayViewMode === "speaker") {
if (type === "leader" && state.settings.speakerLeaderCueEnabled) {
playLeaderCue();
} else if (type === "passing" && state.settings.speakerPassingCueEnabled) {
playPassingBeep();
} else if (type === "finish" && state.settings.speakerFinishCueEnabled) {
playFinishSiren();
} else if (type === "start" && state.settings.speakerSessionStartCueEnabled) {
playStartCue();
} else if (type === "bestlap" && state.settings.speakerBestLapCueEnabled) {
playBestLapCue();
} else if (type === "top3" && state.settings.speakerTop3CueEnabled) {
playLeaderCue();
}
}
}
function speakText(text) {
if (!state.settings.audioEnabled || !("speechSynthesis" in window) || !text) {
return;
}
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = currentLanguage() === "sv" ? "sv-SE" : "en-US";
utterance.rate = 1;
window.speechSynthesis.cancel();
window.speechSynthesis.speak(utterance);
}
function announcePassing(entry) {
if (!state.settings.audioEnabled) {
return;
}
if (state.settings.passingSoundMode === "beep") {
playPassingBeep();
return;
}
if (state.settings.passingSoundMode === "name") {
speakText(entry?.displayName || entry?.driverName || t("common.unknown_driver"));
}
}
function announceRaceFinished() {
if (!state.settings.audioEnabled || !state.settings.finishVoiceEnabled) {
const session = getActiveSession();
if (session) {
pushOverlayEvent("finish", `${session.name}${t("timing.race_finished")}`);
}
return;
}
const session = getActiveSession();
if (session) {
pushOverlayEvent("finish", `${session.name}${t("timing.race_finished")}`);
}
playFinishSiren();
}
function handleSessionTimerTick() {
const active = getActiveSession();
if (!active || active.status !== "running") {
return { changed: false };
}
if (isUntimedSession(active)) {
return { changed: false };
}
const timing = getSessionTiming(active);
if (timing.remainingMs > 0) {
return { changed: false };
}
active.status = "finished";
active.endedAt = Date.now();
active.finishedByTimer = true;
if (lastFinishAnnouncementSessionId !== active.id) {
announceRaceFinished();
lastFinishAnnouncementSessionId = active.id;
}
saveState();
return { changed: true };
}
function renderDashboard() {
const active = getActiveSession();
const totalPassings = Object.values(state.resultsBySession).reduce(
(sum, x) => sum + (x.passings?.length || 0),
0
);
const backendUrl = getBackendUrl();
const decoderUrl = state.settings.wsUrl || "-";
const audioProfile =
state.settings.passingSoundMode === "name"
? t("settings.passing_sound_name")
: state.settings.passingSoundMode === "beep"
? t("settings.passing_sound_beep")
: t("settings.passing_sound_off");
dom.view.innerHTML = `
<div class="grid cols-4">
${statCard(t("dashboard.events"), String(state.events.length), t("dashboard.created"))}
${statCard(t("dashboard.drivers"), String(state.drivers.length), t("dashboard.registered"))}
${statCard(t("dashboard.cars"), String(state.cars.length), t("dashboard.track_fleet"))}
${statCard(t("dashboard.passings"), String(totalPassings), t("dashboard.captured"))}
</div>
<div class="panel-row">
<section class="panel">
<div class="panel-header">
<h3>${t("dashboard.live_session")}</h3>
<span class="pill ${active ? "pill-green" : ""}">${active ? getStatusLabel(active.status) : t("dashboard.idle")}</span>
</div>
<div class="panel-body">
${
active
? `<p><strong>${active.name}</strong> (${getSessionTypeLabel(active.type)})</p>
<p>${getEventName(active.eventId)}${getModeLabel(active.mode)}</p>
<p>${t("dashboard.duration")}: ${active.durationMin} min</p>`
: `<p>${t("dashboard.no_session")}</p>`
}
</div>
</section>
<section class="panel">
<div class="panel-header">
<h3>${t("dashboard.live_board")}</h3>
</div>
<div class="panel-body dashboard-live-stack">
<p class="hint">${t("dashboard.live_note")}</p>
<div class="dashboard-live-grid">
<article class="dashboard-live-card">
<span>${t("dashboard.decoder_feed")}</span>
<strong>${state.decoder.connected ? t("timing.connected") : t("timing.disconnected")}</strong>
<small>${escapeHtml(decoderUrl)}</small>
</article>
<article class="dashboard-live-card">
<span>${t("dashboard.backend_link")}</span>
<strong>${backend.available ? t("settings.online") : t("settings.offline")}</strong>
<small>${escapeHtml(backendUrl)}</small>
</article>
<article class="dashboard-live-card">
<span>${t("dashboard.audio_profile")}</span>
<strong>${state.settings.audioEnabled ? audioProfile : t("settings.passing_sound_off")}</strong>
<small>${state.settings.finishVoiceEnabled ? t("settings.finish_voice") : "-"}</small>
</article>
</div>
<div class="actions">
<button id="goEvents" class="btn btn-primary">${t("dashboard.create_event")}</button>
<button id="goTiming" class="btn">${t("dashboard.open_timing")}</button>
<button id="openDashboardOverlay" class="btn">${t("timing.open_overlay")}</button>
<button id="connectNow" class="btn">${t("dashboard.connect_decoder")}</button>
<button id="disconnectNow" class="btn">${t("timing.disconnect")}</button>
<button id="dashboardTestAudio" class="btn">${t("settings.test_audio")}</button>
</div>
</div>
</section>
</div>
<section class="panel">
<div class="panel-header">
<h3>${t("dashboard.recent_sessions")}</h3>
</div>
<div class="panel-body">
${renderSessionsTable(state.sessions.slice(-8).reverse())}
</div>
</section>
`;
document.getElementById("goEvents")?.addEventListener("click", () => {
currentView = "events";
renderNav();
renderView();
});
document.getElementById("goTiming")?.addEventListener("click", () => {
currentView = "timing";
renderNav();
renderView();
});
document.getElementById("connectNow")?.addEventListener("click", connectDecoder);
document.getElementById("disconnectNow")?.addEventListener("click", disconnectDecoder);
document.getElementById("openDashboardOverlay")?.addEventListener("click", openOverlayWindow);
document.getElementById("dashboardTestAudio")?.addEventListener("click", () => {
ensureAudioContext();
playPassingBeep();
if (state.settings.finishVoiceEnabled) {
setTimeout(playFinishSiren, 220);
}
});
}
function statCard(label, value, note) {
return `
<article class="stat-card">
<p>${label}</p>
<h3>${value}</h3>
<small>${note}</small>
</article>
`;
}
function renderClasses() {
const editingClass = state.classes.find((item) => item.id === selectedClassEditId) || null;
dom.view.innerHTML = `
<div class="panel-row">
<section class="panel">
<div class="panel-header"><h3>${t("classes.create")}</h3></div>
<form id="classForm" class="form-row panel-body">
<input required name="name" placeholder="${t("classes.placeholder")}" />
<button class="btn btn-primary" type="submit">${t("classes.add")}</button>
</form>
</section>
</div>
<section class="panel">
<div class="panel-header"><h3>${t("classes.title")}</h3></div>
<div class="panel-body">
${renderTable(
[t("table.name"), t("events.actions")],
state.classes.map(
(c) => `
<tr>
<td>${escapeHtml(c.name)}</td>
<td class="actions-inline">
<button id="class-edit-${c.id}" class="btn">${t("common.edit")}</button>
<button id="class-delete-${c.id}" class="btn btn-danger">${t("common.delete")}</button>
</td>
</tr>
`
)
)}
</div>
</section>
${
editingClass
? `
<div class="modal-overlay" id="classEditModalOverlay">
<div class="modal-card">
<div class="panel-header">
<h3>${t("common.edit")}</h3>
<button class="btn" id="classEditCancel">${t("common.cancel")}</button>
</div>
<form id="classEditForm" class="panel-body form-grid cols-2">
<input name="name" required value="${escapeHtml(editingClass.name)}" placeholder="${t("classes.placeholder")}" />
<p class="form-error" id="classEditError" hidden></p>
<div class="actions-inline">
<button class="btn btn-primary" type="submit">${t("common.save")}</button>
<button class="btn" id="classEditCancelFooter" type="button">${t("common.cancel")}</button>
</div>
</form>
</div>
</div>
`
: ""
}
`;
document.getElementById("classForm")?.addEventListener("submit", (e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
state.classes.push({ id: uid("class"), name: String(form.get("name")).trim() });
saveState();
renderView();
});
state.classes.forEach((item) => {
document.getElementById(`class-edit-${item.id}`)?.addEventListener("click", () => {
selectedClassEditId = item.id;
renderView();
});
document.getElementById(`class-delete-${item.id}`)?.addEventListener("click", () => {
state.classes = state.classes.filter((x) => x.id !== item.id);
saveState();
renderView();
});
});
document.getElementById("classEditCancel")?.addEventListener("click", () => {
selectedClassEditId = null;
renderView();
});
document.getElementById("classEditCancelFooter")?.addEventListener("click", () => {
selectedClassEditId = null;
renderView();
});
document.getElementById("classEditModalOverlay")?.addEventListener("click", (event) => {
if (event.target?.id === "classEditModalOverlay") {
selectedClassEditId = null;
renderView();
}
});
bindModalShell("classEditModalOverlay", () => {
selectedClassEditId = null;
renderView();
});
document.getElementById("classEditForm")?.addEventListener("submit", (event) => {
event.preventDefault();
if (!editingClass) {
return;
}
const form = new FormData(event.currentTarget);
const cleaned = String(form.get("name") || "").trim();
if (!cleaned) {
setFormError("classEditError", t("validation.required_name"));
return;
}
setFormError("classEditError", "");
editingClass.name = cleaned;
selectedClassEditId = null;
saveState();
renderView();
});
}
function renderDrivers() {
const classOptions = state.classes
.map((c) => `<option value="${c.id}">${escapeHtml(c.name)}</option>`)
.join("");
const editingDriver = state.drivers.find((driver) => driver.id === selectedDriverEditId) || null;
dom.view.innerHTML = `
<section class="panel">
<div class="panel-header"><h3>${t("drivers.create")}</h3></div>
<form id="driverForm" class="panel-body form-grid cols-4">
<input required name="name" placeholder="${t("drivers.name_placeholder")}" />
<select name="classId">${classOptions}</select>
<input name="transponder" placeholder="${t("drivers.transponder_placeholder")}" />
<button class="btn btn-primary" type="submit">${t("drivers.add")}</button>
</form>
</section>
<section class="panel">
<div class="panel-header"><h3>${t("drivers.title")}</h3></div>
<div class="panel-body">
${renderTable(
[t("table.name"), t("table.class"), t("table.transponder"), t("events.actions")],
state.drivers.map(
(d) => `
<tr>
<td>${escapeHtml(d.name)}</td>
<td>${escapeHtml(getClassName(d.classId))}</td>
<td>${escapeHtml(d.transponder || "-")}</td>
<td class="actions-inline">
<button id="driver-edit-${d.id}" class="btn">${t("common.edit")}</button>
<button id="driver-delete-${d.id}" class="btn btn-danger">${t("common.delete")}</button>
</td>
</tr>
`
)
)}
</div>
</section>
${
editingDriver
? `
<div class="modal-overlay" id="driverEditModalOverlay">
<div class="modal-card">
<div class="panel-header">
<h3>${t("common.edit")}</h3>
<button class="btn" id="driverEditCancel">${t("common.cancel")}</button>
</div>
<form id="driverEditForm" class="panel-body form-grid cols-3">
<input name="name" required value="${escapeHtml(editingDriver.name)}" placeholder="${t("drivers.name_placeholder")}" />
<select name="classId">
${state.classes
.map(
(item) =>
`<option value="${item.id}" ${item.id === editingDriver.classId ? "selected" : ""}>${escapeHtml(item.name)}</option>`
)
.join("")}
</select>
<input name="transponder" value="${escapeHtml(editingDriver.transponder || "")}" placeholder="${t("drivers.transponder_placeholder")}" />
<p class="form-error" id="driverEditError" hidden></p>
<div class="actions-inline">
<button class="btn btn-primary" type="submit">${t("common.save")}</button>
<button class="btn" id="driverEditCancelFooter" type="button">${t("common.cancel")}</button>
</div>
</form>
</div>
</div>
`
: ""
}
`;
document.getElementById("driverForm")?.addEventListener("submit", (e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
state.drivers.push({
id: uid("driver"),
name: String(form.get("name")).trim(),
classId: String(form.get("classId")),
transponder: String(form.get("transponder") || "").trim(),
});
saveState();
renderView();
});
state.drivers.forEach((d) => {
document.getElementById(`driver-edit-${d.id}`)?.addEventListener("click", () => {
selectedDriverEditId = d.id;
renderView();
});
document.getElementById(`driver-delete-${d.id}`)?.addEventListener("click", () => {
state.drivers = state.drivers.filter((x) => x.id !== d.id);
state.sessions.forEach((s) => {
s.assignments = (s.assignments || []).filter((a) => a.driverId !== d.id);
});
saveState();
renderView();
});
});
document.getElementById("driverEditCancel")?.addEventListener("click", () => {
selectedDriverEditId = null;
renderView();
});
document.getElementById("driverEditCancelFooter")?.addEventListener("click", () => {
selectedDriverEditId = null;
renderView();
});
document.getElementById("driverEditModalOverlay")?.addEventListener("click", (event) => {
if (event.target?.id === "driverEditModalOverlay") {
selectedDriverEditId = null;
renderView();
}
});
bindModalShell("driverEditModalOverlay", () => {
selectedDriverEditId = null;
renderView();
});
document.getElementById("driverEditForm")?.addEventListener("submit", (event) => {
event.preventDefault();
if (!editingDriver) {
return;
}
const form = new FormData(event.currentTarget);
const cleanedName = String(form.get("name") || "").trim();
const cleanedClassId = String(form.get("classId") || "").trim();
const cleanedTp = String(form.get("transponder") || "").trim();
if (!cleanedName) {
setFormError("driverEditError", t("validation.required_name"));
return;
}
if (cleanedClassId && !state.classes.some((item) => item.id === cleanedClassId)) {
setFormError("driverEditError", t("validation.invalid_selection"));
return;
}
setFormError("driverEditError", "");
editingDriver.name = cleanedName;
editingDriver.classId = cleanedClassId || editingDriver.classId;
editingDriver.transponder = cleanedTp;
selectedDriverEditId = null;
saveState();
renderView();
});
}
function renderCars() {
const editingCar = state.cars.find((car) => car.id === selectedCarEditId) || null;
dom.view.innerHTML = `
<section class="panel">
<div class="panel-header"><h3>${t("cars.create")}</h3></div>
<form id="carForm" class="panel-body form-grid cols-3">
<input required name="name" placeholder="${t("cars.name_placeholder")}" />
<input required name="transponder" placeholder="${t("cars.transponder_placeholder")}" />
<button class="btn btn-primary" type="submit">${t("cars.add")}</button>
</form>
</section>
<section class="panel">
<div class="panel-header"><h3>${t("cars.title")}</h3></div>
<div class="panel-body">
${renderTable(
[t("table.car"), t("table.transponder"), t("events.actions")],
state.cars.map(
(c) => `
<tr>
<td>${escapeHtml(c.name)}</td>
<td>${escapeHtml(c.transponder)}</td>
<td class="actions-inline">
<button id="car-edit-${c.id}" class="btn">${t("common.edit")}</button>
<button id="car-delete-${c.id}" class="btn btn-danger">${t("common.delete")}</button>
</td>
</tr>
`
)
)}
</div>
</section>
${
editingCar
? `
<div class="modal-overlay" id="carEditModalOverlay">
<div class="modal-card">
<div class="panel-header">
<h3>${t("common.edit")}</h3>
<button class="btn" id="carEditCancel">${t("common.cancel")}</button>
</div>
<form id="carEditForm" class="panel-body form-grid cols-3">
<input name="name" required value="${escapeHtml(editingCar.name)}" placeholder="${t("cars.name_placeholder")}" />
<input
name="transponder"
required
value="${escapeHtml(editingCar.transponder || "")}"
placeholder="${t("cars.transponder_placeholder")}"
/>
<p class="form-error" id="carEditError" hidden></p>
<div class="actions-inline">
<button class="btn btn-primary" type="submit">${t("common.save")}</button>
<button class="btn" id="carEditCancelFooter" type="button">${t("common.cancel")}</button>
</div>
</form>
</div>
</div>
`
: ""
}
`;
document.getElementById("carForm")?.addEventListener("submit", (e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
state.cars.push({
id: uid("car"),
name: String(form.get("name")).trim(),
transponder: String(form.get("transponder")).trim(),
});
saveState();
renderView();
});
state.cars.forEach((c) => {
document.getElementById(`car-edit-${c.id}`)?.addEventListener("click", () => {
selectedCarEditId = c.id;
renderView();
});
document.getElementById(`car-delete-${c.id}`)?.addEventListener("click", () => {
state.cars = state.cars.filter((x) => x.id !== c.id);
state.sessions.forEach((s) => {
s.assignments = (s.assignments || []).filter((a) => a.carId !== c.id);
});
saveState();
renderView();
});
});
document.getElementById("carEditCancel")?.addEventListener("click", () => {
selectedCarEditId = null;
renderView();
});
document.getElementById("carEditCancelFooter")?.addEventListener("click", () => {
selectedCarEditId = null;
renderView();
});
document.getElementById("carEditModalOverlay")?.addEventListener("click", (event) => {
if (event.target?.id === "carEditModalOverlay") {
selectedCarEditId = null;
renderView();
}
});
bindModalShell("carEditModalOverlay", () => {
selectedCarEditId = null;
renderView();
});
document.getElementById("carEditForm")?.addEventListener("submit", (event) => {
event.preventDefault();
if (!editingCar) {
return;
}
const form = new FormData(event.currentTarget);
const cleanedName = String(form.get("name") || "").trim();
const cleanedTp = String(form.get("transponder") || "").trim();
if (!cleanedName) {
setFormError("carEditError", t("validation.required_name"));
return;
}
if (!cleanedTp) {
setFormError("carEditError", t("validation.required_transponder"));
return;
}
setFormError("carEditError", "");
editingCar.name = cleanedName;
editingCar.transponder = cleanedTp;
selectedCarEditId = null;
saveState();
renderView();
});
}
function renderEvents() {
renderEventWorkspace("track");
}
function renderRaceSetup() {
renderEventWorkspace("race");
}
function renderEventWorkspace(mode) {
const isRaceMode = mode === "race";
const filteredEvents = state.events.filter((event) => event.mode === mode);
const classOptions = state.classes
.map((c) => `<option value="${c.id}">${escapeHtml(c.name)}</option>`)
.join("");
const editingEvent = filteredEvents.find((event) => event.id === selectedEventEditId) || null;
dom.view.innerHTML = `
<section class="panel">
<div class="panel-header"><h3>${t(isRaceMode ? "events.create_race" : "events.create")}</h3></div>
<div class="panel-body">
<p>${t(isRaceMode ? "events.race_only_intro" : "events.track_only_intro")}</p>
</div>
<form id="eventForm" class="panel-body form-grid cols-4">
<input required name="name" placeholder="${t("events.name_placeholder")}" />
<input required type="date" name="date" />
<select name="classId">${classOptions}</select>
<button class="btn btn-primary" type="submit">${t(isRaceMode ? "events.add_race" : "events.add")}</button>
</form>
</section>
<section class="panel">
<div class="panel-header"><h3>${t(isRaceMode ? "events.race_title" : "events.title")}</h3></div>
<div class="panel-body">
${renderTable(
[t("table.name"), t("table.date"), t("table.class"), t("table.mode"), t("events.sessions"), t("events.actions")],
filteredEvents.map((e) => {
const sessions = getSessionsForEvent(e.id);
return `
<tr>
<td>${escapeHtml(e.name)}</td>
<td>${escapeHtml(e.date)}</td>
<td>${escapeHtml(getClassName(e.classId))}</td>
<td>${getModeLabel(e.mode)}</td>
<td>${sessions.length}</td>
<td class="actions-inline">
<button id="event-edit-${e.id}" class="btn">${t("events.edit")}</button>
<button id="event-manage-${e.id}" class="btn">${t("events.manage")}</button>
<button id="event-delete-${e.id}" class="btn btn-danger">${t("common.delete")}</button>
</td>
</tr>
`;
})
)}
</div>
</section>
<section id="eventManageArea"></section>
${
editingEvent
? `
<div class="modal-overlay" id="eventEditModalOverlay">
<div class="modal-card">
<div class="panel-header">
<h3>${t("common.edit")}</h3>
<button class="btn" id="eventEditCancel">${t("common.cancel")}</button>
</div>
<form id="eventEditForm" class="panel-body form-grid cols-3">
<input name="name" required value="${escapeHtml(editingEvent.name)}" placeholder="${t("events.name_placeholder")}" />
<input name="date" required type="date" value="${escapeHtml(editingEvent.date || "")}" />
<select name="classId">
${state.classes
.map(
(item) =>
`<option value="${item.id}" ${item.id === editingEvent.classId ? "selected" : ""}>${escapeHtml(item.name)}</option>`
)
.join("")}
</select>
<p class="form-error" id="eventEditError" hidden></p>
<div class="actions-inline">
<button class="btn btn-primary" type="submit">${t("common.save")}</button>
<button class="btn" id="eventEditCancelFooter" type="button">${t("common.cancel")}</button>
</div>
</form>
</div>
</div>
`
: ""
}
`;
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", () => {
selectedEventEditId = e.id;
renderView();
});
document.getElementById(`event-delete-${e.id}`)?.addEventListener("click", () => {
const sessionIds = getSessionsForEvent(e.id).map((s) => s.id);
state.events = state.events.filter((x) => x.id !== e.id);
state.sessions = state.sessions.filter((x) => x.eventId !== e.id);
sessionIds.forEach((id) => delete state.resultsBySession[id]);
if (state.activeSessionId && sessionIds.includes(state.activeSessionId)) {
state.activeSessionId = null;
}
saveState();
renderView();
});
document.getElementById(`event-manage-${e.id}`)?.addEventListener("click", () => {
renderEventManager(e.id);
});
});
document.getElementById("eventEditCancel")?.addEventListener("click", () => {
selectedEventEditId = null;
renderView();
});
document.getElementById("eventEditCancelFooter")?.addEventListener("click", () => {
selectedEventEditId = null;
renderView();
});
document.getElementById("eventEditModalOverlay")?.addEventListener("click", (event) => {
if (event.target?.id === "eventEditModalOverlay") {
selectedEventEditId = null;
renderView();
}
});
bindModalShell("eventEditModalOverlay", () => {
selectedEventEditId = null;
renderView();
});
document.getElementById("eventEditForm")?.addEventListener("submit", (event) => {
event.preventDefault();
if (!editingEvent) {
return;
}
const form = new FormData(event.currentTarget);
const cleanedName = String(form.get("name") || "").trim();
const cleanedDate = String(form.get("date") || "").trim();
const cleanedClassId = String(form.get("classId") || "").trim();
if (!cleanedName) {
setFormError("eventEditError", t("validation.required_name"));
return;
}
if (!cleanedDate) {
setFormError("eventEditError", t("validation.required_date"));
return;
}
if (!isValidIsoDate(cleanedDate)) {
setFormError("eventEditError", t("validation.invalid_date"));
return;
}
if (cleanedClassId && !state.classes.some((item) => item.id === cleanedClassId)) {
setFormError("eventEditError", t("validation.invalid_selection"));
return;
}
setFormError("eventEditError", "");
editingEvent.name = cleanedName;
editingEvent.date = cleanedDate;
editingEvent.classId = cleanedClassId || editingEvent.classId;
selectedEventEditId = null;
saveState();
renderView();
});
}
function renderEventManager(eventId) {
const event = state.events.find((e) => e.id === eventId);
if (!event) {
return;
}
const normalizedEvent = normalizeEvent(event);
if (normalizedEvent !== event) {
Object.assign(event, normalizedEvent);
}
ensureRaceParticipantsConfigured(event);
const sessions = getSessionsForEvent(eventId);
const eventManageArea = document.getElementById("eventManageArea");
if (!eventManageArea) {
return;
}
const driverOptions = state.drivers
.map((d) => `<option value="${d.id}">${escapeHtml(d.name)}</option>`)
.join("");
const teamDriverPool = event.mode === "race" ? getTeamDriverPool(event) : { drivers: [], fallback: false };
const raceDrivers = event.mode === "race" ? teamDriverPool.drivers : [];
const raceTeams = event.mode === "race" ? getEventTeams(event) : [];
if (selectedTeamEditId && !raceTeams.some((team) => team.id === selectedTeamEditId)) {
selectedTeamEditId = null;
}
const editingTeam = event.mode === "race" ? raceTeams.find((team) => team.id === selectedTeamEditId) || null : null;
const carOptions = state.cars
.map((c) => `<option value="${c.id}">${escapeHtml(c.name)} (${escapeHtml(c.transponder)})</option>`)
.join("");
const branding = normalizeBrandingConfig(event.branding);
const editingSession = sessions.find((session) => session.id === selectedSessionEditId) || null;
const gridSessions = event.mode === "race" ? sessions.filter((session) => normalizeStartMode(session.startMode) === "position") : [];
if (selectedGridSessionId && !gridSessions.some((session) => session.id === selectedGridSessionId)) {
selectedGridSessionId = "";
}
const selectedGridSession =
gridSessions.find((session) => session.id === selectedGridSessionId) || gridSessions[0] || null;
if (!selectedGridSessionId && selectedGridSession) {
selectedGridSessionId = selectedGridSession.id;
}
eventManageArea.innerHTML = `
<section class="panel">
<div class="panel-header">
<h3>${t("events.manage_title")}: ${escapeHtml(event.name)}</h3>
</div>
<div class="panel-body">
<form id="sessionForm" class="form-grid cols-5">
<input required name="name" placeholder="${t("events.session_name")}" />
<select name="type">
${SESSION_TYPES.map((s) => `<option value="${s}">${getSessionTypeLabel(s)}</option>`).join("")}
</select>
<input required type="number" min="1" name="durationMin" placeholder="${t("events.duration_placeholder")}" />
<select name="startMode">
<option value="mass">${t("events.start_mode_mass")}</option>
<option value="position">${t("events.start_mode_position")}</option>
<option value="staggered">${t("events.start_mode_staggered")}</option>
</select>
<input type="number" min="0" name="seedBestLapCount" placeholder="${t("events.seed_best_laps")}" />
<input type="number" min="0" step="1" name="staggerGapSec" placeholder="${t("events.stagger_gap_sec")}" />
<input type="number" min="1" name="maxCars" placeholder="${t("events.max_cars_placeholder")}" />
<button class="btn btn-primary" type="submit">${t("events.add_session")}</button>
</form>
<p class="hint">${t("events.seed_best_laps_hint")}</p>
<p class="hint">${t("events.free_practice_note")}</p>
<p class="hint">${t("events.open_practice_note")}</p>
<div class="mt-16">
${renderTable(
[t("table.session"), t("table.type"), t("table.duration"), t("table.start_mode"), t("table.seeding"), t("table.status"), t(event.mode === "track" ? "events.assignments" : "events.participants"), t("events.actions")],
sessions.map((s) => {
const assignCount =
event.mode === "track"
? (s.assignments || []).length
: s.type === "team_race"
? raceTeams.length
: getSessionEntrants(s).length;
return `
<tr>
<td>${escapeHtml(s.name)}</td>
<td>${escapeHtml(getSessionTypeLabel(s.type))}</td>
<td>${s.durationMin} min</td>
<td>${escapeHtml(getStartModeLabel(s.startMode))}</td>
<td>${s.seedBestLapCount > 0 ? `${s.seedBestLapCount}` : "-"}</td>
<td>${escapeHtml(getStatusLabel(s.status))}</td>
<td>${assignCount || (event.mode === "track" ? t("events.na") : "-")}</td>
<td class="actions-inline">
<button id="session-edit-${s.id}" class="btn">${t("events.edit_session")}</button>
<button id="session-active-${s.id}" class="btn">${t("events.set_active")}</button>
${
event.mode === "race" && normalizeStartMode(s.startMode) === "position"
? `<button id="session-grid-${s.id}" class="btn" type="button">${t("events.open_grid")}</button>`
: ""
}
${
event.mode === "race" && ["qualification", "final"].includes(s.type)
? `
<button id="session-sheet-print-${s.id}" class="btn" type="button">${t("events.print_heat_sheet")}</button>
<button id="session-sheet-export-${s.id}" class="btn" type="button">${t("events.export_heat_sheet")}</button>
<button id="session-sheet-pdf-${s.id}" class="btn" type="button">${t("events.pdf_heat_sheet")}</button>
`
: ""
}
<button id="session-delete-${s.id}" class="btn btn-danger">${t("common.delete")}</button>
</td>
</tr>
`;
})
)}
</div>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("events.branding")}</h3></div>
<form id="eventBrandingForm" class="panel-body form-grid cols-3">
<input name="brandName" value="${escapeHtml(branding.brandName)}" placeholder="${t("events.brand_name")}" />
<input name="brandTagline" value="${escapeHtml(branding.brandTagline)}" placeholder="${t("events.brand_tagline")}" />
<input name="pdfFooter" value="${escapeHtml(branding.pdfFooter)}" placeholder="${t("events.brand_footer")}" />
<select name="pdfTheme">
<option value="" ${!branding.pdfTheme ? "selected" : ""}>${t("events.branding_use_global")}</option>
<option value="classic" ${branding.pdfTheme === "classic" ? "selected" : ""}>${t("settings.pdf_theme_classic")}</option>
<option value="minimal" ${branding.pdfTheme === "minimal" ? "selected" : ""}>${t("settings.pdf_theme_minimal")}</option>
<option value="motorsport" ${branding.pdfTheme === "motorsport" ? "selected" : ""}>${t("settings.pdf_theme_motorsport")}</option>
</select>
<button class="btn btn-primary" type="submit">${t("events.branding_save")}</button>
</form>
<div class="panel-body">
<p class="hint">${t("events.branding_note")}</p>
<div class="actions">
<input id="eventLogoUpload" type="file" accept="image/*" />
<button id="eventLogoClear" class="btn" type="button">${t("settings.logo_clear")}</button>
</div>
${branding.logoDataUrl ? `<div class="logo-preview mt-16"><img src="${escapeHtml(branding.logoDataUrl)}" alt="event-logo" /></div>` : ""}
</div>
</section>
${
event.mode === "track"
? `
<section class="panel mt-16">
<div class="panel-header"><h3>${t("events.sponsor_tools")}</h3></div>
<div class="panel-body">
<form id="sponsorRoundsForm" class="form-grid cols-5">
<input type="number" min="0" name="qualificationRounds" value="1" placeholder="${t("events.qual_rounds")}" />
<input type="number" min="0" name="heatRounds" value="3" placeholder="${t("events.heat_rounds")}" />
<input type="number" min="0" name="finalRounds" value="3" placeholder="${t("events.final_rounds")}" />
<input type="number" min="1" name="roundDuration" value="5" placeholder="${t("events.round_duration")}" />
<button class="btn btn-primary" type="submit">${t("events.create_rounds")}</button>
</form>
<p class="hint">
${t("events.tp_rule")}
</p>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("events.assign_title")}</h3></div>
<div class="panel-body">
<form id="assignForm" class="form-grid cols-4">
<select name="sessionId">${sessions
.map((s) => `<option value="${s.id}">${escapeHtml(s.name)}</option>`)
.join("")}</select>
<select name="driverId">${driverOptions}</select>
<select name="carId">${carOptions}</select>
<button class="btn btn-primary" type="submit">${t("events.assign")}</button>
</form>
<div class="actions mt-16">
<button id="autoAssignSession" class="btn" type="button">${t("events.auto_assign")}</button>
<button id="clearAssignSession" class="btn btn-danger" type="button">${t("events.clear_assign")}</button>
</div>
<div id="assignmentList" class="mt-16"></div>
</div>
</section>
`
: ""
}
${
event.mode === "race"
? `
<section class="panel mt-16">
<div class="panel-header"><h3>${t("events.select_participants")}</h3></div>
<div class="panel-body">
<div class="actions">
<button id="selectAllParticipants" class="btn" type="button">${t("events.select_all_participants")}</button>
<button id="clearParticipants" class="btn btn-danger" type="button">${t("events.clear_participants")}</button>
</div>
<div class="check-grid mt-16">
${raceDrivers
.map((driver) => {
const checked = event.raceConfig.participantsConfigured
? (event.raceConfig.driverIds || []).includes(driver.id)
: true;
return `
<label class="check-card">
<input type="checkbox" class="race-participant" value="${driver.id}" ${checked ? "checked" : ""} />
<span>${escapeHtml(driver.name)}${driver.transponder ? ` (${escapeHtml(driver.transponder)})` : ""}</span>
</label>
`;
})
.join("")}
</div>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("events.teams")}</h3></div>
<div class="panel-body">
<p class="hint">${t("events.team_race_intro")}</p>
<p class="hint">${t("events.team_steps")}</p>
<form id="teamForm" class="form-grid cols-4 team-create-form">
<input name="teamName" required placeholder="${t("events.team_name")}" />
<button class="btn btn-primary" type="submit">${t("events.add_team")}</button>
</form>
<p class="hint">${t("events.team_hint")}</p>
<div class="panel-row mt-16">
<section class="panel">
<div class="panel-header"><h3>${t("events.team_drivers")}</h3></div>
<div class="panel-body"><p class="hint">${t("events.team_form_drivers")}</p></div>
${teamDriverPool.fallback ? `<div class="panel-body"><p class="hint">${t("events.team_driver_fallback")}</p></div>` : ""}
<div class="panel-body check-grid">
${raceDrivers
.map(
(driver) => `
<label class="check-card">
<input type="checkbox" name="teamDriverIds" form="teamForm" value="${driver.id}" />
<span>${escapeHtml(driver.name)}${driver.transponder ? ` (${escapeHtml(driver.transponder)})` : ""}</span>
</label>
`
)
.join("")}
</div>
</section>
<section class="panel">
<div class="panel-header"><h3>${t("events.team_cars")}</h3></div>
<div class="panel-body"><p class="hint">${t("events.team_form_cars")}</p></div>
<div class="panel-body check-grid">
${state.cars
.map(
(car) => `
<label class="check-card">
<input type="checkbox" name="teamCarIds" form="teamForm" value="${car.id}" />
<span>${escapeHtml(car.name)} (${escapeHtml(car.transponder || "-")})</span>
</label>
`
)
.join("")}
</div>
</section>
</div>
<div class="mt-16">
${
raceTeams.length
? raceTeams
.map(
(team) => `
<article class="team-card">
<div>
<strong>${escapeHtml(team.name)}</strong>
<div class="hint">${t("events.team_drivers")}: ${escapeHtml(
team.driverIds.map((driverId) => getDriverDisplayById(driverId)).join(", ") || "-"
)}</div>
<div class="hint">${t("events.team_cars")}: ${escapeHtml(
team.carIds
.map((carId) => {
const car = state.cars.find((item) => item.id === carId);
return car ? `${car.name} (${car.transponder || "-"})` : "";
})
.filter(Boolean)
.join(", ") || "-"
)}</div>
</div>
<div class="actions-inline">
<button id="team-edit-${team.id}" class="btn" type="button">${t("events.edit_team")}</button>
<button id="team-delete-${team.id}" class="btn btn-danger" type="button">${t("common.delete")}</button>
</div>
</article>
`
)
.join("")
: `<p>${t("events.no_teams")}</p>`
}
</div>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("events.race_format")}</h3></div>
<div class="panel-body">
<p>${t("events.race_format_intro")}</p>
</div>
<form id="raceFormatForm" class="panel-body form-grid cols-5">
${renderRaceFormatField(
"events.qualifying_scoring",
"events.qualifying_scoring_hint",
`<select name="qualifyingScoring">
<option value="points" ${event.raceConfig.qualifyingScoring === "points" ? "selected" : ""}>${t("events.qualifying_scoring_points")}</option>
<option value="best" ${event.raceConfig.qualifyingScoring === "best" ? "selected" : ""}>${t("events.qualifying_scoring_best")}</option>
</select>`
)}
${renderRaceFormatField(
"events.qualifying_rounds",
"events.qualifying_rounds_hint",
`<input type="number" min="1" name="qualifyingRounds" value="${event.raceConfig.qualifyingRounds}" />`
)}
${renderRaceFormatField(
"events.cars_per_heat",
"events.cars_per_heat_hint",
`<input type="number" min="2" name="carsPerHeat" value="${event.raceConfig.carsPerHeat}" />`
)}
${renderRaceFormatField(
"events.qual_duration",
"events.qual_duration_hint",
`<input type="number" min="1" name="qualDurationMin" value="${event.raceConfig.qualDurationMin}" />`
)}
${renderRaceFormatField(
"events.qual_start_mode",
"events.qual_start_mode_hint",
`<select name="qualStartMode">
<option value="mass" ${event.raceConfig.qualStartMode === "mass" ? "selected" : ""}>${t("events.start_mode_mass")}</option>
<option value="position" ${event.raceConfig.qualStartMode === "position" ? "selected" : ""}>${t("events.start_mode_position")}</option>
<option value="staggered" ${event.raceConfig.qualStartMode === "staggered" ? "selected" : ""}>${t("events.start_mode_staggered")}</option>
</select>`
)}
${renderRaceFormatField(
"events.counted_qual_rounds",
"events.counted_qual_rounds_hint",
`<input type="number" min="1" name="countedQualRounds" value="${event.raceConfig.countedQualRounds}" />`
)}
${renderRaceFormatField(
"events.cars_per_final",
"events.cars_per_final_hint",
`<input type="number" min="2" name="carsPerFinal" value="${event.raceConfig.carsPerFinal}" />`
)}
${renderRaceFormatField(
"events.final_legs",
"events.final_legs_hint",
`<input type="number" min="1" name="finalLegs" value="${event.raceConfig.finalLegs}" />`
)}
${renderRaceFormatField(
"events.counted_final_legs",
"events.counted_final_legs_hint",
`<input type="number" min="1" name="countedFinalLegs" value="${event.raceConfig.countedFinalLegs}" />`
)}
${renderRaceFormatField(
"events.final_duration",
"events.final_duration_hint",
`<input type="number" min="1" name="finalDurationMin" value="${event.raceConfig.finalDurationMin}" />`
)}
${renderRaceFormatField(
"events.final_start_mode",
"events.final_start_mode_hint",
`<select name="finalStartMode">
<option value="mass" ${event.raceConfig.finalStartMode === "mass" ? "selected" : ""}>${t("events.start_mode_mass")}</option>
<option value="position" ${event.raceConfig.finalStartMode === "position" ? "selected" : ""}>${t("events.start_mode_position")}</option>
<option value="staggered" ${event.raceConfig.finalStartMode === "staggered" ? "selected" : ""}>${t("events.start_mode_staggered")}</option>
</select>`
)}
${renderRaceFormatField(
"events.min_lap_time",
"events.min_lap_time_hint",
`<input type="number" min="0" step="0.1" name="minLapSec" value="${((event.raceConfig.minLapMs || 0) / 1000).toFixed(1)}" />`
)}
${renderRaceFormatField(
"events.max_lap_time",
"events.max_lap_time_hint",
`<input type="number" min="1" step="0.1" name="maxLapSec" value="${((event.raceConfig.maxLapMs || 60000) / 1000).toFixed(1)}" />`
)}
${renderRaceFormatField(
"events.bump_count",
"events.bump_count_hint",
`<input type="number" min="0" name="bumpCount" value="${event.raceConfig.bumpCount}" />`
)}
${renderRaceFormatField(
"events.reserve_bump_slots",
"events.reserve_bump_slots_hint",
`<label class="toggle"><input type="checkbox" name="reserveBumpSlots" ${event.raceConfig.reserveBumpSlots ? "checked" : ""} /><span>${t("events.reserve_bump_slots")}</span></label>`,
{ checkbox: true }
)}
${renderRaceFormatField(
"events.source_for_finals",
"events.finals_source_hint",
`<select name="finalsSource">
<option value="qualifying" ${event.raceConfig.finalsSource === "qualifying" ? "selected" : ""}>${t("events.finals_from_qualifying")}</option>
<option value="practice" ${event.raceConfig.finalsSource === "practice" ? "selected" : ""}>${t("events.finals_from_practice")}</option>
</select>`
)}
<button class="btn btn-primary" type="submit">${t("events.save_race_format")}</button>
</form>
<div class="panel-body">
<p>${t("events.race_driver_scope")}</p>
<p>${t("events.bump_reserved_note")}</p>
<div class="actions">
<button id="generateQualifying" class="btn" type="button">${t("events.generate_qualifying")}</button>
<button id="clearGeneratedQualifying" class="btn btn-danger" type="button">${t("events.clear_generated_qualifying")}</button>
<button id="reseedQualifying" class="btn" type="button">${t("events.reseed_qualifying")}</button>
<button id="generateFinals" class="btn btn-primary" type="button">${t("events.generate_finals")}</button>
<button id="clearGeneratedFinals" class="btn btn-danger" type="button">${t("events.clear_generated_finals")}</button>
<button id="applyBumps" class="btn" type="button">${t("events.apply_bumps")}</button>
</div>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("events.grid_editor")}</h3></div>
<div class="panel-body">
${renderGridEditor(selectedGridSession)}
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("events.practice_standings")}</h3></div>
<div class="panel-body">
${renderRaceStandingsTable(buildPracticeStandings(event), t("events.no_practice_results"))}
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("events.qualifying_standings")}</h3></div>
<div class="panel-body">
${renderRaceStandingsTable(buildQualifyingStandings(event), t("events.no_qualifying_results"))}
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("events.final_standings")}</h3></div>
<div class="panel-body">
${renderRaceStandingsTable(buildFinalStandings(event), t("events.no_final_results"))}
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("events.team_standings")}</h3></div>
<div class="panel-body">
<div class="actions">
<button id="printTeamResults" class="btn" type="button">${t("events.print_team_results")}</button>
<button id="pdfTeamResults" class="btn" type="button">${t("events.pdf_team_results")}</button>
</div>
<div class="mt-16">
${renderTeamRaceStandings(event)}
</div>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("events.final_matrix")}</h3></div>
<div class="panel-body">
<div class="actions">
<button id="printStartlists" class="btn" type="button">${t("events.print_startlists")}</button>
<button id="printResults" class="btn" type="button">${t("events.print_results")}</button>
<button id="pdfStartlists" class="btn" type="button">${t("events.pdf_startlists")}</button>
<button id="pdfResults" class="btn" type="button">${t("events.pdf_results")}</button>
</div>
<div class="mt-16">
${renderFinalMatrix(event)}
</div>
</div>
</section>
`
: ""
}
${
editingTeam
? `
<div class="modal-overlay" id="teamEditModalOverlay">
<div class="modal-card">
<div class="panel-header">
<h3>${t("events.edit_team")}</h3>
<button class="btn" id="teamEditCancel">${t("common.cancel")}</button>
</div>
<form id="teamEditForm" class="panel-body form-grid cols-2">
<input name="teamName" required value="${escapeHtml(editingTeam.name)}" placeholder="${t("events.team_name")}" />
<p class="form-error" id="teamEditError" hidden></p>
<div>
<h4>${t("events.team_drivers")}</h4>
<div class="check-grid">
${raceDrivers
.map(
(driver) => `
<label class="check-card">
<input type="checkbox" name="teamDriverIds" value="${driver.id}" ${editingTeam.driverIds.includes(driver.id) ? "checked" : ""} />
<span>${escapeHtml(driver.name)}${driver.transponder ? ` (${escapeHtml(driver.transponder)})` : ""}</span>
</label>
`
)
.join("")}
</div>
</div>
<div>
<h4>${t("events.team_cars")}</h4>
<div class="check-grid">
${state.cars
.map(
(car) => `
<label class="check-card">
<input type="checkbox" name="teamCarIds" value="${car.id}" ${editingTeam.carIds.includes(car.id) ? "checked" : ""} />
<span>${escapeHtml(car.name)} (${escapeHtml(car.transponder || "-")})</span>
</label>
`
)
.join("")}
</div>
</div>
<div class="actions-inline">
<button class="btn btn-primary" type="submit">${t("common.save")}</button>
<button class="btn" id="teamEditCancelFooter" type="button">${t("common.cancel")}</button>
</div>
</form>
</div>
</div>
`
: ""
}
${
editingSession
? `
<div class="modal-overlay" id="sessionEditModalOverlay">
<div class="modal-card">
<div class="panel-header">
<h3>${t("events.edit_session")}</h3>
<button class="btn" id="sessionEditCancel">${t("common.cancel")}</button>
</div>
<form id="sessionEditForm" class="panel-body form-grid cols-5">
<input name="name" required value="${escapeHtml(editingSession.name)}" placeholder="${t("events.session_name")}" />
<select name="type">
${SESSION_TYPES.map(
(item) => `<option value="${item}" ${item === editingSession.type ? "selected" : ""}>${getSessionTypeLabel(item)}</option>`
).join("")}
</select>
<input name="durationMin" required type="number" min="1" value="${editingSession.durationMin || 5}" />
<select name="startMode">
<option value="mass" ${normalizeStartMode(editingSession.startMode) === "mass" ? "selected" : ""}>${t("events.start_mode_mass")}</option>
<option value="position" ${normalizeStartMode(editingSession.startMode) === "position" ? "selected" : ""}>${t("events.start_mode_position")}</option>
<option value="staggered" ${normalizeStartMode(editingSession.startMode) === "staggered" ? "selected" : ""}>${t("events.start_mode_staggered")}</option>
</select>
<input name="seedBestLapCount" type="number" min="0" step="1" value="${editingSession.seedBestLapCount || 0}" />
<input name="staggerGapSec" type="number" min="0" step="1" value="${editingSession.staggerGapSec || 0}" />
<p class="form-error" id="sessionEditError" hidden></p>
<div class="actions-inline">
<button class="btn btn-primary" type="submit">${t("common.save")}</button>
<button class="btn" id="sessionEditCancelFooter" type="button">${t("common.cancel")}</button>
</div>
</form>
</div>
</div>
`
: ""
}
`;
document.getElementById("eventBrandingForm")?.addEventListener("submit", (e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
event.branding = normalizeBrandingConfig({
...event.branding,
brandName: String(form.get("brandName") || "").trim(),
brandTagline: String(form.get("brandTagline") || "").trim(),
pdfFooter: String(form.get("pdfFooter") || "").trim(),
pdfTheme: String(form.get("pdfTheme") || "").trim(),
});
saveState();
renderEventManager(eventId);
});
document.getElementById("eventLogoUpload")?.addEventListener("change", (eventInput) => {
const input = eventInput.currentTarget;
const file = input instanceof HTMLInputElement ? input.files?.[0] : null;
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = () => {
event.branding = normalizeBrandingConfig({
...event.branding,
logoDataUrl: typeof reader.result === "string" ? reader.result : "",
});
saveState();
renderEventManager(eventId);
};
reader.readAsDataURL(file);
});
document.getElementById("eventLogoClear")?.addEventListener("click", () => {
event.branding = normalizeBrandingConfig({
...event.branding,
logoDataUrl: "",
});
saveState();
renderEventManager(eventId);
});
document.getElementById("sessionForm")?.addEventListener("submit", (e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
state.sessions.push(normalizeSession({
id: uid("session"),
eventId,
name: String(form.get("name")).trim(),
type: String(form.get("type")),
durationMin: Number(form.get("durationMin")),
startMode: String(form.get("startMode") || "mass"),
seedBestLapCount: Math.max(0, Number(form.get("seedBestLapCount") || 0) || 0),
staggerGapSec: Math.max(0, Number(form.get("staggerGapSec") || 0) || 0),
maxCars: Number(form.get("maxCars") || 0) || null,
mode: event.mode,
status: "ready",
startedAt: null,
endedAt: null,
finishedByTimer: false,
assignments: [],
}));
saveState();
renderEventManager(eventId);
updateHeaderState();
});
sessions.forEach((s) => {
document.getElementById(`session-edit-${s.id}`)?.addEventListener("click", () => {
selectedSessionEditId = s.id;
renderEventManager(eventId);
});
document.getElementById(`session-active-${s.id}`)?.addEventListener("click", () => {
state.activeSessionId = s.id;
saveState();
updateHeaderState();
renderView();
});
document.getElementById(`session-delete-${s.id}`)?.addEventListener("click", () => {
state.sessions = state.sessions.filter((x) => x.id !== s.id);
delete state.resultsBySession[s.id];
if (state.activeSessionId === s.id) {
state.activeSessionId = null;
}
saveState();
renderEventManager(eventId);
updateHeaderState();
});
document.getElementById(`session-grid-${s.id}`)?.addEventListener("click", () => {
ensureSessionDriverOrder(s);
selectedGridSessionId = s.id;
saveState();
renderEventManager(eventId);
});
document.getElementById(`session-sheet-print-${s.id}`)?.addEventListener("click", () => {
openPrintWindow(`${getEventName(eventId)} - ${s.name}`, buildSessionHeatSheetHtml(s));
});
document.getElementById(`session-sheet-export-${s.id}`)?.addEventListener("click", () => {
exportSessionHeatSheet(s);
});
document.getElementById(`session-sheet-pdf-${s.id}`)?.addEventListener("click", async () => {
await exportSessionHeatSheetPdf(s);
});
});
document.getElementById("sessionEditCancel")?.addEventListener("click", () => {
selectedSessionEditId = null;
renderEventManager(eventId);
});
document.getElementById("sessionEditCancelFooter")?.addEventListener("click", () => {
selectedSessionEditId = null;
renderEventManager(eventId);
});
document.getElementById("sessionEditModalOverlay")?.addEventListener("click", (event) => {
if (event.target?.id === "sessionEditModalOverlay") {
selectedSessionEditId = null;
renderEventManager(eventId);
}
});
bindModalShell("sessionEditModalOverlay", () => {
selectedSessionEditId = null;
renderEventManager(eventId);
});
document.getElementById("sessionEditForm")?.addEventListener("submit", (event) => {
event.preventDefault();
if (!editingSession) {
return;
}
const form = new FormData(event.currentTarget);
const cleanedName = String(form.get("name") || "").trim();
const cleanedDuration = Number(form.get("durationMin") || editingSession.durationMin || 5) || 0;
if (!cleanedName) {
setFormError("sessionEditError", t("validation.required_name"));
return;
}
if (cleanedDuration < 1) {
setFormError("sessionEditError", t("validation.required_duration"));
return;
}
setFormError("sessionEditError", "");
editingSession.name = cleanedName;
editingSession.type = String(form.get("type") || editingSession.type);
editingSession.durationMin = Math.max(1, cleanedDuration);
editingSession.startMode = normalizeStartMode(String(form.get("startMode") || editingSession.startMode || "mass"));
editingSession.seedBestLapCount = Math.max(0, Number(form.get("seedBestLapCount") || 0) || 0);
editingSession.staggerGapSec = Math.max(0, Number(form.get("staggerGapSec") || 0) || 0);
selectedSessionEditId = null;
saveState();
renderEventManager(eventId);
});
if (event.mode === "track") {
document.getElementById("sponsorRoundsForm")?.addEventListener("submit", (e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
const qualificationRounds = Number(form.get("qualificationRounds") || 0);
const heatRounds = Number(form.get("heatRounds") || 0);
const finalRounds = Number(form.get("finalRounds") || 0);
const durationMin = Number(form.get("roundDuration") || 5);
createSponsorRounds(eventId, {
qualificationRounds,
heatRounds,
finalRounds,
durationMin,
});
saveState();
renderEventManager(eventId);
});
document.getElementById("assignForm")?.addEventListener("submit", (e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
const sessionId = String(form.get("sessionId"));
const session = state.sessions.find((x) => x.id === sessionId);
if (!session) {
return;
}
const driverId = String(form.get("driverId"));
const carId = String(form.get("carId"));
const car = state.cars.find((x) => x.id === carId);
if (!car) {
return;
}
const duplicateCar = (session.assignments || []).find((a) => a.carId === carId);
if (duplicateCar) {
alert(t("events.duplicate_car"));
return;
}
const duplicateDriver = (session.assignments || []).find((a) => a.driverId === driverId);
if (duplicateDriver) {
alert(t("events.duplicate_driver"));
return;
}
const duplicateTp = (session.assignments || []).find((a) => {
const existingCar = state.cars.find((x) => x.id === a.carId);
return existingCar?.transponder && existingCar.transponder === car.transponder;
});
if (duplicateTp) {
alert(t("events.duplicate_tp"));
return;
}
session.assignments = session.assignments || [];
session.assignments.push({ id: uid("as"), driverId, carId });
saveState();
renderEventManager(eventId);
});
document.getElementById("autoAssignSession")?.addEventListener("click", () => {
const sessionId = getSelectedAssignmentSessionId();
if (!sessionId) {
return;
}
autoAssignTrackSession(event, sessionId);
saveState();
renderEventManager(eventId);
});
document.getElementById("clearAssignSession")?.addEventListener("click", () => {
const sessionId = getSelectedAssignmentSessionId();
if (!sessionId) {
return;
}
const session = state.sessions.find((x) => x.id === sessionId);
if (!session) {
return;
}
session.assignments = [];
saveState();
renderEventManager(eventId);
});
renderAssignmentList(eventId);
}
if (event.mode === "race") {
const persistRaceParticipants = () => {
const selectedIds = Array.from(document.querySelectorAll(".race-participant:checked")).map((node) => node.value);
event.raceConfig.driverIds = selectedIds;
event.raceConfig.participantsConfigured = true;
saveState();
};
document.querySelectorAll(".race-participant").forEach((node) => {
node.addEventListener("change", persistRaceParticipants);
});
document.getElementById("selectAllParticipants")?.addEventListener("click", () => {
document.querySelectorAll(".race-participant").forEach((node) => {
node.checked = true;
});
persistRaceParticipants();
});
document.getElementById("clearParticipants")?.addEventListener("click", () => {
document.querySelectorAll(".race-participant").forEach((node) => {
node.checked = false;
});
persistRaceParticipants();
});
document.getElementById("teamForm")?.addEventListener("submit", (e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
const name = String(form.get("teamName") || "").trim();
const driverIds = form.getAll("teamDriverIds").map(String).filter(Boolean);
const carIds = form.getAll("teamCarIds").map(String).filter(Boolean);
if (!name || (!driverIds.length && !carIds.length)) {
return;
}
const createdTeam = normalizeRaceTeam({ id: uid("team"), name, driverIds, carIds });
event.raceConfig.teams = [...getEventTeams(event), createdTeam];
selectedTeamEditId = createdTeam.id;
saveState();
renderEventManager(eventId);
});
raceTeams.forEach((team) => {
document.getElementById(`team-edit-${team.id}`)?.addEventListener("click", () => {
selectedTeamEditId = team.id;
renderEventManager(eventId);
});
document.getElementById(`team-delete-${team.id}`)?.addEventListener("click", () => {
event.raceConfig.teams = getEventTeams(event).filter((item) => item.id !== team.id);
if (selectedTeamEditId === team.id) {
selectedTeamEditId = null;
}
saveState();
renderEventManager(eventId);
});
});
document.getElementById("teamEditCancel")?.addEventListener("click", () => {
selectedTeamEditId = null;
renderEventManager(eventId);
});
document.getElementById("teamEditCancelFooter")?.addEventListener("click", () => {
selectedTeamEditId = null;
renderEventManager(eventId);
});
document.getElementById("teamEditModalOverlay")?.addEventListener("click", (modalEvent) => {
if (modalEvent.target?.id === "teamEditModalOverlay") {
selectedTeamEditId = null;
renderEventManager(eventId);
}
});
bindModalShell("teamEditModalOverlay", () => {
selectedTeamEditId = null;
renderEventManager(eventId);
});
document.getElementById("teamEditForm")?.addEventListener("submit", (submitEvent) => {
submitEvent.preventDefault();
if (!editingTeam) {
return;
}
const form = new FormData(submitEvent.currentTarget);
const name = String(form.get("teamName") || "").trim();
const driverIds = form.getAll("teamDriverIds").map(String).filter(Boolean);
const carIds = form.getAll("teamCarIds").map(String).filter(Boolean);
if (!name) {
setFormError("teamEditError", t("validation.required_name"));
return;
}
if (!driverIds.length && !carIds.length) {
setFormError("teamEditError", t("validation.invalid_selection"));
return;
}
setFormError("teamEditError", "");
event.raceConfig.teams = getEventTeams(event).map((team) =>
team.id === editingTeam.id ? normalizeRaceTeam({ ...team, name, driverIds, carIds }) : team
);
selectedTeamEditId = null;
saveState();
renderEventManager(eventId);
});
document.getElementById("raceFormatForm")?.addEventListener("submit", (e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
event.raceConfig = {
qualifyingScoring: String(form.get("qualifyingScoring") || "points") === "best" ? "best" : "points",
qualifyingRounds: Math.max(1, Number(form.get("qualifyingRounds") || 3) || 3),
carsPerHeat: Math.max(2, Number(form.get("carsPerHeat") || 8) || 8),
qualDurationMin: Math.max(1, Number(form.get("qualDurationMin") || 5) || 5),
qualStartMode: normalizeStartMode(String(form.get("qualStartMode") || "staggered")),
countedQualRounds: Math.max(1, Number(form.get("countedQualRounds") || 1) || 1),
carsPerFinal: Math.max(2, Number(form.get("carsPerFinal") || 8) || 8),
finalLegs: Math.max(1, Number(form.get("finalLegs") || 1) || 1),
countedFinalLegs: Math.max(1, Number(form.get("countedFinalLegs") || 1) || 1),
finalDurationMin: Math.max(1, Number(form.get("finalDurationMin") || 5) || 5),
finalStartMode: normalizeStartMode(String(form.get("finalStartMode") || "position")),
minLapMs: Math.max(0, Math.round((Number(form.get("minLapSec") || 0) || 0) * 1000)),
maxLapMs: Math.max(1000, Math.round((Number(form.get("maxLapSec") || 60) || 60) * 1000)),
bumpCount: Math.max(0, Number(form.get("bumpCount") || 0) || 0),
reserveBumpSlots: form.get("reserveBumpSlots") === "on",
driverIds: event.raceConfig.driverIds || [],
participantsConfigured: event.raceConfig.participantsConfigured !== false,
finalsSource: String(form.get("finalsSource") || "qualifying") === "practice" ? "practice" : "qualifying",
teams: getEventTeams(event),
};
saveState();
renderEventManager(eventId);
});
document.getElementById("generateQualifying")?.addEventListener("click", () => {
const created = generateQualifyingForRace(event);
saveState();
renderEventManager(eventId);
if (created > 0) {
alert(t("events.generated_qualifying"));
}
});
document.getElementById("clearGeneratedQualifying")?.addEventListener("click", () => {
clearGeneratedQualifying(event.id);
saveState();
renderEventManager(eventId);
});
document.getElementById("reseedQualifying")?.addEventListener("click", () => {
const result = reseedUpcomingQualifying(event);
saveState();
renderEventManager(eventId);
const messages = [];
if (result.updated > 0) {
messages.push(t("events.reseed_done"));
} else {
messages.push(t("events.no_reseed_done"));
}
if (result.locked > 0) {
messages.push(t("events.reseed_locked", { count: result.locked }));
}
alert(messages.join("\n"));
});
document.getElementById("generateFinals")?.addEventListener("click", () => {
const created = generateFinalsForRace(event);
saveState();
renderEventManager(eventId);
if (created > 0) {
alert(t("events.finals_generated"));
}
});
document.getElementById("clearGeneratedFinals")?.addEventListener("click", () => {
clearGeneratedFinals(event.id);
saveState();
renderEventManager(eventId);
});
document.getElementById("applyBumps")?.addEventListener("click", () => {
const applied = applyBumpsForRace(event);
saveState();
renderEventManager(eventId);
alert(t(applied > 0 ? "events.bumps_applied" : "events.no_bumps_applied"));
});
document.getElementById("printStartlists")?.addEventListener("click", () => {
openPrintWindow(`${event.name} - ${t("events.start_lists")}`, buildRaceStartListsHtml(event));
});
document.getElementById("printResults")?.addEventListener("click", () => {
openPrintWindow(`${event.name} - ${t("events.results_overview")}`, buildRaceResultsHtml(event));
});
document.getElementById("printTeamResults")?.addEventListener("click", () => {
openPrintWindow(`${event.name} - ${t("events.team_report")}`, buildTeamRaceResultsHtml(event));
});
document.getElementById("pdfStartlists")?.addEventListener("click", async () => {
await exportRaceStartListsPdf(event);
});
document.getElementById("pdfResults")?.addEventListener("click", async () => {
await exportRaceResultsPdf(event);
});
document.getElementById("pdfTeamResults")?.addEventListener("click", async () => {
await exportTeamRaceResultsPdf(event);
});
document.getElementById("gridResetOrder")?.addEventListener("click", () => {
if (!selectedGridSession) {
return;
}
selectedGridSession.driverIds = getSessionEntrants(selectedGridSession)
.map((driver) => driver.id)
.filter(Boolean);
selectedGridSession.manualGridIds = [...selectedGridSession.driverIds];
selectedGridSession.gridCustomized = false;
saveState();
renderEventManager(eventId);
});
document.getElementById("gridToggleLock")?.addEventListener("click", () => {
if (!selectedGridSession) {
return;
}
if (!selectedGridSession.gridCustomized) {
selectedGridSession.manualGridIds = [...ensureSessionDriverOrder(selectedGridSession)];
selectedGridSession.gridCustomized = true;
} else {
selectedGridSession.manualGridIds = [...selectedGridSession.driverIds];
selectedGridSession.gridCustomized = false;
}
saveState();
renderEventManager(eventId);
});
let dragIndex = null;
document.querySelectorAll("#gridDragList .drag-item").forEach((node) => {
node.addEventListener("dragstart", () => {
dragIndex = Number(node.dataset.index);
node.classList.add("drag-item-active");
});
node.addEventListener("dragend", () => {
dragIndex = null;
node.classList.remove("drag-item-active");
});
node.addEventListener("dragover", (dragEvent) => {
dragEvent.preventDefault();
node.classList.add("drag-item-over");
});
node.addEventListener("dragleave", () => {
node.classList.remove("drag-item-over");
});
node.addEventListener("drop", (dropEvent) => {
dropEvent.preventDefault();
node.classList.remove("drag-item-over");
if (!selectedGridSession || dragIndex === null) {
return;
}
const dropIndex = Number(node.dataset.index);
if (Number.isNaN(dropIndex) || dropIndex === dragIndex) {
return;
}
selectedGridSession.manualGridIds = reorderList(ensureSessionDriverOrder(selectedGridSession), dragIndex, dropIndex);
selectedGridSession.gridCustomized = true;
saveState();
renderEventManager(eventId);
});
});
}
}
function renderAssignmentList(eventId) {
const block = document.getElementById("assignmentList");
if (!block) {
return;
}
const sessions = getSessionsForEvent(eventId);
block.innerHTML = sessions
.map((s) => {
const items = (s.assignments || [])
.map((a) => {
const driver = state.drivers.find((d) => d.id === a.driverId);
const car = state.cars.find((c) => c.id === a.carId);
return `
<li>
${escapeHtml(driver?.name || t("common.unknown_driver"))} -> ${escapeHtml(car?.name || t("common.unknown_car"))} (${escapeHtml(
car?.transponder || "-"
)})
<button id="as-delete-${a.id}" class="btn btn-danger btn-mini">x</button>
</li>
`;
})
.join("");
return `
<div class="assignment-group">
<h4>${escapeHtml(s.name)} (${escapeHtml(getSessionTypeLabel(s.type))})</h4>
<ul>${items || `<li>${t("events.no_assignments")}</li>`}</ul>
</div>
`;
})
.join("");
sessions.forEach((s) => {
(s.assignments || []).forEach((a) => {
document.getElementById(`as-delete-${a.id}`)?.addEventListener("click", () => {
s.assignments = s.assignments.filter((x) => x.id !== a.id);
saveState();
renderAssignmentList(eventId);
});
});
});
}
function renderTiming() {
const active = getActiveSession();
const result = active ? ensureSessionResult(active.id) : null;
const leaderboard = active ? buildLeaderboard(active) : [];
const sessionTiming = active ? getSessionTiming(active) : null;
const clockLabel = active && sessionTiming?.untimed ? t("timing.elapsed") : t("timing.remaining");
const clockValue = sessionTiming?.untimed ? formatElapsedClock(sessionTiming?.elapsedMs ?? 0) : formatCountdown(sessionTiming?.remainingMs ?? 0);
const showFinishedBanner = Boolean(active && active.status === "finished" && active.finishedByTimer);
const selectedRow = leaderboard.find((row) => row.key === selectedLeaderboardKey) || null;
if (selectedLeaderboardKey && !selectedRow) {
selectedLeaderboardKey = null;
}
dom.view.innerHTML = `
<section class="panel">
<div class="panel-header"><h3>${t("timing.decoder_connection")}</h3></div>
<div class="panel-body timing-top-grid">
<div class="timing-compact-card">
<label class="timing-compact-label" for="timingWsUrl">${t("settings.decoder")}</label>
<input id="timingWsUrl" value="${escapeHtml(state.settings.wsUrl)}" placeholder="ws://127.0.0.1:9000" />
<div class="actions">
<button id="timingConnect" class="btn btn-primary">${t("timing.connect")}</button>
<button id="timingDisconnect" class="btn">${t("timing.disconnect")}</button>
<button id="timingSimPass" class="btn">${t("timing.simulate")}</button>
</div>
</div>
<div class="timing-compact-card">
<span class="timing-compact-label">${t("timing.status")}</span>
<strong>${state.decoder.connected ? t("timing.connected") : t("timing.disconnected")}</strong>
<small>${t("timing.last_message")}: ${state.decoder.lastMessageAt ? new Date(state.decoder.lastMessageAt).toLocaleString() : "-"}</small>
<p class="error">${escapeHtml(state.decoder.lastError || "")}</p>
</div>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("timing.control")}</h3></div>
<div class="panel-body timing-top-grid">
<div class="timing-compact-card timing-compact-card-wide">
<label class="timing-compact-label" for="activeSessionSelect">${t("timing.select_session")}</label>
<select id="activeSessionSelect">
<option value="">${t("timing.select_session")}</option>
${state.sessions
.map(
(s) => `<option value="${s.id}" ${state.activeSessionId === s.id ? "selected" : ""}>${escapeHtml(
getEventName(s.eventId)
)}${escapeHtml(s.name)}${escapeHtml(getSessionTypeLabel(s.type))}</option>`
)
.join("")}
</select>
<div class="actions">
<button id="setActiveSession" class="btn">${t("timing.set_active")}</button>
<button id="startSession" class="btn btn-primary">${t("timing.start")}</button>
<button id="stopSession" class="btn">${t("timing.stop")}</button>
<button id="resetSession" class="btn btn-danger">${t("timing.reset")}</button>
</div>
</div>
<div class="timing-compact-card">
<span class="timing-compact-label">${t("overlay.title")}</span>
<div class="actions">
<button id="openOverlay" class="btn" type="button">${t("timing.open_overlay")}</button>
<button id="openSpeakerOverlay" class="btn" type="button">${t("timing.open_speaker_overlay")}</button>
<button id="openResultsOverlay" class="btn" type="button">${t("timing.open_results_overlay")}</button>
<button id="openTvOverlay" class="btn" type="button">${t("timing.open_tv_overlay")}</button>
<button id="openTeamOverlay" class="btn" type="button">${t("timing.open_team_overlay")}</button>
</div>
</div>
</div>
<div class="panel-body timing-session-summary">
${
active
? `<div class="timing-session-head">
<strong>${escapeHtml(active.name)}</strong>
<span class="pill">${escapeHtml(getSessionTypeLabel(active.type))}</span>
<span class="pill">${escapeHtml(getEventName(active.eventId))}</span>
<span class="pill ${active.status === "running" ? "pill-green" : ""}">${escapeHtml(getStatusLabel(active.status))}</span>
</div>
<div class="timing-session-stats">
<article class="timing-session-stat"><span>${clockLabel}</span><strong>${clockValue}</strong></article>
<article class="timing-session-stat"><span>${t("timing.started")}</span><strong>${active.startedAt ? new Date(active.startedAt).toLocaleTimeString() : "-"}</strong></article>
<article class="timing-session-stat"><span>${t("table.start_mode")}</span><strong>${escapeHtml(getStartModeLabel(active.startMode))}</strong></article>
<article class="timing-session-stat"><span>${t("timing.seeding_mode")}</span><strong>${active.seedBestLapCount > 0 ? `${active.seedBestLapCount}` : "-"}</strong></article>
<article class="timing-session-stat"><span>${t("timing.total_passings")}</span><strong>${getVisiblePassings(result).length}</strong></article>
</div>
${
active.type === "free_practice"
? `<p class="hint">${t("events.free_practice_note")}</p>`
: active.type === "open_practice"
? `<p class="hint">${t("events.open_practice_note")}</p>`
: ""
}`
: `<p>${t("timing.no_active")}</p>`
}
${showFinishedBanner ? `<p class="finish-banner">${t("timing.race_finished")}</p>` : ""}
${active && normalizeStartMode(active.startMode) === "position" ? renderPositionGrid(active) : ""}
</div>
</section>
${active ? renderQuickAddPanel(active) : ""}
<section class="panel mt-16">
<div class="panel-header"><h3>${t("timing.speaker_panel")}</h3></div>
<div class="panel-body">
<p class="hint">${t("timing.speaker_panel_hint")}</p>
<div class="check-grid">
${renderSpeakerToggle("speakerPassingCueEnabled", "settings.speaker_passing_cue")}
${renderSpeakerToggle("speakerLeaderCueEnabled", "settings.speaker_leader_cue")}
${renderSpeakerToggle("speakerBestLapCueEnabled", "settings.speaker_bestlap_cue")}
${renderSpeakerToggle("speakerTop3CueEnabled", "settings.speaker_top3_cue")}
${renderSpeakerToggle("speakerSessionStartCueEnabled", "settings.speaker_start_cue")}
${renderSpeakerToggle("speakerFinishCueEnabled", "settings.speaker_finish_cue")}
</div>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("timing.leaderboard")}</h3></div>
<div class="panel-body">
${renderLeaderboard(leaderboard)}
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("timing.recent_passings")}</h3></div>
<div class="panel-body">
${renderRecentPassings(active)}
</div>
</section>
${selectedRow && active ? renderLeaderboardModal(active, selectedRow) : ""}
`;
document.getElementById("timingConnect")?.addEventListener("click", () => {
const input = document.getElementById("timingWsUrl");
if (input && input.value.trim()) {
state.settings.wsUrl = input.value.trim();
saveState();
}
connectDecoder();
});
document.getElementById("timingDisconnect")?.addEventListener("click", disconnectDecoder);
leaderboard.forEach((row) => {
document.getElementById(`leaderboard-detail-${row.key}`)?.addEventListener("click", () => {
selectedLeaderboardKey = row.key;
renderView();
});
});
if (active && selectedRow) {
bindQuickAddActions(active, selectedRow.transponder, "leaderboardModal");
}
if (active) {
ensureSessionResult(active.id)
.passings.slice(-20)
.reverse()
.forEach((passing, index) => {
bindQuickAddActions(active, passing.transponder, `recentPassing-${index}`);
});
}
document.getElementById("quickAddCancel")?.addEventListener("click", () => {
quickAddDraft = null;
renderView();
});
document.getElementById("quickAddForm")?.addEventListener("submit", (event) => {
event.preventDefault();
if (!active || !quickAddDraft) {
return;
}
const form = new FormData(event.currentTarget);
const name = String(form.get("name") || "").trim();
if (!name) {
return;
}
const transponder = String(form.get("transponder") || "").trim();
if (!transponder) {
return;
}
if (quickAddDraft.type === "driver") {
if (!state.drivers.some((item) => String(item.transponder || "").trim() === transponder)) {
state.drivers.push({
id: uid("driver"),
name,
classId: String(form.get("classId") || getPreferredClassId(active)),
transponder,
});
}
} else if (!state.cars.some((item) => String(item.transponder || "").trim() === transponder)) {
state.cars.push({
id: uid("car"),
name,
transponder,
});
}
quickAddDraft = null;
saveState();
renderView();
});
document.getElementById("leaderboardModalClose")?.addEventListener("click", () => {
selectedLeaderboardKey = null;
renderView();
});
document.getElementById("leaderboardModalOverlay")?.addEventListener("click", (event) => {
if (event.target?.id === "leaderboardModalOverlay") {
selectedLeaderboardKey = null;
renderView();
}
});
document.getElementById("timingSimPass")?.addEventListener("click", () => {
const tp = prompt(t("timing.prompt_transponder"), "232323");
if (!tp) {
return;
}
processDecoderMessage({
msg: "PASSING",
transponder: tp,
rtc_time: new Date().toISOString(),
strength: 0,
resend: false,
loop_id: "sim",
});
});
document.getElementById("setActiveSession")?.addEventListener("click", () => {
const select = document.getElementById("activeSessionSelect");
if (!select || !select.value) {
return;
}
state.activeSessionId = select.value;
saveState();
updateHeaderState();
renderView();
});
document.getElementById("startSession")?.addEventListener("click", () => {
const session = getActiveSession();
if (!session) {
return;
}
ensureAudioContext();
if (session.mode === "track") {
const trackValidation = validateTrackSessionForStart(session);
if (!trackValidation.ok) {
alert(trackValidation.message);
return;
}
}
session.status = "running";
session.startedAt = Date.now();
session.endedAt = null;
session.finishedByTimer = false;
lastFinishAnnouncementSessionId = null;
lastOverlayLeaderKeyBySession[session.id] = null;
lastOverlayTop3BySession[session.id] = [];
overlayEvents = [];
ensureSessionResult(session.id);
pushOverlayEvent("start", `${session.name}${t("timing.start")}`);
saveState();
updateHeaderState();
renderView();
});
document.getElementById("stopSession")?.addEventListener("click", () => {
const session = getActiveSession();
if (!session) {
return;
}
session.status = "finished";
session.endedAt = Date.now();
session.finishedByTimer = false;
saveState();
updateHeaderState();
renderView();
});
document.getElementById("resetSession")?.addEventListener("click", () => {
const session = getActiveSession();
if (!session) {
return;
}
if (!confirm(t("timing.clear_confirm"))) {
return;
}
delete state.resultsBySession[session.id];
lastFinishAnnouncementSessionId = null;
delete lastOverlayLeaderKeyBySession[session.id];
delete lastOverlayTop3BySession[session.id];
overlayEvents = [];
saveState();
renderView();
});
document.getElementById("openOverlay")?.addEventListener("click", openOverlayWindow);
document.getElementById("openSpeakerOverlay")?.addEventListener("click", () => openOverlayWindow("speaker"));
document.getElementById("openResultsOverlay")?.addEventListener("click", () => openOverlayWindow("results"));
document.getElementById("openTvOverlay")?.addEventListener("click", () => openOverlayWindow("tv"));
document.getElementById("openTeamOverlay")?.addEventListener("click", () => openOverlayWindow("team"));
document.querySelectorAll("[data-speaker-setting]").forEach((node) => {
node.addEventListener("change", (event) => {
const input = event.currentTarget;
if (!(input instanceof HTMLInputElement)) {
return;
}
state.settings[input.dataset.speakerSetting] = input.checked;
saveState();
});
});
}
function renderSpeakerToggle(settingKey, labelKey) {
return `
<label class="check-card">
<input type="checkbox" data-speaker-setting="${settingKey}" ${state.settings[settingKey] ? "checked" : ""} />
<span>${t(labelKey)}</span>
</label>
`;
}
function renderQuickAddPanel(session) {
if (!quickAddDraft || !quickAddDraft.transponder) {
return "";
}
const classOptions = state.classes
.map(
(item) => `<option value="${item.id}" ${item.id === (quickAddDraft.classId || getPreferredClassId(session)) ? "selected" : ""}>${escapeHtml(item.name)}</option>`
)
.join("");
const isDriver = quickAddDraft.type === "driver";
return `
<section class="panel mt-16">
<div class="panel-header"><h3>${t(isDriver ? "timing.quick_add_driver_title" : "timing.quick_add_car_title")}</h3></div>
<form id="quickAddForm" class="panel-body form-grid cols-4">
<input name="transponder" value="${escapeHtml(quickAddDraft.transponder)}" readonly />
<input
name="name"
required
autofocus
placeholder="${t(isDriver ? "drivers.name_placeholder" : "cars.name_placeholder")}"
value="${escapeHtml(quickAddDraft.name || "")}"
/>
${
isDriver
? `<select name="classId">${classOptions}</select>`
: `<div class="hint quick-add-spacer">${t("timing.quick_add_hint")}</div>`
}
<div class="actions-inline">
<button class="btn btn-primary" type="submit">${t("common.save")}</button>
<button class="btn" id="quickAddCancel" type="button">${t("common.cancel")}</button>
</div>
</form>
</section>
`;
}
function renderGuide() {
dom.view.innerHTML = `
<section class="panel">
<div class="panel-header"><h3>${t("guide.title")}</h3></div>
<div class="panel-body">
<p>${t("guide.intro")}</p>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("guide.sponsor_title")}</h3></div>
<div class="panel-body">
<ul>
<li>${t("guide.sponsor_1")}</li>
<li>${t("guide.sponsor_2")}</li>
<li>${t("guide.sponsor_3")}</li>
<li>${t("guide.sponsor_4")}</li>
<li>${t("guide.sponsor_5")}</li>
<li>${t("guide.sponsor_6")}</li>
</ul>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("guide.race_title")}</h3></div>
<div class="panel-body">
<ul>
<li>${t("guide.race_1")}</li>
<li>${t("guide.race_2")}</li>
<li>${t("guide.race_3")}</li>
<li>${t("guide.race_4")}</li>
<li>${t("guide.race_5")}</li>
<li>${t("guide.race_6")}</li>
<li>${t("guide.race_7")}</li>
<li>${t("guide.race_8")}</li>
<li>${t("guide.race_9")}</li>
<li>${t("guide.race_10")}</li>
</ul>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("guide.race_format_title")}</h3></div>
<div class="panel-body">
<ul>
<li>${t("guide.race_format_1")}</li>
<li>${t("guide.race_format_2")}</li>
<li>${t("guide.race_format_3")}</li>
<li>${t("guide.race_format_4")}</li>
<li>${t("guide.race_format_5")}</li>
<li>${t("guide.race_format_6")}</li>
<li>${t("guide.race_format_7")}</li>
</ul>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("guide.free_practice_title")}</h3></div>
<div class="panel-body">
<ul>
<li>${t("guide.free_practice_1")}</li>
<li>${t("guide.free_practice_2")}</li>
<li>${t("guide.free_practice_3")}</li>
</ul>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("guide.open_practice_title")}</h3></div>
<div class="panel-body">
<ul>
<li>${t("guide.open_practice_1")}</li>
<li>${t("guide.open_practice_2")}</li>
<li>${t("guide.open_practice_3")}</li>
</ul>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("guide.team_title")}</h3></div>
<div class="panel-body">
<ul>
<li>${t("guide.team_1")}</li>
<li>${t("guide.team_2")}</li>
<li>${t("guide.team_3")}</li>
<li>${t("guide.team_4")}</li>
<li>${t("guide.team_5")}</li>
<li>${t("guide.team_6")}</li>
</ul>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("guide.host_title")}</h3></div>
<div class="panel-body">
<ul>
<li>${t("guide.host_1")}</li>
<li>${t("guide.host_2")}</li>
<li>${t("guide.host_3")}</li>
<li>${t("guide.host_4")}</li>
<li>${t("guide.host_5")}</li>
</ul>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("guide.windows_title")}</h3></div>
<div class="panel-body">
<ul>
<li>${t("guide.windows_1")}</li>
<li>${t("guide.windows_2")}</li>
<li>${t("guide.windows_3")}</li>
<li>${t("guide.windows_4")}</li>
<li>${t("guide.windows_5")}</li>
</ul>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("guide.linux_title")}</h3></div>
<div class="panel-body">
<ul>
<li>${t("guide.linux_1")}</li>
<li>${t("guide.linux_2")}</li>
<li>${t("guide.linux_3")}</li>
</ul>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("guide.sqlite_title")}</h3></div>
<div class="panel-body">
<ul>
<li>${t("guide.sqlite_1")}</li>
<li>${t("guide.sqlite_2")}</li>
</ul>
<p><a href="https://www.ammconverter.eu/docs/intro/quick-start/" target="_blank" rel="noreferrer">${t("guide.ammc_ref")}</a></p>
</div>
</section>
`;
}
function renderOverlay() {
const active = getActiveSession();
const leaderboard = active ? buildLeaderboard(active).slice(0, 12) : [];
const result = active ? ensureSessionResult(active.id) : null;
const sessionTiming = active ? getSessionTiming(active) : null;
const overlayClock = sessionTiming?.untimed
? formatElapsedClock(sessionTiming?.elapsedMs ?? 0)
: formatCountdown(sessionTiming?.remainingMs ?? 0);
const recent = active && result ? getVisiblePassings(result).slice(-8).reverse() : [];
const event = active ? state.events.find((item) => item.id === active.eventId) : null;
const branding = resolveEventBranding(event);
const practiceRows = event ? buildPracticeStandings(event) : [];
const qualifyingRows = event ? buildQualifyingStandings(event) : [];
const finalRows = event ? buildFinalStandings(event) : [];
const topRow = leaderboard[0] || null;
const fastestRow =
[...leaderboard].filter((row) => Number.isFinite(row.bestLapMs)).sort((left, right) => left.bestLapMs - right.bestLapMs)[0] || null;
const modeLabel = getOverlayModeLabel(overlayViewMode);
const rotatingPanels = buildOverlayPanels(active, recent);
const activePanel = rotatingPanels.length ? rotatingPanels[overlayRotationIndex % rotatingPanels.length] : null;
const denseOverlay = overlayViewMode === "leaderboard" || overlayViewMode === "tv";
dom.view.innerHTML = `
<section class="overlay-shell ${overlayViewMode === "tv" ? "overlay-shell-tv" : ""} ${denseOverlay ? "overlay-shell-dense" : ""}">
${
active
? `
<header class="overlay-header">
<div class="overlay-header-main">
${branding.logoDataUrl ? `<img class="overlay-logo" src="${escapeHtml(branding.logoDataUrl)}" alt="logo" />` : ""}
<div class="overlay-header-copy">
<div class="overlay-kicker-row">
<p class="overlay-kicker">${escapeHtml(getEventName(active.eventId))}</p>
<span class="pill">${escapeHtml(getSessionTypeLabel(active.type))}</span>
${overlayViewMode !== "tv" ? `<span class="pill">${escapeHtml(getStartModeLabel(active.startMode))}</span>` : ""}
<span class="pill">${escapeHtml(modeLabel)}</span>
</div>
<h1>${escapeHtml(active.name)}</h1>
<p class="overlay-header-sub">${escapeHtml(branding.brandName || "JMK RB Live Event")}</p>
</div>
</div>
<div class="overlay-meta">
<button id="overlayFullscreen" class="btn overlay-fullscreen-btn" type="button">${t("overlay.fullscreen")}</button>
<div class="overlay-clock">${overlayClock}</div>
<div class="overlay-status">${escapeHtml(getStatusLabel(active.status))}</div>
</div>
</header>
${
overlayViewMode === "speaker"
? `
<section class="overlay-speaker">
<div class="overlay-speaker-main">
<div class="overlay-speaker-label">P1</div>
<h2>${escapeHtml(topRow?.displayName || topRow?.driverName || t("common.unknown_driver"))}</h2>
<p>${t("table.result")}: ${escapeHtml(topRow?.resultDisplay || "-")}</p>
<p>${t("table.best_lap")}: ${formatLap(topRow?.bestLapMs)}</p>
</div>
<div class="overlay-speaker-side">
<section class="overlay-side-card">
<h3>${t("overlay.last_passings")}</h3>
${
recent.length
? recent
.map(
(passing) => `
<div class="overlay-passing">
<strong>${escapeHtml(passing.displayName || passing.teamName || passing.driverName || t("common.unknown_driver"))}</strong>
<span>${formatLap(passing.lapMs)}</span>
</div>
`
)
.join("")
: `<p>${t("timing.no_passings")}</p>`
}
</section>
<section class="overlay-side-card">
<h3>${t("events.position_grid")}</h3>
${normalizeStartMode(active.startMode) === "position" ? renderPositionGrid(active) : `<p>${t("events.na")}</p>`}
</section>
<section class="overlay-side-card">
<h3>${t("overlay.event_markers")}</h3>
${
overlayEvents.length
? overlayEvents
.map(
(item) => `
<div class="overlay-passing">
<strong>${escapeHtml(item.label)}</strong>
<span>${new Date(item.ts).toLocaleTimeString()}</span>
</div>
`
)
.join("")
: `<p>${t("timing.no_passings")}</p>`
}
</section>
</div>
</section>
`
: overlayViewMode === "results"
? `
<section class="overlay-results">
<div class="overlay-side-card">
<h3>${t("events.practice_standings")}</h3>
${renderRaceStandingsTable(practiceRows, t("events.no_practice_results"))}
</div>
<div class="overlay-side-card">
<h3>${t("events.qualifying_standings")}</h3>
${renderRaceStandingsTable(qualifyingRows, t("events.no_qualifying_results"))}
</div>
<div class="overlay-side-card">
<h3>${t("events.final_standings")}</h3>
${renderRaceStandingsTable(finalRows, t("events.no_final_results"))}
</div>
</section>
`
: overlayViewMode === "team"
? renderTeamOverlay(leaderboard, result, sessionTiming)
: overlayViewMode === "tv"
? `
<section class="overlay-board overlay-board-tv">
<div class="overlay-table-wrap overlay-display-wrap overlay-display-wrap-dense">
<section class="overlay-fastest-banner overlay-fastest-banner-dense">
<div class="overlay-fastest-banner-copy">
<span>${t("overlay.fastest_lap")}</span>
<strong>${formatLap(fastestRow?.bestLapMs)}</strong>
</div>
<div class="overlay-fastest-driver">${escapeHtml(fastestRow?.displayName || fastestRow?.driverName || "-")}</div>
<div class="overlay-fastest-meta">${t("table.laps")}: ${topRow?.laps || 0} | ${t("timing.total_passings")}: ${getVisiblePassings(result).length || 0}</div>
</section>
<div class="overlay-leaderboard-card overlay-leaderboard-card-tv overlay-leaderboard-card-dense">
${renderOverlayLeaderboard(leaderboard)}
</div>
</div>
</section>
`
: `
<section class="overlay-board">
<div class="overlay-table-wrap overlay-display-wrap overlay-display-wrap-dense">
<section class="overlay-fastest-banner overlay-fastest-banner-dense">
<div class="overlay-fastest-banner-copy">
<span>${t("overlay.fastest_lap")}</span>
<strong>${formatLap(fastestRow?.bestLapMs)}</strong>
</div>
<div class="overlay-fastest-driver">${escapeHtml(fastestRow?.displayName || fastestRow?.driverName || "-")}</div>
<div class="overlay-fastest-meta">${t("table.laps")}: ${topRow?.laps || 0} | ${t("timing.total_passings")}: ${result?.passings.length || 0}</div>
</section>
<div class="overlay-leaderboard-card overlay-leaderboard-card-dense">
${renderOverlayLeaderboard(leaderboard)}
</div>
</div>
<aside class="overlay-side">
${activePanel ? renderOverlaySidePanel(activePanel) : `<section class="overlay-side-card"><p>${t("timing.no_passings")}</p></section>`}
</aside>
</section>
`
}
`
: `
<section class="overlay-empty">
<h1>${t("overlay.title")}</h1>
<p>${t("overlay.no_active")}</p>
</section>
`
}
</section>
`;
document.getElementById("overlayFullscreen")?.addEventListener("click", async () => {
const target = document.documentElement;
if (!document.fullscreenElement) {
await target.requestFullscreen?.().catch(() => {});
return;
}
await document.exitFullscreen?.().catch(() => {});
});
}
function buildOverlayPanels(active, recent) {
return [
{
title: t("overlay.last_passings"),
content: recent.length
? recent
.map(
(passing) => `
<div class="overlay-passing">
<strong>${escapeHtml(passing.displayName || passing.teamName || passing.driverName || passing.transponder || t("common.unknown_driver"))}</strong>
<span>${formatLap(passing.lapMs)}</span>
</div>
`
)
.join("")
: `<p>${t("timing.no_passings")}</p>`,
},
{
title: t("overlay.event_markers"),
content: overlayEvents.length
? overlayEvents
.map(
(item) => `
<div class="overlay-passing">
<strong>${escapeHtml(item.label)}</strong>
<span>${new Date(item.ts).toLocaleTimeString()}</span>
</div>
`
)
.join("")
: `<p>${t("timing.no_passings")}</p>`,
},
{
title: t("events.position_grid"),
content:
active && normalizeStartMode(active.startMode) === "position" ? renderPositionGrid(active) : `<p>${t("events.na")}</p>`,
},
];
}
function renderOverlaySidePanel(panel) {
return `
<section class="overlay-side-card overlay-rotating-card">
<div class="overlay-section-head">
<h3>${escapeHtml(panel.title)}</h3>
<span class="pill">${t("overlay.rotating_panel")}</span>
</div>
${panel.content}
</section>
`;
}
function getQuickAddState(transponder) {
const normalized = String(transponder || "").trim();
const driver = state.drivers.find((item) => String(item.transponder || "").trim() === normalized) || null;
const car = state.cars.find((item) => String(item.transponder || "").trim() === normalized) || null;
return {
transponder: normalized,
hasDriver: Boolean(driver),
hasCar: Boolean(car),
};
}
function getPreferredClassId(session) {
const event = state.events.find((item) => item.id === session?.eventId);
if (event?.classId) {
return event.classId;
}
return state.classes[0]?.id || "";
}
function beginQuickAddDraft(session, type, transponder) {
const normalized = String(transponder || "").trim();
if (!normalized) {
return;
}
if (type === "driver" && state.drivers.some((item) => String(item.transponder || "").trim() === normalized)) {
return;
}
if (type === "car" && state.cars.some((item) => String(item.transponder || "").trim() === normalized)) {
return;
}
quickAddDraft = {
type,
transponder: normalized,
classId: getPreferredClassId(session),
name: type === "driver" ? normalized : `Car ${normalized}`,
};
renderView();
}
function renderQuickAddActions(session, transponder, idPrefix) {
const quickState = getQuickAddState(transponder);
if (!quickState.transponder || (quickState.hasDriver && quickState.hasCar)) {
return "";
}
return `
<div class="actions-inline quick-add-actions">
${!quickState.hasDriver ? `<button class="btn btn-mini" id="${idPrefix}-add-driver">${t("timing.add_driver")}</button>` : ""}
${!quickState.hasCar ? `<button class="btn btn-mini" id="${idPrefix}-add-car">${t("timing.add_car")}</button>` : ""}
</div>
`;
}
function bindQuickAddActions(session, transponder, idPrefix) {
document.getElementById(`${idPrefix}-add-driver`)?.addEventListener("click", () => {
beginQuickAddDraft(session, "driver", transponder);
});
document.getElementById(`${idPrefix}-add-car`)?.addEventListener("click", () => {
beginQuickAddDraft(session, "car", transponder);
});
}
function renderLeaderboardModal(session, row) {
const passings = getCompetitorPassings(session, row);
return `
<div class="modal-overlay" id="leaderboardModalOverlay">
<div class="modal-card">
<div class="panel-header">
<h3>${t("timing.detail_title")}</h3>
<button class="btn" id="leaderboardModalClose">${t("timing.close_details")}</button>
</div>
<div class="panel-body">
<p><strong>${escapeHtml(row.displayName || row.driverName)}</strong> • ${escapeHtml(row.subLabel || row.carName)}</p>
<p>${t("table.transponder")}: ${escapeHtml(row.transponder)}</p>
${renderQuickAddActions(session, row.transponder, "leaderboardModal")}
<p>${t("table.laps")}: ${row.laps}</p>
<p>${t("timing.total_time")}: ${escapeHtml(row.resultDisplay)}</p>
<p>${t("table.best_lap")}: ${formatLap(row.bestLapMs)}</p>
<p>${t("table.last_lap")}: ${formatLap(row.lastLapMs)}</p>
<p>${t("table.own_delta")}: ${escapeHtml(row.lapDelta || "-")}</p>
</div>
<div class="panel-body">
<h4>${t("timing.lap_history")}</h4>
${
passings.length
? renderTable(
[t("table.lap"), t("table.time"), t("table.last_lap")],
passings.map(
(passing, index) => `
<tr>
<td>${index + 1}</td>
<td>${formatRaceClock(Math.max(0, passing.timestamp - (session.startedAt || passing.timestamp)))}</td>
<td>${formatLap(passing.lapMs)}</td>
</tr>
`
)
)
: `<p>${t("timing.no_lap_history")}</p>`
}
</div>
</div>
</div>
`;
}
function renderLeaderboard(rows) {
if (!rows.length) {
return `<p>${t("timing.no_laps")}</p>`;
}
return renderTable(
[
t("table.pos"),
t("table.driver"),
t("table.car"),
t("table.transponder"),
t("table.laps"),
t("table.result"),
t("table.last_lap"),
t("table.best_lap"),
t("table.leader_gap"),
t("table.ahead_gap"),
t("table.own_delta"),
"",
],
rows.map((row, idx) => {
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
return `
<tr>
<td><span class="pos-pill ${posClass}">${idx + 1}</span></td>
<td>
<div class="table-primary">${escapeHtml(row.displayName || row.driverName)}</div>
${row.teamId ? `<div class="table-subnote">${t("overlay.active_member")}: ${escapeHtml(formatTeamActiveMemberLabel(row))}</div>` : ""}
</td>
<td>${escapeHtml(row.subLabel || row.carName)}</td>
<td>${escapeHtml(row.transponder)}</td>
<td>${row.laps}</td>
<td>${escapeHtml(row.resultDisplay)}</td>
<td>${formatLap(row.lastLapMs)}</td>
<td class="best">${formatLap(row.bestLapMs)}</td>
<td>${escapeHtml(row.leaderGap || row.gap || "-")}</td>
<td>${escapeHtml(row.gapAhead || "-")}</td>
<td>${escapeHtml(row.lapDelta || "-")}</td>
<td><button id="leaderboard-detail-${row.key}" class="btn btn-mini">${t("timing.details")}</button></td>
</tr>
`;
})
);
}
function renderOverlayLeaderboard(rows) {
if (!rows.length) {
return `<p>${t("timing.no_laps")}</p>`;
}
return `
<div class="overlay-race-list">
${rows
.map((row, idx) => {
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
return `
<article class="overlay-race-row ${idx === 0 ? "overlay-race-row-leader" : ""}">
<div class="overlay-race-pos">
<span class="pos-pill ${posClass}">${idx + 1}</span>
</div>
<div class="overlay-race-driver">
<strong>${escapeHtml(row.displayName || row.driverName)}</strong>
<span>${escapeHtml(row.teamId ? `${t("overlay.active_member")}: ${formatTeamActiveMemberLabel(row)}` : row.subLabel || row.transponder || "-")}</span>
<div class="overlay-prediction">
<div class="overlay-prediction-meta">
<label>${t("overlay.next_predicted_lap")}</label>
<span>${row.predictedRemainingMs !== null ? formatLap(row.predictedRemainingMs) : "-"}</span>
</div>
<div class="overlay-prediction-track">
<div class="overlay-prediction-fill overlay-prediction-${escapeHtml(row.predictionTone || "good")}" style="width:${Math.max(0, Math.min(100, Math.round((row.predictedProgress || 0) * 100)))}%"></div>
</div>
</div>
</div>
<div class="overlay-race-metric">
<label>${t("table.result")}</label>
<strong>${escapeHtml(row.resultDisplay)}</strong>
</div>
<div class="overlay-race-metric">
<label>${t("table.ahead_gap")}</label>
<strong>${escapeHtml(row.gapAhead || "-")}</strong>
</div>
<div class="overlay-race-metric">
<label>${t("table.own_delta")}</label>
<strong>${escapeHtml(row.lapDelta || "-")}</strong>
</div>
<div class="overlay-race-best">
<label>${t("table.best_lap")}</label>
<strong>${formatLap(row.bestLapMs)}</strong>
</div>
</article>
`;
})
.join("")}
</div>
`;
}
function renderTeamOverlay(rows, result, sessionTiming) {
const topThree = rows.slice(0, 3);
return `
<section class="overlay-team-layout">
<section class="overlay-team-podium">
<div class="overlay-section-head">
<h3>${t("overlay.top_three")}</h3>
<span class="pill">${t("overlay.team_battle")}</span>
</div>
<div class="overlay-team-podium-grid">
${topThree
.map(
(row, index) => `
<article class="overlay-team-card overlay-team-card-${index + 1}">
<span class="pos-pill pos-${Math.min(index + 1, 3)}">${index + 1}</span>
<strong>${escapeHtml(row.displayName || row.driverName)}</strong>
<p>${escapeHtml(row.resultDisplay || "-")}</p>
<small>${t("overlay.active_member")}: ${escapeHtml(formatTeamActiveMemberLabel(row))}</small>
</article>
`
)
.join("")}
</div>
</section>
<section class="overlay-board overlay-board-tv">
<div class="overlay-table-wrap overlay-display-wrap">
<section class="overlay-stats-row">
<article class="overlay-stat-card">
<span>${t("table.laps")}</span>
<strong>${rows[0]?.laps || 0}</strong>
<small>${escapeHtml(rows[0]?.displayName || rows[0]?.driverName || "-")}</small>
</article>
<article class="overlay-stat-card">
<span>${t("timing.total_passings")}</span>
<strong>${getVisiblePassings(result).length || 0}</strong>
<small>${sessionTiming?.untimed ? t("timing.elapsed") : t("timing.remaining")}</small>
</article>
<article class="overlay-stat-card">
<span>${t("overlay.fastest_lap")}</span>
<strong>${formatLap([...rows].filter((row) => Number.isFinite(row.bestLapMs)).sort((a, b) => a.bestLapMs - b.bestLapMs)[0]?.bestLapMs)}</strong>
<small>${escapeHtml(
[...rows].filter((row) => Number.isFinite(row.bestLapMs)).sort((a, b) => a.bestLapMs - b.bestLapMs)[0]?.displayName || "-"
)}</small>
</article>
</section>
<div class="overlay-leaderboard-card overlay-leaderboard-card-tv">
<div class="overlay-section-head">
<h3>${t("events.team_standings")}</h3>
</div>
${renderOverlayLeaderboard(rows)}
</div>
</div>
</section>
</section>
`;
}
function renderRecentPassings(session) {
if (!session) {
return `<p>${t("timing.no_session_selected")}</p>`;
}
const result = ensureSessionResult(session.id);
const items = getVisiblePassings(result).slice(-20).reverse();
if (!items.length) {
return `<p>${t("timing.no_passings")}</p>`;
}
return renderTable(
[t("table.time"), t("table.transponder"), t("table.driver"), t("table.car"), t("table.loop"), t("table.strength"), ""],
items.map((p, index) => {
return `
<tr>
<td>${new Date(p.timestamp).toLocaleTimeString()}</td>
<td>${escapeHtml(p.transponder)}</td>
<td>${escapeHtml(p.teamName ? `${p.teamName}${p.driverName || t("common.unknown_driver")}` : p.driverName || t("common.unknown_driver"))}</td>
<td>${escapeHtml(p.carName || p.subLabel || "-")}</td>
<td>${escapeHtml(p.loopId || "-")}</td>
<td>${p.strength ?? "-"}</td>
<td>${renderQuickAddActions(session, p.transponder, `recentPassing-${index}`)}</td>
</tr>
`;
})
);
}
function renderSettings() {
const ammcStatus = ammc.status || {};
const ammcOutput = Array.isArray(ammcStatus.lastOutput) ? ammcStatus.lastOutput : [];
const suggestedWsUrl = getManagedWsUrl();
const running = Boolean(ammcStatus.running);
dom.view.innerHTML = `
<section class="panel">
<div class="panel-header"><h3>${t("settings.decoder")}</h3></div>
<form id="settingsForm" class="panel-body form-grid cols-3 settings-grid">
<input name="wsUrl" value="${escapeHtml(state.settings.wsUrl)}" placeholder="ws://127.0.0.1:9000" />
<input name="backendUrl" value="${escapeHtml(
state.settings.backendUrl || getDefaultBackendUrl()
)}" placeholder="http://127.0.0.1:8081" />
<label class="toggle">
<input type="checkbox" name="autoReconnect" ${state.settings.autoReconnect ? "checked" : ""} />
<span>${t("settings.auto_reconnect")}</span>
</label>
<button class="btn btn-primary" type="submit">${t("settings.save")}</button>
<button id="settingsConnect" class="btn" type="button">${t("settings.connect_now")}</button>
</form>
<div class="panel-body">
<p>${t("settings.expected_json")}: <code>{"msg":"PASSING", "transponder":232323, "rtc_time":"..."}</code></p>
<p class="hint">${t("dashboard.live_note")}</p>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("settings.audio")}</h3></div>
<div class="panel-body form-grid cols-4 settings-grid settings-grid-toggles">
<label class="toggle">
<input type="checkbox" name="audioEnabled" form="settingsForm" ${state.settings.audioEnabled ? "checked" : ""} />
<span>${t("settings.audio_enabled")}</span>
</label>
<select name="passingSoundMode" form="settingsForm">
<option value="off" ${state.settings.passingSoundMode === "off" ? "selected" : ""}>${t("settings.passing_sound_off")}</option>
<option value="beep" ${state.settings.passingSoundMode === "beep" ? "selected" : ""}>${t("settings.passing_sound_beep")}</option>
<option value="name" ${state.settings.passingSoundMode === "name" ? "selected" : ""}>${t("settings.passing_sound_name")}</option>
</select>
<label class="toggle">
<input type="checkbox" name="finishVoiceEnabled" form="settingsForm" ${state.settings.finishVoiceEnabled ? "checked" : ""} />
<span>${t("settings.finish_voice")}</span>
</label>
<button id="settingsTestAudio" class="btn" type="button">${t("settings.test_audio")}</button>
<label class="toggle">
<input type="checkbox" name="speakerPassingCueEnabled" form="settingsForm" ${state.settings.speakerPassingCueEnabled ? "checked" : ""} />
<span>${t("settings.speaker_passing_cue")}</span>
</label>
<label class="toggle">
<input type="checkbox" name="speakerLeaderCueEnabled" form="settingsForm" ${state.settings.speakerLeaderCueEnabled ? "checked" : ""} />
<span>${t("settings.speaker_leader_cue")}</span>
</label>
<label class="toggle">
<input type="checkbox" name="speakerFinishCueEnabled" form="settingsForm" ${state.settings.speakerFinishCueEnabled ? "checked" : ""} />
<span>${t("settings.speaker_finish_cue")}</span>
</label>
<label class="toggle">
<input type="checkbox" name="speakerBestLapCueEnabled" form="settingsForm" ${state.settings.speakerBestLapCueEnabled ? "checked" : ""} />
<span>${t("settings.speaker_bestlap_cue")}</span>
</label>
<label class="toggle">
<input type="checkbox" name="speakerTop3CueEnabled" form="settingsForm" ${state.settings.speakerTop3CueEnabled ? "checked" : ""} />
<span>${t("settings.speaker_top3_cue")}</span>
</label>
<label class="toggle">
<input type="checkbox" name="speakerSessionStartCueEnabled" form="settingsForm" ${state.settings.speakerSessionStartCueEnabled ? "checked" : ""} />
<span>${t("settings.speaker_start_cue")}</span>
</label>
</div>
<div class="panel-body">
<p>${t("settings.audio_note")}</p>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("settings.branding")}</h3></div>
<div class="panel-body form-grid cols-3 settings-grid">
<input name="clubName" form="settingsForm" value="${escapeHtml(state.settings.clubName || "")}" placeholder="${t("settings.club_name")}" />
<input name="clubTagline" form="settingsForm" value="${escapeHtml(state.settings.clubTagline || "")}" placeholder="${t("settings.club_tagline")}" />
<input name="pdfFooter" form="settingsForm" value="${escapeHtml(state.settings.pdfFooter || "")}" placeholder="${t("settings.pdf_footer")}" />
<select name="pdfTheme" form="settingsForm">
<option value="classic" ${state.settings.pdfTheme === "classic" ? "selected" : ""}>${t("settings.pdf_theme_classic")}</option>
<option value="minimal" ${state.settings.pdfTheme === "minimal" ? "selected" : ""}>${t("settings.pdf_theme_minimal")}</option>
<option value="motorsport" ${state.settings.pdfTheme === "motorsport" ? "selected" : ""}>${t("settings.pdf_theme_motorsport")}</option>
</select>
</div>
<div class="panel-body">
<h4>${t("settings.logo")}</h4>
<p class="hint">${t("settings.logo_note")}</p>
<div class="actions">
<input id="logoUpload" type="file" accept="image/*" />
<button id="clearLogo" class="btn" type="button">${t("settings.logo_clear")}</button>
</div>
${
state.settings.logoDataUrl
? `<div class="logo-preview mt-16"><img src="${escapeHtml(state.settings.logoDataUrl)}" alt="logo" /></div>`
: ""
}
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("settings.managed_ammc")}</h3></div>
<form id="ammcForm" class="panel-body form-grid cols-2 settings-grid">
<label class="toggle">
<input type="checkbox" name="managedEnabled" ${ammc.config.managedEnabled ? "checked" : ""} />
<span>${t("settings.enable_managed")}</span>
</label>
<label class="toggle">
<input type="checkbox" name="autoStart" ${ammc.config.autoStart ? "checked" : ""} />
<span>${t("settings.auto_start_ammc")}</span>
</label>
<input name="decoderHost" value="${escapeHtml(ammc.config.decoderHost || "")}" placeholder="${t("settings.decoder_host")}" />
<input name="wsPort" value="${escapeHtml(String(ammc.config.wsPort || 9000))}" placeholder="9000" />
<input name="executablePath" value="${escapeHtml(ammc.config.executablePath || "")}" placeholder="${t("settings.executable_path")}" />
<input name="workingDirectory" value="${escapeHtml(ammc.config.workingDirectory || "")}" placeholder="${t("settings.working_dir")}" />
<input name="extraArgs" value="${escapeHtml(ammc.config.extraArgs || "")}" placeholder="${t("settings.extra_args")}" />
<div class="actions">
<button class="btn btn-primary" type="submit">${t("settings.save_ammc")}</button>
<button id="ammcRefresh" class="btn" type="button">${t("settings.refresh_ammc")}</button>
<button id="ammcStart" class="btn" type="button">${t("settings.start_ammc")}</button>
<button id="ammcStop" class="btn" type="button">${t("settings.stop_ammc")}</button>
<button id="ammcUseServerWs" class="btn" type="button">${t("settings.use_server_ws")}</button>
</div>
</form>
<div class="panel-body">
<p>${t("settings.managed_ammc_sub")}</p>
<p>${t("settings.bundled_hint")}</p>
<p>${t("settings.ammc_status")}: <strong>${running ? t("settings.running") : t("settings.stopped")}</strong></p>
<p>${t("settings.server_platform")}: <strong>${escapeHtml(String(ammcStatus.serverPlatform || "-"))}</strong></p>
<p>${t("settings.pid")}: <strong>${escapeHtml(String(ammcStatus.pid || "-"))}</strong></p>
<p>${t("settings.started_at")}: ${ammcStatus.startedAt ? new Date(ammcStatus.startedAt).toLocaleString() : "-"}</p>
<p>${t("settings.stopped_at")}: ${ammcStatus.stoppedAt ? new Date(ammcStatus.stoppedAt).toLocaleString() : "-"}</p>
<p>${t("settings.executable_path")}: <strong>${escapeHtml(String(ammcStatus.resolvedExecutablePath || ammc.config.executablePath || "-"))}</strong></p>
<p>${ammcStatus.executableExists ? t("settings.executable_found") : t("settings.executable_missing")}</p>
<p>${t("settings.decoder_host")}: <strong>${escapeHtml(String(ammc.config.decoderHost || "-"))}</strong></p>
<p>${t("settings.ws_port")}: <strong>${escapeHtml(String(ammc.config.wsPort || 9000))}</strong></p>
<p>${t("settings.last_error")}: ${escapeHtml(ammc.lastError || ammcStatus.lastError || "-")}</p>
<p>${t("settings.backend_url")}: <strong>${escapeHtml(getBackendUrl())}</strong></p>
<p>WebSocket URL: <strong>${escapeHtml(suggestedWsUrl)}</strong></p>
<pre class="log-box">${escapeHtml(
ammcOutput.length
? ammcOutput.map((entry) => `[${new Date(entry.ts).toLocaleTimeString()}] ${entry.stream}: ${entry.line}`).join("\n")
: "-"
)}</pre>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("settings.storage")}</h3></div>
<div class="panel-body">
<p>${t("settings.backend_url")}: <strong>${escapeHtml(getBackendUrl())}</strong></p>
<p>${t("settings.backend_status")}: <strong>${backend.available ? t("settings.online") : t("settings.offline")}</strong></p>
<p>${t("settings.last_sync")}: ${backend.lastSyncAt ? new Date(backend.lastSyncAt).toLocaleString() : "-"}</p>
<p class="error">${escapeHtml(backend.lastError || "")}</p>
<div class="actions">
<button id="settingsTestBackend" class="btn" type="button">${t("settings.test_backend")}</button>
<button id="settingsSyncNow" class="btn btn-primary" type="button">${t("settings.sync_now")}</button>
</div>
<button id="exportData" class="btn">${t("settings.export_json")}</button>
</div>
</section>
`;
document.getElementById("settingsForm")?.addEventListener("submit", (e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
state.settings.wsUrl = String(form.get("wsUrl") || "").trim();
state.settings.backendUrl = String(form.get("backendUrl") || "").trim();
state.settings.autoReconnect = form.get("autoReconnect") === "on";
state.settings.audioEnabled = form.get("audioEnabled") === "on";
state.settings.passingSoundMode = String(form.get("passingSoundMode") || "beep");
state.settings.finishVoiceEnabled = form.get("finishVoiceEnabled") === "on";
state.settings.speakerPassingCueEnabled = form.get("speakerPassingCueEnabled") === "on";
state.settings.speakerLeaderCueEnabled = form.get("speakerLeaderCueEnabled") === "on";
state.settings.speakerFinishCueEnabled = form.get("speakerFinishCueEnabled") === "on";
state.settings.speakerBestLapCueEnabled = form.get("speakerBestLapCueEnabled") === "on";
state.settings.speakerTop3CueEnabled = form.get("speakerTop3CueEnabled") === "on";
state.settings.speakerSessionStartCueEnabled = form.get("speakerSessionStartCueEnabled") === "on";
state.settings.clubName = String(form.get("clubName") || "").trim() || "JMK RB";
state.settings.clubTagline = String(form.get("clubTagline") || "").trim() || "Live Event";
state.settings.pdfFooter = String(form.get("pdfFooter") || "").trim() || "Generated by JMK RB Live Event";
state.settings.pdfTheme = ["classic", "minimal", "motorsport"].includes(String(form.get("pdfTheme") || "classic"))
? String(form.get("pdfTheme"))
: "classic";
saveState();
renderView();
});
document.getElementById("settingsConnect")?.addEventListener("click", connectDecoder);
document.getElementById("settingsTestAudio")?.addEventListener("click", () => {
playPassingBeep();
setTimeout(() => {
if (state.settings.finishVoiceEnabled) {
playFinishSiren();
}
}, 180);
});
document.getElementById("settingsTestBackend")?.addEventListener("click", async () => {
await pingBackend();
renderView();
});
document.getElementById("settingsSyncNow")?.addEventListener("click", async () => {
await syncStateToBackend();
renderView();
});
document.getElementById("logoUpload")?.addEventListener("change", (event) => {
const input = event.currentTarget;
const file = input instanceof HTMLInputElement ? input.files?.[0] : null;
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = () => {
state.settings.logoDataUrl = typeof reader.result === "string" ? reader.result : "";
saveState();
renderView();
};
reader.readAsDataURL(file);
});
document.getElementById("clearLogo")?.addEventListener("click", () => {
state.settings.logoDataUrl = "";
saveState();
renderView();
});
document.getElementById("exportData")?.addEventListener("click", () => {
const payload = {
classes: state.classes,
drivers: state.drivers,
cars: state.cars,
events: state.events,
sessions: state.sessions,
resultsBySession: state.resultsBySession,
exportedAt: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "rc_timing_export.json";
link.click();
URL.revokeObjectURL(url);
});
document.getElementById("ammcForm")?.addEventListener("submit", async (e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
const config = {
managedEnabled: form.get("managedEnabled") === "on",
autoStart: form.get("autoStart") === "on",
decoderHost: String(form.get("decoderHost") || "").trim(),
wsPort: Number(form.get("wsPort") || 9000),
executablePath: String(form.get("executablePath") || "").trim(),
workingDirectory: String(form.get("workingDirectory") || "").trim(),
extraArgs: String(form.get("extraArgs") || "").trim(),
};
await saveAmmcConfigToBackend(config);
renderView();
});
document.getElementById("ammcRefresh")?.addEventListener("click", async () => {
await refreshAmmcStatus();
renderView();
});
document.getElementById("ammcStart")?.addEventListener("click", async () => {
const formElement = document.getElementById("ammcForm");
if (formElement instanceof HTMLFormElement) {
const form = new FormData(formElement);
await saveAmmcConfigToBackend({
managedEnabled: form.get("managedEnabled") === "on",
autoStart: form.get("autoStart") === "on",
decoderHost: String(form.get("decoderHost") || "").trim(),
wsPort: Number(form.get("wsPort") || 9000),
executablePath: String(form.get("executablePath") || "").trim(),
workingDirectory: String(form.get("workingDirectory") || "").trim(),
extraArgs: String(form.get("extraArgs") || "").trim(),
});
}
await startManagedAmmc();
renderView();
});
document.getElementById("ammcStop")?.addEventListener("click", async () => {
await stopManagedAmmc();
renderView();
});
document.getElementById("ammcUseServerWs")?.addEventListener("click", () => {
state.settings.wsUrl = getManagedWsUrl();
saveState();
renderView();
});
}
function getSessionsForEvent(eventId) {
return state.sessions.filter((s) => s.eventId === eventId);
}
function getModeLabel(mode) {
return mode === "track" ? t("mode.track") : t("mode.race");
}
function getSessionTypeLabel(type) {
const key = `session.${String(type || "").toLowerCase()}`;
const translated = t(key);
return translated === key ? String(type || "") : translated;
}
function getOverlayModeLabel(mode) {
return t(`overlay.mode_${String(mode || "leaderboard").toLowerCase()}`);
}
function normalizeStartMode(mode) {
return ["mass", "position", "staggered"].includes(String(mode || "").toLowerCase()) ? String(mode).toLowerCase() : "mass";
}
function getStartModeLabel(mode) {
return t(`events.start_mode_${normalizeStartMode(mode)}`);
}
function getStatusLabel(status) {
const key = `status.${String(status || "").toLowerCase()}`;
const translated = t(key);
return translated === key ? String(status || "") : translated;
}
function getClassName(classId) {
return state.classes.find((x) => x.id === classId)?.name || t("common.unknown");
}
function getEventName(eventId) {
return state.events.find((x) => x.id === eventId)?.name || t("common.unknown_event");
}
function isUntimedSession(session) {
return String(session?.type || "").toLowerCase() === "open_practice";
}
function getActiveSession() {
return state.sessions.find((s) => s.id === state.activeSessionId) || null;
}
function getSessionTargetMs(session) {
if (isUntimedSession(session)) {
return null;
}
return Math.max(1, Number(session?.durationMin || 0)) * 60 * 1000;
}
function getSessionLapWindow(session) {
const event = state.events.find((item) => item.id === session?.eventId);
if (event?.mode !== "race") {
return { minLapMs: 0, maxLapMs: Number.POSITIVE_INFINITY };
}
const minLapMs = Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0);
const configuredMaxLapMs = Math.max(0, Number(event?.raceConfig?.maxLapMs || 60000) || 60000);
const maxLapMs = configuredMaxLapMs > 0 ? Math.max(configuredMaxLapMs, minLapMs || 0) : Number.POSITIVE_INFINITY;
return { minLapMs, maxLapMs };
}
function isCountedPassing(passing) {
return passing?.validLap !== false;
}
function getVisiblePassings(result) {
return Array.isArray(result?.passings) ? result.passings.filter((passing) => isCountedPassing(passing)) : [];
}
function getSessionTiming(session, nowTs = Date.now()) {
const targetMs = getSessionTargetMs(session);
const startedAt = Number(session?.startedAt || 0);
const elapsedMs = startedAt ? Math.max(0, nowTs - startedAt) : 0;
return {
targetMs,
elapsedMs,
remainingMs: targetMs === null ? null : Math.max(0, targetMs - elapsedMs),
untimed: targetMs === null,
};
}
function ensureSessionResult(sessionId) {
if (!state.resultsBySession[sessionId]) {
state.resultsBySession[sessionId] = {
passings: [],
competitors: {},
};
}
return state.resultsBySession[sessionId];
}
function getFreePracticeSessions(eventId) {
return getSessionsForEvent(eventId).filter((session) => session.type === "free_practice");
}
function connectDecoder() {
disconnectDecoder({ silent: true });
state.decoder.lastError = "";
saveState();
const url = state.settings.wsUrl;
try {
wsClient = new WebSocket(url);
} catch (error) {
state.decoder.lastError = t("error.ws_invalid", { msg: error instanceof Error ? error.message : String(error) });
saveState();
renderView();
return;
}
wsClient.onopen = () => {
state.decoder.connected = true;
state.decoder.lastError = "";
saveState();
updateConnectionBadge();
if (currentView === "timing" || currentView === "settings" || currentView === "overlay") {
renderView();
}
};
wsClient.onmessage = (event) => {
state.decoder.lastMessageAt = Date.now();
let parsed;
try {
parsed = JSON.parse(String(event.data));
} catch {
return;
}
if (Array.isArray(parsed)) {
parsed.forEach(processDecoderMessage);
} else {
processDecoderMessage(parsed);
}
saveState();
updateConnectionBadge();
if (currentView === "timing" || currentView === "dashboard" || currentView === "overlay") {
renderView();
}
};
wsClient.onclose = () => {
state.decoder.connected = false;
saveState();
updateConnectionBadge();
if (state.settings.autoReconnect) {
clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
if (!state.decoder.connected) {
connectDecoder();
}
}, 2000);
}
if (currentView === "timing" || currentView === "settings" || currentView === "overlay") {
renderView();
}
};
wsClient.onerror = () => {
state.decoder.lastError = t("error.decoder_connection");
saveState();
updateConnectionBadge();
if (currentView === "timing" || currentView === "settings" || currentView === "overlay") {
renderView();
}
};
}
function disconnectDecoder(options = {}) {
clearTimeout(reconnectTimer);
if (wsClient) {
wsClient.onopen = null;
wsClient.onmessage = null;
wsClient.onclose = null;
wsClient.onerror = null;
wsClient.close();
wsClient = null;
}
state.decoder.connected = false;
if (!options.silent) {
saveState();
}
updateConnectionBadge();
}
function processDecoderMessage(msg) {
if (!msg || typeof msg !== "object") {
return;
}
const type = String(msg.msg || msg.type || "").toUpperCase();
if (type !== "PASSING") {
return;
}
const session = getActiveSession();
if (!session || session.status !== "running") {
return;
}
const timestamp = parseRtcTime(msg.rtc_time) || Date.now();
const transponder = String(msg.transponder ?? msg.tran_code ?? "").replace("ID:", "").trim();
if (!transponder) {
return;
}
const result = ensureSessionResult(session.id);
const competitor = resolveCompetitor(session, transponder);
if (competitor.ignore) {
return;
}
const key = competitor.key;
if (!result.competitors[key]) {
result.competitors[key] = {
key,
teamId: competitor.teamId || null,
teamName: competitor.teamName || "",
driverId: competitor.driverId,
driverName: competitor.driverName,
displayName: competitor.displayName || competitor.driverName,
subLabel: competitor.subLabel || competitor.carName || "",
carId: competitor.carId,
carName: competitor.carName,
transponder,
laps: 0,
lastLapMs: null,
bestLapMs: null,
startTimestamp: null,
lastTimestamp: null,
};
}
const entry = result.competitors[key];
entry.teamId = competitor.teamId || entry.teamId || null;
entry.teamName = competitor.teamName || entry.teamName || "";
entry.displayName = competitor.displayName || entry.displayName || competitor.driverName;
entry.subLabel = competitor.subLabel || entry.subLabel || competitor.carName || "";
entry.driverId = competitor.driverId ?? entry.driverId;
entry.driverName = competitor.driverName || entry.driverName;
entry.carId = competitor.carId ?? entry.carId;
entry.carName = competitor.carName || entry.carName;
entry.transponder = transponder;
const startMode = normalizeStartMode(session.startMode);
if (startMode === "staggered" && !entry.startTimestamp) {
entry.startTimestamp = timestamp;
entry.lastTimestamp = timestamp;
announcePassing(entry);
saveState();
return;
}
if (!entry.startTimestamp) {
entry.startTimestamp = session.startedAt || timestamp;
}
const baseTs = entry.lastTimestamp || entry.startTimestamp || session.startedAt || timestamp;
const lapMs = Math.max(0, timestamp - baseTs);
const { minLapMs, maxLapMs } = getSessionLapWindow(session);
let validLap = true;
let invalidReason = "";
if (minLapMs > 0 && lapMs > 0 && lapMs < minLapMs) {
validLap = false;
invalidReason = "below_min";
} else if (Number.isFinite(maxLapMs) && maxLapMs > 0 && lapMs > maxLapMs) {
validLap = false;
invalidReason = "above_max";
}
const passing = {
timestamp,
transponder,
teamId: entry.teamId,
teamName: entry.teamName,
driverId: entry.driverId,
driverName: entry.driverName,
displayName: entry.displayName,
subLabel: entry.subLabel,
carId: entry.carId,
carName: entry.carName,
competitorKey: key,
lapMs,
validLap,
invalidReason,
strength: msg.strength,
loopId: String(msg.loop_id || ""),
resend: Boolean(msg.resend),
};
if (!validLap) {
result.passings.push(passing);
if (invalidReason === "above_max") {
entry.lastTimestamp = timestamp;
}
persistPassingToBackend(session.id, passing);
saveState();
return;
}
entry.laps += 1;
entry.lastLapMs = lapMs;
entry.lastTimestamp = timestamp;
if (lapMs > 500 && (!entry.bestLapMs || lapMs < entry.bestLapMs)) {
entry.bestLapMs = lapMs;
}
result.passings.push(passing);
persistPassingToBackend(session.id, passing);
pushOverlayEvent("passing", `${entry.displayName || entry.driverName}${formatLap(entry.lastLapMs)}`);
const leaderboard = buildLeaderboard(session);
const leader = leaderboard[0];
if (leader?.key && lastOverlayLeaderKeyBySession[session.id] !== leader.key) {
lastOverlayLeaderKeyBySession[session.id] = leader.key;
pushOverlayEvent("leader", `${leader.displayName || leader.driverName} • P1`);
}
if (entry.bestLapMs && Number.isFinite(entry.bestLapMs)) {
const bestKey = `${session.id}:${entry.key}`;
const previousBest = lastOverlayBestLapByKey[bestKey];
if (!previousBest || entry.bestLapMs < previousBest) {
lastOverlayBestLapByKey[bestKey] = entry.bestLapMs;
pushOverlayEvent("bestlap", `${entry.displayName || entry.driverName}${formatLap(entry.bestLapMs)}`);
}
}
const top3Keys = leaderboard.slice(0, 3).map((row) => row.key);
const previousTop3 = lastOverlayTop3BySession[session.id] || [];
if (top3Keys.join("|") !== previousTop3.join("|")) {
lastOverlayTop3BySession[session.id] = top3Keys;
if (previousTop3.length) {
pushOverlayEvent("top3", `${t("overlay.mode_leaderboard")} • Top 3 updated`);
}
}
announcePassing(entry);
}
function parseRtcTime(value) {
if (!value || typeof value !== "string") {
return null;
}
const ts = Date.parse(value);
return Number.isFinite(ts) ? ts : null;
}
function resolveCompetitor(session, transponder) {
const sessionType = String(session?.type || "").toLowerCase();
const isOpenPractice = sessionType === "open_practice";
const isFreePractice = sessionType === "free_practice";
const isOpenMonitoringSession = isOpenPractice || isFreePractice;
const event = state.events.find((item) => item.id === session?.eventId) || null;
if (session.mode === "track") {
const matchingAssignments = (session.assignments || []).filter((a) => {
const car = state.cars.find((c) => c.id === a.carId);
return car?.transponder === transponder;
});
if (matchingAssignments.length > 1) {
return {
key: `track_ambiguous_${transponder}`,
driverId: null,
driverName: `Ambiguous TP ${transponder}`,
carId: null,
carName: t("common.unknown_car"),
};
}
const assignment = matchingAssignments[0];
if (assignment) {
const driver = state.drivers.find((d) => d.id === assignment.driverId);
const car = state.cars.find((c) => c.id === assignment.carId);
return {
key: `track_${assignment.id}`,
driverId: driver?.id || null,
driverName: driver?.name || t("common.unknown_driver"),
carId: car?.id || null,
carName: car?.name || t("common.unknown_car"),
};
}
return {
key: `track_tp_${transponder}`,
driverId: null,
driverName: t("common.unassigned_driver"),
carId: null,
carName: t("common.unknown_car"),
};
}
if (session.mode === "race" && sessionType === "team_race") {
const driver = state.drivers.find((d) => d.transponder === transponder) || null;
const car = state.cars.find((c) => c.transponder === transponder) || null;
const team = event ? findEventTeamForPassing(event, driver?.id || null, car?.id || null) : null;
if (!team) {
return {
key: `ignore_team_${transponder}`,
ignore: true,
};
}
const memberBits = [driver?.name || "", car?.name || ""].filter(Boolean);
return {
key: `team_${team.id}`,
teamId: team.id,
teamName: team.name,
displayName: team.name,
subLabel: memberBits.join(" • ") || transponder,
driverId: driver?.id || null,
driverName: driver?.name || team.name,
carId: car?.id || null,
carName: car?.name || t("common.unknown_car"),
};
}
const driver = state.drivers.find((d) => d.transponder === transponder);
if (driver) {
if (!isOpenMonitoringSession && Array.isArray(session.driverIds) && session.driverIds.length && !session.driverIds.includes(driver.id)) {
return {
key: `ignore_${driver.id}`,
ignore: true,
};
}
return {
key: `driver_${driver.id}`,
driverId: driver.id,
driverName: driver.name,
displayName: driver.name,
subLabel: driver.transponder || "",
carId: null,
carName: t("common.driver_car"),
};
}
return {
key: `driver_tp_${transponder}`,
driverId: null,
driverName: isOpenPractice ? transponder : isFreePractice ? `TP ${transponder}` : t("common.unknown_driver"),
displayName: isOpenPractice ? transponder : isFreePractice ? `TP ${transponder}` : t("common.unknown_driver"),
subLabel: transponder,
carId: null,
carName: t("common.unknown_car"),
};
}
function buildLeaderboard(session) {
const result = ensureSessionResult(session.id);
const sessionType = String(session.type || "").toLowerCase();
const targetMs = getSessionTargetMs(session);
const useTargetTieBreak = session.status === "finished";
const useSeedRanking = ["practice", "qualification"].includes(sessionType) && Number(session.seedBestLapCount || 0) > 0;
const isFreePractice = sessionType === "free_practice";
const isOpenPractice = sessionType === "open_practice";
const isRollingPractice = isFreePractice || isOpenPractice;
const nowTs = Date.now();
const rows = Object.values(result.competitors).map((row) => {
const totalElapsedMs = getCompetitorElapsedMs(session, row);
const distanceToTargetMs = Math.abs(targetMs - totalElapsedMs);
const seedMetric = getCompetitorSeedMetric(session, row);
const passings = getCompetitorPassings(session, row);
const latestPassing = passings.length ? passings[passings.length - 1] : null;
const previousLapMs = passings.length >= 2 ? Number(passings[passings.length - 2].lapMs || 0) : null;
const lastLapMs = latestPassing ? Number(latestPassing.lapMs || 0) : Number(row.lastLapMs || 0) || 0;
const bestLapMs = Number(row.bestLapMs || 0) || 0;
const lastPassingTs = latestPassing ? Number(latestPassing.timestamp || 0) : Number(row.lastTimestamp || 0) || 0;
const lapDeltaMs =
lastLapMs && previousLapMs && lastLapMs > 0 && previousLapMs > 0 ? lastLapMs - previousLapMs : null;
const predictionBaseMs =
lastLapMs > 0
? lastLapMs
: bestLapMs > 0
? bestLapMs
: null;
const currentLapElapsedMs = lastPassingTs ? Math.max(0, nowTs - lastPassingTs) : 0;
const predictedRemainingMs = predictionBaseMs ? Math.max(0, predictionBaseMs - currentLapElapsedMs) : null;
const predictedProgress = predictionBaseMs ? Math.min(1.25, currentLapElapsedMs / predictionBaseMs) : 0;
const predictionTone =
!predictionBaseMs || predictedProgress <= 0.85
? "good"
: predictedProgress <= 1
? "warn"
: "late";
return {
...row,
lastLapMs,
bestLapMs,
lastTimestamp: lastPassingTs || row.lastTimestamp,
totalElapsedMs,
distanceToTargetMs,
seedMetric,
previousLapMs,
lapDeltaMs,
predictedRemainingMs,
predictedProgress,
predictionTone,
comparisonMs:
isRollingPractice
? row.bestLapMs || row.lastLapMs || Number.MAX_SAFE_INTEGER
: useSeedRanking && seedMetric
? seedMetric.totalMs
: totalElapsedMs,
resultDisplay:
isRollingPractice
? formatLap(row.bestLapMs || row.lastLapMs)
: useSeedRanking && seedMetric
? `${seedMetric.lapCount}/${formatRaceClock(seedMetric.totalMs)}`
: `${row.laps}/${formatRaceClock(totalElapsedMs)}`,
};
});
rows.sort((a, b) => {
if (isRollingPractice) {
if (a.comparisonMs !== b.comparisonMs) {
return a.comparisonMs - b.comparisonMs;
}
if (b.laps !== a.laps) {
return b.laps - a.laps;
}
return (b.lastTimestamp || 0) - (a.lastTimestamp || 0);
}
if (useSeedRanking) {
if (a.seedMetric && b.seedMetric && a.seedMetric.totalMs !== b.seedMetric.totalMs) {
return a.seedMetric.totalMs - b.seedMetric.totalMs;
}
if (a.seedMetric && !b.seedMetric) {
return -1;
}
if (!a.seedMetric && b.seedMetric) {
return 1;
}
if (b.laps !== a.laps) {
return b.laps - a.laps;
}
return a.totalElapsedMs - b.totalElapsedMs;
}
if (b.laps !== a.laps) {
return b.laps - a.laps;
}
if (useTargetTieBreak && a.distanceToTargetMs !== b.distanceToTargetMs) {
return a.distanceToTargetMs - b.distanceToTargetMs;
}
return a.totalElapsedMs - b.totalElapsedMs;
});
const leader = rows[0];
return rows.map((row, index) => {
if (!leader) {
return { ...row, gap: "-", leaderGap: "-", gapAhead: "-", lapDelta: "-" };
}
return {
...row,
gap: formatLeaderboardGap(row, leader, { useSeedRanking, useTargetTieBreak, isFreePractice: isRollingPractice }),
leaderGap: formatLeaderboardGap(row, leader, { useSeedRanking, useTargetTieBreak, isFreePractice: isRollingPractice }),
gapAhead: formatLeaderboardGap(row, rows[index - 1], {
useSeedRanking,
useTargetTieBreak,
isFreePractice: isRollingPractice,
selfLabel: t("status.leader"),
}),
lapDelta: formatLapDelta(row.lapDeltaMs),
};
});
}
function formatLapDelta(deltaMs) {
if (!deltaMs && deltaMs !== 0) {
return "-";
}
const sign = deltaMs <= 0 ? "-" : "+";
return `${sign}${(Math.abs(deltaMs) / 1000).toFixed(3)}s`;
}
function formatLeaderboardGap(row, referenceRow, options = {}) {
if (!referenceRow) {
return "-";
}
if (row.key === referenceRow.key) {
if (options.isFreePractice) {
return t("status.free_practice");
}
return options.selfLabel || (options.useSeedRanking ? t("status.seeded") : t("status.leader"));
}
if (options.isFreePractice) {
if (Number.isFinite(row.comparisonMs) && Number.isFinite(referenceRow.comparisonMs)) {
return `+${((row.comparisonMs - referenceRow.comparisonMs) / 1000).toFixed(3)}s`;
}
return "-";
}
if (options.useSeedRanking) {
if (referenceRow.seedMetric && row.seedMetric) {
const seedGap = Math.max(0, row.seedMetric.totalMs - referenceRow.seedMetric.totalMs);
return `+${(seedGap / 1000).toFixed(3)}s`;
}
return `+${Math.max(0, (referenceRow.laps || 0) - (row.laps || 0))}L`;
}
const lapDiff = (referenceRow.laps || 0) - (row.laps || 0);
if (lapDiff > 0) {
return `+${lapDiff}L`;
}
const referenceValue = options.useTargetTieBreak ? referenceRow.distanceToTargetMs : referenceRow.totalElapsedMs;
const rowValue = options.useTargetTieBreak ? row.distanceToTargetMs : row.totalElapsedMs;
const timeGap = Math.max(0, rowValue - referenceValue);
return `+${(timeGap / 1000).toFixed(3)}s`;
}
function renderSessionsTable(sessions) {
if (!sessions.length) {
return `<p>${t("session.none_yet")}</p>`;
}
return renderTable(
[t("table.event"), t("table.session"), t("table.type"), t("table.status"), t("table.mode")],
sessions.map(
(s) => `
<tr>
<td>${escapeHtml(getEventName(s.eventId))}</td>
<td>${escapeHtml(s.name)}</td>
<td>${escapeHtml(getSessionTypeLabel(s.type))}</td>
<td>${escapeHtml(getStatusLabel(s.status))}</td>
<td>${getModeLabel(s.mode)}</td>
</tr>
`
)
);
}
function renderSimpleList(items, labelFn, idFn) {
if (!items.length) {
return `<p>${t("common.no_entries")}</p>`;
}
return `
<ul class="simple-list">
${items
.map(
(item) => `
<li>
<span>${labelFn(item)}</span>
<button class="btn btn-danger btn-mini" id="${idFn(item)}">${t("common.delete")}</button>
</li>
`
)
.join("")}
</ul>
`;
}
function renderTable(headers, rowHtml) {
return `
<table class="data-table">
<thead>
<tr>
${headers.map((h) => `<th>${escapeHtml(h)}</th>`).join("")}
</tr>
</thead>
<tbody>
${rowHtml.join("") || `<tr><td colspan="${headers.length}">${t("common.no_rows")}</td></tr>`}
</tbody>
</table>
`;
}
function renderRaceFormatField(labelKey, hintKey, controlHtml, options = {}) {
const extraClass = options.checkbox ? " field-card-checkbox" : "";
return `
<label class="field-card${extraClass}">
<span class="field-label">${t(labelKey)}</span>
<span class="field-hint">${t(hintKey)}</span>
${controlHtml}
</label>
`;
}
function formatLap(ms) {
if (!ms && ms !== 0) {
return "-";
}
const total = Math.max(0, Math.floor(ms));
const m = Math.floor(total / 60000);
const s = Math.floor((total % 60000) / 1000)
.toString()
.padStart(2, "0");
const t = Math.floor(total % 1000)
.toString()
.padStart(3, "0");
return `${m}:${s}.${t}`;
}
function formatCountdown(ms) {
const total = Math.max(0, Math.floor(ms));
const m = Math.floor(total / 60000)
.toString()
.padStart(2, "0");
const s = Math.floor((total % 60000) / 1000)
.toString()
.padStart(2, "0");
return `${m}:${s}`;
}
function formatElapsedClock(ms) {
const total = Math.max(0, Math.floor(ms / 1000));
const h = Math.floor(total / 3600)
.toString()
.padStart(1, "0");
const m = Math.floor((total % 3600) / 60)
.toString()
.padStart(2, "0");
const s = Math.floor(total % 60)
.toString()
.padStart(2, "0");
return `${h}:${m}:${s}`;
}
function formatRaceClock(ms) {
const total = Math.max(0, Math.floor(ms));
const m = Math.floor(total / 60000)
.toString()
.padStart(2, "0");
const s = Math.floor((total % 60000) / 1000)
.toString()
.padStart(2, "0");
const centiseconds = Math.floor((total % 1000) / 10)
.toString()
.padStart(2, "0");
const millis = Math.floor(total % 1000)
.toString()
.padStart(3, "0");
return `${m}:${s}.${centiseconds}:${millis}`;
}
function getCompetitorElapsedMs(session, row) {
const startTs = Number(row?.startTimestamp || session?.startedAt || 0);
if (!startTs || !row?.lastTimestamp) {
return 0;
}
return Math.max(0, row.lastTimestamp - startTs);
}
function getCompetitorPassings(session, row, options = {}) {
const result = ensureSessionResult(session.id);
return result.passings
.filter((passing) => {
if (!options.includeInvalid && !isCountedPassing(passing)) {
return false;
}
if (passing.competitorKey) {
return passing.competitorKey === row.key;
}
return (
String(passing.transponder || "") === String(row.transponder || "") &&
String(passing.driverId || "") === String(row.driverId || "") &&
String(passing.carId || "") === String(row.carId || "")
);
})
.sort((a, b) => a.timestamp - b.timestamp);
}
function getCompetitorSeedMetric(session, row) {
const lapCount = Math.max(0, Number(session?.seedBestLapCount || 0) || 0);
if (lapCount <= 0) {
return null;
}
const laps = getCompetitorPassings(session, row)
.map((passing) => Number(passing.lapMs || 0))
.filter((lapMs) => lapMs > 500)
.sort((a, b) => a - b);
if (laps.length < lapCount) {
return null;
}
const selected = laps.slice(0, lapCount);
return {
lapCount,
totalMs: selected.reduce((sum, lapMs) => sum + lapMs, 0),
laps: selected,
};
}
function getEventDrivers(event) {
const classDrivers = state.drivers.filter((driver) => !event?.classId || driver.classId === event.classId);
if (!event?.raceConfig?.participantsConfigured) {
return classDrivers;
}
return classDrivers.filter((driver) => (event.raceConfig.driverIds || []).includes(driver.id));
}
function getEventTeams(event) {
return Array.isArray(event?.raceConfig?.teams) ? event.raceConfig.teams.map((team) => normalizeRaceTeam(team)).filter((team) => team.name) : [];
}
function getTeamDriverPool(event) {
const scopedDrivers = getEventDrivers(event);
if (scopedDrivers.length) {
return { drivers: scopedDrivers, fallback: false };
}
return {
drivers: [...state.drivers],
fallback: state.drivers.length > 0,
};
}
function findEventTeamForPassing(event, driverId, carId) {
return getEventTeams(event).find((team) => {
const driverMatch = driverId && Array.isArray(team.driverIds) && team.driverIds.includes(driverId);
const carMatch = carId && Array.isArray(team.carIds) && team.carIds.includes(carId);
return Boolean(driverMatch || carMatch);
}) || null;
}
function getSessionEntrants(session) {
const event = state.events.find((item) => item.id === session.eventId);
const eventDrivers = event ? getEventDrivers(event) : state.drivers;
if (!Array.isArray(session.driverIds) || !session.driverIds.length) {
return eventDrivers;
}
return eventDrivers.filter((driver) => session.driverIds.includes(driver.id));
}
function buildPracticeStandings(event) {
const sessions = getSessionsForEvent(event.id).filter((session) => session.type === "practice");
const competitorMap = new Map();
sessions.forEach((session) => {
buildLeaderboard(session).forEach((row) => {
const key = row.driverId || row.key;
const seedMetric = getCompetitorSeedMetric(session, row);
const comparableMs = seedMetric?.totalMs ?? row.bestLapMs ?? row.totalElapsedMs;
const current = competitorMap.get(key);
if (!current || comparableMs < current.comparableMs) {
competitorMap.set(key, {
key,
driverId: row.driverId,
driverName: row.driverName,
comparableMs,
resultDisplay: seedMetric
? `${seedMetric.lapCount}/${formatRaceClock(seedMetric.totalMs)}`
: `${row.laps}/${formatRaceClock(row.totalElapsedMs)}`,
sourceSessionName: session.name,
});
}
});
});
return [...competitorMap.values()].sort((a, b) => a.comparableMs - b.comparableMs).map((row, index) => ({
...row,
rank: index + 1,
score: row.resultDisplay,
}));
}
function buildQualifyingStandings(event) {
const sessions = getSessionsForEvent(event.id).filter((session) => session.type === "qualification");
const scoringMode = event.raceConfig?.qualifyingScoring || "points";
const countedRounds = Math.max(1, Number(event.raceConfig?.countedQualRounds || 1) || 1);
const competitorMap = new Map();
sessions.forEach((session) => {
const rows = buildLeaderboard(session);
const entrantCount = Math.max(rows.length, getSessionEntrants(session).length || 0);
rows.forEach((row, index) => {
const key = row.driverId || row.key;
if (!competitorMap.has(key)) {
competitorMap.set(key, {
key,
driverId: row.driverId,
driverName: row.driverName,
points: [],
ranks: [],
bestRoundMs: [],
});
}
const entry = competitorMap.get(key);
entry.points.push(index + 1);
entry.ranks.push(index + 1);
entry.bestRoundMs.push(row.totalElapsedMs);
entry.bestResultDisplay = row.resultDisplay;
entry.lastSessionName = session.name;
entry.sessionCount = (entry.sessionCount || 0) + 1;
entry.maxFieldSize = entrantCount;
});
});
const rows = [...competitorMap.values()].map((entry) => {
const sortedPoints = [...entry.points].sort((a, b) => a - b);
const counted = sortedPoints.slice(0, countedRounds);
const totalScore = counted.reduce((sum, value) => sum + value, 0);
const bestRank = Math.min(...entry.ranks);
const bestElapsed = Math.min(...entry.bestRoundMs);
return {
key: entry.key,
driverId: entry.driverId,
driverName: entry.driverName,
ranks: [...entry.ranks].sort((a, b) => a - b),
totalScore,
bestRank,
bestElapsed,
score:
scoringMode === "points"
? `${totalScore} (${counted.join("+")})`
: `${bestRank} / ${formatRaceClock(bestElapsed)}`,
};
});
rows.sort((a, b) => {
if (scoringMode === "points") {
if (a.totalScore !== b.totalScore) {
return a.totalScore - b.totalScore;
}
for (let i = 0; i < Math.max(a.ranks.length, b.ranks.length); i += 1) {
const left = a.ranks[i] ?? 999;
const right = b.ranks[i] ?? 999;
if (left !== right) {
return left - right;
}
}
return a.bestElapsed - b.bestElapsed;
}
if (a.bestRank !== b.bestRank) {
return a.bestRank - b.bestRank;
}
return a.bestElapsed - b.bestElapsed;
});
return rows.map((row, index) => ({
...row,
rank: index + 1,
}));
}
function renderRaceStandingsTable(rows, emptyLabel) {
if (!rows.length) {
return `<p>${emptyLabel}</p>`;
}
return renderTable(
[t("table.pos"), t("table.driver"), t("table.score")],
rows.map(
(row) => `
<tr>
<td>${row.rank}</td>
<td>${escapeHtml(row.driverName || t("common.unknown_driver"))}</td>
<td>${escapeHtml(row.score || "-")}</td>
</tr>
`
)
);
}
function formatTeamActiveMemberLabel(rowOrPassing) {
if (!rowOrPassing) {
return "-";
}
const parts = [rowOrPassing.driverName || "", rowOrPassing.carName || ""].filter(Boolean);
return parts.join(" • ") || rowOrPassing.subLabel || "-";
}
function buildTeamRaceStandings(event) {
return getSessionsForEvent(event.id)
.filter((session) => session.type === "team_race")
.sort((left, right) => getSessionSortWeight(left) - getSessionSortWeight(right) || String(left.name).localeCompare(String(right.name)))
.map((session) => ({
session,
rows: buildLeaderboard(session),
}));
}
function buildTeamStintLog(session, row) {
const passings = getCompetitorPassings(session, row);
if (!passings.length) {
return [];
}
const { maxLapMs } = getSessionLapWindow(session);
const stintGapMs = Number.isFinite(maxLapMs) ? maxLapMs : Number.POSITIVE_INFINITY;
const stints = [];
let current = null;
passings.forEach((passing) => {
const memberLabel = formatTeamActiveMemberLabel(passing);
const memberKey = `${passing.driverId || passing.driverName || "-"}|${passing.carId || passing.carName || "-"}`;
const gapBreak = current && Number.isFinite(stintGapMs) && Math.max(0, passing.timestamp - current.endTs) > stintGapMs;
if (!current || current.memberKey !== memberKey || gapBreak) {
current = {
memberKey,
memberLabel,
driverName: passing.driverName || "-",
carName: passing.carName || "-",
startTs: passing.timestamp,
endTs: passing.timestamp,
laps: 1,
};
stints.push(current);
return;
}
current.endTs = passing.timestamp;
current.laps += 1;
});
return stints.map((stint, index) => ({
...stint,
index: index + 1,
durationMs: Math.max(0, stint.endTs - stint.startTs),
}));
}
function renderTeamStintLog(session, rows) {
if (!rows.length) {
return `<p>${t("events.no_team_results")}</p>`;
}
return `
<div class="team-log-grid">
${rows
.map((row) => {
const stints = buildTeamStintLog(session, row);
return `
<article class="team-log-card">
<h5>${escapeHtml(row.displayName || row.driverName)}</h5>
<div class="hint">${t("overlay.active_member")}: ${escapeHtml(formatTeamActiveMemberLabel(row))}</div>
${
stints.length
? renderTable(
[t("events.slot"), t("table.driver"), t("table.car"), t("table.time"), t("table.duration"), t("table.laps")],
stints.map(
(stint) => `
<tr>
<td>${stint.index}</td>
<td>${escapeHtml(stint.driverName || "-")}</td>
<td>${escapeHtml(stint.carName || "-")}</td>
<td>${new Date(stint.startTs).toLocaleTimeString()}</td>
<td>${formatRaceClock(stint.durationMs)}</td>
<td>${stint.laps}</td>
</tr>
`
)
)
: `<p>${t("timing.no_passings")}</p>`
}
</article>
`;
})
.join("")}
</div>
`;
}
function renderTeamRaceStandings(event) {
const groups = buildTeamRaceStandings(event);
if (!groups.length) {
return `<p>${t("events.no_team_results")}</p>`;
}
return groups
.map(
({ session, rows }) => `
<section class="team-standings-block">
<h4>${escapeHtml(session.name)}${escapeHtml(getSessionTypeLabel(session.type))}</h4>
${
rows.length
? renderTable(
[t("table.pos"), t("events.team_name"), t("table.laps"), t("table.result"), t("table.best_lap")],
rows.map(
(row, index) => `
<tr>
<td>${index + 1}</td>
<td>
<div class="table-primary">${escapeHtml(row.displayName || row.driverName)}</div>
<div class="table-subnote">${t("overlay.active_member")}: ${escapeHtml(formatTeamActiveMemberLabel(row))}</div>
</td>
<td>${row.laps}</td>
<td>${escapeHtml(row.resultDisplay)}</td>
<td>${formatLap(row.bestLapMs)}</td>
</tr>
`
)
)
: `<p>${t("events.no_team_results")}</p>`
}
<div class="mt-16">
<h5>${t("events.team_stint_log")}</h5>
${rows.length ? renderTeamStintLog(session, rows) : `<p>${t("events.no_team_results")}</p>`}
</div>
</section>
`
)
.join("");
}
function getSessionSortWeight(session) {
const order = {
open_practice: 0,
free_practice: 1,
practice: 2,
qualification: 3,
heat: 4,
final: 5,
team_race: 6,
};
return order[String(session?.type || "").toLowerCase()] || 99;
}
function getDriverDisplayById(driverId) {
const driver = state.drivers.find((item) => item.id === driverId);
if (!driver) {
return t("common.unknown_driver");
}
return driver.transponder ? `${driver.name} (${driver.transponder})` : driver.name;
}
function getSessionGridEntries(session) {
if (!session) {
return [];
}
if (session.mode === "track") {
return (session.assignments || []).map((assignment, index) => {
const driver = state.drivers.find((item) => item.id === assignment.driverId);
const car = state.cars.find((item) => item.id === assignment.carId);
return {
slot: index + 1,
name: driver?.name || t("common.unknown_driver"),
meta: car ? `${car.name} (${car.transponder || "-"})` : t("common.unknown_car"),
};
});
}
if (session.type === "team_race") {
const event = state.events.find((item) => item.id === session.eventId);
return getEventTeams(event).map((team, index) => ({
slot: index + 1,
name: team.name,
meta:
team.driverIds.map((driverId) => getDriverDisplayById(driverId)).join(", ") ||
team.carIds
.map((carId) => {
const car = state.cars.find((item) => item.id === carId);
return car ? `${car.name} (${car.transponder || "-"})` : "";
})
.filter(Boolean)
.join(", ") ||
"-",
}));
}
return getSessionGridOrder(session).map((driverId, index) => {
const driver = state.drivers.find((item) => item.id === driverId);
return {
slot: index + 1,
name: driver?.name || t("common.unknown_driver"),
meta: driver?.transponder || "-",
};
});
}
function getSessionGridOrder(session) {
if (!session) {
return [];
}
if (Array.isArray(session.manualGridIds) && session.manualGridIds.length) {
return session.manualGridIds;
}
return Array.isArray(session.driverIds) ? session.driverIds : [];
}
function renderPositionGrid(session) {
const entries = getSessionGridEntries(session);
if (!entries.length) {
return "";
}
return `
<div class="position-grid mt-16">
<h4>${t("events.position_grid")}</h4>
<p class="hint">${t("timing.position_grid_hint")}</p>
<div class="position-grid-list">
${entries
.map(
(entry) => `
<div class="position-grid-item">
<span class="pos-pill">${entry.slot}</span>
<div>
<strong>${escapeHtml(entry.name)}</strong>
${entry.meta ? `<div class="hint">${escapeHtml(entry.meta)}</div>` : ""}
</div>
</div>
`
)
.join("")}
</div>
</div>
`;
}
function ensureSessionDriverOrder(session) {
if (!session || session.mode !== "race") {
return [];
}
if (!Array.isArray(session.driverIds) || !session.driverIds.length) {
session.driverIds = getSessionEntrants(session)
.map((driver) => driver.id)
.filter(Boolean);
}
if (!Array.isArray(session.manualGridIds) || !session.manualGridIds.length) {
session.manualGridIds = [...session.driverIds];
}
return session.manualGridIds;
}
function renderGridEditor(session) {
if (!session) {
return `<p>${t("events.grid_empty")}</p>`;
}
const driverIds = ensureSessionDriverOrder(session);
return `
<div class="grid-editor-toolbar">
<div>
<strong>${escapeHtml(session.name)}</strong>
<div class="hint">${t("events.grid_editor_hint")}</div>
<div class="hint">${t(session.gridCustomized ? "events.grid_locked" : "events.grid_unlocked")}</div>
</div>
<div class="actions">
<button id="gridToggleLock" class="btn" type="button">${t(session.gridCustomized ? "events.grid_unlock" : "events.grid_lock")}</button>
<button id="gridResetOrder" class="btn" type="button">${t("events.grid_reset")}</button>
</div>
</div>
<div class="drag-list mt-16" id="gridDragList">
${driverIds
.map((driverId, index) => {
const driver = state.drivers.find((item) => item.id === driverId);
return `
<div class="drag-item" draggable="true" data-index="${index}">
<span class="pos-pill">${index + 1}</span>
<div>
<strong>${escapeHtml(driver?.name || t("common.unknown_driver"))}</strong>
<div class="hint">${escapeHtml(driver?.transponder || "-")}</div>
</div>
</div>
`;
})
.join("")}
</div>
`;
}
function clearGeneratedQualifying(eventId) {
const generatedIds = state.sessions
.filter((session) => session.eventId === eventId && session.type === "qualification" && session.generated)
.map((session) => session.id);
state.sessions = state.sessions.filter((session) => !generatedIds.includes(session.id));
generatedIds.forEach((sessionId) => {
delete state.resultsBySession[sessionId];
if (state.activeSessionId === sessionId) {
state.activeSessionId = null;
}
});
}
function clearGeneratedFinals(eventId) {
const generatedIds = state.sessions.filter((session) => session.eventId === eventId && session.type === "final" && session.generated).map((session) => session.id);
state.sessions = state.sessions.filter((session) => !generatedIds.includes(session.id));
generatedIds.forEach((sessionId) => {
delete state.resultsBySession[sessionId];
if (state.activeSessionId === sessionId) {
state.activeSessionId = null;
}
});
}
function generateQualifyingForRace(event) {
const sourceRows = buildPracticeStandings(event);
const fallbackRows =
sourceRows.length > 0
? sourceRows
: getEventDrivers(event).map((driver, index) => ({
rank: index + 1,
driverId: driver.id,
driverName: driver.name,
}));
if (!fallbackRows.length) {
return 0;
}
clearGeneratedQualifying(event.id);
const qualifyingRounds = Math.max(1, Number(event.raceConfig?.qualifyingRounds || 1) || 1);
const carsPerHeat = Math.max(2, Number(event.raceConfig?.carsPerHeat || 8) || 8);
const qualDurationMin = Math.max(1, Number(event.raceConfig?.qualDurationMin || 5) || 5);
const qualStartMode = normalizeStartMode(event.raceConfig?.qualStartMode || "staggered");
const heats = chunkArray(fallbackRows, carsPerHeat);
let created = 0;
heats.forEach((heatRows, heatIndex) => {
const heatNumber = heatIndex + 1;
const driverIds = heatRows.map((row) => row.driverId).filter(Boolean);
for (let round = 1; round <= qualifyingRounds; round += 1) {
state.sessions.push(
normalizeSession({
id: uid("session"),
eventId: event.id,
name: `Q${round} H${heatNumber}`,
type: "qualification",
durationMin: qualDurationMin,
maxCars: carsPerHeat,
mode: "race",
status: "ready",
startedAt: null,
endedAt: null,
finishedByTimer: false,
startMode: qualStartMode,
seedBestLapCount: 2,
staggerGapSec: 3,
driverIds,
manualGridIds: [...driverIds],
gridCustomized: false,
generated: true,
assignments: [],
})
);
created += 1;
}
});
return created;
}
function reseedUpcomingQualifying(event) {
const standings = buildQualifyingStandings(event);
const sourceRows =
standings.length > 0
? standings
: buildPracticeStandings(event).length > 0
? buildPracticeStandings(event)
: getEventDrivers(event).map((driver, index) => ({
rank: index + 1,
driverId: driver.id,
driverName: driver.name,
}));
if (!sourceRows.length) {
return 0;
}
const carsPerHeat = Math.max(2, Number(event.raceConfig?.carsPerHeat || 8) || 8);
const heats = chunkArray(sourceRows, carsPerHeat);
const sessionsByRound = new Map();
getSessionsForEvent(event.id)
.filter((session) => session.type === "qualification" && session.generated && session.status === "ready")
.forEach((session) => {
const match = String(session.name || "").match(/^Q(\d+)\s+H(\d+)/i);
const round = match ? Number(match[1]) : 0;
if (!sessionsByRound.has(round)) {
sessionsByRound.set(round, []);
}
sessionsByRound.get(round).push(session);
});
let updated = 0;
let locked = 0;
[...sessionsByRound.entries()].forEach(([, roundSessions]) => {
roundSessions.sort((a, b) => String(a.name).localeCompare(String(b.name)));
roundSessions.forEach((session, heatIndex) => {
if (session.gridCustomized) {
locked += 1;
return;
}
const heatRows = heats[heatIndex] || [];
session.driverIds = heatRows.map((row) => row.driverId).filter(Boolean);
session.manualGridIds = [...session.driverIds];
updated += 1;
});
});
return { updated, locked };
}
function generateFinalsForRace(event) {
const sourceRows =
event.raceConfig?.finalsSource === "practice"
? buildPracticeStandings(event)
: buildQualifyingStandings(event);
if (!sourceRows.length) {
return 0;
}
clearGeneratedFinals(event.id);
const finalLegs = Math.max(1, Number(event.raceConfig?.finalLegs || 1) || 1);
const carsPerFinal = Math.max(2, Number(event.raceConfig?.carsPerFinal || 8) || 8);
const finalDurationMin = Math.max(1, Number(event.raceConfig?.finalDurationMin || 5) || 5);
const finalStartMode = normalizeStartMode(event.raceConfig?.finalStartMode || "position");
const bumpCount = Math.max(0, Number(event.raceConfig?.bumpCount || 0) || 0);
const reserveBumpSlots = Boolean(event.raceConfig?.reserveBumpSlots && bumpCount > 0);
const seededSlotsPerMain = reserveBumpSlots ? Math.max(1, carsPerFinal - bumpCount) : carsPerFinal;
const mains = chunkArray(sourceRows, seededSlotsPerMain);
let created = 0;
mains.forEach((mainRows, mainIndex) => {
const mainLetter = String.fromCharCode(65 + mainIndex);
const driverIds = mainRows.map((row) => row.driverId).filter(Boolean);
for (let leg = 1; leg <= finalLegs; leg += 1) {
state.sessions.push(
normalizeSession({
id: uid("session"),
eventId: event.id,
name: `${mainLetter} ${t("session.final")} ${leg}`,
type: "final",
durationMin: finalDurationMin,
maxCars: carsPerFinal,
mode: "race",
status: "ready",
startedAt: null,
endedAt: null,
finishedByTimer: false,
startMode: finalStartMode,
seedBestLapCount: 0,
staggerGapSec: 0,
driverIds,
manualGridIds: [...driverIds],
gridCustomized: false,
reservedBumpSlots: reserveBumpSlots && mainIndex < mains.length - 1 ? bumpCount : 0,
generated: true,
assignments: [],
})
);
created += 1;
}
});
return created;
}
function buildFinalStandings(event) {
const sessions = getSessionsForEvent(event.id).filter((session) => session.type === "final");
const groupedByMain = new Map();
const countedFinalLegs = Math.max(1, Number(event.raceConfig?.countedFinalLegs || 1) || 1);
sessions.forEach((session) => {
const rows = buildLeaderboard(session);
const mainMatch = String(session.name || "").match(/^([A-Z])/i);
const mainKey = mainMatch ? mainMatch[1].toUpperCase() : "A";
if (!groupedByMain.has(mainKey)) {
groupedByMain.set(mainKey, new Map());
}
const competitorMap = groupedByMain.get(mainKey);
rows.forEach((row, index) => {
const key = row.driverId || row.key;
if (!competitorMap.has(key)) {
competitorMap.set(key, {
key,
mainKey,
driverId: row.driverId,
driverName: row.driverName,
legRanks: [],
bestElapsedMs: [],
});
}
const entry = competitorMap.get(key);
entry.legRanks.push(index + 1);
entry.bestElapsedMs.push(row.totalElapsedMs);
});
});
const mains = [...groupedByMain.entries()].sort((a, b) => a[0].localeCompare(b[0]));
const rows = [];
mains.forEach(([mainKey, competitorMap], mainIndex) => {
const mainRows = [...competitorMap.values()].map((entry) => {
const sortedRanks = [...entry.legRanks].sort((a, b) => a - b);
const countedRanks = sortedRanks.slice(0, countedFinalLegs);
return {
mainKey,
driverId: entry.driverId,
driverName: entry.driverName,
finalScore: countedRanks.reduce((sum, value) => sum + value, 0),
legRanks: sortedRanks,
bestElapsedMs: Math.min(...entry.bestElapsedMs),
};
});
mainRows.sort((a, b) => {
if (a.finalScore !== b.finalScore) {
return a.finalScore - b.finalScore;
}
for (let i = 0; i < Math.max(a.legRanks.length, b.legRanks.length); i += 1) {
const left = a.legRanks[i] ?? 999;
const right = b.legRanks[i] ?? 999;
if (left !== right) {
return left - right;
}
}
return a.bestElapsedMs - b.bestElapsedMs;
});
mainRows.forEach((row, rowIndex) => {
rows.push({
rank: `${mainKey}${rowIndex + 1}`,
driverId: row.driverId,
driverName: row.driverName,
score: `${row.finalScore} (${row.legRanks.join("+")})`,
orderingGroup: mainIndex,
orderingIndex: rowIndex,
});
});
});
return rows.sort((a, b) => {
if (a.orderingGroup !== b.orderingGroup) {
return a.orderingGroup - b.orderingGroup;
}
return a.orderingIndex - b.orderingIndex;
});
}
function getFinalMainLayouts(event) {
const finals = getSessionsForEvent(event.id)
.filter((session) => session.type === "final")
.sort((left, right) => {
const leftMain = String(left.name || "").match(/^([A-Z])/i)?.[1]?.toUpperCase() || "Z";
const rightMain = String(right.name || "").match(/^([A-Z])/i)?.[1]?.toUpperCase() || "Z";
if (leftMain !== rightMain) {
return leftMain.localeCompare(rightMain);
}
return String(left.name || "").localeCompare(String(right.name || ""));
});
const grouped = new Map();
finals.forEach((session) => {
const mainKey = String(session.name || "").match(/^([A-Z])/i)?.[1]?.toUpperCase() || "A";
if (!grouped.has(mainKey)) {
grouped.set(mainKey, []);
}
grouped.get(mainKey).push(session);
});
return [...grouped.entries()].map(([mainKey, sessions]) => {
const sortedSessions = [...sessions].sort((left, right) => String(left.name || "").localeCompare(String(right.name || "")));
const baseSession = sortedSessions[0];
const baseDriverIds = [...getSessionGridOrder(baseSession)];
const reservedSlots = Math.max(0, Number(baseSession?.reservedBumpSlots || 0) || 0);
const capacity = Math.max(
Number(baseSession?.maxCars || event.raceConfig?.carsPerFinal || 0) || 0,
baseDriverIds.length + reservedSlots
);
const slots = [];
for (let index = 0; index < capacity; index += 1) {
const driverId = baseDriverIds[index];
if (driverId) {
slots.push({
slot: index + 1,
label: getDriverDisplayById(driverId),
reserved: false,
});
} else if (index < baseDriverIds.length + reservedSlots) {
slots.push({
slot: index + 1,
label: t("events.reserved_slot"),
reserved: true,
});
} else {
slots.push({
slot: index + 1,
label: "-",
reserved: false,
});
}
}
return {
mainKey,
sessions: sortedSessions,
slots,
};
});
}
function renderFinalMatrix(event) {
const mains = getFinalMainLayouts(event);
if (!mains.length) {
return `<p>${t("events.no_final_matrix")}</p>`;
}
return `
<div class="final-matrix">
${mains
.map(
(main) => `
<section class="final-card">
<div class="final-card-header">
<h4>${t("events.main")} ${escapeHtml(main.mainKey)}</h4>
<span class="pill">${main.sessions.length} ${escapeHtml(t("events.leg_status").toLowerCase())}</span>
</div>
<div class="matrix-slots">
${main.slots
.map(
(slot) => `
<div class="matrix-slot ${slot.reserved ? "matrix-slot-reserved" : ""}">
<span class="pill">${t("events.slot")} ${slot.slot}</span>
<strong>${escapeHtml(slot.label)}</strong>
</div>
`
)
.join("")}
</div>
<div class="matrix-session-list">
${main.sessions
.map(
(session) => `
<div class="matrix-session-row">
<span>${escapeHtml(session.name)}</span>
<span class="pill ${session.status === "finished" ? "pill-green" : ""}">${escapeHtml(getStatusLabel(session.status))}</span>
</div>
`
)
.join("")}
</div>
</section>
`
)
.join("")}
</div>
`;
}
function buildPrintBrandBlock(branding) {
return `
<div class="print-brand-block">
${branding.logoDataUrl ? `<img class="print-logo" src="${escapeHtml(branding.logoDataUrl)}" alt="logo" />` : ""}
<div>
<strong>${escapeHtml(branding.brandName)}</strong>
<p>${escapeHtml(branding.brandTagline)}</p>
</div>
</div>
`;
}
function buildRaceStartListsHtml(event) {
const branding = resolveEventBranding(event);
const sessions = getSessionsForEvent(event.id)
.filter((session) => session.mode === "race")
.sort((left, right) => {
const weightDiff = getSessionSortWeight(left) - getSessionSortWeight(right);
if (weightDiff !== 0) {
return weightDiff;
}
return String(left.name || "").localeCompare(String(right.name || ""));
});
return `
<header class="print-header">
<div>
<p class="print-kicker">${escapeHtml(getClassName(event.classId))}</p>
<h1>${escapeHtml(event.name)}</h1>
<p>${escapeHtml(event.date || "-")}</p>
</div>
<div class="print-meta">
${buildPrintBrandBlock(branding)}
</div>
</header>
<h2>${t("events.start_lists")}</h2>
${sessions
.map((session) => {
const entries = getSessionGridEntries(session);
return `
<section class="print-block">
<h3>${escapeHtml(session.name)}${escapeHtml(getSessionTypeLabel(session.type))}</h3>
<p>${t("table.start_mode")}: ${escapeHtml(getStartModeLabel(session.startMode))}${t("table.duration")}: ${session.durationMin} min</p>
${
entries.length
? renderTable(
[t("events.slot"), t("table.driver"), t("table.transponder")],
entries.map(
(entry) => `
<tr>
<td>${entry.slot}</td>
<td>${escapeHtml(entry.name)}</td>
<td>${escapeHtml(entry.meta || "-")}</td>
</tr>
`
)
)
: `<p>${t("common.no_entries")}</p>`
}
</section>
`;
})
.join("")}
`;
}
function buildRaceResultsHtml(event) {
const branding = resolveEventBranding(event);
return `
<header class="print-header">
<div>
<p class="print-kicker">${escapeHtml(getClassName(event.classId))}</p>
<h1>${escapeHtml(event.name)}</h1>
<p>${escapeHtml(event.date || "-")}</p>
</div>
<div class="print-meta">
${buildPrintBrandBlock(branding)}
</div>
</header>
<section class="print-block">
<h2>${t("events.practice_standings")}</h2>
${renderRaceStandingsTable(buildPracticeStandings(event), t("events.no_practice_results"))}
</section>
<section class="print-block">
<h2>${t("events.qualifying_standings")}</h2>
${renderRaceStandingsTable(buildQualifyingStandings(event), t("events.no_qualifying_results"))}
</section>
<section class="print-block">
<h2>${t("events.final_standings")}</h2>
${renderRaceStandingsTable(buildFinalStandings(event), t("events.no_final_results"))}
</section>
<section class="print-block">
<h2>${t("events.team_standings")}</h2>
${renderTeamRaceStandings(event)}
</section>
`;
}
function buildTeamRaceResultsHtml(event) {
const branding = resolveEventBranding(event);
const groups = buildTeamRaceStandings(event);
return `
<header class="print-header">
<div>
<p class="print-kicker">${escapeHtml(getClassName(event.classId))}</p>
<h1>${escapeHtml(event.name)}</h1>
<p>${escapeHtml(event.date || "-")}</p>
</div>
<div class="print-meta">
${buildPrintBrandBlock(branding)}
</div>
</header>
${groups
.map(
({ session, rows }) => `
<section class="print-block">
<h2>${t("events.team_report")} ${escapeHtml(session.name)}</h2>
${
rows.length
? renderTable(
[t("table.pos"), t("events.team_name"), t("table.laps"), t("table.result"), t("table.best_lap")],
rows.map(
(row, index) => `
<tr>
<td>${index + 1}</td>
<td>${escapeHtml(row.displayName || row.driverName)}</td>
<td>${row.laps}</td>
<td>${escapeHtml(row.resultDisplay)}</td>
<td>${formatLap(row.bestLapMs)}</td>
</tr>
`
)
)
: `<p>${t("events.no_team_results")}</p>`
}
<h3>${t("events.team_stint_log")}</h3>
${renderTeamStintLog(session, rows)}
</section>
`
)
.join("")}
`;
}
function openPrintWindow(title, bodyHtml) {
const printWindow = window.open("", "_blank", "noopener,noreferrer,width=1200,height=900");
if (!printWindow) {
alert(t("error.print_blocked"));
return;
}
printWindow.document.write(`
<!doctype html>
<html lang="${escapeHtml(state.settings.language || DEFAULT_LANGUAGE)}">
<head>
<meta charset="utf-8" />
<title>${escapeHtml(title)}</title>
<style>
@page { size: A4 portrait; margin: 14mm; }
body { font-family: Arial, sans-serif; padding: 0; color: #10131a; }
h1, h2, h3 { margin: 0 0 12px; }
h1 { font-size: 28px; }
h2 { font-size: 18px; border-bottom: 2px solid #10131a; padding-bottom: 4px; }
p { margin: 0 0 12px; color: #44506b; }
.print-header { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; padding-bottom: 16px; border-bottom: 4px solid #10131a; }
.print-kicker { text-transform: uppercase; letter-spacing: 0.08em; color: #5b677f; font-size: 12px; }
.print-meta { text-align: right; font-size: 13px; }
.print-brand-block { display: flex; align-items: center; justify-content: flex-end; gap: 12px; }
.print-brand-block p { margin: 0; }
.print-logo { max-width: 92px; max-height: 52px; object-fit: contain; }
.print-block { margin-top: 24px; break-inside: avoid; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
th, td { border: 1px solid #b9c2d4; padding: 8px; text-align: left; }
th { background: #eef2f8; font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
tbody tr:nth-child(even) { background: #f8faff; }
</style>
</head>
<body>
${bodyHtml}
</body>
</html>
`);
printWindow.document.close();
printWindow.focus();
setTimeout(() => {
printWindow.print();
}, 150);
}
function buildPdfSection(title, headers, rows) {
return { title, headers, rows };
}
async function requestPdfExport(payload) {
try {
const response = await fetch(`${getBackendUrl()}/api/export/pdf`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorPayload = await response.json().catch(() => ({}));
throw new Error(errorPayload.error || `HTTP ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = payload.filename || "export.pdf";
link.click();
URL.revokeObjectURL(url);
} catch (error) {
alert(t("error.pdf_export_failed", { msg: error instanceof Error ? error.message : String(error) }));
}
}
function loadImageElement(src) {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Image load failed"));
image.src = src;
});
}
async function ensurePdfLogoDataUrl(dataUrl) {
if (!dataUrl) {
return "";
}
if (/^data:image\/jpeg;base64,/i.test(dataUrl)) {
return dataUrl;
}
try {
const image = await loadImageElement(dataUrl);
const canvas = document.createElement("canvas");
const width = Math.max(1, image.naturalWidth || image.width || 1);
const height = Math.max(1, image.naturalHeight || image.height || 1);
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) {
return "";
}
context.fillStyle = "#ffffff";
context.fillRect(0, 0, width, height);
context.drawImage(image, 0, 0, width, height);
return canvas.toDataURL("image/jpeg", 0.92);
} catch {
return "";
}
}
async function exportRaceStartListsPdf(event) {
const branding = resolveEventBranding(event);
const sessions = getSessionsForEvent(event.id)
.filter((session) => session.mode === "race")
.sort((left, right) => {
const weightDiff = getSessionSortWeight(left) - getSessionSortWeight(right);
if (weightDiff !== 0) {
return weightDiff;
}
return String(left.name || "").localeCompare(String(right.name || ""));
});
const sections = sessions.map((session) =>
buildPdfSection(
`${session.name}${getSessionTypeLabel(session.type)}`,
[t("events.slot"), t("table.driver"), t("table.transponder")],
getSessionGridEntries(session).map((entry) => [String(entry.slot), entry.name, entry.meta || "-"])
)
);
await requestPdfExport({
filename: `${event.name.replaceAll(/\s+/g, "_")}_startlists.pdf`,
title: event.name,
subtitle: `${getClassName(event.classId)}${event.date || "-"}`,
brandName: branding.brandName,
brandTagline: branding.brandTagline,
footer: branding.pdfFooter,
theme: branding.pdfTheme,
logoDataUrl: await ensurePdfLogoDataUrl(branding.logoDataUrl),
sections,
});
}
async function exportRaceResultsPdf(event) {
const branding = resolveEventBranding(event);
await requestPdfExport({
filename: `${event.name.replaceAll(/\s+/g, "_")}_results.pdf`,
title: event.name,
subtitle: `${getClassName(event.classId)}${event.date || "-"}`,
brandName: branding.brandName,
brandTagline: branding.brandTagline,
footer: branding.pdfFooter,
theme: branding.pdfTheme,
logoDataUrl: await ensurePdfLogoDataUrl(branding.logoDataUrl),
sections: [
buildPdfSection(
t("events.practice_standings"),
[t("table.pos"), t("table.driver"), t("table.score")],
buildPracticeStandings(event).map((row) => [String(row.rank), row.driverName || "-", row.score || "-"])
),
buildPdfSection(
t("events.qualifying_standings"),
[t("table.pos"), t("table.driver"), t("table.score")],
buildQualifyingStandings(event).map((row) => [String(row.rank), row.driverName || "-", row.score || "-"])
),
buildPdfSection(
t("events.final_standings"),
[t("table.pos"), t("table.driver"), t("table.score")],
buildFinalStandings(event).map((row) => [String(row.rank), row.driverName || "-", row.score || "-"])
),
...buildTeamRaceStandings(event).map(({ session, rows }) =>
buildPdfSection(
`${t("events.team_standings")}${session.name}`,
[t("table.pos"), t("events.team_name"), t("table.laps"), t("table.result"), t("table.best_lap")],
rows.map((row, index) => [
String(index + 1),
row.displayName || row.driverName || "-",
String(row.laps || 0),
row.resultDisplay || "-",
formatLap(row.bestLapMs),
])
)
),
],
});
}
async function exportTeamRaceResultsPdf(event) {
const branding = resolveEventBranding(event);
const sections = [];
buildTeamRaceStandings(event).forEach(({ session, rows }) => {
sections.push(
buildPdfSection(
`${t("events.team_report")}${session.name}`,
[t("table.pos"), t("events.team_name"), t("table.laps"), t("table.result"), t("table.best_lap")],
rows.map((row, index) => [
String(index + 1),
row.displayName || row.driverName || "-",
String(row.laps || 0),
row.resultDisplay || "-",
formatLap(row.bestLapMs),
])
)
);
rows.forEach((row) => {
const stints = buildTeamStintLog(session, row);
sections.push(
buildPdfSection(
`${session.name}${row.displayName || row.driverName}${t("events.team_stint_log")}`,
[t("events.slot"), t("table.driver"), t("table.car"), t("table.time"), t("table.duration"), t("table.laps")],
stints.map((stint) => [
String(stint.index),
stint.driverName || "-",
stint.carName || "-",
new Date(stint.startTs).toLocaleTimeString(),
formatRaceClock(stint.durationMs),
String(stint.laps || 0),
])
)
);
});
});
await requestPdfExport({
filename: `${event.name.replaceAll(/\s+/g, "_")}_team_report.pdf`,
title: event.name,
subtitle: `${t("events.team_report")}${getClassName(event.classId)}${event.date || "-"}`,
brandName: branding.brandName,
brandTagline: branding.brandTagline,
footer: branding.pdfFooter,
theme: branding.pdfTheme,
logoDataUrl: await ensurePdfLogoDataUrl(branding.logoDataUrl),
sections,
});
}
async function exportSessionHeatSheetPdf(session) {
const event = state.events.find((item) => item.id === session.eventId);
const branding = resolveEventBranding(event);
await requestPdfExport({
filename: `${(event?.name || "event").replaceAll(/\s+/g, "_")}_${session.name.replaceAll(/\s+/g, "_")}.pdf`,
title: event?.name || t("common.unknown_event"),
subtitle: `${getSessionTypeLabel(session.type)}${session.name}${getClassName(event?.classId || "")}`,
brandName: branding.brandName,
brandTagline: branding.brandTagline,
footer: branding.pdfFooter,
theme: branding.pdfTheme,
logoDataUrl: await ensurePdfLogoDataUrl(branding.logoDataUrl),
sections: [
buildPdfSection(
`${session.name}${getSessionTypeLabel(session.type)}`,
[t("events.slot"), t("table.driver"), t("table.transponder")],
getSessionGridEntries(session).map((entry) => [String(entry.slot), entry.name, entry.meta || "-"])
),
],
});
}
function reorderList(items, fromIndex, toIndex) {
const copy = [...items];
const [moved] = copy.splice(fromIndex, 1);
copy.splice(toIndex, 0, moved);
return copy;
}
function buildSessionHeatSheetHtml(session) {
const event = state.events.find((item) => item.id === session.eventId);
const branding = resolveEventBranding(event);
const entries = getSessionGridEntries(session);
return `
<header class="print-header">
<div>
<p class="print-kicker">${escapeHtml(getClassName(event?.classId || ""))}</p>
<h1>${escapeHtml(event?.name || t("common.unknown_event"))}</h1>
<p>${escapeHtml(session.name)}${escapeHtml(getSessionTypeLabel(session.type))}</p>
</div>
<div class="print-meta">
${buildPrintBrandBlock(branding)}
</div>
</header>
<p>${t("table.start_mode")}: ${escapeHtml(getStartModeLabel(session.startMode))}${t("table.duration")}: ${session.durationMin} min</p>
${
entries.length
? renderTable(
[t("events.slot"), t("table.driver"), t("table.transponder")],
entries.map(
(entry) => `
<tr>
<td>${entry.slot}</td>
<td>${escapeHtml(entry.name)}</td>
<td>${escapeHtml(entry.meta || "-")}</td>
</tr>
`
)
)
: `<p>${t("common.no_entries")}</p>`
}
`;
}
function exportSessionHeatSheet(session) {
const event = state.events.find((item) => item.id === session.eventId);
const entries = getSessionGridEntries(session);
const rows = [
["event", "class", "session", "type", "start_mode", "duration_min", "slot", "driver", "transponder"],
...entries.map((entry) => [
event?.name || "",
getClassName(event?.classId || ""),
session.name,
getSessionTypeLabel(session.type),
getStartModeLabel(session.startMode),
String(session.durationMin || ""),
String(entry.slot),
entry.name,
entry.meta || "",
]),
];
const csv = rows
.map((row) => row.map((value) => `"${String(value || "").replaceAll('"', '""')}"`).join(","))
.join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${(event?.name || "event").replaceAll(/\s+/g, "_")}_${session.name.replaceAll(/\s+/g, "_")}.csv`;
link.click();
URL.revokeObjectURL(url);
}
function buildOverlayUrl(mode = "leaderboard") {
const url = new URL(window.location.href);
url.searchParams.set("view", "overlay");
url.searchParams.set("overlayMode", mode);
return url.toString();
}
function openOverlayWindow(mode = "leaderboard") {
const width = Math.max(1280, window.screen?.availWidth || 1600);
const height = Math.max(720, window.screen?.availHeight || 900);
const overlayWindow = window.open(
buildOverlayUrl(mode),
"_blank",
`noopener,noreferrer,popup=yes,left=0,top=0,width=${width},height=${height}`
);
if (!overlayWindow) {
alert(t("error.print_blocked"));
return;
}
overlayWindow.focus();
}
function applyBumpsForRace(event) {
const bumpCount = Math.max(0, Number(event.raceConfig?.bumpCount || 0) || 0);
if (bumpCount <= 0) {
return 0;
}
const standings = buildFinalStandings(event);
if (!standings.length) {
return 0;
}
const grouped = new Map();
standings.forEach((row) => {
const mainKey = String(row.rank || "").charAt(0).toUpperCase();
if (!grouped.has(mainKey)) {
grouped.set(mainKey, []);
}
grouped.get(mainKey).push(row);
});
const mainKeys = [...grouped.keys()].sort();
let applied = 0;
for (let index = 1; index < mainKeys.length; index += 1) {
const upperMainKey = mainKeys[index - 1];
const lowerMainKey = mainKeys[index];
const lowerRows = grouped.get(lowerMainKey) || [];
const bumpRows = lowerRows.slice(0, bumpCount);
if (!bumpRows.length) {
continue;
}
const upperSessions = getSessionsForEvent(event.id).filter((session) => {
return session.type === "final" && String(session.name || "").toUpperCase().startsWith(upperMainKey) && session.status === "ready";
});
if (!upperSessions.length) {
continue;
}
const bumpDriverIds = bumpRows.map((row) => row.driverId).filter(Boolean);
if (!bumpDriverIds.length) {
continue;
}
upperSessions.forEach((session) => {
session.driverIds = Array.isArray(session.driverIds) ? session.driverIds : [];
session.manualGridIds = Array.isArray(session.manualGridIds) ? session.manualGridIds : [...session.driverIds];
const reservedSlots = Math.max(0, Number(session.reservedBumpSlots || 0) || 0);
const capacity = Math.max(0, Number(session.maxCars || event.raceConfig?.carsPerFinal || 0) || 0);
const allowedSize = reservedSlots > 0 ? Math.min(capacity, session.driverIds.length + reservedSlots) : capacity || Infinity;
let addedHere = 0;
bumpDriverIds.forEach((driverId) => {
if (!session.driverIds.includes(driverId) && session.driverIds.length < allowedSize) {
session.driverIds.push(driverId);
if (!session.manualGridIds.includes(driverId)) {
session.manualGridIds.push(driverId);
}
applied += 1;
addedHere += 1;
}
});
session.reservedBumpSlots = Math.max(0, reservedSlots - addedHere);
});
}
return applied;
}
function chunkArray(items, size) {
const chunks = [];
for (let index = 0; index < items.length; index += size) {
chunks.push(items.slice(index, index + size));
}
return chunks;
}
function ensureRaceParticipantsConfigured(event) {
if (!event || event.mode !== "race") {
return;
}
if (event.raceConfig?.participantsConfigured) {
return;
}
const classDrivers = state.drivers.filter((driver) => !event.classId || driver.classId === event.classId);
event.raceConfig.driverIds = classDrivers.map((driver) => driver.id);
event.raceConfig.participantsConfigured = true;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function isValidIsoDate(value) {
return /^\d{4}-\d{2}-\d{2}$/.test(String(value || ""));
}
function createSponsorRounds(eventId, config) {
const qualificationRounds = Math.max(0, Math.floor(config.qualificationRounds || 0));
const heatRounds = Math.max(0, Math.floor(config.heatRounds || 0));
const finalRounds = Math.max(0, Math.floor(config.finalRounds || 0));
const durationMin = Math.max(1, Math.floor(config.durationMin || 5));
for (let i = 1; i <= qualificationRounds; i += 1) {
state.sessions.push(buildTrackSession(eventId, `${t("session.qualification")} ${i}`, "qualification", durationMin));
}
for (let i = 1; i <= heatRounds; i += 1) {
state.sessions.push(buildTrackSession(eventId, `${t("session.heat")} ${i}`, "heat", durationMin));
}
for (let i = 1; i <= finalRounds; i += 1) {
state.sessions.push(buildTrackSession(eventId, `${t("session.final")} ${i}`, "final", durationMin));
}
}
function buildTrackSession(eventId, name, type, durationMin) {
return normalizeSession({
id: uid("session"),
eventId,
name,
type,
durationMin,
maxCars: null,
mode: "track",
status: "ready",
startedAt: null,
endedAt: null,
finishedByTimer: false,
assignments: [],
});
}
function getSelectedAssignmentSessionId() {
const form = document.getElementById("assignForm");
if (!(form instanceof HTMLFormElement)) {
return "";
}
const formData = new FormData(form);
return String(formData.get("sessionId") || "");
}
function autoAssignTrackSession(event, sessionId) {
const session = state.sessions.find((x) => x.id === sessionId);
if (!session) {
return;
}
const driversForClass = state.drivers.filter((d) => d.classId === event.classId);
const drivers = shuffle([...driversForClass.length ? driversForClass : state.drivers]);
const uniqueCars = uniqueCarsByTransponder(state.cars);
session.assignments = [];
const limit = Math.min(uniqueCars.length, drivers.length);
for (let i = 0; i < limit; i += 1) {
session.assignments.push({
id: uid("as"),
driverId: drivers[i].id,
carId: uniqueCars[i].id,
});
}
}
function uniqueCarsByTransponder(cars) {
const seen = new Set();
const output = [];
cars.forEach((car) => {
const key = String(car.transponder || "").trim();
if (!key) {
return;
}
if (seen.has(key)) {
return;
}
seen.add(key);
output.push(car);
});
return output;
}
function shuffle(items) {
for (let i = items.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
const temp = items[i];
items[i] = items[j];
items[j] = temp;
}
return items;
}
function validateTrackSessionForStart(session) {
const assignments = session.assignments || [];
if (!assignments.length) {
return { ok: false, message: t("validation.no_assignments") };
}
const missingTransponder = assignments.find((assignment) => {
const car = state.cars.find((c) => c.id === assignment.carId);
return !String(car?.transponder || "").trim();
});
if (missingTransponder) {
return { ok: false, message: t("validation.missing_tp") };
}
const duplicateTransponders = findDuplicateSessionTransponders(session);
if (duplicateTransponders.length) {
return {
ok: false,
message: t("validation.duplicate_tp", { ids: duplicateTransponders.join(", ") }),
};
}
return { ok: true, message: "" };
}
function findDuplicateSessionTransponders(session) {
const counts = {};
(session.assignments || []).forEach((assignment) => {
const car = state.cars.find((c) => c.id === assignment.carId);
const tp = String(car?.transponder || "").trim();
if (!tp) {
return;
}
counts[tp] = (counts[tp] || 0) + 1;
});
return Object.keys(counts).filter((tp) => counts[tp] > 1);
}
async function persistPassingToBackend(sessionId, passing) {
try {
const res = await fetch(`${getBackendUrl()}/api/passings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, passing, sessionResult: state.resultsBySession[sessionId] || null }),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
backend.available = true;
backend.lastError = "";
} catch (error) {
backend.available = false;
backend.lastError = t("error.passing_save_failed", { msg: error instanceof Error ? error.message : String(error) });
}
}