Add judging view, preset import/export and lap restore
This commit is contained in:
371
src/app.js
371
src/app.js
@@ -7,6 +7,7 @@ const NAV_ITEMS = [
|
||||
{ id: "drivers", titleKey: "nav.drivers", subtitleKey: "nav.drivers_sub" },
|
||||
{ id: "cars", titleKey: "nav.cars", subtitleKey: "nav.cars_sub" },
|
||||
{ id: "timing", titleKey: "nav.timing", subtitleKey: "nav.timing_sub" },
|
||||
{ id: "judging", titleKey: "nav.judging", subtitleKey: "nav.judging_sub" },
|
||||
{ id: "settings", titleKey: "nav.settings", subtitleKey: "nav.settings_sub" },
|
||||
{ id: "guide", titleKey: "nav.guide", subtitleKey: "nav.guide_sub" },
|
||||
];
|
||||
@@ -33,6 +34,8 @@ const TRANSLATIONS = {
|
||||
"nav.cars_sub": "Bilar med fasta transpondrar",
|
||||
"nav.timing": "Tidtagning",
|
||||
"nav.timing_sub": "Live timing-board",
|
||||
"nav.judging": "Domare",
|
||||
"nav.judging_sub": "Korrigeringar och penalties",
|
||||
"nav.settings": "Inställningar",
|
||||
"nav.settings_sub": "Decoder, backend och lagring",
|
||||
"nav.guide": "Guide",
|
||||
@@ -351,9 +354,20 @@ const TRANSLATIONS = {
|
||||
"timing.penalty_add_5sec": "+5 sek",
|
||||
"timing.penalty_remove_sec": "-1 sek",
|
||||
"timing.penalty_reset": "Nollställ korrigering",
|
||||
"timing.restore_last_invalid": "Återställ senaste manuellt ogiltiga varv",
|
||||
"timing.no_manual_invalid": "Inget manuellt ogiltigt varv hittades.",
|
||||
"timing.valid_passing": "Giltigt varv",
|
||||
"timing.invalid_short": "För kort varv",
|
||||
"timing.invalid_long": "Över maxvarv",
|
||||
"judging.title": "Domarvy",
|
||||
"judging.active_session": "Aktiv session",
|
||||
"judging.no_active_session": "Ingen aktiv session vald.",
|
||||
"judging.select_competitor": "Välj förare eller lag",
|
||||
"judging.manual_actions": "Manuella åtgärder",
|
||||
"judging.action_log": "Domarlogg",
|
||||
"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.",
|
||||
"timing.total_time": "Total tid",
|
||||
"timing.clear_confirm": "Rensa all tiddata för denna session?",
|
||||
"timing.prompt_transponder": "Transponder",
|
||||
@@ -419,6 +433,10 @@ const TRANSLATIONS = {
|
||||
"settings.logo_clear": "Rensa logo",
|
||||
"settings.logo_note": "Logon visas i overlay. PDF-export försöker bädda in loggan automatiskt via backend.",
|
||||
"settings.storage": "Lagring",
|
||||
"settings.race_presets": "Klubb-presetar",
|
||||
"settings.race_presets_note": "Exportera eller importera lokala klubb-presetar mellan installationer.",
|
||||
"settings.export_presets": "Exportera presets",
|
||||
"settings.import_presets": "Importera presets",
|
||||
"settings.backend_url": "Backend URL",
|
||||
"settings.backend_status": "Backend-status",
|
||||
"settings.online": "Online",
|
||||
@@ -535,6 +553,7 @@ const TRANSLATIONS = {
|
||||
"guide.race_format_11": "Preset låter dig snabbt fylla raceformat med vettiga grundvärden för kort teknisk bana, klubbrace, IFMAR-liknande upplägg eller endurance.",
|
||||
"guide.race_format_12": "Du kan applicera preset och sedan justera enskilda fält manuellt innan du sparar raceformatet.",
|
||||
"guide.race_format_13": "Spara klubb-preset lagrar dina egna lokala raceformat så du kan återanvända dem på samma installation utan att bygga om allt varje gång.",
|
||||
"guide.race_format_14": "Klubb-presetar kan också exporteras och importeras från Inställningar om du vill flytta dem mellan olika servrar eller laptops.",
|
||||
"guide.free_practice_title": "Free Practice",
|
||||
"guide.free_practice_1": "Använd sessionstypen fri träning när du bara vill visa löpande varvtider.",
|
||||
"guide.free_practice_2": "Free Practice påverkar inte seedning till kval eller finaler.",
|
||||
@@ -557,6 +576,7 @@ const TRANSLATIONS = {
|
||||
"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.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.",
|
||||
@@ -636,6 +656,8 @@ const TRANSLATIONS = {
|
||||
"nav.cars_sub": "Track cars with fixed transponders",
|
||||
"nav.timing": "Timing",
|
||||
"nav.timing_sub": "Live timing board",
|
||||
"nav.judging": "Judging",
|
||||
"nav.judging_sub": "Corrections and penalties",
|
||||
"nav.settings": "Settings",
|
||||
"nav.settings_sub": "Decoder, backend and storage",
|
||||
"nav.guide": "Guide",
|
||||
@@ -954,9 +976,20 @@ const TRANSLATIONS = {
|
||||
"timing.penalty_add_5sec": "+5 sec",
|
||||
"timing.penalty_remove_sec": "-1 sec",
|
||||
"timing.penalty_reset": "Reset correction",
|
||||
"timing.restore_last_invalid": "Restore latest manually invalidated lap",
|
||||
"timing.no_manual_invalid": "No manually invalidated lap was found.",
|
||||
"timing.valid_passing": "Valid lap",
|
||||
"timing.invalid_short": "Short lap",
|
||||
"timing.invalid_long": "Over max lap",
|
||||
"judging.title": "Judging view",
|
||||
"judging.active_session": "Active session",
|
||||
"judging.no_active_session": "No active session selected.",
|
||||
"judging.select_competitor": "Select driver or team",
|
||||
"judging.manual_actions": "Manual actions",
|
||||
"judging.action_log": "Judging log",
|
||||
"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.",
|
||||
"timing.total_time": "Total time",
|
||||
"timing.clear_confirm": "Clear all timing data for this session?",
|
||||
"timing.prompt_transponder": "Transponder",
|
||||
@@ -1022,6 +1055,10 @@ const TRANSLATIONS = {
|
||||
"settings.logo_clear": "Clear logo",
|
||||
"settings.logo_note": "The logo is shown in overlay. PDF export attempts to embed the logo automatically via the backend.",
|
||||
"settings.storage": "Storage",
|
||||
"settings.race_presets": "Club presets",
|
||||
"settings.race_presets_note": "Export or import local club presets between installations.",
|
||||
"settings.export_presets": "Export presets",
|
||||
"settings.import_presets": "Import presets",
|
||||
"settings.backend_url": "Backend URL",
|
||||
"settings.backend_status": "Backend status",
|
||||
"settings.online": "Online",
|
||||
@@ -1138,6 +1175,7 @@ const TRANSLATIONS = {
|
||||
"guide.race_format_11": "Preset lets you quickly fill the race format with sensible defaults for a short technical track, club race, IFMAR-like setup or endurance.",
|
||||
"guide.race_format_12": "You can apply a preset and then adjust individual fields manually before saving the race format.",
|
||||
"guide.race_format_13": "Save club preset stores your own local race formats so you can reuse them on the same installation without rebuilding everything each time.",
|
||||
"guide.race_format_14": "Club presets can also be exported and imported from Settings if you want to move them between different servers or laptops.",
|
||||
"guide.free_practice_title": "Free Practice",
|
||||
"guide.free_practice_1": "Use the free practice session type when you only want to show live lap times.",
|
||||
"guide.free_practice_2": "Free Practice does not affect seeding for qualifying or finals.",
|
||||
@@ -1160,6 +1198,7 @@ const TRANSLATIONS = {
|
||||
"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.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.",
|
||||
@@ -1244,6 +1283,7 @@ let selectedCarEditId = null;
|
||||
let selectedEventEditId = null;
|
||||
let selectedSessionEditId = null;
|
||||
let selectedTeamEditId = null;
|
||||
let selectedJudgeKey = null;
|
||||
let quickAddDraft = null;
|
||||
let overlaySyncTimer = null;
|
||||
let overlayRotationTimer = null;
|
||||
@@ -1420,6 +1460,9 @@ function loadState() {
|
||||
pdfFooter: parsed.settings?.pdfFooter || "Generated by JMK RB Live Event",
|
||||
pdfTheme: parsed.settings?.pdfTheme || "classic",
|
||||
logoDataUrl: parsed.settings?.logoDataUrl || "",
|
||||
racePresets: Array.isArray(parsed.settings?.racePresets)
|
||||
? parsed.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name)
|
||||
: [],
|
||||
},
|
||||
decoder: {
|
||||
connected: false,
|
||||
@@ -1459,6 +1502,7 @@ function loadState() {
|
||||
pdfFooter: "Generated by JMK RB Live Event",
|
||||
pdfTheme: "classic",
|
||||
logoDataUrl: "",
|
||||
racePresets: [],
|
||||
},
|
||||
decoder: {
|
||||
connected: false,
|
||||
@@ -2130,6 +2174,9 @@ function renderView() {
|
||||
case "timing":
|
||||
renderTiming();
|
||||
break;
|
||||
case "judging":
|
||||
renderJudging();
|
||||
break;
|
||||
case "overlay":
|
||||
renderOverlay();
|
||||
break;
|
||||
@@ -4633,6 +4680,10 @@ function renderTiming() {
|
||||
invalidateCompetitorLastLap(active, selectedRow);
|
||||
renderView();
|
||||
});
|
||||
document.getElementById("corrRestoreInvalid")?.addEventListener("click", () => {
|
||||
restoreCompetitorLastInvalidLap(active, selectedRow);
|
||||
renderView();
|
||||
});
|
||||
document.getElementById("corrReset")?.addEventListener("click", () => {
|
||||
applyCompetitorCorrection(active, selectedRow, { reset: true });
|
||||
renderView();
|
||||
@@ -4806,6 +4857,180 @@ function renderTiming() {
|
||||
});
|
||||
}
|
||||
|
||||
function renderJudging() {
|
||||
const active = getActiveSession();
|
||||
if (!active) {
|
||||
dom.view.innerHTML = `
|
||||
<section class="panel">
|
||||
<div class="panel-header"><h3>${t("judging.title")}</h3></div>
|
||||
<div class="panel-body"><p>${t("judging.no_active_session")}</p></div>
|
||||
</section>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = ensureSessionResult(active.id);
|
||||
const leaderboard = buildLeaderboard(active);
|
||||
if (selectedJudgeKey && !leaderboard.some((row) => row.key === selectedJudgeKey)) {
|
||||
selectedJudgeKey = null;
|
||||
}
|
||||
if (!selectedJudgeKey && leaderboard.length) {
|
||||
selectedJudgeKey = leaderboard[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() : [];
|
||||
|
||||
dom.view.innerHTML = `
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>${t("judging.title")}</h3>
|
||||
<span class="pill pill-green">${escapeHtml(active.name)}</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p>${t("judging.active_session")}: <strong>${escapeHtml(active.name)}</strong> • ${escapeHtml(getSessionTypeLabel(active.type))}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-row">
|
||||
<section class="panel">
|
||||
<div class="panel-header"><h3>${t("judging.select_competitor")}</h3></div>
|
||||
<div class="panel-body">
|
||||
${renderTable(
|
||||
[t("table.pos"), t("table.driver"), t("table.result"), t("table.best_lap"), ""],
|
||||
leaderboard.map(
|
||||
(row, index) => `
|
||||
<tr class="${row.key === selectedJudgeKey ? "judge-selected-row" : ""}">
|
||||
<td>${index + 1}</td>
|
||||
<td>
|
||||
<div class="table-primary">${escapeHtml(row.displayName || row.driverName)}</div>
|
||||
<div class="table-subnote">${escapeHtml(row.subLabel || row.transponder || "-")}</div>
|
||||
</td>
|
||||
<td>${escapeHtml(row.resultDisplay || "-")}</td>
|
||||
<td>${formatLap(row.bestLapMs)}</td>
|
||||
<td><button class="btn btn-mini" id="judge-select-${row.key}">${t("timing.details")}</button></td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header"><h3>${t("judging.manual_actions")}</h3></div>
|
||||
<div class="panel-body">
|
||||
${
|
||||
selectedRow
|
||||
? `
|
||||
<p><strong>${escapeHtml(selectedRow.displayName || selectedRow.driverName)}</strong> • ${escapeHtml(selectedRow.subLabel || selectedRow.carName || "-")}</p>
|
||||
<p>${t("table.laps")}: ${selectedRow.laps}</p>
|
||||
<p>${t("table.best_lap")}: ${formatLap(selectedRow.bestLapMs)}</p>
|
||||
<p>${t("table.last_lap")}: ${formatLap(selectedRow.lastLapMs)}</p>
|
||||
<div class="actions">
|
||||
<button class="btn" id="judgeLapPlus">${t("timing.penalty_add_lap")}</button>
|
||||
<button class="btn" id="judgeLapMinus">${t("timing.penalty_remove_lap")}</button>
|
||||
<button class="btn" id="judgeSecPlus">${t("timing.penalty_add_sec")}</button>
|
||||
<button class="btn" id="judgeFivePlus">${t("timing.penalty_add_5sec")}</button>
|
||||
<button class="btn" id="judgeSecMinus">${t("timing.penalty_remove_sec")}</button>
|
||||
<button class="btn" id="judgeInvalidate">${t("timing.invalidate_last_lap")}</button>
|
||||
<button class="btn" id="judgeRestore">${t("timing.restore_last_invalid")}</button>
|
||||
<button class="btn btn-danger" id="judgeReset">${t("timing.penalty_reset")}</button>
|
||||
</div>
|
||||
<div class="mt-16">
|
||||
<h4>${t("timing.lap_history")}</h4>
|
||||
${
|
||||
selectedPassings.length
|
||||
? renderTable(
|
||||
[t("table.lap"), t("table.last_lap"), t("table.status")],
|
||||
selectedPassings.map(
|
||||
(passing, index) => `
|
||||
<tr class="${isCountedPassing(passing) ? "" : "passing-invalid"}">
|
||||
<td>${index + 1}</td>
|
||||
<td>${formatLap(passing.lapMs)}</td>
|
||||
<td>${escapeHtml(getPassingValidationLabel(passing))}</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
)
|
||||
: `<p>${t("timing.no_lap_history")}</p>`
|
||||
}
|
||||
</div>
|
||||
`
|
||||
: `<p>${t("judging.selected_none")}</p>`
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="panel mt-16">
|
||||
<div class="panel-header"><h3>${t("judging.action_log")}</h3></div>
|
||||
<div class="panel-body">
|
||||
${
|
||||
actionLog.length
|
||||
? renderTable(
|
||||
[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>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
)
|
||||
: `<p>${t("judging.no_action_log")}</p>`
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
leaderboard.forEach((row) => {
|
||||
document.getElementById(`judge-select-${row.key}`)?.addEventListener("click", () => {
|
||||
selectedJudgeKey = row.key;
|
||||
renderJudging();
|
||||
});
|
||||
});
|
||||
|
||||
if (!selectedRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById("judgeLapPlus")?.addEventListener("click", () => {
|
||||
applyCompetitorCorrection(active, selectedRow, { lapDelta: 1 });
|
||||
renderJudging();
|
||||
});
|
||||
document.getElementById("judgeLapMinus")?.addEventListener("click", () => {
|
||||
applyCompetitorCorrection(active, selectedRow, { lapDelta: -1 });
|
||||
renderJudging();
|
||||
});
|
||||
document.getElementById("judgeSecPlus")?.addEventListener("click", () => {
|
||||
applyCompetitorCorrection(active, selectedRow, { timeMsDelta: 1000 });
|
||||
renderJudging();
|
||||
});
|
||||
document.getElementById("judgeFivePlus")?.addEventListener("click", () => {
|
||||
applyCompetitorCorrection(active, selectedRow, { timeMsDelta: 5000 });
|
||||
renderJudging();
|
||||
});
|
||||
document.getElementById("judgeSecMinus")?.addEventListener("click", () => {
|
||||
applyCompetitorCorrection(active, selectedRow, { timeMsDelta: -1000 });
|
||||
renderJudging();
|
||||
});
|
||||
document.getElementById("judgeInvalidate")?.addEventListener("click", () => {
|
||||
invalidateCompetitorLastLap(active, selectedRow);
|
||||
renderJudging();
|
||||
});
|
||||
document.getElementById("judgeRestore")?.addEventListener("click", () => {
|
||||
restoreCompetitorLastInvalidLap(active, selectedRow);
|
||||
renderJudging();
|
||||
});
|
||||
document.getElementById("judgeReset")?.addEventListener("click", () => {
|
||||
applyCompetitorCorrection(active, selectedRow, { reset: true });
|
||||
renderJudging();
|
||||
});
|
||||
}
|
||||
|
||||
function renderSpeakerToggle(settingKey, labelKey) {
|
||||
return `
|
||||
<label class="check-card">
|
||||
@@ -4909,6 +5134,7 @@ function renderGuide() {
|
||||
<li>${t("guide.race_format_11")}</li>
|
||||
<li>${t("guide.race_format_12")}</li>
|
||||
<li>${t("guide.race_format_13")}</li>
|
||||
<li>${t("guide.race_format_14")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
@@ -4959,6 +5185,7 @@ function renderGuide() {
|
||||
<li>${t("guide.validation_4")}</li>
|
||||
<li>${t("guide.validation_5")}</li>
|
||||
<li>${t("guide.validation_6")}</li>
|
||||
<li>${t("guide.validation_7")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
@@ -5363,6 +5590,7 @@ function renderLeaderboardModal(session, row) {
|
||||
<button class="btn btn-mini" id="corr5SecPlus" type="button">${t("timing.penalty_add_5sec")}</button>
|
||||
<button class="btn btn-mini" id="corrSecMinus" type="button">${t("timing.penalty_remove_sec")}</button>
|
||||
<button class="btn btn-mini" id="corrInvalidateLast" type="button">${t("timing.invalidate_last_lap")}</button>
|
||||
<button class="btn btn-mini" id="corrRestoreInvalid" type="button">${t("timing.restore_last_invalid")}</button>
|
||||
<button class="btn btn-danger btn-mini" id="corrReset" type="button">${t("timing.penalty_reset")}</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -5737,6 +5965,18 @@ function renderSettings() {
|
||||
<button id="exportData" class="btn">${t("settings.export_json")}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel mt-16">
|
||||
<div class="panel-header"><h3>${t("settings.race_presets")}</h3></div>
|
||||
<div class="panel-body">
|
||||
<p>${t("settings.race_presets_note")}</p>
|
||||
<p>${(state.settings.racePresets || []).length} preset(s)</p>
|
||||
<div class="actions">
|
||||
<button id="exportRacePresets" class="btn" type="button">${t("settings.export_presets")}</button>
|
||||
<input id="importRacePresets" type="file" accept="application/json" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
document.getElementById("settingsForm")?.addEventListener("submit", (e) => {
|
||||
@@ -5823,6 +6063,45 @@ function renderSettings() {
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
document.getElementById("exportRacePresets")?.addEventListener("click", () => {
|
||||
const payload = {
|
||||
racePresets: (state.settings.racePresets || []).map((preset) => normalizeStoredRacePreset(preset)),
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "live_rc_race_presets.json";
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
document.getElementById("importRacePresets")?.addEventListener("change", (event) => {
|
||||
const input = event.currentTarget;
|
||||
const file = input instanceof HTMLInputElement ? input.files?.[0] : null;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(String(reader.result || "{}"));
|
||||
const incoming = Array.isArray(parsed?.racePresets) ? parsed.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name) : [];
|
||||
const existingById = new Map((state.settings.racePresets || []).map((preset) => [preset.id, normalizeStoredRacePreset(preset)]));
|
||||
incoming.forEach((preset) => {
|
||||
existingById.set(preset.id, preset);
|
||||
});
|
||||
state.settings.racePresets = [...existingById.values()];
|
||||
saveState();
|
||||
renderView();
|
||||
} catch {
|
||||
// ignore invalid preset file
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
document.getElementById("ammcForm")?.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const form = new FormData(e.currentTarget);
|
||||
@@ -5975,20 +6254,63 @@ function getManualCorrectionSummary(row) {
|
||||
}
|
||||
|
||||
function applyCompetitorCorrection(session, row, options = {}) {
|
||||
const entry = ensureSessionResult(session.id).competitors[row.key];
|
||||
const result = ensureSessionResult(session.id);
|
||||
const entry = result.competitors[row.key];
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
if (options.reset) {
|
||||
entry.manualLapAdjustment = 0;
|
||||
entry.manualTimeAdjustmentMs = 0;
|
||||
result.adjustments.push({
|
||||
id: uid("judge"),
|
||||
ts: Date.now(),
|
||||
competitorKey: row.key,
|
||||
displayName: row.displayName || row.driverName || t("common.unknown_driver"),
|
||||
action: t("timing.penalty_reset"),
|
||||
detail: "-",
|
||||
});
|
||||
} else {
|
||||
entry.manualLapAdjustment = (Number(entry.manualLapAdjustment || 0) || 0) + (Number(options.lapDelta || 0) || 0);
|
||||
entry.manualTimeAdjustmentMs = (Number(entry.manualTimeAdjustmentMs || 0) || 0) + (Number(options.timeMsDelta || 0) || 0);
|
||||
const lapDelta = Number(options.lapDelta || 0) || 0;
|
||||
const timeMsDelta = Number(options.timeMsDelta || 0) || 0;
|
||||
entry.manualLapAdjustment = (Number(entry.manualLapAdjustment || 0) || 0) + lapDelta;
|
||||
entry.manualTimeAdjustmentMs = (Number(entry.manualTimeAdjustmentMs || 0) || 0) + timeMsDelta;
|
||||
const detailBits = [];
|
||||
if (lapDelta) {
|
||||
detailBits.push(`${lapDelta > 0 ? "+" : ""}${lapDelta}L`);
|
||||
}
|
||||
if (timeMsDelta) {
|
||||
detailBits.push(`${timeMsDelta > 0 ? "+" : "-"}${formatLap(Math.abs(timeMsDelta))}`);
|
||||
}
|
||||
result.adjustments.push({
|
||||
id: uid("judge"),
|
||||
ts: Date.now(),
|
||||
competitorKey: row.key,
|
||||
displayName: row.displayName || row.driverName || t("common.unknown_driver"),
|
||||
action: t("timing.manual_corrections"),
|
||||
detail: detailBits.join(" • ") || "-",
|
||||
});
|
||||
}
|
||||
saveState();
|
||||
}
|
||||
|
||||
function recalculateCompetitorFromPassings(session, rowKey) {
|
||||
const result = ensureSessionResult(session.id);
|
||||
const entry = result.competitors[rowKey];
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
const passings = getCompetitorPassings(session, entry, { includeInvalid: true });
|
||||
const validPassings = passings.filter((passing) => isCountedPassing(passing));
|
||||
entry.laps = validPassings.length;
|
||||
entry.lastLapMs = validPassings.length ? Number(validPassings[validPassings.length - 1].lapMs || 0) || null : null;
|
||||
entry.lastTimestamp = validPassings.length
|
||||
? Number(validPassings[validPassings.length - 1].timestamp || 0) || entry.startTimestamp || session.startedAt || null
|
||||
: entry.startTimestamp || session.startedAt || null;
|
||||
const bestLapCandidates = validPassings.map((passing) => Number(passing.lapMs || 0)).filter((lapMs) => lapMs > 500);
|
||||
entry.bestLapMs = bestLapCandidates.length ? Math.min(...bestLapCandidates) : null;
|
||||
}
|
||||
|
||||
function invalidateCompetitorLastLap(session, row) {
|
||||
const result = ensureSessionResult(session.id);
|
||||
const entry = result.competitors[row.key];
|
||||
@@ -6004,16 +6326,37 @@ function invalidateCompetitorLastLap(session, row) {
|
||||
|
||||
target.validLap = false;
|
||||
target.invalidReason = "manual_invalid";
|
||||
recalculateCompetitorFromPassings(session, row.key);
|
||||
result.adjustments.push({
|
||||
id: uid("judge"),
|
||||
ts: Date.now(),
|
||||
competitorKey: row.key,
|
||||
displayName: row.displayName || row.driverName || t("common.unknown_driver"),
|
||||
action: t("timing.invalidate_last_lap"),
|
||||
detail: formatLap(Number(target.lapMs || 0) || 0),
|
||||
});
|
||||
saveState();
|
||||
return true;
|
||||
}
|
||||
|
||||
const validPassings = passings.filter((passing) => isCountedPassing(passing));
|
||||
entry.laps = validPassings.length;
|
||||
entry.lastLapMs = validPassings.length ? Number(validPassings[validPassings.length - 1].lapMs || 0) || null : null;
|
||||
entry.lastTimestamp = validPassings.length
|
||||
? Number(validPassings[validPassings.length - 1].timestamp || 0) || entry.startTimestamp || session.startedAt || null
|
||||
: entry.startTimestamp || session.startedAt || null;
|
||||
|
||||
const bestLapCandidates = validPassings.map((passing) => Number(passing.lapMs || 0)).filter((lapMs) => lapMs > 500);
|
||||
entry.bestLapMs = bestLapCandidates.length ? Math.min(...bestLapCandidates) : null;
|
||||
function restoreCompetitorLastInvalidLap(session, row) {
|
||||
const result = ensureSessionResult(session.id);
|
||||
const passings = getCompetitorPassings(session, row, { includeInvalid: true });
|
||||
const target = [...passings].reverse().find((passing) => passing.validLap === false && passing.invalidReason === "manual_invalid");
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
target.validLap = true;
|
||||
target.invalidReason = "";
|
||||
recalculateCompetitorFromPassings(session, row.key);
|
||||
result.adjustments.push({
|
||||
id: uid("judge"),
|
||||
ts: Date.now(),
|
||||
competitorKey: row.key,
|
||||
displayName: row.displayName || row.driverName || t("common.unknown_driver"),
|
||||
action: t("timing.restore_last_invalid"),
|
||||
detail: formatLap(Number(target.lapMs || 0) || 0),
|
||||
});
|
||||
saveState();
|
||||
return true;
|
||||
}
|
||||
@@ -6041,8 +6384,12 @@ function ensureSessionResult(sessionId) {
|
||||
state.resultsBySession[sessionId] = {
|
||||
passings: [],
|
||||
competitors: {},
|
||||
adjustments: [],
|
||||
};
|
||||
}
|
||||
if (!Array.isArray(state.resultsBySession[sessionId].adjustments)) {
|
||||
state.resultsBySession[sessionId].adjustments = [];
|
||||
}
|
||||
return state.resultsBySession[sessionId];
|
||||
}
|
||||
|
||||
|
||||
@@ -652,6 +652,10 @@ select:focus {
|
||||
background: rgba(225, 106, 0, 0.06);
|
||||
}
|
||||
|
||||
.data-table tr.judge-selected-row td {
|
||||
background: rgba(225, 6, 0, 0.08);
|
||||
}
|
||||
|
||||
.grid-editor-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
Reference in New Issue
Block a user