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 }); event.raceConfig.practiceOnly = false; } 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), practiceOnly: event.raceConfig.practiceOnly === true, }; } 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) : [], practiceOnly: event?.raceConfig?.practiceOnly === true, scheduleStartAt: Number(event?.raceConfig?.scheduleStartAt) > 0 ? Number(event.raceConfig.scheduleStartAt) : null, // 0 is a valid gap (back-to-back sessions), so this can't use || - only a genuinely // non-numeric/missing value should fall back to the 3-minute default. scheduleGapMin: Math.max(0, Number.isFinite(Number(event?.raceConfig?.scheduleGapMin)) ? Number(event.raceConfig.scheduleGapMin) : 3), }, }; } 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 RC RaceCore", 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 isPracticeOnly = cfg.practiceOnly === true; const participantCount = cfg.participantsConfigured ? (cfg.driverIds || []).length : raceDrivers.length; const isEndurancePreset = selectedPreset?.id === "endurance"; const practiceCount = sessions.filter((session) => ["practice", "free_practice", "open_practice"].includes(session.type)).length; 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; if (isPracticeOnly) { const openPracticeCount = sessions.filter((session) => session.type === "open_practice").length; return { setup: { status: openPracticeCount > 0 ? "complete" : "pending", detail: t("events.open_practice_all_transponders"), }, format: { status: "complete", detail: t("events.open_practice_untimed"), }, generation: { status: openPracticeCount > 0 ? "complete" : "pending", detail: `${openPracticeCount} ${t("events.detail_sessions")}`, }, live: { status: startedCount > 0 || resultSourceCount > 0 ? "complete" : "pending", detail: `${startedCount} ${t("events.detail_active")} · ${resultSourceCount} ${t("events.detail_results")}`, }, }; } const setupStatus = participantCount > 0 && (!isEndurancePreset || raceTeams.length > 0) ? "complete" : participantCount > 0 || raceTeams.length > 0 ? "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 buildOpenPracticeBundle(input = {}, { normalizeEvent, buildRaceSession, uid }) { const name = String(input.name || "Open Practice").trim() || "Open Practice"; const event = normalizeEvent({ id: uid("event"), name, date: String(input.date || new Date().toISOString().slice(0, 10)), classId: "", mode: "race", }); event.raceConfig = { ...event.raceConfig, presetId: "custom", practiceOnly: true, driverIds: [], participantsConfigured: false, teams: [], }; const session = buildRaceSession(event.id, name, "open_practice", 1, { driverIds: [], startMode: "mass", followUpSec: 0, }); return { event, session }; } export function buildTrackSession(eventId, name, type, durationMin, { normalizeSession, uid }) { return normalizeSession({ id: uid("session"), 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; } // Preserves the order participants were selected in (event.raceConfig.driverIds), not // the club's global driver-directory order - the two rarely match, and anything seeded // from this list (a fallback qualifying/finals grid with no practice data yet, a // staggered heat's start-order announcement) should follow the order a club admin // actually built the entry list in, not an unrelated internal array order. const classDriverIds = new Set(classDrivers.map((driver) => driver.id)); const byId = new Map(classDrivers.map((driver) => [driver.id, driver])); return (event.raceConfig.driverIds || []) .filter((id) => classDriverIds.has(id)) .map((id) => byId.get(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 }; } // No driver matched the event's class or configured participant list (e.g. a team is // being set up before drivers/participants exist yet) - fall back to every driver in // the club rather than leaving the admin with zero checkboxes and no way to proceed. const allDrivers = Array.isArray(state?.drivers) ? [...state.drivers] : []; return { drivers: allDrivers, fallback: allDrivers.length > 0 }; } // A driver or car can only be a member of one team at a time. There is no separate "move // to another team" action in the UI - ticking a driver's checkbox on a different team's // form IS the move - so saving a team's roster here must pull that driver/car out of // whichever other team currently has them, or the same driver ends up listed on two teams // at once (their live passings then get silently credited to only the first match). export function reassignTeamMembers(teams, { excludeTeamId = "", driverIds = [], carIds = [] } = {}) { const driverSet = new Set(driverIds); const carSet = new Set(carIds); return (Array.isArray(teams) ? teams : []).map((team) => { if (team.id === excludeTeamId) return team; const nextDriverIds = (team.driverIds || []).filter((id) => !driverSet.has(id)); const nextCarIds = (team.carIds || []).filter((id) => !carSet.has(id)); if (nextDriverIds.length === (team.driverIds || []).length && nextCarIds.length === (team.carIds || []).length) { return team; } return { ...team, driverIds: nextDriverIds, carIds: nextCarIds }; }); } 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; } // Generated qualifying sessions are named "Q H" (see generateQualifyingForRace) // with no separate round/heat fields of their own - shared here so every place that needs // to know which round/heat a session belongs to parses the same way. export function parseQualifyingHeatName(name) { const match = String(name || "").match(/^Q(\d+)\s+H(\d+)/i); return match ? { round: Number(match[1]), heat: Number(match[2]) } : null; } 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 { updated: 0, locked: 0, mismatched: 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 parsed = parseQualifyingHeatName(session.name); const round = parsed?.round ?? 0; // parseQualifyingHeatName already captures the heat number - sorting the full // session name as a string instead ("H10" < "H2" lexicographically) used to scramble // the grid for any round with 10+ heats. const heatNumber = parsed?.heat ?? 0; if (!sessionsByRound.has(round)) { sessionsByRound.set(round, []); } sessionsByRound.get(round).push({ session, heatNumber }); }); let updated = 0; let locked = 0; let mismatched = 0; [...sessionsByRound.entries()].forEach(([, roundEntries]) => { roundEntries.sort((a, b) => a.heatNumber - b.heatNumber); // The field size changed since these heats were generated (drivers added or removed) // - the number of ready heats no longer matches how many the current participant list // needs. Heats that do line up still get reseeded below (better than leaving a stale // grid), but this is surfaced to the caller rather than silently dropping the extra // drivers or leaving empty "ghost" heats with no explanation. if (roundEntries.length !== heats.length) { mismatched += 1; } roundEntries.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, mismatched }; } // Moving a driver onto a heat's roster (there is no separate "move" action - ticking them // on the target heat's edit form IS the move) must pull them out of whichever other heat // they're currently gridded in, or they end up double-entered - the same class of bug // already fixed for team rosters. Scoped to the target heat's own round only ("Q2 H2" never // touches "Q1"/"Q3"), since heat composition can legitimately differ round to round after a // reseed, and an admin fixing one round's lone-driver heat should not silently reshuffle // rounds they didn't ask to change. export function moveDriversIntoQualifyingHeat(sessions, targetSessionId, driverIds) { const list = Array.isArray(sessions) ? sessions : []; const target = list.find((session) => session.id === targetSessionId); const parsedTarget = target ? parseQualifyingHeatName(target.name) : null; if (!parsedTarget) { return; } const driverSet = new Set(driverIds); list.forEach((session) => { if (session.id === targetSessionId || session.type !== "qualification") { return; } const parsed = parseQualifyingHeatName(session.name); if (!parsed || parsed.round !== parsedTarget.round) { return; } session.driverIds = (session.driverIds || []).filter((id) => !driverSet.has(id)); session.manualGridIds = (session.manualGridIds || []).filter((id) => !driverSet.has(id)); }); } 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); // Clamped below carsPerFinal so a session never claims to reserve more bump slots than // its own final can hold. const bumpCount = Math.min(Math.max(0, Number(event.raceConfig?.bumpCount || 0) || 0), Math.max(0, carsPerFinal - 1)); const reserveBumpSlots = Boolean(event.raceConfig?.reserveBumpSlots && bumpCount > 0); // A large bumpCount relative to carsPerFinal (even after the clamp above) still seeds // very few drivers per main - with a big enough field, that used to produce dozens of // one-car mains, running the A/B/C... letter naming past "Z" into unrelated punctuation // characters (and every main past Z then collapsed onto "A" in buildFinalStandings, // silently merging their results). Mains are capped at 26 by widening each main instead. const minSlotsForMainCap = Math.max(1, Math.ceil(sourceRows.length / 26)); const seededSlotsPerMain = Math.max(minSlotsForMainCap, 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(); // Read from the event's current setting rather than each session's own // reservedBumpSlots value - that value reaches 0 both when reservation was never turned // on (mains were seeded to full capacity, and bumping should still grow the field) and // when a reserved main's slots have all been used already (which should keep the main // capped). Those need different behavior below, and reservedBumpSlots alone can't tell // them apart. const reserveBumpSlots = Boolean(event.raceConfig?.reserveBumpSlots); 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); // Reserved mode: capped at capacity, gated by whatever reserved room is left (0 once // exhausted, correctly blocking further growth). Not reserved: no cap at all - the // main was already seeded to full capacity, so bumping has to grow it to do anything. const allowedSize = reserveBumpSlots ? Math.min(capacity, session.driverIds.length + reservedSlots) : 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); }); // A bumped driver only leaves the lower main's leg(s) that haven't run yet - the leg(s) // already completed keep their real, historical result. Without this they'd stay // gridded in both the upper main (their new final) and the lower main's remaining // legs at once, the same kind of double-entry the team-roster bug had. const remainingLowerSessions = getSessionsForEvent(event.id).filter((session) => session.type === "final" && String(session.name || "").toUpperCase().startsWith(lowerMainKey) && session.status === "ready" ); remainingLowerSessions.forEach((session) => { if (Array.isArray(session.driverIds)) { session.driverIds = session.driverIds.filter((id) => !bumpDriverIds.includes(id)); } if (Array.isArray(session.manualGridIds)) { session.manualGridIds = session.manualGridIds.filter((id) => !bumpDriverIds.includes(id)); } }); } return applied; } // A planned run-of-day schedule: given a start time and a gap between sessions, each // session gets a planned clock time in running order (practice, then qualifying, then // finals - the same order the printed start lists already use). Once a session has // actually started or finished, everything after it re-anchors to that real time instead // of the original plan - a heat that started late or ran long (including any pause; a // paused session's real endedAt already reflects the full wall-clock time it took) // automatically pushes the rest of the day forward, with no separate "reschedule" step. export function buildEventSchedule(event, sessions, { getSessionSortWeight }) { const scheduleStartAt = Number(event?.raceConfig?.scheduleStartAt || 0) || null; if (!scheduleStartAt) { return []; } const gapMs = Math.max(0, Number(event?.raceConfig?.scheduleGapMin ?? 3) || 0) * 60000; const ordered = [...sessions] .filter((session) => session.mode === "race") .sort((left, right) => { const weightDiff = getSessionSortWeight(left) - getSessionSortWeight(right); if (weightDiff !== 0) { return weightDiff; } return String(left.name || "").localeCompare(String(right.name || "")); }); let cursor = scheduleStartAt; return ordered.map((session) => { const plannedStart = cursor; const durationMs = Math.max(1, Number(session.durationMin || 0)) * 60000 + Math.max(0, Number(session.followUpSec || 0)) * 1000; const plannedEnd = plannedStart + durationMs; const actualStart = Number(session.startedAt || 0) || null; const actualEnd = session.status === "finished" ? Number(session.endedAt || 0) || null : null; const effectiveEnd = actualEnd || (actualStart ? actualStart + durationMs : plannedEnd); cursor = effectiveEnd + gapMs; return { sessionId: session.id, plannedStart, plannedEnd, actualStart, actualEnd, // A session that actually started more than a minute after its own planned time is // worth flagging - small drift is normal and not worth calling out row by row. delayed: Boolean(actualStart && actualStart > plannedStart + 60000), }; }); }