Finish qualifying scoring, guide and schedule drift docs
This commit is contained in:
362
src/app.js
362
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() {
|
||||
<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],
|
||||
|
||||
Reference in New Issue
Block a user