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) { ` )}