Files
Live_RC/src/event_race_logic.js
larssand cf89086dff Add a time-of-day race day schedule to Event Manager
Events can now be given a start time and a gap between sessions on a
new "Schedule" panel; every session's planned clock time cascades
automatically in running order (practice, qualifying, finals), and
once a session actually starts or finishes, everything after it
re-anchors to that real time - a late start or a session that ran
long (including any pause) pushes the rest of the day forward with
no manual reschedule step. The scheduled time now also shows on the
printed/PDF start lists.

Printable start lists and manually moving a driver to start last in
their own heat (for a mechanic-time request) already existed, so
this only builds the missing piece: the schedule itself.

Also fixes a real pre-existing bug in the same session-ordering logic
this feature relies on: getSessionSortWeight used `order[type] || 99`,
which silently treated open_practice's own weight of 0 as falsy and
sorted practice sessions dead last instead of first in print/PDF
output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 22:33:57 +02:00

1004 lines
40 KiB
JavaScript

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<round> H<heat>" (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),
};
});
}