From 3a73f72e09d28dbe7c5aec4fb2589e02ba39bd85 Mon Sep 17 00:00:00 2001 From: larssand Date: Sun, 15 Mar 2026 19:31:32 +0100 Subject: [PATCH] Finish qualifying scoring, guide and schedule drift docs --- README.md | 36 +++++- src/app.js | 362 ++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 367 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 865a51d..14a5d4e 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he - beskrivningar direkt i alla fält under `Raceformat` - `Follow-up tid` efter ordinarie racetid innan heatet stängs - `Min varvtid` och `Max varvtid` för att filtrera shortcuts/felträffar och styra bättre statistik/stintlogik + - seedmetoder för practice/kval: bästa N varv som summa, snitt eller konsekutiva varv + - valbar kval-poängtabell och tie-break-regler + - schemaavvikelse på `Översikt` mellan planerad och faktisk körtid - sessionstyp `Free Practice` för löpande varvtider utan seedning - auto-generering av kvalheat från practice-ranking eller klasslista - reseeding av kommande kvalheat från aktuell ranking @@ -115,6 +118,37 @@ Praktiskt exempel: - `Nollställ korrigering` - korrigeringarna uppdaterar leaderboard och resultat direkt +### Best laps / average / consecutive +- Ställs per session med `Seedmetod` när `Seed bästa varv` är större än `0` +- tillgängliga lägen: + - `Bästa N varv, summa` + - `Bästa N varv, snitt` + - `Bästa N konsekutiva varv` +- leaderboard och ranking visar rätt format beroende på läge: + - summa: `3/00:48.321` + - snitt: `3 avg 16.107` + - konsekutiva: `3 con 00:49.005` + +### Poängtabeller och tie-break +- Ställs i `Race Setup -> Hantera -> Raceformat` +- poängtabeller för kval: + - `Placeringstal (1,2,3...)` + - `Fallande efter fältstorlek` + - `10-9-8-7-6-5-4-3-2-1` +- tie-break för kvalranking: + - `Jämför räknade rundor` + - `Bästa enskilda varv` + - `Bästa runda / heatresultat` + +### Schemaavvikelse på Översikt +- `Översikt` visar nu om dagen ligger före eller efter schema +- planerad tid räknar: + - sessionens varaktighet + - plus follow-up tid +- faktisk tid räknar: + - verklig tid från start till stopp + - eller pågående körtid om sessionen fortfarande kör + ## Windows installation Kör i PowerShell i projektmappen. @@ -197,7 +231,7 @@ sudo ufw allow 8081/tcp ## Auto reload vid uppdatering - Servern bevakar `index.html`, `src/app.js` och `src/styles.css`. -- När du uppdaterar filer i `live_event` och sparar, laddar klienten om sidan automatiskt. +- När du uppdaterar filer i `Live_RC` och sparar, laddar klienten om sidan automatiskt. - Om backendkoden ändras, kör `npm restart`. ## Verifiera att SQLite sparar diff --git a/src/app.js b/src/app.js index bf623b7..77f14ae 100644 --- a/src/app.js +++ b/src/app.js @@ -69,6 +69,12 @@ const TRANSLATIONS = { "dashboard.decoder_feed": "Decoder-feed", "dashboard.backend_link": "Backend-länk", "dashboard.audio_profile": "Ljudprofil", + "dashboard.schedule_drift": "Schemaavvikelse", + "dashboard.schedule_plan": "Planerad tid", + "dashboard.schedule_actual": "Faktisk tid", + "dashboard.on_time": "I fas", + "dashboard.ahead": "Före schema", + "dashboard.behind": "Efter schema", "dashboard.live_note": "Snabb driftpanel för anslutning, overlay och ljud. Djupare konfig ligger kvar under Inställningar.", "session.none_yet": "Inga sessioner ännu.", "classes.create": "Skapa klass", @@ -125,6 +131,11 @@ const TRANSLATIONS = { "events.max_cars_placeholder": "Max bilar (valfritt)", "events.start_mode": "Starttyp", "events.seed_best_laps": "Seedning bästa varv", + "events.seed_method": "Seedmetod", + "events.seed_method_hint": "Hur bästa varv ska räknas när seedBestLapCount är större än 0.", + "events.seed_method_best_sum": "Bästa N varv, summa", + "events.seed_method_average": "Bästa N varv, snitt", + "events.seed_method_consecutive": "Bästa N konsekutiva varv", "events.stagger_gap_sec": "Stagger-gap (sek)", "events.session_settings": "Sessioninställningar", "events.edit_session": "Inställningar", @@ -146,8 +157,22 @@ const TRANSLATIONS = { "events.qual_duration_hint": "Längd per kvalheat i minuter.", "events.qual_start_mode": "Kval-start", "events.qual_start_mode_hint": "Mass, position eller staggered för kvalomgångarna.", + "events.qual_seed_laps": "Kval bästa varv", + "events.qual_seed_laps_hint": "Antal varv som används för ranking i varje kvalheat när seedmetod är aktiv.", + "events.qual_seed_method": "Kval seedmetod", + "events.qual_seed_method_hint": "Hur kvalheat räknar varv när bästa-varvsläget används.", "events.counted_qual_rounds": "Räknade kvalrundor", "events.counted_qual_rounds_hint": "Hur många av kvalrundorna som räknas i slutrankingen.", + "events.qual_points_table": "Poängtabell", + "events.qual_points_table_hint": "Välj hur poäng per kvalomgång delas ut när Kval-scoring använder poäng.", + "events.qual_points_rank": "Placeringstal (1,2,3...)", + "events.qual_points_desc": "Fallande efter fältstorlek", + "events.qual_points_ifmar": "10-9-8-7-6-5-4-3-2-1", + "events.qual_tie_break": "Tie-break", + "events.qual_tie_break_hint": "Välj vilken regel som avgör lika resultat i kvalrankingen.", + "events.qual_tie_break_rounds": "Jämför räknade rundor", + "events.qual_tie_break_best_lap": "Bästa enskilda varv", + "events.qual_tie_break_best_round": "Bästa runda / heatresultat", "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", @@ -510,6 +535,16 @@ 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.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.", + "guide.qualifying_3": "Kval-scoring kan kombinera poängläge med poängtabell: placeringstal, fallande efter fältstorlek eller IFMAR 10-9-8-7-6-5-4-3-2-1.", + "guide.qualifying_4": "Tie-break i kvalrankingen kan avgöras på räknade rundor, bästa enskilda varv eller bästa runda/heatresultat.", + "guide.qualifying_5": "Leaderboarden visar seedningsresultat i rätt format, till exempel 3/00:48.321, 3 avg 16.107 eller 3 con 00:49.005.", + "guide.dashboard_title": "Schemaavvikelse på Översikt", + "guide.dashboard_1": "Översikt visar nu skillnaden mellan planerad tid och faktisk körtid för alla startade sessioner.", + "guide.dashboard_2": "Planerad tid räknar sessionstid plus follow-up. Faktisk tid räknar verklig tid från start till stopp eller nuvarande tid om heatet fortfarande kör.", + "guide.dashboard_3": "Det gör det lättare att se om tävlingsdagen ligger före eller efter schema direkt från dashboarden.", "overlay.title": "Overlay", "overlay.subtitle": "Extern leaderboard-skärm", "overlay.no_active": "Ingen aktiv session vald.", @@ -615,6 +650,12 @@ const TRANSLATIONS = { "dashboard.decoder_feed": "Decoder feed", "dashboard.backend_link": "Backend link", "dashboard.audio_profile": "Audio profile", + "dashboard.schedule_drift": "Schedule drift", + "dashboard.schedule_plan": "Planned time", + "dashboard.schedule_actual": "Actual time", + "dashboard.on_time": "On time", + "dashboard.ahead": "Ahead of schedule", + "dashboard.behind": "Behind schedule", "dashboard.live_note": "Quick operations panel for connection, overlay and audio. Deeper configuration remains under Settings.", "session.none_yet": "No sessions yet.", "classes.create": "Create Class", @@ -671,6 +712,11 @@ const TRANSLATIONS = { "events.max_cars_placeholder": "Max cars (optional)", "events.start_mode": "Start mode", "events.seed_best_laps": "Best laps for seeding", + "events.seed_method": "Seed method", + "events.seed_method_hint": "How best laps should be evaluated when seedBestLapCount is greater than 0.", + "events.seed_method_best_sum": "Best N laps, total", + "events.seed_method_average": "Best N laps, average", + "events.seed_method_consecutive": "Best N consecutive laps", "events.stagger_gap_sec": "Stagger gap (sec)", "events.session_settings": "Session settings", "events.edit_session": "Settings", @@ -692,8 +738,22 @@ const TRANSLATIONS = { "events.qual_duration_hint": "Length of each qualifying heat in minutes.", "events.qual_start_mode": "Qualifying start", "events.qual_start_mode_hint": "Mass, position or staggered for qualifying rounds.", + "events.qual_seed_laps": "Qualifying best laps", + "events.qual_seed_laps_hint": "Number of laps used for ranking in each qualifying heat when seed mode is active.", + "events.qual_seed_method": "Qualifying seed method", + "events.qual_seed_method_hint": "How qualifying heats evaluate laps when best-lap mode is used.", "events.counted_qual_rounds": "Counted qualifying rounds", "events.counted_qual_rounds_hint": "How many qualifying rounds count toward the final ranking.", + "events.qual_points_table": "Points table", + "events.qual_points_table_hint": "Choose how each qualifying round awards points when Qualifying scoring uses points.", + "events.qual_points_rank": "Placement values (1,2,3...)", + "events.qual_points_desc": "Descending by field size", + "events.qual_points_ifmar": "10-9-8-7-6-5-4-3-2-1", + "events.qual_tie_break": "Tie-break", + "events.qual_tie_break_hint": "Choose which rule resolves equal results in qualifying standings.", + "events.qual_tie_break_rounds": "Compare counted rounds", + "events.qual_tie_break_best_lap": "Best single lap", + "events.qual_tie_break_best_round": "Best round / heat result", "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", @@ -1056,6 +1116,16 @@ 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.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.", + "guide.qualifying_3": "Qualifying scoring can combine points mode with a points table: placement values, descending by field size or IFMAR 10-9-8-7-6-5-4-3-2-1.", + "guide.qualifying_4": "Qualifying tie-break can now be resolved by counted rounds, best single lap or best round / heat result.", + "guide.qualifying_5": "The leaderboard now shows seeded results in the correct format, for example 3/00:48.321, 3 avg 16.107 or 3 con 00:49.005.", + "guide.dashboard_title": "Schedule drift on overview", + "guide.dashboard_1": "Overview now shows the difference between planned time and actual elapsed time for all started sessions.", + "guide.dashboard_2": "Planned time includes session duration plus follow-up. Actual time uses real time from start to stop, or the current time if the heat is still running.", + "guide.dashboard_3": "That makes it easier to see whether the race day is running ahead or behind schedule directly from the dashboard.", "overlay.title": "Overlay", "overlay.subtitle": "External leaderboard screen", "overlay.no_active": "No active session selected.", @@ -1596,6 +1666,9 @@ function normalizeSession(session) { startMode: session?.startMode || "mass", staggerGapSec: Number(session?.staggerGapSec || 5) || 5, seedBestLapCount: Math.max(0, Number(session?.seedBestLapCount || 0) || 0), + seedMethod: ["best_sum", "average", "consecutive"].includes(String(session?.seedMethod || "").toLowerCase()) + ? String(session.seedMethod).toLowerCase() + : "best_sum", followUpSec: Math.max(0, Number(session?.followUpSec || 0) || 0), followUpStartedAt: Number(session?.followUpStartedAt || 0) || null, driverIds: Array.isArray(session?.driverIds) ? session.driverIds : [], @@ -1626,7 +1699,17 @@ function normalizeEvent(event) { carsPerHeat: Math.max(2, Number(event?.raceConfig?.carsPerHeat || 8) || 8), qualDurationMin: Math.max(1, Number(event?.raceConfig?.qualDurationMin || 5) || 5), qualStartMode: normalizeStartMode(event?.raceConfig?.qualStartMode || "staggered"), + qualSeedLapCount: Math.max(0, Number(event?.raceConfig?.qualSeedLapCount || 2) || 2), + qualSeedMethod: ["best_sum", "average", "consecutive"].includes(String(event?.raceConfig?.qualSeedMethod || "").toLowerCase()) + ? String(event.raceConfig.qualSeedMethod).toLowerCase() + : "best_sum", countedQualRounds: Math.max(1, Number(event?.raceConfig?.countedQualRounds || 1) || 1), + qualifyingPointsTable: ["rank_low", "field_desc", "ifmar"].includes(String(event?.raceConfig?.qualifyingPointsTable || "").toLowerCase()) + ? String(event.raceConfig.qualifyingPointsTable).toLowerCase() + : "rank_low", + qualifyingTieBreak: ["rounds", "best_lap", "best_round"].includes(String(event?.raceConfig?.qualifyingTieBreak || "").toLowerCase()) + ? String(event.raceConfig.qualifyingTieBreak).toLowerCase() + : "rounds", carsPerFinal: Math.max(2, Number(event?.raceConfig?.carsPerFinal || 8) || 8), finalLegs: Math.max(1, Number(event?.raceConfig?.finalLegs || 1) || 1), countedFinalLegs: Math.max(1, Number(event?.raceConfig?.countedFinalLegs || 1) || 1), @@ -2134,6 +2217,7 @@ function handleSessionTimerTick() { function renderDashboard() { const active = getActiveSession(); + const schedule = getScheduleDriftSummary(); const totalPassings = Object.values(state.resultsBySession).reduce( (sum, x) => sum + (x.passings?.length || 0), 0 @@ -2194,6 +2278,19 @@ function renderDashboard() { ${state.settings.audioEnabled ? audioProfile : t("settings.passing_sound_off")} ${state.settings.finishVoiceEnabled ? t("settings.finish_voice") : "-"} +
+ ${t("dashboard.schedule_drift")} + ${ + schedule + ? `${schedule.driftMs === 0 ? t("dashboard.on_time") : schedule.driftMs < 0 ? t("dashboard.ahead") : t("dashboard.behind")} ${formatLap(Math.abs(schedule.driftMs))}` + : "-" + } + ${ + schedule + ? `${t("dashboard.schedule_plan")}: ${formatLap(schedule.plannedMs)} • ${t("dashboard.schedule_actual")}: ${formatLap(schedule.actualMs)}` + : "-" + } +
@@ -2241,6 +2338,34 @@ function renderDashboard() { }); } +function getScheduleDriftSummary() { + const scheduledSessions = state.sessions + .filter((session) => Number(session.startedAt || 0) > 0) + .sort((left, right) => Number(left.startedAt || 0) - Number(right.startedAt || 0)); + if (!scheduledSessions.length) { + return null; + } + const nowTs = Date.now(); + const plannedMs = scheduledSessions.reduce( + (sum, session) => sum + Math.max(1, Number(session.durationMin || 0) || 0) * 60000 + Math.max(0, Number(session.followUpSec || 0) || 0) * 1000, + 0 + ); + const actualMs = scheduledSessions.reduce((sum, session) => { + const startedAt = Number(session.startedAt || 0) || 0; + const endedAt = Number(session.endedAt || 0) || (session.status === "finished" ? nowTs : 0); + if (!startedAt) { + return sum; + } + const effectiveEnd = endedAt || nowTs; + return sum + Math.max(0, effectiveEnd - startedAt); + }, 0); + return { + plannedMs, + actualMs, + driftMs: actualMs - plannedMs, + }; +} + function statCard(label, value, note) { return `
@@ -2905,6 +3030,11 @@ function renderEventManager(eventId) { + @@ -3171,11 +3301,43 @@ function renderEventManager(eventId) { ` )} + ${renderRaceFormatField( + "events.qual_seed_laps", + "events.qual_seed_laps_hint", + `` + )} + ${renderRaceFormatField( + "events.qual_seed_method", + "events.qual_seed_method_hint", + `` + )} ${renderRaceFormatField( "events.counted_qual_rounds", "events.counted_qual_rounds_hint", `` )} + ${renderRaceFormatField( + "events.qual_points_table", + "events.qual_points_table_hint", + `` + )} + ${renderRaceFormatField( + "events.qual_tie_break", + "events.qual_tie_break_hint", + `` + )} ${renderRaceFormatField( "events.cars_per_final", "events.cars_per_final_hint", @@ -3391,6 +3553,11 @@ function renderEventManager(eventId) { +
@@ -3458,6 +3625,7 @@ function renderEventManager(eventId) { followUpSec: Math.max(0, Number(form.get("followUpSec") || 0) || 0), startMode: String(form.get("startMode") || "mass"), seedBestLapCount: Math.max(0, Number(form.get("seedBestLapCount") || 0) || 0), + seedMethod: String(form.get("seedMethod") || "best_sum"), staggerGapSec: Math.max(0, Number(form.get("staggerGapSec") || 0) || 0), maxCars: Number(form.get("maxCars") || 0) || null, mode: event.mode, @@ -3561,6 +3729,9 @@ function renderEventManager(eventId) { editingSession.followUpSec = Math.max(0, Number(form.get("followUpSec") || 0) || 0); editingSession.startMode = normalizeStartMode(String(form.get("startMode") || editingSession.startMode || "mass")); editingSession.seedBestLapCount = Math.max(0, Number(form.get("seedBestLapCount") || 0) || 0); + editingSession.seedMethod = ["best_sum", "average", "consecutive"].includes(String(form.get("seedMethod") || "").toLowerCase()) + ? String(form.get("seedMethod")).toLowerCase() + : "best_sum"; editingSession.staggerGapSec = Math.max(0, Number(form.get("staggerGapSec") || 0) || 0); selectedSessionEditId = null; saveState(); @@ -3771,7 +3942,17 @@ function renderEventManager(eventId) { 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), @@ -4480,6 +4661,30 @@ function renderGuide() {
+
+

${t("guide.qualifying_title")}

+
+
    +
  • ${t("guide.qualifying_1")}
  • +
  • ${t("guide.qualifying_2")}
  • +
  • ${t("guide.qualifying_3")}
  • +
  • ${t("guide.qualifying_4")}
  • +
  • ${t("guide.qualifying_5")}
  • +
+
+
+ +
+

${t("guide.dashboard_title")}

+
+
    +
  • ${t("guide.dashboard_1")}
  • +
  • ${t("guide.dashboard_2")}
  • +
  • ${t("guide.dashboard_3")}
  • +
+
+
+

${t("guide.host_title")}

@@ -5919,13 +6124,13 @@ function buildLeaderboard(session) { isRollingPractice ? bestLapMs || lastLapMs || Number.MAX_SAFE_INTEGER : useSeedRanking && seedMetric - ? seedMetric.totalMs + ? seedMetric.comparableMs : totalElapsedMs, resultDisplay: isRollingPractice ? formatLap(bestLapMs || lastLapMs) : useSeedRanking && seedMetric - ? `${seedMetric.lapCount}/${formatRaceClock(seedMetric.totalMs)}` + ? formatSeedMetric(seedMetric) : `${Math.max(0, Number(row.laps || 0) + manualLapAdjustment)}/${formatRaceClock(totalElapsedMs)}`, }; }); @@ -5941,8 +6146,8 @@ function buildLeaderboard(session) { return (b.lastTimestamp || 0) - (a.lastTimestamp || 0); } if (useSeedRanking) { - if (a.seedMetric && b.seedMetric && a.seedMetric.totalMs !== b.seedMetric.totalMs) { - return a.seedMetric.totalMs - b.seedMetric.totalMs; + if (a.seedMetric && b.seedMetric && a.seedMetric.comparableMs !== b.seedMetric.comparableMs) { + return a.seedMetric.comparableMs - b.seedMetric.comparableMs; } if (a.seedMetric && !b.seedMetric) { return -1; @@ -6011,7 +6216,7 @@ function formatLeaderboardGap(row, referenceRow, options = {}) { } if (options.useSeedRanking) { if (referenceRow.seedMetric && row.seedMetric) { - const seedGap = Math.max(0, row.seedMetric.totalMs - referenceRow.seedMetric.totalMs); + const seedGap = Math.max(0, row.seedMetric.comparableMs - referenceRow.seedMetric.comparableMs); return `+${(seedGap / 1000).toFixed(3)}s`; } return `+${Math.max(0, (referenceRow.laps || 0) - (row.laps || 0))}L`; @@ -6150,6 +6355,55 @@ function formatRaceClock(ms) { return `${m}:${s}.${centiseconds}:${millis}`; } +function getSeedMethodLabel(method) { + const normalized = ["best_sum", "average", "consecutive"].includes(String(method || "").toLowerCase()) + ? String(method).toLowerCase() + : "best_sum"; + return t(`events.seed_method_${normalized}`); +} + +function formatSeedMetric(metric) { + if (!metric) { + return "-"; + } + if (metric.method === "average") { + return `${metric.lapCount} avg ${formatLap(metric.averageMs)}`; + } + if (metric.method === "consecutive") { + return `${metric.lapCount} con ${formatRaceClock(metric.totalMs)}`; + } + return `${metric.lapCount}/${formatRaceClock(metric.totalMs)}`; +} + +function getQualifyingPointsValue(place, fieldSize, tableType) { + const normalized = ["rank_low", "field_desc", "ifmar"].includes(String(tableType || "").toLowerCase()) + ? String(tableType).toLowerCase() + : "rank_low"; + if (normalized === "field_desc") { + return Math.max(1, Number(fieldSize || 0) - place + 1); + } + if (normalized === "ifmar") { + const scale = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]; + return scale[place - 1] ?? 0; + } + return place; +} + +function isHighPointsTable(tableType) { + return ["field_desc", "ifmar"].includes(String(tableType || "").toLowerCase()); +} + +function compareNumberSet(left, right, highWins = false) { + for (let i = 0; i < Math.max(left.length, right.length); i += 1) { + const leftValue = left[i] ?? (highWins ? -Infinity : Infinity); + const rightValue = right[i] ?? (highWins ? -Infinity : Infinity); + if (leftValue !== rightValue) { + return highWins ? rightValue - leftValue : leftValue - rightValue; + } + } + return 0; +} + function getCompetitorElapsedMs(session, row) { const startTs = Number(row?.startTimestamp || session?.startedAt || 0); if (!startTs || !row?.lastTimestamp) { @@ -6183,19 +6437,42 @@ function getCompetitorSeedMetric(session, row) { return null; } + const method = ["best_sum", "average", "consecutive"].includes(String(session?.seedMethod || "").toLowerCase()) + ? String(session.seedMethod).toLowerCase() + : "best_sum"; const laps = getCompetitorPassings(session, row) .map((passing) => Number(passing.lapMs || 0)) - .filter((lapMs) => lapMs > 500) - .sort((a, b) => a - b); + .filter((lapMs) => lapMs > 500); if (laps.length < lapCount) { return null; } - const selected = laps.slice(0, lapCount); + let selected = []; + if (method === "consecutive") { + let bestWindow = null; + for (let index = 0; index <= laps.length - lapCount; index += 1) { + const window = laps.slice(index, index + lapCount); + const totalMs = window.reduce((sum, lapMs) => sum + lapMs, 0); + if (!bestWindow || totalMs < bestWindow.totalMs) { + bestWindow = { laps: window, totalMs }; + } + } + if (!bestWindow) { + return null; + } + selected = bestWindow.laps; + } else { + selected = [...laps].sort((a, b) => a - b).slice(0, lapCount); + } + const totalMs = selected.reduce((sum, lapMs) => sum + lapMs, 0); + const averageMs = totalMs / lapCount; return { lapCount, - totalMs: selected.reduce((sum, lapMs) => sum + lapMs, 0), + method, + totalMs, + averageMs, + comparableMs: method === "average" ? averageMs : totalMs, laps: selected, }; } @@ -6248,7 +6525,7 @@ function buildPracticeStandings(event) { buildLeaderboard(session).forEach((row) => { const key = row.driverId || row.key; const seedMetric = getCompetitorSeedMetric(session, row); - const comparableMs = seedMetric?.totalMs ?? row.bestLapMs ?? row.totalElapsedMs; + const comparableMs = seedMetric?.comparableMs ?? row.bestLapMs ?? row.totalElapsedMs; const current = competitorMap.get(key); if (!current || comparableMs < current.comparableMs) { competitorMap.set(key, { @@ -6256,9 +6533,7 @@ function buildPracticeStandings(event) { driverId: row.driverId, driverName: row.driverName, comparableMs, - resultDisplay: seedMetric - ? `${seedMetric.lapCount}/${formatRaceClock(seedMetric.totalMs)}` - : `${row.laps}/${formatRaceClock(row.totalElapsedMs)}`, + resultDisplay: seedMetric ? formatSeedMetric(seedMetric) : `${row.laps}/${formatRaceClock(row.totalElapsedMs)}`, sourceSessionName: session.name, }); } @@ -6276,6 +6551,9 @@ function buildQualifyingStandings(event) { const sessions = getSessionsForEvent(event.id).filter((session) => session.type === "qualification"); const scoringMode = event.raceConfig?.qualifyingScoring || "points"; const countedRounds = Math.max(1, Number(event.raceConfig?.countedQualRounds || 1) || 1); + const pointsTable = event.raceConfig?.qualifyingPointsTable || "rank_low"; + const highPointsWin = isHighPointsTable(pointsTable); + const tieBreak = event.raceConfig?.qualifyingTieBreak || "rounds"; const competitorMap = new Map(); sessions.forEach((session) => { @@ -6290,13 +6568,17 @@ function buildQualifyingStandings(event) { driverName: row.driverName, points: [], ranks: [], - bestRoundMs: [], + roundMetrics: [], + bestLaps: [], }); } const entry = competitorMap.get(key); - entry.points.push(index + 1); + entry.points.push(getQualifyingPointsValue(index + 1, entrantCount, pointsTable)); entry.ranks.push(index + 1); - entry.bestRoundMs.push(row.totalElapsedMs); + entry.roundMetrics.push(row.comparisonMs); + if (Number.isFinite(row.bestLapMs)) { + entry.bestLaps.push(row.bestLapMs); + } entry.bestResultDisplay = row.resultDisplay; entry.lastSessionName = session.name; entry.sessionCount = (entry.sessionCount || 0) + 1; @@ -6305,45 +6587,60 @@ function buildQualifyingStandings(event) { }); const rows = [...competitorMap.values()].map((entry) => { - const sortedPoints = [...entry.points].sort((a, b) => a - b); + const sortedPoints = [...entry.points].sort((a, b) => (highPointsWin ? b - a : a - b)); const counted = sortedPoints.slice(0, countedRounds); const totalScore = counted.reduce((sum, value) => sum + value, 0); const bestRank = Math.min(...entry.ranks); - const bestElapsed = Math.min(...entry.bestRoundMs); + const bestRoundMetric = Math.min(...entry.roundMetrics); + const bestSingleLapMs = entry.bestLaps.length ? Math.min(...entry.bestLaps) : Number.MAX_SAFE_INTEGER; return { key: entry.key, driverId: entry.driverId, driverName: entry.driverName, ranks: [...entry.ranks].sort((a, b) => a - b), + countedPoints: counted, totalScore, bestRank, - bestElapsed, + bestRoundMetric, + bestSingleLapMs, score: scoringMode === "points" ? `${totalScore} (${counted.join("+")})` - : `${bestRank} / ${formatRaceClock(bestElapsed)}`, + : `${bestRank} / ${formatRaceClock(bestRoundMetric)}`, }; }); rows.sort((a, b) => { if (scoringMode === "points") { if (a.totalScore !== b.totalScore) { - return a.totalScore - b.totalScore; + return highPointsWin ? b.totalScore - a.totalScore : a.totalScore - b.totalScore; } - for (let i = 0; i < Math.max(a.ranks.length, b.ranks.length); i += 1) { - const left = a.ranks[i] ?? 999; - const right = b.ranks[i] ?? 999; - if (left !== right) { - return left - right; - } + if (tieBreak === "best_lap" && a.bestSingleLapMs !== b.bestSingleLapMs) { + return a.bestSingleLapMs - b.bestSingleLapMs; } - return a.bestElapsed - b.bestElapsed; + if (tieBreak === "best_round" && a.bestRoundMetric !== b.bestRoundMetric) { + return a.bestRoundMetric - b.bestRoundMetric; + } + const pointDiff = compareNumberSet(a.countedPoints, b.countedPoints, highPointsWin); + if (pointDiff !== 0) { + return pointDiff; + } + return a.bestRoundMetric - b.bestRoundMetric; } if (a.bestRank !== b.bestRank) { return a.bestRank - b.bestRank; } - return a.bestElapsed - b.bestElapsed; + if (tieBreak === "rounds") { + const rankDiff = compareNumberSet(a.ranks, b.ranks, false); + if (rankDiff !== 0) { + return rankDiff; + } + } + if (tieBreak === "best_lap" && a.bestSingleLapMs !== b.bestSingleLapMs) { + return a.bestSingleLapMs - b.bestSingleLapMs; + } + return a.bestRoundMetric - b.bestRoundMetric; }); return rows.map((row, index) => ({ @@ -6713,6 +7010,10 @@ function generateQualifyingForRace(event) { const carsPerHeat = Math.max(2, Number(event.raceConfig?.carsPerHeat || 8) || 8); const qualDurationMin = Math.max(1, Number(event.raceConfig?.qualDurationMin || 5) || 5); const qualStartMode = normalizeStartMode(event.raceConfig?.qualStartMode || "staggered"); + const qualSeedLapCount = Math.max(0, Number(event.raceConfig?.qualSeedLapCount || 2) || 0); + const qualSeedMethod = ["best_sum", "average", "consecutive"].includes(String(event.raceConfig?.qualSeedMethod || "").toLowerCase()) + ? String(event.raceConfig.qualSeedMethod).toLowerCase() + : "best_sum"; const followUpSec = Math.max(0, Number(event.raceConfig?.followUpSec || 0) || 0); const heats = chunkArray(fallbackRows, carsPerHeat); let created = 0; @@ -6737,7 +7038,8 @@ function generateQualifyingForRace(event) { followUpSec, followUpStartedAt: null, startMode: qualStartMode, - seedBestLapCount: 2, + seedBestLapCount: qualSeedLapCount, + seedMethod: qualSeedMethod, staggerGapSec: 3, driverIds, manualGridIds: [...driverIds],