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 = ["free_practice", "practice", "qualification", "heat", "final"]; 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", "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.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.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.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.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.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.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.open_overlay": "Öppna overlay", "timing.open_speaker_overlay": "Speaker overlay", "timing.open_results_overlay": "Result 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 använder fortfarande textheader i denna version.", "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.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", "session.free_practice": "fri träning", "session.practice": "träning", "session.qualification": "kval", "session.heat": "heat", "session.final": "final", "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.", "edit.class_name": "Redigera klassnamn", "edit.driver_name": "Redigera förarnamn", "edit.driver_transponder": "Redigera personlig transponder (kan vara tom)", "edit.car_name": "Redigera bilnamn", "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.", "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.event_markers": "Eventmarkörer", "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://:8081, `WebSocket URL` = ws://: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://: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", "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.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.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.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.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.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.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.open_overlay": "Open overlay", "timing.open_speaker_overlay": "Speaker overlay", "timing.open_results_overlay": "Results 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 still uses a text header in this version.", "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.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", "session.free_practice": "free practice", "session.practice": "practice", "session.qualification": "qualification", "session.heat": "heat", "session.final": "final", "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.", "edit.class_name": "Edit class name", "edit.driver_name": "Edit driver name", "edit.driver_transponder": "Edit personal transponder (can be empty)", "edit.car_name": "Edit 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.", "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.event_markers": "Event markers", "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://:8081, `WebSocket URL` = ws://: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://: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"].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 selectedLeaderboardKey = null; let selectedGridSessionId = null; let overlaySyncTimer = null; let overlayEvents = []; let lastOverlayLeaderKeyBySession = {}; let lastOverlayTop3BySession = {}; let lastOverlayBestLapByKey = {}; 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(); 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 normalizeEvent(event) { return { ...event, 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"), 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", }, }; } 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(); } }, 2000); } 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() { 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 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?.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 }; } 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 ); dom.view.innerHTML = `
${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"))}

${t("dashboard.live_session")}

${active ? getStatusLabel(active.status) : t("dashboard.idle")}
${ active ? `

${active.name} (${getSessionTypeLabel(active.type)})

${getEventName(active.eventId)} • ${getModeLabel(active.mode)}

${t("dashboard.duration")}: ${active.durationMin} min

` : `

${t("dashboard.no_session")}

` }

${t("dashboard.quick_actions")}

${t("dashboard.recent_sessions")}

${renderSessionsTable(state.sessions.slice(-8).reverse())}
`; document.getElementById("goEvents")?.addEventListener("click", () => { currentView = "events"; renderNav(); renderView(); }); document.getElementById("goTiming")?.addEventListener("click", () => { currentView = "timing"; renderNav(); renderView(); }); document.getElementById("connectNow")?.addEventListener("click", connectDecoder); } function statCard(label, value, note) { return `

${label}

${value}

${note}
`; } function renderClasses() { dom.view.innerHTML = `

${t("classes.create")}

${t("classes.title")}

${renderTable( [t("table.name"), t("events.actions")], state.classes.map( (c) => ` ${escapeHtml(c.name)} ` ) )}
`; 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", () => { const nextName = prompt(t("edit.class_name"), item.name); if (nextName === null) { return; } const cleaned = nextName.trim(); if (!cleaned) { return; } item.name = cleaned; saveState(); renderView(); }); document.getElementById(`class-delete-${item.id}`)?.addEventListener("click", () => { state.classes = state.classes.filter((x) => x.id !== item.id); saveState(); renderView(); }); }); } function renderDrivers() { const classOptions = state.classes .map((c) => ``) .join(""); dom.view.innerHTML = `

${t("drivers.create")}

${t("drivers.title")}

${renderTable( [t("table.name"), t("table.class"), t("table.transponder"), t("events.actions")], state.drivers.map( (d) => ` ${escapeHtml(d.name)} ${escapeHtml(getClassName(d.classId))} ${escapeHtml(d.transponder || "-")} ` ) )}
`; 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", () => { const nextName = prompt(t("edit.driver_name"), d.name); if (nextName === null) { return; } const nextTp = prompt(t("edit.driver_transponder"), d.transponder || ""); if (nextTp === null) { return; } const cleanedName = nextName.trim(); if (!cleanedName) { return; } d.name = cleanedName; d.transponder = nextTp.trim(); saveState(); 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(); }); }); } function renderCars() { dom.view.innerHTML = `

${t("cars.create")}

${t("cars.title")}

${renderTable( [t("table.car"), t("table.transponder"), t("events.actions")], state.cars.map( (c) => ` ${escapeHtml(c.name)} ${escapeHtml(c.transponder)} ` ) )}
`; 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", () => { const nextName = prompt(t("edit.car_name"), c.name); if (nextName === null) { return; } const nextTp = prompt(t("edit.car_transponder"), c.transponder || ""); if (nextTp === null) { return; } const cleanedName = nextName.trim(); const cleanedTp = nextTp.trim(); if (!cleanedName || !cleanedTp) { return; } c.name = cleanedName; c.transponder = cleanedTp; saveState(); 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(); }); }); } 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) => ``) .join(""); dom.view.innerHTML = `

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

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

${t(isRaceMode ? "events.race_title" : "events.title")}

${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 ` ${escapeHtml(e.name)} ${escapeHtml(e.date)} ${escapeHtml(getClassName(e.classId))} ${getModeLabel(e.mode)} ${sessions.length} `; }) )}
`; 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", () => { const nextName = prompt(t("edit.event_name"), e.name); if (nextName === null) { return; } const nextDate = prompt(t("edit.event_date"), e.date); if (nextDate === null) { return; } const cleanedName = nextName.trim(); const cleanedDate = nextDate.trim(); if (!cleanedName) { return; } if (!isValidIsoDate(cleanedDate)) { alert(t("validation.invalid_date")); return; } e.name = cleanedName; e.date = cleanedDate; saveState(); 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); }); }); } 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) => ``) .join(""); const raceDrivers = event.mode === "race" ? state.drivers.filter((driver) => !event.classId || driver.classId === event.classId) : []; const carOptions = state.cars .map((c) => ``) .join(""); 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 = `

${t("events.manage_title")}: ${escapeHtml(event.name)}

${t("events.seed_best_laps_hint")}

${t("events.free_practice_note")}

${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 : getSessionEntrants(s).length; return ` ${escapeHtml(s.name)} ${escapeHtml(getSessionTypeLabel(s.type))} ${s.durationMin} min ${escapeHtml(getStartModeLabel(s.startMode))} ${s.seedBestLapCount > 0 ? `${s.seedBestLapCount}` : "-"} ${escapeHtml(getStatusLabel(s.status))} ${assignCount || (event.mode === "track" ? t("events.na") : "-")} ${ event.mode === "race" && normalizeStartMode(s.startMode) === "position" ? `` : "" } ${ event.mode === "race" && ["qualification", "final"].includes(s.type) ? ` ` : "" } `; }) )}
${ event.mode === "track" ? `

${t("events.sponsor_tools")}

${t("events.tp_rule")}

${t("events.assign_title")}

` : "" } ${ event.mode === "race" ? `

${t("events.select_participants")}

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

${t("events.race_format")}

${t("events.race_format_intro")}

${renderRaceFormatField( "events.qualifying_scoring", "events.qualifying_scoring_hint", `` )} ${renderRaceFormatField( "events.qualifying_rounds", "events.qualifying_rounds_hint", `` )} ${renderRaceFormatField( "events.cars_per_heat", "events.cars_per_heat_hint", `` )} ${renderRaceFormatField( "events.qual_duration", "events.qual_duration_hint", `` )} ${renderRaceFormatField( "events.qual_start_mode", "events.qual_start_mode_hint", `` )} ${renderRaceFormatField( "events.counted_qual_rounds", "events.counted_qual_rounds_hint", `` )} ${renderRaceFormatField( "events.cars_per_final", "events.cars_per_final_hint", `` )} ${renderRaceFormatField( "events.final_legs", "events.final_legs_hint", `` )} ${renderRaceFormatField( "events.counted_final_legs", "events.counted_final_legs_hint", `` )} ${renderRaceFormatField( "events.final_duration", "events.final_duration_hint", `` )} ${renderRaceFormatField( "events.final_start_mode", "events.final_start_mode_hint", `` )} ${renderRaceFormatField( "events.bump_count", "events.bump_count_hint", `` )} ${renderRaceFormatField( "events.reserve_bump_slots", "events.reserve_bump_slots_hint", ``, { checkbox: true } )} ${renderRaceFormatField( "events.source_for_finals", "events.finals_source_hint", `` )}

${t("events.race_driver_scope")}

${t("events.bump_reserved_note")}

${t("events.grid_editor")}

${renderGridEditor(selectedGridSession)}

${t("events.practice_standings")}

${renderRaceStandingsTable(buildPracticeStandings(event), t("events.no_practice_results"))}

${t("events.qualifying_standings")}

${renderRaceStandingsTable(buildQualifyingStandings(event), t("events.no_qualifying_results"))}

${t("events.final_standings")}

${renderRaceStandingsTable(buildFinalStandings(event), t("events.no_final_results"))}

${t("events.final_matrix")}

${renderFinalMatrix(event)}
` : "" } `; 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", () => { const nextName = prompt(t("events.session_name"), s.name); if (nextName === null) { return; } const nextDuration = prompt(t("events.duration_placeholder"), String(s.durationMin || 5)); if (nextDuration === null) { return; } const nextStartMode = prompt( `${t("events.start_mode")} (mass|position|staggered)`, String(s.startMode || "mass") ); if (nextStartMode === null) { return; } const nextSeedBestLaps = prompt(t("events.seed_best_laps"), String(s.seedBestLapCount || 0)); if (nextSeedBestLaps === null) { return; } const nextStaggerGapSec = prompt(t("events.stagger_gap_sec"), String(s.staggerGapSec || 0)); if (nextStaggerGapSec === null) { return; } s.name = nextName.trim() || s.name; s.durationMin = Math.max(1, Number(nextDuration) || s.durationMin || 5); s.startMode = normalizeStartMode(nextStartMode); s.seedBestLapCount = Math.max(0, Number(nextSeedBestLaps) || 0); s.staggerGapSec = Math.max(0, Number(nextStaggerGapSec) || 0); saveState(); 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); }); }); 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("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")), 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", }; 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("pdfStartlists")?.addEventListener("click", async () => { await exportRaceStartListsPdf(event); }); document.getElementById("pdfResults")?.addEventListener("click", async () => { await exportRaceResultsPdf(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 `
  • ${escapeHtml(driver?.name || t("common.unknown_driver"))} -> ${escapeHtml(car?.name || t("common.unknown_car"))} (${escapeHtml( car?.transponder || "-" )})
  • `; }) .join(""); return `

    ${escapeHtml(s.name)} (${escapeHtml(getSessionTypeLabel(s.type))})

      ${items || `
    • ${t("events.no_assignments")}
    • `}
    `; }) .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 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 = `

    ${t("timing.decoder_connection")}

    ${t("timing.status")}: ${state.decoder.connected ? t("timing.connected") : t("timing.disconnected")}

    ${t("timing.last_message")}: ${state.decoder.lastMessageAt ? new Date(state.decoder.lastMessageAt).toLocaleString() : "-"}

    ${escapeHtml(state.decoder.lastError || "")}

    ${t("timing.control")}

    ${ active ? `

    ${escapeHtml(active.name)} (${escapeHtml(getSessionTypeLabel(active.type))}) • ${escapeHtml( getEventName(active.eventId) )}

    ${t("timing.status")}: ${escapeHtml(getStatusLabel(active.status))} • ${t("timing.started")}: ${active.startedAt ? new Date(active.startedAt).toLocaleTimeString() : "-"}

    ${t("table.start_mode")}: ${escapeHtml(getStartModeLabel(active.startMode))} • ${t("timing.seeding_mode")}: ${ active.seedBestLapCount > 0 ? `${active.seedBestLapCount}` : "-" }

    ${t("timing.remaining")}: ${formatCountdown(sessionTiming?.remainingMs ?? 0)}

    ${t("timing.total_passings")}: ${result.passings.length}

    ${active.type === "free_practice" ? `

    ${t("events.free_practice_note")}

    ` : ""}` : `

    ${t("timing.no_active")}

    ` } ${showFinishedBanner ? `

    ${t("timing.race_finished")}

    ` : ""} ${active && normalizeStartMode(active.startMode) === "position" ? renderPositionGrid(active) : ""}

    ${t("timing.leaderboard")}

    ${renderLeaderboard(leaderboard)}

    ${t("timing.recent_passings")}

    ${renderRecentPassings(active)}
    ${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(); }); }); 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")); } function renderGuide() { dom.view.innerHTML = `

    ${t("guide.title")}

    ${t("guide.intro")}

    ${t("guide.sponsor_title")}

    • ${t("guide.sponsor_1")}
    • ${t("guide.sponsor_2")}
    • ${t("guide.sponsor_3")}
    • ${t("guide.sponsor_4")}
    • ${t("guide.sponsor_5")}
    • ${t("guide.sponsor_6")}

    ${t("guide.race_title")}

    • ${t("guide.race_1")}
    • ${t("guide.race_2")}
    • ${t("guide.race_3")}
    • ${t("guide.race_4")}
    • ${t("guide.race_5")}
    • ${t("guide.race_6")}
    • ${t("guide.race_7")}
    • ${t("guide.race_8")}
    • ${t("guide.race_9")}
    • ${t("guide.race_10")}

    ${t("guide.race_format_title")}

    • ${t("guide.race_format_1")}
    • ${t("guide.race_format_2")}
    • ${t("guide.race_format_3")}
    • ${t("guide.race_format_4")}
    • ${t("guide.race_format_5")}
    • ${t("guide.race_format_6")}
    • ${t("guide.race_format_7")}

    ${t("guide.free_practice_title")}

    • ${t("guide.free_practice_1")}
    • ${t("guide.free_practice_2")}
    • ${t("guide.free_practice_3")}

    ${t("guide.host_title")}

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

    ${t("guide.windows_title")}

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

    ${t("guide.linux_title")}

    • ${t("guide.linux_1")}
    • ${t("guide.linux_2")}
    • ${t("guide.linux_3")}

    ${t("guide.sqlite_title")}

    • ${t("guide.sqlite_1")}
    • ${t("guide.sqlite_2")}

    ${t("guide.ammc_ref")}

    `; } 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 recent = active && result ? result.passings.slice(-8).reverse() : []; const event = active ? state.events.find((item) => item.id === active.eventId) : null; const practiceRows = event ? buildPracticeStandings(event) : []; const qualifyingRows = event ? buildQualifyingStandings(event) : []; const finalRows = event ? buildFinalStandings(event) : []; const topRow = leaderboard[0] || null; const modeLabel = getOverlayModeLabel(overlayViewMode); dom.view.innerHTML = `
    ${ active ? `
    ${state.settings.logoDataUrl ? `` : ""}

    ${escapeHtml(getEventName(active.eventId))}

    ${escapeHtml(active.name)} • ${escapeHtml(getSessionTypeLabel(active.type))}

    ${escapeHtml(getStartModeLabel(active.startMode))} • ${escapeHtml(modeLabel)}

    ${formatCountdown(sessionTiming?.remainingMs ?? 0)}
    ${escapeHtml(getStatusLabel(active.status))}
    ${ overlayViewMode === "speaker" ? `
    P1

    ${escapeHtml(topRow?.driverName || t("common.unknown_driver"))}

    ${t("table.result")}: ${escapeHtml(topRow?.resultDisplay || "-")}

    ${t("table.best_lap")}: ${formatLap(topRow?.bestLapMs)}

    ${t("overlay.last_passings")}

    ${ recent.length ? recent .map( (passing) => `
    ${escapeHtml(passing.driverName || t("common.unknown_driver"))} ${new Date(passing.timestamp).toLocaleTimeString()}
    ` ) .join("") : `

    ${t("timing.no_passings")}

    ` }

    ${t("events.position_grid")}

    ${normalizeStartMode(active.startMode) === "position" ? renderPositionGrid(active) : `

    ${t("events.na")}

    `}

    ${t("overlay.event_markers")}

    ${ overlayEvents.length ? overlayEvents .map( (item) => `
    ${escapeHtml(item.label)} ${new Date(item.ts).toLocaleTimeString()}
    ` ) .join("") : `

    ${t("timing.no_passings")}

    ` }
    ` : overlayViewMode === "results" ? `

    ${t("events.practice_standings")}

    ${renderRaceStandingsTable(practiceRows, t("events.no_practice_results"))}

    ${t("events.qualifying_standings")}

    ${renderRaceStandingsTable(qualifyingRows, t("events.no_qualifying_results"))}

    ${t("events.final_standings")}

    ${renderRaceStandingsTable(finalRows, t("events.no_final_results"))}
    ` : `
    ${renderOverlayLeaderboard(leaderboard)}
    ` } ` : `

    ${t("overlay.title")}

    ${t("overlay.no_active")}

    ` }
    `; } function renderLeaderboardModal(session, row) { const passings = getCompetitorPassings(session, row); return ` `; } function renderLeaderboard(rows) { if (!rows.length) { return `

    ${t("timing.no_laps")}

    `; } 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 ` ${idx + 1} ${escapeHtml(row.driverName)} ${escapeHtml(row.carName)} ${escapeHtml(row.transponder)} ${row.laps} ${escapeHtml(row.resultDisplay)} ${formatLap(row.lastLapMs)} ${formatLap(row.bestLapMs)} ${escapeHtml(row.leaderGap || row.gap || "-")} ${escapeHtml(row.gapAhead || "-")} ${escapeHtml(row.lapDelta || "-")} `; }) ); } function renderOverlayLeaderboard(rows) { if (!rows.length) { return `

    ${t("timing.no_laps")}

    `; } return renderTable( [t("table.pos"), t("table.driver"), t("table.laps"), t("table.result"), t("table.best_lap"), 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 ` ${idx + 1} ${escapeHtml(row.driverName)} ${row.laps} ${escapeHtml(row.resultDisplay)} ${formatLap(row.bestLapMs)} ${escapeHtml(row.gapAhead || "-")} ${escapeHtml(row.lapDelta || "-")} `; }) ); } function renderRecentPassings(session) { if (!session) { return `

    ${t("timing.no_session_selected")}

    `; } const result = ensureSessionResult(session.id); const items = result.passings.slice(-20).reverse(); if (!items.length) { return `

    ${t("timing.no_passings")}

    `; } return renderTable( [t("table.time"), t("table.transponder"), t("table.driver"), t("table.car"), t("table.loop"), t("table.strength")], items.map((p) => { return ` ${new Date(p.timestamp).toLocaleTimeString()} ${escapeHtml(p.transponder)} ${escapeHtml(p.driverName || t("common.unknown_driver"))} ${escapeHtml(p.carName || "-")} ${escapeHtml(p.loopId || "-")} ${p.strength ?? "-"} `; }) ); } 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 = `

    ${t("settings.decoder")}

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

    ${t("settings.audio_note")}

    ${t("settings.branding")}

    ${t("settings.logo")}

    ${t("settings.logo_note")}

    ${ state.settings.logoDataUrl ? `
    logo
    ` : "" }

    ${t("settings.managed_ammc")}

    ${t("settings.managed_ammc_sub")}

    ${t("settings.bundled_hint")}

    ${t("settings.ammc_status")}: ${running ? t("settings.running") : t("settings.stopped")}

    ${t("settings.server_platform")}: ${escapeHtml(String(ammcStatus.serverPlatform || "-"))}

    ${t("settings.pid")}: ${escapeHtml(String(ammcStatus.pid || "-"))}

    ${t("settings.started_at")}: ${ammcStatus.startedAt ? new Date(ammcStatus.startedAt).toLocaleString() : "-"}

    ${t("settings.stopped_at")}: ${ammcStatus.stoppedAt ? new Date(ammcStatus.stoppedAt).toLocaleString() : "-"}

    ${t("settings.executable_path")}: ${escapeHtml(String(ammcStatus.resolvedExecutablePath || ammc.config.executablePath || "-"))}

    ${ammcStatus.executableExists ? t("settings.executable_found") : t("settings.executable_missing")}

    ${t("settings.decoder_host")}: ${escapeHtml(String(ammc.config.decoderHost || "-"))}

    ${t("settings.ws_port")}: ${escapeHtml(String(ammc.config.wsPort || 9000))}

    ${t("settings.last_error")}: ${escapeHtml(ammc.lastError || ammcStatus.lastError || "-")}

    ${t("settings.backend_url")}: ${escapeHtml(getBackendUrl())}

    WebSocket URL: ${escapeHtml(suggestedWsUrl)}

    ${escapeHtml(
              ammcOutput.length
                ? ammcOutput.map((entry) => `[${new Date(entry.ts).toLocaleTimeString()}] ${entry.stream}: ${entry.line}`).join("\n")
                : "-"
            )}

    ${t("settings.storage")}

    ${t("settings.backend_url")}: ${escapeHtml(getBackendUrl())}

    ${t("settings.backend_status")}: ${backend.available ? t("settings.online") : t("settings.offline")}

    ${t("settings.last_sync")}: ${backend.lastSyncAt ? new Date(backend.lastSyncAt).toLocaleString() : "-"}

    ${escapeHtml(backend.lastError || "")}

    `; 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.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 getActiveSession() { return state.sessions.find((s) => s.id === state.activeSessionId) || null; } function getSessionTargetMs(session) { return Math.max(1, Number(session?.durationMin || 0)) * 60 * 1000; } 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: Math.max(0, targetMs - elapsedMs), }; } 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, driverId: competitor.driverId, driverName: competitor.driverName, carId: competitor.carId, carName: competitor.carName, transponder, laps: 0, lastLapMs: null, bestLapMs: null, startTimestamp: null, lastTimestamp: null, }; } const entry = result.competitors[key]; 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); entry.laps += 1; entry.lastLapMs = lapMs; entry.lastTimestamp = timestamp; if (lapMs > 500 && (!entry.bestLapMs || lapMs < entry.bestLapMs)) { entry.bestLapMs = lapMs; } const passing = { timestamp, transponder, driverId: entry.driverId, driverName: entry.driverName, carId: entry.carId, carName: entry.carName, competitorKey: key, lapMs, strength: msg.strength, loopId: String(msg.loop_id || ""), resend: Boolean(msg.resend), }; result.passings.push(passing); persistPassingToBackend(session.id, passing); pushOverlayEvent("passing", `${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.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.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) { 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"), }; } const driver = state.drivers.find((d) => d.transponder === transponder); if (driver) { if (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, carId: null, carName: t("common.driver_car"), }; } return { key: `driver_tp_${transponder}`, driverId: null, driverName: t("common.unknown_driver"), 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 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 previousLapMs = passings.length >= 2 ? Number(passings[passings.length - 2].lapMs || 0) : null; const lapDeltaMs = row.lastLapMs && previousLapMs && row.lastLapMs > 0 && previousLapMs > 0 ? row.lastLapMs - previousLapMs : null; return { ...row, totalElapsedMs, distanceToTargetMs, seedMetric, previousLapMs, lapDeltaMs, comparisonMs: isFreePractice ? row.bestLapMs || row.lastLapMs || Number.MAX_SAFE_INTEGER : useSeedRanking && seedMetric ? seedMetric.totalMs : totalElapsedMs, resultDisplay: isFreePractice ? formatLap(row.bestLapMs || row.lastLapMs) : useSeedRanking && seedMetric ? `${seedMetric.lapCount}/${formatRaceClock(seedMetric.totalMs)}` : `${row.laps}/${formatRaceClock(totalElapsedMs)}`, }; }); rows.sort((a, b) => { if (isFreePractice) { 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 }), leaderGap: formatLeaderboardGap(row, leader, { useSeedRanking, useTargetTieBreak, isFreePractice }), gapAhead: formatLeaderboardGap(row, rows[index - 1], { useSeedRanking, useTargetTieBreak, isFreePractice, 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 `

    ${t("session.none_yet")}

    `; } return renderTable( [t("table.event"), t("table.session"), t("table.type"), t("table.status"), t("table.mode")], sessions.map( (s) => ` ${escapeHtml(getEventName(s.eventId))} ${escapeHtml(s.name)} ${escapeHtml(getSessionTypeLabel(s.type))} ${escapeHtml(getStatusLabel(s.status))} ${getModeLabel(s.mode)} ` ) ); } function renderSimpleList(items, labelFn, idFn) { if (!items.length) { return `

    ${t("common.no_entries")}

    `; } return `
      ${items .map( (item) => `
    • ${labelFn(item)}
    • ` ) .join("")}
    `; } function renderTable(headers, rowHtml) { return ` ${headers.map((h) => ``).join("")} ${rowHtml.join("") || ``}
    ${escapeHtml(h)}
    ${t("common.no_rows")}
    `; } function renderRaceFormatField(labelKey, hintKey, controlHtml, options = {}) { const extraClass = options.checkbox ? " field-card-checkbox" : ""; return ` `; } 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 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) { const result = ensureSessionResult(session.id); return result.passings .filter((passing) => { 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 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 `

    ${emptyLabel}

    `; } return renderTable( [t("table.pos"), t("table.driver"), t("table.score")], rows.map( (row) => ` ${row.rank} ${escapeHtml(row.driverName || t("common.unknown_driver"))} ${escapeHtml(row.score || "-")} ` ) ); } function getSessionSortWeight(session) { const order = { free_practice: 0, practice: 1, qualification: 2, heat: 3, final: 4, }; 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"), }; }); } 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 `

    ${t("events.position_grid")}

    ${t("timing.position_grid_hint")}

    ${entries .map( (entry) => `
    ${entry.slot}
    ${escapeHtml(entry.name)} ${entry.meta ? `
    ${escapeHtml(entry.meta)}
    ` : ""}
    ` ) .join("")}
    `; } 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 `

    ${t("events.grid_empty")}

    `; } const driverIds = ensureSessionDriverOrder(session); return `
    ${escapeHtml(session.name)}
    ${t("events.grid_editor_hint")}
    ${t(session.gridCustomized ? "events.grid_locked" : "events.grid_unlocked")}
    ${driverIds .map((driverId, index) => { const driver = state.drivers.find((item) => item.id === driverId); return `
    ${index + 1}
    ${escapeHtml(driver?.name || t("common.unknown_driver"))}
    ${escapeHtml(driver?.transponder || "-")}
    `; }) .join("")}
    `; } 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 `

    ${t("events.no_final_matrix")}

    `; } return `
    ${mains .map( (main) => `

    ${t("events.main")} ${escapeHtml(main.mainKey)}

    ${main.sessions.length} ${escapeHtml(t("events.leg_status").toLowerCase())}
    ${main.slots .map( (slot) => `
    ${t("events.slot")} ${slot.slot} ${escapeHtml(slot.label)}
    ` ) .join("")}
    ${main.sessions .map( (session) => `
    ${escapeHtml(session.name)} ${escapeHtml(getStatusLabel(session.status))}
    ` ) .join("")}
    ` ) .join("")}
    `; } function buildRaceStartListsHtml(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 `

    ${escapeHtml(event.name)}

    ${escapeHtml(getClassName(event.classId))} • ${escapeHtml(event.date || "-")}

    ${t("events.start_lists")}

    ${sessions .map((session) => { const entries = getSessionGridEntries(session); return ` `; }) .join("")} `; } function buildRaceResultsHtml(event) { return ` `; } 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(` ${escapeHtml(title)} ${bodyHtml} `); 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) })); } } async function exportRaceStartListsPdf(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: state.settings.clubName || "JMK RB", brandTagline: state.settings.clubTagline || "Live Event", footer: state.settings.pdfFooter || "Generated by JMK RB Live Event", theme: state.settings.pdfTheme || "classic", sections, }); } async function exportRaceResultsPdf(event) { await requestPdfExport({ filename: `${event.name.replaceAll(/\s+/g, "_")}_results.pdf`, title: event.name, subtitle: `${getClassName(event.classId)} • ${event.date || "-"}`, brandName: state.settings.clubName || "JMK RB", brandTagline: state.settings.clubTagline || "Live Event", footer: state.settings.pdfFooter || "Generated by JMK RB Live Event", theme: state.settings.pdfTheme || "classic", 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 || "-"]) ), ], }); } async function exportSessionHeatSheetPdf(session) { const event = state.events.find((item) => item.id === session.eventId); 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: state.settings.clubName || "JMK RB", brandTagline: state.settings.clubTagline || "Live Event", footer: state.settings.pdfFooter || "Generated by JMK RB Live Event", theme: state.settings.pdfTheme || "classic", 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 entries = getSessionGridEntries(session); return `

    ${escapeHtml(event?.name || t("common.unknown_event"))}

    ${escapeHtml(getClassName(event?.classId || ""))} • ${escapeHtml(session.name)} • ${escapeHtml(getSessionTypeLabel(session.type))}

    ${t("table.start_mode")}: ${escapeHtml(getStartModeLabel(session.startMode))} • ${t("table.duration")}: ${session.durationMin} min

    ${ entries.length ? renderTable( [t("events.slot"), t("table.driver"), t("table.transponder")], entries.map( (entry) => ` ${entry.slot} ${escapeHtml(entry.name)} ${escapeHtml(entry.meta || "-")} ` ) ) : `

    ${t("common.no_entries")}

    ` } `; } 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 overlayWindow = window.open(buildOverlayUrl(mode), "_blank", "noopener,noreferrer,width=1600,height=900"); if (!overlayWindow) { alert(t("error.print_blocked")); } } 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("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } 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 }), }); 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) }); } }