From 70d72c030840f15cc66ab045847991348f3fdc14 Mon Sep 17 00:00:00 2001 From: larssand Date: Sun, 15 Mar 2026 22:25:19 +0100 Subject: [PATCH] Add judging filters, log export and undo stack --- README.md | 7 ++ src/app.js | 231 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 229 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index a225ad8..16f1e24 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,13 @@ Praktiskt exempel: - ny meny `Domare` - visar aktiv session, leaderboard, lap history och domarlogg i samma vy - samma korrigeringar som i `Tidtagning -> Detaljer` finns där som snabbknappar +- filter för: + - `Alla` + - `Ogiltiga` + - `Korrigerade` + - `Team race` +- domarloggen kan exporteras per session som JSON +- både `Ångra senaste` och undo-knapp per loggrad finns för flera manuella åtgärder ## Windows installation Kör i PowerShell i projektmappen. diff --git a/src/app.js b/src/app.js index a866627..029f67d 100644 --- a/src/app.js +++ b/src/app.js @@ -368,6 +368,20 @@ const TRANSLATIONS = { "judging.no_action_log": "Inga manuella åtgärder registrerade ännu.", "judging.selected_none": "Ingen rad vald.", "judging.restore_done": "Senaste manuellt ogiltiga varv återställdes.", + "judging.filter_competitors": "Filter rader", + "judging.filter_log": "Filter logg", + "judging.filter_all": "Alla", + "judging.filter_invalid": "Ogiltiga", + "judging.filter_corrected": "Korrigerade", + "judging.filter_team": "Team race", + "judging.filter_log_corrections": "Korrigeringar", + "judging.filter_log_invalidations": "Invalidate/restore", + "judging.filter_log_undo": "Undo", + "judging.export_log": "Exportera domarlogg", + "judging.undo_last": "Ångra senaste", + "judging.undo_action": "Ångra", + "judging.undo_done": "Senaste manuella åtgärden ångrades.", + "judging.no_undo": "Ingen åtgärd att ångra.", "timing.total_time": "Total tid", "timing.clear_confirm": "Rensa all tiddata för denna session?", "timing.prompt_transponder": "Transponder", @@ -577,6 +591,7 @@ const TRANSLATIONS = { "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_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.", "guide.qualifying_2": "Raceformat styr både Kval seedvarv och Kval seedmetod när nya kvalheat skapas från practice eller deltagarlistan.", @@ -990,6 +1005,20 @@ const TRANSLATIONS = { "judging.no_action_log": "No manual actions registered yet.", "judging.selected_none": "No row selected.", "judging.restore_done": "The latest manually invalidated lap was restored.", + "judging.filter_competitors": "Filter rows", + "judging.filter_log": "Filter log", + "judging.filter_all": "All", + "judging.filter_invalid": "Invalid", + "judging.filter_corrected": "Corrected", + "judging.filter_team": "Team race", + "judging.filter_log_corrections": "Corrections", + "judging.filter_log_invalidations": "Invalidate/restore", + "judging.filter_log_undo": "Undo", + "judging.export_log": "Export judging log", + "judging.undo_last": "Undo latest", + "judging.undo_action": "Undo", + "judging.undo_done": "The latest manual action was undone.", + "judging.no_undo": "No action to undo.", "timing.total_time": "Total time", "timing.clear_confirm": "Clear all timing data for this session?", "timing.prompt_transponder": "Transponder", @@ -1199,6 +1228,7 @@ const TRANSLATIONS = { "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_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.", "guide.qualifying_2": "Race format controls both Qualifying seed laps and Qualifying seed method when new qualifying heats are generated from practice or the participant list.", @@ -1284,6 +1314,8 @@ let selectedEventEditId = null; let selectedSessionEditId = null; let selectedTeamEditId = null; let selectedJudgeKey = null; +let judgingCompetitorFilter = "all"; +let judgingLogFilter = "all"; let quickAddDraft = null; let overlaySyncTimer = null; let overlayRotationTimer = null; @@ -4871,15 +4903,17 @@ function renderJudging() { const result = ensureSessionResult(active.id); const leaderboard = buildLeaderboard(active); - if (selectedJudgeKey && !leaderboard.some((row) => row.key === selectedJudgeKey)) { + const filteredRows = getJudgeFilteredRows(leaderboard, judgingCompetitorFilter); + if (selectedJudgeKey && !filteredRows.some((row) => row.key === selectedJudgeKey)) { selectedJudgeKey = null; } - if (!selectedJudgeKey && leaderboard.length) { - selectedJudgeKey = leaderboard[0].key; + if (!selectedJudgeKey && filteredRows.length) { + selectedJudgeKey = filteredRows[0].key; } const selectedRow = leaderboard.find((row) => row.key === selectedJudgeKey) || null; const selectedPassings = selectedRow ? getCompetitorPassings(active, selectedRow, { includeInvalid: true }) : []; - const actionLog = Array.isArray(result.adjustments) ? result.adjustments.slice(-25).reverse() : []; + const actionLog = getJudgeFilteredLog(Array.isArray(result.adjustments) ? result.adjustments.slice(-50).reverse() : [], judgingLogFilter); + const latestUndoable = (result.adjustments || []).slice().reverse().find((entry) => !entry.undoneAt && entry.undo); dom.view.innerHTML = `
@@ -4894,11 +4928,19 @@ function renderJudging() {
-

${t("judging.select_competitor")}

+
+

${t("judging.select_competitor")}

+ +
${renderTable( [t("table.pos"), t("table.driver"), t("table.result"), t("table.best_lap"), ""], - leaderboard.map( + filteredRows.map( (row, index) => ` ${index + 1} @@ -4963,19 +5005,32 @@ function renderJudging() {
-

${t("judging.action_log")}

+
+

${t("judging.action_log")}

+
+ + + +
+
${ actionLog.length ? renderTable( - [t("table.time"), t("table.driver"), t("events.actions"), t("table.status")], + [t("table.time"), t("table.driver"), t("events.actions"), t("table.status"), ""], actionLog.map( (entry) => ` ${new Date(entry.ts).toLocaleTimeString()} ${escapeHtml(entry.displayName || "-")} ${escapeHtml(entry.action || "-")} - ${escapeHtml(entry.detail || "-")} + ${escapeHtml(entry.detail || "-")}${entry.undoneAt ? ` • ${escapeHtml(t("judging.filter_log_undo"))}` : ""} + ${entry.undo && !entry.undoneAt ? `` : ""} ` ) @@ -4993,6 +5048,61 @@ function renderJudging() { }); }); + document.getElementById("judgingCompetitorFilter")?.addEventListener("change", (event) => { + const input = event.currentTarget; + if (!(input instanceof HTMLSelectElement)) { + return; + } + judgingCompetitorFilter = input.value; + renderJudging(); + }); + + document.getElementById("judgingLogFilter")?.addEventListener("change", (event) => { + const input = event.currentTarget; + if (!(input instanceof HTMLSelectElement)) { + return; + } + judgingLogFilter = input.value; + renderJudging(); + }); + + document.getElementById("judgingExportLog")?.addEventListener("click", () => { + const rows = (result.adjustments || []).map((entry) => ({ + time: new Date(entry.ts).toISOString(), + competitor: entry.displayName || "-", + action: entry.action || "-", + detail: entry.detail || "-", + category: entry.category || "-", + undoneAt: entry.undoneAt ? new Date(entry.undoneAt).toISOString() : "", + })); + const blob = new Blob([JSON.stringify({ session: active.name, items: rows }, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${active.name.replaceAll(/\s+/g, "_")}_judging_log.json`; + link.click(); + URL.revokeObjectURL(url); + }); + + document.getElementById("judgingUndoLast")?.addEventListener("click", () => { + if (!latestUndoable) { + alert(t("judging.no_undo")); + return; + } + undoJudgingAdjustment(active, latestUndoable.id); + renderJudging(); + }); + + actionLog.forEach((entry) => { + if (!entry.undo || entry.undoneAt) { + return; + } + document.getElementById(`judge-undo-${entry.id}`)?.addEventListener("click", () => { + undoJudgingAdjustment(active, entry.id); + renderJudging(); + }); + }); + if (!selectedRow) { return; } @@ -5186,6 +5296,7 @@ function renderGuide() {
  • ${t("guide.validation_5")}
  • ${t("guide.validation_6")}
  • ${t("guide.validation_7")}
  • +
  • ${t("guide.validation_8")}
  • @@ -6260,6 +6371,8 @@ function applyCompetitorCorrection(session, row, options = {}) { return; } if (options.reset) { + const previousLapAdjustment = Number(entry.manualLapAdjustment || 0) || 0; + const previousTimeAdjustmentMs = Number(entry.manualTimeAdjustmentMs || 0) || 0; entry.manualLapAdjustment = 0; entry.manualTimeAdjustmentMs = 0; result.adjustments.push({ @@ -6269,10 +6382,18 @@ function applyCompetitorCorrection(session, row, options = {}) { displayName: row.displayName || row.driverName || t("common.unknown_driver"), action: t("timing.penalty_reset"), detail: "-", + category: "correction", + undo: { + type: "restore_correction_state", + previousLapAdjustment, + 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 = []; @@ -6289,6 +6410,12 @@ function applyCompetitorCorrection(session, row, options = {}) { displayName: row.displayName || row.driverName || t("common.unknown_driver"), action: t("timing.manual_corrections"), detail: detailBits.join(" • ") || "-", + category: "correction", + undo: { + type: "restore_correction_state", + previousLapAdjustment, + previousTimeAdjustmentMs, + }, }); } saveState(); @@ -6334,6 +6461,13 @@ function invalidateCompetitorLastLap(session, row) { displayName: row.displayName || row.driverName || t("common.unknown_driver"), action: t("timing.invalidate_last_lap"), detail: formatLap(Number(target.lapMs || 0) || 0), + category: "invalid", + undo: { + type: "set_passing_validity", + passingTimestamp: Number(target.timestamp || 0) || 0, + validLap: true, + invalidReason: "", + }, }); saveState(); return true; @@ -6356,11 +6490,90 @@ function restoreCompetitorLastInvalidLap(session, row) { displayName: row.displayName || row.driverName || t("common.unknown_driver"), action: t("timing.restore_last_invalid"), detail: formatLap(Number(target.lapMs || 0) || 0), + category: "invalid", + undo: { + type: "set_passing_validity", + passingTimestamp: Number(target.timestamp || 0) || 0, + validLap: false, + invalidReason: "manual_invalid", + }, }); saveState(); return true; } +function findPassingByUndoMarker(session, rowKey, passingTimestamp) { + const result = ensureSessionResult(session.id); + return result.passings.find((passing) => passing.competitorKey === rowKey && Number(passing.timestamp || 0) === Number(passingTimestamp || 0)) || null; +} + +function undoJudgingAdjustment(session, adjustmentId) { + const result = ensureSessionResult(session.id); + const adjustment = (result.adjustments || []).find((entry) => entry.id === adjustmentId && !entry.undoneAt); + if (!adjustment || !adjustment.undo) { + return false; + } + + const entry = result.competitors[adjustment.competitorKey]; + if (!entry) { + return false; + } + + if (adjustment.undo.type === "restore_correction_state") { + entry.manualLapAdjustment = Number(adjustment.undo.previousLapAdjustment || 0) || 0; + entry.manualTimeAdjustmentMs = Number(adjustment.undo.previousTimeAdjustmentMs || 0) || 0; + } else if (adjustment.undo.type === "set_passing_validity") { + const passing = findPassingByUndoMarker(session, adjustment.competitorKey, adjustment.undo.passingTimestamp); + if (!passing) { + return false; + } + passing.validLap = adjustment.undo.validLap !== false; + passing.invalidReason = adjustment.undo.validLap === false ? adjustment.undo.invalidReason || "manual_invalid" : ""; + recalculateCompetitorFromPassings(session, adjustment.competitorKey); + } else { + return false; + } + + adjustment.undoneAt = Date.now(); + result.adjustments.push({ + id: uid("judge"), + ts: Date.now(), + competitorKey: adjustment.competitorKey, + displayName: adjustment.displayName || t("common.unknown_driver"), + action: t("judging.undo_action"), + detail: adjustment.action || "-", + category: "undo", + }); + saveState(); + return true; +} + +function getJudgeFilteredRows(rows, filterValue) { + if (filterValue === "invalid") { + return rows.filter((row) => row.invalidPending); + } + if (filterValue === "corrected") { + return rows.filter((row) => (Number(row.manualLapAdjustment || 0) || 0) !== 0 || (Number(row.manualTimeAdjustmentMs || 0) || 0) !== 0); + } + if (filterValue === "team") { + return rows.filter((row) => Boolean(row.teamId)); + } + return rows; +} + +function getJudgeFilteredLog(adjustments, filterValue) { + if (filterValue === "corrections") { + return adjustments.filter((entry) => entry.category === "correction"); + } + if (filterValue === "invalid") { + return adjustments.filter((entry) => entry.category === "invalid"); + } + if (filterValue === "undo") { + return adjustments.filter((entry) => entry.category === "undo"); + } + return adjustments; +} + function getSessionTiming(session, nowTs = Date.now()) { const targetMs = getSessionTargetMs(session); const startedAt = Number(session?.startedAt || 0);