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: "judging", titleKey: "nav.judging", subtitleKey: "nav.judging_sub" }, { id: "settings", titleKey: "nav.settings", subtitleKey: "nav.settings_sub" }, { id: "guide", titleKey: "nav.guide", subtitleKey: "nav.guide_sub" }, ]; const SESSION_TYPES = ["open_practice", "free_practice", "practice", "qualification", "heat", "final", "team_race"]; const STORAGE_KEY = "rc_timing_control_v1"; const DEFAULT_LANGUAGE = "sv"; const TRANSLATIONS = { sv: { "nav.dashboard": "Översikt", "nav.dashboard_sub": "Status och liveinformation", "nav.events": "Event", "nav.events_sub": "Sponsor-event och delade bilar", "nav.race_setup": "Race Setup", "nav.race_setup_sub": "Tävlingsrace och heatupplägg", "nav.overlay": "Overlay", "nav.overlay_sub": "Extern leaderboard-skärm", "nav.classes": "Klasser", "nav.classes_sub": "Hantera tävlingsklasser", "nav.drivers": "Förare", "nav.drivers_sub": "Förare och personliga transpondrar", "nav.cars": "Bilar", "nav.cars_sub": "Bilar med fasta transpondrar", "nav.timing": "Tidtagning", "nav.timing_sub": "Live timing-board", "nav.judging": "Domare", "nav.judging_sub": "Korrigeringar och penalties", "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 RaceController", "brand.subtitle": "RC Timing System", "ui.no_active_session": "Ingen aktiv session", "ui.event": "Event", "ui.decoder_online": "Decoder online", "ui.decoder_offline": "Decoder offline", "mode.track": "Sponsor Event", "mode.race": "Race", "dashboard.events": "Event", "dashboard.drivers": "Förare", "dashboard.cars": "Bilar", "dashboard.passings": "Passeringar", "dashboard.created": "Skapade", "dashboard.registered": "Registrerade", "dashboard.track_fleet": "Banans bilar", "dashboard.captured": "Mottagna", "dashboard.live_session": "Live-session", "dashboard.idle": "inaktiv", "dashboard.duration": "Tid", "dashboard.no_session": "Ingen session är aktiv. Gå till Event eller Tidtagning för att starta.", "dashboard.quick_actions": "Snabbval", "dashboard.create_event": "Skapa Event", "dashboard.open_timing": "Öppna Tidtagning", "dashboard.connect_decoder": "Anslut Decoder", "dashboard.recent_sessions": "Senaste sessioner", "dashboard.free_practice": "Fri träning", "dashboard.open_practice": "Open Practice", "dashboard.live_board": "Live Board", "dashboard.decoder_feed": "Decoder-feed", "dashboard.backend_link": "Backend-länk", "dashboard.audio_profile": "Ljudprofil", "dashboard.schedule_drift": "Schemaavvikelse", "dashboard.schedule_plan": "Planerad tid", "dashboard.schedule_actual": "Faktisk tid", "dashboard.on_time": "I fas", "dashboard.ahead": "Före schema", "dashboard.behind": "Efter schema", "dashboard.live_note": "Snabb driftpanel för anslutning, overlay och ljud. Djupare konfig ligger kvar under Inställningar.", "session.none_yet": "Inga sessioner ännu.", "classes.create": "Skapa klass", "classes.placeholder": "Klassnamn (t.ex. 2WD Buggy)", "classes.add": "Lägg till klass", "classes.title": "Klasser", "drivers.create": "Skapa förare", "drivers.name_placeholder": "Förarnamn", "drivers.brand_placeholder": "Team / märke (valfritt)", "drivers.brand_filter_placeholder": "Sök namn / transponder / brand", "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.brand_placeholder": "Märke / modell (valfritt)", "cars.brand_filter_placeholder": "Sök namn / transponder / brand", "cars.transponder_placeholder": "Bilens transponder", "cars.add": "Lägg till bil", "cars.title": "Bilar", "events.create": "Skapa event", "events.create_race": "Skapa race", "events.name_placeholder": "Eventnamn", "events.add": "Lägg till event", "events.add_race": "Lägg till race", "events.mode_race_option": "Race (förare med egen transponder)", "events.mode_track_option": "Sponsor Event (delade bilar)", "events.title": "Event", "events.race_title": "Race", "events.track_only_intro": "Här skapar du sponsor-event med delade bilar/transpondrar.", "events.race_only_intro": "Här skapar du riktiga race med personlig transponder per förare.", "events.manage": "Hantera", "events.edit": "Redigera", "events.sessions": "Sessioner", "events.participants": "Deltagare", "events.select_participants": "Välj racedeltagare", "events.select_all_participants": "Markera alla", "events.clear_participants": "Rensa deltagare", "events.reseed_qualifying": "Reseeda kommande kval", "events.reseed_done": "Kommande kval heat reseedade från aktuell ranking.", "events.no_reseed_done": "Inga kommande kval kunde reseedas.", "events.reseed_locked": "{count} heat hoppades över eftersom manuell grid är låst.", "events.reserve_bump_slots": "Reservera bump-platser i finaler", "events.bump_reserved_note": "Om bump används kan finalgeneratorn reservera platser i högre finaler redan från start.", "events.actions": "Åtgärder", "events.manage_title": "Hantera", "events.branding": "Branding för detta event", "events.branding_note": "Lämna fält tomma för att ärva global branding från Inställningar. Logo används i overlay och bäddas in i PDF-export när den kan konverteras.", "events.brand_name": "Brandnamn", "events.brand_tagline": "Brandtext", "events.brand_footer": "PDF-footer", "events.brand_theme": "PDF-tema", "events.brand_logo": "Eventlogo", "events.branding_use_global": "Använd globalt standardtema", "events.branding_save": "Spara branding", "events.session_name": "Sessionsnamn", "events.duration_placeholder": "Längd (min)", "events.max_cars_placeholder": "Max bilar (valfritt)", "events.start_mode": "Starttyp", "events.seed_best_laps": "Seedning bästa varv", "events.seed_method": "Seedmetod", "events.seed_method_hint": "Hur bästa varv ska räknas när seedBestLapCount är större än 0.", "events.seed_method_best_sum": "Bästa N varv, summa", "events.seed_method_average": "Bästa N varv, snitt", "events.seed_method_consecutive": "Bästa N konsekutiva 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.qual_seed_laps": "Kval bästa varv", "events.qual_seed_laps_hint": "Antal varv som används för ranking i varje kvalheat när seedmetod är aktiv.", "events.qual_seed_method": "Kval seedmetod", "events.qual_seed_method_hint": "Hur kvalheat räknar varv när bästa-varvsläget används.", "events.counted_qual_rounds": "Räknade kvalrundor", "events.counted_qual_rounds_hint": "Hur många av kvalrundorna som räknas i slutrankingen.", "events.qual_points_table": "Poängtabell", "events.qual_points_table_hint": "Välj hur poäng per kvalomgång delas ut när Kval-scoring använder poäng.", "events.qual_points_rank": "Placeringstal (1,2,3...)", "events.qual_points_desc": "Fallande efter fältstorlek", "events.qual_points_ifmar": "10-9-8-7-6-5-4-3-2-1", "events.qual_tie_break": "Tie-break", "events.qual_tie_break_hint": "Välj vilken regel som avgör lika resultat i kvalrankingen.", "events.qual_tie_break_rounds": "Jämför räknade rundor", "events.qual_tie_break_best_lap": "Bästa enskilda varv", "events.qual_tie_break_best_round": "Bästa runda / heatresultat", "events.race_preset": "Preset", "events.race_preset_hint": "Snabbstart för bana/klass. Applicera preset och finjustera sedan manuellt.", "events.apply_preset": "Applicera preset", "events.save_preset": "Spara klubb-preset", "events.delete_preset": "Ta bort klubb-preset", "events.preset_name": "Presetnamn", "events.preset_custom": "Custom / nuvarande värden", "events.preset_short_technical": "Kort teknisk bana 16s", "events.preset_club_qualifying": "Klubbrace kval + final", "events.preset_ifmar": "IFMAR-stil kval/final", "events.preset_endurance": "Endurance / lagrace", "events.tie_break_note": "Tie-break", "events.counted_rounds_label": "Räknade rundor", "events.tie_break_won": "Vann mot", "events.tie_break_lost": "Förlorade mot", "events.invalid_recent": "Senaste träff ogiltig", "timing.invalid_manual": "Manuellt ogiltigförklarat", "timing.invalidate_last_lap": "Ogiltigförklara senaste varv", "events.cars_per_final": "Förare per final", "events.cars_per_final_hint": "Max antal förare i varje A/B/C-final.", "events.final_legs": "Final-heat per final", "events.final_legs_hint": "Hur många finalheat som ska skapas per main.", "events.counted_final_legs": "Räknade finalheat", "events.counted_final_legs_hint": "Hur många finalheat som ska räknas i sammanlagd finalranking.", "events.final_duration": "Finaltid (min)", "events.final_duration_hint": "Längd per finalheat i minuter.", "events.final_start_mode": "Final-start", "events.final_start_mode_hint": "Starttyp för finaler, ofta positionsstart.", "events.bump_count": "Bump-up per final", "events.bump_count_hint": "Antal förare som kan flyttas upp från lägre final till nästa main.", "events.save_race_format": "Spara raceformat", "events.open_grid": "Grid", "events.grid_editor": "Grid-editor", "events.grid_editor_hint": "Dra förare upp eller ner för att ändra startordningen manuellt för positionsstart.", "events.grid_reset": "Återställ från deltagarlista", "events.grid_lock": "Lås grid", "events.grid_unlock": "Lås upp grid", "events.grid_locked": "Manuell grid är låst mot auto-reseed.", "events.grid_unlocked": "Grid följer auto-seed/reseed tills du låser den.", "events.grid_empty": "Ingen grid att redigera ännu.", "events.print_heat_sheet": "Skriv ut heatsheet", "events.export_heat_sheet": "Exportera heatsheet", "events.pdf_heat_sheet": "PDF heatsheet", "events.free_practice_note": "Free Practice visar löpande varvtider och används inte för seedning.", "events.open_practice_note": "Open Practice visar alla inkommande transpondrar löpande. Om ingen förare matchar visas bara transpondern.", "events.generate_qualifying": "Skapa kval från practice", "events.clear_generated_qualifying": "Rensa genererade kval", "events.generate_finals": "Skapa finaler från kval", "events.clear_generated_finals": "Rensa genererade finaler", "events.apply_bumps": "Applicera bump-ups", "events.practice_standings": "Practice-ranking", "events.qualifying_standings": "Kval-ranking", "events.final_standings": "Final-ranking", "events.generated_qualifying": "Kvalheat skapade från ranking.", "events.finals_generated": "Finaler skapade från ranking.", "events.bumps_applied": "Bump-ups applicerade till nästa final.", "events.no_bumps_applied": "Inga bump-ups kunde appliceras ännu.", "events.no_practice_results": "Inga practice-resultat ännu.", "events.no_qualifying_results": "Inga kval-resultat ännu.", "events.no_final_results": "Inga final-resultat ännu.", "events.final_matrix": "Finalmatris", "events.print_startlists": "Skriv ut startlistor", "events.print_results": "Skriv ut resultat", "events.pdf_startlists": "PDF startlistor", "events.pdf_results": "PDF resultat", "events.reserved_slot": "Reserverad bump-plats", "events.position_grid": "Grid / startordning", "events.start_lists": "Startlistor", "events.no_final_matrix": "Inga finaler skapade ännu.", "events.results_overview": "Resultatöversikt", "events.main": "Main", "events.slot": "Ruta", "events.leg_status": "Heatstatus", "events.source_for_finals": "Källa för finaler", "events.finals_from_qualifying": "Kval-ranking", "events.finals_from_practice": "Practice-ranking", "events.finals_source_hint": "Välj om finalerna ska seedas från practice eller kval.", "events.follow_up_sec": "Follow-up tid (sek)", "events.follow_up_sec_hint": "Extra tid efter ordinarie racetid så sista bilarna kan avsluta innan sessionen stängs.", "events.min_lap_time": "Min varvtid (sek)", "events.min_lap_time_hint": "Varv snabbare än detta ignoreras som shortcut eller felträff.", "events.max_lap_time": "Max varvtid (sek)", "events.max_lap_time_hint": "Varv långsammare än detta räknas inte som giltigt varv och bryter lap-basen för nästa varv.", "events.race_driver_scope": "Race i denna klass använder alla förare i vald klass om sessionen inte har egen deltagarlista.", "events.reserve_bump_slots_hint": "Reserverar tomma platser i högre finaler så bumpade förare kan flyttas in utan att skriva över seedade platser.", "events.team_race": "Lagrace", "events.team_race_intro": "Skapa lag för långlopp. Alla passeringar från lagets förare eller bilar summeras till lagets totalvarv i Team Race-sessioner.", "events.team_name": "Lagnamn", "events.add_team": "Lägg till lag", "events.teams": "Lag", "events.team_drivers": "Lagförare", "events.team_cars": "Lagbilar", "events.team_hint": "Välj minst en förare eller bil per lag. Team Race-sessioner summerar lagets totala varv under hela körtiden, t.ex. 4 timmar.", "events.team_steps": "1. Skriv lagnamn. 2. Kryssa förare och/eller bilar här under. 3. Klicka Lägg till lag. 4. Använd Redigera lag för ändringar efteråt.", "events.team_form_drivers": "Markera lagförare innan du sparar laget.", "events.team_form_cars": "Markera lagbilar innan du sparar laget.", "events.team_driver_fallback": "Inga förare matchade race-klassen eller deltagarlistan. Visar alla förare som fallback.", "events.no_teams": "Inga lag skapade ännu.", "events.team_standings": "Lagställning", "events.no_team_results": "Inga teamresultat ännu.", "events.edit_team": "Redigera lag", "events.team_stint_log": "Stint- och förarbyteslogg", "events.team_report": "Lagrapport", "events.print_team_results": "Skriv ut lagrapport", "events.pdf_team_results": "PDF lagrapport", "events.add_session": "Lägg till session", "events.set_active": "Sätt aktiv", "events.assignments": "Tilldelningar", "events.na": "ej relevant", "events.sponsor_tools": "Sponsorverktyg", "events.qual_rounds": "Kvalrundor", "events.heat_rounds": "Heat-rundor", "events.final_rounds": "Finalrundor", "events.round_duration": "Rundtid (min)", "events.create_rounds": "Skapa rundor", "events.tp_rule": "Samma transponder kan återanvändas mellan sessioner (Heat 1 -> Heat 2 -> Final 1). I en pågående session måste alla aktiva bilar ha unikt transponder-ID.", "events.assign_title": "Tilldelningar (Förare -> Bil)", "events.assign": "Tilldela", "events.auto_assign": "Auto-tilldela vald session", "events.clear_assign": "Rensa vald session", "events.no_assignments": "Inga tilldelningar", "events.duplicate_car": "Den bilen är redan tilldelad i denna session.", "events.duplicate_driver": "Den föraren är redan tilldelad i denna session.", "events.duplicate_tp": "Dubblett-transponder i samma session är inte tillåtet. Återanvänd i nästa heat/final.", "timing.decoder_connection": "Decoder-anslutning", "timing.connect": "Anslut decoder", "timing.disconnect": "Koppla ner", "timing.simulate": "Simulera passering", "timing.status": "Status", "timing.connected": "Ansluten", "timing.disconnected": "Frånkopplad", "timing.last_message": "Senaste meddelande", "timing.control": "Sessionkontroll", "timing.speaker_panel": "Speaker-panel", "timing.speaker_panel_hint": "Dessa växlar slår av/på cues direkt för pågående session och overlay utan att lämna Tidtagning.", "timing.select_session": "Välj session", "timing.set_active": "Sätt aktiv", "timing.start": "Starta", "timing.stop": "Stoppa", "timing.reset": "Nollställ data", "timing.total_passings": "Totala passeringar", "timing.started": "Startad", "timing.remaining": "Nedräkning", "timing.elapsed": "Körtid", "timing.race_finished": "Race is finished", "timing.follow_up": "Follow-up", "timing.follow_up_active": "Follow-up aktiv", "timing.no_active": "Ingen aktiv session vald.", "timing.leaderboard": "Live leaderboard", "timing.recent_passings": "Senaste passeringar", "timing.no_laps": "Inga varv ännu.", "timing.no_session_selected": "Ingen session vald.", "timing.no_passings": "Inga passeringar registrerade.", "timing.details": "Detaljer", "timing.add_driver": "Lägg till förare", "timing.add_car": "Lägg till bil", "timing.quick_add_hint": "Snabbregistrera transponder", "timing.quick_add_title": "Snabbregistrering", "timing.quick_add_driver_title": "Lägg till förare från transponder", "timing.quick_add_car_title": "Lägg till bil från transponder", "timing.open_overlay": "Öppna overlay", "timing.open_speaker_overlay": "Speaker overlay", "timing.open_results_overlay": "Result overlay", "timing.open_tv_overlay": "TV overlay", "timing.open_team_overlay": "Team overlay", "timing.close_details": "Stang", "timing.detail_title": "Leaderboard-detaljer", "timing.lap_history": "Varvhistorik", "timing.no_lap_history": "Inga varv att visa.", "timing.manual_corrections": "Manuella korrigeringar", "timing.lap_adjustment": "Varvjustering", "timing.time_penalty": "Tidspåslag", "timing.penalty_add_lap": "+1 varv", "timing.penalty_remove_lap": "-1 varv", "timing.penalty_add_sec": "+1 sek", "timing.penalty_add_5sec": "+5 sek", "timing.penalty_remove_sec": "-1 sek", "timing.penalty_reset": "Nollställ korrigering", "timing.restore_last_invalid": "Återställ senaste manuellt ogiltiga varv", "timing.no_manual_invalid": "Inget manuellt ogiltigt varv hittades.", "timing.valid_passing": "Giltigt varv", "timing.invalid_short": "För kort varv", "timing.invalid_long": "Över maxvarv", "judging.title": "Domarvy", "judging.active_session": "Aktiv session", "judging.no_active_session": "Ingen aktiv session vald.", "judging.select_competitor": "Välj förare eller lag", "judging.manual_actions": "Manuella åtgärder", "judging.action_log": "Domarlogg", "judging.no_action_log": "Inga manuella åtgärder registrerade ännu.", "judging.selected_none": "Ingen rad vald.", "judging.restore_done": "Senaste manuellt ogiltiga varv återställdes.", "judging.filter_competitors": "Filter rader", "judging.filter_log": "Filter logg", "judging.filter_all": "Alla", "judging.filter_invalid": "Ogiltiga", "judging.filter_corrected": "Korrigerade", "judging.filter_team": "Team race", "judging.filter_log_corrections": "Korrigeringar", "judging.filter_log_invalidations": "Invalidate/restore", "judging.filter_log_undo": "Undo", "judging.export_log": "Exportera domarlogg", "judging.undo_last": "Ångra senaste", "judging.undo_action": "Ångra", "judging.undo_done": "Senaste manuella åtgärden ångrades.", "judging.no_undo": "Ingen åtgärd att ångra.", "timing.total_time": "Total tid", "timing.clear_confirm": "Rensa all tiddata för denna session?", "timing.prompt_transponder": "Transponder", "timing.first_crossing_start": "Första crossing satte personlig start", "timing.seeding_mode": "Seedning", "timing.position_grid_hint": "Griden visar startordningen för positionsstart i aktiv session.", "settings.decoder": "Decoder", "settings.auto_reconnect": "Auto-återanslut", "settings.save": "Spara", "settings.connect_now": "Anslut nu", "settings.expected_json": "Förväntat AMMC JSON-format", "settings.managed_ammc": "Hanterad AMMC", "settings.managed_ammc_sub": "Starta lokal AMMC från backend på denna maskin.", "settings.enable_managed": "Aktivera hanterad AMMC", "settings.auto_start_ammc": "Auto-starta AMMC när backend startar", "settings.decoder_host": "Decoder IP / host", "settings.ws_port": "AMMC WebSocket-port", "settings.executable_path": "AMMC binär", "settings.working_dir": "Arbetskatalog (valfritt)", "settings.extra_args": "Extra argument (valfritt)", "settings.save_ammc": "Spara AMMC", "settings.start_ammc": "Starta AMMC", "settings.stop_ammc": "Stoppa AMMC", "settings.refresh_ammc": "Uppdatera status", "settings.ammc_status": "AMMC-status", "settings.running": "Kör", "settings.stopped": "Stoppad", "settings.server_platform": "Server-OS", "settings.pid": "PID", "settings.started_at": "Startad", "settings.stopped_at": "Stoppad", "settings.last_error": "Senaste fel", "settings.output": "Senaste AMMC-logg", "settings.executable_found": "Binär hittad", "settings.executable_missing": "Binär saknas", "settings.bundled_hint": "Bundlad standardbana i appen används automatiskt om den finns.", "settings.use_server_ws": "Använd serverns WS-url", "settings.audio": "Ljud", "settings.audio_enabled": "Aktivera ljud i browsern", "settings.speaker_passing_cue": "Speaker-cue vid passing", "settings.speaker_leader_cue": "Speaker-cue vid ny ledare", "settings.speaker_finish_cue": "Speaker-cue vid finish", "settings.speaker_bestlap_cue": "Speaker-cue vid nytt bästa varv", "settings.speaker_top3_cue": "Speaker-cue vid topp 3-ändring", "settings.speaker_start_cue": "Speaker-cue vid sessionstart", "settings.passing_sound": "Passing-ljud", "settings.passing_sound_off": "Av", "settings.passing_sound_beep": "Blipp", "settings.passing_sound_name": "Säg förarnamn", "settings.finish_voice": "Spela finish-siren", "settings.test_audio": "Testa ljud", "settings.audio_note": "Browsern kräver oftast ett klick först innan ljud/tal tillåts.", "settings.branding": "Klubbinfo / PDF", "settings.club_name": "Klubbnamn", "settings.club_tagline": "Klubbtext", "settings.pdf_footer": "PDF-footer", "settings.pdf_theme": "PDF-tema", "settings.pdf_theme_classic": "Classic", "settings.pdf_theme_minimal": "Minimal", "settings.pdf_theme_motorsport": "Motorsport", "settings.logo": "Logo / overlay", "settings.logo_upload": "Ladda logo", "settings.logo_clear": "Rensa logo", "settings.logo_note": "Logon visas i overlay. PDF-export försöker bädda in loggan automatiskt via backend.", "settings.storage": "Lagring", "settings.race_presets": "Klubb-presetar", "settings.race_presets_note": "Exportera eller importera lokala klubb-presetar mellan installationer.", "settings.export_presets": "Exportera presets", "settings.import_presets": "Importera presets", "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.brand": "Märke", "table.class": "Klass", "table.transponder": "Transponder", "table.delete": "Ta bort", "table.car": "Bil", "table.date": "Datum", "table.mode": "Läge", "table.start_mode": "Start", "table.seeding": "Seedning", "table.score": "Poäng", "table.session": "Session", "table.type": "Typ", "table.duration": "Tid", "table.status": "Status", "table.time": "Tid", "table.driver": "Förare", "table.loop": "Loop", "table.strength": "Signal", "table.pos": "Pos", "table.laps": "Varv", "table.last_lap": "Senaste varv", "table.best_lap": "Bästa varv", "table.gap": "Gap", "table.event": "Event", "table.result": "Resultat", "table.lap": "Varv", "table.leader_gap": "Gap ledare", "table.ahead_gap": "Gap fram", "table.own_delta": "Eget delta", "common.delete": "Ta bort", "common.cancel": "Avbryt", "common.save": "Spara", "common.edit": "Redigera", "common.unknown_driver": "Okänd förare", "common.unknown_car": "Okänd bil", "common.unknown": "Okänd", "common.unassigned_driver": "Otilldelad förare", "common.driver_car": "Förarbil", "common.unknown_event": "Okänt event", "common.no_rows": "Inga rader", "common.no_entries": "Inga poster.", "status.ready": "redo", "status.running": "pågår", "status.finished": "klar", "status.leader": "LEDARE", "status.seeded": "SEED", "status.free_practice": "FREE", "status.open_practice": "OPEN", "session.open_practice": "open practice", "session.free_practice": "fri träning", "session.practice": "träning", "session.qualification": "kval", "session.heat": "heat", "session.final": "final", "session.team_race": "lagrace", "validation.no_assignments": "Ingen förar-/biltilldelning i denna session.", "validation.missing_tp": "En eller flera tilldelade bilar saknar transponder-ID.", "validation.duplicate_tp": "Dubblett-transponder i session: {ids}.", "validation.invalid_date": "Datum måste vara i format YYYY-MM-DD.", "validation.invalid_selection": "Välj ett giltigt alternativ.", "validation.required_name": "Namn måste fyllas i.", "validation.required_transponder": "Transponder måste fyllas i.", "validation.required_date": "Datum måste fyllas i.", "validation.required_duration": "Duration måste vara minst 1 minut.", "edit.class_name": "Redigera klassnamn", "edit.driver_name": "Redigera förarnamn", "edit.driver_class": "Redigera förarklass", "edit.new_driver_name": "Namn på ny förare", "edit.driver_transponder": "Redigera personlig transponder (kan vara tom)", "edit.car_name": "Redigera bilnamn", "edit.new_car_name": "Namn på ny bil", "edit.car_transponder": "Redigera bilens transponder", "edit.event_name": "Redigera eventnamn", "edit.event_date": "Redigera eventdatum (YYYY-MM-DD)", "guide.title": "Guide och dokumentation", "guide.intro": "Här finns steg-för-steg för sponsor-event (10 personer, 4 bilar), vanligt race, samt AMMC-installation på Windows/Linux. Guiden beskriver också var AMMC faktiskt körs i Managed AMMC-läget.", "guide.sponsor_title": "Skapa Sponsor Event: 10 personer, 4 bilar", "guide.sponsor_1": "1. Lägg upp 4 bilar i sidan Bilar med unikt transponder-ID. Lägg gärna också in märke/modell i brandfältet.", "guide.sponsor_2": "2. Lägg upp 10 förare i sidan Förare. Du kan också spara team/märke i brandfältet och filtrera listan på det senare.", "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. Brandfältet kan användas för team, sponsor eller bilmärke.", "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.race_format_8": "Follow-up tid ger en extra uppsamlingsperiod efter ordinarie racetid innan heatet verkligen stängs.", "guide.race_format_9": "Min varvtid filtrerar bort shortcuts och felträffar. Exempel: på en 16-sekundersbana kan du sätta 11 sekunder som min-gräns.", "guide.race_format_10": "Max varvtid stoppar långa felvarv från att räknas och används också för att bryta stintar och förbättra statistik. Exempel: 60 sekunder.", "guide.race_format_11": "Preset låter dig snabbt fylla raceformat med vettiga grundvärden för kort teknisk bana, klubbrace, IFMAR-liknande upplägg eller endurance.", "guide.race_format_12": "Du kan applicera preset och sedan justera enskilda fält manuellt innan du sparar raceformatet.", "guide.race_format_13": "Spara klubb-preset lagrar dina egna lokala raceformat så du kan återanvända dem på samma installation utan att bygga om allt varje gång.", "guide.race_format_14": "Klubb-presetar kan också exporteras och importeras från Inställningar om du vill flytta dem mellan olika servrar eller laptops.", "guide.free_practice_title": "Free Practice", "guide.free_practice_1": "Använd sessionstypen fri träning när du bara vill visa löpande varvtider.", "guide.free_practice_2": "Free Practice påverkar inte seedning till kval eller finaler.", "guide.free_practice_3": "Leaderboarden visar varv, senaste varv, bästa varv, gap till framförvarande och eget delta mot föregående varv.", "guide.open_practice_title": "Open Practice", "guide.open_practice_1": "Använd Open Practice när du vill att systemet bara ska lista alla transpondrar som kommer in.", "guide.open_practice_2": "Om transpondern inte matchar en registrerad förare visas transpondernumret som namn.", "guide.open_practice_3": "Open Practice påverkar inte seedning, kval eller finaler.", "guide.team_title": "Lagrace / Endurance", "guide.team_1": "Gå till Race Setup och skapa ett race i rätt klass.", "guide.team_2": "Öppna Hantera och gå till sektionen Lag.", "guide.team_3": "Skriv lagnamn och kryssa förare och/eller bilar i samma teamblock innan du klickar Lägg till lag.", "guide.team_4": "Efter att laget skapats kan du klicka Redigera lag för att ändra förare eller bilar.", "guide.team_5": "Skapa en session med typ Team Race och sätt tiden, t.ex. 240 minuter för 4 timmar.", "guide.team_6": "Starta sessionen i Tidtagning. Alla passeringar från lagets medlemmar summeras till lagets totalvarv.", "guide.validation_title": "Ogiltiga varv, follow-up och manuella korrigeringar", "guide.validation_1": "Senaste passeringar visar nu både giltiga och ogiltiga varv. För korta varv markeras som För kort varv och för långa som Över maxvarv.", "guide.validation_2": "Ogiltiga kortvarv under min-gränsen räknas inte alls i leaderboard eller statistik.", "guide.validation_3": "Ogiltiga långvarv över max-gränsen räknas inte som varv, men de kan bryta lap-basen så nästa giltiga varv börjar om korrekt.", "guide.validation_4": "När ordinarie tid är slut kan sessionen gå in i Follow-up aktiv om du har satt Follow-up tid i raceformat eller sessionen.", "guide.validation_5": "I Tidtagning -> Detaljer kan du ge +1/-1 varv och +1/+5/-1 sekunder som manuell korrigering. Det slår igenom direkt i leaderboarden.", "guide.validation_6": "I samma detaljvy kan du också manuellt ogiltigförklara senaste räknade varvet om du behöver ta bort en felträff i efterhand.", "guide.validation_7": "Menyn Domare samlar samma korrigeringar i en separat arbetsvy med leaderboard, lap history och domarlogg för pågående session.", "guide.validation_8": "Domarvyn kan filtrera på ogiltiga rader, korrigerade rader eller teamrace, exportera domarloggen och ångra flera senaste manuella åtgärder via undo-knappar.", "guide.qualifying_title": "Seedning, poängtabeller och tie-break", "guide.qualifying_1": "Practice och kval kan nu använda tre seedmetoder: bästa N varv som summa, bästa N varv som snitt eller bästa N konsekutiva varv.", "guide.qualifying_2": "Raceformat styr både Kval seedvarv och Kval seedmetod när nya kvalheat skapas från practice eller deltagarlistan.", "guide.qualifying_3": "Kval-scoring kan kombinera poängläge med poängtabell: placeringstal, fallande efter fältstorlek eller IFMAR 10-9-8-7-6-5-4-3-2-1.", "guide.qualifying_4": "Tie-break i kvalrankingen kan avgöras på räknade rundor, bästa enskilda varv eller bästa runda/heatresultat.", "guide.qualifying_5": "Leaderboarden visar seedningsresultat i rätt format, till exempel 3/00:48.321, 3 avg 16.107 eller 3 con 00:49.005.", "guide.dashboard_title": "Schemaavvikelse på Översikt", "guide.dashboard_1": "Översikt visar nu skillnaden mellan planerad tid och faktisk körtid för alla startade sessioner.", "guide.dashboard_2": "Planerad tid räknar sessionstid plus follow-up. Faktisk tid räknar verklig tid från start till stopp eller nuvarande tid om heatet fortfarande kör.", "guide.dashboard_3": "Det gör det lättare att se om tävlingsdagen ligger före eller efter schema direkt från dashboarden.", "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 RaceController Overlay", "overlay.mode_leaderboard": "Leaderboard", "overlay.mode_speaker": "Speaker", "overlay.mode_results": "Resultat", "overlay.mode_tv": "TV", "overlay.mode_team": "Team", "overlay.fastest_lap": "Snabbaste varv", "overlay.fullscreen": "Fullscreen", "overlay.leaderboard_live": "Live leaderboard", "overlay.rotating_panel": "Displaypanel", "overlay.next_predicted_lap": "Nästa varv", "overlay.event_markers": "Eventmarkörer", "overlay.team_battle": "Lagkamp", "overlay.active_member": "Aktiv förare/bil", "overlay.top_three": "Topp 3", "guide.host_title": "Hur Managed AMMC körs", "guide.host_1": "1. AMMC körs alltid på samma maskin som `npm start` eller `node server.js` körs på.", "guide.host_2": "2. Om du bara surfar in från en laptop/webbläsare startas ingen process där. Webbläsaren styr bara backend via HTTP.", "guide.host_3": "3. Kör backend på Linux-servern -> Linux-binären används: `AMMC/linux_x86-64/ammc-amb`.", "guide.host_4": "4. Kör backend på Windows-burken -> Windows-binären används: `AMMC/windows64/ammc-amb.exe`.", "guide.host_5": "5. Fältet `AMMC binär` i Settings är sökvägen på hosten där backend kör, inte på klient-laptopen.", "guide.windows_title": "Windows + AMMC + npm", "guide.windows_1": "1. Installera Node.js LTS och Visual C++ Runtime 2015-2022 på hosten som ska köra `live_event`.", "guide.windows_2": "2. Standardbinär för Managed AMMC på Windows-host: `AMMC/windows64/ammc-amb.exe`.", "guide.windows_3": "3. Kör `npm start` på Windows-hosten. Då är det där AMMC startas om du använder Managed AMMC.", "guide.windows_4": "4. I Settings: `Decoder IP / host` = decoderns IP, t.ex. `192.168.1.11`.", "guide.windows_5": "5. I appen: `Backend URL` = http://: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.judging": "Judging", "nav.judging_sub": "Corrections and penalties", "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 RaceController", "brand.subtitle": "RC Timing System", "ui.no_active_session": "No Active Session", "ui.event": "Event", "ui.decoder_online": "Decoder Online", "ui.decoder_offline": "Decoder Offline", "mode.track": "Track Event", "mode.race": "Race", "dashboard.events": "Events", "dashboard.drivers": "Drivers", "dashboard.cars": "Cars", "dashboard.passings": "Passings", "dashboard.created": "Created", "dashboard.registered": "Registered", "dashboard.track_fleet": "Track Fleet", "dashboard.captured": "Captured", "dashboard.live_session": "Live Session", "dashboard.idle": "idle", "dashboard.duration": "Duration", "dashboard.no_session": "No session is active. Go to Events or Timing to create/start one.", "dashboard.quick_actions": "Quick Actions", "dashboard.create_event": "Create Event", "dashboard.open_timing": "Open Timing Board", "dashboard.connect_decoder": "Connect Decoder", "dashboard.recent_sessions": "Recent Sessions", "dashboard.free_practice": "Free Practice", "dashboard.open_practice": "Open Practice", "dashboard.live_board": "Live Board", "dashboard.decoder_feed": "Decoder feed", "dashboard.backend_link": "Backend link", "dashboard.audio_profile": "Audio profile", "dashboard.schedule_drift": "Schedule drift", "dashboard.schedule_plan": "Planned time", "dashboard.schedule_actual": "Actual time", "dashboard.on_time": "On time", "dashboard.ahead": "Ahead of schedule", "dashboard.behind": "Behind schedule", "dashboard.live_note": "Quick operations panel for connection, overlay and audio. Deeper configuration remains under Settings.", "session.none_yet": "No sessions yet.", "classes.create": "Create Class", "classes.placeholder": "Class name (e.g. 2WD Buggy)", "classes.add": "Add Class", "classes.title": "Classes", "drivers.create": "Create Driver", "drivers.name_placeholder": "Driver name", "drivers.brand_placeholder": "Team / brand (optional)", "drivers.brand_filter_placeholder": "Search name / transponder / brand", "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.brand_placeholder": "Brand / model (optional)", "cars.brand_filter_placeholder": "Search name / transponder / brand", "cars.transponder_placeholder": "Car transponder", "cars.add": "Add Car", "cars.title": "Cars", "events.create": "Create Event", "events.create_race": "Create Race", "events.name_placeholder": "Event name", "events.add": "Add Event", "events.add_race": "Add Race", "events.mode_race_option": "Race (driver transponders)", "events.mode_track_option": "Track Event (shared cars)", "events.title": "Events", "events.race_title": "Races", "events.track_only_intro": "Create sponsor events with shared cars/transponders here.", "events.race_only_intro": "Create proper races with personal driver transponders here.", "events.manage": "Manage", "events.edit": "Edit", "events.sessions": "Sessions", "events.participants": "Participants", "events.select_participants": "Select race participants", "events.select_all_participants": "Select all", "events.clear_participants": "Clear participants", "events.reseed_qualifying": "Reseed upcoming qualifying", "events.reseed_done": "Upcoming qualifying heats reseeded from current standings.", "events.no_reseed_done": "No upcoming qualifying heats could be reseeded.", "events.reseed_locked": "{count} heats were skipped because manual grid is locked.", "events.reserve_bump_slots": "Reserve bump slots in finals", "events.bump_reserved_note": "If bump-up is used, finals can reserve slots in upper mains from the start.", "events.actions": "Actions", "events.manage_title": "Manage", "events.branding": "Branding for this event", "events.branding_note": "Leave fields empty to inherit global branding from Settings. The logo is used in overlay and embedded in PDF exports when it can be converted.", "events.brand_name": "Brand name", "events.brand_tagline": "Brand tagline", "events.brand_footer": "PDF footer", "events.brand_theme": "PDF theme", "events.brand_logo": "Event logo", "events.branding_use_global": "Use global default theme", "events.branding_save": "Save branding", "events.session_name": "Session name", "events.duration_placeholder": "Duration (min)", "events.max_cars_placeholder": "Max cars (optional)", "events.start_mode": "Start mode", "events.seed_best_laps": "Best laps for seeding", "events.seed_method": "Seed method", "events.seed_method_hint": "How best laps should be evaluated when seedBestLapCount is greater than 0.", "events.seed_method_best_sum": "Best N laps, total", "events.seed_method_average": "Best N laps, average", "events.seed_method_consecutive": "Best N consecutive laps", "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.qual_seed_laps": "Qualifying best laps", "events.qual_seed_laps_hint": "Number of laps used for ranking in each qualifying heat when seed mode is active.", "events.qual_seed_method": "Qualifying seed method", "events.qual_seed_method_hint": "How qualifying heats evaluate laps when best-lap mode is used.", "events.counted_qual_rounds": "Counted qualifying rounds", "events.counted_qual_rounds_hint": "How many qualifying rounds count toward the final ranking.", "events.qual_points_table": "Points table", "events.qual_points_table_hint": "Choose how each qualifying round awards points when Qualifying scoring uses points.", "events.qual_points_rank": "Placement values (1,2,3...)", "events.qual_points_desc": "Descending by field size", "events.qual_points_ifmar": "10-9-8-7-6-5-4-3-2-1", "events.qual_tie_break": "Tie-break", "events.qual_tie_break_hint": "Choose which rule resolves equal results in qualifying standings.", "events.qual_tie_break_rounds": "Compare counted rounds", "events.qual_tie_break_best_lap": "Best single lap", "events.qual_tie_break_best_round": "Best round / heat result", "events.race_preset": "Preset", "events.race_preset_hint": "Quick start for track/class. Apply the preset and then fine tune manually.", "events.apply_preset": "Apply preset", "events.save_preset": "Save club preset", "events.delete_preset": "Delete club preset", "events.preset_name": "Preset name", "events.preset_custom": "Custom / current values", "events.preset_short_technical": "Short technical track 16s", "events.preset_club_qualifying": "Club race qual + finals", "events.preset_ifmar": "IFMAR-style qual/finals", "events.preset_endurance": "Endurance / team race", "events.tie_break_note": "Tie-break", "events.counted_rounds_label": "Counted rounds", "events.tie_break_won": "Won against", "events.tie_break_lost": "Lost against", "events.invalid_recent": "Latest hit invalid", "timing.invalid_manual": "Manually invalidated", "timing.invalidate_last_lap": "Invalidate last lap", "events.cars_per_final": "Drivers per final", "events.cars_per_final_hint": "Maximum number of drivers in each A/B/C final.", "events.final_legs": "Final heats per main", "events.final_legs_hint": "How many final legs should be generated per main.", "events.counted_final_legs": "Counted final heats", "events.counted_final_legs_hint": "How many final legs count in the combined final standings.", "events.final_duration": "Final duration (min)", "events.final_duration_hint": "Length of each final leg in minutes.", "events.final_start_mode": "Final start", "events.final_start_mode_hint": "Start mode for finals, often position start.", "events.bump_count": "Bump-up per main", "events.bump_count_hint": "How many drivers can move up from a lower final into the next main.", "events.save_race_format": "Save race format", "events.open_grid": "Grid", "events.grid_editor": "Grid editor", "events.grid_editor_hint": "Drag drivers up or down to change the manual start order for position start.", "events.grid_reset": "Reset from participant list", "events.grid_lock": "Lock grid", "events.grid_unlock": "Unlock grid", "events.grid_locked": "Manual grid is locked against auto-reseed.", "events.grid_unlocked": "Grid follows auto seed/reseed until you lock it.", "events.grid_empty": "No grid available to edit yet.", "events.print_heat_sheet": "Print heat sheet", "events.export_heat_sheet": "Export heat sheet", "events.pdf_heat_sheet": "PDF heat sheet", "events.free_practice_note": "Free Practice shows rolling lap times and is not used for seeding.", "events.open_practice_note": "Open Practice shows all incoming transponders live. If no driver matches, only the transponder is shown.", "events.generate_qualifying": "Generate qualifying from practice", "events.clear_generated_qualifying": "Clear generated qualifying", "events.generate_finals": "Generate finals from qualifying", "events.clear_generated_finals": "Clear generated finals", "events.apply_bumps": "Apply bump-ups", "events.practice_standings": "Practice standings", "events.qualifying_standings": "Qualifying standings", "events.final_standings": "Final standings", "events.generated_qualifying": "Qualifying heats generated from standings.", "events.finals_generated": "Finals generated from standings.", "events.bumps_applied": "Bump-ups applied to the next main.", "events.no_bumps_applied": "No bump-ups could be applied yet.", "events.no_practice_results": "No practice results yet.", "events.no_qualifying_results": "No qualifying results yet.", "events.no_final_results": "No final results yet.", "events.final_matrix": "Final matrix", "events.print_startlists": "Print start lists", "events.print_results": "Print results", "events.pdf_startlists": "PDF start lists", "events.pdf_results": "PDF results", "events.reserved_slot": "Reserved bump slot", "events.position_grid": "Grid / start order", "events.start_lists": "Start lists", "events.no_final_matrix": "No finals generated yet.", "events.results_overview": "Results overview", "events.main": "Main", "events.slot": "Slot", "events.leg_status": "Leg status", "events.source_for_finals": "Source for finals", "events.finals_from_qualifying": "Qualifying standings", "events.finals_from_practice": "Practice standings", "events.finals_source_hint": "Choose whether finals should be seeded from practice or qualifying.", "events.follow_up_sec": "Follow-up time (sec)", "events.follow_up_sec_hint": "Extra time after race duration so the last cars can finish before the session closes.", "events.min_lap_time": "Min lap time (sec)", "events.min_lap_time_hint": "Laps faster than this are ignored as shortcuts or false hits.", "events.max_lap_time": "Max lap time (sec)", "events.max_lap_time_hint": "Laps slower than this are not counted as valid laps and reset the lap base for the next lap.", "events.race_driver_scope": "Race mode uses all drivers in the event class unless a session has its own participant list.", "events.reserve_bump_slots_hint": "Reserve empty slots in higher finals so bumped drivers can be inserted without overwriting seeded spots.", "events.team_race": "Team Race", "events.team_race_intro": "Create endurance teams. All passings from the team's drivers or cars are added to the team's total laps in Team Race sessions.", "events.team_name": "Team name", "events.add_team": "Add team", "events.teams": "Teams", "events.team_drivers": "Team drivers", "events.team_cars": "Team cars", "events.team_hint": "Select at least one driver or car per team. Team Race sessions sum the team's total laps across the whole race duration, for example 4 hours.", "events.team_steps": "1. Enter the team name. 2. Tick drivers and/or cars below. 3. Click Add team. 4. Use Edit team for later changes.", "events.team_form_drivers": "Select team drivers before saving the team.", "events.team_form_cars": "Select team cars before saving the team.", "events.team_driver_fallback": "No drivers matched the race class or participant list. Showing all drivers as fallback.", "events.no_teams": "No teams created yet.", "events.team_standings": "Team standings", "events.no_team_results": "No team results yet.", "events.edit_team": "Edit team", "events.team_stint_log": "Stint and driver-change log", "events.team_report": "Team report", "events.print_team_results": "Print team report", "events.pdf_team_results": "PDF team report", "events.add_session": "Add Session", "events.set_active": "Set Active", "events.assignments": "Assignments", "events.na": "n/a", "events.sponsor_tools": "Sponsor Event Tools", "events.qual_rounds": "Qualification rounds", "events.heat_rounds": "Heat rounds", "events.final_rounds": "Final rounds", "events.round_duration": "Round duration (min)", "events.create_rounds": "Create Rounds", "events.tp_rule": "Same transponder can be reused across sessions (Heat 1 -> Heat 2 -> Final 1). In a running session, each active car must have a unique transponder.", "events.assign_title": "Track Assignments (Driver -> Car)", "events.assign": "Assign", "events.auto_assign": "Auto Assign Selected Session", "events.clear_assign": "Clear Selected Session", "events.no_assignments": "No assignments", "events.duplicate_car": "That car is already assigned in this session.", "events.duplicate_driver": "That driver is already assigned in this session.", "events.duplicate_tp": "Duplicate transponder in same session is not allowed. Reuse in next heat/final.", "timing.decoder_connection": "Decoder Connection", "timing.connect": "Connect Decoder", "timing.disconnect": "Disconnect", "timing.simulate": "Simulate Passing", "timing.status": "Status", "timing.connected": "Connected", "timing.disconnected": "Disconnected", "timing.last_message": "Last message", "timing.control": "Session Control", "timing.speaker_panel": "Speaker panel", "timing.speaker_panel_hint": "These toggles enable or disable cues live for the current session and overlay without leaving Timing.", "timing.select_session": "Select session", "timing.set_active": "Set Active", "timing.start": "Start", "timing.stop": "Stop", "timing.reset": "Reset Data", "timing.total_passings": "Total passings", "timing.started": "Started", "timing.remaining": "Countdown", "timing.elapsed": "Elapsed", "timing.race_finished": "Race is finished", "timing.follow_up": "Follow-up", "timing.follow_up_active": "Follow-up active", "timing.no_active": "No active session selected.", "timing.leaderboard": "Live Leaderboard", "timing.recent_passings": "Recent Passings", "timing.no_laps": "No laps yet.", "timing.no_session_selected": "No session selected.", "timing.no_passings": "No passings recorded.", "timing.details": "Details", "timing.add_driver": "Add driver", "timing.add_car": "Add car", "timing.quick_add_hint": "Quick-register transponder", "timing.quick_add_title": "Quick add", "timing.quick_add_driver_title": "Add driver from transponder", "timing.quick_add_car_title": "Add car from transponder", "timing.open_overlay": "Open overlay", "timing.open_speaker_overlay": "Speaker overlay", "timing.open_results_overlay": "Results overlay", "timing.open_tv_overlay": "TV overlay", "timing.open_team_overlay": "Team overlay", "timing.close_details": "Close", "timing.detail_title": "Leaderboard details", "timing.lap_history": "Lap history", "timing.no_lap_history": "No laps to show.", "timing.manual_corrections": "Manual corrections", "timing.lap_adjustment": "Lap adjustment", "timing.time_penalty": "Time penalty", "timing.penalty_add_lap": "+1 lap", "timing.penalty_remove_lap": "-1 lap", "timing.penalty_add_sec": "+1 sec", "timing.penalty_add_5sec": "+5 sec", "timing.penalty_remove_sec": "-1 sec", "timing.penalty_reset": "Reset correction", "timing.restore_last_invalid": "Restore latest manually invalidated lap", "timing.no_manual_invalid": "No manually invalidated lap was found.", "timing.valid_passing": "Valid lap", "timing.invalid_short": "Short lap", "timing.invalid_long": "Over max lap", "judging.title": "Judging view", "judging.active_session": "Active session", "judging.no_active_session": "No active session selected.", "judging.select_competitor": "Select driver or team", "judging.manual_actions": "Manual actions", "judging.action_log": "Judging log", "judging.no_action_log": "No manual actions registered yet.", "judging.selected_none": "No row selected.", "judging.restore_done": "The latest manually invalidated lap was restored.", "judging.filter_competitors": "Filter rows", "judging.filter_log": "Filter log", "judging.filter_all": "All", "judging.filter_invalid": "Invalid", "judging.filter_corrected": "Corrected", "judging.filter_team": "Team race", "judging.filter_log_corrections": "Corrections", "judging.filter_log_invalidations": "Invalidate/restore", "judging.filter_log_undo": "Undo", "judging.export_log": "Export judging log", "judging.undo_last": "Undo latest", "judging.undo_action": "Undo", "judging.undo_done": "The latest manual action was undone.", "judging.no_undo": "No action to undo.", "timing.total_time": "Total time", "timing.clear_confirm": "Clear all timing data for this session?", "timing.prompt_transponder": "Transponder", "timing.first_crossing_start": "First crossing set personal start", "timing.seeding_mode": "Seeding", "timing.position_grid_hint": "The grid shows the start order for position start in the active session.", "settings.decoder": "Decoder", "settings.auto_reconnect": "Auto reconnect", "settings.save": "Save", "settings.connect_now": "Connect Now", "settings.expected_json": "Expected AMMC JSON format", "settings.managed_ammc": "Managed AMMC", "settings.managed_ammc_sub": "Start local AMMC from the backend on this machine.", "settings.enable_managed": "Enable managed AMMC", "settings.auto_start_ammc": "Auto-start AMMC when backend starts", "settings.decoder_host": "Decoder IP / host", "settings.ws_port": "AMMC WebSocket port", "settings.executable_path": "AMMC executable", "settings.working_dir": "Working directory (optional)", "settings.extra_args": "Extra arguments (optional)", "settings.save_ammc": "Save AMMC", "settings.start_ammc": "Start AMMC", "settings.stop_ammc": "Stop AMMC", "settings.refresh_ammc": "Refresh status", "settings.ammc_status": "AMMC status", "settings.running": "Running", "settings.stopped": "Stopped", "settings.server_platform": "Server OS", "settings.pid": "PID", "settings.started_at": "Started at", "settings.stopped_at": "Stopped at", "settings.last_error": "Last error", "settings.output": "Recent AMMC log", "settings.executable_found": "Executable found", "settings.executable_missing": "Executable missing", "settings.bundled_hint": "The bundled app path is used automatically when present.", "settings.use_server_ws": "Use server WS URL", "settings.audio": "Audio", "settings.audio_enabled": "Enable browser audio", "settings.speaker_passing_cue": "Speaker cue on passing", "settings.speaker_leader_cue": "Speaker cue on new leader", "settings.speaker_finish_cue": "Speaker cue on finish", "settings.speaker_bestlap_cue": "Speaker cue on new best lap", "settings.speaker_top3_cue": "Speaker cue on top 3 change", "settings.speaker_start_cue": "Speaker cue on session start", "settings.passing_sound": "Passing sound", "settings.passing_sound_off": "Off", "settings.passing_sound_beep": "Beep", "settings.passing_sound_name": "Speak driver name", "settings.finish_voice": "Play finish siren", "settings.test_audio": "Test audio", "settings.audio_note": "Browsers usually require a click first before sound/speech is allowed.", "settings.branding": "Club Info / PDF", "settings.club_name": "Club name", "settings.club_tagline": "Club tagline", "settings.pdf_footer": "PDF footer", "settings.pdf_theme": "PDF theme", "settings.pdf_theme_classic": "Classic", "settings.pdf_theme_minimal": "Minimal", "settings.pdf_theme_motorsport": "Motorsport", "settings.logo": "Logo / overlay", "settings.logo_upload": "Upload logo", "settings.logo_clear": "Clear logo", "settings.logo_note": "The logo is shown in overlay. PDF export attempts to embed the logo automatically via the backend.", "settings.storage": "Storage", "settings.race_presets": "Club presets", "settings.race_presets_note": "Export or import local club presets between installations.", "settings.export_presets": "Export presets", "settings.import_presets": "Import presets", "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.brand": "Brand", "table.class": "Class", "table.transponder": "Transponder", "table.delete": "Delete", "table.car": "Car", "table.date": "Date", "table.mode": "Mode", "table.start_mode": "Start", "table.seeding": "Seeding", "table.score": "Score", "table.session": "Session", "table.type": "Type", "table.duration": "Duration", "table.status": "Status", "table.time": "Time", "table.driver": "Driver", "table.loop": "Loop", "table.strength": "Strength", "table.pos": "Pos", "table.laps": "Laps", "table.last_lap": "Last Lap", "table.best_lap": "Best Lap", "table.gap": "Gap", "table.event": "Event", "table.result": "Result", "table.lap": "Lap", "table.leader_gap": "Leader gap", "table.ahead_gap": "Gap ahead", "table.own_delta": "Own delta", "common.delete": "Delete", "common.cancel": "Cancel", "common.save": "Save", "common.edit": "Edit", "common.unknown_driver": "Unknown Driver", "common.unknown_car": "Unknown Car", "common.unknown": "Unknown", "common.unassigned_driver": "Unassigned Driver", "common.driver_car": "Driver Car", "common.unknown_event": "Unknown Event", "common.no_rows": "No rows", "common.no_entries": "No entries.", "status.ready": "ready", "status.running": "running", "status.finished": "finished", "status.leader": "LEADER", "status.seeded": "SEED", "status.free_practice": "FREE", "status.open_practice": "OPEN", "session.open_practice": "open practice", "session.free_practice": "free practice", "session.practice": "practice", "session.qualification": "qualification", "session.heat": "heat", "session.final": "final", "session.team_race": "team race", "validation.no_assignments": "No driver/car assignments in this session.", "validation.missing_tp": "One or more assigned cars are missing transponder ID.", "validation.duplicate_tp": "Duplicate transponder(s) in session: {ids}.", "validation.invalid_date": "Date must be in YYYY-MM-DD format.", "validation.invalid_selection": "Select a valid option.", "validation.required_name": "Name is required.", "validation.required_transponder": "Transponder is required.", "validation.required_date": "Date is required.", "validation.required_duration": "Duration must be at least 1 minute.", "edit.class_name": "Edit class name", "edit.driver_name": "Edit driver name", "edit.driver_class": "Edit driver class", "edit.new_driver_name": "New driver name", "edit.driver_transponder": "Edit personal transponder (can be empty)", "edit.car_name": "Edit car name", "edit.new_car_name": "New car name", "edit.car_transponder": "Edit car transponder", "edit.event_name": "Edit event name", "edit.event_date": "Edit event date (YYYY-MM-DD)", "guide.title": "Guide and Documentation", "guide.intro": "Step-by-step setup for sponsor events (10 drivers, 4 cars), normal race mode, and AMMC on Windows/Linux. The guide also explains where Managed AMMC actually runs.", "guide.sponsor_title": "Create Sponsor Event: 10 drivers, 4 cars", "guide.sponsor_1": "1. Add 4 cars in Cars with unique transponder IDs. You can also store brand/model in the brand field.", "guide.sponsor_2": "2. Add 10 drivers in Drivers. You can also store team/brand in the brand field and filter by it later.", "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. The brand field can be used for team, sponsor or car brand.", "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.race_format_8": "Follow-up time adds an extra collection period after the scheduled race time before the heat is actually closed.", "guide.race_format_9": "Min lap time filters out shortcuts and false hits. Example: on a 16-second track you can set 11 seconds as the minimum.", "guide.race_format_10": "Max lap time stops long false laps from counting and is also used to split stints and improve driver statistics. Example: 60 seconds.", "guide.race_format_11": "Preset lets you quickly fill the race format with sensible defaults for a short technical track, club race, IFMAR-like setup or endurance.", "guide.race_format_12": "You can apply a preset and then adjust individual fields manually before saving the race format.", "guide.race_format_13": "Save club preset stores your own local race formats so you can reuse them on the same installation without rebuilding everything each time.", "guide.race_format_14": "Club presets can also be exported and imported from Settings if you want to move them between different servers or laptops.", "guide.free_practice_title": "Free Practice", "guide.free_practice_1": "Use the free practice session type when you only want to show live lap times.", "guide.free_practice_2": "Free Practice does not affect seeding for qualifying or finals.", "guide.free_practice_3": "The leaderboard shows laps, last lap, best lap, gap to the car ahead and your own delta versus the previous lap.", "guide.open_practice_title": "Open Practice", "guide.open_practice_1": "Use Open Practice when you want the system to simply list every transponder that comes in.", "guide.open_practice_2": "If the transponder does not match a registered driver, the transponder number is shown as the name.", "guide.open_practice_3": "Open Practice does not affect seeding, qualifying or finals.", "guide.team_title": "Team Race / Endurance", "guide.team_1": "Go to Race Setup and create a race in the correct class.", "guide.team_2": "Open Manage and go to the Teams section.", "guide.team_3": "Enter the team name and tick drivers and/or cars in the same team block before you click Add team.", "guide.team_4": "After the team is created, click Edit team to change drivers or cars.", "guide.team_5": "Create a session with type Team Race and set the time, for example 240 minutes for 4 hours.", "guide.team_6": "Start the session in Timing. All passings from the team's members are added to the team's total laps.", "guide.validation_title": "Invalid laps, follow-up and manual corrections", "guide.validation_1": "Recent Passings now shows both valid and invalid laps. Short laps are marked as Short lap and long laps as Over max lap.", "guide.validation_2": "Invalid short laps under the minimum threshold do not count in the leaderboard or statistics.", "guide.validation_3": "Invalid long laps over the maximum threshold do not count as laps, but they can reset the lap base so the next valid lap starts correctly.", "guide.validation_4": "When the scheduled time ends, the session can enter Follow-up active if Follow-up time has been configured in race format or on the session.", "guide.validation_5": "In Timing -> Details you can apply +1/-1 lap and +1/+5/-1 seconds as manual corrections. The leaderboard updates immediately.", "guide.validation_6": "In the same detail view you can also manually invalidate the latest counted lap if you need to remove a false hit afterwards.", "guide.validation_7": "The Judging menu collects the same corrections in a separate work view with leaderboard, lap history and a judging log for the current session.", "guide.validation_8": "The Judging view can filter invalid rows, corrected rows or team race rows, export the judging log and undo multiple recent manual actions via undo buttons.", "guide.qualifying_title": "Seeding, points tables and tie-break", "guide.qualifying_1": "Practice and qualifying can now use three seed methods: best N laps as total, best N laps as average or best N consecutive laps.", "guide.qualifying_2": "Race format controls both Qualifying seed laps and Qualifying seed method when new qualifying heats are generated from practice or the participant list.", "guide.qualifying_3": "Qualifying scoring can combine points mode with a points table: placement values, descending by field size or IFMAR 10-9-8-7-6-5-4-3-2-1.", "guide.qualifying_4": "Qualifying tie-break can now be resolved by counted rounds, best single lap or best round / heat result.", "guide.qualifying_5": "The leaderboard now shows seeded results in the correct format, for example 3/00:48.321, 3 avg 16.107 or 3 con 00:49.005.", "guide.dashboard_title": "Schedule drift on overview", "guide.dashboard_1": "Overview now shows the difference between planned time and actual elapsed time for all started sessions.", "guide.dashboard_2": "Planned time includes session duration plus follow-up. Actual time uses real time from start to stop, or the current time if the heat is still running.", "guide.dashboard_3": "That makes it easier to see whether the race day is running ahead or behind schedule directly from the dashboard.", "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 RaceController Overlay", "overlay.mode_leaderboard": "Leaderboard", "overlay.mode_speaker": "Speaker", "overlay.mode_results": "Results", "overlay.mode_tv": "TV", "overlay.mode_team": "Team", "overlay.fastest_lap": "Fastest Lap", "overlay.fullscreen": "Fullscreen", "overlay.leaderboard_live": "Live leaderboard", "overlay.rotating_panel": "Display panel", "overlay.next_predicted_lap": "Next lap", "overlay.event_markers": "Event markers", "overlay.team_battle": "Team battle", "overlay.active_member": "Active driver/car", "overlay.top_three": "Top 3", "guide.host_title": "How Managed AMMC Runs", "guide.host_1": "1. AMMC always runs on the same machine where `npm start` or `node server.js` is running.", "guide.host_2": "2. If you only browse from a laptop/browser, no process is started there. The browser only controls the backend over HTTP.", "guide.host_3": "3. Run the backend on Linux -> the Linux binary is used: `AMMC/linux_x86-64/ammc-amb`.", "guide.host_4": "4. Run the backend on Windows -> the Windows binary is used: `AMMC/windows64/ammc-amb.exe`.", "guide.host_5": "5. The `AMMC executable` field in Settings is a path on the backend host, not on the client laptop.", "guide.windows_title": "Windows + AMMC + npm", "guide.windows_1": "1. Install Node.js LTS and Visual C++ Runtime 2015-2022 on the host that will run `live_event`.", "guide.windows_2": "2. Default Managed AMMC binary on a Windows host: `AMMC/windows64/ammc-amb.exe`.", "guide.windows_3": "3. Run `npm start` on the Windows host. Managed AMMC starts there if enabled.", "guide.windows_4": "4. In Settings: `Decoder IP / host` = decoder IP, for example `192.168.1.11`.", "guide.windows_5": "5. In app: `Backend URL` = http://: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", "tv", "team"].includes(String(urlParams.get("overlayMode") || "").toLowerCase()) ? String(urlParams.get("overlayMode")).toLowerCase() : "leaderboard"; const state = loadState(); let currentView = overlayMode ? "overlay" : "dashboard"; let wsClient = null; let reconnectTimer = null; let backendSyncTimer = null; let appVersionPollTimer = null; let baselineAppVersion = ""; let selectedClassEditId = null; let selectedLeaderboardKey = null; let selectedGridSessionId = null; let selectedDriverEditId = null; let selectedCarEditId = null; let selectedEventEditId = null; let selectedSessionEditId = null; let selectedTeamEditId = null; let selectedJudgeKey = null; let judgingCompetitorFilter = "all"; let judgingLogFilter = "all"; let quickAddDraft = null; let driverBrandFilter = ""; let carBrandFilter = ""; let overlaySyncTimer = null; let overlayRotationTimer = null; let overlayLiveRefreshTimer = null; let overlayRotationIndex = 0; let overlayEvents = []; let lastOverlayLeaderKeyBySession = {}; let lastOverlayTop3BySession = {}; let lastOverlayBestLapByKey = {}; let activeModalEscapeHandler = null; const backend = { available: false, lastSyncAt: null, lastError: "", }; let audioCtx = null; let lastFinishAnnouncementSessionId = null; const ammc = { config: createDefaultAmmcConfig(), status: null, lastError: "", loaded: false, }; const dom = { nav: document.getElementById("nav"), view: document.getElementById("view"), pageTitle: document.getElementById("pageTitle"), pageSubtitle: document.getElementById("pageSubtitle"), activeSessionChip: document.getElementById("activeSessionChip"), connectionBadge: document.getElementById("connectionBadge"), clock: document.getElementById("clock"), }; init(); async function init() { document.body.classList.toggle("overlay-mode", overlayMode); seedDefaultData(); await hydrateFromBackend(); await loadAmmcConfigFromBackend(); renderNav(); renderView(); setupLanguageControl(); updateHeaderState(); updateConnectionBadge(); tickClock(); setInterval(tickClock, 1000); startAppVersionPolling(); if (overlayMode) { startOverlaySync(); startOverlayRotation(); startOverlayLiveRefresh(); if (state.settings.wsUrl) { connectDecoder(); } } } function seedDefaultData() { if (!state.classes.length) { state.classes.push( { id: uid("class"), name: "Stock 17.5T" }, { id: uid("class"), name: "Modified" } ); } if (!state.settings.wsUrl) { state.settings.wsUrl = "ws://127.0.0.1:9000"; } if (!state.settings.backendUrl) { state.settings.backendUrl = getDefaultBackendUrl(); } if (!state.settings.language) { state.settings.language = DEFAULT_LANGUAGE; } if (typeof state.settings.audioEnabled !== "boolean") { state.settings.audioEnabled = true; } if (!state.settings.passingSoundMode) { state.settings.passingSoundMode = "beep"; } if (typeof state.settings.finishVoiceEnabled !== "boolean") { state.settings.finishVoiceEnabled = true; } if (typeof state.settings.speakerPassingCueEnabled !== "boolean") { state.settings.speakerPassingCueEnabled = false; } if (typeof state.settings.speakerLeaderCueEnabled !== "boolean") { state.settings.speakerLeaderCueEnabled = true; } if (typeof state.settings.speakerFinishCueEnabled !== "boolean") { state.settings.speakerFinishCueEnabled = true; } if (!state.settings.clubName) { state.settings.clubName = "JMK RB RaceController"; } if (!state.settings.clubTagline) { state.settings.clubTagline = "RC Timing System"; } if (!state.settings.pdfFooter) { state.settings.pdfFooter = "Generated by JMK RB RaceController"; } 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 = ""; } if (!Array.isArray(state.settings.racePresets)) { state.settings.racePresets = []; } state.drivers = state.drivers.map((driver) => normalizeDriver(driver)).filter((driver) => driver.name); state.cars = state.cars.map((car) => normalizeCar(car)).filter((car) => car.name); 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 RaceController", clubTagline: parsed.settings?.clubTagline || "RC Timing System", pdfFooter: parsed.settings?.pdfFooter || "Generated by JMK RB RaceController", pdfTheme: parsed.settings?.pdfTheme || "classic", logoDataUrl: parsed.settings?.logoDataUrl || "", racePresets: Array.isArray(parsed.settings?.racePresets) ? parsed.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name) : [], }, 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 RaceController", clubTagline: "RC Timing System", pdfFooter: "Generated by JMK RB RaceController", pdfTheme: "classic", logoDataUrl: "", racePresets: [], }, 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.map((driver) => normalizeDriver(driver)), cars: state.cars.map((car) => normalizeCar(car)), 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 || []).map((driver) => normalizeDriver(driver)).filter((driver) => driver.name); state.cars = (persisted.cars || []).map((car) => normalizeCar(car)).filter((car) => car.name); 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 RaceController", clubTagline: persisted.settings?.clubTagline || state.settings.clubTagline || "RC Timing System", pdfFooter: persisted.settings?.pdfFooter || state.settings.pdfFooter || "Generated by JMK RB RaceController", pdfTheme: persisted.settings?.pdfTheme || state.settings.pdfTheme || "classic", logoDataUrl: persisted.settings?.logoDataUrl || state.settings.logoDataUrl || "", racePresets: Array.isArray(persisted.settings?.racePresets) ? persisted.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name) : Array.isArray(state.settings?.racePresets) ? state.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name) : [], }; } function normalizeSession(session) { return { ...session, startMode: session?.startMode || "mass", staggerGapSec: Number(session?.staggerGapSec || 5) || 5, seedBestLapCount: Math.max(0, Number(session?.seedBestLapCount || 0) || 0), seedMethod: ["best_sum", "average", "consecutive"].includes(String(session?.seedMethod || "").toLowerCase()) ? String(session.seedMethod).toLowerCase() : "best_sum", followUpSec: Math.max(0, Number(session?.followUpSec || 0) || 0), followUpStartedAt: Number(session?.followUpStartedAt || 0) || null, driverIds: Array.isArray(session?.driverIds) ? session.driverIds : [], manualGridIds: Array.isArray(session?.manualGridIds) ? session.manualGridIds : [], gridCustomized: Boolean(session?.gridCustomized), reservedBumpSlots: Math.max(0, Number(session?.reservedBumpSlots || 0) || 0), generated: Boolean(session?.generated), assignments: Array.isArray(session?.assignments) ? session.assignments : [], }; } function normalizeRaceTeam(team) { return { id: String(team?.id || uid("team")), name: String(team?.name || "").trim(), driverIds: Array.isArray(team?.driverIds) ? team.driverIds.filter(Boolean) : [], carIds: Array.isArray(team?.carIds) ? team.carIds.filter(Boolean) : [], }; } function normalizeStoredRacePreset(preset) { return { id: String(preset?.id || uid("preset")), name: String(preset?.name || "").trim(), values: preset?.values && typeof preset.values === "object" ? { ...preset.values } : {}, }; } function getRaceFormatPresets() { const builtins = [ { id: "custom", label: t("events.preset_custom"), values: {}, }, { id: "short_technical", label: t("events.preset_short_technical"), values: { qualifyingScoring: "points", qualifyingRounds: 3, carsPerHeat: 6, qualDurationMin: 5, qualStartMode: "staggered", qualSeedLapCount: 3, qualSeedMethod: "best_sum", countedQualRounds: 2, qualifyingPointsTable: "rank_low", qualifyingTieBreak: "best_lap", carsPerFinal: 8, finalLegs: 3, countedFinalLegs: 2, finalDurationMin: 5, finalStartMode: "position", followUpSec: 10, minLapMs: 11000, maxLapMs: 60000, bumpCount: 0, }, }, { id: "club_qualifying", label: t("events.preset_club_qualifying"), values: { qualifyingScoring: "points", qualifyingRounds: 4, carsPerHeat: 8, qualDurationMin: 5, qualStartMode: "staggered", qualSeedLapCount: 3, qualSeedMethod: "best_sum", countedQualRounds: 2, qualifyingPointsTable: "rank_low", qualifyingTieBreak: "rounds", carsPerFinal: 8, finalLegs: 3, countedFinalLegs: 2, finalDurationMin: 5, finalStartMode: "position", followUpSec: 15, minLapMs: 12000, maxLapMs: 60000, bumpCount: 0, }, }, { id: "ifmar", label: t("events.preset_ifmar"), values: { qualifyingScoring: "points", qualifyingRounds: 5, carsPerHeat: 10, qualDurationMin: 5, qualStartMode: "staggered", qualSeedLapCount: 3, qualSeedMethod: "best_sum", countedQualRounds: 3, qualifyingPointsTable: "ifmar", qualifyingTieBreak: "best_lap", carsPerFinal: 10, finalLegs: 3, countedFinalLegs: 2, finalDurationMin: 5, finalStartMode: "position", followUpSec: 15, minLapMs: 12000, maxLapMs: 70000, bumpCount: 0, }, }, { id: "endurance", label: t("events.preset_endurance"), values: { qualifyingScoring: "best", qualifyingRounds: 1, carsPerHeat: 12, qualDurationMin: 10, qualStartMode: "mass", qualSeedLapCount: 0, qualSeedMethod: "best_sum", countedQualRounds: 1, qualifyingPointsTable: "rank_low", qualifyingTieBreak: "best_round", carsPerFinal: 12, finalLegs: 1, countedFinalLegs: 1, finalDurationMin: 240, finalStartMode: "mass", followUpSec: 60, minLapMs: 10000, maxLapMs: 120000, bumpCount: 0, }, }, ]; const customPresets = Array.isArray(state.settings?.racePresets) ? state.settings.racePresets .map((preset) => normalizeStoredRacePreset(preset)) .filter((preset) => preset.name) .map((preset) => ({ id: preset.id, label: preset.name, custom: true, values: { ...preset.values }, })) : []; return [...builtins, ...customPresets]; } function applyRaceFormatPreset(event, presetId) { const preset = getRaceFormatPresets().find((item) => item.id === presetId); if (!preset || preset.id === "custom") { event.raceConfig.presetId = "custom"; return; } Object.assign(event.raceConfig, preset.values, { presetId: preset.id }); } function buildRaceFormatConfigFromForm(form, event) { return { presetId: String(form.get("presetId") || "custom").trim() || "custom", 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")), qualSeedLapCount: Math.max(0, Number(form.get("qualSeedLapCount") || 2) || 0), qualSeedMethod: ["best_sum", "average", "consecutive"].includes(String(form.get("qualSeedMethod") || "").toLowerCase()) ? String(form.get("qualSeedMethod")).toLowerCase() : "best_sum", countedQualRounds: Math.max(1, Number(form.get("countedQualRounds") || 1) || 1), qualifyingPointsTable: ["rank_low", "field_desc", "ifmar"].includes(String(form.get("qualifyingPointsTable") || "").toLowerCase()) ? String(form.get("qualifyingPointsTable")).toLowerCase() : "rank_low", qualifyingTieBreak: ["rounds", "best_lap", "best_round"].includes(String(form.get("qualifyingTieBreak") || "").toLowerCase()) ? String(form.get("qualifyingTieBreak")).toLowerCase() : "rounds", 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")), followUpSec: Math.max(0, Number(form.get("followUpSec") || 0) || 0), minLapMs: Math.max(0, Math.round((Number(form.get("minLapSec") || 0) || 0) * 1000)), maxLapMs: Math.max(1000, Math.round((Number(form.get("maxLapSec") || 60) || 60) * 1000)), bumpCount: Math.max(0, Number(form.get("bumpCount") || 0) || 0), reserveBumpSlots: form.get("reserveBumpSlots") === "on", driverIds: event.raceConfig.driverIds || [], participantsConfigured: event.raceConfig.participantsConfigured !== false, finalsSource: String(form.get("finalsSource") || "qualifying") === "practice" ? "practice" : "qualifying", teams: getEventTeams(event), }; } function normalizeDriver(driver) { const item = driver && typeof driver === "object" ? driver : {}; return { id: item.id || uid("driver"), name: String(item.name || "").trim(), classId: String(item.classId || ""), brand: String(item.brand || "").trim(), transponder: String(item.transponder || "").trim(), }; } function normalizeCar(car) { const item = car && typeof car === "object" ? car : {}; return { id: item.id || uid("car"), name: String(item.name || "").trim(), brand: String(item.brand || "").trim(), transponder: String(item.transponder || "").trim(), }; } function normalizeEvent(event) { return { ...event, branding: normalizeBrandingConfig(event?.branding), raceConfig: { presetId: String(event?.raceConfig?.presetId || "custom").trim() || "custom", 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"), qualSeedLapCount: Math.max(0, Number(event?.raceConfig?.qualSeedLapCount || 2) || 2), qualSeedMethod: ["best_sum", "average", "consecutive"].includes(String(event?.raceConfig?.qualSeedMethod || "").toLowerCase()) ? String(event.raceConfig.qualSeedMethod).toLowerCase() : "best_sum", countedQualRounds: Math.max(1, Number(event?.raceConfig?.countedQualRounds || 1) || 1), qualifyingPointsTable: ["rank_low", "field_desc", "ifmar"].includes(String(event?.raceConfig?.qualifyingPointsTable || "").toLowerCase()) ? String(event.raceConfig.qualifyingPointsTable).toLowerCase() : "rank_low", qualifyingTieBreak: ["rounds", "best_lap", "best_round"].includes(String(event?.raceConfig?.qualifyingTieBreak || "").toLowerCase()) ? String(event.raceConfig.qualifyingTieBreak).toLowerCase() : "rounds", 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"), followUpSec: Math.max(0, Number(event?.raceConfig?.followUpSec || 0) || 0), minLapMs: Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0), maxLapMs: Math.max(0, Number(event?.raceConfig?.maxLapMs || 60000) || 60000), bumpCount: Math.max(0, Number(event?.raceConfig?.bumpCount || 0) || 0), reserveBumpSlots: event?.raceConfig?.reserveBumpSlots !== false, driverIds: Array.isArray(event?.raceConfig?.driverIds) ? event.raceConfig.driverIds : [], participantsConfigured: Boolean(event?.raceConfig?.participantsConfigured), finalsSource: event?.raceConfig?.finalsSource === "practice" ? "practice" : "qualifying", teams: Array.isArray(event?.raceConfig?.teams) ? event.raceConfig.teams.map((team) => normalizeRaceTeam(team)).filter((team) => team.name) : [], }, }; } function normalizeBrandingConfig(branding) { const theme = ["classic", "minimal", "motorsport"].includes(String(branding?.pdfTheme || "").toLowerCase()) ? String(branding.pdfTheme).toLowerCase() : ""; return { brandName: String(branding?.brandName || "").trim(), brandTagline: String(branding?.brandTagline || "").trim(), pdfFooter: String(branding?.pdfFooter || "").trim(), pdfTheme: theme, logoDataUrl: String(branding?.logoDataUrl || "").trim(), }; } function resolveEventBranding(event) { const local = normalizeBrandingConfig(event?.branding); return { brandName: local.brandName || state.settings.clubName || "JMK RB", brandTagline: local.brandTagline || state.settings.clubTagline || "Live Event", pdfFooter: local.pdfFooter || state.settings.pdfFooter || "Generated by JMK RB RaceController", pdfTheme: local.pdfTheme || state.settings.pdfTheme || "classic", logoDataUrl: local.logoDataUrl || state.settings.logoDataUrl || "", }; } function scheduleBackendSync() { clearTimeout(backendSyncTimer); backendSyncTimer = setTimeout(() => { syncStateToBackend(); }, 350); } async function syncStateToBackend() { try { const res = await fetch(`${getBackendUrl()}/api/state`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(buildPersistableState()), }); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } backend.available = true; backend.lastError = ""; backend.lastSyncAt = new Date().toISOString(); } catch (error) { backend.available = false; backend.lastError = t("error.sync_failed", { msg: error instanceof Error ? error.message : String(error) }); } } async function pingBackend() { try { const res = await fetch(`${getBackendUrl()}/api/health`); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } backend.available = true; backend.lastError = ""; } catch (error) { backend.available = false; backend.lastError = t("error.health_failed", { msg: error instanceof Error ? error.message : String(error) }); } } function startAppVersionPolling() { if (!window.location.protocol.startsWith("http")) { return; } clearInterval(appVersionPollTimer); checkAppVersion(); appVersionPollTimer = setInterval(checkAppVersion, 3000); } async function checkAppVersion() { try { const res = await fetch(`${getBackendUrl()}/api/app-version`, { cache: "no-store" }); if (!res.ok) { return; } const payload = await res.json(); const key = `${payload.revision}:${payload.updatedAt}`; if (!baselineAppVersion) { baselineAppVersion = key; return; } if (key !== baselineAppVersion) { window.location.reload(); } } catch { // silent - normal when backend is temporarily unavailable } } function startOverlaySync() { clearInterval(overlaySyncTimer); overlaySyncTimer = setInterval(async () => { await hydrateFromBackend(); if (currentView === "overlay") { renderView(); } }, 800); } function startOverlayRotation() { clearInterval(overlayRotationTimer); overlayRotationTimer = setInterval(() => { overlayRotationIndex = (overlayRotationIndex + 1) % 3; if (currentView === "overlay" && overlayViewMode === "leaderboard") { renderView(); } }, 8000); } function startOverlayLiveRefresh() { clearInterval(overlayLiveRefreshTimer); overlayLiveRefreshTimer = setInterval(() => { if (currentView === "overlay" && ["leaderboard", "tv", "team"].includes(overlayViewMode)) { renderOverlay(); } }, 250); } function renderNav() { if (overlayMode) { dom.nav.innerHTML = ""; return; } dom.nav.innerHTML = ""; NAV_ITEMS.forEach((item) => { const button = document.createElement("button"); button.className = `nav-item ${item.id === currentView ? "active" : ""}`; button.textContent = t(item.titleKey); button.addEventListener("click", () => { currentView = item.id; renderNav(); renderView(); }); dom.nav.appendChild(button); }); } function renderView() { clearModalEscapeHandler(); const navMeta = NAV_ITEMS.find((x) => x.id === currentView); dom.pageTitle.textContent = navMeta ? t(navMeta.titleKey) : ""; dom.pageSubtitle.textContent = navMeta ? t(navMeta.subtitleKey) : ""; const languageLabel = document.getElementById("languageLabel"); if (languageLabel) { languageLabel.textContent = t("ui.language"); } switch (currentView) { case "dashboard": renderDashboard(); break; case "events": renderEvents(); break; case "race_setup": renderRaceSetup(); break; case "classes": renderClasses(); break; case "drivers": renderDrivers(); break; case "cars": renderCars(); break; case "timing": renderTiming(); break; case "judging": renderJudging(); break; case "overlay": renderOverlay(); break; case "settings": renderSettings(); break; case "guide": renderGuide(); break; default: renderDashboard(); } updateHeaderState(); } function clearModalEscapeHandler() { if (activeModalEscapeHandler) { document.removeEventListener("keydown", activeModalEscapeHandler); activeModalEscapeHandler = null; } } function bindModalShell(overlayId, onClose, focusSelector = 'input, select, textarea, button') { const overlay = document.getElementById(overlayId); if (!overlay) { clearModalEscapeHandler(); return; } const focusTarget = overlay.querySelector(focusSelector); window.setTimeout(() => { if (focusTarget instanceof HTMLElement) { focusTarget.focus(); if (focusTarget instanceof HTMLInputElement) { focusTarget.select(); } } }, 0); clearModalEscapeHandler(); activeModalEscapeHandler = (event) => { if (event.key === "Escape") { event.preventDefault(); onClose(); } }; document.addEventListener("keydown", activeModalEscapeHandler); } function setFormError(errorId, message) { const errorNode = document.getElementById(errorId); if (!errorNode) { return; } errorNode.textContent = message || ""; errorNode.hidden = !message; } function updateHeaderState() { const session = getActiveSession(); if (!session) { dom.activeSessionChip.textContent = t("ui.no_active_session"); return; } const event = state.events.find((e) => e.id === session.eventId); dom.activeSessionChip.textContent = `${event?.name || t("ui.event")} • ${getSessionTypeLabel(session.type).toUpperCase()} • ${getStatusLabel( session.status ).toUpperCase()}`; } function updateConnectionBadge() { const isOnline = state.decoder.connected; dom.connectionBadge.textContent = isOnline ? t("ui.decoder_online") : t("ui.decoder_offline"); dom.connectionBadge.className = `badge ${isOnline ? "badge-online" : "badge-offline"}`; } function tickClock() { dom.clock.textContent = new Date().toLocaleString(currentLanguage() === "sv" ? "sv-SE" : "en-US"); const timerState = handleSessionTimerTick(); const active = getActiveSession(); if (currentView === "timing" && active && (active.status === "running" || timerState.changed)) { renderView(); } if (currentView === "dashboard" && timerState.changed) { renderView(); } if (currentView === "overlay" && active) { renderView(); } if (timerState.changed) { updateHeaderState(); } } function ensureAudioContext() { if (!state.settings.audioEnabled) { return null; } const Ctx = window.AudioContext || window.webkitAudioContext; if (!Ctx) { return null; } if (!audioCtx) { audioCtx = new Ctx(); } if (audioCtx.state === "suspended") { audioCtx.resume().catch(() => {}); } return audioCtx; } function playPassingBeep() { const ctx = ensureAudioContext(); if (!ctx) { return; } const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = "square"; osc.frequency.setValueAtTime(1320, ctx.currentTime); gain.gain.setValueAtTime(0.001, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.08, ctx.currentTime + 0.01); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.14); osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + 0.16); } function playFinishSiren() { const ctx = ensureAudioContext(); if (!ctx) { return; } const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = "sawtooth"; gain.gain.setValueAtTime(0.001, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.12, ctx.currentTime + 0.03); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 1.2); osc.frequency.setValueAtTime(720, ctx.currentTime); osc.frequency.linearRampToValueAtTime(1280, ctx.currentTime + 0.28); osc.frequency.linearRampToValueAtTime(720, ctx.currentTime + 0.56); osc.frequency.linearRampToValueAtTime(1280, ctx.currentTime + 0.84); osc.frequency.linearRampToValueAtTime(720, ctx.currentTime + 1.12); osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + 1.22); } function playLeaderCue() { const ctx = ensureAudioContext(); if (!ctx) { return; } const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = "triangle"; gain.gain.setValueAtTime(0.001, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.09, ctx.currentTime + 0.01); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.26); osc.frequency.setValueAtTime(880, ctx.currentTime); osc.frequency.linearRampToValueAtTime(1320, ctx.currentTime + 0.12); osc.frequency.linearRampToValueAtTime(1760, ctx.currentTime + 0.24); osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + 0.28); } function playStartCue() { const ctx = ensureAudioContext(); if (!ctx) { return; } const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = "triangle"; gain.gain.setValueAtTime(0.001, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.08, ctx.currentTime + 0.01); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4); osc.frequency.setValueAtTime(520, ctx.currentTime); osc.frequency.linearRampToValueAtTime(1040, ctx.currentTime + 0.4); osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + 0.42); } function playBestLapCue() { const ctx = ensureAudioContext(); if (!ctx) { return; } const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.type = "sine"; gain.gain.setValueAtTime(0.001, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.07, ctx.currentTime + 0.01); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.22); osc.frequency.setValueAtTime(1540, ctx.currentTime); osc.frequency.linearRampToValueAtTime(1980, ctx.currentTime + 0.2); osc.connect(gain); gain.connect(ctx.destination); osc.start(); osc.stop(ctx.currentTime + 0.24); } function pushOverlayEvent(type, label) { overlayEvents.unshift({ id: uid("overlay"), type, label, ts: Date.now(), }); if (overlayEvents.length > 12) { overlayEvents = overlayEvents.slice(0, 12); } if (overlayMode && currentView === "overlay" && overlayViewMode === "speaker") { if (type === "leader" && state.settings.speakerLeaderCueEnabled) { playLeaderCue(); } else if (type === "passing" && state.settings.speakerPassingCueEnabled) { playPassingBeep(); } else if (type === "finish" && state.settings.speakerFinishCueEnabled) { playFinishSiren(); } else if (type === "start" && state.settings.speakerSessionStartCueEnabled) { playStartCue(); } else if (type === "bestlap" && state.settings.speakerBestLapCueEnabled) { playBestLapCue(); } else if (type === "top3" && state.settings.speakerTop3CueEnabled) { playLeaderCue(); } } } function speakText(text) { if (!state.settings.audioEnabled || !("speechSynthesis" in window) || !text) { return; } const utterance = new SpeechSynthesisUtterance(text); utterance.lang = currentLanguage() === "sv" ? "sv-SE" : "en-US"; utterance.rate = 1; window.speechSynthesis.cancel(); window.speechSynthesis.speak(utterance); } function announcePassing(entry) { if (!state.settings.audioEnabled) { return; } if (state.settings.passingSoundMode === "beep") { playPassingBeep(); return; } if (state.settings.passingSoundMode === "name") { speakText(entry?.displayName || entry?.driverName || t("common.unknown_driver")); } } function announceRaceFinished() { if (!state.settings.audioEnabled || !state.settings.finishVoiceEnabled) { const session = getActiveSession(); if (session) { pushOverlayEvent("finish", `${session.name} • ${t("timing.race_finished")}`); } return; } const session = getActiveSession(); if (session) { pushOverlayEvent("finish", `${session.name} • ${t("timing.race_finished")}`); } playFinishSiren(); } function handleSessionTimerTick() { const active = getActiveSession(); if (!active || active.status !== "running") { return { changed: false }; } if (isUntimedSession(active)) { return { changed: false }; } const timing = getSessionTiming(active); if (timing.remainingMs > 0) { return { changed: false }; } if (Number(active.followUpSec || 0) > 0) { if (!active.followUpStartedAt) { active.followUpStartedAt = Date.now(); saveState(); return { changed: true }; } if (timing.followUpRemainingMs > 0) { return { changed: false }; } } active.status = "finished"; active.endedAt = Date.now(); active.finishedByTimer = true; active.followUpStartedAt = null; if (lastFinishAnnouncementSessionId !== active.id) { announceRaceFinished(); lastFinishAnnouncementSessionId = active.id; } saveState(); return { changed: true }; } function renderDashboard() { const active = getActiveSession(); const schedule = getScheduleDriftSummary(); const totalPassings = Object.values(state.resultsBySession).reduce( (sum, x) => sum + (x.passings?.length || 0), 0 ); const backendUrl = getBackendUrl(); const decoderUrl = state.settings.wsUrl || "-"; const audioProfile = state.settings.passingSoundMode === "name" ? t("settings.passing_sound_name") : state.settings.passingSoundMode === "beep" ? t("settings.passing_sound_beep") : t("settings.passing_sound_off"); dom.view.innerHTML = `
${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.live_board")}

${t("dashboard.live_note")}

${t("dashboard.decoder_feed")} ${state.decoder.connected ? t("timing.connected") : t("timing.disconnected")} ${escapeHtml(decoderUrl)}
${t("dashboard.backend_link")} ${backend.available ? t("settings.online") : t("settings.offline")} ${escapeHtml(backendUrl)}
${t("dashboard.audio_profile")} ${state.settings.audioEnabled ? audioProfile : t("settings.passing_sound_off")} ${state.settings.finishVoiceEnabled ? t("settings.finish_voice") : "-"}
${t("dashboard.schedule_drift")} ${ schedule ? `${schedule.driftMs === 0 ? t("dashboard.on_time") : schedule.driftMs < 0 ? t("dashboard.ahead") : t("dashboard.behind")} ${formatLap(Math.abs(schedule.driftMs))}` : "-" } ${ schedule ? `${t("dashboard.schedule_plan")}: ${formatLap(schedule.plannedMs)} • ${t("dashboard.schedule_actual")}: ${formatLap(schedule.actualMs)}` : "-" }

${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); document.getElementById("disconnectNow")?.addEventListener("click", disconnectDecoder); document.getElementById("openDashboardOverlay")?.addEventListener("click", openOverlayWindow); document.getElementById("dashboardTestAudio")?.addEventListener("click", () => { ensureAudioContext(); playPassingBeep(); if (state.settings.finishVoiceEnabled) { setTimeout(playFinishSiren, 220); } }); } function getScheduleDriftSummary() { const scheduledSessions = state.sessions .filter((session) => Number(session.startedAt || 0) > 0) .sort((left, right) => Number(left.startedAt || 0) - Number(right.startedAt || 0)); if (!scheduledSessions.length) { return null; } const nowTs = Date.now(); const plannedMs = scheduledSessions.reduce( (sum, session) => sum + Math.max(1, Number(session.durationMin || 0) || 0) * 60000 + Math.max(0, Number(session.followUpSec || 0) || 0) * 1000, 0 ); const actualMs = scheduledSessions.reduce((sum, session) => { const startedAt = Number(session.startedAt || 0) || 0; const endedAt = Number(session.endedAt || 0) || (session.status === "finished" ? nowTs : 0); if (!startedAt) { return sum; } const effectiveEnd = endedAt || nowTs; return sum + Math.max(0, effectiveEnd - startedAt); }, 0); return { plannedMs, actualMs, driftMs: actualMs - plannedMs, }; } function statCard(label, value, note) { return `

${label}

${value}

${note}
`; } function renderClasses() { const editingClass = state.classes.find((item) => item.id === selectedClassEditId) || null; dom.view.innerHTML = `

${t("classes.create")}

${t("classes.title")}

${renderTable( [t("table.name"), t("events.actions")], state.classes.map( (c) => ` ${escapeHtml(c.name)} ` ) )}
${ editingClass ? ` ` : "" } `; document.getElementById("classForm")?.addEventListener("submit", (e) => { e.preventDefault(); const form = new FormData(e.currentTarget); state.classes.push({ id: uid("class"), name: String(form.get("name")).trim() }); saveState(); renderView(); }); state.classes.forEach((item) => { document.getElementById(`class-edit-${item.id}`)?.addEventListener("click", () => { selectedClassEditId = item.id; renderView(); }); document.getElementById(`class-delete-${item.id}`)?.addEventListener("click", () => { state.classes = state.classes.filter((x) => x.id !== item.id); saveState(); renderView(); }); }); document.getElementById("classEditCancel")?.addEventListener("click", () => { selectedClassEditId = null; renderView(); }); document.getElementById("classEditCancelFooter")?.addEventListener("click", () => { selectedClassEditId = null; renderView(); }); document.getElementById("classEditModalOverlay")?.addEventListener("click", (event) => { if (event.target?.id === "classEditModalOverlay") { selectedClassEditId = null; renderView(); } }); bindModalShell("classEditModalOverlay", () => { selectedClassEditId = null; renderView(); }); document.getElementById("classEditForm")?.addEventListener("submit", (event) => { event.preventDefault(); if (!editingClass) { return; } const form = new FormData(event.currentTarget); const cleaned = String(form.get("name") || "").trim(); if (!cleaned) { setFormError("classEditError", t("validation.required_name")); return; } setFormError("classEditError", ""); editingClass.name = cleaned; selectedClassEditId = null; saveState(); renderView(); }); } function renderDrivers() { const classOptions = state.classes .map((c) => ``) .join(""); const driverSearch = driverBrandFilter.trim().toLowerCase(); const filteredDrivers = state.drivers.filter((driver) => !driverSearch || [driver.name, driver.transponder, driver.brand] .map((value) => String(value || "").toLowerCase()) .some((value) => value.includes(driverSearch)) ); const editingDriver = state.drivers.find((driver) => driver.id === selectedDriverEditId) || null; dom.view.innerHTML = `

${t("drivers.create")}

${t("drivers.title")}

${renderTable( [t("table.name"), t("table.class"), t("table.brand"), t("table.transponder"), t("events.actions")], filteredDrivers.map( (d) => ` ${escapeHtml(d.name)} ${escapeHtml(getClassName(d.classId))} ${escapeHtml(d.brand || "-")} ${escapeHtml(d.transponder || "-")} ` ) )}
${ editingDriver ? ` ` : "" } `; document.getElementById("driverForm")?.addEventListener("submit", (e) => { e.preventDefault(); const form = new FormData(e.currentTarget); state.drivers.push( normalizeDriver({ id: uid("driver"), name: String(form.get("name")).trim(), classId: String(form.get("classId")), brand: String(form.get("brand") || "").trim(), transponder: String(form.get("transponder") || "").trim(), }) ); saveState(); renderView(); }); document.getElementById("driverBrandFilter")?.addEventListener("input", (event) => { const input = event.currentTarget; if (!(input instanceof HTMLInputElement)) { return; } driverBrandFilter = input.value; renderDrivers(); }); state.drivers.forEach((d) => { document.getElementById(`driver-edit-${d.id}`)?.addEventListener("click", () => { selectedDriverEditId = d.id; renderView(); }); document.getElementById(`driver-delete-${d.id}`)?.addEventListener("click", () => { state.drivers = state.drivers.filter((x) => x.id !== d.id); state.sessions.forEach((s) => { s.assignments = (s.assignments || []).filter((a) => a.driverId !== d.id); }); saveState(); renderView(); }); }); document.getElementById("driverEditCancel")?.addEventListener("click", () => { selectedDriverEditId = null; renderView(); }); document.getElementById("driverEditCancelFooter")?.addEventListener("click", () => { selectedDriverEditId = null; renderView(); }); document.getElementById("driverEditModalOverlay")?.addEventListener("click", (event) => { if (event.target?.id === "driverEditModalOverlay") { selectedDriverEditId = null; renderView(); } }); bindModalShell("driverEditModalOverlay", () => { selectedDriverEditId = null; renderView(); }); document.getElementById("driverEditForm")?.addEventListener("submit", (event) => { event.preventDefault(); if (!editingDriver) { return; } const form = new FormData(event.currentTarget); const cleanedName = String(form.get("name") || "").trim(); const cleanedClassId = String(form.get("classId") || "").trim(); const cleanedBrand = String(form.get("brand") || "").trim(); const cleanedTp = String(form.get("transponder") || "").trim(); if (!cleanedName) { setFormError("driverEditError", t("validation.required_name")); return; } if (cleanedClassId && !state.classes.some((item) => item.id === cleanedClassId)) { setFormError("driverEditError", t("validation.invalid_selection")); return; } setFormError("driverEditError", ""); editingDriver.name = cleanedName; editingDriver.classId = cleanedClassId || editingDriver.classId; editingDriver.brand = cleanedBrand; editingDriver.transponder = cleanedTp; selectedDriverEditId = null; saveState(); renderView(); }); } function renderCars() { const carSearch = carBrandFilter.trim().toLowerCase(); const filteredCars = state.cars.filter((car) => !carSearch || [car.name, car.transponder, car.brand] .map((value) => String(value || "").toLowerCase()) .some((value) => value.includes(carSearch)) ); const editingCar = state.cars.find((car) => car.id === selectedCarEditId) || null; dom.view.innerHTML = `

${t("cars.create")}

${t("cars.title")}

${renderTable( [t("table.car"), t("table.brand"), t("table.transponder"), t("events.actions")], filteredCars.map( (c) => ` ${escapeHtml(c.name)} ${escapeHtml(c.brand || "-")} ${escapeHtml(c.transponder)} ` ) )}
${ editingCar ? ` ` : "" } `; document.getElementById("carForm")?.addEventListener("submit", (e) => { e.preventDefault(); const form = new FormData(e.currentTarget); state.cars.push( normalizeCar({ id: uid("car"), name: String(form.get("name")).trim(), brand: String(form.get("brand") || "").trim(), transponder: String(form.get("transponder")).trim(), }) ); saveState(); renderView(); }); document.getElementById("carBrandFilter")?.addEventListener("input", (event) => { const input = event.currentTarget; if (!(input instanceof HTMLInputElement)) { return; } carBrandFilter = input.value; renderCars(); }); state.cars.forEach((c) => { document.getElementById(`car-edit-${c.id}`)?.addEventListener("click", () => { selectedCarEditId = c.id; renderView(); }); document.getElementById(`car-delete-${c.id}`)?.addEventListener("click", () => { state.cars = state.cars.filter((x) => x.id !== c.id); state.sessions.forEach((s) => { s.assignments = (s.assignments || []).filter((a) => a.carId !== c.id); }); saveState(); renderView(); }); }); document.getElementById("carEditCancel")?.addEventListener("click", () => { selectedCarEditId = null; renderView(); }); document.getElementById("carEditCancelFooter")?.addEventListener("click", () => { selectedCarEditId = null; renderView(); }); document.getElementById("carEditModalOverlay")?.addEventListener("click", (event) => { if (event.target?.id === "carEditModalOverlay") { selectedCarEditId = null; renderView(); } }); bindModalShell("carEditModalOverlay", () => { selectedCarEditId = null; renderView(); }); document.getElementById("carEditForm")?.addEventListener("submit", (event) => { event.preventDefault(); if (!editingCar) { return; } const form = new FormData(event.currentTarget); const cleanedName = String(form.get("name") || "").trim(); const cleanedBrand = String(form.get("brand") || "").trim(); const cleanedTp = String(form.get("transponder") || "").trim(); if (!cleanedName) { setFormError("carEditError", t("validation.required_name")); return; } if (!cleanedTp) { setFormError("carEditError", t("validation.required_transponder")); return; } setFormError("carEditError", ""); editingCar.name = cleanedName; editingCar.brand = cleanedBrand; editingCar.transponder = cleanedTp; selectedCarEditId = null; saveState(); renderView(); }); } function renderEvents() { renderEventWorkspace("track"); } function renderRaceSetup() { renderEventWorkspace("race"); } function renderEventWorkspace(mode) { const isRaceMode = mode === "race"; const filteredEvents = state.events.filter((event) => event.mode === mode); const classOptions = state.classes .map((c) => ``) .join(""); const editingEvent = filteredEvents.find((event) => event.id === selectedEventEditId) || null; 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} `; }) )}
${ editingEvent ? ` ` : "" } `; document.getElementById("eventForm")?.addEventListener("submit", (e) => { e.preventDefault(); const form = new FormData(e.currentTarget); const event = { id: uid("event"), name: String(form.get("name")).trim(), date: String(form.get("date")), classId: String(form.get("classId")), mode, }; state.events.push(normalizeEvent(event)); saveState(); renderView(); }); filteredEvents.forEach((e) => { document.getElementById(`event-edit-${e.id}`)?.addEventListener("click", () => { selectedEventEditId = e.id; renderView(); }); document.getElementById(`event-delete-${e.id}`)?.addEventListener("click", () => { const sessionIds = getSessionsForEvent(e.id).map((s) => s.id); state.events = state.events.filter((x) => x.id !== e.id); state.sessions = state.sessions.filter((x) => x.eventId !== e.id); sessionIds.forEach((id) => delete state.resultsBySession[id]); if (state.activeSessionId && sessionIds.includes(state.activeSessionId)) { state.activeSessionId = null; } saveState(); renderView(); }); document.getElementById(`event-manage-${e.id}`)?.addEventListener("click", () => { renderEventManager(e.id); }); }); document.getElementById("eventEditCancel")?.addEventListener("click", () => { selectedEventEditId = null; renderView(); }); document.getElementById("eventEditCancelFooter")?.addEventListener("click", () => { selectedEventEditId = null; renderView(); }); document.getElementById("eventEditModalOverlay")?.addEventListener("click", (event) => { if (event.target?.id === "eventEditModalOverlay") { selectedEventEditId = null; renderView(); } }); bindModalShell("eventEditModalOverlay", () => { selectedEventEditId = null; renderView(); }); document.getElementById("eventEditForm")?.addEventListener("submit", (event) => { event.preventDefault(); if (!editingEvent) { return; } const form = new FormData(event.currentTarget); const cleanedName = String(form.get("name") || "").trim(); const cleanedDate = String(form.get("date") || "").trim(); const cleanedClassId = String(form.get("classId") || "").trim(); if (!cleanedName) { setFormError("eventEditError", t("validation.required_name")); return; } if (!cleanedDate) { setFormError("eventEditError", t("validation.required_date")); return; } if (!isValidIsoDate(cleanedDate)) { setFormError("eventEditError", t("validation.invalid_date")); return; } if (cleanedClassId && !state.classes.some((item) => item.id === cleanedClassId)) { setFormError("eventEditError", t("validation.invalid_selection")); return; } setFormError("eventEditError", ""); editingEvent.name = cleanedName; editingEvent.date = cleanedDate; editingEvent.classId = cleanedClassId || editingEvent.classId; selectedEventEditId = null; saveState(); renderView(); }); } function renderEventManager(eventId) { const event = state.events.find((e) => e.id === eventId); if (!event) { return; } const normalizedEvent = normalizeEvent(event); if (normalizedEvent !== event) { Object.assign(event, normalizedEvent); } ensureRaceParticipantsConfigured(event); const sessions = getSessionsForEvent(eventId); const eventManageArea = document.getElementById("eventManageArea"); if (!eventManageArea) { return; } const driverOptions = state.drivers .map((d) => ``) .join(""); const teamDriverPool = event.mode === "race" ? getTeamDriverPool(event) : { drivers: [], fallback: false }; const raceDrivers = event.mode === "race" ? teamDriverPool.drivers : []; const raceTeams = event.mode === "race" ? getEventTeams(event) : []; if (selectedTeamEditId && !raceTeams.some((team) => team.id === selectedTeamEditId)) { selectedTeamEditId = null; } const editingTeam = event.mode === "race" ? raceTeams.find((team) => team.id === selectedTeamEditId) || null : null; const carOptions = state.cars .map((c) => ``) .join(""); const branding = normalizeBrandingConfig(event.branding); const editingSession = sessions.find((session) => session.id === selectedSessionEditId) || null; const racePresets = getRaceFormatPresets(); const selectedPreset = racePresets.find((preset) => preset.id === event.raceConfig.presetId) || racePresets[0]; const 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")}

${t("events.open_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 : s.type === "team_race" ? raceTeams.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) ? ` ` : "" } `; }) )}

${t("events.branding")}

${t("events.branding_note")}

${branding.logoDataUrl ? `
event-logo
` : ""}
${ 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.teams")}

${t("events.team_race_intro")}

${t("events.team_steps")}

${t("events.team_hint")}

${t("events.team_drivers")}

${t("events.team_form_drivers")}

${teamDriverPool.fallback ? `

${t("events.team_driver_fallback")}

` : ""}
${raceDrivers .map( (driver) => ` ` ) .join("")}

${t("events.team_cars")}

${t("events.team_form_cars")}

${state.cars .map( (car) => ` ` ) .join("")}
${ raceTeams.length ? raceTeams .map( (team) => `
${escapeHtml(team.name)}
${t("events.team_drivers")}: ${escapeHtml( team.driverIds.map((driverId) => getDriverDisplayById(driverId)).join(", ") || "-" )}
${t("events.team_cars")}: ${escapeHtml( team.carIds .map((carId) => { const car = state.cars.find((item) => item.id === carId); return car ? `${car.name} (${car.transponder || "-"})` : ""; }) .filter(Boolean) .join(", ") || "-" )}
` ) .join("") : `

${t("events.no_teams")}

` }

${t("events.race_format")}

${t("events.race_format_intro")}

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

${t("events.race_driver_scope")}

${t("events.bump_reserved_note")}

${t("events.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.team_standings")}

${renderTeamRaceStandings(event)}

${t("events.final_matrix")}

${renderFinalMatrix(event)}
` : "" } ${ editingTeam ? ` ` : "" } ${ editingSession ? ` ` : "" } `; document.getElementById("eventBrandingForm")?.addEventListener("submit", (e) => { e.preventDefault(); const form = new FormData(e.currentTarget); event.branding = normalizeBrandingConfig({ ...event.branding, brandName: String(form.get("brandName") || "").trim(), brandTagline: String(form.get("brandTagline") || "").trim(), pdfFooter: String(form.get("pdfFooter") || "").trim(), pdfTheme: String(form.get("pdfTheme") || "").trim(), }); saveState(); renderEventManager(eventId); }); document.getElementById("eventLogoUpload")?.addEventListener("change", (eventInput) => { const input = eventInput.currentTarget; const file = input instanceof HTMLInputElement ? input.files?.[0] : null; if (!file) { return; } const reader = new FileReader(); reader.onload = () => { event.branding = normalizeBrandingConfig({ ...event.branding, logoDataUrl: typeof reader.result === "string" ? reader.result : "", }); saveState(); renderEventManager(eventId); }; reader.readAsDataURL(file); }); document.getElementById("eventLogoClear")?.addEventListener("click", () => { event.branding = normalizeBrandingConfig({ ...event.branding, logoDataUrl: "", }); saveState(); renderEventManager(eventId); }); document.getElementById("sessionForm")?.addEventListener("submit", (e) => { e.preventDefault(); const form = new FormData(e.currentTarget); state.sessions.push(normalizeSession({ id: uid("session"), eventId, name: String(form.get("name")).trim(), type: String(form.get("type")), durationMin: Number(form.get("durationMin")), followUpSec: Math.max(0, Number(form.get("followUpSec") || 0) || 0), startMode: String(form.get("startMode") || "mass"), seedBestLapCount: Math.max(0, Number(form.get("seedBestLapCount") || 0) || 0), seedMethod: String(form.get("seedMethod") || "best_sum"), staggerGapSec: Math.max(0, Number(form.get("staggerGapSec") || 0) || 0), maxCars: Number(form.get("maxCars") || 0) || null, mode: event.mode, status: "ready", startedAt: null, endedAt: null, finishedByTimer: false, assignments: [], })); saveState(); renderEventManager(eventId); updateHeaderState(); }); sessions.forEach((s) => { document.getElementById(`session-edit-${s.id}`)?.addEventListener("click", () => { selectedSessionEditId = s.id; renderEventManager(eventId); }); document.getElementById(`session-active-${s.id}`)?.addEventListener("click", () => { state.activeSessionId = s.id; saveState(); updateHeaderState(); renderView(); }); document.getElementById(`session-delete-${s.id}`)?.addEventListener("click", () => { state.sessions = state.sessions.filter((x) => x.id !== s.id); delete state.resultsBySession[s.id]; if (state.activeSessionId === s.id) { state.activeSessionId = null; } saveState(); renderEventManager(eventId); updateHeaderState(); }); document.getElementById(`session-grid-${s.id}`)?.addEventListener("click", () => { ensureSessionDriverOrder(s); selectedGridSessionId = s.id; saveState(); renderEventManager(eventId); }); document.getElementById(`session-sheet-print-${s.id}`)?.addEventListener("click", () => { openPrintWindow(`${getEventName(eventId)} - ${s.name}`, buildSessionHeatSheetHtml(s)); }); document.getElementById(`session-sheet-export-${s.id}`)?.addEventListener("click", () => { exportSessionHeatSheet(s); }); document.getElementById(`session-sheet-pdf-${s.id}`)?.addEventListener("click", async () => { await exportSessionHeatSheetPdf(s); }); }); document.getElementById("sessionEditCancel")?.addEventListener("click", () => { selectedSessionEditId = null; renderEventManager(eventId); }); document.getElementById("sessionEditCancelFooter")?.addEventListener("click", () => { selectedSessionEditId = null; renderEventManager(eventId); }); document.getElementById("sessionEditModalOverlay")?.addEventListener("click", (event) => { if (event.target?.id === "sessionEditModalOverlay") { selectedSessionEditId = null; renderEventManager(eventId); } }); bindModalShell("sessionEditModalOverlay", () => { selectedSessionEditId = null; renderEventManager(eventId); }); document.getElementById("sessionEditForm")?.addEventListener("submit", (event) => { event.preventDefault(); if (!editingSession) { return; } const form = new FormData(event.currentTarget); const cleanedName = String(form.get("name") || "").trim(); const cleanedDuration = Number(form.get("durationMin") || editingSession.durationMin || 5) || 0; if (!cleanedName) { setFormError("sessionEditError", t("validation.required_name")); return; } if (cleanedDuration < 1) { setFormError("sessionEditError", t("validation.required_duration")); return; } setFormError("sessionEditError", ""); editingSession.name = cleanedName; editingSession.type = String(form.get("type") || editingSession.type); editingSession.durationMin = Math.max(1, cleanedDuration); editingSession.followUpSec = Math.max(0, Number(form.get("followUpSec") || 0) || 0); editingSession.startMode = normalizeStartMode(String(form.get("startMode") || editingSession.startMode || "mass")); editingSession.seedBestLapCount = Math.max(0, Number(form.get("seedBestLapCount") || 0) || 0); editingSession.seedMethod = ["best_sum", "average", "consecutive"].includes(String(form.get("seedMethod") || "").toLowerCase()) ? String(form.get("seedMethod")).toLowerCase() : "best_sum"; editingSession.staggerGapSec = Math.max(0, Number(form.get("staggerGapSec") || 0) || 0); selectedSessionEditId = null; saveState(); renderEventManager(eventId); }); if (event.mode === "track") { document.getElementById("sponsorRoundsForm")?.addEventListener("submit", (e) => { e.preventDefault(); const form = new FormData(e.currentTarget); const qualificationRounds = Number(form.get("qualificationRounds") || 0); const heatRounds = Number(form.get("heatRounds") || 0); const finalRounds = Number(form.get("finalRounds") || 0); const durationMin = Number(form.get("roundDuration") || 5); createSponsorRounds(eventId, { qualificationRounds, heatRounds, finalRounds, durationMin, }); saveState(); renderEventManager(eventId); }); document.getElementById("assignForm")?.addEventListener("submit", (e) => { e.preventDefault(); const form = new FormData(e.currentTarget); const sessionId = String(form.get("sessionId")); const session = state.sessions.find((x) => x.id === sessionId); if (!session) { return; } const driverId = String(form.get("driverId")); const carId = String(form.get("carId")); const car = state.cars.find((x) => x.id === carId); if (!car) { return; } const duplicateCar = (session.assignments || []).find((a) => a.carId === carId); if (duplicateCar) { alert(t("events.duplicate_car")); return; } const duplicateDriver = (session.assignments || []).find((a) => a.driverId === driverId); if (duplicateDriver) { alert(t("events.duplicate_driver")); return; } const duplicateTp = (session.assignments || []).find((a) => { const existingCar = state.cars.find((x) => x.id === a.carId); return existingCar?.transponder && existingCar.transponder === car.transponder; }); if (duplicateTp) { alert(t("events.duplicate_tp")); return; } session.assignments = session.assignments || []; session.assignments.push({ id: uid("as"), driverId, carId }); saveState(); renderEventManager(eventId); }); document.getElementById("autoAssignSession")?.addEventListener("click", () => { const sessionId = getSelectedAssignmentSessionId(); if (!sessionId) { return; } autoAssignTrackSession(event, sessionId); saveState(); renderEventManager(eventId); }); document.getElementById("clearAssignSession")?.addEventListener("click", () => { const sessionId = getSelectedAssignmentSessionId(); if (!sessionId) { return; } const session = state.sessions.find((x) => x.id === sessionId); if (!session) { return; } session.assignments = []; saveState(); renderEventManager(eventId); }); renderAssignmentList(eventId); } if (event.mode === "race") { const persistRaceParticipants = () => { const selectedIds = Array.from(document.querySelectorAll(".race-participant:checked")).map((node) => node.value); event.raceConfig.driverIds = selectedIds; event.raceConfig.participantsConfigured = true; saveState(); }; document.querySelectorAll(".race-participant").forEach((node) => { node.addEventListener("change", persistRaceParticipants); }); document.getElementById("selectAllParticipants")?.addEventListener("click", () => { document.querySelectorAll(".race-participant").forEach((node) => { node.checked = true; }); persistRaceParticipants(); }); document.getElementById("clearParticipants")?.addEventListener("click", () => { document.querySelectorAll(".race-participant").forEach((node) => { node.checked = false; }); persistRaceParticipants(); }); document.getElementById("teamForm")?.addEventListener("submit", (e) => { e.preventDefault(); const form = new FormData(e.currentTarget); const name = String(form.get("teamName") || "").trim(); const driverIds = form.getAll("teamDriverIds").map(String).filter(Boolean); const carIds = form.getAll("teamCarIds").map(String).filter(Boolean); if (!name || (!driverIds.length && !carIds.length)) { return; } const createdTeam = normalizeRaceTeam({ id: uid("team"), name, driverIds, carIds }); event.raceConfig.teams = [...getEventTeams(event), createdTeam]; selectedTeamEditId = createdTeam.id; saveState(); renderEventManager(eventId); }); raceTeams.forEach((team) => { document.getElementById(`team-edit-${team.id}`)?.addEventListener("click", () => { selectedTeamEditId = team.id; renderEventManager(eventId); }); document.getElementById(`team-delete-${team.id}`)?.addEventListener("click", () => { event.raceConfig.teams = getEventTeams(event).filter((item) => item.id !== team.id); if (selectedTeamEditId === team.id) { selectedTeamEditId = null; } saveState(); renderEventManager(eventId); }); }); document.getElementById("teamEditCancel")?.addEventListener("click", () => { selectedTeamEditId = null; renderEventManager(eventId); }); document.getElementById("teamEditCancelFooter")?.addEventListener("click", () => { selectedTeamEditId = null; renderEventManager(eventId); }); document.getElementById("teamEditModalOverlay")?.addEventListener("click", (modalEvent) => { if (modalEvent.target?.id === "teamEditModalOverlay") { selectedTeamEditId = null; renderEventManager(eventId); } }); bindModalShell("teamEditModalOverlay", () => { selectedTeamEditId = null; renderEventManager(eventId); }); document.getElementById("teamEditForm")?.addEventListener("submit", (submitEvent) => { submitEvent.preventDefault(); if (!editingTeam) { return; } const form = new FormData(submitEvent.currentTarget); const name = String(form.get("teamName") || "").trim(); const driverIds = form.getAll("teamDriverIds").map(String).filter(Boolean); const carIds = form.getAll("teamCarIds").map(String).filter(Boolean); if (!name) { setFormError("teamEditError", t("validation.required_name")); return; } if (!driverIds.length && !carIds.length) { setFormError("teamEditError", t("validation.invalid_selection")); return; } setFormError("teamEditError", ""); event.raceConfig.teams = getEventTeams(event).map((team) => team.id === editingTeam.id ? normalizeRaceTeam({ ...team, name, driverIds, carIds }) : team ); selectedTeamEditId = null; saveState(); renderEventManager(eventId); }); document.getElementById("raceFormatForm")?.addEventListener("submit", (e) => { e.preventDefault(); const form = new FormData(e.currentTarget); event.raceConfig = buildRaceFormatConfigFromForm(form, event); saveState(); renderEventManager(eventId); }); document.getElementById("applyRacePreset")?.addEventListener("click", () => { const formElement = document.getElementById("raceFormatForm"); if (!(formElement instanceof HTMLFormElement)) { return; } const form = new FormData(formElement); applyRaceFormatPreset(event, String(form.get("presetId") || "custom")); saveState(); renderEventManager(eventId); }); document.getElementById("saveRacePreset")?.addEventListener("click", () => { const formElement = document.getElementById("raceFormatForm"); if (!(formElement instanceof HTMLFormElement)) { return; } const form = new FormData(formElement); const presetName = String(form.get("presetName") || "").trim(); if (!presetName) { return; } const config = buildRaceFormatConfigFromForm(form, event); const selectedPresetId = String(form.get("presetId") || "custom"); const existingCustomPreset = (state.settings.racePresets || []).find((preset) => preset.id === selectedPresetId); const presetId = existingCustomPreset ? existingCustomPreset.id : uid("preset"); const storedPreset = normalizeStoredRacePreset({ id: presetId, name: presetName, values: { qualifyingScoring: config.qualifyingScoring, qualifyingRounds: config.qualifyingRounds, carsPerHeat: config.carsPerHeat, qualDurationMin: config.qualDurationMin, qualStartMode: config.qualStartMode, qualSeedLapCount: config.qualSeedLapCount, qualSeedMethod: config.qualSeedMethod, countedQualRounds: config.countedQualRounds, qualifyingPointsTable: config.qualifyingPointsTable, qualifyingTieBreak: config.qualifyingTieBreak, carsPerFinal: config.carsPerFinal, finalLegs: config.finalLegs, countedFinalLegs: config.countedFinalLegs, finalDurationMin: config.finalDurationMin, finalStartMode: config.finalStartMode, followUpSec: config.followUpSec, minLapMs: config.minLapMs, maxLapMs: config.maxLapMs, bumpCount: config.bumpCount, reserveBumpSlots: config.reserveBumpSlots, finalsSource: config.finalsSource, }, }); const otherPresets = (state.settings.racePresets || []).filter((preset) => preset.id !== presetId); state.settings.racePresets = [...otherPresets, storedPreset]; event.raceConfig = { ...config, presetId }; saveState(); renderEventManager(eventId); }); document.getElementById("deleteRacePreset")?.addEventListener("click", () => { const formElement = document.getElementById("raceFormatForm"); if (!(formElement instanceof HTMLFormElement)) { return; } const form = new FormData(formElement); const presetId = String(form.get("presetId") || "custom"); if (!(state.settings.racePresets || []).some((preset) => preset.id === presetId)) { return; } state.settings.racePresets = (state.settings.racePresets || []).filter((preset) => preset.id !== presetId); event.raceConfig.presetId = "custom"; saveState(); renderEventManager(eventId); }); document.getElementById("generateQualifying")?.addEventListener("click", () => { const created = generateQualifyingForRace(event); saveState(); renderEventManager(eventId); if (created > 0) { alert(t("events.generated_qualifying")); } }); document.getElementById("clearGeneratedQualifying")?.addEventListener("click", () => { clearGeneratedQualifying(event.id); saveState(); renderEventManager(eventId); }); document.getElementById("reseedQualifying")?.addEventListener("click", () => { const result = reseedUpcomingQualifying(event); saveState(); renderEventManager(eventId); const messages = []; if (result.updated > 0) { messages.push(t("events.reseed_done")); } else { messages.push(t("events.no_reseed_done")); } if (result.locked > 0) { messages.push(t("events.reseed_locked", { count: result.locked })); } alert(messages.join("\n")); }); document.getElementById("generateFinals")?.addEventListener("click", () => { const created = generateFinalsForRace(event); saveState(); renderEventManager(eventId); if (created > 0) { alert(t("events.finals_generated")); } }); document.getElementById("clearGeneratedFinals")?.addEventListener("click", () => { clearGeneratedFinals(event.id); saveState(); renderEventManager(eventId); }); document.getElementById("applyBumps")?.addEventListener("click", () => { const applied = applyBumpsForRace(event); saveState(); renderEventManager(eventId); alert(t(applied > 0 ? "events.bumps_applied" : "events.no_bumps_applied")); }); document.getElementById("printStartlists")?.addEventListener("click", () => { openPrintWindow(`${event.name} - ${t("events.start_lists")}`, buildRaceStartListsHtml(event)); }); document.getElementById("printResults")?.addEventListener("click", () => { openPrintWindow(`${event.name} - ${t("events.results_overview")}`, buildRaceResultsHtml(event)); }); document.getElementById("printTeamResults")?.addEventListener("click", () => { openPrintWindow(`${event.name} - ${t("events.team_report")}`, buildTeamRaceResultsHtml(event)); }); document.getElementById("pdfStartlists")?.addEventListener("click", async () => { await exportRaceStartListsPdf(event); }); document.getElementById("pdfResults")?.addEventListener("click", async () => { await exportRaceResultsPdf(event); }); document.getElementById("pdfTeamResults")?.addEventListener("click", async () => { await exportTeamRaceResultsPdf(event); }); document.getElementById("gridResetOrder")?.addEventListener("click", () => { if (!selectedGridSession) { return; } selectedGridSession.driverIds = getSessionEntrants(selectedGridSession) .map((driver) => driver.id) .filter(Boolean); selectedGridSession.manualGridIds = [...selectedGridSession.driverIds]; selectedGridSession.gridCustomized = false; saveState(); renderEventManager(eventId); }); document.getElementById("gridToggleLock")?.addEventListener("click", () => { if (!selectedGridSession) { return; } if (!selectedGridSession.gridCustomized) { selectedGridSession.manualGridIds = [...ensureSessionDriverOrder(selectedGridSession)]; selectedGridSession.gridCustomized = true; } else { selectedGridSession.manualGridIds = [...selectedGridSession.driverIds]; selectedGridSession.gridCustomized = false; } saveState(); renderEventManager(eventId); }); let dragIndex = null; document.querySelectorAll("#gridDragList .drag-item").forEach((node) => { node.addEventListener("dragstart", () => { dragIndex = Number(node.dataset.index); node.classList.add("drag-item-active"); }); node.addEventListener("dragend", () => { dragIndex = null; node.classList.remove("drag-item-active"); }); node.addEventListener("dragover", (dragEvent) => { dragEvent.preventDefault(); node.classList.add("drag-item-over"); }); node.addEventListener("dragleave", () => { node.classList.remove("drag-item-over"); }); node.addEventListener("drop", (dropEvent) => { dropEvent.preventDefault(); node.classList.remove("drag-item-over"); if (!selectedGridSession || dragIndex === null) { return; } const dropIndex = Number(node.dataset.index); if (Number.isNaN(dropIndex) || dropIndex === dragIndex) { return; } selectedGridSession.manualGridIds = reorderList(ensureSessionDriverOrder(selectedGridSession), dragIndex, dropIndex); selectedGridSession.gridCustomized = true; saveState(); renderEventManager(eventId); }); }); } } function renderAssignmentList(eventId) { const block = document.getElementById("assignmentList"); if (!block) { return; } const sessions = getSessionsForEvent(eventId); block.innerHTML = sessions .map((s) => { const items = (s.assignments || []) .map((a) => { const driver = state.drivers.find((d) => d.id === a.driverId); const car = state.cars.find((c) => c.id === a.carId); return `
  • ${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 clockLabel = active && sessionTiming?.followUpActive ? t("timing.follow_up") : active && sessionTiming?.untimed ? t("timing.elapsed") : t("timing.remaining"); const clockValue = sessionTiming?.followUpActive ? formatCountdown(sessionTiming?.followUpRemainingMs ?? 0) : sessionTiming?.untimed ? formatElapsedClock(sessionTiming?.elapsedMs ?? 0) : formatCountdown(sessionTiming?.remainingMs ?? 0); const showFinishedBanner = Boolean(active && active.status === "finished" && active.finishedByTimer); const showFollowUpBanner = Boolean(active && sessionTiming?.followUpActive); 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))} ${escapeHtml(getStatusLabel(active.status))}
    ${clockLabel}${clockValue}
    ${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.total_passings")}${getVisiblePassings(result).length}
    ${ active.type === "free_practice" ? `

    ${t("events.free_practice_note")}

    ` : active.type === "open_practice" ? `

    ${t("events.open_practice_note")}

    ` : "" }` : `

    ${t("timing.no_active")}

    ` } ${showFollowUpBanner ? `

    ${t("timing.follow_up_active")}

    ` : ""} ${showFinishedBanner ? `

    ${t("timing.race_finished")}

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

    ${t("timing.speaker_panel")}

    ${t("timing.speaker_panel_hint")}

    ${renderSpeakerToggle("speakerPassingCueEnabled", "settings.speaker_passing_cue")} ${renderSpeakerToggle("speakerLeaderCueEnabled", "settings.speaker_leader_cue")} ${renderSpeakerToggle("speakerBestLapCueEnabled", "settings.speaker_bestlap_cue")} ${renderSpeakerToggle("speakerTop3CueEnabled", "settings.speaker_top3_cue")} ${renderSpeakerToggle("speakerSessionStartCueEnabled", "settings.speaker_start_cue")} ${renderSpeakerToggle("speakerFinishCueEnabled", "settings.speaker_finish_cue")}

    ${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(); }); }); if (active && selectedRow) { bindQuickAddActions(active, selectedRow.transponder, "leaderboardModal"); document.getElementById("corrLapPlus")?.addEventListener("click", () => { applyCompetitorCorrection(active, selectedRow, { lapDelta: 1 }); renderView(); }); document.getElementById("corrLapMinus")?.addEventListener("click", () => { applyCompetitorCorrection(active, selectedRow, { lapDelta: -1 }); renderView(); }); document.getElementById("corrSecPlus")?.addEventListener("click", () => { applyCompetitorCorrection(active, selectedRow, { timeMsDelta: 1000 }); renderView(); }); document.getElementById("corr5SecPlus")?.addEventListener("click", () => { applyCompetitorCorrection(active, selectedRow, { timeMsDelta: 5000 }); renderView(); }); document.getElementById("corrSecMinus")?.addEventListener("click", () => { applyCompetitorCorrection(active, selectedRow, { timeMsDelta: -1000 }); renderView(); }); document.getElementById("corrInvalidateLast")?.addEventListener("click", () => { invalidateCompetitorLastLap(active, selectedRow); renderView(); }); document.getElementById("corrRestoreInvalid")?.addEventListener("click", () => { restoreCompetitorLastInvalidLap(active, selectedRow); renderView(); }); document.getElementById("corrReset")?.addEventListener("click", () => { applyCompetitorCorrection(active, selectedRow, { reset: true }); renderView(); }); } if (active) { ensureSessionResult(active.id) .passings.slice(-20) .reverse() .forEach((passing, index) => { bindQuickAddActions(active, passing.transponder, `recentPassing-${index}`); }); } document.getElementById("quickAddCancel")?.addEventListener("click", () => { quickAddDraft = null; renderView(); }); document.getElementById("quickAddForm")?.addEventListener("submit", (event) => { event.preventDefault(); if (!active || !quickAddDraft) { return; } const form = new FormData(event.currentTarget); const name = String(form.get("name") || "").trim(); if (!name) { return; } const transponder = String(form.get("transponder") || "").trim(); const brand = String(form.get("brand") || "").trim(); if (!transponder) { return; } if (quickAddDraft.type === "driver") { if (!state.drivers.some((item) => String(item.transponder || "").trim() === transponder)) { state.drivers.push( normalizeDriver({ id: uid("driver"), name, classId: String(form.get("classId") || getPreferredClassId(active)), brand, transponder, }) ); } } else if (!state.cars.some((item) => String(item.transponder || "").trim() === transponder)) { state.cars.push( normalizeCar({ id: uid("car"), name, brand, transponder, }) ); } quickAddDraft = null; saveState(); renderView(); }); document.getElementById("leaderboardModalClose")?.addEventListener("click", () => { selectedLeaderboardKey = null; renderView(); }); document.getElementById("leaderboardModalOverlay")?.addEventListener("click", (event) => { if (event.target?.id === "leaderboardModalOverlay") { selectedLeaderboardKey = null; renderView(); } }); document.getElementById("timingSimPass")?.addEventListener("click", () => { const tp = prompt(t("timing.prompt_transponder"), "232323"); if (!tp) { return; } processDecoderMessage({ msg: "PASSING", transponder: tp, rtc_time: new Date().toISOString(), strength: 0, resend: false, loop_id: "sim", }); }); document.getElementById("setActiveSession")?.addEventListener("click", () => { const select = document.getElementById("activeSessionSelect"); if (!select || !select.value) { return; } state.activeSessionId = select.value; saveState(); updateHeaderState(); renderView(); }); document.getElementById("startSession")?.addEventListener("click", () => { const session = getActiveSession(); if (!session) { return; } ensureAudioContext(); if (session.mode === "track") { const trackValidation = validateTrackSessionForStart(session); if (!trackValidation.ok) { alert(trackValidation.message); return; } } session.status = "running"; session.startedAt = Date.now(); session.endedAt = null; session.finishedByTimer = false; session.followUpStartedAt = null; 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; session.followUpStartedAt = null; 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]; session.followUpStartedAt = null; lastFinishAnnouncementSessionId = null; delete lastOverlayLeaderKeyBySession[session.id]; delete lastOverlayTop3BySession[session.id]; overlayEvents = []; saveState(); renderView(); }); document.querySelectorAll("[data-speaker-setting]").forEach((node) => { node.addEventListener("change", (event) => { const input = event.currentTarget; if (!(input instanceof HTMLInputElement)) { return; } state.settings[input.dataset.speakerSetting] = input.checked; saveState(); }); }); } function renderJudging() { const active = getActiveSession(); if (!active) { dom.view.innerHTML = `

    ${t("judging.title")}

    ${t("judging.no_active_session")}

    `; return; } const result = ensureSessionResult(active.id); const leaderboard = buildLeaderboard(active); const filteredRows = getJudgeFilteredRows(leaderboard, judgingCompetitorFilter); if (selectedJudgeKey && !filteredRows.some((row) => row.key === selectedJudgeKey)) { selectedJudgeKey = null; } if (!selectedJudgeKey && filteredRows.length) { selectedJudgeKey = filteredRows[0].key; } const selectedRow = leaderboard.find((row) => row.key === selectedJudgeKey) || null; const selectedPassings = selectedRow ? getCompetitorPassings(active, selectedRow, { includeInvalid: true }) : []; const actionLog = getJudgeFilteredLog(Array.isArray(result.adjustments) ? result.adjustments.slice(-50).reverse() : [], judgingLogFilter); const latestUndoable = (result.adjustments || []).slice().reverse().find((entry) => !entry.undoneAt && entry.undo); dom.view.innerHTML = `

    ${t("judging.title")}

    ${escapeHtml(active.name)}

    ${t("judging.active_session")}: ${escapeHtml(active.name)} • ${escapeHtml(getSessionTypeLabel(active.type))}

    ${t("judging.select_competitor")}

    ${renderTable( [t("table.pos"), t("table.driver"), t("table.result"), t("table.best_lap"), ""], filteredRows.map( (row, index) => ` ${index + 1}
    ${escapeHtml(row.displayName || row.driverName)}
    ${escapeHtml(row.subLabel || row.transponder || "-")}
    ${escapeHtml(row.resultDisplay || "-")} ${formatLap(row.bestLapMs)} ` ) )}

    ${t("judging.manual_actions")}

    ${ selectedRow ? `

    ${escapeHtml(selectedRow.displayName || selectedRow.driverName)} • ${escapeHtml(selectedRow.subLabel || selectedRow.carName || "-")}

    ${t("table.laps")}: ${selectedRow.laps}

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

    ${t("table.last_lap")}: ${formatLap(selectedRow.lastLapMs)}

    ${t("timing.lap_history")}

    ${ selectedPassings.length ? renderTable( [t("table.lap"), t("table.last_lap"), t("table.status")], selectedPassings.map( (passing, index) => ` ${index + 1} ${formatLap(passing.lapMs)} ${escapeHtml(getPassingValidationLabel(passing))} ` ) ) : `

    ${t("timing.no_lap_history")}

    ` }
    ` : `

    ${t("judging.selected_none")}

    ` }

    ${t("judging.action_log")}

    ${ actionLog.length ? renderTable( [t("table.time"), t("table.driver"), t("events.actions"), t("table.status"), ""], actionLog.map( (entry) => ` ${new Date(entry.ts).toLocaleTimeString()} ${escapeHtml(entry.displayName || "-")} ${escapeHtml(entry.action || "-")} ${escapeHtml(entry.detail || "-")}${entry.undoneAt ? ` • ${escapeHtml(t("judging.filter_log_undo"))}` : ""} ${entry.undo && !entry.undoneAt ? `` : ""} ` ) ) : `

    ${t("judging.no_action_log")}

    ` }
    `; leaderboard.forEach((row) => { document.getElementById(`judge-select-${row.key}`)?.addEventListener("click", () => { selectedJudgeKey = row.key; renderJudging(); }); }); document.getElementById("judgingCompetitorFilter")?.addEventListener("change", (event) => { const input = event.currentTarget; if (!(input instanceof HTMLSelectElement)) { return; } judgingCompetitorFilter = input.value; renderJudging(); }); document.getElementById("judgingLogFilter")?.addEventListener("change", (event) => { const input = event.currentTarget; if (!(input instanceof HTMLSelectElement)) { return; } judgingLogFilter = input.value; renderJudging(); }); document.getElementById("judgingExportLog")?.addEventListener("click", () => { const rows = (result.adjustments || []).map((entry) => ({ time: new Date(entry.ts).toISOString(), competitor: entry.displayName || "-", action: entry.action || "-", detail: entry.detail || "-", category: entry.category || "-", undoneAt: entry.undoneAt ? new Date(entry.undoneAt).toISOString() : "", })); const blob = new Blob([JSON.stringify({ session: active.name, items: rows }, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${active.name.replaceAll(/\s+/g, "_")}_judging_log.json`; link.click(); URL.revokeObjectURL(url); }); document.getElementById("judgingUndoLast")?.addEventListener("click", () => { if (!latestUndoable) { alert(t("judging.no_undo")); return; } undoJudgingAdjustment(active, latestUndoable.id); renderJudging(); }); actionLog.forEach((entry) => { if (!entry.undo || entry.undoneAt) { return; } document.getElementById(`judge-undo-${entry.id}`)?.addEventListener("click", () => { undoJudgingAdjustment(active, entry.id); renderJudging(); }); }); if (!selectedRow) { return; } document.getElementById("judgeLapPlus")?.addEventListener("click", () => { applyCompetitorCorrection(active, selectedRow, { lapDelta: 1 }); renderJudging(); }); document.getElementById("judgeLapMinus")?.addEventListener("click", () => { applyCompetitorCorrection(active, selectedRow, { lapDelta: -1 }); renderJudging(); }); document.getElementById("judgeSecPlus")?.addEventListener("click", () => { applyCompetitorCorrection(active, selectedRow, { timeMsDelta: 1000 }); renderJudging(); }); document.getElementById("judgeFivePlus")?.addEventListener("click", () => { applyCompetitorCorrection(active, selectedRow, { timeMsDelta: 5000 }); renderJudging(); }); document.getElementById("judgeSecMinus")?.addEventListener("click", () => { applyCompetitorCorrection(active, selectedRow, { timeMsDelta: -1000 }); renderJudging(); }); document.getElementById("judgeInvalidate")?.addEventListener("click", () => { invalidateCompetitorLastLap(active, selectedRow); renderJudging(); }); document.getElementById("judgeRestore")?.addEventListener("click", () => { restoreCompetitorLastInvalidLap(active, selectedRow); renderJudging(); }); document.getElementById("judgeReset")?.addEventListener("click", () => { applyCompetitorCorrection(active, selectedRow, { reset: true }); renderJudging(); }); } function renderSpeakerToggle(settingKey, labelKey) { return ` `; } function renderQuickAddPanel(session) { if (!quickAddDraft || !quickAddDraft.transponder) { return ""; } const classOptions = state.classes .map( (item) => `` ) .join(""); const isDriver = quickAddDraft.type === "driver"; return `

    ${t(isDriver ? "timing.quick_add_driver_title" : "timing.quick_add_car_title")}

    ${ isDriver ? `` : `
    ${t("timing.quick_add_hint")}
    ` }
    `; } 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.race_format_8")}
    • ${t("guide.race_format_9")}
    • ${t("guide.race_format_10")}
    • ${t("guide.race_format_11")}
    • ${t("guide.race_format_12")}
    • ${t("guide.race_format_13")}
    • ${t("guide.race_format_14")}

    ${t("guide.free_practice_title")}

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

    ${t("guide.open_practice_title")}

    • ${t("guide.open_practice_1")}
    • ${t("guide.open_practice_2")}
    • ${t("guide.open_practice_3")}

    ${t("guide.team_title")}

    • ${t("guide.team_1")}
    • ${t("guide.team_2")}
    • ${t("guide.team_3")}
    • ${t("guide.team_4")}
    • ${t("guide.team_5")}
    • ${t("guide.team_6")}

    ${t("guide.validation_title")}

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

    ${t("guide.qualifying_title")}

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

    ${t("guide.dashboard_title")}

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

    ${t("guide.host_title")}

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

    ${t("guide.windows_title")}

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

    ${t("guide.linux_title")}

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

    ${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 overlayClock = sessionTiming?.followUpActive ? formatCountdown(sessionTiming?.followUpRemainingMs ?? 0) : sessionTiming?.untimed ? formatElapsedClock(sessionTiming?.elapsedMs ?? 0) : formatCountdown(sessionTiming?.remainingMs ?? 0); const recent = active && result ? getVisiblePassings(result).slice(-8).reverse() : []; const event = active ? state.events.find((item) => item.id === active.eventId) : null; const branding = resolveEventBranding(event); const practiceRows = event ? buildPracticeStandings(event) : []; const qualifyingRows = event ? buildQualifyingStandings(event) : []; const finalRows = event ? buildFinalStandings(event) : []; const topRow = leaderboard[0] || null; const fastestRow = [...leaderboard].filter((row) => Number.isFinite(row.bestLapMs)).sort((left, right) => left.bestLapMs - right.bestLapMs)[0] || null; const modeLabel = getOverlayModeLabel(overlayViewMode); const overlayStatusLabel = sessionTiming?.followUpActive ? t("timing.follow_up_active") : active ? getStatusLabel(active.status) : ""; const rotatingPanels = buildOverlayPanels(active, recent); const activePanel = rotatingPanels.length ? rotatingPanels[overlayRotationIndex % rotatingPanels.length] : null; const denseOverlay = overlayViewMode === "leaderboard" || overlayViewMode === "tv"; dom.view.innerHTML = ` ${ overlayMode ? "" : `

    ${t("overlay.title")}

    ` }
    ${ active ? `
    ${branding.logoDataUrl ? `` : ""}

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

    ${escapeHtml(getSessionTypeLabel(active.type))} ${overlayViewMode !== "tv" ? `${escapeHtml(getStartModeLabel(active.startMode))}` : ""} ${escapeHtml(modeLabel)}

    ${escapeHtml(active.name)}

    ${escapeHtml(branding.brandName || "JMK RB RaceController")}

    ${overlayClock}
    ${escapeHtml(overlayStatusLabel)}
    ${ overlayViewMode === "speaker" ? `
    P1

    ${escapeHtml(topRow?.displayName || 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.displayName || passing.teamName || passing.driverName || t("common.unknown_driver"))} ${formatLap(passing.lapMs)}
    ` ) .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"))}
    ` : overlayViewMode === "team" ? renderTeamOverlay(leaderboard, result, sessionTiming) : overlayViewMode === "tv" ? `
    ${t("overlay.fastest_lap")} ${formatLap(fastestRow?.bestLapMs)}
    ${escapeHtml(fastestRow?.displayName || fastestRow?.driverName || "-")}
    ${t("table.laps")}: ${topRow?.laps || 0} | ${t("timing.total_passings")}: ${getVisiblePassings(result).length || 0}
    ${renderOverlayLeaderboard(leaderboard)}
    ` : `
    ${t("overlay.fastest_lap")} ${formatLap(fastestRow?.bestLapMs)}
    ${escapeHtml(fastestRow?.displayName || fastestRow?.driverName || "-")}
    ${t("table.laps")}: ${topRow?.laps || 0} | ${t("timing.total_passings")}: ${result?.passings.length || 0}
    ${renderOverlayLeaderboard(leaderboard)}
    ` } ` : `

    ${t("overlay.title")}

    ${t("overlay.no_active")}

    ` }
    `; document.getElementById("overlayFullscreen")?.addEventListener("click", async () => { const target = document.documentElement; if (!document.fullscreenElement) { await target.requestFullscreen?.().catch(() => {}); return; } await document.exitFullscreen?.().catch(() => {}); }); document.getElementById("overlayLaunchLeaderboard")?.addEventListener("click", () => openOverlayWindow("leaderboard")); document.getElementById("overlayLaunchSpeaker")?.addEventListener("click", () => openOverlayWindow("speaker")); document.getElementById("overlayLaunchResults")?.addEventListener("click", () => openOverlayWindow("results")); document.getElementById("overlayLaunchTv")?.addEventListener("click", () => openOverlayWindow("tv")); document.getElementById("overlayLaunchTeam")?.addEventListener("click", () => openOverlayWindow("team")); } function buildOverlayPanels(active, recent) { return [ { title: t("overlay.last_passings"), content: recent.length ? recent .map( (passing) => `
    ${escapeHtml(passing.displayName || passing.teamName || passing.driverName || passing.transponder || t("common.unknown_driver"))} ${formatLap(passing.lapMs)}
    ` ) .join("") : `

    ${t("timing.no_passings")}

    `, }, { title: t("overlay.event_markers"), content: overlayEvents.length ? overlayEvents .map( (item) => `
    ${escapeHtml(item.label)} ${new Date(item.ts).toLocaleTimeString()}
    ` ) .join("") : `

    ${t("timing.no_passings")}

    `, }, { title: t("events.position_grid"), content: active && normalizeStartMode(active.startMode) === "position" ? renderPositionGrid(active) : `

    ${t("events.na")}

    `, }, ]; } function renderOverlaySidePanel(panel) { return `

    ${escapeHtml(panel.title)}

    ${t("overlay.rotating_panel")}
    ${panel.content}
    `; } function getQuickAddState(transponder) { const normalized = String(transponder || "").trim(); const driver = state.drivers.find((item) => String(item.transponder || "").trim() === normalized) || null; const car = state.cars.find((item) => String(item.transponder || "").trim() === normalized) || null; return { transponder: normalized, hasDriver: Boolean(driver), hasCar: Boolean(car), }; } function getPreferredClassId(session) { const event = state.events.find((item) => item.id === session?.eventId); if (event?.classId) { return event.classId; } return state.classes[0]?.id || ""; } function beginQuickAddDraft(session, type, transponder) { const normalized = String(transponder || "").trim(); if (!normalized) { return; } if (type === "driver" && state.drivers.some((item) => String(item.transponder || "").trim() === normalized)) { return; } if (type === "car" && state.cars.some((item) => String(item.transponder || "").trim() === normalized)) { return; } quickAddDraft = { type, transponder: normalized, classId: getPreferredClassId(session), name: type === "driver" ? normalized : `Car ${normalized}`, }; renderView(); } function renderQuickAddActions(session, transponder, idPrefix) { const quickState = getQuickAddState(transponder); if (!quickState.transponder || (quickState.hasDriver && quickState.hasCar)) { return ""; } return `
    ${!quickState.hasDriver ? `` : ""} ${!quickState.hasCar ? `` : ""}
    `; } function bindQuickAddActions(session, transponder, idPrefix) { document.getElementById(`${idPrefix}-add-driver`)?.addEventListener("click", () => { beginQuickAddDraft(session, "driver", transponder); }); document.getElementById(`${idPrefix}-add-car`)?.addEventListener("click", () => { beginQuickAddDraft(session, "car", transponder); }); } function renderLeaderboardModal(session, row) { const passings = getCompetitorPassings(session, row); return ` `; } 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.displayName || row.driverName)}
    ${row.teamId ? `
    ${t("overlay.active_member")}: ${escapeHtml(formatTeamActiveMemberLabel(row))}
    ` : ""} ${getManualCorrectionSummary(row) ? `
    ${escapeHtml(getManualCorrectionSummary(row))}
    ` : ""} ${row.invalidPending ? `
    ${escapeHtml(row.invalidLabel)}${row.invalidLapMs ? ` • ${formatLap(row.invalidLapMs)}` : ""}
    ` : ""} ${escapeHtml(row.subLabel || 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 `
    ${rows .map((row, idx) => { const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : ""; return `
    ${idx + 1}
    ${escapeHtml(row.displayName || row.driverName)} ${escapeHtml(row.teamId ? `${t("overlay.active_member")}: ${formatTeamActiveMemberLabel(row)}` : row.subLabel || row.transponder || "-")} ${row.invalidPending ? `${escapeHtml(row.invalidLabel)}${row.invalidLapMs ? ` • ${formatLap(row.invalidLapMs)}` : ""}` : ""}
    ${formatPredictedLapDelta(row.predictedRemainingMs)}
    ${row.laps ?? 0}
    ${escapeHtml(row.resultDisplay)}
    ${escapeHtml(row.gapDisplay || row.gapAhead || "-")}
    ${escapeHtml(row.gapAhead || "-")}
    ${escapeHtml(row.lapDelta || "-")}
    ${formatLap(row.bestLapMs)}
    `; }) .join("")}
    `; } function renderTeamOverlay(rows, result, sessionTiming) { const topThree = rows.slice(0, 3); return `

    ${t("overlay.top_three")}

    ${t("overlay.team_battle")}
    ${topThree .map( (row, index) => `
    ${index + 1} ${escapeHtml(row.displayName || row.driverName)}

    ${escapeHtml(row.resultDisplay || "-")}

    ${t("overlay.active_member")}: ${escapeHtml(formatTeamActiveMemberLabel(row))}
    ` ) .join("")}
    ${t("table.laps")} ${rows[0]?.laps || 0} ${escapeHtml(rows[0]?.displayName || rows[0]?.driverName || "-")}
    ${t("timing.total_passings")} ${getVisiblePassings(result).length || 0} ${sessionTiming?.untimed ? t("timing.elapsed") : t("timing.remaining")}
    ${t("overlay.fastest_lap")} ${formatLap([...rows].filter((row) => Number.isFinite(row.bestLapMs)).sort((a, b) => a.bestLapMs - b.bestLapMs)[0]?.bestLapMs)} ${escapeHtml( [...rows].filter((row) => Number.isFinite(row.bestLapMs)).sort((a, b) => a.bestLapMs - b.bestLapMs)[0]?.displayName || "-" )}

    ${t("events.team_standings")}

    ${renderOverlayLeaderboard(rows)}
    `; } 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.last_lap"), t("table.status"), ""], items.map((p, index) => { return ` ${new Date(p.timestamp).toLocaleTimeString()} ${escapeHtml(p.transponder)} ${escapeHtml(p.teamName ? `${p.teamName} • ${p.driverName || t("common.unknown_driver")}` : p.driverName || t("common.unknown_driver"))} ${escapeHtml(p.carName || p.subLabel || "-")} ${formatLap(p.lapMs)} ${escapeHtml(getPassingValidationLabel(p))} ${renderQuickAddActions(session, p.transponder, `recentPassing-${index}`)} `; }) ); } 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("dashboard.live_note")}

    ${t("settings.audio")}

    ${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 || "")}

    ${t("settings.race_presets")}

    ${t("settings.race_presets_note")}

    ${(state.settings.racePresets || []).length} preset(s)

    `; document.getElementById("settingsForm")?.addEventListener("submit", (e) => { e.preventDefault(); const form = new FormData(e.currentTarget); state.settings.wsUrl = String(form.get("wsUrl") || "").trim(); state.settings.backendUrl = String(form.get("backendUrl") || "").trim(); state.settings.autoReconnect = form.get("autoReconnect") === "on"; state.settings.audioEnabled = form.get("audioEnabled") === "on"; state.settings.passingSoundMode = String(form.get("passingSoundMode") || "beep"); state.settings.finishVoiceEnabled = form.get("finishVoiceEnabled") === "on"; state.settings.speakerPassingCueEnabled = form.get("speakerPassingCueEnabled") === "on"; state.settings.speakerLeaderCueEnabled = form.get("speakerLeaderCueEnabled") === "on"; state.settings.speakerFinishCueEnabled = form.get("speakerFinishCueEnabled") === "on"; state.settings.speakerBestLapCueEnabled = form.get("speakerBestLapCueEnabled") === "on"; state.settings.speakerTop3CueEnabled = form.get("speakerTop3CueEnabled") === "on"; state.settings.speakerSessionStartCueEnabled = form.get("speakerSessionStartCueEnabled") === "on"; state.settings.clubName = String(form.get("clubName") || "").trim() || "JMK RB RaceController"; state.settings.clubTagline = String(form.get("clubTagline") || "").trim() || "RC Timing System"; state.settings.pdfFooter = String(form.get("pdfFooter") || "").trim() || "Generated by JMK RB RaceController"; 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("exportRacePresets")?.addEventListener("click", () => { const payload = { racePresets: (state.settings.racePresets || []).map((preset) => normalizeStoredRacePreset(preset)), 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 = "live_rc_race_presets.json"; link.click(); URL.revokeObjectURL(url); }); document.getElementById("importRacePresets")?.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 = () => { try { const parsed = JSON.parse(String(reader.result || "{}")); const incoming = Array.isArray(parsed?.racePresets) ? parsed.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name) : []; const existingById = new Map((state.settings.racePresets || []).map((preset) => [preset.id, normalizeStoredRacePreset(preset)])); incoming.forEach((preset) => { existingById.set(preset.id, preset); }); state.settings.racePresets = [...existingById.values()]; saveState(); renderView(); } catch { // ignore invalid preset file } }; reader.readAsText(file); }); document.getElementById("ammcForm")?.addEventListener("submit", async (e) => { e.preventDefault(); const form = new FormData(e.currentTarget); const config = { managedEnabled: form.get("managedEnabled") === "on", autoStart: form.get("autoStart") === "on", decoderHost: String(form.get("decoderHost") || "").trim(), wsPort: Number(form.get("wsPort") || 9000), executablePath: String(form.get("executablePath") || "").trim(), workingDirectory: String(form.get("workingDirectory") || "").trim(), extraArgs: String(form.get("extraArgs") || "").trim(), }; await saveAmmcConfigToBackend(config); renderView(); }); document.getElementById("ammcRefresh")?.addEventListener("click", async () => { await refreshAmmcStatus(); renderView(); }); document.getElementById("ammcStart")?.addEventListener("click", async () => { const formElement = document.getElementById("ammcForm"); if (formElement instanceof HTMLFormElement) { const form = new FormData(formElement); await saveAmmcConfigToBackend({ managedEnabled: form.get("managedEnabled") === "on", autoStart: form.get("autoStart") === "on", decoderHost: String(form.get("decoderHost") || "").trim(), wsPort: Number(form.get("wsPort") || 9000), executablePath: String(form.get("executablePath") || "").trim(), workingDirectory: String(form.get("workingDirectory") || "").trim(), extraArgs: String(form.get("extraArgs") || "").trim(), }); } await startManagedAmmc(); renderView(); }); document.getElementById("ammcStop")?.addEventListener("click", async () => { await stopManagedAmmc(); renderView(); }); document.getElementById("ammcUseServerWs")?.addEventListener("click", () => { state.settings.wsUrl = getManagedWsUrl(); saveState(); renderView(); }); } function getSessionsForEvent(eventId) { return state.sessions.filter((s) => s.eventId === eventId); } function getModeLabel(mode) { return mode === "track" ? t("mode.track") : t("mode.race"); } function getSessionTypeLabel(type) { const key = `session.${String(type || "").toLowerCase()}`; const translated = t(key); return translated === key ? String(type || "") : translated; } function getOverlayModeLabel(mode) { return t(`overlay.mode_${String(mode || "leaderboard").toLowerCase()}`); } function normalizeStartMode(mode) { return ["mass", "position", "staggered"].includes(String(mode || "").toLowerCase()) ? String(mode).toLowerCase() : "mass"; } function getStartModeLabel(mode) { return t(`events.start_mode_${normalizeStartMode(mode)}`); } function getStatusLabel(status) { const key = `status.${String(status || "").toLowerCase()}`; const translated = t(key); return translated === key ? String(status || "") : translated; } function getClassName(classId) { return state.classes.find((x) => x.id === classId)?.name || t("common.unknown"); } function getEventName(eventId) { return state.events.find((x) => x.id === eventId)?.name || t("common.unknown_event"); } function isUntimedSession(session) { return String(session?.type || "").toLowerCase() === "open_practice"; } function getActiveSession() { return state.sessions.find((s) => s.id === state.activeSessionId) || null; } function getSessionTargetMs(session) { if (isUntimedSession(session)) { return null; } return Math.max(1, Number(session?.durationMin || 0)) * 60 * 1000; } function getSessionLapWindow(session) { const event = state.events.find((item) => item.id === session?.eventId); if (event?.mode !== "race") { return { minLapMs: 0, maxLapMs: Number.POSITIVE_INFINITY }; } const minLapMs = Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0); const configuredMaxLapMs = Math.max(0, Number(event?.raceConfig?.maxLapMs || 60000) || 60000); const maxLapMs = configuredMaxLapMs > 0 ? Math.max(configuredMaxLapMs, minLapMs || 0) : Number.POSITIVE_INFINITY; return { minLapMs, maxLapMs }; } function isCountedPassing(passing) { return passing?.validLap !== false; } function getVisiblePassings(result) { return Array.isArray(result?.passings) ? result.passings.filter((passing) => isCountedPassing(passing)) : []; } function getPassingValidationLabel(passing) { if (passing?.validLap === false) { if (passing.invalidReason === "below_min") { return t("timing.invalid_short"); } if (passing.invalidReason === "manual_invalid") { return t("timing.invalid_manual"); } return t("timing.invalid_long"); } return t("timing.valid_passing"); } function getManualCorrectionSummary(row) { const laps = Number(row?.manualLapAdjustment || 0) || 0; const timeMs = Number(row?.manualTimeAdjustmentMs || 0) || 0; const bits = []; if (laps) { bits.push(`${laps > 0 ? "+" : ""}${laps}L`); } if (timeMs) { bits.push(`${timeMs > 0 ? "+" : "-"}${formatLap(Math.abs(timeMs))}`); } return bits.join(" • "); } function applyCompetitorCorrection(session, row, options = {}) { const result = ensureSessionResult(session.id); const entry = result.competitors[row.key]; if (!entry) { return; } if (options.reset) { const previousLapAdjustment = Number(entry.manualLapAdjustment || 0) || 0; const previousTimeAdjustmentMs = Number(entry.manualTimeAdjustmentMs || 0) || 0; entry.manualLapAdjustment = 0; entry.manualTimeAdjustmentMs = 0; result.adjustments.push({ id: uid("judge"), ts: Date.now(), competitorKey: row.key, displayName: row.displayName || row.driverName || t("common.unknown_driver"), action: t("timing.penalty_reset"), detail: "-", category: "correction", undo: { type: "restore_correction_state", previousLapAdjustment, previousTimeAdjustmentMs, }, }); } else { const lapDelta = Number(options.lapDelta || 0) || 0; const timeMsDelta = Number(options.timeMsDelta || 0) || 0; const previousLapAdjustment = Number(entry.manualLapAdjustment || 0) || 0; const previousTimeAdjustmentMs = Number(entry.manualTimeAdjustmentMs || 0) || 0; entry.manualLapAdjustment = (Number(entry.manualLapAdjustment || 0) || 0) + lapDelta; entry.manualTimeAdjustmentMs = (Number(entry.manualTimeAdjustmentMs || 0) || 0) + timeMsDelta; const detailBits = []; if (lapDelta) { detailBits.push(`${lapDelta > 0 ? "+" : ""}${lapDelta}L`); } if (timeMsDelta) { detailBits.push(`${timeMsDelta > 0 ? "+" : "-"}${formatLap(Math.abs(timeMsDelta))}`); } result.adjustments.push({ id: uid("judge"), ts: Date.now(), competitorKey: row.key, displayName: row.displayName || row.driverName || t("common.unknown_driver"), action: t("timing.manual_corrections"), detail: detailBits.join(" • ") || "-", category: "correction", undo: { type: "restore_correction_state", previousLapAdjustment, previousTimeAdjustmentMs, }, }); } saveState(); } function recalculateCompetitorFromPassings(session, rowKey) { const result = ensureSessionResult(session.id); const entry = result.competitors[rowKey]; if (!entry) { return; } const passings = getCompetitorPassings(session, entry, { includeInvalid: true }); const validPassings = passings.filter((passing) => isCountedPassing(passing)); entry.laps = validPassings.length; entry.lastLapMs = validPassings.length ? Number(validPassings[validPassings.length - 1].lapMs || 0) || null : null; entry.lastTimestamp = validPassings.length ? Number(validPassings[validPassings.length - 1].timestamp || 0) || entry.startTimestamp || session.startedAt || null : entry.startTimestamp || session.startedAt || null; const bestLapCandidates = validPassings.map((passing) => Number(passing.lapMs || 0)).filter((lapMs) => lapMs > 500); entry.bestLapMs = bestLapCandidates.length ? Math.min(...bestLapCandidates) : null; } function invalidateCompetitorLastLap(session, row) { const result = ensureSessionResult(session.id); const entry = result.competitors[row.key]; if (!entry) { return false; } const passings = getCompetitorPassings(session, row, { includeInvalid: true }); const target = [...passings].reverse().find((passing) => isCountedPassing(passing)); if (!target) { return false; } target.validLap = false; target.invalidReason = "manual_invalid"; recalculateCompetitorFromPassings(session, row.key); result.adjustments.push({ id: uid("judge"), ts: Date.now(), competitorKey: row.key, displayName: row.displayName || row.driverName || t("common.unknown_driver"), action: t("timing.invalidate_last_lap"), detail: formatLap(Number(target.lapMs || 0) || 0), category: "invalid", undo: { type: "set_passing_validity", passingTimestamp: Number(target.timestamp || 0) || 0, validLap: true, invalidReason: "", }, }); saveState(); return true; } function restoreCompetitorLastInvalidLap(session, row) { const result = ensureSessionResult(session.id); const passings = getCompetitorPassings(session, row, { includeInvalid: true }); const target = [...passings].reverse().find((passing) => passing.validLap === false && passing.invalidReason === "manual_invalid"); if (!target) { return false; } target.validLap = true; target.invalidReason = ""; recalculateCompetitorFromPassings(session, row.key); result.adjustments.push({ id: uid("judge"), ts: Date.now(), competitorKey: row.key, displayName: row.displayName || row.driverName || t("common.unknown_driver"), action: t("timing.restore_last_invalid"), detail: formatLap(Number(target.lapMs || 0) || 0), category: "invalid", undo: { type: "set_passing_validity", passingTimestamp: Number(target.timestamp || 0) || 0, validLap: false, invalidReason: "manual_invalid", }, }); saveState(); return true; } function findPassingByUndoMarker(session, rowKey, passingTimestamp) { const result = ensureSessionResult(session.id); return result.passings.find((passing) => passing.competitorKey === rowKey && Number(passing.timestamp || 0) === Number(passingTimestamp || 0)) || null; } function undoJudgingAdjustment(session, adjustmentId) { const result = ensureSessionResult(session.id); const adjustment = (result.adjustments || []).find((entry) => entry.id === adjustmentId && !entry.undoneAt); if (!adjustment || !adjustment.undo) { return false; } const entry = result.competitors[adjustment.competitorKey]; if (!entry) { return false; } if (adjustment.undo.type === "restore_correction_state") { entry.manualLapAdjustment = Number(adjustment.undo.previousLapAdjustment || 0) || 0; entry.manualTimeAdjustmentMs = Number(adjustment.undo.previousTimeAdjustmentMs || 0) || 0; } else if (adjustment.undo.type === "set_passing_validity") { const passing = findPassingByUndoMarker(session, adjustment.competitorKey, adjustment.undo.passingTimestamp); if (!passing) { return false; } passing.validLap = adjustment.undo.validLap !== false; passing.invalidReason = adjustment.undo.validLap === false ? adjustment.undo.invalidReason || "manual_invalid" : ""; recalculateCompetitorFromPassings(session, adjustment.competitorKey); } else { return false; } adjustment.undoneAt = Date.now(); result.adjustments.push({ id: uid("judge"), ts: Date.now(), competitorKey: adjustment.competitorKey, displayName: adjustment.displayName || t("common.unknown_driver"), action: t("judging.undo_action"), detail: adjustment.action || "-", category: "undo", }); saveState(); return true; } function getJudgeFilteredRows(rows, filterValue) { if (filterValue === "invalid") { return rows.filter((row) => row.invalidPending); } if (filterValue === "corrected") { return rows.filter((row) => (Number(row.manualLapAdjustment || 0) || 0) !== 0 || (Number(row.manualTimeAdjustmentMs || 0) || 0) !== 0); } if (filterValue === "team") { return rows.filter((row) => Boolean(row.teamId)); } return rows; } function getJudgeFilteredLog(adjustments, filterValue) { if (filterValue === "corrections") { return adjustments.filter((entry) => entry.category === "correction"); } if (filterValue === "invalid") { return adjustments.filter((entry) => entry.category === "invalid"); } if (filterValue === "undo") { return adjustments.filter((entry) => entry.category === "undo"); } return adjustments; } 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; const followUpMs = Math.max(0, Number(session?.followUpSec || 0) || 0) * 1000; const followUpStartedAt = Number(session?.followUpStartedAt || 0) || 0; const followUpActive = Boolean(followUpStartedAt && followUpMs > 0); const followUpRemainingMs = followUpActive ? Math.max(0, followUpMs - Math.max(0, nowTs - followUpStartedAt)) : 0; return { targetMs, elapsedMs, remainingMs: targetMs === null ? null : Math.max(0, targetMs - elapsedMs), untimed: targetMs === null, followUpActive, followUpRemainingMs, }; } function ensureSessionResult(sessionId) { if (!state.resultsBySession[sessionId]) { state.resultsBySession[sessionId] = { passings: [], competitors: {}, adjustments: [], }; } if (!Array.isArray(state.resultsBySession[sessionId].adjustments)) { state.resultsBySession[sessionId].adjustments = []; } return state.resultsBySession[sessionId]; } function getFreePracticeSessions(eventId) { return getSessionsForEvent(eventId).filter((session) => session.type === "free_practice"); } function connectDecoder() { disconnectDecoder({ silent: true }); state.decoder.lastError = ""; saveState(); const url = state.settings.wsUrl; try { wsClient = new WebSocket(url); } catch (error) { state.decoder.lastError = t("error.ws_invalid", { msg: error instanceof Error ? error.message : String(error) }); saveState(); renderView(); return; } wsClient.onopen = () => { state.decoder.connected = true; state.decoder.lastError = ""; saveState(); updateConnectionBadge(); if (currentView === "timing" || currentView === "settings" || currentView === "overlay") { renderView(); } }; wsClient.onmessage = (event) => { state.decoder.lastMessageAt = Date.now(); let parsed; try { parsed = JSON.parse(String(event.data)); } catch { return; } if (Array.isArray(parsed)) { parsed.forEach(processDecoderMessage); } else { processDecoderMessage(parsed); } saveState(); updateConnectionBadge(); if (currentView === "timing" || currentView === "dashboard" || currentView === "overlay") { renderView(); } }; wsClient.onclose = () => { state.decoder.connected = false; saveState(); updateConnectionBadge(); if (state.settings.autoReconnect) { clearTimeout(reconnectTimer); reconnectTimer = setTimeout(() => { if (!state.decoder.connected) { connectDecoder(); } }, 2000); } if (currentView === "timing" || currentView === "settings" || currentView === "overlay") { renderView(); } }; wsClient.onerror = () => { state.decoder.lastError = t("error.decoder_connection"); saveState(); updateConnectionBadge(); if (currentView === "timing" || currentView === "settings" || currentView === "overlay") { renderView(); } }; } function disconnectDecoder(options = {}) { clearTimeout(reconnectTimer); if (wsClient) { wsClient.onopen = null; wsClient.onmessage = null; wsClient.onclose = null; wsClient.onerror = null; wsClient.close(); wsClient = null; } state.decoder.connected = false; if (!options.silent) { saveState(); } updateConnectionBadge(); } function processDecoderMessage(msg) { if (!msg || typeof msg !== "object") { return; } const type = String(msg.msg || msg.type || "").toUpperCase(); if (type !== "PASSING") { return; } const session = getActiveSession(); if (!session || session.status !== "running") { return; } const timestamp = parseRtcTime(msg.rtc_time) || Date.now(); const transponder = String(msg.transponder ?? msg.tran_code ?? "").replace("ID:", "").trim(); if (!transponder) { return; } const result = ensureSessionResult(session.id); const competitor = resolveCompetitor(session, transponder); if (competitor.ignore) { return; } const key = competitor.key; if (!result.competitors[key]) { result.competitors[key] = { key, teamId: competitor.teamId || null, teamName: competitor.teamName || "", driverId: competitor.driverId, driverName: competitor.driverName, displayName: competitor.displayName || competitor.driverName, subLabel: competitor.subLabel || competitor.carName || "", carId: competitor.carId, carName: competitor.carName, transponder, laps: 0, lastLapMs: null, bestLapMs: null, startTimestamp: null, lastTimestamp: null, }; } const entry = result.competitors[key]; entry.teamId = competitor.teamId || entry.teamId || null; entry.teamName = competitor.teamName || entry.teamName || ""; entry.displayName = competitor.displayName || entry.displayName || competitor.driverName; entry.subLabel = competitor.subLabel || entry.subLabel || competitor.carName || ""; entry.driverId = competitor.driverId ?? entry.driverId; entry.driverName = competitor.driverName || entry.driverName; entry.carId = competitor.carId ?? entry.carId; entry.carName = competitor.carName || entry.carName; entry.transponder = transponder; const startMode = normalizeStartMode(session.startMode); if (startMode === "staggered" && !entry.startTimestamp) { entry.startTimestamp = timestamp; entry.lastTimestamp = timestamp; announcePassing(entry); saveState(); return; } if (!entry.startTimestamp) { entry.startTimestamp = session.startedAt || timestamp; } const baseTs = entry.lastTimestamp || entry.startTimestamp || session.startedAt || timestamp; const lapMs = Math.max(0, timestamp - baseTs); const { minLapMs, maxLapMs } = getSessionLapWindow(session); let validLap = true; let invalidReason = ""; if (minLapMs > 0 && lapMs > 0 && lapMs < minLapMs) { validLap = false; invalidReason = "below_min"; } else if (Number.isFinite(maxLapMs) && maxLapMs > 0 && lapMs > maxLapMs) { validLap = false; invalidReason = "above_max"; } const passing = { timestamp, transponder, teamId: entry.teamId, teamName: entry.teamName, driverId: entry.driverId, driverName: entry.driverName, displayName: entry.displayName, subLabel: entry.subLabel, carId: entry.carId, carName: entry.carName, competitorKey: key, lapMs, validLap, invalidReason, strength: msg.strength, loopId: String(msg.loop_id || ""), resend: Boolean(msg.resend), }; if (!validLap) { result.passings.push(passing); if (invalidReason === "above_max") { entry.lastTimestamp = timestamp; } persistPassingToBackend(session.id, passing); saveState(); return; } entry.laps += 1; entry.lastLapMs = lapMs; entry.lastTimestamp = timestamp; if (lapMs > 500 && (!entry.bestLapMs || lapMs < entry.bestLapMs)) { entry.bestLapMs = lapMs; } result.passings.push(passing); persistPassingToBackend(session.id, passing); pushOverlayEvent("passing", `${entry.displayName || entry.driverName} • ${formatLap(entry.lastLapMs)}`); const leaderboard = buildLeaderboard(session); const leader = leaderboard[0]; if (leader?.key && lastOverlayLeaderKeyBySession[session.id] !== leader.key) { lastOverlayLeaderKeyBySession[session.id] = leader.key; pushOverlayEvent("leader", `${leader.displayName || leader.driverName} • P1`); } if (entry.bestLapMs && Number.isFinite(entry.bestLapMs)) { const bestKey = `${session.id}:${entry.key}`; const previousBest = lastOverlayBestLapByKey[bestKey]; if (!previousBest || entry.bestLapMs < previousBest) { lastOverlayBestLapByKey[bestKey] = entry.bestLapMs; pushOverlayEvent("bestlap", `${entry.displayName || entry.driverName} • ${formatLap(entry.bestLapMs)}`); } } const top3Keys = leaderboard.slice(0, 3).map((row) => row.key); const previousTop3 = lastOverlayTop3BySession[session.id] || []; if (top3Keys.join("|") !== previousTop3.join("|")) { lastOverlayTop3BySession[session.id] = top3Keys; if (previousTop3.length) { pushOverlayEvent("top3", `${t("overlay.mode_leaderboard")} • Top 3 updated`); } } announcePassing(entry); } function parseRtcTime(value) { if (!value || typeof value !== "string") { return null; } const ts = Date.parse(value); return Number.isFinite(ts) ? ts : null; } function resolveCompetitor(session, transponder) { const sessionType = String(session?.type || "").toLowerCase(); const isOpenPractice = sessionType === "open_practice"; const isFreePractice = sessionType === "free_practice"; const isOpenMonitoringSession = isOpenPractice || isFreePractice; const event = state.events.find((item) => item.id === session?.eventId) || null; if (session.mode === "track") { const matchingAssignments = (session.assignments || []).filter((a) => { const car = state.cars.find((c) => c.id === a.carId); return car?.transponder === transponder; }); if (matchingAssignments.length > 1) { return { key: `track_ambiguous_${transponder}`, driverId: null, driverName: `Ambiguous TP ${transponder}`, carId: null, carName: t("common.unknown_car"), }; } const assignment = matchingAssignments[0]; if (assignment) { const driver = state.drivers.find((d) => d.id === assignment.driverId); const car = state.cars.find((c) => c.id === assignment.carId); return { key: `track_${assignment.id}`, driverId: driver?.id || null, driverName: driver?.name || t("common.unknown_driver"), carId: car?.id || null, carName: car?.name || t("common.unknown_car"), }; } return { key: `track_tp_${transponder}`, driverId: null, driverName: t("common.unassigned_driver"), carId: null, carName: t("common.unknown_car"), }; } if (session.mode === "race" && sessionType === "team_race") { const driver = state.drivers.find((d) => d.transponder === transponder) || null; const car = state.cars.find((c) => c.transponder === transponder) || null; const team = event ? findEventTeamForPassing(event, driver?.id || null, car?.id || null) : null; if (!team) { return { key: `ignore_team_${transponder}`, ignore: true, }; } const memberBits = [driver?.name || "", car?.name || ""].filter(Boolean); return { key: `team_${team.id}`, teamId: team.id, teamName: team.name, displayName: team.name, subLabel: memberBits.join(" • ") || transponder, driverId: driver?.id || null, driverName: driver?.name || team.name, carId: car?.id || null, carName: car?.name || t("common.unknown_car"), }; } const driver = state.drivers.find((d) => d.transponder === transponder); if (driver) { if (!isOpenMonitoringSession && Array.isArray(session.driverIds) && session.driverIds.length && !session.driverIds.includes(driver.id)) { return { key: `ignore_${driver.id}`, ignore: true, }; } return { key: `driver_${driver.id}`, driverId: driver.id, driverName: driver.name, displayName: driver.name, subLabel: driver.transponder || "", carId: null, carName: t("common.driver_car"), }; } return { key: `driver_tp_${transponder}`, driverId: null, driverName: isOpenPractice ? transponder : isFreePractice ? `TP ${transponder}` : t("common.unknown_driver"), displayName: isOpenPractice ? transponder : isFreePractice ? `TP ${transponder}` : t("common.unknown_driver"), subLabel: transponder, carId: null, carName: t("common.unknown_car"), }; } function buildLeaderboard(session) { const result = ensureSessionResult(session.id); const sessionType = String(session.type || "").toLowerCase(); const targetMs = getSessionTargetMs(session); const useTargetTieBreak = session.status === "finished"; const useSeedRanking = ["practice", "qualification"].includes(sessionType) && Number(session.seedBestLapCount || 0) > 0; const isFreePractice = sessionType === "free_practice"; const isOpenPractice = sessionType === "open_practice"; const isRollingPractice = isFreePractice || isOpenPractice; const nowTs = Date.now(); const rows = Object.values(result.competitors).map((row) => { const allPassings = getCompetitorPassings(session, row, { includeInvalid: true }); const passings = allPassings.filter((passing) => isCountedPassing(passing)); const latestAnyPassing = allPassings.length ? allPassings[allPassings.length - 1] : null; const latestPassing = passings.length ? passings[passings.length - 1] : null; const lastPassingTs = latestPassing ? Number(latestPassing.timestamp || 0) : Number(row.lastTimestamp || 0) || 0; const rawElapsedMs = lastPassingTs ? Math.max(0, lastPassingTs - Number(row.startTimestamp || session.startedAt || lastPassingTs)) : getCompetitorElapsedMs(session, row); const manualLapAdjustment = Number(row.manualLapAdjustment || 0) || 0; const manualTimeAdjustmentMs = Number(row.manualTimeAdjustmentMs || 0) || 0; const totalElapsedMs = Math.max(0, rawElapsedMs + manualTimeAdjustmentMs); const distanceToTargetMs = Math.abs(targetMs - totalElapsedMs); const seedMetric = getCompetitorSeedMetric(session, row); const previousLapMs = passings.length >= 2 ? Number(passings[passings.length - 2].lapMs || 0) : null; const lastLapMs = latestPassing ? Number(latestPassing.lapMs || 0) : Number(row.lastLapMs || 0) || 0; const bestLapMs = Number(row.bestLapMs || 0) || 0; const lapDeltaMs = lastLapMs && previousLapMs && lastLapMs > 0 && previousLapMs > 0 ? lastLapMs - previousLapMs : null; const predictionBaseMs = lastLapMs > 0 ? lastLapMs : bestLapMs > 0 ? bestLapMs : null; const currentLapElapsedMs = lastPassingTs ? Math.max(0, nowTs - lastPassingTs) : 0; const predictedRemainingMs = predictionBaseMs ? predictionBaseMs - currentLapElapsedMs : null; const predictedProgress = predictionBaseMs ? currentLapElapsedMs / predictionBaseMs : 0; const predictionTone = !predictionBaseMs || predictedProgress <= 0.85 ? "good" : predictedProgress <= 1 ? "warn" : "late"; const invalidPending = latestAnyPassing?.validLap === false; return { ...row, laps: Math.max(0, Number(row.laps || 0) + manualLapAdjustment), lastLapMs, bestLapMs, lastTimestamp: lastPassingTs || row.lastTimestamp, totalElapsedMs, manualLapAdjustment, manualTimeAdjustmentMs, distanceToTargetMs, seedMetric, previousLapMs, lapDeltaMs, predictedRemainingMs, predictedProgress, predictionTone, invalidPending, invalidLabel: invalidPending ? getPassingValidationLabel(latestAnyPassing) : "", invalidLapMs: invalidPending ? Number(latestAnyPassing?.lapMs || 0) || 0 : 0, comparisonMs: isRollingPractice ? bestLapMs || lastLapMs || Number.MAX_SAFE_INTEGER : useSeedRanking && seedMetric ? seedMetric.comparableMs : totalElapsedMs, resultDisplay: isRollingPractice ? formatLap(bestLapMs || lastLapMs) : useSeedRanking && seedMetric ? formatSeedMetric(seedMetric) : `${Math.max(0, Number(row.laps || 0) + manualLapAdjustment)}/${formatRaceClock(totalElapsedMs)}`, }; }); rows.sort((a, b) => { if (isRollingPractice) { if (a.comparisonMs !== b.comparisonMs) { return a.comparisonMs - b.comparisonMs; } if (b.laps !== a.laps) { return b.laps - a.laps; } return (b.lastTimestamp || 0) - (a.lastTimestamp || 0); } if (useSeedRanking) { if (a.seedMetric && b.seedMetric && a.seedMetric.comparableMs !== b.seedMetric.comparableMs) { return a.seedMetric.comparableMs - b.seedMetric.comparableMs; } if (a.seedMetric && !b.seedMetric) { return -1; } if (!a.seedMetric && b.seedMetric) { return 1; } if (b.laps !== a.laps) { return b.laps - a.laps; } return a.totalElapsedMs - b.totalElapsedMs; } if (b.laps !== a.laps) { return b.laps - a.laps; } if (useTargetTieBreak && a.distanceToTargetMs !== b.distanceToTargetMs) { return a.distanceToTargetMs - b.distanceToTargetMs; } return a.totalElapsedMs - b.totalElapsedMs; }); const leader = rows[0]; return rows.map((row, index) => { if (!leader) { return { ...row, gap: "-", leaderGap: "-", gapAhead: "-", lapDelta: "-" }; } return { ...row, gap: formatLeaderboardGap(row, leader, { useSeedRanking, useTargetTieBreak, isFreePractice: isRollingPractice }), leaderGap: formatLeaderboardGap(row, leader, { useSeedRanking, useTargetTieBreak, isFreePractice: isRollingPractice }), gapAhead: formatLeaderboardGap(row, rows[index - 1], { useSeedRanking, useTargetTieBreak, isFreePractice: isRollingPractice, selfLabel: t("status.leader"), }), lapDelta: formatLapDelta(row.lapDeltaMs), }; }); } function formatLapDelta(deltaMs) { if (!deltaMs && deltaMs !== 0) { return "-"; } const sign = deltaMs <= 0 ? "-" : "+"; return `${sign}${(Math.abs(deltaMs) / 1000).toFixed(3)}s`; } function formatLeaderboardGap(row, referenceRow, options = {}) { if (!referenceRow) { return "-"; } if (row.key === referenceRow.key) { if (options.isFreePractice) { return t("status.free_practice"); } return options.selfLabel || (options.useSeedRanking ? t("status.seeded") : t("status.leader")); } if (options.isFreePractice) { if (Number.isFinite(row.comparisonMs) && Number.isFinite(referenceRow.comparisonMs)) { return `+${((row.comparisonMs - referenceRow.comparisonMs) / 1000).toFixed(3)}s`; } return "-"; } if (options.useSeedRanking) { if (referenceRow.seedMetric && row.seedMetric) { const seedGap = Math.max(0, row.seedMetric.comparableMs - referenceRow.seedMetric.comparableMs); 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 formatPredictedLapDelta(ms) { if (ms === null || ms === undefined || Number.isNaN(ms)) { return "-"; } if (ms >= 0) { return formatLap(ms); } return `+${formatLap(Math.abs(ms))}`; } function formatCountdown(ms) { const total = Math.max(0, Math.floor(ms)); const m = Math.floor(total / 60000) .toString() .padStart(2, "0"); const s = Math.floor((total % 60000) / 1000) .toString() .padStart(2, "0"); return `${m}:${s}`; } function formatElapsedClock(ms) { const total = Math.max(0, Math.floor(ms / 1000)); const h = Math.floor(total / 3600) .toString() .padStart(1, "0"); const m = Math.floor((total % 3600) / 60) .toString() .padStart(2, "0"); const s = Math.floor(total % 60) .toString() .padStart(2, "0"); return `${h}:${m}:${s}`; } function formatRaceClock(ms) { const total = Math.max(0, Math.floor(ms)); const m = Math.floor(total / 60000) .toString() .padStart(2, "0"); const s = Math.floor((total % 60000) / 1000) .toString() .padStart(2, "0"); const centiseconds = Math.floor((total % 1000) / 10) .toString() .padStart(2, "0"); const millis = Math.floor(total % 1000) .toString() .padStart(3, "0"); return `${m}:${s}.${centiseconds}:${millis}`; } function getSeedMethodLabel(method) { const normalized = ["best_sum", "average", "consecutive"].includes(String(method || "").toLowerCase()) ? String(method).toLowerCase() : "best_sum"; return t(`events.seed_method_${normalized}`); } function formatSeedMetric(metric) { if (!metric) { return "-"; } if (metric.method === "average") { return `${metric.lapCount} avg ${formatLap(metric.averageMs)}`; } if (metric.method === "consecutive") { return `${metric.lapCount} con ${formatRaceClock(metric.totalMs)}`; } return `${metric.lapCount}/${formatRaceClock(metric.totalMs)}`; } function getQualifyingPointsValue(place, fieldSize, tableType) { const normalized = ["rank_low", "field_desc", "ifmar"].includes(String(tableType || "").toLowerCase()) ? String(tableType).toLowerCase() : "rank_low"; if (normalized === "field_desc") { return Math.max(1, Number(fieldSize || 0) - place + 1); } if (normalized === "ifmar") { const scale = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]; return scale[place - 1] ?? 0; } return place; } function isHighPointsTable(tableType) { return ["field_desc", "ifmar"].includes(String(tableType || "").toLowerCase()); } function compareNumberSet(left, right, highWins = false) { for (let i = 0; i < Math.max(left.length, right.length); i += 1) { const leftValue = left[i] ?? (highWins ? -Infinity : Infinity); const rightValue = right[i] ?? (highWins ? -Infinity : Infinity); if (leftValue !== rightValue) { return highWins ? rightValue - leftValue : leftValue - rightValue; } } return 0; } function buildQualifyingTieBreakNote(row, tieBreak) { if (tieBreak === "best_lap") { return `${t("events.tie_break_note")}: ${t("events.qual_tie_break_best_lap")} • ${formatLap(row.bestSingleLapMs)}`; } if (tieBreak === "best_round") { return `${t("events.tie_break_note")}: ${t("events.qual_tie_break_best_round")} • ${row.bestRoundDisplay || formatLap(row.bestRoundMetric)}`; } return `${t("events.tie_break_note")}: ${t("events.counted_rounds_label")} • ${(row.ranks || []).join(" / ") || "-"}`; } function hasQualifyingPrimaryTie(left, right, scoringMode) { if (!left || !right) { return false; } if (scoringMode === "points") { return left.totalScore === right.totalScore; } return left.bestRank === right.bestRank; } function getCompetitorElapsedMs(session, row) { const startTs = Number(row?.startTimestamp || session?.startedAt || 0); if (!startTs || !row?.lastTimestamp) { return 0; } return Math.max(0, row.lastTimestamp - startTs); } function getCompetitorPassings(session, row, options = {}) { const result = ensureSessionResult(session.id); return result.passings .filter((passing) => { if (!options.includeInvalid && !isCountedPassing(passing)) { return false; } if (passing.competitorKey) { return passing.competitorKey === row.key; } return ( String(passing.transponder || "") === String(row.transponder || "") && String(passing.driverId || "") === String(row.driverId || "") && String(passing.carId || "") === String(row.carId || "") ); }) .sort((a, b) => a.timestamp - b.timestamp); } function getCompetitorSeedMetric(session, row) { const lapCount = Math.max(0, Number(session?.seedBestLapCount || 0) || 0); if (lapCount <= 0) { return null; } const method = ["best_sum", "average", "consecutive"].includes(String(session?.seedMethod || "").toLowerCase()) ? String(session.seedMethod).toLowerCase() : "best_sum"; const laps = getCompetitorPassings(session, row) .map((passing) => Number(passing.lapMs || 0)) .filter((lapMs) => lapMs > 500); if (laps.length < lapCount) { return null; } let selected = []; if (method === "consecutive") { let bestWindow = null; for (let index = 0; index <= laps.length - lapCount; index += 1) { const window = laps.slice(index, index + lapCount); const totalMs = window.reduce((sum, lapMs) => sum + lapMs, 0); if (!bestWindow || totalMs < bestWindow.totalMs) { bestWindow = { laps: window, totalMs }; } } if (!bestWindow) { return null; } selected = bestWindow.laps; } else { selected = [...laps].sort((a, b) => a - b).slice(0, lapCount); } const totalMs = selected.reduce((sum, lapMs) => sum + lapMs, 0); const averageMs = totalMs / lapCount; return { lapCount, method, totalMs, averageMs, comparableMs: method === "average" ? averageMs : totalMs, laps: selected, }; } function getEventDrivers(event) { const classDrivers = state.drivers.filter((driver) => !event?.classId || driver.classId === event.classId); if (!event?.raceConfig?.participantsConfigured) { return classDrivers; } return classDrivers.filter((driver) => (event.raceConfig.driverIds || []).includes(driver.id)); } function getEventTeams(event) { return Array.isArray(event?.raceConfig?.teams) ? event.raceConfig.teams.map((team) => normalizeRaceTeam(team)).filter((team) => team.name) : []; } function getTeamDriverPool(event) { const scopedDrivers = getEventDrivers(event); if (scopedDrivers.length) { return { drivers: scopedDrivers, fallback: false }; } return { drivers: [...state.drivers], fallback: state.drivers.length > 0, }; } function findEventTeamForPassing(event, driverId, carId) { return getEventTeams(event).find((team) => { const driverMatch = driverId && Array.isArray(team.driverIds) && team.driverIds.includes(driverId); const carMatch = carId && Array.isArray(team.carIds) && team.carIds.includes(carId); return Boolean(driverMatch || carMatch); }) || null; } function getSessionEntrants(session) { const event = state.events.find((item) => item.id === session.eventId); const eventDrivers = event ? getEventDrivers(event) : state.drivers; if (!Array.isArray(session.driverIds) || !session.driverIds.length) { return eventDrivers; } return eventDrivers.filter((driver) => session.driverIds.includes(driver.id)); } function buildPracticeStandings(event) { const sessions = getSessionsForEvent(event.id).filter((session) => session.type === "practice"); const competitorMap = new Map(); sessions.forEach((session) => { buildLeaderboard(session).forEach((row) => { const key = row.driverId || row.key; const seedMetric = getCompetitorSeedMetric(session, row); const comparableMs = seedMetric?.comparableMs ?? 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 ? formatSeedMetric(seedMetric) : `${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 pointsTable = event.raceConfig?.qualifyingPointsTable || "rank_low"; const highPointsWin = isHighPointsTable(pointsTable); const tieBreak = event.raceConfig?.qualifyingTieBreak || "rounds"; 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: [], roundMetrics: [], bestLaps: [], }); } const entry = competitorMap.get(key); entry.points.push(getQualifyingPointsValue(index + 1, entrantCount, pointsTable)); entry.ranks.push(index + 1); entry.roundMetrics.push(row.comparisonMs); if (Number.isFinite(row.bestLapMs)) { entry.bestLaps.push(row.bestLapMs); } if (!entry.bestRoundDisplay || row.comparisonMs < (entry.bestRoundMetricValue ?? Number.MAX_SAFE_INTEGER)) { entry.bestRoundMetricValue = row.comparisonMs; entry.bestRoundDisplay = row.resultDisplay; } 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) => (highPointsWin ? b - a : a - b)); const counted = sortedPoints.slice(0, countedRounds); const totalScore = counted.reduce((sum, value) => sum + value, 0); const bestRank = Math.min(...entry.ranks); const bestRoundMetric = Math.min(...entry.roundMetrics); const bestSingleLapMs = entry.bestLaps.length ? Math.min(...entry.bestLaps) : Number.MAX_SAFE_INTEGER; return { key: entry.key, driverId: entry.driverId, driverName: entry.driverName, ranks: [...entry.ranks].sort((a, b) => a - b), countedPoints: counted, totalScore, bestRank, bestRoundMetric, bestRoundDisplay: entry.bestRoundDisplay || formatLap(bestRoundMetric), bestSingleLapMs, score: scoringMode === "points" ? `${totalScore} (${counted.join("+")})` : `${bestRank} / ${formatRaceClock(bestRoundMetric)}`, }; }); rows.sort((a, b) => { if (scoringMode === "points") { if (a.totalScore !== b.totalScore) { return highPointsWin ? b.totalScore - a.totalScore : a.totalScore - b.totalScore; } if (tieBreak === "best_lap" && a.bestSingleLapMs !== b.bestSingleLapMs) { return a.bestSingleLapMs - b.bestSingleLapMs; } if (tieBreak === "best_round" && a.bestRoundMetric !== b.bestRoundMetric) { return a.bestRoundMetric - b.bestRoundMetric; } const pointDiff = compareNumberSet(a.countedPoints, b.countedPoints, highPointsWin); if (pointDiff !== 0) { return pointDiff; } return a.bestRoundMetric - b.bestRoundMetric; } if (a.bestRank !== b.bestRank) { return a.bestRank - b.bestRank; } if (tieBreak === "rounds") { const rankDiff = compareNumberSet(a.ranks, b.ranks, false); if (rankDiff !== 0) { return rankDiff; } } if (tieBreak === "best_lap" && a.bestSingleLapMs !== b.bestSingleLapMs) { return a.bestSingleLapMs - b.bestSingleLapMs; } return a.bestRoundMetric - b.bestRoundMetric; }); rows.forEach((row, index) => { row.tieBreakWonAgainst = ""; row.tieBreakLostAgainst = ""; if (index === 0) { return; } const previous = rows[index - 1]; if (!hasQualifyingPrimaryTie(previous, row, scoringMode)) { return; } previous.tieBreakWonAgainst = previous.tieBreakWonAgainst || row.driverName || t("common.unknown_driver"); row.tieBreakLostAgainst = row.tieBreakLostAgainst || previous.driverName || t("common.unknown_driver"); }); return rows.map((row, index) => ({ ...row, rank: index + 1, scoreNote: [ buildQualifyingTieBreakNote(row, tieBreak), row.tieBreakWonAgainst ? `${t("events.tie_break_won")}: ${row.tieBreakWonAgainst}` : "", row.tieBreakLostAgainst ? `${t("events.tie_break_lost")}: ${row.tieBreakLostAgainst}` : "", ] .filter(Boolean) .join(" • "), })); } 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 || "-")}
    ${row.scoreNote ? `
    ${escapeHtml(row.scoreNote)}
    ` : ""} ` ) ); } function formatTeamActiveMemberLabel(rowOrPassing) { if (!rowOrPassing) { return "-"; } const parts = [rowOrPassing.driverName || "", rowOrPassing.carName || ""].filter(Boolean); return parts.join(" • ") || rowOrPassing.subLabel || "-"; } function buildTeamRaceStandings(event) { return getSessionsForEvent(event.id) .filter((session) => session.type === "team_race") .sort((left, right) => getSessionSortWeight(left) - getSessionSortWeight(right) || String(left.name).localeCompare(String(right.name))) .map((session) => ({ session, rows: buildLeaderboard(session), })); } function buildTeamStintLog(session, row) { const passings = getCompetitorPassings(session, row); if (!passings.length) { return []; } const { maxLapMs } = getSessionLapWindow(session); const stintGapMs = Number.isFinite(maxLapMs) ? maxLapMs : Number.POSITIVE_INFINITY; const stints = []; let current = null; passings.forEach((passing) => { const memberLabel = formatTeamActiveMemberLabel(passing); const memberKey = `${passing.driverId || passing.driverName || "-"}|${passing.carId || passing.carName || "-"}`; const gapBreak = current && Number.isFinite(stintGapMs) && Math.max(0, passing.timestamp - current.endTs) > stintGapMs; if (!current || current.memberKey !== memberKey || gapBreak) { current = { memberKey, memberLabel, driverName: passing.driverName || "-", carName: passing.carName || "-", startTs: passing.timestamp, endTs: passing.timestamp, laps: 1, }; stints.push(current); return; } current.endTs = passing.timestamp; current.laps += 1; }); return stints.map((stint, index) => ({ ...stint, index: index + 1, durationMs: Math.max(0, stint.endTs - stint.startTs), })); } function renderTeamStintLog(session, rows) { if (!rows.length) { return `

    ${t("events.no_team_results")}

    `; } return `
    ${rows .map((row) => { const stints = buildTeamStintLog(session, row); return `
    ${escapeHtml(row.displayName || row.driverName)}
    ${t("overlay.active_member")}: ${escapeHtml(formatTeamActiveMemberLabel(row))}
    ${ stints.length ? renderTable( [t("events.slot"), t("table.driver"), t("table.car"), t("table.time"), t("table.duration"), t("table.laps")], stints.map( (stint) => ` ${stint.index} ${escapeHtml(stint.driverName || "-")} ${escapeHtml(stint.carName || "-")} ${new Date(stint.startTs).toLocaleTimeString()} ${formatRaceClock(stint.durationMs)} ${stint.laps} ` ) ) : `

    ${t("timing.no_passings")}

    ` }
    `; }) .join("")}
    `; } function renderTeamRaceStandings(event) { const groups = buildTeamRaceStandings(event); if (!groups.length) { return `

    ${t("events.no_team_results")}

    `; } return groups .map( ({ session, rows }) => `

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

    ${ rows.length ? renderTable( [t("table.pos"), t("events.team_name"), t("table.laps"), t("table.result"), t("table.best_lap")], rows.map( (row, index) => ` ${index + 1}
    ${escapeHtml(row.displayName || row.driverName)}
    ${t("overlay.active_member")}: ${escapeHtml(formatTeamActiveMemberLabel(row))}
    ${row.laps} ${escapeHtml(row.resultDisplay)} ${formatLap(row.bestLapMs)} ` ) ) : `

    ${t("events.no_team_results")}

    ` }
    ${t("events.team_stint_log")}
    ${rows.length ? renderTeamStintLog(session, rows) : `

    ${t("events.no_team_results")}

    `}
    ` ) .join(""); } function getSessionSortWeight(session) { const order = { open_practice: 0, free_practice: 1, practice: 2, qualification: 3, heat: 4, final: 5, team_race: 6, }; return order[String(session?.type || "").toLowerCase()] || 99; } function getDriverDisplayById(driverId) { const driver = state.drivers.find((item) => item.id === driverId); if (!driver) { return t("common.unknown_driver"); } return driver.transponder ? `${driver.name} (${driver.transponder})` : driver.name; } function getSessionGridEntries(session) { if (!session) { return []; } if (session.mode === "track") { return (session.assignments || []).map((assignment, index) => { const driver = state.drivers.find((item) => item.id === assignment.driverId); const car = state.cars.find((item) => item.id === assignment.carId); return { slot: index + 1, name: driver?.name || t("common.unknown_driver"), meta: car ? `${car.name} (${car.transponder || "-"})` : t("common.unknown_car"), }; }); } if (session.type === "team_race") { const event = state.events.find((item) => item.id === session.eventId); return getEventTeams(event).map((team, index) => ({ slot: index + 1, name: team.name, meta: team.driverIds.map((driverId) => getDriverDisplayById(driverId)).join(", ") || team.carIds .map((carId) => { const car = state.cars.find((item) => item.id === carId); return car ? `${car.name} (${car.transponder || "-"})` : ""; }) .filter(Boolean) .join(", ") || "-", })); } return getSessionGridOrder(session).map((driverId, index) => { const driver = state.drivers.find((item) => item.id === driverId); return { slot: index + 1, name: driver?.name || t("common.unknown_driver"), meta: driver?.transponder || "-", }; }); } function getSessionGridOrder(session) { if (!session) { return []; } if (Array.isArray(session.manualGridIds) && session.manualGridIds.length) { return session.manualGridIds; } return Array.isArray(session.driverIds) ? session.driverIds : []; } function renderPositionGrid(session) { const entries = getSessionGridEntries(session); if (!entries.length) { return ""; } return `

    ${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 qualSeedLapCount = Math.max(0, Number(event.raceConfig?.qualSeedLapCount || 2) || 0); const qualSeedMethod = ["best_sum", "average", "consecutive"].includes(String(event.raceConfig?.qualSeedMethod || "").toLowerCase()) ? String(event.raceConfig.qualSeedMethod).toLowerCase() : "best_sum"; const followUpSec = Math.max(0, Number(event.raceConfig?.followUpSec || 0) || 0); 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, followUpSec, followUpStartedAt: null, startMode: qualStartMode, seedBestLapCount: qualSeedLapCount, seedMethod: qualSeedMethod, 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 followUpSec = Math.max(0, Number(event.raceConfig?.followUpSec || 0) || 0); 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, followUpSec, followUpStartedAt: null, 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 buildPrintBrandBlock(branding) { return ` `; } function buildRaceStartListsHtml(event) { const branding = resolveEventBranding(event); const sessions = getSessionsForEvent(event.id) .filter((session) => session.mode === "race") .sort((left, right) => { const weightDiff = getSessionSortWeight(left) - getSessionSortWeight(right); if (weightDiff !== 0) { return weightDiff; } return String(left.name || "").localeCompare(String(right.name || "")); }); return `

    ${t("events.start_lists")}

    ${sessions .map((session) => { const entries = getSessionGridEntries(session); return ` `; }) .join("")} `; } function buildRaceResultsHtml(event) { const branding = resolveEventBranding(event); return ` `; } function buildTeamRaceResultsHtml(event) { const branding = resolveEventBranding(event); const groups = buildTeamRaceStandings(event); return ` ${groups .map( ({ session, rows }) => ` ` ) .join("")} `; } function openPrintWindow(title, bodyHtml) { const printWindow = window.open("", "_blank", "noopener,noreferrer,width=1200,height=900"); if (!printWindow) { alert(t("error.print_blocked")); return; } printWindow.document.write(` ${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) })); } } function loadImageElement(src) { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error("Image load failed")); image.src = src; }); } async function ensurePdfLogoDataUrl(dataUrl) { if (!dataUrl) { return ""; } if (/^data:image\/jpeg;base64,/i.test(dataUrl)) { return dataUrl; } try { const image = await loadImageElement(dataUrl); const canvas = document.createElement("canvas"); const width = Math.max(1, image.naturalWidth || image.width || 1); const height = Math.max(1, image.naturalHeight || image.height || 1); canvas.width = width; canvas.height = height; const context = canvas.getContext("2d"); if (!context) { return ""; } context.fillStyle = "#ffffff"; context.fillRect(0, 0, width, height); context.drawImage(image, 0, 0, width, height); return canvas.toDataURL("image/jpeg", 0.92); } catch { return ""; } } async function exportRaceStartListsPdf(event) { const branding = resolveEventBranding(event); const sessions = getSessionsForEvent(event.id) .filter((session) => session.mode === "race") .sort((left, right) => { const weightDiff = getSessionSortWeight(left) - getSessionSortWeight(right); if (weightDiff !== 0) { return weightDiff; } return String(left.name || "").localeCompare(String(right.name || "")); }); const sections = sessions.map((session) => buildPdfSection( `${session.name} • ${getSessionTypeLabel(session.type)}`, [t("events.slot"), t("table.driver"), t("table.brand"), t("table.transponder")], getSessionGridEntries(session).map((entry) => { const driver = state.drivers.find((item) => item.id === entry.id); return [String(entry.slot), entry.name, driver?.brand || "-", entry.meta || "-"]; }) ) ); await requestPdfExport({ filename: `${event.name.replaceAll(/\s+/g, "_")}_startlists.pdf`, title: event.name, subtitle: `${getClassName(event.classId)} • ${event.date || "-"}`, brandName: branding.brandName, brandTagline: branding.brandTagline, footer: branding.pdfFooter, theme: branding.pdfTheme, logoDataUrl: await ensurePdfLogoDataUrl(branding.logoDataUrl), sections, }); } async function exportRaceResultsPdf(event) { const branding = resolveEventBranding(event); await requestPdfExport({ filename: `${event.name.replaceAll(/\s+/g, "_")}_results.pdf`, title: event.name, subtitle: `${getClassName(event.classId)} • ${event.date || "-"}`, brandName: branding.brandName, brandTagline: branding.brandTagline, footer: branding.pdfFooter, theme: branding.pdfTheme, logoDataUrl: await ensurePdfLogoDataUrl(branding.logoDataUrl), sections: [ buildPdfSection( t("events.practice_standings"), [t("table.pos"), t("table.driver"), t("table.score")], buildPracticeStandings(event).map((row) => [String(row.rank), row.driverName || "-", row.score || "-"]) ), buildPdfSection( t("events.qualifying_standings"), [t("table.pos"), t("table.driver"), t("table.score")], buildQualifyingStandings(event).map((row) => [ String(row.rank), row.driverName || "-", row.scoreNote ? `${row.score || "-"} | ${row.scoreNote}` : row.score || "-", ]) ), buildPdfSection( t("events.final_standings"), [t("table.pos"), t("table.driver"), t("table.score")], buildFinalStandings(event).map((row) => [String(row.rank), row.driverName || "-", row.score || "-"]) ), ...buildTeamRaceStandings(event).map(({ session, rows }) => buildPdfSection( `${t("events.team_standings")} • ${session.name}`, [t("table.pos"), t("events.team_name"), t("table.laps"), t("table.result"), t("table.best_lap")], rows.map((row, index) => [ String(index + 1), row.displayName || row.driverName || "-", String(row.laps || 0), row.resultDisplay || "-", formatLap(row.bestLapMs), ]) ) ), ], }); } async function exportTeamRaceResultsPdf(event) { const branding = resolveEventBranding(event); const sections = []; buildTeamRaceStandings(event).forEach(({ session, rows }) => { sections.push( buildPdfSection( `${t("events.team_report")} • ${session.name}`, [t("table.pos"), t("events.team_name"), t("table.laps"), t("table.result"), t("table.best_lap")], rows.map((row, index) => [ String(index + 1), row.displayName || row.driverName || "-", String(row.laps || 0), row.resultDisplay || "-", formatLap(row.bestLapMs), ]) ) ); rows.forEach((row) => { const stints = buildTeamStintLog(session, row); sections.push( buildPdfSection( `${session.name} • ${row.displayName || row.driverName} • ${t("events.team_stint_log")}`, [t("events.slot"), t("table.driver"), t("table.car"), t("table.time"), t("table.duration"), t("table.laps")], stints.map((stint) => [ String(stint.index), stint.driverName || "-", stint.carName || "-", new Date(stint.startTs).toLocaleTimeString(), formatRaceClock(stint.durationMs), String(stint.laps || 0), ]) ) ); }); }); await requestPdfExport({ filename: `${event.name.replaceAll(/\s+/g, "_")}_team_report.pdf`, title: event.name, subtitle: `${t("events.team_report")} • ${getClassName(event.classId)} • ${event.date || "-"}`, brandName: branding.brandName, brandTagline: branding.brandTagline, footer: branding.pdfFooter, theme: branding.pdfTheme, logoDataUrl: await ensurePdfLogoDataUrl(branding.logoDataUrl), sections, }); } async function exportSessionHeatSheetPdf(session) { const event = state.events.find((item) => item.id === session.eventId); const branding = resolveEventBranding(event); await requestPdfExport({ filename: `${(event?.name || "event").replaceAll(/\s+/g, "_")}_${session.name.replaceAll(/\s+/g, "_")}.pdf`, title: event?.name || t("common.unknown_event"), subtitle: `${getSessionTypeLabel(session.type)} • ${session.name} • ${getClassName(event?.classId || "")}`, brandName: branding.brandName, brandTagline: branding.brandTagline, footer: branding.pdfFooter, theme: branding.pdfTheme, logoDataUrl: await ensurePdfLogoDataUrl(branding.logoDataUrl), sections: [ buildPdfSection( `${session.name} • ${getSessionTypeLabel(session.type)}`, [t("events.slot"), t("table.driver"), t("table.brand"), t("table.transponder")], getSessionGridEntries(session).map((entry) => { const driver = state.drivers.find((item) => item.id === entry.id); return [String(entry.slot), entry.name, driver?.brand || "-", entry.meta || "-"]; }) ), ], }); } function reorderList(items, fromIndex, toIndex) { const copy = [...items]; const [moved] = copy.splice(fromIndex, 1); copy.splice(toIndex, 0, moved); return copy; } function buildSessionHeatSheetHtml(session) { const event = state.events.find((item) => item.id === session.eventId); const branding = resolveEventBranding(event); const entries = getSessionGridEntries(session); return `

    ${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.brand"), t("table.transponder")], entries.map((entry) => { const driver = state.drivers.find((item) => item.id === entry.id); return ` ${entry.slot} ${escapeHtml(entry.name)} ${escapeHtml(driver?.brand || "-")} ${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", "brand", "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, state.drivers.find((item) => item.id === entry.id)?.brand || "", entry.meta || "", ]), ]; const csv = rows .map((row) => row.map((value) => `"${String(value || "").replaceAll('"', '""')}"`).join(",")) .join("\n"); const blob = new Blob([csv], { type: "text/csv;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${(event?.name || "event").replaceAll(/\s+/g, "_")}_${session.name.replaceAll(/\s+/g, "_")}.csv`; link.click(); URL.revokeObjectURL(url); } function buildOverlayUrl(mode = "leaderboard") { const url = new URL(window.location.href); url.searchParams.set("view", "overlay"); url.searchParams.set("overlayMode", mode); return url.toString(); } function openOverlayWindow(mode = "leaderboard") { const width = Math.max(1280, window.screen?.availWidth || 1600); const height = Math.max(720, window.screen?.availHeight || 900); const overlayWindow = window.open( buildOverlayUrl(mode), "_blank", `noopener,noreferrer,popup=yes,left=0,top=0,width=${width},height=${height}` ); if (!overlayWindow) { alert(t("error.print_blocked")); return; } overlayWindow.focus(); } function applyBumpsForRace(event) { const bumpCount = Math.max(0, Number(event.raceConfig?.bumpCount || 0) || 0); if (bumpCount <= 0) { return 0; } const standings = buildFinalStandings(event); if (!standings.length) { return 0; } const grouped = new Map(); standings.forEach((row) => { const mainKey = String(row.rank || "").charAt(0).toUpperCase(); if (!grouped.has(mainKey)) { grouped.set(mainKey, []); } grouped.get(mainKey).push(row); }); const mainKeys = [...grouped.keys()].sort(); let applied = 0; for (let index = 1; index < mainKeys.length; index += 1) { const upperMainKey = mainKeys[index - 1]; const lowerMainKey = mainKeys[index]; const lowerRows = grouped.get(lowerMainKey) || []; const bumpRows = lowerRows.slice(0, bumpCount); if (!bumpRows.length) { continue; } const upperSessions = getSessionsForEvent(event.id).filter((session) => { return session.type === "final" && String(session.name || "").toUpperCase().startsWith(upperMainKey) && session.status === "ready"; }); if (!upperSessions.length) { continue; } const bumpDriverIds = bumpRows.map((row) => row.driverId).filter(Boolean); if (!bumpDriverIds.length) { continue; } upperSessions.forEach((session) => { session.driverIds = Array.isArray(session.driverIds) ? session.driverIds : []; session.manualGridIds = Array.isArray(session.manualGridIds) ? session.manualGridIds : [...session.driverIds]; const reservedSlots = Math.max(0, Number(session.reservedBumpSlots || 0) || 0); const capacity = Math.max(0, Number(session.maxCars || event.raceConfig?.carsPerFinal || 0) || 0); const allowedSize = reservedSlots > 0 ? Math.min(capacity, session.driverIds.length + reservedSlots) : capacity || Infinity; let addedHere = 0; bumpDriverIds.forEach((driverId) => { if (!session.driverIds.includes(driverId) && session.driverIds.length < allowedSize) { session.driverIds.push(driverId); if (!session.manualGridIds.includes(driverId)) { session.manualGridIds.push(driverId); } applied += 1; addedHere += 1; } }); session.reservedBumpSlots = Math.max(0, reservedSlots - addedHere); }); } return applied; } function chunkArray(items, size) { const chunks = []; for (let index = 0; index < items.length; index += size) { chunks.push(items.slice(index, index + size)); } return chunks; } function ensureRaceParticipantsConfigured(event) { if (!event || event.mode !== "race") { return; } if (event.raceConfig?.participantsConfigured) { return; } const classDrivers = state.drivers.filter((driver) => !event.classId || driver.classId === event.classId); event.raceConfig.driverIds = classDrivers.map((driver) => driver.id); event.raceConfig.participantsConfigured = true; } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .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, sessionResult: state.resultsBySession[sessionId] || null }), }); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } backend.available = true; backend.lastError = ""; } catch (error) { backend.available = false; backend.lastError = t("error.passing_save_failed", { msg: error instanceof Error ? error.message : String(error) }); } }