Fix qualifying/finals/bump-up/judging/pause-timing bugs

A thorough audit of practice/qualifying/heats/finals, bump-up,
judging and pause/timer code (requested by the user) found several
real, concrete bugs, fixed together:

- Qualifying reseed sorted heats as text ("H10" before "H2"),
  scrambling grids at 10+ heats; now sorts numerically and reports a
  clear warning instead of silently dropping/ghosting heats when the
  participant count changes.
- A driver with zero laps in a finished heat/final (DNS) vanished
  entirely - not just from that round, but silently from every future
  reseeded round and the final grid. Now backfilled as a real,
  last-place "DNS" row.
- Bump-up did nothing at all with "reserve bump slots" off (mains
  were already seeded full, so the old capacity check always
  blocked it), and a promoted driver stayed double-entered in their
  old main's upcoming leg. An oversized bump count could also blow
  the main-letter naming past "Z". All three fixed.
- Judging undo restored an absolute snapshot instead of reversing its
  own delta - undoing two penalties out of order could leave a live
  penalty behind two "undone" log entries. Now always nets correctly
  regardless of undo order.
- A competitor's elapsed time didn't exclude paused time (only the
  session clock did), making a paused heat's results incomparable to
  an unpaused one in qualifying ranking. Finishing while paused also
  dropped the final pause segment, so the displayed elapsed time kept
  growing after finish. Both fixed.

Verified with new unit tests for every fix (including a new
session_control.test.js - that file had zero prior coverage) and a
real headless-browser run confirming the judging-undo and bump-up
fixes 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 17:41:10 +02:00
parent 15b574a37d
commit f0838bc5db
14 changed files with 464 additions and 36 deletions

View File

@@ -686,7 +686,7 @@ export function reseedUpcomingQualifying(event, { buildQualifyingStandings, buil
}));
if (!sourceRows.length) {
return 0;
return { updated: 0, locked: 0, mismatched: 0 };
}
const carsPerHeat = Math.max(2, Number(event.raceConfig?.carsPerHeat || 8) || 8);
@@ -697,17 +697,30 @@ export function reseedUpcomingQualifying(event, { buildQualifyingStandings, buil
.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;
if (!sessionsByRound.has(round)) {
sessionsByRound.set(round, []);
}
sessionsByRound.get(round).push(session);
sessionsByRound.get(round).push({ session, heatNumber });
});
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) => {
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;
@@ -719,7 +732,7 @@ export function reseedUpcomingQualifying(event, { buildQualifyingStandings, buil
});
});
return { updated, locked };
return { updated, locked, mismatched };
}
@@ -741,9 +754,17 @@ export function generateFinalsForRace(event, { buildPracticeStandings, buildQual
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);
// 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);
const seededSlotsPerMain = reserveBumpSlots ? Math.max(1, carsPerFinal - bumpCount) : carsPerFinal;
// 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;
@@ -807,6 +828,13 @@ export function applyBumpsForRace(event, { buildFinalStandings, getSessionsForEv
});
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) {
@@ -836,7 +864,10 @@ export function applyBumpsForRace(event, { buildFinalStandings, getSessionsForEv
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;
// 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) {
@@ -850,6 +881,22 @@ export function applyBumpsForRace(event, { buildFinalStandings, getSessionsForEv
});
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;