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