Add judging filters, log export and undo stack
This commit is contained in:
@@ -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.
|
||||
|
||||
231
src/app.js
231
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 = `
|
||||
<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);
|
||||
|
||||
Reference in New Issue
Block a user