diff --git a/README.md b/README.md index cd838de..68f0a04 100644 --- a/README.md +++ b/README.md @@ -67,13 +67,17 @@ Recommended deployment: - `Track Event (shared cars)` - UI separation: - `Event` = sponsor events with shared cars/transponders - - `Race Setup` = competition races with personal driver transponders + - `Race Setup` = untimed Open Practice or competition races with personal driver transponders +- Grouped navigation follows the operating flow: `Start`, `Create`, `Run and follow`, `Directory`, and `System and help`. +- `Dashboard -> Getting started` links directly to system setup, driver registration, race/practice creation, Timing, and Overlay. - Storage/import tools: - full race-data export/import for backup or moving to another machine - directory export/import for only classes, drivers and cars - CSV export for drivers and cars when you want simple spreadsheet lists - single race-package export/import from `Race Setup -> Manage -> Race Actions` - Race Setup includes: + - `Quick Open Practice`: enter only a name, create an untimed all-transponder session, and continue directly to Timing + - no class, registered drivers, preset, qualifying, finals, or duration is required for Quick Open Practice - grouped `Participants` and `Teams` sections in `Manage` - a four-step `Create Race Wizard` for new races - `Manage` split into `Setup`, `Format`, `Generation`, and `Live / results` diff --git a/README.sv.md b/README.sv.md index b29a2ef..ab54d37 100644 --- a/README.sv.md +++ b/README.sv.md @@ -65,13 +65,17 @@ Rekommenderad deploy: - `Track Event (shared cars)` - UI-separering: - `Event` = sponsor-event med delade bilar/transpondrar - - `Race Setup` = riktiga race med personlig transponder per förare + - `Race Setup` = otidsstyrd Open Practice eller riktiga race med personlig transponder per förare +- Grupperad meny följer arbetsordningen: `Start`, `Skapa`, `Kör och följ`, `Register` samt `System och hjälp`. +- `Översikt -> Kom igång` länkar direkt till systeminställningar, förarregister, race/practice, Tidtagning och Overlay. - Lagring/import: - full export/import av race-data för backup eller flytt till annan maskin - register-export/import för bara klasser, förare och bilar - CSV-export för förare och bilar när du vill ha enkla listor i kalkylark - export/import av ett enskilt racepaket via `Race Setup -> Hantera -> Race actions` - `Race Setup` innehåller nu även: + - `Snabbstart Open Practice`: ange bara ett namn, skapa en otidsstyrd session för alla transpondrar och fortsätt direkt till Tidtagning + - ingen klass, registrerad förare, preset, kval, final eller tidslängd krävs för Snabbstart Open Practice - grupperade sektioner för `Deltagare` och `Lag` i `Hantera` - en fyrstegs `Create Race Wizard` för nya race - `Hantera` uppdelad i `Setup`, `Format`, `Generering` och `Live / resultat` diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index f0974f2..15b8f5c 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -294,6 +294,7 @@ Owns: - team normalization - race presets - race wizard defaults and session generation +- practice-only Open Practice event/session generation - sponsor round generation - event drivers / teams / team driver pool - qualifying/finals generation and bumps @@ -1013,6 +1014,7 @@ Why fragile: High-risk functions: - `normalizeEvent(...)` - `buildRaceFormatConfigFromForm(...)` +- `buildOpenPracticeBundle(...)` - `getEventDrivers(...)` - `getTeamDriverPool(...)` - `generateQualifyingForRace(...)` @@ -1419,9 +1421,12 @@ Practical normalized shape: participantsConfigured: boolean, finalsSource: "practice" | "qualifying", teams: RaceTeam[] + practiceOnly: boolean } ``` +`practiceOnly: true` identifies the simplified Open Practice flow. It deliberately has no class scope or configured participants, uses an `open_practice` session, ignores `durationMin` in timing logic, and must not require qualifying/finals in race summary warnings. + ### `RaceTeam` ```js @@ -2075,6 +2080,7 @@ npm test `tests/race_timing_logic.test.js` covers: - race-format parsing, defaults, lap limits, participants, and teams +- practice-only Open Practice bundle creation and persisted mode markers - wizard generation of practice, qualifying, and team-race sessions - qualifying heat generation and manual-grid-safe reseeding - final generation, reserved bump slots, and bump-up promotion diff --git a/src/app.js b/src/app.js index 5dc1ec0..737914f 100644 --- a/src/app.js +++ b/src/app.js @@ -4,7 +4,7 @@ import { renderSettings } from "./settings.js"; import { renderRaceFormatField, renderRaceFormatContextCard, getRaceSummaryItems, getRaceSummaryWarnings, renderManageStatusBadge, renderRaceWizardSteps, renderRaceWizardContent, renderRaceStandingsTable } from "./race_setup_ui.js"; -import { normalizeRaceTeam as normalizeRaceTeamLogic, normalizeStoredRacePreset as normalizeStoredRacePresetLogic, getRaceFormatPresets as getRaceFormatPresetsLogic, applyRaceFormatPreset as applyRaceFormatPresetLogic, buildRaceFormatConfigFromForm as buildRaceFormatConfigFromFormLogic, normalizeEvent as normalizeEventLogic, normalizeBrandingConfig as normalizeBrandingConfigLogic, resolveEventBranding as resolveEventBrandingLogic, getRaceManageStatuses as getRaceManageStatusesLogic, buildDefaultRaceWizardDraft as buildDefaultRaceWizardDraftLogic, getRaceWizardPreset as getRaceWizardPresetLogic, applyRaceWizardPresetDefaults as applyRaceWizardPresetDefaultsLogic, ensureRaceParticipantsConfigured as ensureRaceParticipantsConfiguredLogic, buildRaceSession as buildRaceSessionLogic, buildTrackSession as buildTrackSessionLogic, createSponsorRounds as createSponsorRoundsLogic, buildRaceSessionsFromWizard as buildRaceSessionsFromWizardLogic, getRaceWizardSessionPlan as getRaceWizardSessionPlanLogic, getEventDrivers as getEventDriversLogic, getEventTeams as getEventTeamsLogic, getTeamDriverPool as getTeamDriverPoolLogic, findEventTeamForPassing as findEventTeamForPassingLogic, generateQualifyingForRace as generateQualifyingForRaceLogic, reseedUpcomingQualifying as reseedUpcomingQualifyingLogic, generateFinalsForRace as generateFinalsForRaceLogic, applyBumpsForRace as applyBumpsForRaceLogic } from "./event_race_logic.js"; +import { normalizeRaceTeam as normalizeRaceTeamLogic, normalizeStoredRacePreset as normalizeStoredRacePresetLogic, getRaceFormatPresets as getRaceFormatPresetsLogic, applyRaceFormatPreset as applyRaceFormatPresetLogic, buildRaceFormatConfigFromForm as buildRaceFormatConfigFromFormLogic, normalizeEvent as normalizeEventLogic, normalizeBrandingConfig as normalizeBrandingConfigLogic, resolveEventBranding as resolveEventBrandingLogic, getRaceManageStatuses as getRaceManageStatusesLogic, buildDefaultRaceWizardDraft as buildDefaultRaceWizardDraftLogic, getRaceWizardPreset as getRaceWizardPresetLogic, applyRaceWizardPresetDefaults as applyRaceWizardPresetDefaultsLogic, ensureRaceParticipantsConfigured as ensureRaceParticipantsConfiguredLogic, buildRaceSession as buildRaceSessionLogic, buildOpenPracticeBundle as buildOpenPracticeBundleLogic, buildTrackSession as buildTrackSessionLogic, createSponsorRounds as createSponsorRoundsLogic, buildRaceSessionsFromWizard as buildRaceSessionsFromWizardLogic, getRaceWizardSessionPlan as getRaceWizardSessionPlanLogic, getEventDrivers as getEventDriversLogic, getEventTeams as getEventTeamsLogic, getTeamDriverPool as getTeamDriverPoolLogic, findEventTeamForPassing as findEventTeamForPassingLogic, generateQualifyingForRace as generateQualifyingForRaceLogic, reseedUpcomingQualifying as reseedUpcomingQualifyingLogic, generateFinalsForRace as generateFinalsForRaceLogic, applyBumpsForRace as applyBumpsForRaceLogic } from "./event_race_logic.js"; import { getSessionTypeLabel as getSessionTypeLabelLogic, getStatusLabel as getStatusLabelLogic, isUntimedSession as isUntimedSessionLogic, getActiveSession as getActiveSessionLogic, getSessionTargetMs as getSessionTargetMsLogic, getSessionLapWindow as getSessionLapWindowLogic, isCountedPassing as isCountedPassingLogic, getVisiblePassings as getVisiblePassingsLogic, getPassingValidationLabel as getPassingValidationLabelLogic, getSessionTiming as getSessionTimingLogic, ensureSessionResult as ensureSessionResultLogic, buildLeaderboard as buildLeaderboardLogic, formatLapDelta as formatLapDeltaLogic, formatLeaderboardGap as formatLeaderboardGapLogic, getCompetitorElapsedMs as getCompetitorElapsedMsLogic, getCompetitorPassings as getCompetitorPassingsLogic, getCompetitorSeedMetric as getCompetitorSeedMetricLogic, getSessionEntrants as getSessionEntrantsLogic, buildPracticeStandings as buildPracticeStandingsLogic, getQualifyingPointsValue as getQualifyingPointsValueLogic, isHighPointsTable as isHighPointsTableLogic, compareNumberSet as compareNumberSetLogic, buildQualifyingTieBreakNote as buildQualifyingTieBreakNoteLogic, hasQualifyingPrimaryTie as hasQualifyingPrimaryTieLogic, buildQualifyingStandings as buildQualifyingStandingsLogic, formatTeamActiveMemberLabel as formatTeamActiveMemberLabelLogic, buildTeamRaceStandings as buildTeamRaceStandingsLogic, buildTeamStintLog as buildTeamStintLogLogic, getSessionGridEntries as getSessionGridEntriesLogic, getSessionGridOrder as getSessionGridOrderLogic, ensureSessionDriverOrder as ensureSessionDriverOrderLogic, buildFinalStandings as buildFinalStandingsLogic } from "./timing_logic.js"; @@ -51,6 +51,7 @@ const getRaceWizardPreset = (presetId) => getRaceWizardPresetLogic(presetId, { g const applyRaceWizardPresetDefaults = (draft, presetId) => applyRaceWizardPresetDefaultsLogic(draft, presetId, { getRaceWizardPreset }); const ensureRaceParticipantsConfigured = (event) => ensureRaceParticipantsConfiguredLogic(event, { state }); const buildRaceSession = (eventId, name, type, durationMin, overrides = {}) => buildRaceSessionLogic(eventId, name, type, durationMin, overrides, { normalizeSession, uid }); +const buildOpenPracticeBundle = (input) => buildOpenPracticeBundleLogic(input, { normalizeEvent, buildRaceSession, uid }); const buildTrackSession = (eventId, name, type, durationMin) => buildTrackSessionLogic(eventId, name, type, durationMin, { normalizeSession, uid }); const createSponsorRounds = (eventId, config) => createSponsorRoundsLogic(eventId, config, { state, t, buildTrackSession }); const buildRaceSessionsFromWizard = (event, draft) => buildRaceSessionsFromWizardLogic(event, draft, { t, buildRaceSession }); @@ -189,7 +190,7 @@ const processDecoderMessage = (msg) => processDecoderMessageHelper(msg, { lastOverlayTop3BySession, lastOverlayBestLapByKey, }); -const renderDashboard = () => renderDashboardView({ state, dom, t, backend, getActiveSession, getStatusLabel, getSessionTypeLabel, getEventName, getModeLabel, getBackendUrl, formatLap, renderSessionsTable, setCurrentView: (view) => { currentView = view; }, renderNav, renderView, connectDecoder, disconnectDecoder, openOverlayWindow, ensureAudioContext, playPassingBeep, playFinishSiren, escapeHtml }); +const renderDashboard = () => renderDashboardView({ state, dom, t, backend, getActiveSession, getStatusLabel, getSessionTypeLabel, getEventName, getModeLabel, getBackendUrl, formatLap, renderSessionsTable, setCurrentView: (view) => { currentView = view; }, updateViewUrl, renderNav, renderView, connectDecoder, disconnectDecoder, openOverlayWindow, ensureAudioContext, playPassingBeep, playFinishSiren, escapeHtml }); const renderClasses = () => renderClassesView({ state, dom, t, selectedClassEditId: () => selectedClassEditId, setSelectedClassEditId: (value) => { selectedClassEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell }); const renderDrivers = () => renderDriversView({ state, dom, t, driverBrandFilter: () => driverBrandFilter, setDriverBrandFilter: (value) => { driverBrandFilter = value; }, selectedDriverEditId: () => selectedDriverEditId, setSelectedDriverEditId: (value) => { selectedDriverEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell, normalizeDriver, getClassName }); const renderCars = () => renderCarsView({ state, dom, t, carBrandFilter: () => carBrandFilter, setCarBrandFilter: (value) => { carBrandFilter = value; }, selectedCarEditId: () => selectedCarEditId, setSelectedCarEditId: (value) => { selectedCarEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell, normalizeCar }); @@ -217,18 +218,18 @@ const getJudgeFilteredRows = (rows, filterValue) => getJudgeFilteredRowsLogic(ro const getJudgeFilteredLog = (adjustments, filterValue) => getJudgeFilteredLogLogic(adjustments, filterValue); 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: "training", titleKey: "nav.training", subtitleKey: "nav.training_sub" }, - { id: "timing", titleKey: "nav.timing", subtitleKey: "nav.timing_sub" }, - { id: "judging", titleKey: "nav.judging", subtitleKey: "nav.judging_sub" }, - { id: "settings", titleKey: "nav.settings", subtitleKey: "nav.settings_sub" }, - { id: "guide", titleKey: "nav.guide", subtitleKey: "nav.guide_sub" }, + { id: "dashboard", titleKey: "nav.dashboard", subtitleKey: "nav.dashboard_sub", groupKey: "nav.group_start" }, + { id: "race_setup", titleKey: "nav.race_setup", subtitleKey: "nav.race_setup_sub", groupKey: "nav.group_create" }, + { id: "events", titleKey: "nav.events", subtitleKey: "nav.events_sub", groupKey: "nav.group_create" }, + { id: "timing", titleKey: "nav.timing", subtitleKey: "nav.timing_sub", groupKey: "nav.group_run" }, + { id: "judging", titleKey: "nav.judging", subtitleKey: "nav.judging_sub", groupKey: "nav.group_run" }, + { id: "overlay", titleKey: "nav.overlay", subtitleKey: "nav.overlay_sub", groupKey: "nav.group_run" }, + { id: "training", titleKey: "nav.training", subtitleKey: "nav.training_sub", groupKey: "nav.group_run" }, + { id: "classes", titleKey: "nav.classes", subtitleKey: "nav.classes_sub", groupKey: "nav.group_register" }, + { id: "drivers", titleKey: "nav.drivers", subtitleKey: "nav.drivers_sub", groupKey: "nav.group_register" }, + { id: "cars", titleKey: "nav.cars", subtitleKey: "nav.cars_sub", groupKey: "nav.group_register" }, + { id: "settings", titleKey: "nav.settings", subtitleKey: "nav.settings_sub", groupKey: "nav.group_system" }, + { id: "guide", titleKey: "nav.guide", subtitleKey: "nav.guide_sub", groupKey: "nav.group_system" }, ]; const SESSION_TYPES = ["open_practice", "free_practice", "practice", "qualification", "heat", "final", "team_race"]; @@ -246,10 +247,15 @@ const TRANSLATIONS = { sv: { "nav.dashboard": "Översikt", "nav.dashboard_sub": "Status och liveinformation", - "nav.events": "Event", + "nav.group_start": "Start", + "nav.group_create": "Skapa", + "nav.group_run": "Kör och följ", + "nav.group_register": "Register", + "nav.group_system": "System och hjälp", + "nav.events": "Sponsor Event", "nav.events_sub": "Sponsor-event och delade bilar", "nav.race_setup": "Race Setup", - "nav.race_setup_sub": "Tävlingsrace och heatupplägg", + "nav.race_setup_sub": "Open Practice, klubbrace och endurance", "nav.overlay": "Overlay", "nav.overlay_sub": "Extern leaderboard-skärm", "nav.classes": "Klasser", @@ -258,8 +264,8 @@ const TRANSLATIONS = { "nav.drivers_sub": "Förare och personliga transpondrar", "nav.cars": "Bilar", "nav.cars_sub": "Bilar med fasta transpondrar", - "nav.training": "Träning", - "nav.training_sub": "Utveckling, delmål och belöningar", + "nav.training": "Träningsstatistik", + "nav.training_sub": "Utveckling, delmål och belöningar per förare", "nav.timing": "Tidtagning", "nav.timing_sub": "Live timing-board", "nav.judging": "Domare", @@ -385,6 +391,21 @@ const TRANSLATIONS = { "dashboard.ahead": "Före schema", "dashboard.behind": "Efter schema", "dashboard.live_note": "Snabb driftpanel för anslutning, overlay och ljud. Djupare konfig ligger kvar under Inställningar.", + "dashboard.get_started": "Kom igång", + "dashboard.get_started_hint": "Följ arbetsordningen första gången. Vid vanlig träning kan du gå direkt till Open Practice.", + "dashboard.step_system": "1. Anslut systemet", + "dashboard.step_system_hint": "Konfigurera backend, decoder och AMMC i Inställningar.", + "dashboard.step_register": "2. Lägg in register", + "dashboard.step_register_hint": "Skapa klasser, förare och bilar när du behöver namngivna resultat.", + "dashboard.step_create": "3. Skapa körning", + "dashboard.step_create_hint": "Starta enkel Open Practice eller bygg klubbrace, endurance och sponsor-event.", + "dashboard.step_run": "4. Kör och visa", + "dashboard.step_run_hint": "Starta sessionen i Tidtagning och öppna Overlay på extern skärm.", + "dashboard.open_settings": "Öppna Inställningar", + "dashboard.open_register": "Öppna Förare", + "dashboard.open_race_setup": "Open Practice / Race Setup", + "dashboard.open_overlay": "Öppna Overlay", + "dashboard.untimed": "Ingen tidsgräns", "session.none_yet": "Inga sessioner ännu.", "classes.create": "Skapa klass", "classes.placeholder": "Klassnamn (t.ex. 2WD Buggy)", @@ -406,6 +427,15 @@ const TRANSLATIONS = { "cars.title": "Bilar", "events.create": "Skapa event", "events.create_race": "Skapa race", + "events.create_competition_race": "Skapa tävlingsrace", + "events.quick_open_practice_title": "Snabbstart Open Practice", + "events.quick_open_practice_intro": "För löpande tidtagning utan raceformat. Skapa sessionen och kör tills du själv stoppar den.", + "events.quick_open_practice_default": "Open Practice", + "events.quick_open_practice_create": "Skapa och öppna Tidtagning", + "events.open_practice_untimed": "Otidsstyrd", + "events.open_practice_all_transponders": "Alla transpondrar", + "events.open_practice_no_registration": "Fungerar utan registrerade förare", + "events.open_practice_until_stopped": "Pågår tills du stoppar den", "events.name_placeholder": "Eventnamn", "events.field_name": "Eventnamn", "events.field_name_hint": "Namnet som visas i listor, overlay och resultat.", @@ -422,6 +452,7 @@ const TRANSLATIONS = { "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.open_timing": "Öppna Tidtagning", "events.edit": "Redigera", "events.sessions": "Sessioner", "events.participants": "Deltagare", @@ -1021,6 +1052,8 @@ const TRANSLATIONS = { "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.open_practice_4": "Gå till Race Setup och använd Snabbstart Open Practice. Du behöver bara ange ett namn; klass, deltagare, preset och racetid behövs inte.", + "guide.open_practice_5": "Efter skapandet öppnas Tidtagning direkt. Klicka Starta och låt sessionen gå tills du själv stoppar den.", "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.", @@ -1047,6 +1080,7 @@ const TRANSLATIONS = { "guide.dashboard_1": "Översikt visar nu skillnaden mellan planerad tid och faktisk körtid för alla startade sessioner.", "guide.dashboard_2": "Planerad tid räknar sessionstid plus follow-up. Faktisk tid räknar verklig tid från start till stopp eller nuvarande tid om heatet fortfarande kör.", "guide.dashboard_3": "Det gör det lättare att se om tävlingsdagen ligger före eller efter schema direkt från dashboarden.", + "guide.dashboard_4": "Kom igång-korten och den grupperade sidomenyn följer samma ordning: anslut systemet, registrera vid behov, skapa körning och öppna sedan Tidtagning/Overlay.", "guide.training_title": "Träning, utveckling och belöningar", "guide.training_1": "1. Registrera föraren och koppla den personliga transpondern under Förare. Endast passeringar med ett registrerat förar-ID kan räknas i träningsprofilen.", "guide.training_2": "2. Kör Free Practice, Open Practice eller en vanlig racesession. Giltiga varv kopieras automatiskt till en beständig träningslogg; korta/långa ogiltiga varv räknas inte.", @@ -1140,10 +1174,15 @@ const TRANSLATIONS = { en: { "nav.dashboard": "Dashboard", "nav.dashboard_sub": "Overview and live status", - "nav.events": "Events", + "nav.group_start": "Start", + "nav.group_create": "Create", + "nav.group_run": "Run and follow", + "nav.group_register": "Directory", + "nav.group_system": "System and help", + "nav.events": "Sponsor Events", "nav.events_sub": "Sponsor events and shared cars", "nav.race_setup": "Race Setup", - "nav.race_setup_sub": "Competition race and heat setup", + "nav.race_setup_sub": "Open Practice, club races and endurance", "nav.overlay": "Overlay", "nav.overlay_sub": "External leaderboard screen", "nav.classes": "Classes", @@ -1152,8 +1191,8 @@ const TRANSLATIONS = { "nav.drivers_sub": "Drivers and personal transponders", "nav.cars": "Cars", "nav.cars_sub": "Track cars with fixed transponders", - "nav.training": "Training", - "nav.training_sub": "Development, milestones and rewards", + "nav.training": "Training stats", + "nav.training_sub": "Development, milestones and rewards per driver", "nav.timing": "Timing", "nav.timing_sub": "Live timing board", "nav.judging": "Judging", @@ -1279,6 +1318,21 @@ const TRANSLATIONS = { "dashboard.ahead": "Ahead of schedule", "dashboard.behind": "Behind schedule", "dashboard.live_note": "Quick operations panel for connection, overlay and audio. Deeper configuration remains under Settings.", + "dashboard.get_started": "Getting started", + "dashboard.get_started_hint": "Follow this order for initial setup. For normal practice, go directly to Open Practice.", + "dashboard.step_system": "1. Connect the system", + "dashboard.step_system_hint": "Configure backend, decoder and AMMC in Settings.", + "dashboard.step_register": "2. Build the directory", + "dashboard.step_register_hint": "Add classes, drivers and cars when named results are required.", + "dashboard.step_create": "3. Create a run", + "dashboard.step_create_hint": "Start a simple Open Practice or build club races, endurance and sponsor events.", + "dashboard.step_run": "4. Run and display", + "dashboard.step_run_hint": "Start the session in Timing and open Overlay on an external display.", + "dashboard.open_settings": "Open Settings", + "dashboard.open_register": "Open Drivers", + "dashboard.open_race_setup": "Open Practice / Race Setup", + "dashboard.open_overlay": "Open Overlay", + "dashboard.untimed": "No time limit", "session.none_yet": "No sessions yet.", "classes.create": "Create Class", "classes.placeholder": "Class name (e.g. 2WD Buggy)", @@ -1300,6 +1354,15 @@ const TRANSLATIONS = { "cars.title": "Cars", "events.create": "Create Event", "events.create_race": "Create Race", + "events.create_competition_race": "Create competition race", + "events.quick_open_practice_title": "Quick Open Practice", + "events.quick_open_practice_intro": "For continuous timing without a race format. Create the session and run it until you stop it.", + "events.quick_open_practice_default": "Open Practice", + "events.quick_open_practice_create": "Create and open Timing", + "events.open_practice_untimed": "Untimed", + "events.open_practice_all_transponders": "All transponders", + "events.open_practice_no_registration": "Works without registered drivers", + "events.open_practice_until_stopped": "Runs until you stop it", "events.name_placeholder": "Event name", "events.field_name": "Event name", "events.field_name_hint": "Name shown in lists, overlays and results.", @@ -1316,6 +1379,7 @@ const TRANSLATIONS = { "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.open_timing": "Open Timing", "events.edit": "Edit", "events.sessions": "Sessions", "events.participants": "Participants", @@ -1915,6 +1979,8 @@ const TRANSLATIONS = { "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.open_practice_4": "Go to Race Setup and use Quick Open Practice. Only a name is required; class, participants, preset and race duration are not needed.", + "guide.open_practice_5": "Timing opens directly after creation. Click Start and let the session run until you stop it yourself.", "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.", @@ -1941,6 +2007,7 @@ const TRANSLATIONS = { "guide.dashboard_1": "Overview now shows the difference between planned time and actual elapsed time for all started sessions.", "guide.dashboard_2": "Planned time includes session duration plus follow-up. Actual time uses real time from start to stop, or the current time if the heat is still running.", "guide.dashboard_3": "That makes it easier to see whether the race day is running ahead or behind schedule directly from the dashboard.", + "guide.dashboard_4": "The Getting started cards and grouped side navigation follow the same order: connect the system, register entries when needed, create a run, then open Timing/Overlay.", "guide.training_title": "Practice, development and rewards", "guide.training_1": "1. Register the driver and personal transponder under Drivers. Only passings linked to a registered driver ID count toward the practice profile.", "guide.training_2": "2. Run Free Practice, Open Practice or a regular race session. Valid laps are copied automatically to a durable training ledger; invalid short or long laps do not count.", @@ -2827,7 +2894,15 @@ function renderNav() { return; } dom.nav.innerHTML = ""; + let currentGroupKey = ""; NAV_ITEMS.forEach((item) => { + if (item.groupKey && item.groupKey !== currentGroupKey) { + currentGroupKey = item.groupKey; + const groupLabel = document.createElement("div"); + groupLabel.className = "nav-group-label"; + groupLabel.textContent = t(currentGroupKey); + dom.nav.appendChild(groupLabel); + } const button = document.createElement("button"); button.className = `nav-item ${item.id === currentView ? "active" : ""}`; button.textContent = t(item.titleKey); @@ -3054,6 +3129,7 @@ function renderEventWorkspace(mode) { uid, normalizeEvent, applyRaceFormatPreset, + buildOpenPracticeBundle, buildRaceSessionsFromWizard, buildDefaultRaceWizardDraft, applyRaceWizardPresetDefaults, @@ -3063,6 +3139,15 @@ function renderEventWorkspace(mode) { bindModalShell, isValidIsoDate, saveState, + openTimingSession: (sessionId) => { + state.activeSessionId = sessionId; + saveState(); + currentView = "timing"; + updateViewUrl(currentView); + renderNav(); + updateHeaderState(); + renderView(); + }, }); } diff --git a/src/core_views.js b/src/core_views.js index b5507e4..c42772b 100644 --- a/src/core_views.js +++ b/src/core_views.js @@ -51,6 +51,7 @@ export function renderDashboardView(deps) { formatLap, renderSessionsTable, setCurrentView, + updateViewUrl, renderNav, renderView, connectDecoder, @@ -75,6 +76,40 @@ export function renderDashboardView(deps) { : t("settings.passing_sound_off"); dom.view.innerHTML = ` +
+
+
+

${t("dashboard.get_started")}

+

${t("dashboard.get_started_hint")}

+
+
+
+
+ 1 +
${t("dashboard.step_system")}

${t("dashboard.step_system_hint")}

+ +
+
+ 2 +
${t("dashboard.step_register")}

${t("dashboard.step_register_hint")}

+ +
+
+ 3 +
${t("dashboard.step_create")}

${t("dashboard.step_create_hint")}

+ +
+
+ 4 +
${t("dashboard.step_run")}

${t("dashboard.step_run_hint")}

+
+ + +
+
+
+
+
${statCard(t("dashboard.events"), String(state.events.length), t("dashboard.created"))} ${statCard(t("dashboard.drivers"), String(state.drivers.length), t("dashboard.registered"))} @@ -93,7 +128,7 @@ export function renderDashboardView(deps) { active ? `

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

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

-

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

` +

${t("dashboard.duration")}: ${active.type === "open_practice" ? t("dashboard.untimed") : `${active.durationMin} min`}

` : `

${t("dashboard.no_session")}

` }
@@ -157,17 +192,22 @@ export function renderDashboardView(deps) { `; - document.getElementById("goEvents")?.addEventListener("click", () => { - setCurrentView("events"); + const navigateTo = (view) => { + setCurrentView(view); + updateViewUrl(view); renderNav(); renderView(); - }); + }; - document.getElementById("goTiming")?.addEventListener("click", () => { - setCurrentView("timing"); - renderNav(); - renderView(); - }); + document.getElementById("workflowSettings")?.addEventListener("click", () => navigateTo("settings")); + document.getElementById("workflowDrivers")?.addEventListener("click", () => navigateTo("drivers")); + document.getElementById("workflowRaceSetup")?.addEventListener("click", () => navigateTo("race_setup")); + document.getElementById("workflowTiming")?.addEventListener("click", () => navigateTo("timing")); + document.getElementById("workflowOverlay")?.addEventListener("click", () => navigateTo("overlay")); + + document.getElementById("goEvents")?.addEventListener("click", () => navigateTo("events")); + + document.getElementById("goTiming")?.addEventListener("click", () => navigateTo("timing")); document.getElementById("connectNow")?.addEventListener("click", connectDecoder); document.getElementById("disconnectNow")?.addEventListener("click", disconnectDecoder); diff --git a/src/event_manager_view.js b/src/event_manager_view.js index 6290aab..46679ed 100644 --- a/src/event_manager_view.js +++ b/src/event_manager_view.js @@ -129,7 +129,7 @@ export function renderEventManagerMarkup(context) { ${escapeHtml(s.name)} ${escapeHtml(getSessionTypeLabel(s.type))} - ${s.durationMin} min + ${s.type === "open_practice" ? t("events.open_practice_untimed") : `${s.durationMin} min`} ${escapeHtml(getStartModeLabel(s.startMode))} ${s.seedBestLapCount > 0 ? `${s.seedBestLapCount}` : "-"} ${escapeHtml(getStatusLabel(s.status))} diff --git a/src/event_race_logic.js b/src/event_race_logic.js index 1a7d11b..baeb7c0 100644 --- a/src/event_race_logic.js +++ b/src/event_race_logic.js @@ -150,6 +150,7 @@ export function applyRaceFormatPreset(event, presetId, { getRaceFormatPresets }) return; } Object.assign(event.raceConfig, preset.values, { presetId: preset.id }); + event.raceConfig.practiceOnly = false; } @@ -187,6 +188,7 @@ export function buildRaceFormatConfigFromForm(form, event, { normalizeStartMode, participantsConfigured: event.raceConfig.participantsConfigured !== false, finalsSource: String(form.get("finalsSource") || "qualifying") === "practice" ? "practice" : "qualifying", teams: getEventTeams(event), + practiceOnly: event.raceConfig.practiceOnly === true, }; } @@ -228,6 +230,7 @@ export function normalizeEvent(event, { normalizeBrandingConfig, normalizeStartM 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) : [], + practiceOnly: event?.raceConfig?.practiceOnly === true, }, }; } @@ -264,6 +267,7 @@ export function resolveEventBranding(event, { normalizeBrandingConfig, state }) export function getRaceManageStatuses(event, sessions, raceDrivers, raceTeams, selectedPreset, { t, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings }) { const cfg = event.raceConfig || {}; + const isPracticeOnly = cfg.practiceOnly === true; const participantCount = cfg.participantsConfigured ? (cfg.driverIds || []).length : raceDrivers.length; const isEndurancePreset = selectedPreset?.id === "endurance"; const practiceCount = sessions.filter((session) => ["practice", "free_practice", "open_practice"].includes(session.type)).length; @@ -280,6 +284,28 @@ export function getRaceManageStatuses(event, sessions, raceDrivers, raceTeams, s const lapWindowValid = Math.max(0, Number(cfg.maxLapMs || 0) || 0) > Math.max(0, Number(cfg.minLapMs || 0) || 0); const resultSourceCount = [practiceRows, qualifyingRows, finalRows].filter((rows) => rows.length > 0).length; + if (isPracticeOnly) { + const openPracticeCount = sessions.filter((session) => session.type === "open_practice").length; + return { + setup: { + status: openPracticeCount > 0 ? "complete" : "pending", + detail: t("events.open_practice_all_transponders"), + }, + format: { + status: "complete", + detail: t("events.open_practice_untimed"), + }, + generation: { + status: openPracticeCount > 0 ? "complete" : "pending", + detail: `${openPracticeCount} ${t("events.detail_sessions")}`, + }, + live: { + status: startedCount > 0 || resultSourceCount > 0 ? "complete" : "pending", + detail: `${startedCount} ${t("events.detail_active")} · ${resultSourceCount} ${t("events.detail_results")}`, + }, + }; + } + const setupStatus = participantCount > 0 && (!isEndurancePreset || raceTeams.length > 0) ? "complete" : participantCount > 0 || raceTeams.length > 0 @@ -395,6 +421,31 @@ export function buildRaceSession(eventId, name, type, durationMin, overrides = { }); } +export function buildOpenPracticeBundle(input = {}, { normalizeEvent, buildRaceSession, uid }) { + const name = String(input.name || "Open Practice").trim() || "Open Practice"; + const event = normalizeEvent({ + id: uid("event"), + name, + date: String(input.date || new Date().toISOString().slice(0, 10)), + classId: "", + mode: "race", + }); + event.raceConfig = { + ...event.raceConfig, + presetId: "custom", + practiceOnly: true, + driverIds: [], + participantsConfigured: false, + teams: [], + }; + const session = buildRaceSession(event.id, name, "open_practice", 1, { + driverIds: [], + startMode: "mass", + followUpSec: 0, + }); + return { event, session }; +} + export function buildTrackSession(eventId, name, type, durationMin, { normalizeSession, uid }) { return normalizeSession({ id: uid("session"), diff --git a/src/event_views.js b/src/event_views.js index 1ec400f..4b72d2b 100644 --- a/src/event_views.js +++ b/src/event_views.js @@ -23,8 +23,38 @@ export function renderEventWorkspaceMarkup(mode, deps) { .join(""); return ` + ${ + isRaceMode + ? ` +
+
+
+

${t("events.quick_open_practice_title")}

+

${t("events.quick_open_practice_intro")}

+
+ ${t("events.open_practice_untimed")} +
+
+
+ ${t("events.open_practice_all_transponders")} + ${t("events.open_practice_no_registration")} + ${t("events.open_practice_until_stopped")} +
+
+ + +
+
+
+ ` + : "" + } +
-

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

+

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

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

${isRaceMode ? `

${t("events.wizard_hint")}

` : ""} @@ -82,12 +112,16 @@ export function renderEventWorkspaceMarkup(mode, deps) { ${escapeHtml(e.name)} ${escapeHtml(e.date)} - ${escapeHtml(getClassName(e.classId))} - ${getModeLabel(e.mode)} + ${e.raceConfig?.practiceOnly ? escapeHtml(t("events.open_practice_all_transponders")) : escapeHtml(getClassName(e.classId))} + ${e.raceConfig?.practiceOnly ? escapeHtml(t("events.quick_open_practice_title")) : getModeLabel(e.mode)} ${sessions.length} - + ${ + e.raceConfig?.practiceOnly + ? `` + : `` + } @@ -119,15 +153,19 @@ export function renderEventWorkspaceMarkup(mode, deps) { ${t("events.field_date_hint")} - + ${ + editingEvent.raceConfig?.practiceOnly + ? `` + : `` + }
diff --git a/src/event_workspace_controller.js b/src/event_workspace_controller.js index 7304cb9..de6d7cc 100644 --- a/src/event_workspace_controller.js +++ b/src/event_workspace_controller.js @@ -27,6 +27,7 @@ export function renderEventWorkspaceView(context) { uid, normalizeEvent, applyRaceFormatPreset, + buildOpenPracticeBundle, buildRaceSessionsFromWizard, buildDefaultRaceWizardDraft, applyRaceWizardPresetDefaults, @@ -36,6 +37,7 @@ export function renderEventWorkspaceView(context) { bindModalShell, isValidIsoDate, saveState, + openTimingSession, } = context; const isRaceMode = mode === "race"; @@ -64,6 +66,22 @@ export function renderEventWorkspaceView(context) { editingEvent, }); + document.getElementById("quickOpenPracticeForm")?.addEventListener("submit", (event) => { + event.preventDefault(); + const formNode = event.currentTarget; + if (!(formNode instanceof HTMLFormElement)) { + return; + } + const form = new FormData(formNode); + const name = String(form.get("name") || "").trim() || t("events.quick_open_practice_default"); + const bundle = buildOpenPracticeBundle({ name, date: new Date().toISOString().slice(0, 10) }); + state.events.push(bundle.event); + state.sessions.push(bundle.session); + setSelectedTeamEditId(null); + setSelectedSessionEditId(null); + openTimingSession(bundle.session.id); + }); + if (isRaceMode) { const persistWizardStepOne = () => { const form = document.getElementById("raceWizardStepForm"); @@ -269,6 +287,13 @@ export function renderEventWorkspaceView(context) { document.getElementById(`event-manage-${e.id}`)?.addEventListener("click", () => { renderEventManager(e.id); }); + + document.getElementById(`event-timing-${e.id}`)?.addEventListener("click", () => { + const session = getSessionsForEvent(e.id).find((item) => item.type === "open_practice"); + if (session) { + openTimingSession(session.id); + } + }); }); document.getElementById("eventEditCancel")?.addEventListener("click", () => { diff --git a/src/misc_views.js b/src/misc_views.js index 6970307..94536a4 100644 --- a/src/misc_views.js +++ b/src/misc_views.js @@ -54,7 +54,7 @@ export function renderGuideView({ dom, t }) {
${renderGuidePanel("guide.free_practice_title", ["guide.free_practice_1", "guide.free_practice_2", "guide.free_practice_3"], "", { t })} - ${renderGuidePanel("guide.open_practice_title", ["guide.open_practice_1", "guide.open_practice_2", "guide.open_practice_3"], "", { t })} + ${renderGuidePanel("guide.open_practice_title", ["guide.open_practice_1", "guide.open_practice_2", "guide.open_practice_3", "guide.open_practice_4", "guide.open_practice_5"], "", { t })}
@@ -63,7 +63,7 @@ export function renderGuideView({ dom, t }) {
- ${renderGuidePanel("guide.dashboard_title", ["guide.dashboard_1", "guide.dashboard_2", "guide.dashboard_3"], "", { t })} + ${renderGuidePanel("guide.dashboard_title", ["guide.dashboard_1", "guide.dashboard_2", "guide.dashboard_3", "guide.dashboard_4"], "", { t })} ${renderGuidePanel("guide.training_title", ["guide.training_1", "guide.training_2", "guide.training_3", "guide.training_4", "guide.training_5", "guide.training_6", "guide.training_7", "guide.training_8", "guide.training_9"], "", { t })}
diff --git a/src/race_setup_ui.js b/src/race_setup_ui.js index 98c7412..081f78a 100644 --- a/src/race_setup_ui.js +++ b/src/race_setup_ui.js @@ -28,6 +28,14 @@ export function getRaceSummaryItems(event, sessions, raceDrivers, selectedPreset const scoringLabel = cfg.qualifyingScoring === "best" ? t("events.qualifying_scoring_best") : t("events.qualifying_scoring_points"); const lapWindow = `${((cfg.minLapMs || 0) / 1000).toFixed(1)}s / ${((cfg.maxLapMs || 0) / 1000).toFixed(1)}s`; const isEndurancePreset = selectedPreset?.id === "endurance"; + if (cfg.practiceOnly === true) { + return [ + { label: t("events.race_summary_focus"), value: t("events.quick_open_practice_title") }, + { label: t("table.duration"), value: t("events.open_practice_untimed") }, + { label: t("events.race_summary_participants"), value: t("events.open_practice_all_transponders") }, + { label: t("events.race_summary_created_sessions"), value: String(practiceCount) }, + ]; + } if (isEndurancePreset) { return [ { label: t("events.race_summary_preset"), value: selectedPreset?.label || t("events.preset_custom") }, @@ -59,6 +67,10 @@ export function getRaceSummaryWarnings(event, sessions, raceDrivers, raceTeams, const hasQualifying = sessions.some((session) => session.type === "qualification"); const hasFinals = sessions.some((session) => session.type === "final"); const hasAnySession = sessions.length > 0; + if (cfg.practiceOnly === true) { + if (!hasAnySession) warnings.push({ message: t("events.summary_warning_no_sessions"), target: "manage-session-plan" }); + return warnings; + } if (participantCount <= 0) warnings.push({ message: t("events.summary_warning_no_participants"), target: "manage-setup-participants" }); if (!hasAnySession) warnings.push({ message: t("events.summary_warning_no_sessions"), target: "manage-session-plan" }); if (!isEndurancePreset && !hasQualifying) warnings.push({ message: t("events.summary_warning_no_qualifying"), target: "manage-generation" }); diff --git a/src/styles.css b/src/styles.css index 2054fe5..8e4dc42 100644 --- a/src/styles.css +++ b/src/styles.css @@ -107,6 +107,9 @@ body { position: sticky; top: 0; height: 100vh; + display: flex; + flex-direction: column; + overflow-y: auto; } .brand { @@ -145,8 +148,23 @@ body { .nav { display: grid; - gap: 8px; + gap: 6px; padding-top: 6px; + align-content: start; + flex: 1; +} + +.nav-group-label { + margin: 9px 4px 1px; + color: var(--muted); + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.nav-group-label:first-child { + margin-top: 0; } .nav-item { @@ -154,7 +172,7 @@ body { background: var(--surface-soft); color: var(--text); border-radius: 10px; - padding: 10px 12px; + padding: 8px 12px; text-align: left; cursor: pointer; transition: all 0.2s ease; @@ -172,13 +190,52 @@ body { } .sidebar-footer { - position: absolute; - left: 16px; - right: 16px; - bottom: 16px; + position: static; + margin-top: 14px; color: var(--muted); } +.open-practice-quickstart { + border-color: color-mix(in srgb, var(--accent) 38%, var(--line)); + background: + linear-gradient(115deg, color-mix(in srgb, var(--accent) 13%, transparent), transparent 48%), + var(--panel); +} + +.open-practice-quickstart-body { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(320px, 0.72fr); + gap: 18px; + align-items: end; +} + +.open-practice-benefits { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.open-practice-benefits span { + border: 1px solid var(--line); + border-radius: 999px; + padding: 6px 10px; + color: var(--muted); + background: var(--surface-soft); + font-size: 0.78rem; +} + +.open-practice-quickstart-form { + display: grid; + grid-template-columns: minmax(160px, 1fr) auto; + gap: 10px; + align-items: end; +} + +.open-practice-quickstart-form label { + display: grid; + gap: 6px; +} + .badge { display: inline-block; border-radius: 999px; @@ -1282,6 +1339,54 @@ select:focus { justify-content: flex-end; } +.dashboard-workflow { + background: + linear-gradient(135deg, color-mix(in srgb, var(--accent) 10%, transparent), transparent 42%), + var(--panel); +} + +.dashboard-workflow-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.dashboard-workflow-card { + min-height: 190px; + display: grid; + grid-template-rows: auto 1fr auto; + gap: 12px; + padding: 15px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--surface-card); +} + +.dashboard-workflow-card-primary { + border-color: color-mix(in srgb, var(--accent) 46%, var(--line)); + background: linear-gradient(150deg, color-mix(in srgb, var(--accent) 12%, var(--surface-card)), var(--surface-card)); +} + +.dashboard-workflow-card p { + margin: 7px 0 0; + color: var(--muted); + line-height: 1.4; + font-size: 0.86rem; +} + +.workflow-number { + width: 30px; + height: 30px; + display: grid; + place-items: center; + border-radius: 9px; + font-family: Orbitron, sans-serif; + font-weight: 800; + color: var(--text); + border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--line)); + background: color-mix(in srgb, var(--accent) 12%, var(--surface-elevated)); +} + .dashboard-live-stack { display: grid; gap: 14px; @@ -2524,6 +2629,10 @@ select:focus { grid-template-columns: 1fr; } + .dashboard-workflow-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .training-layout { grid-template-columns: 1fr; } @@ -2555,6 +2664,16 @@ select:focus { grid-template-columns: 1fr; } + .dashboard-workflow-grid, + .open-practice-quickstart-body, + .open-practice-quickstart-form { + grid-template-columns: 1fr; + } + + .dashboard-workflow-card { + min-height: 0; + } + .race-setup-modebar, .race-format-save-row, .race-wizard-footer { diff --git a/tests/race_timing_logic.test.js b/tests/race_timing_logic.test.js index b0d4480..bdd21f9 100644 --- a/tests/race_timing_logic.test.js +++ b/tests/race_timing_logic.test.js @@ -3,10 +3,12 @@ import assert from "node:assert/strict"; import { applyBumpsForRace, + buildOpenPracticeBundle, buildRaceFormatConfigFromForm, buildRaceSessionsFromWizard, generateFinalsForRace, generateQualifyingForRace, + normalizeEvent, reseedUpcomingQualifying, } from "../src/event_race_logic.js"; import { @@ -91,6 +93,46 @@ test("race format form normalizes limits and preserves participants and teams", assert.deepEqual(config.teams, [{ id: "team_1", name: "Red" }]); }); +test("open practice bundle is untimed, unrestricted, and independent of race presets", () => { + const uid = createUid(); + const bundle = buildOpenPracticeBundle( + { name: "Friday Practice", date: "2026-08-24" }, + { + uid, + normalizeEvent: (event) => ({ ...event, raceConfig: { presetId: "club_qualifying", driverIds: ["old"] } }), + buildRaceSession: (eventId, name, type, durationMin, overrides) => ({ eventId, name, type, durationMin, ...overrides }), + } + ); + + assert.equal(bundle.event.name, "Friday Practice"); + assert.equal(bundle.event.classId, ""); + assert.equal(bundle.event.mode, "race"); + assert.equal(bundle.event.raceConfig.presetId, "custom"); + assert.equal(bundle.event.raceConfig.practiceOnly, true); + assert.equal(bundle.event.raceConfig.participantsConfigured, false); + assert.deepEqual(bundle.event.raceConfig.driverIds, []); + assert.equal(bundle.session.eventId, bundle.event.id); + assert.equal(bundle.session.type, "open_practice"); + assert.equal(bundle.session.startMode, "mass"); + assert.equal(bundle.session.followUpSec, 0); + assert.deepEqual(bundle.session.driverIds, []); + assert.equal(isUntimedSession(bundle.session), true); +}); + +test("event normalization preserves the practice-only marker", () => { + const event = normalizeEvent( + { id: "event_1", raceConfig: { practiceOnly: true } }, + { + normalizeBrandingConfig: (branding) => branding || {}, + normalizeStartMode, + normalizeRaceTeam: (team) => team, + } + ); + + assert.equal(event.raceConfig.practiceOnly, true); + assert.equal(event.raceConfig.presetId, "custom"); +}); + test("race wizard creates configured practice, qualifying, and endurance sessions", () => { const event = { id: "event_1",