Add simplified Open Practice flow and reorganize

This commit is contained in:
larssand
2026-08-24 19:25:21 +02:00
parent 5a6e51c51b
commit 2e6866a013
13 changed files with 481 additions and 55 deletions

View File

@@ -67,13 +67,17 @@ Recommended deployment:
- `Track Event (shared cars)` - `Track Event (shared cars)`
- UI separation: - UI separation:
- `Event` = sponsor events with shared cars/transponders - `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: - Storage/import tools:
- full race-data export/import for backup or moving to another machine - full race-data export/import for backup or moving to another machine
- directory export/import for only classes, drivers and cars - directory export/import for only classes, drivers and cars
- CSV export for drivers and cars when you want simple spreadsheet lists - CSV export for drivers and cars when you want simple spreadsheet lists
- single race-package export/import from `Race Setup -> Manage -> Race Actions` - single race-package export/import from `Race Setup -> Manage -> Race Actions`
- Race Setup includes: - 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` - grouped `Participants` and `Teams` sections in `Manage`
- a four-step `Create Race Wizard` for new races - a four-step `Create Race Wizard` for new races
- `Manage` split into `Setup`, `Format`, `Generation`, and `Live / results` - `Manage` split into `Setup`, `Format`, `Generation`, and `Live / results`

View File

@@ -65,13 +65,17 @@ Rekommenderad deploy:
- `Track Event (shared cars)` - `Track Event (shared cars)`
- UI-separering: - UI-separering:
- `Event` = sponsor-event med delade bilar/transpondrar - `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: - Lagring/import:
- full export/import av race-data för backup eller flytt till annan maskin - 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 - 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 - 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` - export/import av ett enskilt racepaket via `Race Setup -> Hantera -> Race actions`
- `Race Setup` innehåller nu även: - `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` - grupperade sektioner för `Deltagare` och `Lag` i `Hantera`
- en fyrstegs `Create Race Wizard` för nya race - en fyrstegs `Create Race Wizard` för nya race
- `Hantera` uppdelad i `Setup`, `Format`, `Generering` och `Live / resultat` - `Hantera` uppdelad i `Setup`, `Format`, `Generering` och `Live / resultat`

View File

@@ -294,6 +294,7 @@ Owns:
- team normalization - team normalization
- race presets - race presets
- race wizard defaults and session generation - race wizard defaults and session generation
- practice-only Open Practice event/session generation
- sponsor round generation - sponsor round generation
- event drivers / teams / team driver pool - event drivers / teams / team driver pool
- qualifying/finals generation and bumps - qualifying/finals generation and bumps
@@ -1013,6 +1014,7 @@ Why fragile:
High-risk functions: High-risk functions:
- `normalizeEvent(...)` - `normalizeEvent(...)`
- `buildRaceFormatConfigFromForm(...)` - `buildRaceFormatConfigFromForm(...)`
- `buildOpenPracticeBundle(...)`
- `getEventDrivers(...)` - `getEventDrivers(...)`
- `getTeamDriverPool(...)` - `getTeamDriverPool(...)`
- `generateQualifyingForRace(...)` - `generateQualifyingForRace(...)`
@@ -1419,9 +1421,12 @@ Practical normalized shape:
participantsConfigured: boolean, participantsConfigured: boolean,
finalsSource: "practice" | "qualifying", finalsSource: "practice" | "qualifying",
teams: RaceTeam[] 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` ### `RaceTeam`
```js ```js
@@ -2075,6 +2080,7 @@ npm test
`tests/race_timing_logic.test.js` covers: `tests/race_timing_logic.test.js` covers:
- race-format parsing, defaults, lap limits, participants, and teams - 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 - wizard generation of practice, qualifying, and team-race sessions
- qualifying heat generation and manual-grid-safe reseeding - qualifying heat generation and manual-grid-safe reseeding
- final generation, reserved bump slots, and bump-up promotion - final generation, reserved bump slots, and bump-up promotion

View File

@@ -4,7 +4,7 @@ import { renderSettings } from "./settings.js";
import { renderRaceFormatField, renderRaceFormatContextCard, getRaceSummaryItems, getRaceSummaryWarnings, renderManageStatusBadge, renderRaceWizardSteps, renderRaceWizardContent, renderRaceStandingsTable } from "./race_setup_ui.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"; 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 applyRaceWizardPresetDefaults = (draft, presetId) => applyRaceWizardPresetDefaultsLogic(draft, presetId, { getRaceWizardPreset });
const ensureRaceParticipantsConfigured = (event) => ensureRaceParticipantsConfiguredLogic(event, { state }); const ensureRaceParticipantsConfigured = (event) => ensureRaceParticipantsConfiguredLogic(event, { state });
const buildRaceSession = (eventId, name, type, durationMin, overrides = {}) => buildRaceSessionLogic(eventId, name, type, durationMin, overrides, { normalizeSession, uid }); 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 buildTrackSession = (eventId, name, type, durationMin) => buildTrackSessionLogic(eventId, name, type, durationMin, { normalizeSession, uid });
const createSponsorRounds = (eventId, config) => createSponsorRoundsLogic(eventId, config, { state, t, buildTrackSession }); const createSponsorRounds = (eventId, config) => createSponsorRoundsLogic(eventId, config, { state, t, buildTrackSession });
const buildRaceSessionsFromWizard = (event, draft) => buildRaceSessionsFromWizardLogic(event, draft, { t, buildRaceSession }); const buildRaceSessionsFromWizard = (event, draft) => buildRaceSessionsFromWizardLogic(event, draft, { t, buildRaceSession });
@@ -189,7 +190,7 @@ const processDecoderMessage = (msg) => processDecoderMessageHelper(msg, {
lastOverlayTop3BySession, lastOverlayTop3BySession,
lastOverlayBestLapByKey, 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 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 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 }); 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 getJudgeFilteredLog = (adjustments, filterValue) => getJudgeFilteredLogLogic(adjustments, filterValue);
const NAV_ITEMS = [ const NAV_ITEMS = [
{ id: "dashboard", titleKey: "nav.dashboard", subtitleKey: "nav.dashboard_sub" }, { id: "dashboard", titleKey: "nav.dashboard", subtitleKey: "nav.dashboard_sub", groupKey: "nav.group_start" },
{ id: "events", titleKey: "nav.events", subtitleKey: "nav.events_sub" }, { id: "race_setup", titleKey: "nav.race_setup", subtitleKey: "nav.race_setup_sub", groupKey: "nav.group_create" },
{ id: "race_setup", titleKey: "nav.race_setup", subtitleKey: "nav.race_setup_sub" }, { id: "events", titleKey: "nav.events", subtitleKey: "nav.events_sub", groupKey: "nav.group_create" },
{ id: "overlay", titleKey: "nav.overlay", subtitleKey: "nav.overlay_sub" }, { id: "timing", titleKey: "nav.timing", subtitleKey: "nav.timing_sub", groupKey: "nav.group_run" },
{ id: "classes", titleKey: "nav.classes", subtitleKey: "nav.classes_sub" }, { id: "judging", titleKey: "nav.judging", subtitleKey: "nav.judging_sub", groupKey: "nav.group_run" },
{ id: "drivers", titleKey: "nav.drivers", subtitleKey: "nav.drivers_sub" }, { id: "overlay", titleKey: "nav.overlay", subtitleKey: "nav.overlay_sub", groupKey: "nav.group_run" },
{ id: "cars", titleKey: "nav.cars", subtitleKey: "nav.cars_sub" }, { id: "training", titleKey: "nav.training", subtitleKey: "nav.training_sub", groupKey: "nav.group_run" },
{ id: "training", titleKey: "nav.training", subtitleKey: "nav.training_sub" }, { id: "classes", titleKey: "nav.classes", subtitleKey: "nav.classes_sub", groupKey: "nav.group_register" },
{ id: "timing", titleKey: "nav.timing", subtitleKey: "nav.timing_sub" }, { id: "drivers", titleKey: "nav.drivers", subtitleKey: "nav.drivers_sub", groupKey: "nav.group_register" },
{ id: "judging", titleKey: "nav.judging", subtitleKey: "nav.judging_sub" }, { id: "cars", titleKey: "nav.cars", subtitleKey: "nav.cars_sub", groupKey: "nav.group_register" },
{ id: "settings", titleKey: "nav.settings", subtitleKey: "nav.settings_sub" }, { id: "settings", titleKey: "nav.settings", subtitleKey: "nav.settings_sub", groupKey: "nav.group_system" },
{ id: "guide", titleKey: "nav.guide", subtitleKey: "nav.guide_sub" }, { 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"]; const SESSION_TYPES = ["open_practice", "free_practice", "practice", "qualification", "heat", "final", "team_race"];
@@ -246,10 +247,15 @@ const TRANSLATIONS = {
sv: { sv: {
"nav.dashboard": "Översikt", "nav.dashboard": "Översikt",
"nav.dashboard_sub": "Status och liveinformation", "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.events_sub": "Sponsor-event och delade bilar",
"nav.race_setup": "Race Setup", "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": "Overlay",
"nav.overlay_sub": "Extern leaderboard-skärm", "nav.overlay_sub": "Extern leaderboard-skärm",
"nav.classes": "Klasser", "nav.classes": "Klasser",
@@ -258,8 +264,8 @@ const TRANSLATIONS = {
"nav.drivers_sub": "Förare och personliga transpondrar", "nav.drivers_sub": "Förare och personliga transpondrar",
"nav.cars": "Bilar", "nav.cars": "Bilar",
"nav.cars_sub": "Bilar med fasta transpondrar", "nav.cars_sub": "Bilar med fasta transpondrar",
"nav.training": "Träning", "nav.training": "Träningsstatistik",
"nav.training_sub": "Utveckling, delmål och belöningar", "nav.training_sub": "Utveckling, delmål och belöningar per förare",
"nav.timing": "Tidtagning", "nav.timing": "Tidtagning",
"nav.timing_sub": "Live timing-board", "nav.timing_sub": "Live timing-board",
"nav.judging": "Domare", "nav.judging": "Domare",
@@ -385,6 +391,21 @@ const TRANSLATIONS = {
"dashboard.ahead": "Före schema", "dashboard.ahead": "Före schema",
"dashboard.behind": "Efter schema", "dashboard.behind": "Efter schema",
"dashboard.live_note": "Snabb driftpanel för anslutning, overlay och ljud. Djupare konfig ligger kvar under Inställningar.", "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.", "session.none_yet": "Inga sessioner ännu.",
"classes.create": "Skapa klass", "classes.create": "Skapa klass",
"classes.placeholder": "Klassnamn (t.ex. 2WD Buggy)", "classes.placeholder": "Klassnamn (t.ex. 2WD Buggy)",
@@ -406,6 +427,15 @@ const TRANSLATIONS = {
"cars.title": "Bilar", "cars.title": "Bilar",
"events.create": "Skapa event", "events.create": "Skapa event",
"events.create_race": "Skapa race", "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.name_placeholder": "Eventnamn",
"events.field_name": "Eventnamn", "events.field_name": "Eventnamn",
"events.field_name_hint": "Namnet som visas i listor, overlay och resultat.", "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.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.race_only_intro": "Här skapar du riktiga race med personlig transponder per förare.",
"events.manage": "Hantera", "events.manage": "Hantera",
"events.open_timing": "Öppna Tidtagning",
"events.edit": "Redigera", "events.edit": "Redigera",
"events.sessions": "Sessioner", "events.sessions": "Sessioner",
"events.participants": "Deltagare", "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_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_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_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_title": "Lagrace / Endurance",
"guide.team_1": "Gå till Race Setup och skapa ett race i rätt klass.", "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_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_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_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_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_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_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.", "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: { en: {
"nav.dashboard": "Dashboard", "nav.dashboard": "Dashboard",
"nav.dashboard_sub": "Overview and live status", "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.events_sub": "Sponsor events and shared cars",
"nav.race_setup": "Race Setup", "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": "Overlay",
"nav.overlay_sub": "External leaderboard screen", "nav.overlay_sub": "External leaderboard screen",
"nav.classes": "Classes", "nav.classes": "Classes",
@@ -1152,8 +1191,8 @@ const TRANSLATIONS = {
"nav.drivers_sub": "Drivers and personal transponders", "nav.drivers_sub": "Drivers and personal transponders",
"nav.cars": "Cars", "nav.cars": "Cars",
"nav.cars_sub": "Track cars with fixed transponders", "nav.cars_sub": "Track cars with fixed transponders",
"nav.training": "Training", "nav.training": "Training stats",
"nav.training_sub": "Development, milestones and rewards", "nav.training_sub": "Development, milestones and rewards per driver",
"nav.timing": "Timing", "nav.timing": "Timing",
"nav.timing_sub": "Live timing board", "nav.timing_sub": "Live timing board",
"nav.judging": "Judging", "nav.judging": "Judging",
@@ -1279,6 +1318,21 @@ const TRANSLATIONS = {
"dashboard.ahead": "Ahead of schedule", "dashboard.ahead": "Ahead of schedule",
"dashboard.behind": "Behind schedule", "dashboard.behind": "Behind schedule",
"dashboard.live_note": "Quick operations panel for connection, overlay and audio. Deeper configuration remains under Settings.", "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.", "session.none_yet": "No sessions yet.",
"classes.create": "Create Class", "classes.create": "Create Class",
"classes.placeholder": "Class name (e.g. 2WD Buggy)", "classes.placeholder": "Class name (e.g. 2WD Buggy)",
@@ -1300,6 +1354,15 @@ const TRANSLATIONS = {
"cars.title": "Cars", "cars.title": "Cars",
"events.create": "Create Event", "events.create": "Create Event",
"events.create_race": "Create Race", "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.name_placeholder": "Event name",
"events.field_name": "Event name", "events.field_name": "Event name",
"events.field_name_hint": "Name shown in lists, overlays and results.", "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.track_only_intro": "Create sponsor events with shared cars/transponders here.",
"events.race_only_intro": "Create proper races with personal driver transponders here.", "events.race_only_intro": "Create proper races with personal driver transponders here.",
"events.manage": "Manage", "events.manage": "Manage",
"events.open_timing": "Open Timing",
"events.edit": "Edit", "events.edit": "Edit",
"events.sessions": "Sessions", "events.sessions": "Sessions",
"events.participants": "Participants", "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_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_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_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_title": "Team Race / Endurance",
"guide.team_1": "Go to Race Setup and create a race in the correct class.", "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_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_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_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_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_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_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.", "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; return;
} }
dom.nav.innerHTML = ""; dom.nav.innerHTML = "";
let currentGroupKey = "";
NAV_ITEMS.forEach((item) => { 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"); const button = document.createElement("button");
button.className = `nav-item ${item.id === currentView ? "active" : ""}`; button.className = `nav-item ${item.id === currentView ? "active" : ""}`;
button.textContent = t(item.titleKey); button.textContent = t(item.titleKey);
@@ -3054,6 +3129,7 @@ function renderEventWorkspace(mode) {
uid, uid,
normalizeEvent, normalizeEvent,
applyRaceFormatPreset, applyRaceFormatPreset,
buildOpenPracticeBundle,
buildRaceSessionsFromWizard, buildRaceSessionsFromWizard,
buildDefaultRaceWizardDraft, buildDefaultRaceWizardDraft,
applyRaceWizardPresetDefaults, applyRaceWizardPresetDefaults,
@@ -3063,6 +3139,15 @@ function renderEventWorkspace(mode) {
bindModalShell, bindModalShell,
isValidIsoDate, isValidIsoDate,
saveState, saveState,
openTimingSession: (sessionId) => {
state.activeSessionId = sessionId;
saveState();
currentView = "timing";
updateViewUrl(currentView);
renderNav();
updateHeaderState();
renderView();
},
}); });
} }

View File

@@ -51,6 +51,7 @@ export function renderDashboardView(deps) {
formatLap, formatLap,
renderSessionsTable, renderSessionsTable,
setCurrentView, setCurrentView,
updateViewUrl,
renderNav, renderNav,
renderView, renderView,
connectDecoder, connectDecoder,
@@ -75,6 +76,40 @@ export function renderDashboardView(deps) {
: t("settings.passing_sound_off"); : t("settings.passing_sound_off");
dom.view.innerHTML = ` dom.view.innerHTML = `
<section class="panel dashboard-workflow">
<div class="panel-header">
<div>
<h3>${t("dashboard.get_started")}</h3>
<p class="hint">${t("dashboard.get_started_hint")}</p>
</div>
</div>
<div class="panel-body dashboard-workflow-grid">
<article class="dashboard-workflow-card">
<span class="workflow-number">1</span>
<div><strong>${t("dashboard.step_system")}</strong><p>${t("dashboard.step_system_hint")}</p></div>
<button id="workflowSettings" class="btn" type="button">${t("dashboard.open_settings")}</button>
</article>
<article class="dashboard-workflow-card">
<span class="workflow-number">2</span>
<div><strong>${t("dashboard.step_register")}</strong><p>${t("dashboard.step_register_hint")}</p></div>
<button id="workflowDrivers" class="btn" type="button">${t("dashboard.open_register")}</button>
</article>
<article class="dashboard-workflow-card dashboard-workflow-card-primary">
<span class="workflow-number">3</span>
<div><strong>${t("dashboard.step_create")}</strong><p>${t("dashboard.step_create_hint")}</p></div>
<button id="workflowRaceSetup" class="btn btn-primary" type="button">${t("dashboard.open_race_setup")}</button>
</article>
<article class="dashboard-workflow-card">
<span class="workflow-number">4</span>
<div><strong>${t("dashboard.step_run")}</strong><p>${t("dashboard.step_run_hint")}</p></div>
<div class="actions-inline">
<button id="workflowTiming" class="btn" type="button">${t("dashboard.open_timing")}</button>
<button id="workflowOverlay" class="btn" type="button">${t("dashboard.open_overlay")}</button>
</div>
</article>
</div>
</section>
<div class="grid cols-4"> <div class="grid cols-4">
${statCard(t("dashboard.events"), String(state.events.length), t("dashboard.created"))} ${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.drivers"), String(state.drivers.length), t("dashboard.registered"))}
@@ -93,7 +128,7 @@ export function renderDashboardView(deps) {
active active
? `<p><strong>${active.name}</strong> (${getSessionTypeLabel(active.type)})</p> ? `<p><strong>${active.name}</strong> (${getSessionTypeLabel(active.type)})</p>
<p>${getEventName(active.eventId)} • ${getModeLabel(active.mode)}</p> <p>${getEventName(active.eventId)} • ${getModeLabel(active.mode)}</p>
<p>${t("dashboard.duration")}: ${active.durationMin} min</p>` <p>${t("dashboard.duration")}: ${active.type === "open_practice" ? t("dashboard.untimed") : `${active.durationMin} min`}</p>`
: `<p>${t("dashboard.no_session")}</p>` : `<p>${t("dashboard.no_session")}</p>`
} }
</div> </div>
@@ -157,17 +192,22 @@ export function renderDashboardView(deps) {
</section> </section>
`; `;
document.getElementById("goEvents")?.addEventListener("click", () => { const navigateTo = (view) => {
setCurrentView("events"); setCurrentView(view);
updateViewUrl(view);
renderNav(); renderNav();
renderView(); renderView();
}); };
document.getElementById("goTiming")?.addEventListener("click", () => { document.getElementById("workflowSettings")?.addEventListener("click", () => navigateTo("settings"));
setCurrentView("timing"); document.getElementById("workflowDrivers")?.addEventListener("click", () => navigateTo("drivers"));
renderNav(); document.getElementById("workflowRaceSetup")?.addEventListener("click", () => navigateTo("race_setup"));
renderView(); 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("connectNow")?.addEventListener("click", connectDecoder);
document.getElementById("disconnectNow")?.addEventListener("click", disconnectDecoder); document.getElementById("disconnectNow")?.addEventListener("click", disconnectDecoder);

View File

@@ -129,7 +129,7 @@ export function renderEventManagerMarkup(context) {
<tr> <tr>
<td>${escapeHtml(s.name)}</td> <td>${escapeHtml(s.name)}</td>
<td>${escapeHtml(getSessionTypeLabel(s.type))}</td> <td>${escapeHtml(getSessionTypeLabel(s.type))}</td>
<td>${s.durationMin} min</td> <td>${s.type === "open_practice" ? t("events.open_practice_untimed") : `${s.durationMin} min`}</td>
<td>${escapeHtml(getStartModeLabel(s.startMode))}</td> <td>${escapeHtml(getStartModeLabel(s.startMode))}</td>
<td>${s.seedBestLapCount > 0 ? `${s.seedBestLapCount}` : "-"}</td> <td>${s.seedBestLapCount > 0 ? `${s.seedBestLapCount}` : "-"}</td>
<td>${escapeHtml(getStatusLabel(s.status))}</td> <td>${escapeHtml(getStatusLabel(s.status))}</td>

View File

@@ -150,6 +150,7 @@ export function applyRaceFormatPreset(event, presetId, { getRaceFormatPresets })
return; return;
} }
Object.assign(event.raceConfig, preset.values, { presetId: preset.id }); 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, participantsConfigured: event.raceConfig.participantsConfigured !== false,
finalsSource: String(form.get("finalsSource") || "qualifying") === "practice" ? "practice" : "qualifying", finalsSource: String(form.get("finalsSource") || "qualifying") === "practice" ? "practice" : "qualifying",
teams: getEventTeams(event), teams: getEventTeams(event),
practiceOnly: event.raceConfig.practiceOnly === true,
}; };
} }
@@ -228,6 +230,7 @@ export function normalizeEvent(event, { normalizeBrandingConfig, normalizeStartM
participantsConfigured: Boolean(event?.raceConfig?.participantsConfigured), participantsConfigured: Boolean(event?.raceConfig?.participantsConfigured),
finalsSource: event?.raceConfig?.finalsSource === "practice" ? "practice" : "qualifying", finalsSource: event?.raceConfig?.finalsSource === "practice" ? "practice" : "qualifying",
teams: Array.isArray(event?.raceConfig?.teams) ? event.raceConfig.teams.map((team) => normalizeRaceTeam(team)).filter((team) => team.name) : [], 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 }) { export function getRaceManageStatuses(event, sessions, raceDrivers, raceTeams, selectedPreset, { t, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings }) {
const cfg = event.raceConfig || {}; const cfg = event.raceConfig || {};
const isPracticeOnly = cfg.practiceOnly === true;
const participantCount = cfg.participantsConfigured ? (cfg.driverIds || []).length : raceDrivers.length; const participantCount = cfg.participantsConfigured ? (cfg.driverIds || []).length : raceDrivers.length;
const isEndurancePreset = selectedPreset?.id === "endurance"; const isEndurancePreset = selectedPreset?.id === "endurance";
const practiceCount = sessions.filter((session) => ["practice", "free_practice", "open_practice"].includes(session.type)).length; 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 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; 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) const setupStatus = participantCount > 0 && (!isEndurancePreset || raceTeams.length > 0)
? "complete" ? "complete"
: participantCount > 0 || raceTeams.length > 0 : 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 }) { export function buildTrackSession(eventId, name, type, durationMin, { normalizeSession, uid }) {
return normalizeSession({ return normalizeSession({
id: uid("session"), id: uid("session"),

View File

@@ -23,8 +23,38 @@ export function renderEventWorkspaceMarkup(mode, deps) {
.join(""); .join("");
return ` return `
${
isRaceMode
? `
<section class="panel open-practice-quickstart">
<div class="panel-header panel-header-with-pill">
<div>
<h3>${t("events.quick_open_practice_title")}</h3>
<p class="hint">${t("events.quick_open_practice_intro")}</p>
</div>
<span class="pill pill-green">${t("events.open_practice_untimed")}</span>
</div>
<div class="panel-body open-practice-quickstart-body">
<div class="open-practice-benefits">
<span>${t("events.open_practice_all_transponders")}</span>
<span>${t("events.open_practice_no_registration")}</span>
<span>${t("events.open_practice_until_stopped")}</span>
</div>
<form id="quickOpenPracticeForm" class="open-practice-quickstart-form">
<label>
${t("events.session_name")}
<input name="name" required value="${escapeHtml(t("events.quick_open_practice_default"))}" />
</label>
<button class="btn btn-primary" type="submit">${t("events.quick_open_practice_create")}</button>
</form>
</div>
</section>
`
: ""
}
<section class="panel"> <section class="panel">
<div class="panel-header"><h3>${t(isRaceMode ? "events.create_race" : "events.create")}</h3></div> <div class="panel-header"><h3>${t(isRaceMode ? "events.create_competition_race" : "events.create")}</h3></div>
<div class="panel-body"> <div class="panel-body">
<p>${t(isRaceMode ? "events.race_only_intro" : "events.track_only_intro")}</p> <p>${t(isRaceMode ? "events.race_only_intro" : "events.track_only_intro")}</p>
${isRaceMode ? `<p class="hint">${t("events.wizard_hint")}</p>` : ""} ${isRaceMode ? `<p class="hint">${t("events.wizard_hint")}</p>` : ""}
@@ -82,12 +112,16 @@ export function renderEventWorkspaceMarkup(mode, deps) {
<tr> <tr>
<td>${escapeHtml(e.name)}</td> <td>${escapeHtml(e.name)}</td>
<td>${escapeHtml(e.date)}</td> <td>${escapeHtml(e.date)}</td>
<td>${escapeHtml(getClassName(e.classId))}</td> <td>${e.raceConfig?.practiceOnly ? escapeHtml(t("events.open_practice_all_transponders")) : escapeHtml(getClassName(e.classId))}</td>
<td>${getModeLabel(e.mode)}</td> <td>${e.raceConfig?.practiceOnly ? escapeHtml(t("events.quick_open_practice_title")) : getModeLabel(e.mode)}</td>
<td>${sessions.length}</td> <td>${sessions.length}</td>
<td class="actions-inline"> <td class="actions-inline">
<button id="event-edit-${e.id}" class="btn">${t("events.edit")}</button> <button id="event-edit-${e.id}" class="btn">${t("events.edit")}</button>
<button id="event-manage-${e.id}" class="btn">${t("events.manage")}</button> ${
e.raceConfig?.practiceOnly
? `<button id="event-timing-${e.id}" class="btn btn-primary">${t("events.open_timing")}</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> <button id="event-delete-${e.id}" class="btn btn-danger">${t("common.delete")}</button>
</td> </td>
</tr> </tr>
@@ -119,7 +153,10 @@ export function renderEventWorkspaceMarkup(mode, deps) {
<input name="date" required type="date" value="${escapeHtml(editingEvent.date || "")}" /> <input name="date" required type="date" value="${escapeHtml(editingEvent.date || "")}" />
<small>${t("events.field_date_hint")}</small> <small>${t("events.field_date_hint")}</small>
</label> </label>
<label> ${
editingEvent.raceConfig?.practiceOnly
? `<input type="hidden" name="classId" value="" />`
: `<label>
${t("events.field_class")} ${t("events.field_class")}
<select name="classId"> <select name="classId">
${state.classes ${state.classes
@@ -127,7 +164,8 @@ export function renderEventWorkspaceMarkup(mode, deps) {
.join("")} .join("")}
</select> </select>
<small>${t("events.field_class_hint")}</small> <small>${t("events.field_class_hint")}</small>
</label> </label>`
}
<p class="form-error" id="eventEditError" hidden></p> <p class="form-error" id="eventEditError" hidden></p>
<div class="actions-inline"> <div class="actions-inline">
<button class="btn btn-primary" type="submit">${t("common.save")}</button> <button class="btn btn-primary" type="submit">${t("common.save")}</button>

View File

@@ -27,6 +27,7 @@ export function renderEventWorkspaceView(context) {
uid, uid,
normalizeEvent, normalizeEvent,
applyRaceFormatPreset, applyRaceFormatPreset,
buildOpenPracticeBundle,
buildRaceSessionsFromWizard, buildRaceSessionsFromWizard,
buildDefaultRaceWizardDraft, buildDefaultRaceWizardDraft,
applyRaceWizardPresetDefaults, applyRaceWizardPresetDefaults,
@@ -36,6 +37,7 @@ export function renderEventWorkspaceView(context) {
bindModalShell, bindModalShell,
isValidIsoDate, isValidIsoDate,
saveState, saveState,
openTimingSession,
} = context; } = context;
const isRaceMode = mode === "race"; const isRaceMode = mode === "race";
@@ -64,6 +66,22 @@ export function renderEventWorkspaceView(context) {
editingEvent, 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) { if (isRaceMode) {
const persistWizardStepOne = () => { const persistWizardStepOne = () => {
const form = document.getElementById("raceWizardStepForm"); const form = document.getElementById("raceWizardStepForm");
@@ -269,6 +287,13 @@ export function renderEventWorkspaceView(context) {
document.getElementById(`event-manage-${e.id}`)?.addEventListener("click", () => { document.getElementById(`event-manage-${e.id}`)?.addEventListener("click", () => {
renderEventManager(e.id); 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", () => { document.getElementById("eventEditCancel")?.addEventListener("click", () => {

View File

@@ -54,7 +54,7 @@ export function renderGuideView({ dom, t }) {
<div class="guide-section-grid mt-16"> <div class="guide-section-grid mt-16">
${renderGuidePanel("guide.free_practice_title", ["guide.free_practice_1", "guide.free_practice_2", "guide.free_practice_3"], "", { 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 })}
</div> </div>
<div class="guide-section-grid mt-16"> <div class="guide-section-grid mt-16">
@@ -63,7 +63,7 @@ export function renderGuideView({ dom, t }) {
</div> </div>
<div class="guide-section-grid mt-16"> <div class="guide-section-grid mt-16">
${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 })} ${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 })}
</div> </div>

View File

@@ -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 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 lapWindow = `${((cfg.minLapMs || 0) / 1000).toFixed(1)}s / ${((cfg.maxLapMs || 0) / 1000).toFixed(1)}s`;
const isEndurancePreset = selectedPreset?.id === "endurance"; 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) { if (isEndurancePreset) {
return [ return [
{ label: t("events.race_summary_preset"), value: selectedPreset?.label || t("events.preset_custom") }, { 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 hasQualifying = sessions.some((session) => session.type === "qualification");
const hasFinals = sessions.some((session) => session.type === "final"); const hasFinals = sessions.some((session) => session.type === "final");
const hasAnySession = sessions.length > 0; 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 (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 (!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" }); if (!isEndurancePreset && !hasQualifying) warnings.push({ message: t("events.summary_warning_no_qualifying"), target: "manage-generation" });

View File

@@ -107,6 +107,9 @@ body {
position: sticky; position: sticky;
top: 0; top: 0;
height: 100vh; height: 100vh;
display: flex;
flex-direction: column;
overflow-y: auto;
} }
.brand { .brand {
@@ -145,8 +148,23 @@ body {
.nav { .nav {
display: grid; display: grid;
gap: 8px; gap: 6px;
padding-top: 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 { .nav-item {
@@ -154,7 +172,7 @@ body {
background: var(--surface-soft); background: var(--surface-soft);
color: var(--text); color: var(--text);
border-radius: 10px; border-radius: 10px;
padding: 10px 12px; padding: 8px 12px;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
@@ -172,13 +190,52 @@ body {
} }
.sidebar-footer { .sidebar-footer {
position: absolute; position: static;
left: 16px; margin-top: 14px;
right: 16px;
bottom: 16px;
color: var(--muted); 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 { .badge {
display: inline-block; display: inline-block;
border-radius: 999px; border-radius: 999px;
@@ -1282,6 +1339,54 @@ select:focus {
justify-content: flex-end; 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 { .dashboard-live-stack {
display: grid; display: grid;
gap: 14px; gap: 14px;
@@ -2524,6 +2629,10 @@ select:focus {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.dashboard-workflow-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.training-layout { .training-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -2555,6 +2664,16 @@ select:focus {
grid-template-columns: 1fr; 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-setup-modebar,
.race-format-save-row, .race-format-save-row,
.race-wizard-footer { .race-wizard-footer {

View File

@@ -3,10 +3,12 @@ import assert from "node:assert/strict";
import { import {
applyBumpsForRace, applyBumpsForRace,
buildOpenPracticeBundle,
buildRaceFormatConfigFromForm, buildRaceFormatConfigFromForm,
buildRaceSessionsFromWizard, buildRaceSessionsFromWizard,
generateFinalsForRace, generateFinalsForRace,
generateQualifyingForRace, generateQualifyingForRace,
normalizeEvent,
reseedUpcomingQualifying, reseedUpcomingQualifying,
} from "../src/event_race_logic.js"; } from "../src/event_race_logic.js";
import { 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" }]); 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", () => { test("race wizard creates configured practice, qualifying, and endurance sessions", () => {
const event = { const event = {
id: "event_1", id: "event_1",