Add race format presets, tie-break notes and invalid lap markers
This commit is contained in:
16
README.md
16
README.md
@@ -19,6 +19,7 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he
|
|||||||
- `Min varvtid` och `Max varvtid` för att filtrera shortcuts/felträffar och styra bättre statistik/stintlogik
|
- `Min varvtid` och `Max varvtid` för att filtrera shortcuts/felträffar och styra bättre statistik/stintlogik
|
||||||
- seedmetoder för practice/kval: bästa N varv som summa, snitt eller konsekutiva varv
|
- seedmetoder för practice/kval: bästa N varv som summa, snitt eller konsekutiva varv
|
||||||
- valbar kval-poängtabell och tie-break-regler
|
- valbar kval-poängtabell och tie-break-regler
|
||||||
|
- presets i `Raceformat` för kort teknisk bana, klubbrace, IFMAR-liknande upplägg och endurance
|
||||||
- schemaavvikelse på `Översikt` mellan planerad och faktisk körtid
|
- schemaavvikelse på `Översikt` mellan planerad och faktisk körtid
|
||||||
- sessionstyp `Free Practice` för löpande varvtider utan seedning
|
- sessionstyp `Free Practice` för löpande varvtider utan seedning
|
||||||
- auto-generering av kvalheat från practice-ranking eller klasslista
|
- auto-generering av kvalheat från practice-ranking eller klasslista
|
||||||
@@ -139,6 +140,17 @@ Praktiskt exempel:
|
|||||||
- `Jämför räknade rundor`
|
- `Jämför räknade rundor`
|
||||||
- `Bästa enskilda varv`
|
- `Bästa enskilda varv`
|
||||||
- `Bästa runda / heatresultat`
|
- `Bästa runda / heatresultat`
|
||||||
|
- kvalrankingen visar även en underrad med aktiv tie-break-information i tabellen
|
||||||
|
|
||||||
|
### Raceformat presets
|
||||||
|
- `Race Setup -> Hantera -> Raceformat`
|
||||||
|
- välj ett preset och klicka `Applicera preset`
|
||||||
|
- tillgängliga presets:
|
||||||
|
- `Kort teknisk bana 16s`
|
||||||
|
- `Klubbrace kval + final`
|
||||||
|
- `IFMAR-stil kval/final`
|
||||||
|
- `Endurance / lagrace`
|
||||||
|
- efter applicering kan alla fält fortfarande justeras manuellt och sparas som vanligt
|
||||||
|
|
||||||
### Schemaavvikelse på Översikt
|
### Schemaavvikelse på Översikt
|
||||||
- `Översikt` visar nu om dagen ligger före eller efter schema
|
- `Översikt` visar nu om dagen ligger före eller efter schema
|
||||||
@@ -149,6 +161,10 @@ Praktiskt exempel:
|
|||||||
- verklig tid från start till stopp
|
- verklig tid från start till stopp
|
||||||
- eller pågående körtid om sessionen fortfarande kör
|
- eller pågående körtid om sessionen fortfarande kör
|
||||||
|
|
||||||
|
### Invalid-lap markering i leaderboard
|
||||||
|
- om senaste mottagna passing för en förare/ett lag var ogiltig markeras raden även i leaderboard och overlay
|
||||||
|
- det gör det lättare att se felträffar utan att behöva stå i `Senaste passeringar`
|
||||||
|
|
||||||
## Windows installation
|
## Windows installation
|
||||||
Kör i PowerShell i projektmappen.
|
Kör i PowerShell i projektmappen.
|
||||||
|
|
||||||
|
|||||||
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_rounds": "Jämför räknade rundor",
|
||||||
"events.qual_tie_break_best_lap": "Bästa enskilda varv",
|
"events.qual_tie_break_best_lap": "Bästa enskilda varv",
|
||||||
"events.qual_tie_break_best_round": "Bästa runda / heatresultat",
|
"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": "Förare per final",
|
||||||
"events.cars_per_final_hint": "Max antal förare i varje A/B/C-final.",
|
"events.cars_per_final_hint": "Max antal förare i varje A/B/C-final.",
|
||||||
"events.final_legs": "Final-heat per 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_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_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_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_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_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.",
|
"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_rounds": "Compare counted rounds",
|
||||||
"events.qual_tie_break_best_lap": "Best single lap",
|
"events.qual_tie_break_best_lap": "Best single lap",
|
||||||
"events.qual_tie_break_best_round": "Best round / heat result",
|
"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": "Drivers per final",
|
||||||
"events.cars_per_final_hint": "Maximum number of drivers in each A/B/C final.",
|
"events.cars_per_final_hint": "Maximum number of drivers in each A/B/C final.",
|
||||||
"events.final_legs": "Final heats per main",
|
"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_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_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_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_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_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.",
|
"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) {
|
function normalizeEvent(event) {
|
||||||
return {
|
return {
|
||||||
...event,
|
...event,
|
||||||
branding: normalizeBrandingConfig(event?.branding),
|
branding: normalizeBrandingConfig(event?.branding),
|
||||||
raceConfig: {
|
raceConfig: {
|
||||||
|
presetId: getRaceFormatPresets().some((preset) => preset.id === String(event?.raceConfig?.presetId || ""))
|
||||||
|
? String(event.raceConfig.presetId)
|
||||||
|
: "custom",
|
||||||
qualifyingScoring: event?.raceConfig?.qualifyingScoring === "best" ? "best" : "points",
|
qualifyingScoring: event?.raceConfig?.qualifyingScoring === "best" ? "best" : "points",
|
||||||
qualifyingRounds: Math.max(1, Number(event?.raceConfig?.qualifyingRounds || 3) || 3),
|
qualifyingRounds: Math.max(1, Number(event?.raceConfig?.qualifyingRounds || 3) || 3),
|
||||||
carsPerHeat: Math.max(2, Number(event?.raceConfig?.carsPerHeat || 8) || 8),
|
carsPerHeat: Math.max(2, Number(event?.raceConfig?.carsPerHeat || 8) || 8),
|
||||||
@@ -3001,6 +3149,7 @@ function renderEventManager(eventId) {
|
|||||||
.join("");
|
.join("");
|
||||||
const branding = normalizeBrandingConfig(event.branding);
|
const branding = normalizeBrandingConfig(event.branding);
|
||||||
const editingSession = sessions.find((session) => session.id === selectedSessionEditId) || null;
|
const editingSession = sessions.find((session) => session.id === selectedSessionEditId) || null;
|
||||||
|
const racePresets = getRaceFormatPresets();
|
||||||
const gridSessions = event.mode === "race" ? sessions.filter((session) => normalizeStartMode(session.startMode) === "position") : [];
|
const gridSessions = event.mode === "race" ? sessions.filter((session) => normalizeStartMode(session.startMode) === "position") : [];
|
||||||
if (selectedGridSessionId && !gridSessions.some((session) => session.id === selectedGridSessionId)) {
|
if (selectedGridSessionId && !gridSessions.some((session) => session.id === selectedGridSessionId)) {
|
||||||
selectedGridSessionId = "";
|
selectedGridSessionId = "";
|
||||||
@@ -3269,6 +3418,22 @@ function renderEventManager(eventId) {
|
|||||||
<p>${t("events.race_format_intro")}</p>
|
<p>${t("events.race_format_intro")}</p>
|
||||||
</div>
|
</div>
|
||||||
<form id="raceFormatForm" class="panel-body form-grid cols-5">
|
<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(
|
${renderRaceFormatField(
|
||||||
"events.qualifying_scoring",
|
"events.qualifying_scoring",
|
||||||
"events.qualifying_scoring_hint",
|
"events.qualifying_scoring_hint",
|
||||||
@@ -3937,6 +4102,9 @@ function renderEventManager(eventId) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const form = new FormData(e.currentTarget);
|
const form = new FormData(e.currentTarget);
|
||||||
event.raceConfig = {
|
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",
|
qualifyingScoring: String(form.get("qualifyingScoring") || "points") === "best" ? "best" : "points",
|
||||||
qualifyingRounds: Math.max(1, Number(form.get("qualifyingRounds") || 3) || 3),
|
qualifyingRounds: Math.max(1, Number(form.get("qualifyingRounds") || 3) || 3),
|
||||||
carsPerHeat: Math.max(2, Number(form.get("carsPerHeat") || 8) || 8),
|
carsPerHeat: Math.max(2, Number(form.get("carsPerHeat") || 8) || 8),
|
||||||
@@ -3972,6 +4140,17 @@ function renderEventManager(eventId) {
|
|||||||
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", () => {
|
document.getElementById("generateQualifying")?.addEventListener("click", () => {
|
||||||
const created = generateQualifyingForRace(event);
|
const created = generateQualifyingForRace(event);
|
||||||
saveState();
|
saveState();
|
||||||
@@ -4608,6 +4787,8 @@ function renderGuide() {
|
|||||||
<li>${t("guide.race_format_8")}</li>
|
<li>${t("guide.race_format_8")}</li>
|
||||||
<li>${t("guide.race_format_9")}</li>
|
<li>${t("guide.race_format_9")}</li>
|
||||||
<li>${t("guide.race_format_10")}</li>
|
<li>${t("guide.race_format_10")}</li>
|
||||||
|
<li>${t("guide.race_format_11")}</li>
|
||||||
|
<li>${t("guide.race_format_12")}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -5110,12 +5291,13 @@ function renderLeaderboard(rows) {
|
|||||||
rows.map((row, idx) => {
|
rows.map((row, idx) => {
|
||||||
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
|
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr class="${row.invalidPending ? "leaderboard-invalid" : ""}">
|
||||||
<td><span class="pos-pill ${posClass}">${idx + 1}</span></td>
|
<td><span class="pos-pill ${posClass}">${idx + 1}</span></td>
|
||||||
<td>
|
<td>
|
||||||
<div class="table-primary">${escapeHtml(row.displayName || row.driverName)}</div>
|
<div class="table-primary">${escapeHtml(row.displayName || row.driverName)}</div>
|
||||||
${row.teamId ? `<div class="table-subnote">${t("overlay.active_member")}: ${escapeHtml(formatTeamActiveMemberLabel(row))}</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>` : ""}
|
${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>
|
||||||
<td>${escapeHtml(row.subLabel || row.carName)}</td>
|
<td>${escapeHtml(row.subLabel || row.carName)}</td>
|
||||||
<td>${escapeHtml(row.transponder)}</td>
|
<td>${escapeHtml(row.transponder)}</td>
|
||||||
@@ -5144,13 +5326,14 @@ function renderOverlayLeaderboard(rows) {
|
|||||||
.map((row, idx) => {
|
.map((row, idx) => {
|
||||||
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
|
const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : "";
|
||||||
return `
|
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">
|
<div class="overlay-race-pos">
|
||||||
<span class="pos-pill ${posClass}">${idx + 1}</span>
|
<span class="pos-pill ${posClass}">${idx + 1}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="overlay-race-driver">
|
<div class="overlay-race-driver">
|
||||||
<strong>${escapeHtml(row.displayName || row.driverName)}</strong>
|
<strong>${escapeHtml(row.displayName || row.driverName)}</strong>
|
||||||
<span>${escapeHtml(row.teamId ? `${t("overlay.active_member")}: ${formatTeamActiveMemberLabel(row)}` : row.subLabel || row.transponder || "-")}</span>
|
<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">
|
||||||
<div class="overlay-prediction-meta">
|
<div class="overlay-prediction-meta">
|
||||||
<label>${t("overlay.next_predicted_lap")}</label>
|
<label>${t("overlay.next_predicted_lap")}</label>
|
||||||
@@ -6073,7 +6256,9 @@ function buildLeaderboard(session) {
|
|||||||
const isRollingPractice = isFreePractice || isOpenPractice;
|
const isRollingPractice = isFreePractice || isOpenPractice;
|
||||||
const nowTs = Date.now();
|
const nowTs = Date.now();
|
||||||
const rows = Object.values(result.competitors).map((row) => {
|
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 latestPassing = passings.length ? passings[passings.length - 1] : null;
|
||||||
const lastPassingTs = latestPassing ? Number(latestPassing.timestamp || 0) : Number(row.lastTimestamp || 0) || 0;
|
const lastPassingTs = latestPassing ? Number(latestPassing.timestamp || 0) : Number(row.lastTimestamp || 0) || 0;
|
||||||
const rawElapsedMs = lastPassingTs
|
const rawElapsedMs = lastPassingTs
|
||||||
@@ -6104,6 +6289,7 @@ function buildLeaderboard(session) {
|
|||||||
: predictedProgress <= 1
|
: predictedProgress <= 1
|
||||||
? "warn"
|
? "warn"
|
||||||
: "late";
|
: "late";
|
||||||
|
const invalidPending = latestAnyPassing?.validLap === false;
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
laps: Math.max(0, Number(row.laps || 0) + manualLapAdjustment),
|
laps: Math.max(0, Number(row.laps || 0) + manualLapAdjustment),
|
||||||
@@ -6120,6 +6306,9 @@ function buildLeaderboard(session) {
|
|||||||
predictedRemainingMs,
|
predictedRemainingMs,
|
||||||
predictedProgress,
|
predictedProgress,
|
||||||
predictionTone,
|
predictionTone,
|
||||||
|
invalidPending,
|
||||||
|
invalidLabel: invalidPending ? getPassingValidationLabel(latestAnyPassing) : "",
|
||||||
|
invalidLapMs: invalidPending ? Number(latestAnyPassing?.lapMs || 0) || 0 : 0,
|
||||||
comparisonMs:
|
comparisonMs:
|
||||||
isRollingPractice
|
isRollingPractice
|
||||||
? bestLapMs || lastLapMs || Number.MAX_SAFE_INTEGER
|
? bestLapMs || lastLapMs || Number.MAX_SAFE_INTEGER
|
||||||
@@ -6404,6 +6593,16 @@ function compareNumberSet(left, right, highWins = false) {
|
|||||||
return 0;
|
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) {
|
function getCompetitorElapsedMs(session, row) {
|
||||||
const startTs = Number(row?.startTimestamp || session?.startedAt || 0);
|
const startTs = Number(row?.startTimestamp || session?.startedAt || 0);
|
||||||
if (!startTs || !row?.lastTimestamp) {
|
if (!startTs || !row?.lastTimestamp) {
|
||||||
@@ -6579,6 +6778,10 @@ function buildQualifyingStandings(event) {
|
|||||||
if (Number.isFinite(row.bestLapMs)) {
|
if (Number.isFinite(row.bestLapMs)) {
|
||||||
entry.bestLaps.push(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.bestResultDisplay = row.resultDisplay;
|
||||||
entry.lastSessionName = session.name;
|
entry.lastSessionName = session.name;
|
||||||
entry.sessionCount = (entry.sessionCount || 0) + 1;
|
entry.sessionCount = (entry.sessionCount || 0) + 1;
|
||||||
@@ -6602,6 +6805,7 @@ function buildQualifyingStandings(event) {
|
|||||||
totalScore,
|
totalScore,
|
||||||
bestRank,
|
bestRank,
|
||||||
bestRoundMetric,
|
bestRoundMetric,
|
||||||
|
bestRoundDisplay: entry.bestRoundDisplay || formatLap(bestRoundMetric),
|
||||||
bestSingleLapMs,
|
bestSingleLapMs,
|
||||||
score:
|
score:
|
||||||
scoringMode === "points"
|
scoringMode === "points"
|
||||||
@@ -6646,6 +6850,7 @@ function buildQualifyingStandings(event) {
|
|||||||
return rows.map((row, index) => ({
|
return rows.map((row, index) => ({
|
||||||
...row,
|
...row,
|
||||||
rank: index + 1,
|
rank: index + 1,
|
||||||
|
scoreNote: buildQualifyingTieBreakNote(row, tieBreak),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6661,7 +6866,10 @@ function renderRaceStandingsTable(rows, emptyLabel) {
|
|||||||
<tr>
|
<tr>
|
||||||
<td>${row.rank}</td>
|
<td>${row.rank}</td>
|
||||||
<td>${escapeHtml(row.driverName || t("common.unknown_driver"))}</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>
|
</tr>
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
@@ -7662,7 +7870,11 @@ async function exportRaceResultsPdf(event) {
|
|||||||
buildPdfSection(
|
buildPdfSection(
|
||||||
t("events.qualifying_standings"),
|
t("events.qualifying_standings"),
|
||||||
[t("table.pos"), t("table.driver"), t("table.score")],
|
[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(
|
buildPdfSection(
|
||||||
t("events.final_standings"),
|
t("events.final_standings"),
|
||||||
|
|||||||
@@ -644,6 +644,14 @@ select:focus {
|
|||||||
font-size: 0.8rem;
|
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 {
|
.grid-editor-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
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));
|
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-driver strong,
|
||||||
.overlay-race-metric strong,
|
.overlay-race-metric strong,
|
||||||
.overlay-race-best strong {
|
.overlay-race-best strong {
|
||||||
@@ -1198,6 +1211,15 @@ select:focus {
|
|||||||
letter-spacing: 0.06em;
|
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 {
|
.overlay-prediction {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user