Add local presets, tie-break comparisons and manual lap invalidation

This commit is contained in:
larssand
2026-03-15 22:14:43 +01:00
parent 57a72e5126
commit f82433d17c
2 changed files with 238 additions and 44 deletions

View File

@@ -151,6 +151,8 @@ Praktiskt exempel:
- `IFMAR-stil kval/final`
- `Endurance / lagrace`
- efter applicering kan alla fält fortfarande justeras manuellt och sparas som vanligt
- `Spara klubb-preset` lagrar egna lokala presets i appens state så de kan återanvändas på samma installation
- `Ta bort klubb-preset` tar bort ett lokalt preset igen
### Schemaavvikelse på Översikt
- `Översikt` visar nu om dagen ligger före eller efter schema
@@ -165,6 +167,11 @@ Praktiskt exempel:
- 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`
### Manuell invalidate last lap
- `Tidtagning -> Detaljer`
- knappen `Ogiltigförklara senaste varv` markerar senaste räknade varv som manuellt ogiltigt
- leaderboard, overlay och passings-historik uppdateras direkt
## Windows installation
Kör i PowerShell i projektmappen.

View File

@@ -176,6 +176,9 @@ const TRANSLATIONS = {
"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.save_preset": "Spara klubb-preset",
"events.delete_preset": "Ta bort klubb-preset",
"events.preset_name": "Presetnamn",
"events.preset_custom": "Custom / nuvarande värden",
"events.preset_short_technical": "Kort teknisk bana 16s",
"events.preset_club_qualifying": "Klubbrace kval + final",
@@ -183,7 +186,11 @@ const TRANSLATIONS = {
"events.preset_endurance": "Endurance / lagrace",
"events.tie_break_note": "Tie-break",
"events.counted_rounds_label": "Räknade rundor",
"events.tie_break_won": "Vann mot",
"events.tie_break_lost": "Förlorade mot",
"events.invalid_recent": "Senaste träff ogiltig",
"timing.invalid_manual": "Manuellt ogiltigförklarat",
"timing.invalidate_last_lap": "Ogiltigförklara senaste varv",
"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",
@@ -527,6 +534,7 @@ const TRANSLATIONS = {
"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.race_format_13": "Spara klubb-preset lagrar dina egna lokala raceformat så du kan återanvända dem på samma installation utan att bygga om allt varje gång.",
"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.",
@@ -548,6 +556,7 @@ 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.validation_6": "I samma detaljvy kan du också manuellt ogiltigförklara senaste räknade varvet om du behöver ta bort en felträff i efterhand.",
"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.",
@@ -770,6 +779,9 @@ const TRANSLATIONS = {
"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.save_preset": "Save club preset",
"events.delete_preset": "Delete club preset",
"events.preset_name": "Preset name",
"events.preset_custom": "Custom / current values",
"events.preset_short_technical": "Short technical track 16s",
"events.preset_club_qualifying": "Club race qual + finals",
@@ -777,7 +789,11 @@ const TRANSLATIONS = {
"events.preset_endurance": "Endurance / team race",
"events.tie_break_note": "Tie-break",
"events.counted_rounds_label": "Counted rounds",
"events.tie_break_won": "Won against",
"events.tie_break_lost": "Lost against",
"events.invalid_recent": "Latest hit invalid",
"timing.invalid_manual": "Manually invalidated",
"timing.invalidate_last_lap": "Invalidate last lap",
"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",
@@ -1121,6 +1137,7 @@ const TRANSLATIONS = {
"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.race_format_13": "Save club preset stores your own local race formats so you can reuse them on the same installation without rebuilding everything each time.",
"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.",
@@ -1142,6 +1159,7 @@ 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.validation_6": "In the same detail view you can also manually invalidate the latest counted lap if you need to remove a false hit afterwards.",
"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.",
@@ -1360,6 +1378,9 @@ function seedDefaultData() {
if (!state.settings.logoDataUrl) {
state.settings.logoDataUrl = "";
}
if (!Array.isArray(state.settings.racePresets)) {
state.settings.racePresets = [];
}
state.events = state.events.map((event) => normalizeEvent(event));
state.sessions = state.sessions.map((session) => normalizeSession(session));
@@ -1683,6 +1704,11 @@ function applyPersistedState(persisted) {
pdfFooter: persisted.settings?.pdfFooter || state.settings.pdfFooter || "Generated by JMK RB Live Event",
pdfTheme: persisted.settings?.pdfTheme || state.settings.pdfTheme || "classic",
logoDataUrl: persisted.settings?.logoDataUrl || state.settings.logoDataUrl || "",
racePresets: Array.isArray(persisted.settings?.racePresets)
? persisted.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name)
: Array.isArray(state.settings?.racePresets)
? state.settings.racePresets.map((preset) => normalizeStoredRacePreset(preset)).filter((preset) => preset.name)
: [],
};
}
@@ -1715,8 +1741,16 @@ function normalizeRaceTeam(team) {
};
}
function normalizeStoredRacePreset(preset) {
return {
id: String(preset?.id || uid("preset")),
name: String(preset?.name || "").trim(),
values: preset?.values && typeof preset.values === "object" ? { ...preset.values } : {},
};
}
function getRaceFormatPresets() {
return [
const builtins = [
{
id: "custom",
label: t("events.preset_custom"),
@@ -1823,6 +1857,18 @@ function getRaceFormatPresets() {
},
},
];
const customPresets = Array.isArray(state.settings?.racePresets)
? state.settings.racePresets
.map((preset) => normalizeStoredRacePreset(preset))
.filter((preset) => preset.name)
.map((preset) => ({
id: preset.id,
label: preset.name,
custom: true,
values: { ...preset.values },
}))
: [];
return [...builtins, ...customPresets];
}
function applyRaceFormatPreset(event, presetId) {
@@ -1834,14 +1880,48 @@ function applyRaceFormatPreset(event, presetId) {
Object.assign(event.raceConfig, preset.values, { presetId: preset.id });
}
function buildRaceFormatConfigFromForm(form, event) {
return {
presetId: String(form.get("presetId") || "custom").trim() || "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),
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),
finalDurationMin: Math.max(1, Number(form.get("finalDurationMin") || 5) || 5),
finalStartMode: normalizeStartMode(String(form.get("finalStartMode") || "position")),
followUpSec: Math.max(0, Number(form.get("followUpSec") || 0) || 0),
minLapMs: Math.max(0, Math.round((Number(form.get("minLapSec") || 0) || 0) * 1000)),
maxLapMs: Math.max(1000, Math.round((Number(form.get("maxLapSec") || 60) || 60) * 1000)),
bumpCount: Math.max(0, Number(form.get("bumpCount") || 0) || 0),
reserveBumpSlots: form.get("reserveBumpSlots") === "on",
driverIds: event.raceConfig.driverIds || [],
participantsConfigured: event.raceConfig.participantsConfigured !== false,
finalsSource: String(form.get("finalsSource") || "qualifying") === "practice" ? "practice" : "qualifying",
teams: getEventTeams(event),
};
}
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",
presetId: String(event?.raceConfig?.presetId || "custom").trim() || "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),
@@ -3150,6 +3230,7 @@ function renderEventManager(eventId) {
const branding = normalizeBrandingConfig(event.branding);
const editingSession = sessions.find((session) => session.id === selectedSessionEditId) || null;
const racePresets = getRaceFormatPresets();
const selectedPreset = racePresets.find((preset) => preset.id === event.raceConfig.presetId) || racePresets[0];
const gridSessions = event.mode === "race" ? sessions.filter((session) => normalizeStartMode(session.startMode) === "position") : [];
if (selectedGridSessionId && !gridSessions.some((session) => session.id === selectedGridSessionId)) {
selectedGridSessionId = "";
@@ -3430,9 +3511,13 @@ function renderEventManager(eventId) {
</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>
<span class="field-label">${t("events.preset_name")}</span>
<input name="presetName" value="${escapeHtml(selectedPreset?.custom ? selectedPreset.label : "")}" placeholder="${t("events.preset_name")}" />
<div class="actions-inline">
<button class="btn" id="applyRacePreset" type="button">${t("events.apply_preset")}</button>
<button class="btn" id="saveRacePreset" type="button">${t("events.save_preset")}</button>
<button class="btn btn-danger" id="deleteRacePreset" type="button">${t("events.delete_preset")}</button>
</div>
</div>
${renderRaceFormatField(
"events.qualifying_scoring",
@@ -4101,41 +4186,7 @@ function renderEventManager(eventId) {
document.getElementById("raceFormatForm")?.addEventListener("submit", (e) => {
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),
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),
finalDurationMin: Math.max(1, Number(form.get("finalDurationMin") || 5) || 5),
finalStartMode: normalizeStartMode(String(form.get("finalStartMode") || "position")),
followUpSec: Math.max(0, Number(form.get("followUpSec") || 0) || 0),
minLapMs: Math.max(0, Math.round((Number(form.get("minLapSec") || 0) || 0) * 1000)),
maxLapMs: Math.max(1000, Math.round((Number(form.get("maxLapSec") || 60) || 60) * 1000)),
bumpCount: Math.max(0, Number(form.get("bumpCount") || 0) || 0),
reserveBumpSlots: form.get("reserveBumpSlots") === "on",
driverIds: event.raceConfig.driverIds || [],
participantsConfigured: event.raceConfig.participantsConfigured !== false,
finalsSource: String(form.get("finalsSource") || "qualifying") === "practice" ? "practice" : "qualifying",
teams: getEventTeams(event),
};
event.raceConfig = buildRaceFormatConfigFromForm(form, event);
saveState();
renderEventManager(eventId);
});
@@ -4151,6 +4202,70 @@ function renderEventManager(eventId) {
renderEventManager(eventId);
});
document.getElementById("saveRacePreset")?.addEventListener("click", () => {
const formElement = document.getElementById("raceFormatForm");
if (!(formElement instanceof HTMLFormElement)) {
return;
}
const form = new FormData(formElement);
const presetName = String(form.get("presetName") || "").trim();
if (!presetName) {
return;
}
const config = buildRaceFormatConfigFromForm(form, event);
const selectedPresetId = String(form.get("presetId") || "custom");
const existingCustomPreset = (state.settings.racePresets || []).find((preset) => preset.id === selectedPresetId);
const presetId = existingCustomPreset ? existingCustomPreset.id : uid("preset");
const storedPreset = normalizeStoredRacePreset({
id: presetId,
name: presetName,
values: {
qualifyingScoring: config.qualifyingScoring,
qualifyingRounds: config.qualifyingRounds,
carsPerHeat: config.carsPerHeat,
qualDurationMin: config.qualDurationMin,
qualStartMode: config.qualStartMode,
qualSeedLapCount: config.qualSeedLapCount,
qualSeedMethod: config.qualSeedMethod,
countedQualRounds: config.countedQualRounds,
qualifyingPointsTable: config.qualifyingPointsTable,
qualifyingTieBreak: config.qualifyingTieBreak,
carsPerFinal: config.carsPerFinal,
finalLegs: config.finalLegs,
countedFinalLegs: config.countedFinalLegs,
finalDurationMin: config.finalDurationMin,
finalStartMode: config.finalStartMode,
followUpSec: config.followUpSec,
minLapMs: config.minLapMs,
maxLapMs: config.maxLapMs,
bumpCount: config.bumpCount,
reserveBumpSlots: config.reserveBumpSlots,
finalsSource: config.finalsSource,
},
});
const otherPresets = (state.settings.racePresets || []).filter((preset) => preset.id !== presetId);
state.settings.racePresets = [...otherPresets, storedPreset];
event.raceConfig = { ...config, presetId };
saveState();
renderEventManager(eventId);
});
document.getElementById("deleteRacePreset")?.addEventListener("click", () => {
const formElement = document.getElementById("raceFormatForm");
if (!(formElement instanceof HTMLFormElement)) {
return;
}
const form = new FormData(formElement);
const presetId = String(form.get("presetId") || "custom");
if (!(state.settings.racePresets || []).some((preset) => preset.id === presetId)) {
return;
}
state.settings.racePresets = (state.settings.racePresets || []).filter((preset) => preset.id !== presetId);
event.raceConfig.presetId = "custom";
saveState();
renderEventManager(eventId);
});
document.getElementById("generateQualifying")?.addEventListener("click", () => {
const created = generateQualifyingForRace(event);
saveState();
@@ -4514,6 +4629,10 @@ function renderTiming() {
applyCompetitorCorrection(active, selectedRow, { timeMsDelta: -1000 });
renderView();
});
document.getElementById("corrInvalidateLast")?.addEventListener("click", () => {
invalidateCompetitorLastLap(active, selectedRow);
renderView();
});
document.getElementById("corrReset")?.addEventListener("click", () => {
applyCompetitorCorrection(active, selectedRow, { reset: true });
renderView();
@@ -4789,6 +4908,7 @@ function renderGuide() {
<li>${t("guide.race_format_10")}</li>
<li>${t("guide.race_format_11")}</li>
<li>${t("guide.race_format_12")}</li>
<li>${t("guide.race_format_13")}</li>
</ul>
</div>
</section>
@@ -4838,6 +4958,7 @@ function renderGuide() {
<li>${t("guide.validation_3")}</li>
<li>${t("guide.validation_4")}</li>
<li>${t("guide.validation_5")}</li>
<li>${t("guide.validation_6")}</li>
</ul>
</div>
</section>
@@ -5241,6 +5362,7 @@ function renderLeaderboardModal(session, row) {
<button class="btn btn-mini" id="corrSecPlus" type="button">${t("timing.penalty_add_sec")}</button>
<button class="btn btn-mini" id="corr5SecPlus" type="button">${t("timing.penalty_add_5sec")}</button>
<button class="btn btn-mini" id="corrSecMinus" type="button">${t("timing.penalty_remove_sec")}</button>
<button class="btn btn-mini" id="corrInvalidateLast" type="button">${t("timing.invalidate_last_lap")}</button>
<button class="btn btn-danger btn-mini" id="corrReset" type="button">${t("timing.penalty_reset")}</button>
</div>
</div>
@@ -5828,7 +5950,13 @@ function getVisiblePassings(result) {
function getPassingValidationLabel(passing) {
if (passing?.validLap === false) {
return passing.invalidReason === "below_min" ? t("timing.invalid_short") : t("timing.invalid_long");
if (passing.invalidReason === "below_min") {
return t("timing.invalid_short");
}
if (passing.invalidReason === "manual_invalid") {
return t("timing.invalid_manual");
}
return t("timing.invalid_long");
}
return t("timing.valid_passing");
}
@@ -5861,6 +5989,35 @@ function applyCompetitorCorrection(session, row, options = {}) {
saveState();
}
function invalidateCompetitorLastLap(session, row) {
const result = ensureSessionResult(session.id);
const entry = result.competitors[row.key];
if (!entry) {
return false;
}
const passings = getCompetitorPassings(session, row, { includeInvalid: true });
const target = [...passings].reverse().find((passing) => isCountedPassing(passing));
if (!target) {
return false;
}
target.validLap = false;
target.invalidReason = "manual_invalid";
const validPassings = passings.filter((passing) => isCountedPassing(passing));
entry.laps = validPassings.length;
entry.lastLapMs = validPassings.length ? Number(validPassings[validPassings.length - 1].lapMs || 0) || null : null;
entry.lastTimestamp = validPassings.length
? Number(validPassings[validPassings.length - 1].timestamp || 0) || entry.startTimestamp || session.startedAt || null
: entry.startTimestamp || session.startedAt || null;
const bestLapCandidates = validPassings.map((passing) => Number(passing.lapMs || 0)).filter((lapMs) => lapMs > 500);
entry.bestLapMs = bestLapCandidates.length ? Math.min(...bestLapCandidates) : null;
saveState();
return true;
}
function getSessionTiming(session, nowTs = Date.now()) {
const targetMs = getSessionTargetMs(session);
const startedAt = Number(session?.startedAt || 0);
@@ -6603,6 +6760,16 @@ function buildQualifyingTieBreakNote(row, tieBreak) {
return `${t("events.tie_break_note")}: ${t("events.counted_rounds_label")}${(row.ranks || []).join(" / ") || "-"}`;
}
function hasQualifyingPrimaryTie(left, right, scoringMode) {
if (!left || !right) {
return false;
}
if (scoringMode === "points") {
return left.totalScore === right.totalScore;
}
return left.bestRank === right.bestRank;
}
function getCompetitorElapsedMs(session, row) {
const startTs = Number(row?.startTimestamp || session?.startedAt || 0);
if (!startTs || !row?.lastTimestamp) {
@@ -6847,10 +7014,30 @@ function buildQualifyingStandings(event) {
return a.bestRoundMetric - b.bestRoundMetric;
});
rows.forEach((row, index) => {
row.tieBreakWonAgainst = "";
row.tieBreakLostAgainst = "";
if (index === 0) {
return;
}
const previous = rows[index - 1];
if (!hasQualifyingPrimaryTie(previous, row, scoringMode)) {
return;
}
previous.tieBreakWonAgainst = previous.tieBreakWonAgainst || row.driverName || t("common.unknown_driver");
row.tieBreakLostAgainst = row.tieBreakLostAgainst || previous.driverName || t("common.unknown_driver");
});
return rows.map((row, index) => ({
...row,
rank: index + 1,
scoreNote: buildQualifyingTieBreakNote(row, tieBreak),
scoreNote: [
buildQualifyingTieBreakNote(row, tieBreak),
row.tieBreakWonAgainst ? `${t("events.tie_break_won")}: ${row.tieBreakWonAgainst}` : "",
row.tieBreakLostAgainst ? `${t("events.tie_break_lost")}: ${row.tieBreakLostAgainst}` : "",
]
.filter(Boolean)
.join(" • "),
}));
}