Add race format presets, tie-break notes and invalid lap markers

This commit is contained in:
larssand
2026-03-15 20:35:47 +01:00
parent 3a73f72e09
commit 57a72e5126
3 changed files with 255 additions and 5 deletions

View File

@@ -173,6 +173,17 @@ const TRANSLATIONS = {
"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.race_preset": "Preset",
"events.race_preset_hint": "Snabbstart för bana/klass. Applicera preset och finjustera sedan manuellt.",
"events.apply_preset": "Applicera preset",
"events.preset_custom": "Custom / nuvarande värden",
"events.preset_short_technical": "Kort teknisk bana 16s",
"events.preset_club_qualifying": "Klubbrace kval + final",
"events.preset_ifmar": "IFMAR-stil kval/final",
"events.preset_endurance": "Endurance / lagrace",
"events.tie_break_note": "Tie-break",
"events.counted_rounds_label": "Räknade rundor",
"events.invalid_recent": "Senaste träff ogiltig",
"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",
@@ -514,6 +525,8 @@ const TRANSLATIONS = {
"guide.race_format_8": "Follow-up tid ger en extra uppsamlingsperiod efter ordinarie racetid innan heatet verkligen stängs.",
"guide.race_format_9": "Min varvtid filtrerar bort shortcuts och felträffar. Exempel: på en 16-sekundersbana kan du sätta 11 sekunder som min-gräns.",
"guide.race_format_10": "Max varvtid stoppar långa felvarv från att räknas och används också för att bryta stintar och förbättra statistik. Exempel: 60 sekunder.",
"guide.race_format_11": "Preset låter dig snabbt fylla raceformat med vettiga grundvärden för kort teknisk bana, klubbrace, IFMAR-liknande upplägg eller endurance.",
"guide.race_format_12": "Du kan applicera preset och sedan justera enskilda fält manuellt innan du sparar raceformatet.",
"guide.free_practice_title": "Free Practice",
"guide.free_practice_1": "Använd sessionstypen fri träning när du bara vill visa löpande varvtider.",
"guide.free_practice_2": "Free Practice påverkar inte seedning till kval eller finaler.",
@@ -754,6 +767,17 @@ const TRANSLATIONS = {
"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.race_preset": "Preset",
"events.race_preset_hint": "Quick start for track/class. Apply the preset and then fine tune manually.",
"events.apply_preset": "Apply preset",
"events.preset_custom": "Custom / current values",
"events.preset_short_technical": "Short technical track 16s",
"events.preset_club_qualifying": "Club race qual + finals",
"events.preset_ifmar": "IFMAR-style qual/finals",
"events.preset_endurance": "Endurance / team race",
"events.tie_break_note": "Tie-break",
"events.counted_rounds_label": "Counted rounds",
"events.invalid_recent": "Latest hit invalid",
"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",
@@ -1095,6 +1119,8 @@ const TRANSLATIONS = {
"guide.race_format_8": "Follow-up time adds an extra collection period after the scheduled race time before the heat is actually closed.",
"guide.race_format_9": "Min lap time filters out shortcuts and false hits. Example: on a 16-second track you can set 11 seconds as the minimum.",
"guide.race_format_10": "Max lap time stops long false laps from counting and is also used to split stints and improve driver statistics. Example: 60 seconds.",
"guide.race_format_11": "Preset lets you quickly fill the race format with sensible defaults for a short technical track, club race, IFMAR-like setup or endurance.",
"guide.race_format_12": "You can apply a preset and then adjust individual fields manually before saving the race format.",
"guide.free_practice_title": "Free Practice",
"guide.free_practice_1": "Use the free practice session type when you only want to show live lap times.",
"guide.free_practice_2": "Free Practice does not affect seeding for qualifying or finals.",
@@ -1689,11 +1715,133 @@ function normalizeRaceTeam(team) {
};
}
function getRaceFormatPresets() {
return [
{
id: "custom",
label: t("events.preset_custom"),
values: {},
},
{
id: "short_technical",
label: t("events.preset_short_technical"),
values: {
qualifyingScoring: "points",
qualifyingRounds: 3,
carsPerHeat: 6,
qualDurationMin: 5,
qualStartMode: "staggered",
qualSeedLapCount: 3,
qualSeedMethod: "best_sum",
countedQualRounds: 2,
qualifyingPointsTable: "rank_low",
qualifyingTieBreak: "best_lap",
carsPerFinal: 8,
finalLegs: 3,
countedFinalLegs: 2,
finalDurationMin: 5,
finalStartMode: "position",
followUpSec: 10,
minLapMs: 11000,
maxLapMs: 60000,
bumpCount: 0,
},
},
{
id: "club_qualifying",
label: t("events.preset_club_qualifying"),
values: {
qualifyingScoring: "points",
qualifyingRounds: 4,
carsPerHeat: 8,
qualDurationMin: 5,
qualStartMode: "staggered",
qualSeedLapCount: 3,
qualSeedMethod: "best_sum",
countedQualRounds: 2,
qualifyingPointsTable: "rank_low",
qualifyingTieBreak: "rounds",
carsPerFinal: 8,
finalLegs: 3,
countedFinalLegs: 2,
finalDurationMin: 5,
finalStartMode: "position",
followUpSec: 15,
minLapMs: 12000,
maxLapMs: 60000,
bumpCount: 0,
},
},
{
id: "ifmar",
label: t("events.preset_ifmar"),
values: {
qualifyingScoring: "points",
qualifyingRounds: 5,
carsPerHeat: 10,
qualDurationMin: 5,
qualStartMode: "staggered",
qualSeedLapCount: 3,
qualSeedMethod: "best_sum",
countedQualRounds: 3,
qualifyingPointsTable: "ifmar",
qualifyingTieBreak: "best_lap",
carsPerFinal: 10,
finalLegs: 3,
countedFinalLegs: 2,
finalDurationMin: 5,
finalStartMode: "position",
followUpSec: 15,
minLapMs: 12000,
maxLapMs: 70000,
bumpCount: 0,
},
},
{
id: "endurance",
label: t("events.preset_endurance"),
values: {
qualifyingScoring: "best",
qualifyingRounds: 1,
carsPerHeat: 12,
qualDurationMin: 10,
qualStartMode: "mass",
qualSeedLapCount: 0,
qualSeedMethod: "best_sum",
countedQualRounds: 1,
qualifyingPointsTable: "rank_low",
qualifyingTieBreak: "best_round",
carsPerFinal: 12,
finalLegs: 1,
countedFinalLegs: 1,
finalDurationMin: 240,
finalStartMode: "mass",
followUpSec: 60,
minLapMs: 10000,
maxLapMs: 120000,
bumpCount: 0,
},
},
];
}
function applyRaceFormatPreset(event, presetId) {
const preset = getRaceFormatPresets().find((item) => item.id === presetId);
if (!preset || preset.id === "custom") {
event.raceConfig.presetId = "custom";
return;
}
Object.assign(event.raceConfig, preset.values, { presetId: preset.id });
}
function normalizeEvent(event) {
return {
...event,
branding: normalizeBrandingConfig(event?.branding),
raceConfig: {
presetId: getRaceFormatPresets().some((preset) => preset.id === String(event?.raceConfig?.presetId || ""))
? String(event.raceConfig.presetId)
: "custom",
qualifyingScoring: event?.raceConfig?.qualifyingScoring === "best" ? "best" : "points",
qualifyingRounds: Math.max(1, Number(event?.raceConfig?.qualifyingRounds || 3) || 3),
carsPerHeat: Math.max(2, Number(event?.raceConfig?.carsPerHeat || 8) || 8),
@@ -3001,6 +3149,7 @@ function renderEventManager(eventId) {
.join("");
const branding = normalizeBrandingConfig(event.branding);
const editingSession = sessions.find((session) => session.id === selectedSessionEditId) || null;
const racePresets = getRaceFormatPresets();
const gridSessions = event.mode === "race" ? sessions.filter((session) => normalizeStartMode(session.startMode) === "position") : [];
if (selectedGridSessionId && !gridSessions.some((session) => session.id === selectedGridSessionId)) {
selectedGridSessionId = "";
@@ -3269,6 +3418,22 @@ function renderEventManager(eventId) {
<p>${t("events.race_format_intro")}</p>
</div>
<form id="raceFormatForm" class="panel-body form-grid cols-5">
${renderRaceFormatField(
"events.race_preset",
"events.race_preset_hint",
`<select name="presetId">
${racePresets
.map(
(preset) => `<option value="${preset.id}" ${event.raceConfig.presetId === preset.id ? "selected" : ""}>${escapeHtml(preset.label)}</option>`
)
.join("")}
</select>`
)}
<div class="field-card">
<span class="field-label">&nbsp;</span>
<span class="field-hint">${t("events.race_preset_hint")}</span>
<button class="btn" id="applyRacePreset" type="button">${t("events.apply_preset")}</button>
</div>
${renderRaceFormatField(
"events.qualifying_scoring",
"events.qualifying_scoring_hint",
@@ -3937,6 +4102,9 @@ function renderEventManager(eventId) {
e.preventDefault();
const form = new FormData(e.currentTarget);
event.raceConfig = {
presetId: getRaceFormatPresets().some((preset) => preset.id === String(form.get("presetId") || ""))
? String(form.get("presetId"))
: "custom",
qualifyingScoring: String(form.get("qualifyingScoring") || "points") === "best" ? "best" : "points",
qualifyingRounds: Math.max(1, Number(form.get("qualifyingRounds") || 3) || 3),
carsPerHeat: Math.max(2, Number(form.get("carsPerHeat") || 8) || 8),
@@ -3972,6 +4140,17 @@ function renderEventManager(eventId) {
renderEventManager(eventId);
});
document.getElementById("applyRacePreset")?.addEventListener("click", () => {
const formElement = document.getElementById("raceFormatForm");
if (!(formElement instanceof HTMLFormElement)) {
return;
}
const form = new FormData(formElement);
applyRaceFormatPreset(event, String(form.get("presetId") || "custom"));
saveState();
renderEventManager(eventId);
});
document.getElementById("generateQualifying")?.addEventListener("click", () => {
const created = generateQualifyingForRace(event);
saveState();
@@ -4608,6 +4787,8 @@ function renderGuide() {
<li>${t("guide.race_format_8")}</li>
<li>${t("guide.race_format_9")}</li>
<li>${t("guide.race_format_10")}</li>
<li>${t("guide.race_format_11")}</li>
<li>${t("guide.race_format_12")}</li>
</ul>
</div>
</section>
@@ -5110,12 +5291,13 @@ function renderLeaderboard(rows) {
rows.map((row, idx) => {
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
return `
<tr>
<tr class="${row.invalidPending ? "leaderboard-invalid" : ""}">
<td><span class="pos-pill ${posClass}">${idx + 1}</span></td>
<td>
<div class="table-primary">${escapeHtml(row.displayName || row.driverName)}</div>
${row.teamId ? `<div class="table-subnote">${t("overlay.active_member")}: ${escapeHtml(formatTeamActiveMemberLabel(row))}</div>` : ""}
${getManualCorrectionSummary(row) ? `<div class="table-subnote">${escapeHtml(getManualCorrectionSummary(row))}</div>` : ""}
${row.invalidPending ? `<div class="table-subnote table-subnote-warn">${escapeHtml(row.invalidLabel)}${row.invalidLapMs ? `${formatLap(row.invalidLapMs)}` : ""}</div>` : ""}
</td>
<td>${escapeHtml(row.subLabel || row.carName)}</td>
<td>${escapeHtml(row.transponder)}</td>
@@ -5144,13 +5326,14 @@ function renderOverlayLeaderboard(rows) {
.map((row, idx) => {
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
return `
<article class="overlay-race-row ${idx === 0 ? "overlay-race-row-leader" : ""}">
<article class="overlay-race-row ${idx === 0 ? "overlay-race-row-leader" : ""} ${row.invalidPending ? "overlay-race-row-invalid" : ""}">
<div class="overlay-race-pos">
<span class="pos-pill ${posClass}">${idx + 1}</span>
</div>
<div class="overlay-race-driver">
<strong>${escapeHtml(row.displayName || row.driverName)}</strong>
<span>${escapeHtml(row.teamId ? `${t("overlay.active_member")}: ${formatTeamActiveMemberLabel(row)}` : row.subLabel || row.transponder || "-")}</span>
${row.invalidPending ? `<span class="overlay-invalid-note">${escapeHtml(row.invalidLabel)}${row.invalidLapMs ? `${formatLap(row.invalidLapMs)}` : ""}</span>` : ""}
<div class="overlay-prediction">
<div class="overlay-prediction-meta">
<label>${t("overlay.next_predicted_lap")}</label>
@@ -6073,7 +6256,9 @@ function buildLeaderboard(session) {
const isRollingPractice = isFreePractice || isOpenPractice;
const nowTs = Date.now();
const rows = Object.values(result.competitors).map((row) => {
const passings = getCompetitorPassings(session, row);
const allPassings = getCompetitorPassings(session, row, { includeInvalid: true });
const passings = allPassings.filter((passing) => isCountedPassing(passing));
const latestAnyPassing = allPassings.length ? allPassings[allPassings.length - 1] : null;
const latestPassing = passings.length ? passings[passings.length - 1] : null;
const lastPassingTs = latestPassing ? Number(latestPassing.timestamp || 0) : Number(row.lastTimestamp || 0) || 0;
const rawElapsedMs = lastPassingTs
@@ -6104,6 +6289,7 @@ function buildLeaderboard(session) {
: predictedProgress <= 1
? "warn"
: "late";
const invalidPending = latestAnyPassing?.validLap === false;
return {
...row,
laps: Math.max(0, Number(row.laps || 0) + manualLapAdjustment),
@@ -6120,6 +6306,9 @@ function buildLeaderboard(session) {
predictedRemainingMs,
predictedProgress,
predictionTone,
invalidPending,
invalidLabel: invalidPending ? getPassingValidationLabel(latestAnyPassing) : "",
invalidLapMs: invalidPending ? Number(latestAnyPassing?.lapMs || 0) || 0 : 0,
comparisonMs:
isRollingPractice
? bestLapMs || lastLapMs || Number.MAX_SAFE_INTEGER
@@ -6404,6 +6593,16 @@ function compareNumberSet(left, right, highWins = false) {
return 0;
}
function buildQualifyingTieBreakNote(row, tieBreak) {
if (tieBreak === "best_lap") {
return `${t("events.tie_break_note")}: ${t("events.qual_tie_break_best_lap")}${formatLap(row.bestSingleLapMs)}`;
}
if (tieBreak === "best_round") {
return `${t("events.tie_break_note")}: ${t("events.qual_tie_break_best_round")}${row.bestRoundDisplay || formatLap(row.bestRoundMetric)}`;
}
return `${t("events.tie_break_note")}: ${t("events.counted_rounds_label")}${(row.ranks || []).join(" / ") || "-"}`;
}
function getCompetitorElapsedMs(session, row) {
const startTs = Number(row?.startTimestamp || session?.startedAt || 0);
if (!startTs || !row?.lastTimestamp) {
@@ -6579,6 +6778,10 @@ function buildQualifyingStandings(event) {
if (Number.isFinite(row.bestLapMs)) {
entry.bestLaps.push(row.bestLapMs);
}
if (!entry.bestRoundDisplay || row.comparisonMs < (entry.bestRoundMetricValue ?? Number.MAX_SAFE_INTEGER)) {
entry.bestRoundMetricValue = row.comparisonMs;
entry.bestRoundDisplay = row.resultDisplay;
}
entry.bestResultDisplay = row.resultDisplay;
entry.lastSessionName = session.name;
entry.sessionCount = (entry.sessionCount || 0) + 1;
@@ -6602,6 +6805,7 @@ function buildQualifyingStandings(event) {
totalScore,
bestRank,
bestRoundMetric,
bestRoundDisplay: entry.bestRoundDisplay || formatLap(bestRoundMetric),
bestSingleLapMs,
score:
scoringMode === "points"
@@ -6646,6 +6850,7 @@ function buildQualifyingStandings(event) {
return rows.map((row, index) => ({
...row,
rank: index + 1,
scoreNote: buildQualifyingTieBreakNote(row, tieBreak),
}));
}
@@ -6661,7 +6866,10 @@ function renderRaceStandingsTable(rows, emptyLabel) {
<tr>
<td>${row.rank}</td>
<td>${escapeHtml(row.driverName || t("common.unknown_driver"))}</td>
<td>${escapeHtml(row.score || "-")}</td>
<td>
<div class="table-primary">${escapeHtml(row.score || "-")}</div>
${row.scoreNote ? `<div class="table-subnote">${escapeHtml(row.scoreNote)}</div>` : ""}
</td>
</tr>
`
)
@@ -7662,7 +7870,11 @@ async function exportRaceResultsPdf(event) {
buildPdfSection(
t("events.qualifying_standings"),
[t("table.pos"), t("table.driver"), t("table.score")],
buildQualifyingStandings(event).map((row) => [String(row.rank), row.driverName || "-", row.score || "-"])
buildQualifyingStandings(event).map((row) => [
String(row.rank),
row.driverName || "-",
row.scoreNote ? `${row.score || "-"} | ${row.scoreNote}` : row.score || "-",
])
),
buildPdfSection(
t("events.final_standings"),

View File

@@ -644,6 +644,14 @@ select:focus {
font-size: 0.8rem;
}
.table-subnote-warn {
color: #ffb7a9;
}
.data-table tr.leaderboard-invalid td {
background: rgba(225, 106, 0, 0.06);
}
.grid-editor-toolbar {
display: flex;
justify-content: space-between;
@@ -1178,6 +1186,11 @@ select:focus {
background: linear-gradient(135deg, rgba(225, 6, 0, 0.12), rgba(255, 255, 255, 0.03));
}
.overlay-race-row-invalid {
border-color: rgba(245, 166, 35, 0.4);
box-shadow: inset 0 0 0 1px rgba(245, 166, 35, 0.08);
}
.overlay-race-driver strong,
.overlay-race-metric strong,
.overlay-race-best strong {
@@ -1198,6 +1211,15 @@ select:focus {
letter-spacing: 0.06em;
}
.overlay-invalid-note {
display: block;
margin-top: 2px;
color: #ffbe98;
font-size: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.overlay-prediction {
margin-top: 2px;
}