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, }, }; } 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 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; } 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 }) { return { drivers: Array.isArray(state?.drivers) ? [...state.drivers] : [], fallback: false, }; } 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; }