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

@@ -112,7 +112,7 @@ const buildTeamStintLog = (session, row) => buildTeamStintLogLogic(session, row,
const getSessionGridEntries = (session) => getSessionGridEntriesLogic(session, { state, t, getEventTeams, getDriverDisplayById, getSessionGridOrder });
const getSessionGridOrder = (session) => getSessionGridOrderLogic(session);
const ensureSessionDriverOrder = (session) => ensureSessionDriverOrderLogic(session, { getSessionEntrants });
const buildFinalStandings = (event) => buildFinalStandingsLogic(event, { getSessionsForEvent, buildLeaderboard });
const buildFinalStandings = (event) => buildFinalStandingsLogic(event, { getSessionsForEvent, buildLeaderboard, getSessionEntrants, t });
const getManualCorrectionSummary = (row) => getManualCorrectionSummaryLogic(row, { formatLap });
const renderTeamStintLog = (session, rows) => renderTeamStintLogHelper(session, rows, { t, escapeHtml, buildTeamStintLog, formatTeamActiveMemberLabel, renderTable, formatRaceClock });
@@ -923,6 +923,8 @@ const TRANSLATIONS = {
"events.reseed_done": "Kommande kval heat reseedade från aktuell ranking.",
"events.no_reseed_done": "Inga kommande kval kunde reseedas.",
"events.reseed_locked": "{count} heat hoppades över eftersom manuell grid är låst.",
"events.reseed_mismatched": "Antal deltagare matchar inte längre antal genererade heat i {count} omgång(ar) - generera om kvalet istället för att lita på en delvis reseed.",
"events.dns": "Ej start (DNS)",
"events.reserve_bump_slots": "Reservera bump-platser i finaler",
"events.bump_reserved_note": "Om bump används kan finalgeneratorn reservera platser i högre finaler redan från start.",
"events.actions": "Åtgärder",
@@ -2429,6 +2431,8 @@ const TRANSLATIONS = {
"events.reseed_done": "Upcoming qualifying heats reseeded from current standings.",
"events.no_reseed_done": "No upcoming qualifying heats could be reseeded.",
"events.reseed_locked": "{count} heats were skipped because manual grid is locked.",
"events.reseed_mismatched": "The number of participants no longer matches the generated heats in {count} round(s) - regenerate qualifying instead of relying on a partial reseed.",
"events.dns": "Did not start (DNS)",
"events.reserve_bump_slots": "Reserve bump slots in finals",
"events.bump_reserved_note": "If bump-up is used, finals can reserve slots in upper mains from the start.",
"events.actions": "Actions",

View File

@@ -801,6 +801,9 @@ export function renderEventManagerView(context) {
if (result.locked > 0) {
messages.push(t("events.reseed_locked", { count: result.locked }));
}
if (result.mismatched > 0) {
messages.push(t("events.reseed_mismatched", { count: result.mismatched }));
}
alert(messages.join("\n"));
});

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;

View File

@@ -30,17 +30,19 @@ export function applyCompetitorCorrection(session, row, options = {}, { ensureSe
action: t("timing.penalty_reset"),
detail: "-",
category: "correction",
// Undo reverses this action's own delta (-previous, since it zeroed out whatever was
// there) from whatever the CURRENT value is at undo time, rather than overwriting
// with an absolute snapshot - see the note on undoJudgingAdjustment below for why
// that matters whenever more than one correction is undone out of order.
undo: {
type: "restore_correction_state",
previousLapAdjustment,
previousTimeAdjustmentMs,
lapDelta: -previousLapAdjustment,
timeMsDelta: -previousTimeAdjustmentMs,
},
});
} else {
const lapDelta = Number(options.lapDelta || 0) || 0;
const timeMsDelta = Number(options.timeMsDelta || 0) || 0;
const previousLapAdjustment = Number(entry.manualLapAdjustment || 0) || 0;
const previousTimeAdjustmentMs = Number(entry.manualTimeAdjustmentMs || 0) || 0;
entry.manualLapAdjustment = (Number(entry.manualLapAdjustment || 0) || 0) + lapDelta;
entry.manualTimeAdjustmentMs = (Number(entry.manualTimeAdjustmentMs || 0) || 0) + timeMsDelta;
const detailBits = [];
@@ -60,8 +62,8 @@ export function applyCompetitorCorrection(session, row, options = {}, { ensureSe
category: "correction",
undo: {
type: "restore_correction_state",
previousLapAdjustment,
previousTimeAdjustmentMs,
lapDelta,
timeMsDelta,
},
});
}
@@ -167,8 +169,12 @@ export function undoJudgingAdjustment(session, adjustmentId, { ensureSessionResu
}
if (adjustment.undo.type === "restore_correction_state") {
entry.manualLapAdjustment = Number(adjustment.undo.previousLapAdjustment || 0) || 0;
entry.manualTimeAdjustmentMs = Number(adjustment.undo.previousTimeAdjustmentMs || 0) || 0;
// Reverses this adjustment's own delta from the current cumulative value, not an
// absolute snapshot from when it was first applied - two +5s penalties undone in
// either order must each remove exactly their own +5s, not the older one wiping out
// both and the newer one silently re-adding one back. See applyCompetitorCorrection.
entry.manualLapAdjustment = (Number(entry.manualLapAdjustment || 0) || 0) - (Number(adjustment.undo.lapDelta || 0) || 0);
entry.manualTimeAdjustmentMs = (Number(entry.manualTimeAdjustmentMs || 0) || 0) - (Number(adjustment.undo.timeMsDelta || 0) || 0);
} else if (adjustment.undo.type === "set_passing_validity") {
const passing = findPassingByUndoMarker(session, adjustment.competitorKey, adjustment.undo.passingTimestamp);
if (!passing) {

View File

@@ -12,6 +12,14 @@ export function beginSession(session, nowTs = Date.now()) {
}
export function finishSession(session, nowTs = Date.now()) {
// Finishing while paused must fold the still-open pause segment into totalPausedMs the
// same way resumeSession does - otherwise that final segment's duration is silently
// dropped, and the session's own displayed elapsed time (getSessionTiming) keeps
// growing after finish, since it would still be missing from totalPausedMs.
if (session?.status === "paused" && session.pausedAt) {
const pausedDuration = Math.max(0, nowTs - Number(session.pausedAt || nowTs));
session.totalPausedMs = Math.max(0, Number(session.totalPausedMs || 0)) + pausedDuration;
}
session.status = "finished";
session.endedAt = nowTs;
session.finishedByTimer = false;

View File

@@ -82,7 +82,12 @@ export function getSessionTiming(session, nowTs = Date.now(), { getSessionTarget
const targetMs = getSessionTargetMs(session);
const startedAt = Number(session?.startedAt || 0);
const pausedAt = Number(session?.pausedAt || 0) || 0;
const clockNow = pausedAt || nowTs;
const endedAt = Number(session?.endedAt || 0) || 0;
// A finished session's elapsed time is a fixed historical fact - without this, reopening
// or simply leaving a finished session's timing view open would show its "elapsed" time
// still counting up against the real clock, using how long ago it finished rather than
// how long the session itself actually ran.
const clockNow = session?.status === "finished" && endedAt ? endedAt : (pausedAt || nowTs);
const totalPausedMs = Math.max(0, Number(session?.totalPausedMs || 0) || 0);
const elapsedMs = startedAt ? Math.max(0, clockNow - startedAt - totalPausedMs) : 0;
const followUpMs = Math.max(0, Number(session?.followUpSec || 0) || 0) * 1000;
@@ -172,8 +177,15 @@ export function buildLeaderboard(session, { ensureSessionResult, getSessionTarge
const latestAnyPassing = allPassings.length ? allPassings[allPassings.length - 1] : null;
const latestPassing = passings.length ? passings[passings.length - 1] : null;
const lastPassingTs = latestPassing ? Number(latestPassing.timestamp || 0) : Number(row.lastTimestamp || 0) || 0;
// Excludes paused wall-clock time, the same way the session's own countdown already
// does (getSessionTiming) - otherwise a heat that had a track call isn't comparable to
// one that didn't, even though nobody actually lost race time to the stoppage. Correct
// as long as passings aren't recorded while paused (they aren't - the decoder ignores
// them), so a driver's last pass is always from before or after the full pause, never
// mid-pause.
const totalPausedMs = Math.max(0, Number(session?.totalPausedMs || 0) || 0);
const rawElapsedMs = lastPassingTs
? Math.max(0, lastPassingTs - Number(row.startTimestamp || session.startedAt || lastPassingTs))
? Math.max(0, lastPassingTs - Number(row.startTimestamp || session.startedAt || lastPassingTs) - totalPausedMs)
: getCompetitorElapsedMs(session, row);
const manualLapAdjustment = Number(row.manualLapAdjustment || 0) || 0;
const manualTimeAdjustmentMs = Number(row.manualTimeAdjustmentMs || 0) || 0;
@@ -350,7 +362,8 @@ export function getCompetitorElapsedMs(session, row) {
if (!startTs || !row?.lastTimestamp) {
return 0;
}
return Math.max(0, row.lastTimestamp - startTs);
const totalPausedMs = Math.max(0, Number(session?.totalPausedMs || 0) || 0);
return Math.max(0, row.lastTimestamp - startTs - totalPausedMs);
}
@@ -537,8 +550,26 @@ export function buildQualifyingStandings(event, { getSessionsForEvent, buildLead
sessions.forEach((session) => {
const rows = buildLeaderboard(session);
const entrantCount = Math.max(rows.length, getSessionEntrants(session).length || 0);
rows.forEach((row, index) => {
const entrants = getSessionEntrants(session);
const entrantCount = Math.max(rows.length, entrants.length || 0);
// A driver assigned to this heat who never recorded a single valid passing (dead
// battery, transponder issue, no-show) has no entry in buildLeaderboard's rows at all -
// without this they'd silently vanish not just from this round's standings, but from
// every future round they'd otherwise be reseeded into, and from the final grid
// entirely. Only backfilled once the heat has actually finished - a still-running or
// not-yet-started heat's assigned drivers simply haven't raced yet, which is different.
const rowsWithDns = session.status === "finished"
? [
...rows,
...entrants
.filter((driver) => !rows.some((row) => (row.driverId || row.key) === driver.id))
// bestLapMs is deliberately omitted (not 0) - Number.isFinite(0) is true, and a
// "0ms" lap would otherwise count as the fastest single lap of the round for
// the best_lap tie-break, ahead of every driver who actually raced.
.map((driver) => ({ driverId: driver.id, driverName: driver.name, comparisonMs: Number.MAX_SAFE_INTEGER, resultDisplay: t("events.dns") })),
]
: rows;
rowsWithDns.forEach((row, index) => {
const key = row.driverId || row.key;
if (!competitorMap.has(key)) {
competitorMap.set(key, {
@@ -867,20 +898,30 @@ export function ensureSessionDriverOrder(session, { getSessionEntrants }) {
export function buildFinalStandings(event, { getSessionsForEvent, buildLeaderboard }) {
export function buildFinalStandings(event, { getSessionsForEvent, buildLeaderboard, getSessionEntrants, t }) {
const sessions = getSessionsForEvent(event.id).filter((session) => session.type === "final");
const groupedByMain = new Map();
const countedFinalLegs = Math.max(1, Number(event.raceConfig?.countedFinalLegs || 1) || 1);
sessions.forEach((session) => {
const rows = buildLeaderboard(session);
// See the identical note in buildQualifyingStandings above: a driver assigned to this
// final who never recorded a valid passing must not vanish from the results entirely.
const rowsWithDns = session.status === "finished"
? [
...rows,
...getSessionEntrants(session)
.filter((driver) => !rows.some((row) => (row.driverId || row.key) === driver.id))
.map((driver) => ({ driverId: driver.id, driverName: driver.name, totalElapsedMs: Number.MAX_SAFE_INTEGER, resultDisplay: t("events.dns") })),
]
: rows;
const mainMatch = String(session.name || "").match(/^([A-Z])/i);
const mainKey = mainMatch ? mainMatch[1].toUpperCase() : "A";
if (!groupedByMain.has(mainKey)) {
groupedByMain.set(mainKey, new Map());
}
const competitorMap = groupedByMain.get(mainKey);
rows.forEach((row, index) => {
rowsWithDns.forEach((row, index) => {
const key = row.driverId || row.key;
if (!competitorMap.has(key)) {
competitorMap.set(key, {