diff --git a/CHANGELOG.md b/CHANGELOG.md index 592d73b..4a18b82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.11.0 - 2026-09-10 + +### Added + +- A driver left alone in the last qualifying heat (e.g. 7 drivers at 3 per heat) can now be moved into another heat directly - edit a heat and tick the driver on, and they're automatically moved out of whichever heat they were on before, with a warning shown before you save. + ## 1.10.3 - 2026-09-10 ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index 20bcad3..09bb4c1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -249,6 +249,22 @@ Not built in this pass, out of scope for a bug-fixing sweep: there is no disqual Verified 2026-09-10: new unit tests cover every fix above - `tests/race_timing_logic.test.js` (numeric heat sort, field-size mismatch reporting, DNS backfill in both qualifying and final standings without ever inventing a fake 0ms "fastest lap", the reserve/non-reserve bump-up paths, the 26-main cap, pause exclusion in `buildLeaderboard`/`getCompetitorElapsedMs`, the frozen finished-session clock), a new `tests/session_control.test.js` (previously zero coverage for this file - pause/resume accumulation, follow-up pause, and the finish-while-paused fold-in), and `tests/judging_logic.test.js` (undoing two penalties in both orders, confirming the net always reaches zero). A full headless-browser run against a real, isolated server confirmed both the judging undo fix (two +5s penalties applied via the real Judging UI, undone out of order through the real undo buttons, correctly netting to +5000ms after the first undo and exactly 0 after both) and the bump-up fix (a real Event Manager "Apply bumps" click, with `reserveBumpSlots` off, actually grew the upper final's grid instead of silently doing nothing) end to end. +Follow-up questions from the user, checked against the actual code and confirmed already correct (no changes needed): +- **Practice "best 3 laps" / "best result within 5 minutes"** (`getBestConsecutiveTrainingLaps`, `getBestTrainingDurationResult` in `src/training_logic.js`) is computed per driver, independently, from that driver's own lap timestamps after the fact - there's no shared clock, so multiple drivers practicing at once each get their own best-window result correctly, exactly as described. +- **Heats are seeded by speed from practice results** (`buildPracticeStandings` sorts fastest-first, `generateQualifyingForRace`/`reseedUpcomingQualifying` chunk that sorted list in order) - Heat 1 is always the fastest group, Heat 2 the next, and so on. Proven with a concrete existing test (7 drivers, 3 per heat -> heat 1 gets the 3 fastest, heat 3 gets the single slowest, alone). +- **`carsPerHeat`/`carsPerFinal` are freely adjustable** (plain number fields, no hidden constraints) - e.g. 12 drivers at `carsPerHeat: 4` cleanly makes 3 heats of 4, and setting `carsPerFinal` to the full field size (or to a fraction of it) is the existing, direct way to choose "just one A final" vs "A + B", since the number of finals is derived from field size ÷ `carsPerFinal` rather than being a separate setting. +- A genuinely per-driver rolling "your own 5 minutes starts at your first crossing" qualifying *heat* clock (as opposed to practice, where this already works) is not fully supported - the "staggered" start mode gives each driver their own reference point for lap-time math, but the heat's own auto-finish timer is still one shared clock from when the admin pressed Start. Flagged to the user as a separate, deliberately unbuilt gap, not fixed here. + +## Moving a driver between qualifying heats + +Requested 2026-09-10 by the user, after confirming heats seed by speed correctly: with an odd field size (e.g. 7 drivers at 3 per heat), the last heat can end up with a single lone driver - and there was no way to manually move them into another heat. Investigated first: neither "Reseed" (redistributes every not-yet-run heat by current standings, not a targeted single-driver 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 in the UI. + +- [x] `moveDriversIntoQualifyingHeat` (`src/event_race_logic.js`, new) - the same "checking them elsewhere is the move" pattern already used for team rosters. Editing a qualification session's driver roster (a new checklist added to the existing session edit form, shown only for `type: "qualification"`) now pulls a checked driver out of whichever *other* heat they're currently on. Scoped deliberately to the same round only ("Q2 H2" never touches "Q1"/"Q3"), since heat composition can legitimately differ round to round after a reseed - an admin fixing one round's lone-driver heat doesn't get other rounds silently reshuffled. +- [x] The checklist shows "Redan i {heat} - kryssar du i flyttas den hit" next to a driver already gridded elsewhere in the same round, so the move is visible before saving, not just after (mirrors the team-roster warning). +- [x] Extracted `parseQualifyingHeatName` (parsing a generated session's "Q<round> H<heat>" name) as a shared helper, now used by both the reseed function and this new one, rather than duplicating the regex a second time. + +Verified 2026-09-10: new unit tests (`tests/race_timing_logic.test.js`) cover `parseQualifyingHeatName` and `moveDriversIntoQualifyingHeat` (moves a lone driver out of their old heat, leaves a different round's same-numbered heat untouched, and is a no-op for a session with no parseable heat name). A full headless-browser run against a real, isolated server reproduced the exact reported scenario end to end: generated real qualifying heats for 7 drivers at 3 per heat through the actual "Generate qualifying" button, confirmed heat 3 had exactly one lone driver, opened heat 2's edit form and confirmed it named "Q1 H3" as a warning before saving, ticked the driver in and saved, and confirmed heat 2 gained them while heat 3 became empty - no duplicate entry, nobody left alone. + ## Documentation maintenance - [ ] Update the architecture's module/dependency inventory as modules change. diff --git a/package-lock.json b/package-lock.json index 5fab864..40dcae0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.10.3", + "version": "1.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.10.3", + "version": "1.11.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index ec5a0cb..46d75c2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.10.3", + "version": "1.11.0", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/src/app.js b/src/app.js index bbb4508..40fa5e2 100644 --- a/src/app.js +++ b/src/app.js @@ -11,7 +11,7 @@ import { RACE_AUDIO_EVENT_DEFINITIONS, createRaceAudioEngine, normalizeRaceAudio import { renderRaceFormatField, renderRaceFormatContextCard, getRaceSummaryItems, getRaceSummaryWarnings, renderManageStatusBadge, renderRaceWizardSteps, renderRaceWizardContent, renderRaceStandingsTable } from "./race_setup_ui.js"; -import { normalizeRaceTeam as normalizeRaceTeamLogic, normalizeStoredRacePreset as normalizeStoredRacePresetLogic, getRaceFormatPresets as getRaceFormatPresetsLogic, applyRaceFormatPreset as applyRaceFormatPresetLogic, buildRaceFormatConfigFromForm as buildRaceFormatConfigFromFormLogic, normalizeEvent as normalizeEventLogic, normalizeBrandingConfig as normalizeBrandingConfigLogic, resolveEventBranding as resolveEventBrandingLogic, getRaceManageStatuses as getRaceManageStatusesLogic, buildDefaultRaceWizardDraft as buildDefaultRaceWizardDraftLogic, getRaceWizardPreset as getRaceWizardPresetLogic, applyRaceWizardPresetDefaults as applyRaceWizardPresetDefaultsLogic, ensureRaceParticipantsConfigured as ensureRaceParticipantsConfiguredLogic, buildRaceSession as buildRaceSessionLogic, buildOpenPracticeBundle as buildOpenPracticeBundleLogic, buildTrackSession as buildTrackSessionLogic, createSponsorRounds as createSponsorRoundsLogic, buildRaceSessionsFromWizard as buildRaceSessionsFromWizardLogic, getRaceWizardSessionPlan as getRaceWizardSessionPlanLogic, getEventDrivers as getEventDriversLogic, getEventTeams as getEventTeamsLogic, getTeamDriverPool as getTeamDriverPoolLogic, reassignTeamMembers as reassignTeamMembersLogic, findEventTeamForPassing as findEventTeamForPassingLogic, generateQualifyingForRace as generateQualifyingForRaceLogic, reseedUpcomingQualifying as reseedUpcomingQualifyingLogic, generateFinalsForRace as generateFinalsForRaceLogic, applyBumpsForRace as applyBumpsForRaceLogic } from "./event_race_logic.js"; +import { normalizeRaceTeam as normalizeRaceTeamLogic, normalizeStoredRacePreset as normalizeStoredRacePresetLogic, getRaceFormatPresets as getRaceFormatPresetsLogic, applyRaceFormatPreset as applyRaceFormatPresetLogic, buildRaceFormatConfigFromForm as buildRaceFormatConfigFromFormLogic, normalizeEvent as normalizeEventLogic, normalizeBrandingConfig as normalizeBrandingConfigLogic, resolveEventBranding as resolveEventBrandingLogic, getRaceManageStatuses as getRaceManageStatusesLogic, buildDefaultRaceWizardDraft as buildDefaultRaceWizardDraftLogic, getRaceWizardPreset as getRaceWizardPresetLogic, applyRaceWizardPresetDefaults as applyRaceWizardPresetDefaultsLogic, ensureRaceParticipantsConfigured as ensureRaceParticipantsConfiguredLogic, buildRaceSession as buildRaceSessionLogic, buildOpenPracticeBundle as buildOpenPracticeBundleLogic, buildTrackSession as buildTrackSessionLogic, createSponsorRounds as createSponsorRoundsLogic, buildRaceSessionsFromWizard as buildRaceSessionsFromWizardLogic, getRaceWizardSessionPlan as getRaceWizardSessionPlanLogic, getEventDrivers as getEventDriversLogic, getEventTeams as getEventTeamsLogic, getTeamDriverPool as getTeamDriverPoolLogic, reassignTeamMembers as reassignTeamMembersLogic, findEventTeamForPassing as findEventTeamForPassingLogic, generateQualifyingForRace as generateQualifyingForRaceLogic, reseedUpcomingQualifying as reseedUpcomingQualifyingLogic, moveDriversIntoQualifyingHeat as moveDriversIntoQualifyingHeatLogic, parseQualifyingHeatName as parseQualifyingHeatNameLogic, generateFinalsForRace as generateFinalsForRaceLogic, applyBumpsForRace as applyBumpsForRaceLogic } from "./event_race_logic.js"; import { getSessionTypeLabel as getSessionTypeLabelLogic, getStatusLabel as getStatusLabelLogic, isUntimedSession as isUntimedSessionLogic, getActiveSession as getActiveSessionLogic, getSessionTargetMs as getSessionTargetMsLogic, getSessionLapWindow as getSessionLapWindowLogic, isCountedPassing as isCountedPassingLogic, getVisiblePassings as getVisiblePassingsLogic, getPassingValidationLabel as getPassingValidationLabelLogic, getSessionTiming as getSessionTimingLogic, ensureSessionResult as ensureSessionResultLogic, buildLeaderboard as buildLeaderboardLogic, formatLapDelta as formatLapDeltaLogic, formatLeaderboardGap as formatLeaderboardGapLogic, getCompetitorElapsedMs as getCompetitorElapsedMsLogic, getCompetitorPassings as getCompetitorPassingsLogic, getCompetitorSeedMetric as getCompetitorSeedMetricLogic, getSessionEntrants as getSessionEntrantsLogic, buildPracticeStandings as buildPracticeStandingsLogic, getQualifyingPointsValue as getQualifyingPointsValueLogic, isHighPointsTable as isHighPointsTableLogic, compareNumberSet as compareNumberSetLogic, buildQualifyingTieBreakNote as buildQualifyingTieBreakNoteLogic, hasQualifyingPrimaryTie as hasQualifyingPrimaryTieLogic, buildQualifyingStandings as buildQualifyingStandingsLogic, formatTeamActiveMemberLabel as formatTeamActiveMemberLabelLogic, buildTeamRaceStandings as buildTeamRaceStandingsLogic, buildSessionChannelStats as buildSessionChannelStatsLogic, buildTeamStintLog as buildTeamStintLogLogic, getSessionGridEntries as getSessionGridEntriesLogic, getSessionGridOrder as getSessionGridOrderLogic, ensureSessionDriverOrder as ensureSessionDriverOrderLogic, buildFinalStandings as buildFinalStandingsLogic, getTrackRecordMs as getTrackRecordMsLogic } from "./timing_logic.js"; import { normalizeTrackLayouts, getTrackLayoutIdAt, getTrackLayoutLabel, nextTrackLayoutLabel } from "./track_layouts_logic.js"; @@ -73,6 +73,8 @@ const getEventDrivers = (event) => getEventDriversLogic(event, { state }); const getEventTeams = (event) => getEventTeamsLogic(event, { normalizeRaceTeam }); const getTeamDriverPool = (event) => getTeamDriverPoolLogic(event, { getEventDrivers, state }); const reassignTeamMembers = (teams, options) => reassignTeamMembersLogic(teams, options); +const moveDriversIntoQualifyingHeat = (targetSessionId, driverIds) => moveDriversIntoQualifyingHeatLogic(state.sessions, targetSessionId, driverIds); +const parseQualifyingHeatName = (name) => parseQualifyingHeatNameLogic(name); const findEventTeamForPassing = (event, driverId, carId) => findEventTeamForPassingLogic(event, driverId, carId, { getEventTeams }); const generateQualifyingForRace = (event) => generateQualifyingForRaceLogic(event, { buildPracticeStandings, getEventDrivers, state, normalizeStartMode, normalizeSession, uid, chunkArray, clearGeneratedQualifying }); const reseedUpcomingQualifying = (event) => reseedUpcomingQualifyingLogic(event, { buildQualifyingStandings, buildPracticeStandings, getEventDrivers, getSessionsForEvent, chunkArray }); @@ -1169,6 +1171,9 @@ const TRANSLATIONS = { "events.team_form_cars": "Markera lagbilar innan du sparar laget.", "events.team_driver_fallback": "Inga förare matchade race-klassen eller deltagarlistan. Visar alla förare som fallback.", "events.team_member_elsewhere": "Redan i {team} - kryssar du i flyttas den hit", + "events.heat_drivers": "Förare i heatet", + "events.heat_drivers_hint": "Kryssa i/ur för att ändra vilka förare som kör det här heatet. En förare som redan är i ett annat heat i samma omgång flyttas hit automatiskt när du sparar.", + "events.heat_member_elsewhere": "Redan i {heat} - kryssar du i flyttas den hit", "events.no_teams": "Inga lag skapade ännu.", "events.team_standings": "Lagställning", "events.no_team_results": "Inga teamresultat ännu.", @@ -2677,6 +2682,9 @@ const TRANSLATIONS = { "events.team_form_cars": "Select team cars before saving the team.", "events.team_driver_fallback": "No drivers matched the race class or participant list. Showing all drivers as fallback.", "events.team_member_elsewhere": "Already on {team} - checking this moves them here", + "events.heat_drivers": "Heat drivers", + "events.heat_drivers_hint": "Tick/untick to change who races this heat. A driver already on another heat in the same round is moved here automatically when you save.", + "events.heat_member_elsewhere": "Already on {heat} - checking this moves them here", "events.no_teams": "No teams created yet.", "events.team_standings": "Team standings", "events.no_team_results": "No team results yet.", @@ -5022,6 +5030,8 @@ function renderEventManager(eventId) { getTeamDriverPool, getEventTeams, reassignTeamMembers, + moveDriversIntoQualifyingHeat, + parseQualifyingHeatName, normalizeBrandingConfig, getSessionTypeChoices, getRaceFormatPresets, diff --git a/src/event_manager_controller.js b/src/event_manager_controller.js index 23da0c1..4391d11 100644 --- a/src/event_manager_controller.js +++ b/src/event_manager_controller.js @@ -19,6 +19,8 @@ export function renderEventManagerView(context) { getTeamDriverPool, getEventTeams, reassignTeamMembers, + moveDriversIntoQualifyingHeat, + parseQualifyingHeatName, normalizeBrandingConfig, getSessionTypeChoices, getRaceFormatPresets, @@ -168,6 +170,7 @@ export function renderEventManagerView(context) { raceDrivers, teamDriverPool, showTeamsSection, + parseQualifyingHeatName, state, getDriverDisplayById, raceFormatAdvanced, @@ -411,6 +414,14 @@ export function renderEventManagerView(context) { : "best_sum"; editingSession.staggerGapSec = Math.max(0, Number(form.get("staggerGapSec") || 0) || 0); editingSession.maxCars = Number(form.get("maxCars") || 0) || null; + if (selectedType === "qualification") { + const heatDriverIds = Array.from(formNode.querySelectorAll('[name="heatDriverIds"]:checked')) + .map((node) => String(node.value)) + .filter(Boolean); + moveDriversIntoQualifyingHeat(editingSession.id, heatDriverIds); + editingSession.driverIds = heatDriverIds; + editingSession.manualGridIds = [...heatDriverIds]; + } setSelectedSessionEditId(null); saveState(); refreshManager(); diff --git a/src/event_manager_view.js b/src/event_manager_view.js index 14088ed..ad8700b 100644 --- a/src/event_manager_view.js +++ b/src/event_manager_view.js @@ -21,6 +21,7 @@ export function renderEventManagerMarkup(context) { raceDrivers, teamDriverPool, showTeamsSection, + parseQualifyingHeatName, state, getDriverDisplayById, raceFormatAdvanced, @@ -54,6 +55,21 @@ export function renderEventManagerMarkup(context) { raceTeams.find((team) => team.id !== excludeTeamId && team.driverIds.includes(driverId))?.name || ""; const carTeamElsewhere = (carId, excludeTeamId = "") => raceTeams.find((team) => team.id !== excludeTeamId && team.carIds.includes(carId))?.name || ""; + // Same idea as driverTeamElsewhere above, but for qualifying heats: checking a driver + // onto this heat moves them out of whichever other heat in the SAME round they're + // currently on (see moveDriversIntoQualifyingHeat) - name that heat so the admin sees the + // move coming before saving, not after. + const driverHeatElsewhere = (driverId, targetSession) => { + const targetParsed = parseQualifyingHeatName(targetSession?.name); + if (!targetParsed) return ""; + const other = sessions.find((session) => + session.id !== targetSession.id + && session.type === "qualification" + && (session.driverIds || []).includes(driverId) + && parseQualifyingHeatName(session.name)?.round === targetParsed.round + ); + return other?.name || ""; + }; const sessionChannelMarkup = sessions .map((session) => ({ session, markup: renderSessionChannelAnalysis(session) })) .filter((entry) => entry.markup) @@ -864,6 +880,29 @@ export function renderEventManagerMarkup(context) { ${t("events.session_max_cars_hint")} + ${ + event.mode === "race" && editingSession.type === "qualification" + ? ` +
${t("events.heat_drivers_hint")}
+