diff --git a/README.md b/README.md index 4c4ecef..a225ad8 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ Praktiskt exempel: - 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 - `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 - `Översikt` visar nu om dagen ligger före eller efter schema @@ -171,6 +172,12 @@ Praktiskt exempel: - `Tidtagning -> Detaljer` - knappen `Ogiltigförklara senaste varv` markerar senaste räknade varv som manuellt ogiltigt - 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 Kör i PowerShell i projektmappen. diff --git a/src/app.js b/src/app.js index 2cf58e8..a866627 100644 --- a/src/app.js +++ b/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 = ` +
+

${t("judging.title")}

+

${t("judging.no_active_session")}

+
+ `; + 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 = ` +
+
+

${t("judging.title")}

+ ${escapeHtml(active.name)} +
+
+

${t("judging.active_session")}: ${escapeHtml(active.name)} • ${escapeHtml(getSessionTypeLabel(active.type))}

+
+
+ +
+
+

${t("judging.select_competitor")}

+
+ ${renderTable( + [t("table.pos"), t("table.driver"), t("table.result"), t("table.best_lap"), ""], + leaderboard.map( + (row, index) => ` + + ${index + 1} + +
${escapeHtml(row.displayName || row.driverName)}
+
${escapeHtml(row.subLabel || row.transponder || "-")}
+ + ${escapeHtml(row.resultDisplay || "-")} + ${formatLap(row.bestLapMs)} + + + ` + ) + )} +
+
+ +
+

${t("judging.manual_actions")}

+
+ ${ + selectedRow + ? ` +

${escapeHtml(selectedRow.displayName || selectedRow.driverName)} • ${escapeHtml(selectedRow.subLabel || selectedRow.carName || "-")}

+

${t("table.laps")}: ${selectedRow.laps}

+

${t("table.best_lap")}: ${formatLap(selectedRow.bestLapMs)}

+

${t("table.last_lap")}: ${formatLap(selectedRow.lastLapMs)}

+
+ + + + + + + + +
+
+

${t("timing.lap_history")}

+ ${ + selectedPassings.length + ? renderTable( + [t("table.lap"), t("table.last_lap"), t("table.status")], + selectedPassings.map( + (passing, index) => ` + + ${index + 1} + ${formatLap(passing.lapMs)} + ${escapeHtml(getPassingValidationLabel(passing))} + + ` + ) + ) + : `

${t("timing.no_lap_history")}

` + } +
+ ` + : `

${t("judging.selected_none")}

` + } +
+
+
+ +
+

${t("judging.action_log")}

+
+ ${ + actionLog.length + ? renderTable( + [t("table.time"), t("table.driver"), t("events.actions"), t("table.status")], + actionLog.map( + (entry) => ` + + ${new Date(entry.ts).toLocaleTimeString()} + ${escapeHtml(entry.displayName || "-")} + ${escapeHtml(entry.action || "-")} + ${escapeHtml(entry.detail || "-")} + + ` + ) + ) + : `

${t("judging.no_action_log")}

` + } +
+
+ `; + + 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 `