Add local presets, tie-break comparisons and manual lap invalidation
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
275
src/app.js
275
src/app.js
@@ -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"> </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(" • "),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user