diff --git a/src/app.js b/src/app.js index 350c1b2..6980959 100644 --- a/src/app.js +++ b/src/app.js @@ -4,6 +4,8 @@ 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"; + const renderRaceFormatFieldView = (labelKey, hintKey, controlHtml, options = {}) => renderRaceFormatField(labelKey, hintKey, controlHtml, options, { t }); const renderRaceFormatContextCardView = (titleKey, hintKey) => renderRaceFormatContextCard(titleKey, hintKey, { t }); const renderManageStatusBadgeView = (status) => renderManageStatusBadge(status, { t }); @@ -12,6 +14,34 @@ const renderRaceWizardContentView = (draft, classOptions, wizardDrivers, preset) renderRaceWizardContent(draft, classOptions, wizardDrivers, preset, { t, escapeHtml, raceWizardStep, getRaceFormatPresets, getClassName, getRaceWizardSessionPlan }); const renderRaceStandingsTableView = (rows, emptyLabel) => renderRaceStandingsTable(rows, emptyLabel, { t, renderTable, escapeHtml }); +const normalizeRaceTeam = (team) => normalizeRaceTeamLogic(team, { uid }); +const normalizeStoredRacePreset = (preset) => normalizeStoredRacePresetLogic(preset, { uid }); +const getRaceFormatPresets = () => getRaceFormatPresetsLogic({ state, t, uid, normalizeStoredRacePreset }); +const applyRaceFormatPreset = (event, presetId) => applyRaceFormatPresetLogic(event, presetId, { getRaceFormatPresets }); +const buildRaceFormatConfigFromForm = (form, event) => buildRaceFormatConfigFromFormLogic(form, event, { normalizeStartMode, getEventTeams }); +const normalizeBrandingConfig = (branding) => normalizeBrandingConfigLogic(branding); +const normalizeEvent = (event) => normalizeEventLogic(event, { normalizeBrandingConfig, normalizeStartMode, normalizeRaceTeam }); +const resolveEventBranding = (event) => resolveEventBrandingLogic(event, { normalizeBrandingConfig, state }); +const getRaceManageStatuses = (event, sessions, raceDrivers, raceTeams, selectedPreset) => + getRaceManageStatusesLogic(event, sessions, raceDrivers, raceTeams, selectedPreset, { t, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings }); +const buildDefaultRaceWizardDraft = () => buildDefaultRaceWizardDraftLogic({ state, getDriversForClass }); +const getRaceWizardPreset = (presetId) => getRaceWizardPresetLogic(presetId, { getRaceFormatPresets }); +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 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 }); +const getRaceWizardSessionPlan = (draft) => getRaceWizardSessionPlanLogic(draft, { t }); +const getEventDrivers = (event) => getEventDriversLogic(event, { state }); +const getEventTeams = (event) => getEventTeamsLogic(event, { normalizeRaceTeam }); +const getTeamDriverPool = (event) => getTeamDriverPoolLogic(event, { getEventDrivers, state }); +const findEventTeamForPassing = (event, driverId, carId) => findEventTeamForPassingLogic(event, driverId, carId, { getEventTeams }); +const generateQualifyingForRace = (event) => generateQualifyingForRaceLogic(event, { buildPracticeStandings, getEventDrivers, state, normalizeStartMode, normalizeSession, uid, chunkArray, clearGeneratedQualifying }); +const reseedUpcomingQualifying = (event) => reseedUpcomingQualifyingLogic(event, { buildQualifyingStandings, buildPracticeStandings, getEventDrivers, getSessionsForEvent, chunkArray }); +const generateFinalsForRace = (event) => generateFinalsForRaceLogic(event, { buildPracticeStandings, buildQualifyingStandings, clearGeneratedFinals, state, normalizeStartMode, normalizeSession, uid, chunkArray, t }); +const applyBumpsForRace = (event) => applyBumpsForRaceLogic(event, { buildFinalStandings, getSessionsForEvent }); + const NAV_ITEMS = [ { id: "dashboard", titleKey: "nav.dashboard", subtitleKey: "nav.dashboard_sub" }, { id: "events", titleKey: "nav.events", subtitleKey: "nav.events_sub" }, @@ -2548,190 +2578,6 @@ function normalizeSession(session) { }; } -function normalizeRaceTeam(team) { - return { - id: String(team?.id || uid("team")), - name: String(team?.name || "").trim(), - driverIds: Array.isArray(team?.driverIds) ? team.driverIds.filter(Boolean) : [], - carIds: Array.isArray(team?.carIds) ? team.carIds.filter(Boolean) : [], - }; -} - -function normalizeStoredRacePreset(preset) { - return { - id: String(preset?.id || uid("preset")), - name: String(preset?.name || "").trim(), - values: preset?.values && typeof preset.values === "object" ? { ...preset.values } : {}, - }; -} - -function getRaceFormatPresets() { - const builtins = [ - { - id: "custom", - label: t("events.preset_custom"), - values: {}, - }, - { - id: "short_technical", - label: t("events.preset_short_technical"), - values: { - qualifyingScoring: "points", - qualifyingRounds: 3, - carsPerHeat: 6, - qualDurationMin: 5, - qualStartMode: "staggered", - qualSeedLapCount: 3, - qualSeedMethod: "best_sum", - countedQualRounds: 2, - qualifyingPointsTable: "rank_low", - qualifyingTieBreak: "best_lap", - carsPerFinal: 8, - finalLegs: 3, - countedFinalLegs: 2, - finalDurationMin: 5, - finalStartMode: "position", - followUpSec: 10, - minLapMs: 11000, - maxLapMs: 60000, - bumpCount: 0, - }, - }, - { - id: "club_qualifying", - label: t("events.preset_club_qualifying"), - values: { - qualifyingScoring: "points", - qualifyingRounds: 4, - carsPerHeat: 8, - qualDurationMin: 5, - qualStartMode: "staggered", - qualSeedLapCount: 3, - qualSeedMethod: "best_sum", - countedQualRounds: 2, - qualifyingPointsTable: "rank_low", - qualifyingTieBreak: "rounds", - carsPerFinal: 8, - finalLegs: 3, - countedFinalLegs: 2, - finalDurationMin: 5, - finalStartMode: "position", - followUpSec: 15, - minLapMs: 12000, - maxLapMs: 60000, - bumpCount: 0, - }, - }, - { - id: "ifmar", - label: t("events.preset_ifmar"), - values: { - qualifyingScoring: "points", - qualifyingRounds: 5, - carsPerHeat: 10, - qualDurationMin: 5, - qualStartMode: "staggered", - qualSeedLapCount: 3, - qualSeedMethod: "best_sum", - countedQualRounds: 3, - qualifyingPointsTable: "ifmar", - qualifyingTieBreak: "best_lap", - carsPerFinal: 10, - finalLegs: 3, - countedFinalLegs: 2, - finalDurationMin: 5, - finalStartMode: "position", - followUpSec: 15, - minLapMs: 12000, - maxLapMs: 70000, - bumpCount: 0, - }, - }, - { - id: "endurance", - label: t("events.preset_endurance"), - values: { - qualifyingScoring: "best", - qualifyingRounds: 1, - carsPerHeat: 12, - qualDurationMin: 10, - qualStartMode: "mass", - qualSeedLapCount: 0, - qualSeedMethod: "best_sum", - countedQualRounds: 1, - qualifyingPointsTable: "rank_low", - qualifyingTieBreak: "best_round", - carsPerFinal: 12, - finalLegs: 1, - countedFinalLegs: 1, - finalDurationMin: 240, - finalStartMode: "mass", - followUpSec: 60, - minLapMs: 10000, - maxLapMs: 120000, - bumpCount: 0, - }, - }, - ]; - const customPresets = Array.isArray(state.settings?.racePresets) - ? state.settings.racePresets - .map((preset) => normalizeStoredRacePreset(preset)) - .filter((preset) => preset.name) - .map((preset) => ({ - id: preset.id, - label: preset.name, - custom: true, - values: { ...preset.values }, - })) - : []; - return [...builtins, ...customPresets]; -} - -function applyRaceFormatPreset(event, presetId) { - const preset = getRaceFormatPresets().find((item) => item.id === presetId); - if (!preset || preset.id === "custom") { - event.raceConfig.presetId = "custom"; - return; - } - Object.assign(event.raceConfig, preset.values, { presetId: preset.id }); -} - -function buildRaceFormatConfigFromForm(form, event) { - return { - presetId: String(form.get("presetId") || "custom").trim() || "custom", - qualifyingScoring: String(form.get("qualifyingScoring") || "points") === "best" ? "best" : "points", - qualifyingRounds: Math.max(1, Number(form.get("qualifyingRounds") || 3) || 3), - carsPerHeat: Math.max(2, Number(form.get("carsPerHeat") || 8) || 8), - qualDurationMin: Math.max(1, Number(form.get("qualDurationMin") || 5) || 5), - qualStartMode: normalizeStartMode(String(form.get("qualStartMode") || "staggered")), - qualSeedLapCount: Math.max(0, Number(form.get("qualSeedLapCount") || 2) || 0), - qualSeedMethod: ["best_sum", "average", "consecutive"].includes(String(form.get("qualSeedMethod") || "").toLowerCase()) - ? String(form.get("qualSeedMethod")).toLowerCase() - : "best_sum", - countedQualRounds: Math.max(1, Number(form.get("countedQualRounds") || 1) || 1), - qualifyingPointsTable: ["rank_low", "field_desc", "ifmar"].includes(String(form.get("qualifyingPointsTable") || "").toLowerCase()) - ? String(form.get("qualifyingPointsTable")).toLowerCase() - : "rank_low", - qualifyingTieBreak: ["rounds", "best_lap", "best_round"].includes(String(form.get("qualifyingTieBreak") || "").toLowerCase()) - ? String(form.get("qualifyingTieBreak")).toLowerCase() - : "rounds", - carsPerFinal: Math.max(2, Number(form.get("carsPerFinal") || 8) || 8), - finalLegs: Math.max(1, Number(form.get("finalLegs") || 1) || 1), - countedFinalLegs: Math.max(1, Number(form.get("countedFinalLegs") || 1) || 1), - finalDurationMin: Math.max(1, Number(form.get("finalDurationMin") || 5) || 5), - finalStartMode: normalizeStartMode(String(form.get("finalStartMode") || "position")), - followUpSec: Math.max(0, Number(form.get("followUpSec") || 0) || 0), - minLapMs: Math.max(0, Math.round((Number(form.get("minLapSec") || 0) || 0) * 1000)), - maxLapMs: Math.max(1000, Math.round((Number(form.get("maxLapSec") || 60) || 60) * 1000)), - bumpCount: Math.max(0, Number(form.get("bumpCount") || 0) || 0), - reserveBumpSlots: form.get("reserveBumpSlots") === "on", - driverIds: event.raceConfig.driverIds || [], - participantsConfigured: event.raceConfig.participantsConfigured !== false, - finalsSource: String(form.get("finalsSource") || "qualifying") === "practice" ? "practice" : "qualifying", - teams: getEventTeams(event), - }; -} - function normalizeDriver(driver) { const item = driver && typeof driver === "object" ? driver : {}; return { @@ -2753,70 +2599,6 @@ function normalizeCar(car) { }; } -function normalizeEvent(event) { - return { - ...event, - branding: normalizeBrandingConfig(event?.branding), - raceConfig: { - presetId: String(event?.raceConfig?.presetId || "custom").trim() || "custom", - qualifyingScoring: event?.raceConfig?.qualifyingScoring === "best" ? "best" : "points", - qualifyingRounds: Math.max(1, Number(event?.raceConfig?.qualifyingRounds || 3) || 3), - carsPerHeat: Math.max(2, Number(event?.raceConfig?.carsPerHeat || 8) || 8), - qualDurationMin: Math.max(1, Number(event?.raceConfig?.qualDurationMin || 5) || 5), - qualStartMode: normalizeStartMode(event?.raceConfig?.qualStartMode || "staggered"), - qualSeedLapCount: Math.max(0, Number(event?.raceConfig?.qualSeedLapCount || 2) || 2), - qualSeedMethod: ["best_sum", "average", "consecutive"].includes(String(event?.raceConfig?.qualSeedMethod || "").toLowerCase()) - ? String(event.raceConfig.qualSeedMethod).toLowerCase() - : "best_sum", - countedQualRounds: Math.max(1, Number(event?.raceConfig?.countedQualRounds || 1) || 1), - qualifyingPointsTable: ["rank_low", "field_desc", "ifmar"].includes(String(event?.raceConfig?.qualifyingPointsTable || "").toLowerCase()) - ? String(event.raceConfig.qualifyingPointsTable).toLowerCase() - : "rank_low", - qualifyingTieBreak: ["rounds", "best_lap", "best_round"].includes(String(event?.raceConfig?.qualifyingTieBreak || "").toLowerCase()) - ? String(event.raceConfig.qualifyingTieBreak).toLowerCase() - : "rounds", - carsPerFinal: Math.max(2, Number(event?.raceConfig?.carsPerFinal || 8) || 8), - finalLegs: Math.max(1, Number(event?.raceConfig?.finalLegs || 1) || 1), - countedFinalLegs: Math.max(1, Number(event?.raceConfig?.countedFinalLegs || 1) || 1), - finalDurationMin: Math.max(1, Number(event?.raceConfig?.finalDurationMin || 5) || 5), - finalStartMode: normalizeStartMode(event?.raceConfig?.finalStartMode || "position"), - followUpSec: Math.max(0, Number(event?.raceConfig?.followUpSec || 0) || 0), - minLapMs: Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0), - maxLapMs: Math.max(0, Number(event?.raceConfig?.maxLapMs || 60000) || 60000), - bumpCount: Math.max(0, Number(event?.raceConfig?.bumpCount || 0) || 0), - reserveBumpSlots: event?.raceConfig?.reserveBumpSlots !== false, - driverIds: Array.isArray(event?.raceConfig?.driverIds) ? event.raceConfig.driverIds : [], - participantsConfigured: Boolean(event?.raceConfig?.participantsConfigured), - finalsSource: event?.raceConfig?.finalsSource === "practice" ? "practice" : "qualifying", - teams: Array.isArray(event?.raceConfig?.teams) ? event.raceConfig.teams.map((team) => normalizeRaceTeam(team)).filter((team) => team.name) : [], - }, - }; -} - -function normalizeBrandingConfig(branding) { - const theme = ["classic", "minimal", "motorsport"].includes(String(branding?.pdfTheme || "").toLowerCase()) - ? String(branding.pdfTheme).toLowerCase() - : ""; - return { - brandName: String(branding?.brandName || "").trim(), - brandTagline: String(branding?.brandTagline || "").trim(), - pdfFooter: String(branding?.pdfFooter || "").trim(), - pdfTheme: theme, - logoDataUrl: String(branding?.logoDataUrl || "").trim(), - }; -} - -function resolveEventBranding(event) { - const local = normalizeBrandingConfig(event?.branding); - return { - brandName: local.brandName || state.settings.clubName || "JMK RB", - brandTagline: local.brandTagline || state.settings.clubTagline || "Live Event", - pdfFooter: local.pdfFooter || state.settings.pdfFooter || "Generated by JMK RB RaceController", - pdfTheme: local.pdfTheme || state.settings.pdfTheme || "classic", - logoDataUrl: local.logoDataUrl || state.settings.logoDataUrl || "", - }; -} - function scheduleBackendSync() { clearTimeout(backendSyncTimer); backendSyncTimer = setTimeout(() => { @@ -7995,99 +7777,10 @@ function renderTable(headers, rowHtml) { `; } -function getRaceManageStatuses(event, sessions, raceDrivers, raceTeams, selectedPreset) { - const cfg = event.raceConfig || {}; - 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; - const qualCount = sessions.filter((session) => session.type === "qualification").length; - const finalCount = sessions.filter((session) => session.type === "final").length; - const teamCount = sessions.filter((session) => session.type === "team_race").length; - const hasQualifying = qualCount > 0; - const hasFinals = finalCount > 0; - const hasTeamRace = teamCount > 0; - const startedCount = sessions.filter((session) => ["running", "finished", "stopped"].includes(String(session.status || "").toLowerCase()) || session.startedAt).length; - const practiceRows = buildPracticeStandings(event); - const qualifyingRows = buildQualifyingStandings(event); - const finalRows = buildFinalStandings(event); - 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 setupStatus = participantCount > 0 && (!isEndurancePreset || raceTeams.length > 0) - ? "complete" - : participantCount > 0 || raceTeams.length > 0 - ? "attention" - : "pending"; - - const formatStatus = lapWindowValid ? "complete" : "attention"; - - const generationReady = isEndurancePreset ? hasTeamRace : hasQualifying || hasFinals; - const generationStatus = generationReady ? "complete" : sessions.length > 0 ? "attention" : "pending"; - - const liveStatus = startedCount > 0 || resultSourceCount > 0 ? "complete" : generationReady ? "pending" : "pending"; - - return { - setup: { - status: setupStatus, - detail: `${participantCount} ${t("events.detail_drivers")} · ${raceTeams.length} ${t("events.detail_teams")}`, - }, - format: { - status: formatStatus, - detail: `${selectedPreset?.label || t("events.preset_custom")} · ${((cfg.minLapMs || 0) / 1000).toFixed(1)} / ${((cfg.maxLapMs || 0) / 1000).toFixed(1)}s`, - }, - generation: { - status: generationStatus, - detail: isEndurancePreset - ? `P ${practiceCount} · T ${teamCount} · ${sessions.length} ${t("events.detail_sessions")}` - : `Q ${qualCount} · F ${finalCount} · ${sessions.length} ${t("events.detail_sessions")}`, - }, - live: { - status: liveStatus, - detail: `${startedCount} ${t("events.detail_active")} · ${resultSourceCount} ${t("events.detail_results")}`, - }, - }; -} - function getDriversForClass(classId) { return state.drivers.filter((driver) => !classId || driver.classId === classId); } -function buildDefaultRaceWizardDraft() { - const classId = state.classes[0]?.id || ""; - const presetId = "club_qualifying"; - const classDrivers = getDriversForClass(classId); - return { - name: "", - date: new Date().toISOString().slice(0, 10), - classId, - presetId, - driverIds: classDrivers.map((driver) => driver.id), - createPractice: true, - practiceSessions: 1, - createQualifying: true, - qualifyingRounds: 4, - createTeamRace: false, - teamRaceDurationMin: 240, - }; -} - -function getRaceWizardPreset(presetId) { - return getRaceFormatPresets().find((preset) => preset.id === presetId) || getRaceFormatPresets()[0]; -} - -function applyRaceWizardPresetDefaults(draft, presetId) { - const preset = getRaceWizardPreset(presetId); - const endurance = preset.id === "endurance"; - draft.presetId = preset.id; - draft.createPractice = !endurance; - draft.practiceSessions = endurance ? 0 : Math.max(1, Number(draft.practiceSessions || 1)); - draft.createQualifying = !endurance; - draft.qualifyingRounds = Math.max(1, Number(preset.values?.qualifyingRounds || draft.qualifyingRounds || 3)); - draft.createTeamRace = endurance; - draft.teamRaceDurationMin = Math.max(1, Number(preset.values?.finalDurationMin || draft.teamRaceDurationMin || 240)); - return draft; -} - function ensureRaceWizardDraft() { if (!raceWizardDraft) { raceWizardDraft = applyRaceWizardPresetDefaults(buildDefaultRaceWizardDraft(), "club_qualifying"); @@ -8101,41 +7794,6 @@ function ensureRaceWizardDraft() { } } -function buildRaceSession(eventId, name, type, durationMin, overrides = {}) { - return normalizeSession({ - id: uid("session"), - eventId, - name, - type, - durationMin, - maxCars: null, - mode: "race", - status: "ready", - startedAt: null, - endedAt: null, - finishedByTimer: false, - assignments: [], - driverIds: overrides.driverIds || [], - startMode: overrides.startMode || "mass", - seedBestLapCount: overrides.seedBestLapCount || 0, - seedMethod: overrides.seedMethod || "best_sum", - followUpSec: overrides.followUpSec || 0, - }); -} - -function getRaceWizardSessionPlan(draft) { - const plan = []; - if (draft.createPractice) { - plan.push(`${Math.max(1, Number(draft.practiceSessions || 1))} x ${t("session.practice")}`); - } - if (draft.createQualifying) { - plan.push(`${Math.max(1, Number(draft.qualifyingRounds || 1))} x ${t("session.qualification")}`); - } - if (draft.createTeamRace) { - plan.push(`${t("events.team_race")} ${Math.max(1, Number(draft.teamRaceDurationMin || 1))} min`); - } - return plan; -} function formatLap(ms) { if (!ms && ms !== 0) { @@ -8347,37 +8005,6 @@ function getCompetitorSeedMetric(session, row) { }; } -function getEventDrivers(event) { - const classDrivers = state.drivers.filter((driver) => !event?.classId || driver.classId === event.classId); - if (!event?.raceConfig?.participantsConfigured) { - return classDrivers; - } - return classDrivers.filter((driver) => (event.raceConfig.driverIds || []).includes(driver.id)); -} - -function getEventTeams(event) { - return Array.isArray(event?.raceConfig?.teams) ? event.raceConfig.teams.map((team) => normalizeRaceTeam(team)).filter((team) => team.name) : []; -} - -function getTeamDriverPool(event) { - const scopedDrivers = getEventDrivers(event); - if (scopedDrivers.length) { - return { drivers: scopedDrivers, fallback: false }; - } - return { - drivers: [...state.drivers], - fallback: state.drivers.length > 0, - }; -} - -function findEventTeamForPassing(event, driverId, carId) { - return getEventTeams(event).find((team) => { - const driverMatch = driverId && Array.isArray(team.driverIds) && team.driverIds.includes(driverId); - const carMatch = carId && Array.isArray(team.carIds) && team.carIds.includes(carId); - return Boolean(driverMatch || carMatch); - }) || null; -} - function getSessionEntrants(session) { const event = state.events.find((item) => item.id === session.eventId); const eventDrivers = event ? getEventDrivers(event) : state.drivers; @@ -8866,182 +8493,6 @@ function clearGeneratedFinals(eventId) { }); } -function generateQualifyingForRace(event) { - const sourceRows = buildPracticeStandings(event); - const fallbackRows = - sourceRows.length > 0 - ? sourceRows - : getEventDrivers(event).map((driver, index) => ({ - rank: index + 1, - driverId: driver.id, - driverName: driver.name, - })); - - if (!fallbackRows.length) { - return 0; - } - - clearGeneratedQualifying(event.id); - - const qualifyingRounds = Math.max(1, Number(event.raceConfig?.qualifyingRounds || 1) || 1); - const carsPerHeat = Math.max(2, Number(event.raceConfig?.carsPerHeat || 8) || 8); - const qualDurationMin = Math.max(1, Number(event.raceConfig?.qualDurationMin || 5) || 5); - const qualStartMode = normalizeStartMode(event.raceConfig?.qualStartMode || "staggered"); - const qualSeedLapCount = Math.max(0, Number(event.raceConfig?.qualSeedLapCount || 2) || 0); - const qualSeedMethod = ["best_sum", "average", "consecutive"].includes(String(event.raceConfig?.qualSeedMethod || "").toLowerCase()) - ? String(event.raceConfig.qualSeedMethod).toLowerCase() - : "best_sum"; - const followUpSec = Math.max(0, Number(event.raceConfig?.followUpSec || 0) || 0); - const heats = chunkArray(fallbackRows, carsPerHeat); - let created = 0; - - heats.forEach((heatRows, heatIndex) => { - const heatNumber = heatIndex + 1; - const driverIds = heatRows.map((row) => row.driverId).filter(Boolean); - for (let round = 1; round <= qualifyingRounds; round += 1) { - state.sessions.push( - normalizeSession({ - id: uid("session"), - eventId: event.id, - name: `Q${round} H${heatNumber}`, - type: "qualification", - durationMin: qualDurationMin, - maxCars: carsPerHeat, - mode: "race", - status: "ready", - startedAt: null, - endedAt: null, - finishedByTimer: false, - followUpSec, - followUpStartedAt: null, - startMode: qualStartMode, - seedBestLapCount: qualSeedLapCount, - seedMethod: qualSeedMethod, - staggerGapSec: 3, - driverIds, - manualGridIds: [...driverIds], - gridCustomized: false, - generated: true, - assignments: [], - }) - ); - created += 1; - } - }); - - return created; -} - -function reseedUpcomingQualifying(event) { - const standings = buildQualifyingStandings(event); - const sourceRows = - standings.length > 0 - ? standings - : buildPracticeStandings(event).length > 0 - ? buildPracticeStandings(event) - : getEventDrivers(event).map((driver, index) => ({ - rank: index + 1, - driverId: driver.id, - driverName: driver.name, - })); - - if (!sourceRows.length) { - return 0; - } - - const carsPerHeat = Math.max(2, Number(event.raceConfig?.carsPerHeat || 8) || 8); - const heats = chunkArray(sourceRows, carsPerHeat); - const sessionsByRound = new Map(); - getSessionsForEvent(event.id) - .filter((session) => session.type === "qualification" && session.generated && session.status === "ready") - .forEach((session) => { - const match = String(session.name || "").match(/^Q(\d+)\s+H(\d+)/i); - const round = match ? Number(match[1]) : 0; - if (!sessionsByRound.has(round)) { - sessionsByRound.set(round, []); - } - sessionsByRound.get(round).push(session); - }); - - let updated = 0; - let locked = 0; - [...sessionsByRound.entries()].forEach(([, roundSessions]) => { - roundSessions.sort((a, b) => String(a.name).localeCompare(String(b.name))); - roundSessions.forEach((session, heatIndex) => { - if (session.gridCustomized) { - locked += 1; - return; - } - const heatRows = heats[heatIndex] || []; - session.driverIds = heatRows.map((row) => row.driverId).filter(Boolean); - session.manualGridIds = [...session.driverIds]; - updated += 1; - }); - }); - - return { updated, locked }; -} - -function generateFinalsForRace(event) { - const sourceRows = - event.raceConfig?.finalsSource === "practice" - ? buildPracticeStandings(event) - : buildQualifyingStandings(event); - - if (!sourceRows.length) { - return 0; - } - - clearGeneratedFinals(event.id); - - const finalLegs = Math.max(1, Number(event.raceConfig?.finalLegs || 1) || 1); - const carsPerFinal = Math.max(2, Number(event.raceConfig?.carsPerFinal || 8) || 8); - const finalDurationMin = Math.max(1, Number(event.raceConfig?.finalDurationMin || 5) || 5); - const finalStartMode = normalizeStartMode(event.raceConfig?.finalStartMode || "position"); - const followUpSec = Math.max(0, Number(event.raceConfig?.followUpSec || 0) || 0); - const bumpCount = Math.max(0, Number(event.raceConfig?.bumpCount || 0) || 0); - const reserveBumpSlots = Boolean(event.raceConfig?.reserveBumpSlots && bumpCount > 0); - const seededSlotsPerMain = reserveBumpSlots ? Math.max(1, carsPerFinal - bumpCount) : carsPerFinal; - const mains = chunkArray(sourceRows, seededSlotsPerMain); - let created = 0; - - mains.forEach((mainRows, mainIndex) => { - const mainLetter = String.fromCharCode(65 + mainIndex); - const driverIds = mainRows.map((row) => row.driverId).filter(Boolean); - for (let leg = 1; leg <= finalLegs; leg += 1) { - state.sessions.push( - normalizeSession({ - id: uid("session"), - eventId: event.id, - name: `${mainLetter} ${t("session.final")} ${leg}`, - type: "final", - durationMin: finalDurationMin, - maxCars: carsPerFinal, - mode: "race", - status: "ready", - startedAt: null, - endedAt: null, - finishedByTimer: false, - followUpSec, - followUpStartedAt: null, - startMode: finalStartMode, - seedBestLapCount: 0, - staggerGapSec: 0, - driverIds, - manualGridIds: [...driverIds], - gridCustomized: false, - reservedBumpSlots: reserveBumpSlots && mainIndex < mains.length - 1 ? bumpCount : 0, - generated: true, - assignments: [], - }) - ); - created += 1; - } - }); - - return created; -} - function buildFinalStandings(event) { const sessions = getSessionsForEvent(event.id).filter((session) => session.type === "final"); const groupedByMain = new Map(); @@ -9755,75 +9206,6 @@ function openOverlayWindow(mode = "leaderboard", options = {}) { overlayWindow.focus(); } -function applyBumpsForRace(event) { - const bumpCount = Math.max(0, Number(event.raceConfig?.bumpCount || 0) || 0); - if (bumpCount <= 0) { - return 0; - } - - const standings = buildFinalStandings(event); - if (!standings.length) { - return 0; - } - - const grouped = new Map(); - standings.forEach((row) => { - const mainKey = String(row.rank || "").charAt(0).toUpperCase(); - if (!grouped.has(mainKey)) { - grouped.set(mainKey, []); - } - grouped.get(mainKey).push(row); - }); - - const mainKeys = [...grouped.keys()].sort(); - let applied = 0; - - for (let index = 1; index < mainKeys.length; index += 1) { - const upperMainKey = mainKeys[index - 1]; - const lowerMainKey = mainKeys[index]; - const lowerRows = grouped.get(lowerMainKey) || []; - const bumpRows = lowerRows.slice(0, bumpCount); - if (!bumpRows.length) { - continue; - } - - const upperSessions = getSessionsForEvent(event.id).filter((session) => { - return session.type === "final" && String(session.name || "").toUpperCase().startsWith(upperMainKey) && session.status === "ready"; - }); - - if (!upperSessions.length) { - continue; - } - - const bumpDriverIds = bumpRows.map((row) => row.driverId).filter(Boolean); - if (!bumpDriverIds.length) { - continue; - } - - upperSessions.forEach((session) => { - session.driverIds = Array.isArray(session.driverIds) ? session.driverIds : []; - session.manualGridIds = Array.isArray(session.manualGridIds) ? session.manualGridIds : [...session.driverIds]; - const reservedSlots = Math.max(0, Number(session.reservedBumpSlots || 0) || 0); - const capacity = Math.max(0, Number(session.maxCars || event.raceConfig?.carsPerFinal || 0) || 0); - const allowedSize = reservedSlots > 0 ? Math.min(capacity, session.driverIds.length + reservedSlots) : capacity || Infinity; - let addedHere = 0; - bumpDriverIds.forEach((driverId) => { - if (!session.driverIds.includes(driverId) && session.driverIds.length < allowedSize) { - session.driverIds.push(driverId); - if (!session.manualGridIds.includes(driverId)) { - session.manualGridIds.push(driverId); - } - applied += 1; - addedHere += 1; - } - }); - session.reservedBumpSlots = Math.max(0, reservedSlots - addedHere); - }); - } - - return applied; -} - function chunkArray(items, size) { const chunks = []; for (let index = 0; index < items.length; index += size) { @@ -9832,18 +9214,6 @@ function chunkArray(items, size) { return chunks; } -function ensureRaceParticipantsConfigured(event) { - if (!event || event.mode !== "race") { - return; - } - if (event.raceConfig?.participantsConfigured) { - return; - } - const classDrivers = state.drivers.filter((driver) => !event.classId || driver.classId === event.classId); - event.raceConfig.driverIds = classDrivers.map((driver) => driver.id); - event.raceConfig.participantsConfigured = true; -} - function escapeHtml(value) { return String(value) .replaceAll("&", "&") @@ -9857,81 +9227,6 @@ function isValidIsoDate(value) { return /^\d{4}-\d{2}-\d{2}$/.test(String(value || "")); } -function createSponsorRounds(eventId, config) { - const qualificationRounds = Math.max(0, Math.floor(config.qualificationRounds || 0)); - const heatRounds = Math.max(0, Math.floor(config.heatRounds || 0)); - const finalRounds = Math.max(0, Math.floor(config.finalRounds || 0)); - const durationMin = Math.max(1, Math.floor(config.durationMin || 5)); - - for (let i = 1; i <= qualificationRounds; i += 1) { - state.sessions.push(buildTrackSession(eventId, `${t("session.qualification")} ${i}`, "qualification", durationMin)); - } - for (let i = 1; i <= heatRounds; i += 1) { - state.sessions.push(buildTrackSession(eventId, `${t("session.heat")} ${i}`, "heat", durationMin)); - } - for (let i = 1; i <= finalRounds; i += 1) { - state.sessions.push(buildTrackSession(eventId, `${t("session.final")} ${i}`, "final", durationMin)); - } -} - -function buildTrackSession(eventId, name, type, durationMin) { - return normalizeSession({ - id: uid("session"), - eventId, - name, - type, - durationMin, - maxCars: null, - mode: "track", - status: "ready", - startedAt: null, - endedAt: null, - finishedByTimer: false, - assignments: [], - }); -} - -function buildRaceSessionsFromWizard(event, draft) { - const driverIds = event.raceConfig.driverIds || []; - const created = []; - const practiceCount = Math.max(0, Number(draft.practiceSessions || 0) || 0); - const qualRounds = Math.max(0, Number(draft.qualifyingRounds || 0) || 0); - if (draft.createPractice && practiceCount > 0) { - for (let index = 1; index <= practiceCount; index += 1) { - created.push( - buildRaceSession(event.id, `${t("session.practice")} ${index}`, "practice", event.raceConfig.qualDurationMin, { - driverIds, - startMode: "mass", - followUpSec: 0, - }) - ); - } - } - if (draft.createQualifying && qualRounds > 0) { - for (let index = 1; index <= qualRounds; index += 1) { - created.push( - buildRaceSession(event.id, `${t("session.qualification")} ${index}`, "qualification", event.raceConfig.qualDurationMin, { - driverIds, - startMode: event.raceConfig.qualStartMode, - seedBestLapCount: event.raceConfig.qualSeedLapCount, - seedMethod: event.raceConfig.qualSeedMethod, - followUpSec: event.raceConfig.followUpSec, - }) - ); - } - } - if (draft.createTeamRace) { - created.push( - buildRaceSession(event.id, t("events.team_race"), "team_race", Math.max(1, Number(draft.teamRaceDurationMin || event.raceConfig.finalDurationMin || 1)), { - driverIds, - startMode: "mass", - followUpSec: event.raceConfig.followUpSec, - }) - ); - } - return created; -} - function getSelectedAssignmentSessionId() { const form = document.getElementById("assignForm"); if (!(form instanceof HTMLFormElement)) { diff --git a/src/event_race_logic.js b/src/event_race_logic.js new file mode 100644 index 0000000..04fab4d --- /dev/null +++ b/src/event_race_logic.js @@ -0,0 +1,785 @@ +export function normalizeRaceTeam(team, { uid }) { + return { + id: String(team?.id || uid("team")), + name: String(team?.name || "").trim(), + driverIds: Array.isArray(team?.driverIds) ? team.driverIds.filter(Boolean) : [], + carIds: Array.isArray(team?.carIds) ? team.carIds.filter(Boolean) : [], + }; +} + + + +export function normalizeStoredRacePreset(preset, { uid }) { + return { + id: String(preset?.id || uid("preset")), + name: String(preset?.name || "").trim(), + values: preset?.values && typeof preset.values === "object" ? { ...preset.values } : {}, + }; +} + + + +export function getRaceFormatPresets({ state, t, uid, normalizeStoredRacePreset }) { + const builtins = [ + { + id: "custom", + label: t("events.preset_custom"), + values: {}, + }, + { + id: "short_technical", + label: t("events.preset_short_technical"), + values: { + qualifyingScoring: "points", + qualifyingRounds: 3, + carsPerHeat: 6, + qualDurationMin: 5, + qualStartMode: "staggered", + qualSeedLapCount: 3, + qualSeedMethod: "best_sum", + countedQualRounds: 2, + qualifyingPointsTable: "rank_low", + qualifyingTieBreak: "best_lap", + carsPerFinal: 8, + finalLegs: 3, + countedFinalLegs: 2, + finalDurationMin: 5, + finalStartMode: "position", + followUpSec: 10, + minLapMs: 11000, + maxLapMs: 60000, + bumpCount: 0, + }, + }, + { + id: "club_qualifying", + label: t("events.preset_club_qualifying"), + values: { + qualifyingScoring: "points", + qualifyingRounds: 4, + carsPerHeat: 8, + qualDurationMin: 5, + qualStartMode: "staggered", + qualSeedLapCount: 3, + qualSeedMethod: "best_sum", + countedQualRounds: 2, + qualifyingPointsTable: "rank_low", + qualifyingTieBreak: "rounds", + carsPerFinal: 8, + finalLegs: 3, + countedFinalLegs: 2, + finalDurationMin: 5, + finalStartMode: "position", + followUpSec: 15, + minLapMs: 12000, + maxLapMs: 60000, + bumpCount: 0, + }, + }, + { + id: "ifmar", + label: t("events.preset_ifmar"), + values: { + qualifyingScoring: "points", + qualifyingRounds: 5, + carsPerHeat: 10, + qualDurationMin: 5, + qualStartMode: "staggered", + qualSeedLapCount: 3, + qualSeedMethod: "best_sum", + countedQualRounds: 3, + qualifyingPointsTable: "ifmar", + qualifyingTieBreak: "best_lap", + carsPerFinal: 10, + finalLegs: 3, + countedFinalLegs: 2, + finalDurationMin: 5, + finalStartMode: "position", + followUpSec: 15, + minLapMs: 12000, + maxLapMs: 70000, + bumpCount: 0, + }, + }, + { + id: "endurance", + label: t("events.preset_endurance"), + values: { + qualifyingScoring: "best", + qualifyingRounds: 1, + carsPerHeat: 12, + qualDurationMin: 10, + qualStartMode: "mass", + qualSeedLapCount: 0, + qualSeedMethod: "best_sum", + countedQualRounds: 1, + qualifyingPointsTable: "rank_low", + qualifyingTieBreak: "best_round", + carsPerFinal: 12, + finalLegs: 1, + countedFinalLegs: 1, + finalDurationMin: 240, + finalStartMode: "mass", + followUpSec: 60, + minLapMs: 10000, + maxLapMs: 120000, + bumpCount: 0, + }, + }, + ]; + const customPresets = Array.isArray(state.settings?.racePresets) + ? state.settings.racePresets + .map((preset) => normalizeStoredRacePreset(preset)) + .filter((preset) => preset.name) + .map((preset) => ({ + id: preset.id, + label: preset.name, + custom: true, + values: { ...preset.values }, + })) + : []; + return [...builtins, ...customPresets]; +} + + + +export function applyRaceFormatPreset(event, presetId, { getRaceFormatPresets }) { + const preset = getRaceFormatPresets().find((item) => item.id === presetId); + if (!preset || preset.id === "custom") { + event.raceConfig.presetId = "custom"; + return; + } + Object.assign(event.raceConfig, preset.values, { presetId: preset.id }); +} + + + +export function buildRaceFormatConfigFromForm(form, event, { normalizeStartMode, getEventTeams }) { + return { + presetId: String(form.get("presetId") || "custom").trim() || "custom", + qualifyingScoring: String(form.get("qualifyingScoring") || "points") === "best" ? "best" : "points", + qualifyingRounds: Math.max(1, Number(form.get("qualifyingRounds") || 3) || 3), + carsPerHeat: Math.max(2, Number(form.get("carsPerHeat") || 8) || 8), + qualDurationMin: Math.max(1, Number(form.get("qualDurationMin") || 5) || 5), + qualStartMode: normalizeStartMode(String(form.get("qualStartMode") || "staggered")), + qualSeedLapCount: Math.max(0, Number(form.get("qualSeedLapCount") || 2) || 0), + qualSeedMethod: ["best_sum", "average", "consecutive"].includes(String(form.get("qualSeedMethod") || "").toLowerCase()) + ? String(form.get("qualSeedMethod")).toLowerCase() + : "best_sum", + countedQualRounds: Math.max(1, Number(form.get("countedQualRounds") || 1) || 1), + qualifyingPointsTable: ["rank_low", "field_desc", "ifmar"].includes(String(form.get("qualifyingPointsTable") || "").toLowerCase()) + ? String(form.get("qualifyingPointsTable")).toLowerCase() + : "rank_low", + qualifyingTieBreak: ["rounds", "best_lap", "best_round"].includes(String(form.get("qualifyingTieBreak") || "").toLowerCase()) + ? String(form.get("qualifyingTieBreak")).toLowerCase() + : "rounds", + carsPerFinal: Math.max(2, Number(form.get("carsPerFinal") || 8) || 8), + finalLegs: Math.max(1, Number(form.get("finalLegs") || 1) || 1), + countedFinalLegs: Math.max(1, Number(form.get("countedFinalLegs") || 1) || 1), + finalDurationMin: Math.max(1, Number(form.get("finalDurationMin") || 5) || 5), + finalStartMode: normalizeStartMode(String(form.get("finalStartMode") || "position")), + followUpSec: Math.max(0, Number(form.get("followUpSec") || 0) || 0), + minLapMs: Math.max(0, Math.round((Number(form.get("minLapSec") || 0) || 0) * 1000)), + maxLapMs: Math.max(1000, Math.round((Number(form.get("maxLapSec") || 60) || 60) * 1000)), + bumpCount: Math.max(0, Number(form.get("bumpCount") || 0) || 0), + reserveBumpSlots: form.get("reserveBumpSlots") === "on", + driverIds: event.raceConfig.driverIds || [], + participantsConfigured: event.raceConfig.participantsConfigured !== false, + finalsSource: String(form.get("finalsSource") || "qualifying") === "practice" ? "practice" : "qualifying", + teams: getEventTeams(event), + }; +} + + + +export function normalizeEvent(event, { normalizeBrandingConfig, normalizeStartMode, normalizeRaceTeam }) { + return { + ...event, + branding: normalizeBrandingConfig(event?.branding), + raceConfig: { + presetId: String(event?.raceConfig?.presetId || "custom").trim() || "custom", + qualifyingScoring: event?.raceConfig?.qualifyingScoring === "best" ? "best" : "points", + qualifyingRounds: Math.max(1, Number(event?.raceConfig?.qualifyingRounds || 3) || 3), + carsPerHeat: Math.max(2, Number(event?.raceConfig?.carsPerHeat || 8) || 8), + qualDurationMin: Math.max(1, Number(event?.raceConfig?.qualDurationMin || 5) || 5), + qualStartMode: normalizeStartMode(event?.raceConfig?.qualStartMode || "staggered"), + qualSeedLapCount: Math.max(0, Number(event?.raceConfig?.qualSeedLapCount || 2) || 2), + qualSeedMethod: ["best_sum", "average", "consecutive"].includes(String(event?.raceConfig?.qualSeedMethod || "").toLowerCase()) + ? String(event.raceConfig.qualSeedMethod).toLowerCase() + : "best_sum", + countedQualRounds: Math.max(1, Number(event?.raceConfig?.countedQualRounds || 1) || 1), + qualifyingPointsTable: ["rank_low", "field_desc", "ifmar"].includes(String(event?.raceConfig?.qualifyingPointsTable || "").toLowerCase()) + ? String(event.raceConfig.qualifyingPointsTable).toLowerCase() + : "rank_low", + qualifyingTieBreak: ["rounds", "best_lap", "best_round"].includes(String(event?.raceConfig?.qualifyingTieBreak || "").toLowerCase()) + ? String(event.raceConfig.qualifyingTieBreak).toLowerCase() + : "rounds", + carsPerFinal: Math.max(2, Number(event?.raceConfig?.carsPerFinal || 8) || 8), + finalLegs: Math.max(1, Number(event?.raceConfig?.finalLegs || 1) || 1), + countedFinalLegs: Math.max(1, Number(event?.raceConfig?.countedFinalLegs || 1) || 1), + finalDurationMin: Math.max(1, Number(event?.raceConfig?.finalDurationMin || 5) || 5), + finalStartMode: normalizeStartMode(event?.raceConfig?.finalStartMode || "position"), + followUpSec: Math.max(0, Number(event?.raceConfig?.followUpSec || 0) || 0), + minLapMs: Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0), + maxLapMs: Math.max(0, Number(event?.raceConfig?.maxLapMs || 60000) || 60000), + bumpCount: Math.max(0, Number(event?.raceConfig?.bumpCount || 0) || 0), + reserveBumpSlots: event?.raceConfig?.reserveBumpSlots !== false, + driverIds: Array.isArray(event?.raceConfig?.driverIds) ? event.raceConfig.driverIds : [], + participantsConfigured: Boolean(event?.raceConfig?.participantsConfigured), + finalsSource: event?.raceConfig?.finalsSource === "practice" ? "practice" : "qualifying", + teams: Array.isArray(event?.raceConfig?.teams) ? event.raceConfig.teams.map((team) => normalizeRaceTeam(team)).filter((team) => team.name) : [], + }, + }; +} + + + +export function normalizeBrandingConfig(branding) { + const theme = ["classic", "minimal", "motorsport"].includes(String(branding?.pdfTheme || "").toLowerCase()) + ? String(branding.pdfTheme).toLowerCase() + : ""; + return { + brandName: String(branding?.brandName || "").trim(), + brandTagline: String(branding?.brandTagline || "").trim(), + pdfFooter: String(branding?.pdfFooter || "").trim(), + pdfTheme: theme, + logoDataUrl: String(branding?.logoDataUrl || "").trim(), + }; +} + + + +export function resolveEventBranding(event, { normalizeBrandingConfig, state }) { + const local = normalizeBrandingConfig(event?.branding); + return { + brandName: local.brandName || state.settings.clubName || "JMK RB", + brandTagline: local.brandTagline || state.settings.clubTagline || "Live Event", + pdfFooter: local.pdfFooter || state.settings.pdfFooter || "Generated by JMK RB RaceController", + pdfTheme: local.pdfTheme || state.settings.pdfTheme || "classic", + logoDataUrl: local.logoDataUrl || state.settings.logoDataUrl || "", + }; +} + + + +export function getRaceManageStatuses(event, sessions, raceDrivers, raceTeams, selectedPreset, { t, buildPracticeStandings, buildQualifyingStandings, buildFinalStandings }) { + const cfg = event.raceConfig || {}; + 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; + const qualCount = sessions.filter((session) => session.type === "qualification").length; + const finalCount = sessions.filter((session) => session.type === "final").length; + const teamCount = sessions.filter((session) => session.type === "team_race").length; + const hasQualifying = qualCount > 0; + const hasFinals = finalCount > 0; + const hasTeamRace = teamCount > 0; + const startedCount = sessions.filter((session) => ["running", "finished", "stopped"].includes(String(session.status || "").toLowerCase()) || session.startedAt).length; + const practiceRows = buildPracticeStandings(event); + const qualifyingRows = buildQualifyingStandings(event); + const finalRows = buildFinalStandings(event); + 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 setupStatus = participantCount > 0 && (!isEndurancePreset || raceTeams.length > 0) + ? "complete" + : participantCount > 0 || raceTeams.length > 0 + ? "attention" + : "pending"; + + const formatStatus = lapWindowValid ? "complete" : "attention"; + + const generationReady = isEndurancePreset ? hasTeamRace : hasQualifying || hasFinals; + const generationStatus = generationReady ? "complete" : sessions.length > 0 ? "attention" : "pending"; + + const liveStatus = startedCount > 0 || resultSourceCount > 0 ? "complete" : generationReady ? "pending" : "pending"; + + return { + setup: { + status: setupStatus, + detail: `${participantCount} ${t("events.detail_drivers")} · ${raceTeams.length} ${t("events.detail_teams")}`, + }, + format: { + status: formatStatus, + detail: `${selectedPreset?.label || t("events.preset_custom")} · ${((cfg.minLapMs || 0) / 1000).toFixed(1)} / ${((cfg.maxLapMs || 0) / 1000).toFixed(1)}s`, + }, + generation: { + status: generationStatus, + detail: isEndurancePreset + ? `P ${practiceCount} · T ${teamCount} · ${sessions.length} ${t("events.detail_sessions")}` + : `Q ${qualCount} · F ${finalCount} · ${sessions.length} ${t("events.detail_sessions")}`, + }, + live: { + status: liveStatus, + detail: `${startedCount} ${t("events.detail_active")} · ${resultSourceCount} ${t("events.detail_results")}`, + }, + }; +} + + + +export function buildDefaultRaceWizardDraft({ state, getDriversForClass }) { + const classId = state.classes[0]?.id || ""; + const presetId = "club_qualifying"; + const classDrivers = getDriversForClass(classId); + return { + name: "", + date: new Date().toISOString().slice(0, 10), + classId, + presetId, + driverIds: classDrivers.map((driver) => driver.id), + createPractice: true, + practiceSessions: 1, + createQualifying: true, + qualifyingRounds: 4, + createTeamRace: false, + teamRaceDurationMin: 240, + }; +} + + + +export function getRaceWizardPreset(presetId, { getRaceFormatPresets }) { + return getRaceFormatPresets().find((preset) => preset.id === presetId) || getRaceFormatPresets()[0]; +} + + + +export function applyRaceWizardPresetDefaults(draft, presetId, { getRaceWizardPreset }) { + const preset = getRaceWizardPreset(presetId); + const endurance = preset.id === "endurance"; + draft.presetId = preset.id; + draft.createPractice = !endurance; + draft.practiceSessions = endurance ? 0 : Math.max(1, Number(draft.practiceSessions || 1)); + draft.createQualifying = !endurance; + draft.qualifyingRounds = Math.max(1, Number(preset.values?.qualifyingRounds || draft.qualifyingRounds || 3)); + draft.createTeamRace = endurance; + draft.teamRaceDurationMin = Math.max(1, Number(preset.values?.finalDurationMin || draft.teamRaceDurationMin || 240)); + return draft; +} + + + +export function ensureRaceParticipantsConfigured(event, { state }) { + if (!event || event.mode !== "race") { + return; + } + if (event.raceConfig?.participantsConfigured) { + return; + } + const classDrivers = state.drivers.filter((driver) => !event.classId || driver.classId === event.classId); + event.raceConfig.driverIds = classDrivers.map((driver) => driver.id); + event.raceConfig.participantsConfigured = true; +} + + + +export function buildRaceSession(eventId, name, type, durationMin, overrides = {}, { normalizeSession, uid }) { + return normalizeSession({ + id: uid("session"), + eventId, + name, + type, + durationMin, + maxCars: null, + mode: "race", + status: "ready", + startedAt: null, + endedAt: null, + finishedByTimer: false, + assignments: [], + driverIds: overrides.driverIds || [], + startMode: overrides.startMode || "mass", + seedBestLapCount: overrides.seedBestLapCount || 0, + seedMethod: overrides.seedMethod || "best_sum", + followUpSec: overrides.followUpSec || 0, + }); +} + +export function buildTrackSession(eventId, name, type, durationMin, { normalizeSession, uid }) { + return normalizeSession({ + id: uid("session"), + eventId, + name, + type, + durationMin, + maxCars: null, + mode: "track", + status: "ready", + startedAt: null, + endedAt: null, + finishedByTimer: false, + assignments: [], + }); +} + + + +export function createSponsorRounds(eventId, config, { state, t, buildTrackSession }) { + const qualificationRounds = Math.max(0, Math.floor(config.qualificationRounds || 0)); + const heatRounds = Math.max(0, Math.floor(config.heatRounds || 0)); + const finalRounds = Math.max(0, Math.floor(config.finalRounds || 0)); + const durationMin = Math.max(1, Math.floor(config.durationMin || 5)); + + for (let i = 1; i <= qualificationRounds; i += 1) { + state.sessions.push(buildTrackSession(eventId, `${t("session.qualification")} ${i}`, "qualification", durationMin)); + } + for (let i = 1; i <= heatRounds; i += 1) { + state.sessions.push(buildTrackSession(eventId, `${t("session.heat")} ${i}`, "heat", durationMin)); + } + for (let i = 1; i <= finalRounds; i += 1) { + state.sessions.push(buildTrackSession(eventId, `${t("session.final")} ${i}`, "final", durationMin)); + } +} + + + +export function buildRaceSessionsFromWizard(event, draft, { t, buildRaceSession }) { + const driverIds = event.raceConfig.driverIds || []; + const created = []; + const practiceCount = Math.max(0, Number(draft.practiceSessions || 0) || 0); + const qualRounds = Math.max(0, Number(draft.qualifyingRounds || 0) || 0); + if (draft.createPractice && practiceCount > 0) { + for (let index = 1; index <= practiceCount; index += 1) { + created.push( + buildRaceSession(event.id, `${t("session.practice")} ${index}`, "practice", event.raceConfig.qualDurationMin, { + driverIds, + startMode: "mass", + followUpSec: 0, + }) + ); + } + } + if (draft.createQualifying && qualRounds > 0) { + for (let index = 1; index <= qualRounds; index += 1) { + created.push( + buildRaceSession(event.id, `${t("session.qualification")} ${index}`, "qualification", event.raceConfig.qualDurationMin, { + driverIds, + startMode: event.raceConfig.qualStartMode, + seedBestLapCount: event.raceConfig.qualSeedLapCount, + seedMethod: event.raceConfig.qualSeedMethod, + followUpSec: event.raceConfig.followUpSec, + }) + ); + } + } + if (draft.createTeamRace) { + created.push( + buildRaceSession(event.id, t("events.team_race"), "team_race", Math.max(1, Number(draft.teamRaceDurationMin || event.raceConfig.finalDurationMin || 1)), { + driverIds, + startMode: "mass", + followUpSec: event.raceConfig.followUpSec, + }) + ); + } + return created; +} + + + +export function getRaceWizardSessionPlan(draft, { t }) { + const plan = []; + if (draft.createPractice) { + plan.push(`${Math.max(1, Number(draft.practiceSessions || 1))} x ${t("session.practice")}`); + } + if (draft.createQualifying) { + plan.push(`${Math.max(1, Number(draft.qualifyingRounds || 1))} x ${t("session.qualification")}`); + } + if (draft.createTeamRace) { + plan.push(`${t("events.team_race")} ${Math.max(1, Number(draft.teamRaceDurationMin || 1))} min`); + } + return plan; +} + + + +export function getEventDrivers(event, { state }) { + const classDrivers = state.drivers.filter((driver) => !event?.classId || driver.classId === event.classId); + if (!event?.raceConfig?.participantsConfigured) { + return classDrivers; + } + return classDrivers.filter((driver) => (event.raceConfig.driverIds || []).includes(driver.id)); +} + + + +export function getEventTeams(event, { normalizeRaceTeam }) { + return Array.isArray(event?.raceConfig?.teams) ? event.raceConfig.teams.map((team) => normalizeRaceTeam(team)).filter((team) => team.name) : []; +} + + + +export function getTeamDriverPool(event, { getEventDrivers, state }) { + const scopedDrivers = getEventDrivers(event); + if (scopedDrivers.length) { + return { drivers: scopedDrivers, fallback: false }; + } + return { + drivers: [...state.drivers], + fallback: state.drivers.length > 0, + }; +} + + + +export function findEventTeamForPassing(event, driverId, carId, { getEventTeams }) { + return getEventTeams(event).find((team) => { + const driverMatch = driverId && Array.isArray(team.driverIds) && team.driverIds.includes(driverId); + const carMatch = carId && Array.isArray(team.carIds) && team.carIds.includes(carId); + return Boolean(driverMatch || carMatch); + }) || null; +} + + + +export function generateQualifyingForRace(event, { buildPracticeStandings, getEventDrivers, state, normalizeStartMode, normalizeSession, uid, chunkArray, clearGeneratedQualifying }) { + const sourceRows = buildPracticeStandings(event); + const fallbackRows = + sourceRows.length > 0 + ? sourceRows + : getEventDrivers(event).map((driver, index) => ({ + rank: index + 1, + driverId: driver.id, + driverName: driver.name, + })); + + if (!fallbackRows.length) { + return 0; + } + + clearGeneratedQualifying(event.id); + + const qualifyingRounds = Math.max(1, Number(event.raceConfig?.qualifyingRounds || 1) || 1); + const carsPerHeat = Math.max(2, Number(event.raceConfig?.carsPerHeat || 8) || 8); + const qualDurationMin = Math.max(1, Number(event.raceConfig?.qualDurationMin || 5) || 5); + const qualStartMode = normalizeStartMode(event.raceConfig?.qualStartMode || "staggered"); + const qualSeedLapCount = Math.max(0, Number(event.raceConfig?.qualSeedLapCount || 2) || 0); + const qualSeedMethod = ["best_sum", "average", "consecutive"].includes(String(event.raceConfig?.qualSeedMethod || "").toLowerCase()) + ? String(event.raceConfig.qualSeedMethod).toLowerCase() + : "best_sum"; + const followUpSec = Math.max(0, Number(event.raceConfig?.followUpSec || 0) || 0); + const heats = chunkArray(fallbackRows, carsPerHeat); + let created = 0; + + heats.forEach((heatRows, heatIndex) => { + const heatNumber = heatIndex + 1; + const driverIds = heatRows.map((row) => row.driverId).filter(Boolean); + for (let round = 1; round <= qualifyingRounds; round += 1) { + state.sessions.push( + normalizeSession({ + id: uid("session"), + eventId: event.id, + name: `Q${round} H${heatNumber}`, + type: "qualification", + durationMin: qualDurationMin, + maxCars: carsPerHeat, + mode: "race", + status: "ready", + startedAt: null, + endedAt: null, + finishedByTimer: false, + followUpSec, + followUpStartedAt: null, + startMode: qualStartMode, + seedBestLapCount: qualSeedLapCount, + seedMethod: qualSeedMethod, + staggerGapSec: 3, + driverIds, + manualGridIds: [...driverIds], + gridCustomized: false, + generated: true, + assignments: [], + }) + ); + created += 1; + } + }); + + return created; +} + + + +export function reseedUpcomingQualifying(event, { buildQualifyingStandings, buildPracticeStandings, getEventDrivers, getSessionsForEvent, chunkArray }) { + const standings = buildQualifyingStandings(event); + const sourceRows = + standings.length > 0 + ? standings + : buildPracticeStandings(event).length > 0 + ? buildPracticeStandings(event) + : getEventDrivers(event).map((driver, index) => ({ + rank: index + 1, + driverId: driver.id, + driverName: driver.name, + })); + + if (!sourceRows.length) { + return 0; + } + + const carsPerHeat = Math.max(2, Number(event.raceConfig?.carsPerHeat || 8) || 8); + const heats = chunkArray(sourceRows, carsPerHeat); + const sessionsByRound = new Map(); + getSessionsForEvent(event.id) + .filter((session) => session.type === "qualification" && session.generated && session.status === "ready") + .forEach((session) => { + const match = String(session.name || "").match(/^Q(\d+)\s+H(\d+)/i); + const round = match ? Number(match[1]) : 0; + if (!sessionsByRound.has(round)) { + sessionsByRound.set(round, []); + } + sessionsByRound.get(round).push(session); + }); + + let updated = 0; + let locked = 0; + [...sessionsByRound.entries()].forEach(([, roundSessions]) => { + roundSessions.sort((a, b) => String(a.name).localeCompare(String(b.name))); + roundSessions.forEach((session, heatIndex) => { + if (session.gridCustomized) { + locked += 1; + return; + } + const heatRows = heats[heatIndex] || []; + session.driverIds = heatRows.map((row) => row.driverId).filter(Boolean); + session.manualGridIds = [...session.driverIds]; + updated += 1; + }); + }); + + return { updated, locked }; +} + + + +export function generateFinalsForRace(event, { buildPracticeStandings, buildQualifyingStandings, clearGeneratedFinals, state, normalizeStartMode, normalizeSession, uid, chunkArray, t }) { + const sourceRows = + event.raceConfig?.finalsSource === "practice" + ? buildPracticeStandings(event) + : buildQualifyingStandings(event); + + if (!sourceRows.length) { + return 0; + } + + clearGeneratedFinals(event.id); + + const finalLegs = Math.max(1, Number(event.raceConfig?.finalLegs || 1) || 1); + const carsPerFinal = Math.max(2, Number(event.raceConfig?.carsPerFinal || 8) || 8); + const finalDurationMin = Math.max(1, Number(event.raceConfig?.finalDurationMin || 5) || 5); + const finalStartMode = normalizeStartMode(event.raceConfig?.finalStartMode || "position"); + const followUpSec = Math.max(0, Number(event.raceConfig?.followUpSec || 0) || 0); + const bumpCount = Math.max(0, Number(event.raceConfig?.bumpCount || 0) || 0); + const reserveBumpSlots = Boolean(event.raceConfig?.reserveBumpSlots && bumpCount > 0); + const seededSlotsPerMain = reserveBumpSlots ? Math.max(1, carsPerFinal - bumpCount) : carsPerFinal; + const mains = chunkArray(sourceRows, seededSlotsPerMain); + let created = 0; + + mains.forEach((mainRows, mainIndex) => { + const mainLetter = String.fromCharCode(65 + mainIndex); + const driverIds = mainRows.map((row) => row.driverId).filter(Boolean); + for (let leg = 1; leg <= finalLegs; leg += 1) { + state.sessions.push( + normalizeSession({ + id: uid("session"), + eventId: event.id, + name: `${mainLetter} ${t("session.final")} ${leg}`, + type: "final", + durationMin: finalDurationMin, + maxCars: carsPerFinal, + mode: "race", + status: "ready", + startedAt: null, + endedAt: null, + finishedByTimer: false, + followUpSec, + followUpStartedAt: null, + startMode: finalStartMode, + seedBestLapCount: 0, + staggerGapSec: 0, + driverIds, + manualGridIds: [...driverIds], + gridCustomized: false, + reservedBumpSlots: reserveBumpSlots && mainIndex < mains.length - 1 ? bumpCount : 0, + generated: true, + assignments: [], + }) + ); + created += 1; + } + }); + + return created; +} + + + +export function applyBumpsForRace(event, { buildFinalStandings, getSessionsForEvent }) { + const bumpCount = Math.max(0, Number(event.raceConfig?.bumpCount || 0) || 0); + if (bumpCount <= 0) { + return 0; + } + + const standings = buildFinalStandings(event); + if (!standings.length) { + return 0; + } + + const grouped = new Map(); + standings.forEach((row) => { + const mainKey = String(row.rank || "").charAt(0).toUpperCase(); + if (!grouped.has(mainKey)) { + grouped.set(mainKey, []); + } + grouped.get(mainKey).push(row); + }); + + const mainKeys = [...grouped.keys()].sort(); + let applied = 0; + + for (let index = 1; index < mainKeys.length; index += 1) { + const upperMainKey = mainKeys[index - 1]; + const lowerMainKey = mainKeys[index]; + const lowerRows = grouped.get(lowerMainKey) || []; + const bumpRows = lowerRows.slice(0, bumpCount); + if (!bumpRows.length) { + continue; + } + + const upperSessions = getSessionsForEvent(event.id).filter((session) => { + return session.type === "final" && String(session.name || "").toUpperCase().startsWith(upperMainKey) && session.status === "ready"; + }); + + if (!upperSessions.length) { + continue; + } + + const bumpDriverIds = bumpRows.map((row) => row.driverId).filter(Boolean); + if (!bumpDriverIds.length) { + continue; + } + + upperSessions.forEach((session) => { + session.driverIds = Array.isArray(session.driverIds) ? session.driverIds : []; + session.manualGridIds = Array.isArray(session.manualGridIds) ? session.manualGridIds : [...session.driverIds]; + const reservedSlots = Math.max(0, Number(session.reservedBumpSlots || 0) || 0); + const capacity = Math.max(0, Number(session.maxCars || event.raceConfig?.carsPerFinal || 0) || 0); + const allowedSize = reservedSlots > 0 ? Math.min(capacity, session.driverIds.length + reservedSlots) : capacity || Infinity; + let addedHere = 0; + bumpDriverIds.forEach((driverId) => { + if (!session.driverIds.includes(driverId) && session.driverIds.length < allowedSize) { + session.driverIds.push(driverId); + if (!session.manualGridIds.includes(driverId)) { + session.manualGridIds.push(driverId); + } + applied += 1; + addedHere += 1; + } + }); + session.reservedBumpSlots = Math.max(0, reservedSlots - addedHere); + }); + } + + return applied; +} + +