Add race format presets, tie-break notes and invalid lap markers
This commit is contained in:
222
src/app.js
222
src/app.js
@@ -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"> </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"),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user