diff --git a/CHANGELOG.md b/CHANGELOG.md index a75ad75..0df394a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.32.0 - 2026-10-07 + +### Changed + +- Penalties and lap changes now have one place each. + - **Races (qualifying, heats, finals):** penalties (±lap, ±seconds, reset) and lap changes are made only in the **Judging** view, where they are logged and undoable. In Timing, the driver details show the corrections and the lap list read-only, with an **Open in Judging** button that opens Judging with that driver selected. + - **Practice:** there are no penalties anywhere. Removing, restoring and approving laps stays one click away on Timing, and in Judging. +- The Guide texts were updated to match. + ## 1.31.1 - 2026-10-07 ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index 1b4154e..556e034 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -742,6 +742,24 @@ Verified 2026-10-07: - The browser test clicks the sidebar link from the Dashboard and checks that System & backup is active and the Updates section is visible. - 253 unit and 58 browser scenarios pass. +## Penalties and lap changes: Judging only, races only + +Decided by the user 2026-10-07: in qualifying, heats and finals, the judge should be the one who modifies laps, not anyone at the Timing page. Penalties make no sense outside races. + +Before this, Timing's driver details had the full set of penalty and last-lap buttons for every session type, and from 1.30.0 also Remove / Restore per lap. + +- [x] Timing → driver details: + - Race sessions show the corrections and the lap list read-only, plus "Open in Judging" (`openJudging` selects the driver and navigates). + - Practice sessions show no corrections block, and keep Remove / Restore / Approve per lap. + - The old click handlers were removed. +- [x] Judging: penalty buttons (±lap, ±seconds, reset) only for race sessions. Last-lap invalidate / restore and the per-lap list work for every session. +- [x] Guide texts `validation_5`-`7` (sv/en) updated. + +Verified 2026-10-07: +- 253 unit tests pass. +- `tests/browser_corrections_scope.js` checks a running heat: Timing has no penalty or lap-removal buttons and the lap list has no actions, "Open in Judging" lands on Judging with the penalty buttons and the driver. It then checks a running Open Practice: Timing has Remove per lap and no corrections block, and Judging has invalidate but no penalty or reset buttons. +- 60 browser scenarios pass. + ## Documentation maintenance - [x] Update the architecture's module/dependency inventory as modules change. diff --git a/package-lock.json b/package-lock.json index 1602499..13eca9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.31.1", + "version": "1.32.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.31.1", + "version": "1.32.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index f928147..ef6d013 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.31.1", + "version": "1.32.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 7b630dd..9f71b25 100644 --- a/src/app.js +++ b/src/app.js @@ -465,7 +465,7 @@ const refreshTimingDynamics = () => { node.textContent = clock; }); }; -const renderTiming = () => renderTimingView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatRaceClock, renderTable, uid, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings, getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid, renderSessionChannelAnalysis, connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, startOperationalSession, finishOperationalSession, cancelRaceStart, triggerRaceAudio, getStartLightState: () => raceAudioEngine.getStartLightState(), saveState, updateHeaderState, renderView, normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey: () => selectedLeaderboardKey, setSelectedLeaderboardKey: (value) => { selectedLeaderboardKey = value; }, getQuickAddDraft: () => quickAddDraft, setQuickAddDraft: (value) => { quickAddDraft = value; }, getPassingValidationLabel, isCountedPassing, getCompetitorPassings, getManualCorrectionSummary, formatTeamActiveMemberLabel, lastFinishAnnouncementSessionId: () => lastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId: (value) => { lastFinishAnnouncementSessionId = value; }, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, overlayEvents: () => overlayEvents, setOverlayEvents: (value) => { overlayEvents = value; }, sessionReplay, syncTrainingLedger, setCompetitorLapStatus }); +const renderTiming = () => renderTimingView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatRaceClock, renderTable, uid, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings, getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid, renderSessionChannelAnalysis, connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, startOperationalSession, finishOperationalSession, cancelRaceStart, triggerRaceAudio, getStartLightState: () => raceAudioEngine.getStartLightState(), saveState, updateHeaderState, renderView, normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey: () => selectedLeaderboardKey, setSelectedLeaderboardKey: (value) => { selectedLeaderboardKey = value; }, getQuickAddDraft: () => quickAddDraft, setQuickAddDraft: (value) => { quickAddDraft = value; }, getPassingValidationLabel, isCountedPassing, getCompetitorPassings, getManualCorrectionSummary, formatTeamActiveMemberLabel, lastFinishAnnouncementSessionId: () => lastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId: (value) => { lastFinishAnnouncementSessionId = value; }, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, overlayEvents: () => overlayEvents, setOverlayEvents: (value) => { overlayEvents = value; }, sessionReplay, syncTrainingLedger, setCompetitorLapStatus, openJudging: (rowKey) => { selectedJudgeKey = rowKey; selectedLeaderboardKey = null; navigateToView("judging"); } }); const renderJudging = () => renderJudgingView({ state, dom, t, escapeHtml, formatLap, renderTable, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTypeLabel, getJudgeFilteredRows, getJudgeFilteredLog, getCompetitorPassings, isCountedPassing, getPassingValidationLabel, undoJudgingAdjustment, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, renderView, getSelectedJudgeKey: () => selectedJudgeKey, setSelectedJudgeKey: (value) => { selectedJudgeKey = value; }, getJudgingCompetitorFilter: () => judgingCompetitorFilter, setJudgingCompetitorFilter: (value) => { judgingCompetitorFilter = value; }, getJudgingLogFilter: () => judgingLogFilter, setJudgingLogFilter: (value) => { judgingLogFilter = value; } , setCompetitorLapStatus }); const normalizeTrainingState = (training) => normalizeTrainingStateLogic(training, { uid }); const syncTrainingLedger = () => { @@ -1563,6 +1563,8 @@ const TRANSLATIONS = { "timing.stop": "Stoppa", "timing.reset": "Nollställ data", "timing.lap_remove": "Ta bort", + "timing.corrections_in_judging": "Straff och varvändringar görs av domaren i Domarvyn (loggas och kan ångras).", + "timing.open_in_judging": "Öppna i Domarvyn", "timing.lap_restore": "Återställ", "timing.lap_removed": "borttaget", "suspect.title": "Misstänkt korta varv ({count})", @@ -2035,9 +2037,9 @@ const TRANSLATIONS = { "guide.validation_2": "Ogiltiga kortvarv under min-gränsen räknas inte alls i leaderboard eller statistik.", "guide.validation_3": "Ogiltiga långvarv över max-gränsen räknas inte som varv, men de kan bryta lap-basen så nästa giltiga varv börjar om korrekt.", "guide.validation_4": "När ordinarie tid är slut kan sessionen gå in i Follow-up aktiv om du har satt Follow-up tid i raceformat eller sessionen.", - "guide.validation_5": "I Tidtagning -> Detaljer kan du ge +1/-1 varv och +1/+5/-1 sekunder som manuell korrigering. Det slår igenom direkt i leaderboarden.", - "guide.validation_6": "I samma detaljvy kan du också manuellt ogiltigförklara senaste räknade varvet om du behöver ta bort en felträff i efterhand.", - "guide.validation_7": "Menyn Domare samlar samma korrigeringar i en separat arbetsvy med leaderboard, lap history och domarlogg för pågående session.", + "guide.validation_5": "Straff (+1/-1 varv, +1/+5/-1 sekunder) finns bara i race (kval, heat, final) och görs av domaren i Domarvyn. Det slår igenom direkt i leaderboarden. Tidtagning -> Detaljer visar korrigeringarna och har knappen Öppna i Domarvyn.", + "guide.validation_6": "Under träning tar du bort, återställer eller godkänner valfritt varv direkt i Tidtagning -> Detaljer. Misstänkt korta varv (genvägar) flaggas med ⚠ och listas med Godkänn / Ta bort på Tidtagning och Race Day.", + "guide.validation_7": "Domarvyn har leaderboard, varvhistorik med Ta bort / Återställ per varv, straff (endast race) och domarlogg med ångra för pågående session.", "guide.validation_8": "Domarvyn kan filtrera på ogiltiga rader, korrigerade rader eller teamrace, exportera domarloggen och ångra flera senaste manuella åtgärder via undo-knappar.", "guide.qualifying_title": "Seedning, poängtabeller och tie-break", "guide.qualifying_1": "Practice och kval kan nu använda tre seedmetoder: bästa N varv som summa, bästa N varv som snitt eller bästa N konsekutiva varv.", @@ -3225,6 +3227,8 @@ const TRANSLATIONS = { "timing.stop": "Stop", "timing.reset": "Reset Data", "timing.lap_remove": "Remove", + "timing.corrections_in_judging": "Penalties and lap changes are made by the judge in the Judging view (logged and undoable).", + "timing.open_in_judging": "Open in Judging", "timing.lap_restore": "Restore", "timing.lap_removed": "removed", "suspect.title": "Suspiciously short laps ({count})", @@ -3697,9 +3701,9 @@ const TRANSLATIONS = { "guide.validation_2": "Invalid short laps under the minimum threshold do not count in the leaderboard or statistics.", "guide.validation_3": "Invalid long laps over the maximum threshold do not count as laps, but they can reset the lap base so the next valid lap starts correctly.", "guide.validation_4": "When the scheduled time ends, the session can enter Follow-up active if Follow-up time has been configured in race format or on the session.", - "guide.validation_5": "In Timing -> Details you can apply +1/-1 lap and +1/+5/-1 seconds as manual corrections. The leaderboard updates immediately.", - "guide.validation_6": "In the same detail view you can also manually invalidate the latest counted lap if you need to remove a false hit afterwards.", - "guide.validation_7": "The Judging menu collects the same corrections in a separate work view with leaderboard, lap history and a judging log for the current session.", + "guide.validation_5": "Penalties (+1/-1 lap, +1/+5/-1 seconds) exist only in races (qualifying, heats, finals) and are given by the judge in the Judging view. The leaderboard updates immediately. Timing -> Details shows the corrections and has an Open in Judging button.", + "guide.validation_6": "In practice, remove, restore or approve any lap right in Timing -> Details. Suspiciously short laps (shortcuts) are flagged with ⚠ and listed with Approve / Remove on Timing and Race Day.", + "guide.validation_7": "The Judging view has the leaderboard, lap history with Remove / Restore per lap, penalties (races only) and the judging log with undo for the current session.", "guide.validation_8": "The Judging view can filter invalid rows, corrected rows or team race rows, export the judging log and undo multiple recent manual actions via undo buttons.", "guide.qualifying_title": "Seeding, points tables and tie-break", "guide.qualifying_1": "Practice and qualifying can now use three seed methods: best N laps as total, best N laps as average or best N consecutive laps.", diff --git a/src/timing_views.js b/src/timing_views.js index f29764b..794a8aa 100644 --- a/src/timing_views.js +++ b/src/timing_views.js @@ -227,6 +227,10 @@ function renderQuickAddPanel(session, deps) { `; } +// Penalties belong to races only, and in races every correction is made by the judge in +// the Judging view (logged, undoable). Practice keeps quick lap clean-up on Timing. +const isPracticeType = (type) => ["open_practice", "free_practice", "practice"].includes(String(type || "")); + function renderLapActions(passing, rowKey, { t, escapeHtml }) { const button = (action, label, extra = "") => ``; if (passing.validLap === false) return passing.invalidReason === "manual_invalid" ? button("restore", "timing.lap_restore") : ""; @@ -276,21 +280,14 @@ function renderLeaderboardModal(session, row, deps) {
${t("table.pit")}: ${row.pitActive ? t("timing.pit_active") : `${Number(row.pitStops || 0)} / ${formatLap(row.totalPitMs)}`}
${t("table.own_delta")}: ${escapeHtml(row.lapDelta || "-")}
+ ${isPracticeType(session.type) ? "" : `${t("timing.lap_adjustment")}: ${Number(row.manualLapAdjustment || 0) || 0}
${t("timing.time_penalty")}: ${(Number(row.manualTimeAdjustmentMs || 0) || 0) > 0 ? "+" : (Number(row.manualTimeAdjustmentMs || 0) || 0) < 0 ? "-" : ""}${formatLap(Math.abs(Number(row.manualTimeAdjustmentMs || 0) || 0))}
-${t("timing.corrections_in_judging")}
+ + `}${t("table.best_lap")}: ${formatLap(selectedRow.bestLapMs)}
${t("table.last_lap")}: ${formatLap(selectedRow.lastLapMs)}