Add judging view, preset import/export and lap restore
This commit is contained in:
@@ -153,6 +153,7 @@ Praktiskt exempel:
|
|||||||
- efter applicering kan alla fält fortfarande justeras manuellt och sparas som vanligt
|
- efter applicering kan alla fält fortfarande justeras manuellt och sparas som vanligt
|
||||||
- `Spara klubb-preset` lagrar egna lokala presets i appens state så de kan återanvändas på samma installation
|
- `Spara klubb-preset` lagrar egna lokala presets i appens state så de kan återanvändas på samma installation
|
||||||
- `Ta bort klubb-preset` tar bort ett lokalt preset igen
|
- `Ta bort klubb-preset` tar bort ett lokalt preset igen
|
||||||
|
- `Inställningar -> Klubb-presetar` kan exportera/importera presetbiblioteket som JSON mellan installationer
|
||||||
|
|
||||||
### Schemaavvikelse på Översikt
|
### Schemaavvikelse på Översikt
|
||||||
- `Översikt` visar nu om dagen ligger före eller efter schema
|
- `Översikt` visar nu om dagen ligger före eller efter schema
|
||||||
@@ -171,6 +172,12 @@ Praktiskt exempel:
|
|||||||
- `Tidtagning -> Detaljer`
|
- `Tidtagning -> Detaljer`
|
||||||
- knappen `Ogiltigförklara senaste varv` markerar senaste räknade varv som manuellt ogiltigt
|
- knappen `Ogiltigförklara senaste varv` markerar senaste räknade varv som manuellt ogiltigt
|
||||||
- leaderboard, overlay och passings-historik uppdateras direkt
|
- leaderboard, overlay och passings-historik uppdateras direkt
|
||||||
|
- `Återställ senaste manuellt ogiltiga varv` lägger tillbaka senaste manuellt ogiltiga varvet om du ångrar dig
|
||||||
|
|
||||||
|
### Domarvy
|
||||||
|
- 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
|
||||||
|
|
||||||
## Windows installation
|
## Windows installation
|
||||||
Kör i PowerShell i projektmappen.
|
Kör i PowerShell i projektmappen.
|
||||||
|
|||||||
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: "drivers", titleKey: "nav.drivers", subtitleKey: "nav.drivers_sub" },
|
||||||
{ id: "cars", titleKey: "nav.cars", subtitleKey: "nav.cars_sub" },
|
{ id: "cars", titleKey: "nav.cars", subtitleKey: "nav.cars_sub" },
|
||||||
{ id: "timing", titleKey: "nav.timing", subtitleKey: "nav.timing_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: "settings", titleKey: "nav.settings", subtitleKey: "nav.settings_sub" },
|
||||||
{ id: "guide", titleKey: "nav.guide", subtitleKey: "nav.guide_sub" },
|
{ id: "guide", titleKey: "nav.guide", subtitleKey: "nav.guide_sub" },
|
||||||
];
|
];
|
||||||
@@ -33,6 +34,8 @@ const TRANSLATIONS = {
|
|||||||
"nav.cars_sub": "Bilar med fasta transpondrar",
|
"nav.cars_sub": "Bilar med fasta transpondrar",
|
||||||
"nav.timing": "Tidtagning",
|
"nav.timing": "Tidtagning",
|
||||||
"nav.timing_sub": "Live timing-board",
|
"nav.timing_sub": "Live timing-board",
|
||||||
|
"nav.judging": "Domare",
|
||||||
|
"nav.judging_sub": "Korrigeringar och penalties",
|
||||||
"nav.settings": "Inställningar",
|
"nav.settings": "Inställningar",
|
||||||
"nav.settings_sub": "Decoder, backend och lagring",
|
"nav.settings_sub": "Decoder, backend och lagring",
|
||||||
"nav.guide": "Guide",
|
"nav.guide": "Guide",
|
||||||
@@ -351,9 +354,20 @@ const TRANSLATIONS = {
|
|||||||
"timing.penalty_add_5sec": "+5 sek",
|
"timing.penalty_add_5sec": "+5 sek",
|
||||||
"timing.penalty_remove_sec": "-1 sek",
|
"timing.penalty_remove_sec": "-1 sek",
|
||||||
"timing.penalty_reset": "Nollställ korrigering",
|
"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.valid_passing": "Giltigt varv",
|
||||||
"timing.invalid_short": "För kort varv",
|
"timing.invalid_short": "För kort varv",
|
||||||
"timing.invalid_long": "Över maxvarv",
|
"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.total_time": "Total tid",
|
||||||
"timing.clear_confirm": "Rensa all tiddata för denna session?",
|
"timing.clear_confirm": "Rensa all tiddata för denna session?",
|
||||||
"timing.prompt_transponder": "Transponder",
|
"timing.prompt_transponder": "Transponder",
|
||||||
@@ -419,6 +433,10 @@ const TRANSLATIONS = {
|
|||||||
"settings.logo_clear": "Rensa logo",
|
"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.logo_note": "Logon visas i overlay. PDF-export försöker bädda in loggan automatiskt via backend.",
|
||||||
"settings.storage": "Lagring",
|
"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_url": "Backend URL",
|
||||||
"settings.backend_status": "Backend-status",
|
"settings.backend_status": "Backend-status",
|
||||||
"settings.online": "Online",
|
"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_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_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_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_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_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.",
|
"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_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_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_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_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_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.",
|
"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.cars_sub": "Track cars with fixed transponders",
|
||||||
"nav.timing": "Timing",
|
"nav.timing": "Timing",
|
||||||
"nav.timing_sub": "Live timing board",
|
"nav.timing_sub": "Live timing board",
|
||||||
|
"nav.judging": "Judging",
|
||||||
|
"nav.judging_sub": "Corrections and penalties",
|
||||||
"nav.settings": "Settings",
|
"nav.settings": "Settings",
|
||||||
"nav.settings_sub": "Decoder, backend and storage",
|
"nav.settings_sub": "Decoder, backend and storage",
|
||||||
"nav.guide": "Guide",
|
"nav.guide": "Guide",
|
||||||
@@ -954,9 +976,20 @@ const TRANSLATIONS = {
|
|||||||
"timing.penalty_add_5sec": "+5 sec",
|
"timing.penalty_add_5sec": "+5 sec",
|
||||||
"timing.penalty_remove_sec": "-1 sec",
|
"timing.penalty_remove_sec": "-1 sec",
|
||||||
"timing.penalty_reset": "Reset correction",
|
"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.valid_passing": "Valid lap",
|
||||||
"timing.invalid_short": "Short lap",
|
"timing.invalid_short": "Short lap",
|
||||||
"timing.invalid_long": "Over max 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.total_time": "Total time",
|
||||||
"timing.clear_confirm": "Clear all timing data for this session?",
|
"timing.clear_confirm": "Clear all timing data for this session?",
|
||||||
"timing.prompt_transponder": "Transponder",
|
"timing.prompt_transponder": "Transponder",
|
||||||
@@ -1022,6 +1055,10 @@ const TRANSLATIONS = {
|
|||||||
"settings.logo_clear": "Clear logo",
|
"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.logo_note": "The logo is shown in overlay. PDF export attempts to embed the logo automatically via the backend.",
|
||||||
"settings.storage": "Storage",
|
"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_url": "Backend URL",
|
||||||
"settings.backend_status": "Backend status",
|
"settings.backend_status": "Backend status",
|
||||||
"settings.online": "Online",
|
"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_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_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_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_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_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.",
|
"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_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_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_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_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_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.",
|
"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 selectedEventEditId = null;
|
||||||
let selectedSessionEditId = null;
|
let selectedSessionEditId = null;
|
||||||
let selectedTeamEditId = null;
|
let selectedTeamEditId = null;
|
||||||
|
let selectedJudgeKey = null;
|
||||||
let quickAddDraft = null;
|
let quickAddDraft = null;
|
||||||
let overlaySyncTimer = null;
|
let overlaySyncTimer = null;
|
||||||
let overlayRotationTimer = null;
|
let overlayRotationTimer = null;
|
||||||
@@ -1420,6 +1460,9 @@ function loadState() {
|
|||||||
pdfFooter: parsed.settings?.pdfFooter || "Generated by JMK RB Live Event",
|
pdfFooter: parsed.settings?.pdfFooter || "Generated by JMK RB Live Event",
|
||||||
pdfTheme: parsed.settings?.pdfTheme || "classic",
|
pdfTheme: parsed.settings?.pdfTheme || "classic",
|
||||||
logoDataUrl: parsed.settings?.logoDataUrl || "",
|
logoDataUrl: parsed.settings?.logoDataUrl || "",
|
||||||
|
racePresets: Array.isArray(parsed.settings?.racePresets)
|
||||||
|
? parsed.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name)
|
||||||
|
: [],
|
||||||
},
|
},
|
||||||
decoder: {
|
decoder: {
|
||||||
connected: false,
|
connected: false,
|
||||||
@@ -1459,6 +1502,7 @@ function loadState() {
|
|||||||
pdfFooter: "Generated by JMK RB Live Event",
|
pdfFooter: "Generated by JMK RB Live Event",
|
||||||
pdfTheme: "classic",
|
pdfTheme: "classic",
|
||||||
logoDataUrl: "",
|
logoDataUrl: "",
|
||||||
|
racePresets: [],
|
||||||
},
|
},
|
||||||
decoder: {
|
decoder: {
|
||||||
connected: false,
|
connected: false,
|
||||||
@@ -2130,6 +2174,9 @@ function renderView() {
|
|||||||
case "timing":
|
case "timing":
|
||||||
renderTiming();
|
renderTiming();
|
||||||
break;
|
break;
|
||||||
|
case "judging":
|
||||||
|
renderJudging();
|
||||||
|
break;
|
||||||
case "overlay":
|
case "overlay":
|
||||||
renderOverlay();
|
renderOverlay();
|
||||||
break;
|
break;
|
||||||
@@ -4633,6 +4680,10 @@ function renderTiming() {
|
|||||||
invalidateCompetitorLastLap(active, selectedRow);
|
invalidateCompetitorLastLap(active, selectedRow);
|
||||||
renderView();
|
renderView();
|
||||||
});
|
});
|
||||||
|
document.getElementById("corrRestoreInvalid")?.addEventListener("click", () => {
|
||||||
|
restoreCompetitorLastInvalidLap(active, selectedRow);
|
||||||
|
renderView();
|
||||||
|
});
|
||||||
document.getElementById("corrReset")?.addEventListener("click", () => {
|
document.getElementById("corrReset")?.addEventListener("click", () => {
|
||||||
applyCompetitorCorrection(active, selectedRow, { reset: true });
|
applyCompetitorCorrection(active, selectedRow, { reset: true });
|
||||||
renderView();
|
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) {
|
function renderSpeakerToggle(settingKey, labelKey) {
|
||||||
return `
|
return `
|
||||||
<label class="check-card">
|
<label class="check-card">
|
||||||
@@ -4909,6 +5134,7 @@ function renderGuide() {
|
|||||||
<li>${t("guide.race_format_11")}</li>
|
<li>${t("guide.race_format_11")}</li>
|
||||||
<li>${t("guide.race_format_12")}</li>
|
<li>${t("guide.race_format_12")}</li>
|
||||||
<li>${t("guide.race_format_13")}</li>
|
<li>${t("guide.race_format_13")}</li>
|
||||||
|
<li>${t("guide.race_format_14")}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -4959,6 +5185,7 @@ function renderGuide() {
|
|||||||
<li>${t("guide.validation_4")}</li>
|
<li>${t("guide.validation_4")}</li>
|
||||||
<li>${t("guide.validation_5")}</li>
|
<li>${t("guide.validation_5")}</li>
|
||||||
<li>${t("guide.validation_6")}</li>
|
<li>${t("guide.validation_6")}</li>
|
||||||
|
<li>${t("guide.validation_7")}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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="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="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="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>
|
<button class="btn btn-danger btn-mini" id="corrReset" type="button">${t("timing.penalty_reset")}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -5737,6 +5965,18 @@ function renderSettings() {
|
|||||||
<button id="exportData" class="btn">${t("settings.export_json")}</button>
|
<button id="exportData" class="btn">${t("settings.export_json")}</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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) => {
|
document.getElementById("settingsForm")?.addEventListener("submit", (e) => {
|
||||||
@@ -5823,6 +6063,45 @@ function renderSettings() {
|
|||||||
URL.revokeObjectURL(url);
|
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) => {
|
document.getElementById("ammcForm")?.addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const form = new FormData(e.currentTarget);
|
const form = new FormData(e.currentTarget);
|
||||||
@@ -5975,20 +6254,63 @@ function getManualCorrectionSummary(row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyCompetitorCorrection(session, row, options = {}) {
|
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) {
|
if (!entry) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (options.reset) {
|
if (options.reset) {
|
||||||
entry.manualLapAdjustment = 0;
|
entry.manualLapAdjustment = 0;
|
||||||
entry.manualTimeAdjustmentMs = 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 {
|
} else {
|
||||||
entry.manualLapAdjustment = (Number(entry.manualLapAdjustment || 0) || 0) + (Number(options.lapDelta || 0) || 0);
|
const lapDelta = Number(options.lapDelta || 0) || 0;
|
||||||
entry.manualTimeAdjustmentMs = (Number(entry.manualTimeAdjustmentMs || 0) || 0) + (Number(options.timeMsDelta || 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();
|
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) {
|
function invalidateCompetitorLastLap(session, row) {
|
||||||
const result = ensureSessionResult(session.id);
|
const result = ensureSessionResult(session.id);
|
||||||
const entry = result.competitors[row.key];
|
const entry = result.competitors[row.key];
|
||||||
@@ -6004,16 +6326,37 @@ function invalidateCompetitorLastLap(session, row) {
|
|||||||
|
|
||||||
target.validLap = false;
|
target.validLap = false;
|
||||||
target.invalidReason = "manual_invalid";
|
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));
|
function restoreCompetitorLastInvalidLap(session, row) {
|
||||||
entry.laps = validPassings.length;
|
const result = ensureSessionResult(session.id);
|
||||||
entry.lastLapMs = validPassings.length ? Number(validPassings[validPassings.length - 1].lapMs || 0) || null : null;
|
const passings = getCompetitorPassings(session, row, { includeInvalid: true });
|
||||||
entry.lastTimestamp = validPassings.length
|
const target = [...passings].reverse().find((passing) => passing.validLap === false && passing.invalidReason === "manual_invalid");
|
||||||
? Number(validPassings[validPassings.length - 1].timestamp || 0) || entry.startTimestamp || session.startedAt || null
|
if (!target) {
|
||||||
: entry.startTimestamp || session.startedAt || null;
|
return false;
|
||||||
|
}
|
||||||
const bestLapCandidates = validPassings.map((passing) => Number(passing.lapMs || 0)).filter((lapMs) => lapMs > 500);
|
target.validLap = true;
|
||||||
entry.bestLapMs = bestLapCandidates.length ? Math.min(...bestLapCandidates) : null;
|
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();
|
saveState();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -6041,8 +6384,12 @@ function ensureSessionResult(sessionId) {
|
|||||||
state.resultsBySession[sessionId] = {
|
state.resultsBySession[sessionId] = {
|
||||||
passings: [],
|
passings: [],
|
||||||
competitors: {},
|
competitors: {},
|
||||||
|
adjustments: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (!Array.isArray(state.resultsBySession[sessionId].adjustments)) {
|
||||||
|
state.resultsBySession[sessionId].adjustments = [];
|
||||||
|
}
|
||||||
return state.resultsBySession[sessionId];
|
return state.resultsBySession[sessionId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -652,6 +652,10 @@ select:focus {
|
|||||||
background: rgba(225, 106, 0, 0.06);
|
background: rgba(225, 106, 0, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.data-table tr.judge-selected-row td {
|
||||||
|
background: rgba(225, 6, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.grid-editor-toolbar {
|
.grid-editor-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
Reference in New Issue
Block a user