diff --git a/README.md b/README.md index d7c2b02..4c4ecef 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,8 @@ Praktiskt exempel: - `IFMAR-stil kval/final` - `Endurance / lagrace` - 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 ### Schemaavvikelse på Översikt - `Översikt` visar nu om dagen ligger före eller efter schema @@ -165,6 +167,11 @@ Praktiskt exempel: - om senaste mottagna passing för en förare/ett lag var ogiltig markeras raden även i leaderboard och overlay - det gör det lättare att se felträffar utan att behöva stå i `Senaste passeringar` +### Manuell invalidate last lap +- `Tidtagning -> Detaljer` +- knappen `Ogiltigförklara senaste varv` markerar senaste räknade varv som manuellt ogiltigt +- leaderboard, overlay och passings-historik uppdateras direkt + ## Windows installation Kör i PowerShell i projektmappen. diff --git a/src/app.js b/src/app.js index 4da28ab..2cf58e8 100644 --- a/src/app.js +++ b/src/app.js @@ -176,6 +176,9 @@ const TRANSLATIONS = { "events.race_preset": "Preset", "events.race_preset_hint": "Snabbstart för bana/klass. Applicera preset och finjustera sedan manuellt.", "events.apply_preset": "Applicera preset", + "events.save_preset": "Spara klubb-preset", + "events.delete_preset": "Ta bort klubb-preset", + "events.preset_name": "Presetnamn", "events.preset_custom": "Custom / nuvarande värden", "events.preset_short_technical": "Kort teknisk bana 16s", "events.preset_club_qualifying": "Klubbrace kval + final", @@ -183,7 +186,11 @@ const TRANSLATIONS = { "events.preset_endurance": "Endurance / lagrace", "events.tie_break_note": "Tie-break", "events.counted_rounds_label": "Räknade rundor", + "events.tie_break_won": "Vann mot", + "events.tie_break_lost": "Förlorade mot", "events.invalid_recent": "Senaste träff ogiltig", + "timing.invalid_manual": "Manuellt ogiltigförklarat", + "timing.invalidate_last_lap": "Ogiltigförklara senaste varv", "events.cars_per_final": "Förare per final", "events.cars_per_final_hint": "Max antal förare i varje A/B/C-final.", "events.final_legs": "Final-heat per final", @@ -527,6 +534,7 @@ const TRANSLATIONS = { "guide.race_format_10": "Max varvtid stoppar långa felvarv från att räknas och används också för att bryta stintar och förbättra statistik. Exempel: 60 sekunder.", "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.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.", @@ -548,6 +556,7 @@ const TRANSLATIONS = { "guide.validation_3": "Ogiltiga långvarv över max-gränsen räknas inte som varv, men de kan bryta lap-basen så nästa giltiga varv börjar om korrekt.", "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.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.", @@ -770,6 +779,9 @@ const TRANSLATIONS = { "events.race_preset": "Preset", "events.race_preset_hint": "Quick start for track/class. Apply the preset and then fine tune manually.", "events.apply_preset": "Apply preset", + "events.save_preset": "Save club preset", + "events.delete_preset": "Delete club preset", + "events.preset_name": "Preset name", "events.preset_custom": "Custom / current values", "events.preset_short_technical": "Short technical track 16s", "events.preset_club_qualifying": "Club race qual + finals", @@ -777,7 +789,11 @@ const TRANSLATIONS = { "events.preset_endurance": "Endurance / team race", "events.tie_break_note": "Tie-break", "events.counted_rounds_label": "Counted rounds", + "events.tie_break_won": "Won against", + "events.tie_break_lost": "Lost against", "events.invalid_recent": "Latest hit invalid", + "timing.invalid_manual": "Manually invalidated", + "timing.invalidate_last_lap": "Invalidate last lap", "events.cars_per_final": "Drivers per final", "events.cars_per_final_hint": "Maximum number of drivers in each A/B/C final.", "events.final_legs": "Final heats per main", @@ -1121,6 +1137,7 @@ const TRANSLATIONS = { "guide.race_format_10": "Max lap time stops long false laps from counting and is also used to split stints and improve driver statistics. Example: 60 seconds.", "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.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.", @@ -1142,6 +1159,7 @@ const TRANSLATIONS = { "guide.validation_3": "Invalid long laps over the maximum threshold do not count as laps, but they can reset the lap base so the next valid lap starts correctly.", "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.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.", @@ -1360,6 +1378,9 @@ function seedDefaultData() { if (!state.settings.logoDataUrl) { state.settings.logoDataUrl = ""; } + if (!Array.isArray(state.settings.racePresets)) { + state.settings.racePresets = []; + } state.events = state.events.map((event) => normalizeEvent(event)); state.sessions = state.sessions.map((session) => normalizeSession(session)); @@ -1683,6 +1704,11 @@ function applyPersistedState(persisted) { pdfFooter: persisted.settings?.pdfFooter || state.settings.pdfFooter || "Generated by JMK RB Live Event", pdfTheme: persisted.settings?.pdfTheme || state.settings.pdfTheme || "classic", logoDataUrl: persisted.settings?.logoDataUrl || state.settings.logoDataUrl || "", + racePresets: Array.isArray(persisted.settings?.racePresets) + ? persisted.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name) + : Array.isArray(state.settings?.racePresets) + ? state.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name) + : [], }; } @@ -1715,8 +1741,16 @@ function normalizeRaceTeam(team) { }; } +function normalizeStoredRacePreset(preset) { + return { + id: String(preset?.id || uid("preset")), + name: String(preset?.name || "").trim(), + values: preset?.values && typeof preset.values === "object" ? { ...preset.values } : {}, + }; +} + function getRaceFormatPresets() { - return [ + const builtins = [ { id: "custom", label: t("events.preset_custom"), @@ -1823,6 +1857,18 @@ function getRaceFormatPresets() { }, }, ]; + const customPresets = Array.isArray(state.settings?.racePresets) + ? state.settings.racePresets + .map((preset) => normalizeStoredRacePreset(preset)) + .filter((preset) => preset.name) + .map((preset) => ({ + id: preset.id, + label: preset.name, + custom: true, + values: { ...preset.values }, + })) + : []; + return [...builtins, ...customPresets]; } function applyRaceFormatPreset(event, presetId) { @@ -1834,14 +1880,48 @@ function applyRaceFormatPreset(event, presetId) { Object.assign(event.raceConfig, preset.values, { presetId: preset.id }); } +function buildRaceFormatConfigFromForm(form, event) { + return { + presetId: String(form.get("presetId") || "custom").trim() || "custom", + qualifyingScoring: String(form.get("qualifyingScoring") || "points") === "best" ? "best" : "points", + qualifyingRounds: Math.max(1, Number(form.get("qualifyingRounds") || 3) || 3), + carsPerHeat: Math.max(2, Number(form.get("carsPerHeat") || 8) || 8), + qualDurationMin: Math.max(1, Number(form.get("qualDurationMin") || 5) || 5), + qualStartMode: normalizeStartMode(String(form.get("qualStartMode") || "staggered")), + qualSeedLapCount: Math.max(0, Number(form.get("qualSeedLapCount") || 2) || 0), + qualSeedMethod: ["best_sum", "average", "consecutive"].includes(String(form.get("qualSeedMethod") || "").toLowerCase()) + ? String(form.get("qualSeedMethod")).toLowerCase() + : "best_sum", + countedQualRounds: Math.max(1, Number(form.get("countedQualRounds") || 1) || 1), + qualifyingPointsTable: ["rank_low", "field_desc", "ifmar"].includes(String(form.get("qualifyingPointsTable") || "").toLowerCase()) + ? String(form.get("qualifyingPointsTable")).toLowerCase() + : "rank_low", + qualifyingTieBreak: ["rounds", "best_lap", "best_round"].includes(String(form.get("qualifyingTieBreak") || "").toLowerCase()) + ? String(form.get("qualifyingTieBreak")).toLowerCase() + : "rounds", + carsPerFinal: Math.max(2, Number(form.get("carsPerFinal") || 8) || 8), + finalLegs: Math.max(1, Number(form.get("finalLegs") || 1) || 1), + countedFinalLegs: Math.max(1, Number(form.get("countedFinalLegs") || 1) || 1), + finalDurationMin: Math.max(1, Number(form.get("finalDurationMin") || 5) || 5), + finalStartMode: normalizeStartMode(String(form.get("finalStartMode") || "position")), + followUpSec: Math.max(0, Number(form.get("followUpSec") || 0) || 0), + minLapMs: Math.max(0, Math.round((Number(form.get("minLapSec") || 0) || 0) * 1000)), + maxLapMs: Math.max(1000, Math.round((Number(form.get("maxLapSec") || 60) || 60) * 1000)), + bumpCount: Math.max(0, Number(form.get("bumpCount") || 0) || 0), + reserveBumpSlots: form.get("reserveBumpSlots") === "on", + driverIds: event.raceConfig.driverIds || [], + participantsConfigured: event.raceConfig.participantsConfigured !== false, + finalsSource: String(form.get("finalsSource") || "qualifying") === "practice" ? "practice" : "qualifying", + teams: getEventTeams(event), + }; +} + function normalizeEvent(event) { return { ...event, branding: normalizeBrandingConfig(event?.branding), raceConfig: { - presetId: getRaceFormatPresets().some((preset) => preset.id === String(event?.raceConfig?.presetId || "")) - ? String(event.raceConfig.presetId) - : "custom", + presetId: String(event?.raceConfig?.presetId || "custom").trim() || "custom", qualifyingScoring: event?.raceConfig?.qualifyingScoring === "best" ? "best" : "points", qualifyingRounds: Math.max(1, Number(event?.raceConfig?.qualifyingRounds || 3) || 3), carsPerHeat: Math.max(2, Number(event?.raceConfig?.carsPerHeat || 8) || 8), @@ -3150,6 +3230,7 @@ function renderEventManager(eventId) { const branding = normalizeBrandingConfig(event.branding); const editingSession = sessions.find((session) => session.id === selectedSessionEditId) || null; const racePresets = getRaceFormatPresets(); + const selectedPreset = racePresets.find((preset) => preset.id === event.raceConfig.presetId) || racePresets[0]; const gridSessions = event.mode === "race" ? sessions.filter((session) => normalizeStartMode(session.startMode) === "position") : []; if (selectedGridSessionId && !gridSessions.some((session) => session.id === selectedGridSessionId)) { selectedGridSessionId = ""; @@ -3430,9 +3511,13 @@ function renderEventManager(eventId) { ` )}
-   - ${t("events.race_preset_hint")} - + ${t("events.preset_name")} + +
+ + + +
${renderRaceFormatField( "events.qualifying_scoring", @@ -4101,41 +4186,7 @@ function renderEventManager(eventId) { document.getElementById("raceFormatForm")?.addEventListener("submit", (e) => { e.preventDefault(); const form = new FormData(e.currentTarget); - event.raceConfig = { - presetId: getRaceFormatPresets().some((preset) => preset.id === String(form.get("presetId") || "")) - ? String(form.get("presetId")) - : "custom", - qualifyingScoring: String(form.get("qualifyingScoring") || "points") === "best" ? "best" : "points", - qualifyingRounds: Math.max(1, Number(form.get("qualifyingRounds") || 3) || 3), - carsPerHeat: Math.max(2, Number(form.get("carsPerHeat") || 8) || 8), - qualDurationMin: Math.max(1, Number(form.get("qualDurationMin") || 5) || 5), - qualStartMode: normalizeStartMode(String(form.get("qualStartMode") || "staggered")), - qualSeedLapCount: Math.max(0, Number(form.get("qualSeedLapCount") || 2) || 0), - qualSeedMethod: ["best_sum", "average", "consecutive"].includes(String(form.get("qualSeedMethod") || "").toLowerCase()) - ? String(form.get("qualSeedMethod")).toLowerCase() - : "best_sum", - countedQualRounds: Math.max(1, Number(form.get("countedQualRounds") || 1) || 1), - qualifyingPointsTable: ["rank_low", "field_desc", "ifmar"].includes(String(form.get("qualifyingPointsTable") || "").toLowerCase()) - ? String(form.get("qualifyingPointsTable")).toLowerCase() - : "rank_low", - qualifyingTieBreak: ["rounds", "best_lap", "best_round"].includes(String(form.get("qualifyingTieBreak") || "").toLowerCase()) - ? String(form.get("qualifyingTieBreak")).toLowerCase() - : "rounds", - carsPerFinal: Math.max(2, Number(form.get("carsPerFinal") || 8) || 8), - finalLegs: Math.max(1, Number(form.get("finalLegs") || 1) || 1), - countedFinalLegs: Math.max(1, Number(form.get("countedFinalLegs") || 1) || 1), - finalDurationMin: Math.max(1, Number(form.get("finalDurationMin") || 5) || 5), - finalStartMode: normalizeStartMode(String(form.get("finalStartMode") || "position")), - followUpSec: Math.max(0, Number(form.get("followUpSec") || 0) || 0), - minLapMs: Math.max(0, Math.round((Number(form.get("minLapSec") || 0) || 0) * 1000)), - maxLapMs: Math.max(1000, Math.round((Number(form.get("maxLapSec") || 60) || 60) * 1000)), - bumpCount: Math.max(0, Number(form.get("bumpCount") || 0) || 0), - reserveBumpSlots: form.get("reserveBumpSlots") === "on", - driverIds: event.raceConfig.driverIds || [], - participantsConfigured: event.raceConfig.participantsConfigured !== false, - finalsSource: String(form.get("finalsSource") || "qualifying") === "practice" ? "practice" : "qualifying", - teams: getEventTeams(event), - }; + event.raceConfig = buildRaceFormatConfigFromForm(form, event); saveState(); renderEventManager(eventId); }); @@ -4151,6 +4202,70 @@ function renderEventManager(eventId) { renderEventManager(eventId); }); + document.getElementById("saveRacePreset")?.addEventListener("click", () => { + const formElement = document.getElementById("raceFormatForm"); + if (!(formElement instanceof HTMLFormElement)) { + return; + } + const form = new FormData(formElement); + const presetName = String(form.get("presetName") || "").trim(); + if (!presetName) { + return; + } + const config = buildRaceFormatConfigFromForm(form, event); + const selectedPresetId = String(form.get("presetId") || "custom"); + const existingCustomPreset = (state.settings.racePresets || []).find((preset) => preset.id === selectedPresetId); + const presetId = existingCustomPreset ? existingCustomPreset.id : uid("preset"); + const storedPreset = normalizeStoredRacePreset({ + id: presetId, + name: presetName, + values: { + qualifyingScoring: config.qualifyingScoring, + qualifyingRounds: config.qualifyingRounds, + carsPerHeat: config.carsPerHeat, + qualDurationMin: config.qualDurationMin, + qualStartMode: config.qualStartMode, + qualSeedLapCount: config.qualSeedLapCount, + qualSeedMethod: config.qualSeedMethod, + countedQualRounds: config.countedQualRounds, + qualifyingPointsTable: config.qualifyingPointsTable, + qualifyingTieBreak: config.qualifyingTieBreak, + carsPerFinal: config.carsPerFinal, + finalLegs: config.finalLegs, + countedFinalLegs: config.countedFinalLegs, + finalDurationMin: config.finalDurationMin, + finalStartMode: config.finalStartMode, + followUpSec: config.followUpSec, + minLapMs: config.minLapMs, + maxLapMs: config.maxLapMs, + bumpCount: config.bumpCount, + reserveBumpSlots: config.reserveBumpSlots, + finalsSource: config.finalsSource, + }, + }); + const otherPresets = (state.settings.racePresets || []).filter((preset) => preset.id !== presetId); + state.settings.racePresets = [...otherPresets, storedPreset]; + event.raceConfig = { ...config, presetId }; + saveState(); + renderEventManager(eventId); + }); + + document.getElementById("deleteRacePreset")?.addEventListener("click", () => { + const formElement = document.getElementById("raceFormatForm"); + if (!(formElement instanceof HTMLFormElement)) { + return; + } + const form = new FormData(formElement); + const presetId = String(form.get("presetId") || "custom"); + if (!(state.settings.racePresets || []).some((preset) => preset.id === presetId)) { + return; + } + state.settings.racePresets = (state.settings.racePresets || []).filter((preset) => preset.id !== presetId); + event.raceConfig.presetId = "custom"; + saveState(); + renderEventManager(eventId); + }); + document.getElementById("generateQualifying")?.addEventListener("click", () => { const created = generateQualifyingForRace(event); saveState(); @@ -4514,6 +4629,10 @@ function renderTiming() { applyCompetitorCorrection(active, selectedRow, { timeMsDelta: -1000 }); renderView(); }); + document.getElementById("corrInvalidateLast")?.addEventListener("click", () => { + invalidateCompetitorLastLap(active, selectedRow); + renderView(); + }); document.getElementById("corrReset")?.addEventListener("click", () => { applyCompetitorCorrection(active, selectedRow, { reset: true }); renderView(); @@ -4789,6 +4908,7 @@ function renderGuide() {
  • ${t("guide.race_format_10")}
  • ${t("guide.race_format_11")}
  • ${t("guide.race_format_12")}
  • +
  • ${t("guide.race_format_13")}
  • @@ -4838,6 +4958,7 @@ function renderGuide() {
  • ${t("guide.validation_3")}
  • ${t("guide.validation_4")}
  • ${t("guide.validation_5")}
  • +
  • ${t("guide.validation_6")}
  • @@ -5241,6 +5362,7 @@ function renderLeaderboardModal(session, row) { + @@ -5828,7 +5950,13 @@ function getVisiblePassings(result) { function getPassingValidationLabel(passing) { if (passing?.validLap === false) { - return passing.invalidReason === "below_min" ? t("timing.invalid_short") : t("timing.invalid_long"); + if (passing.invalidReason === "below_min") { + return t("timing.invalid_short"); + } + if (passing.invalidReason === "manual_invalid") { + return t("timing.invalid_manual"); + } + return t("timing.invalid_long"); } return t("timing.valid_passing"); } @@ -5861,6 +5989,35 @@ function applyCompetitorCorrection(session, row, options = {}) { saveState(); } +function invalidateCompetitorLastLap(session, row) { + const result = ensureSessionResult(session.id); + const entry = result.competitors[row.key]; + if (!entry) { + return false; + } + + const passings = getCompetitorPassings(session, row, { includeInvalid: true }); + const target = [...passings].reverse().find((passing) => isCountedPassing(passing)); + if (!target) { + return false; + } + + target.validLap = false; + target.invalidReason = "manual_invalid"; + + 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; + saveState(); + return true; +} + function getSessionTiming(session, nowTs = Date.now()) { const targetMs = getSessionTargetMs(session); const startedAt = Number(session?.startedAt || 0); @@ -6603,6 +6760,16 @@ function buildQualifyingTieBreakNote(row, tieBreak) { return `${t("events.tie_break_note")}: ${t("events.counted_rounds_label")} • ${(row.ranks || []).join(" / ") || "-"}`; } +function hasQualifyingPrimaryTie(left, right, scoringMode) { + if (!left || !right) { + return false; + } + if (scoringMode === "points") { + return left.totalScore === right.totalScore; + } + return left.bestRank === right.bestRank; +} + function getCompetitorElapsedMs(session, row) { const startTs = Number(row?.startTimestamp || session?.startedAt || 0); if (!startTs || !row?.lastTimestamp) { @@ -6847,10 +7014,30 @@ function buildQualifyingStandings(event) { return a.bestRoundMetric - b.bestRoundMetric; }); + rows.forEach((row, index) => { + row.tieBreakWonAgainst = ""; + row.tieBreakLostAgainst = ""; + if (index === 0) { + return; + } + const previous = rows[index - 1]; + if (!hasQualifyingPrimaryTie(previous, row, scoringMode)) { + return; + } + previous.tieBreakWonAgainst = previous.tieBreakWonAgainst || row.driverName || t("common.unknown_driver"); + row.tieBreakLostAgainst = row.tieBreakLostAgainst || previous.driverName || t("common.unknown_driver"); + }); + return rows.map((row, index) => ({ ...row, rank: index + 1, - scoreNote: buildQualifyingTieBreakNote(row, tieBreak), + scoreNote: [ + buildQualifyingTieBreakNote(row, tieBreak), + row.tieBreakWonAgainst ? `${t("events.tie_break_won")}: ${row.tieBreakWonAgainst}` : "", + row.tieBreakLostAgainst ? `${t("events.tie_break_lost")}: ${row.tieBreakLostAgainst}` : "", + ] + .filter(Boolean) + .join(" • "), })); }