Add moving a driver between qualifying heats

The user asked whether a lone driver left in the last heat (e.g. 7
drivers at 3 per heat) could be moved into another heat manually.
Investigated first: neither "Reseed" (redistributes every not-yet-run
heat by current standings, not a targeted move) nor the grid editor
(reorders start position within one heat, never moves a driver
between heats) could do this - there was genuinely no path to it.

moveDriversIntoQualifyingHeat (src/event_race_logic.js) adds the same
"checking them elsewhere is the move" pattern already used for team
rosters: a new driver checklist on the qualification session edit
form pulls a checked driver out of whichever other heat they're
currently on, scoped to the same round only ("Q2 H2" never touches
"Q1"/"Q3", since heat composition can legitimately differ round to
round after a reseed). The checklist names the driver's current heat
before saving, mirroring the team-roster warning.

Also confirmed, per the user's earlier questions, already correct
with no changes needed: practice's per-driver "best 3 laps"/"best
5-minute result" stats, and that heats are already seeded fastest-
first from practice results. Flagged one real, deliberately unbuilt
gap while investigating: a true per-driver rolling "5 minutes from
your own first crossing" qualifying *heat* clock isn't supported -
only the practice stats work that way today.

Verified with new unit tests for moveDriversIntoQualifyingHeat and
the extracted parseQualifyingHeatName helper, and a real headless-
browser run reproducing the exact reported scenario end to end
through the actual UI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-10 18:18:01 +02:00
parent e9883188fe
commit b1b65a9cf9
9 changed files with 166 additions and 10 deletions

View File

@@ -672,6 +672,16 @@ export function generateQualifyingForRace(event, { buildPracticeStandings, getEv
// 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 =
@@ -695,12 +705,12 @@ export function reseedUpcomingQualifying(event, { buildQualifyingStandings, buil
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;
// The regex above 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 = match ? Number(match[2]) : 0;
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, []);
}
@@ -737,6 +747,36 @@ export function reseedUpcomingQualifying(event, { buildQualifyingStandings, buil
// 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"