Add judging filters, log export and undo stack

This commit is contained in:
larssand
2026-03-15 22:25:19 +01:00
parent f05140caed
commit 70d72c0308
2 changed files with 229 additions and 9 deletions

View File

@@ -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.

View File

@@ -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 = `
<section class="panel">
@@ -4894,11 +4928,19 @@ function renderJudging() {
<section class="panel-row">
<section class="panel">
<div class="panel-header"><h3>${t("judging.select_competitor")}</h3></div>
<div class="panel-header">
<h3>${t("judging.select_competitor")}</h3>
<select id="judgingCompetitorFilter">
<option value="all" ${judgingCompetitorFilter === "all" ? "selected" : ""}>${t("judging.filter_all")}</option>
<option value="invalid" ${judgingCompetitorFilter === "invalid" ? "selected" : ""}>${t("judging.filter_invalid")}</option>
<option value="corrected" ${judgingCompetitorFilter === "corrected" ? "selected" : ""}>${t("judging.filter_corrected")}</option>
<option value="team" ${judgingCompetitorFilter === "team" ? "selected" : ""}>${t("judging.filter_team")}</option>
</select>
</div>
<div class="panel-body">
${renderTable(
[t("table.pos"), t("table.driver"), t("table.result"), t("table.best_lap"), ""],
leaderboard.map(
filteredRows.map(
(row, index) => `
<tr class="${row.key === selectedJudgeKey ? "judge-selected-row" : ""}">
<td>${index + 1}</td>
@@ -4963,19 +5005,32 @@ function renderJudging() {
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("judging.action_log")}</h3></div>
<div class="panel-header">
<h3>${t("judging.action_log")}</h3>
<div class="actions-inline">
<select id="judgingLogFilter">
<option value="all" ${judgingLogFilter === "all" ? "selected" : ""}>${t("judging.filter_all")}</option>
<option value="corrections" ${judgingLogFilter === "corrections" ? "selected" : ""}>${t("judging.filter_log_corrections")}</option>
<option value="invalid" ${judgingLogFilter === "invalid" ? "selected" : ""}>${t("judging.filter_log_invalidations")}</option>
<option value="undo" ${judgingLogFilter === "undo" ? "selected" : ""}>${t("judging.filter_log_undo")}</option>
</select>
<button class="btn" id="judgingExportLog" type="button">${t("judging.export_log")}</button>
<button class="btn" id="judgingUndoLast" type="button">${t("judging.undo_last")}</button>
</div>
</div>
<div class="panel-body">
${
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) => `
<tr>
<td>${new Date(entry.ts).toLocaleTimeString()}</td>
<td>${escapeHtml(entry.displayName || "-")}</td>
<td>${escapeHtml(entry.action || "-")}</td>
<td>${escapeHtml(entry.detail || "-")}</td>
<td>${escapeHtml(entry.detail || "-")}${entry.undoneAt ? `${escapeHtml(t("judging.filter_log_undo"))}` : ""}</td>
<td>${entry.undo && !entry.undoneAt ? `<button class="btn btn-mini" id="judge-undo-${entry.id}" type="button">${t("judging.undo_action")}</button>` : ""}</td>
</tr>
`
)
@@ -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() {
<li>${t("guide.validation_5")}</li>
<li>${t("guide.validation_6")}</li>
<li>${t("guide.validation_7")}</li>
<li>${t("guide.validation_8")}</li>
</ul>
</div>
</section>
@@ -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);