Finish qualifying scoring, guide and schedule drift docs

This commit is contained in:
larssand
2026-03-15 19:31:32 +01:00
parent b9e8aa024b
commit 3a73f72e09
2 changed files with 367 additions and 31 deletions

View File

@@ -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() {
<strong>${state.settings.audioEnabled ? audioProfile : t("settings.passing_sound_off")}</strong>
<small>${state.settings.finishVoiceEnabled ? t("settings.finish_voice") : "-"}</small>
</article>
<article class="dashboard-live-card">
<span>${t("dashboard.schedule_drift")}</span>
<strong>${
schedule
? `${schedule.driftMs === 0 ? t("dashboard.on_time") : schedule.driftMs < 0 ? t("dashboard.ahead") : t("dashboard.behind")} ${formatLap(Math.abs(schedule.driftMs))}`
: "-"
}</strong>
<small>${
schedule
? `${t("dashboard.schedule_plan")}: ${formatLap(schedule.plannedMs)}${t("dashboard.schedule_actual")}: ${formatLap(schedule.actualMs)}`
: "-"
}</small>
</article>
</div>
<div class="actions">
<button id="goEvents" class="btn btn-primary">${t("dashboard.create_event")}</button>
@@ -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 `
<article class="stat-card">
@@ -2905,6 +3030,11 @@ function renderEventManager(eventId) {
<option value="staggered">${t("events.start_mode_staggered")}</option>
</select>
<input type="number" min="0" name="seedBestLapCount" placeholder="${t("events.seed_best_laps")}" />
<select name="seedMethod">
<option value="best_sum">${t("events.seed_method_best_sum")}</option>
<option value="average">${t("events.seed_method_average")}</option>
<option value="consecutive">${t("events.seed_method_consecutive")}</option>
</select>
<input type="number" min="0" step="1" name="staggerGapSec" placeholder="${t("events.stagger_gap_sec")}" />
<input type="number" min="1" name="maxCars" placeholder="${t("events.max_cars_placeholder")}" />
<button class="btn btn-primary" type="submit">${t("events.add_session")}</button>
@@ -3171,11 +3301,43 @@ function renderEventManager(eventId) {
<option value="staggered" ${event.raceConfig.qualStartMode === "staggered" ? "selected" : ""}>${t("events.start_mode_staggered")}</option>
</select>`
)}
${renderRaceFormatField(
"events.qual_seed_laps",
"events.qual_seed_laps_hint",
`<input type="number" min="0" name="qualSeedLapCount" value="${event.raceConfig.qualSeedLapCount}" />`
)}
${renderRaceFormatField(
"events.qual_seed_method",
"events.qual_seed_method_hint",
`<select name="qualSeedMethod">
<option value="best_sum" ${event.raceConfig.qualSeedMethod === "best_sum" ? "selected" : ""}>${t("events.seed_method_best_sum")}</option>
<option value="average" ${event.raceConfig.qualSeedMethod === "average" ? "selected" : ""}>${t("events.seed_method_average")}</option>
<option value="consecutive" ${event.raceConfig.qualSeedMethod === "consecutive" ? "selected" : ""}>${t("events.seed_method_consecutive")}</option>
</select>`
)}
${renderRaceFormatField(
"events.counted_qual_rounds",
"events.counted_qual_rounds_hint",
`<input type="number" min="1" name="countedQualRounds" value="${event.raceConfig.countedQualRounds}" />`
)}
${renderRaceFormatField(
"events.qual_points_table",
"events.qual_points_table_hint",
`<select name="qualifyingPointsTable">
<option value="rank_low" ${event.raceConfig.qualifyingPointsTable === "rank_low" ? "selected" : ""}>${t("events.qual_points_rank")}</option>
<option value="field_desc" ${event.raceConfig.qualifyingPointsTable === "field_desc" ? "selected" : ""}>${t("events.qual_points_desc")}</option>
<option value="ifmar" ${event.raceConfig.qualifyingPointsTable === "ifmar" ? "selected" : ""}>${t("events.qual_points_ifmar")}</option>
</select>`
)}
${renderRaceFormatField(
"events.qual_tie_break",
"events.qual_tie_break_hint",
`<select name="qualifyingTieBreak">
<option value="rounds" ${event.raceConfig.qualifyingTieBreak === "rounds" ? "selected" : ""}>${t("events.qual_tie_break_rounds")}</option>
<option value="best_lap" ${event.raceConfig.qualifyingTieBreak === "best_lap" ? "selected" : ""}>${t("events.qual_tie_break_best_lap")}</option>
<option value="best_round" ${event.raceConfig.qualifyingTieBreak === "best_round" ? "selected" : ""}>${t("events.qual_tie_break_best_round")}</option>
</select>`
)}
${renderRaceFormatField(
"events.cars_per_final",
"events.cars_per_final_hint",
@@ -3391,6 +3553,11 @@ function renderEventManager(eventId) {
<option value="staggered" ${normalizeStartMode(editingSession.startMode) === "staggered" ? "selected" : ""}>${t("events.start_mode_staggered")}</option>
</select>
<input name="seedBestLapCount" type="number" min="0" step="1" value="${editingSession.seedBestLapCount || 0}" />
<select name="seedMethod">
<option value="best_sum" ${editingSession.seedMethod === "best_sum" ? "selected" : ""}>${t("events.seed_method_best_sum")}</option>
<option value="average" ${editingSession.seedMethod === "average" ? "selected" : ""}>${t("events.seed_method_average")}</option>
<option value="consecutive" ${editingSession.seedMethod === "consecutive" ? "selected" : ""}>${t("events.seed_method_consecutive")}</option>
</select>
<input name="staggerGapSec" type="number" min="0" step="1" value="${editingSession.staggerGapSec || 0}" />
<p class="form-error" id="sessionEditError" hidden></p>
<div class="actions-inline">
@@ -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() {
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("guide.qualifying_title")}</h3></div>
<div class="panel-body">
<ul>
<li>${t("guide.qualifying_1")}</li>
<li>${t("guide.qualifying_2")}</li>
<li>${t("guide.qualifying_3")}</li>
<li>${t("guide.qualifying_4")}</li>
<li>${t("guide.qualifying_5")}</li>
</ul>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("guide.dashboard_title")}</h3></div>
<div class="panel-body">
<ul>
<li>${t("guide.dashboard_1")}</li>
<li>${t("guide.dashboard_2")}</li>
<li>${t("guide.dashboard_3")}</li>
</ul>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("guide.host_title")}</h3></div>
<div class="panel-body">
@@ -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],