Add follow-up timing, invalid lap handling, corrections and docs

This commit is contained in:
larssand
2026-03-15 19:22:47 +01:00
parent a13a649267
commit b9e8aa024b
3 changed files with 265 additions and 17 deletions

View File

@@ -207,6 +207,8 @@ const TRANSLATIONS = {
"events.finals_from_qualifying": "Kval-ranking",
"events.finals_from_practice": "Practice-ranking",
"events.finals_source_hint": "Välj om finalerna ska seedas från practice eller kval.",
"events.follow_up_sec": "Follow-up tid (sek)",
"events.follow_up_sec_hint": "Extra tid efter ordinarie racetid så sista bilarna kan avsluta innan sessionen stängs.",
"events.min_lap_time": "Min varvtid (sek)",
"events.min_lap_time_hint": "Varv snabbare än detta ignoreras som shortcut eller felträff.",
"events.max_lap_time": "Max varvtid (sek)",
@@ -273,6 +275,8 @@ const TRANSLATIONS = {
"timing.remaining": "Nedräkning",
"timing.elapsed": "Körtid",
"timing.race_finished": "Race is finished",
"timing.follow_up": "Follow-up",
"timing.follow_up_active": "Follow-up aktiv",
"timing.no_active": "Ingen aktiv session vald.",
"timing.leaderboard": "Live leaderboard",
"timing.recent_passings": "Senaste passeringar",
@@ -295,6 +299,18 @@ const TRANSLATIONS = {
"timing.detail_title": "Leaderboard-detaljer",
"timing.lap_history": "Varvhistorik",
"timing.no_lap_history": "Inga varv att visa.",
"timing.manual_corrections": "Manuella korrigeringar",
"timing.lap_adjustment": "Varvjustering",
"timing.time_penalty": "Tidspåslag",
"timing.penalty_add_lap": "+1 varv",
"timing.penalty_remove_lap": "-1 varv",
"timing.penalty_add_sec": "+1 sek",
"timing.penalty_add_5sec": "+5 sek",
"timing.penalty_remove_sec": "-1 sek",
"timing.penalty_reset": "Nollställ korrigering",
"timing.valid_passing": "Giltigt varv",
"timing.invalid_short": "För kort varv",
"timing.invalid_long": "Över maxvarv",
"timing.total_time": "Total tid",
"timing.clear_confirm": "Rensa all tiddata för denna session?",
"timing.prompt_transponder": "Transponder",
@@ -470,6 +486,9 @@ const TRANSLATIONS = {
"guide.race_format_5": "Finaltid och Final-start styr varje finalleg, ofta med positionsstart.",
"guide.race_format_6": "Bump-up per final och Reservera bump-platser används om förare ska kunna flyttas från lägre final till nästa main.",
"guide.race_format_7": "Källa för finaler avgör om finalerna seedas från practice eller kvalrankingen.",
"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.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.",
@@ -485,6 +504,12 @@ const TRANSLATIONS = {
"guide.team_4": "Efter att laget skapats kan du klicka Redigera lag för att ändra förare eller bilar.",
"guide.team_5": "Skapa en session med typ Team Race och sätt tiden, t.ex. 240 minuter för 4 timmar.",
"guide.team_6": "Starta sessionen i Tidtagning. Alla passeringar från lagets medlemmar summeras till lagets totalvarv.",
"guide.validation_title": "Ogiltiga varv, follow-up och manuella korrigeringar",
"guide.validation_1": "Senaste passeringar visar nu både giltiga och ogiltiga varv. För korta varv markeras som För kort varv och för långa som Över maxvarv.",
"guide.validation_2": "Ogiltiga kortvarv under min-gränsen räknas inte alls i leaderboard eller statistik.",
"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.",
"overlay.title": "Overlay",
"overlay.subtitle": "Extern leaderboard-skärm",
"overlay.no_active": "Ingen aktiv session vald.",
@@ -728,6 +753,8 @@ const TRANSLATIONS = {
"events.finals_from_qualifying": "Qualifying standings",
"events.finals_from_practice": "Practice standings",
"events.finals_source_hint": "Choose whether finals should be seeded from practice or qualifying.",
"events.follow_up_sec": "Follow-up time (sec)",
"events.follow_up_sec_hint": "Extra time after race duration so the last cars can finish before the session closes.",
"events.min_lap_time": "Min lap time (sec)",
"events.min_lap_time_hint": "Laps faster than this are ignored as shortcuts or false hits.",
"events.max_lap_time": "Max lap time (sec)",
@@ -794,6 +821,8 @@ const TRANSLATIONS = {
"timing.remaining": "Countdown",
"timing.elapsed": "Elapsed",
"timing.race_finished": "Race is finished",
"timing.follow_up": "Follow-up",
"timing.follow_up_active": "Follow-up active",
"timing.no_active": "No active session selected.",
"timing.leaderboard": "Live Leaderboard",
"timing.recent_passings": "Recent Passings",
@@ -816,6 +845,18 @@ const TRANSLATIONS = {
"timing.detail_title": "Leaderboard details",
"timing.lap_history": "Lap history",
"timing.no_lap_history": "No laps to show.",
"timing.manual_corrections": "Manual corrections",
"timing.lap_adjustment": "Lap adjustment",
"timing.time_penalty": "Time penalty",
"timing.penalty_add_lap": "+1 lap",
"timing.penalty_remove_lap": "-1 lap",
"timing.penalty_add_sec": "+1 sec",
"timing.penalty_add_5sec": "+5 sec",
"timing.penalty_remove_sec": "-1 sec",
"timing.penalty_reset": "Reset correction",
"timing.valid_passing": "Valid lap",
"timing.invalid_short": "Short lap",
"timing.invalid_long": "Over max lap",
"timing.total_time": "Total time",
"timing.clear_confirm": "Clear all timing data for this session?",
"timing.prompt_transponder": "Transponder",
@@ -991,6 +1032,9 @@ const TRANSLATIONS = {
"guide.race_format_5": "Final duration and Final start control each final leg, often with position start.",
"guide.race_format_6": "Bump-up per main and Reserve bump slots are used if drivers should move from a lower final into the next main.",
"guide.race_format_7": "Source for finals decides whether finals are seeded from practice or qualifying standings.",
"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.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.",
@@ -1006,6 +1050,12 @@ const TRANSLATIONS = {
"guide.team_4": "After the team is created, click Edit team to change drivers or cars.",
"guide.team_5": "Create a session with type Team Race and set the time, for example 240 minutes for 4 hours.",
"guide.team_6": "Start the session in Timing. All passings from the team's members are added to the team's total laps.",
"guide.validation_title": "Invalid laps, follow-up and manual corrections",
"guide.validation_1": "Recent Passings now shows both valid and invalid laps. Short laps are marked as Short lap and long laps as Over max lap.",
"guide.validation_2": "Invalid short laps under the minimum threshold do not count in the leaderboard or statistics.",
"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.",
"overlay.title": "Overlay",
"overlay.subtitle": "External leaderboard screen",
"overlay.no_active": "No active session selected.",
@@ -1546,6 +1596,8 @@ function normalizeSession(session) {
startMode: session?.startMode || "mass",
staggerGapSec: Number(session?.staggerGapSec || 5) || 5,
seedBestLapCount: Math.max(0, Number(session?.seedBestLapCount || 0) || 0),
followUpSec: Math.max(0, Number(session?.followUpSec || 0) || 0),
followUpStartedAt: Number(session?.followUpStartedAt || 0) || null,
driverIds: Array.isArray(session?.driverIds) ? session.driverIds : [],
manualGridIds: Array.isArray(session?.manualGridIds) ? session.manualGridIds : [],
gridCustomized: Boolean(session?.gridCustomized),
@@ -1580,6 +1632,7 @@ function normalizeEvent(event) {
countedFinalLegs: Math.max(1, Number(event?.raceConfig?.countedFinalLegs || 1) || 1),
finalDurationMin: Math.max(1, Number(event?.raceConfig?.finalDurationMin || 5) || 5),
finalStartMode: normalizeStartMode(event?.raceConfig?.finalStartMode || "position"),
followUpSec: Math.max(0, Number(event?.raceConfig?.followUpSec || 0) || 0),
minLapMs: Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0),
maxLapMs: Math.max(0, Number(event?.raceConfig?.maxLapMs || 60000) || 60000),
bumpCount: Math.max(0, Number(event?.raceConfig?.bumpCount || 0) || 0),
@@ -2056,9 +2109,21 @@ function handleSessionTimerTick() {
return { changed: false };
}
if (Number(active.followUpSec || 0) > 0) {
if (!active.followUpStartedAt) {
active.followUpStartedAt = Date.now();
saveState();
return { changed: true };
}
if (timing.followUpRemainingMs > 0) {
return { changed: false };
}
}
active.status = "finished";
active.endedAt = Date.now();
active.finishedByTimer = true;
active.followUpStartedAt = null;
if (lastFinishAnnouncementSessionId !== active.id) {
announceRaceFinished();
lastFinishAnnouncementSessionId = active.id;
@@ -2833,6 +2898,7 @@ function renderEventManager(eventId) {
${SESSION_TYPES.map((s) => `<option value="${s}">${getSessionTypeLabel(s)}</option>`).join("")}
</select>
<input required type="number" min="1" name="durationMin" placeholder="${t("events.duration_placeholder")}" />
<input type="number" min="0" step="1" name="followUpSec" placeholder="${t("events.follow_up_sec")}" value="${event.mode === "race" ? event.raceConfig.followUpSec || 0 : 0}" />
<select name="startMode">
<option value="mass">${t("events.start_mode_mass")}</option>
<option value="position">${t("events.start_mode_position")}</option>
@@ -3139,6 +3205,11 @@ function renderEventManager(eventId) {
<option value="staggered" ${event.raceConfig.finalStartMode === "staggered" ? "selected" : ""}>${t("events.start_mode_staggered")}</option>
</select>`
)}
${renderRaceFormatField(
"events.follow_up_sec",
"events.follow_up_sec_hint",
`<input type="number" min="0" step="1" name="followUpSec" value="${event.raceConfig.followUpSec || 0}" />`
)}
${renderRaceFormatField(
"events.min_lap_time",
"events.min_lap_time_hint",
@@ -3313,6 +3384,7 @@ function renderEventManager(eventId) {
).join("")}
</select>
<input name="durationMin" required type="number" min="1" value="${editingSession.durationMin || 5}" />
<input name="followUpSec" type="number" min="0" step="1" value="${editingSession.followUpSec || 0}" />
<select name="startMode">
<option value="mass" ${normalizeStartMode(editingSession.startMode) === "mass" ? "selected" : ""}>${t("events.start_mode_mass")}</option>
<option value="position" ${normalizeStartMode(editingSession.startMode) === "position" ? "selected" : ""}>${t("events.start_mode_position")}</option>
@@ -3383,6 +3455,7 @@ function renderEventManager(eventId) {
name: String(form.get("name")).trim(),
type: String(form.get("type")),
durationMin: Number(form.get("durationMin")),
followUpSec: Math.max(0, Number(form.get("followUpSec") || 0) || 0),
startMode: String(form.get("startMode") || "mass"),
seedBestLapCount: Math.max(0, Number(form.get("seedBestLapCount") || 0) || 0),
staggerGapSec: Math.max(0, Number(form.get("staggerGapSec") || 0) || 0),
@@ -3485,6 +3558,7 @@ function renderEventManager(eventId) {
editingSession.name = cleanedName;
editingSession.type = String(form.get("type") || editingSession.type);
editingSession.durationMin = Math.max(1, cleanedDuration);
editingSession.followUpSec = Math.max(0, Number(form.get("followUpSec") || 0) || 0);
editingSession.startMode = normalizeStartMode(String(form.get("startMode") || editingSession.startMode || "mass"));
editingSession.seedBestLapCount = Math.max(0, Number(form.get("seedBestLapCount") || 0) || 0);
editingSession.staggerGapSec = Math.max(0, Number(form.get("staggerGapSec") || 0) || 0);
@@ -3703,6 +3777,7 @@ function renderEventManager(eventId) {
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),
@@ -3906,9 +3981,14 @@ function renderTiming() {
const result = active ? ensureSessionResult(active.id) : null;
const leaderboard = active ? buildLeaderboard(active) : [];
const sessionTiming = active ? getSessionTiming(active) : null;
const clockLabel = active && sessionTiming?.untimed ? t("timing.elapsed") : t("timing.remaining");
const clockValue = sessionTiming?.untimed ? formatElapsedClock(sessionTiming?.elapsedMs ?? 0) : formatCountdown(sessionTiming?.remainingMs ?? 0);
const clockLabel = active && sessionTiming?.followUpActive ? t("timing.follow_up") : active && sessionTiming?.untimed ? t("timing.elapsed") : t("timing.remaining");
const clockValue = sessionTiming?.followUpActive
? formatCountdown(sessionTiming?.followUpRemainingMs ?? 0)
: sessionTiming?.untimed
? formatElapsedClock(sessionTiming?.elapsedMs ?? 0)
: formatCountdown(sessionTiming?.remainingMs ?? 0);
const showFinishedBanner = Boolean(active && active.status === "finished" && active.finishedByTimer);
const showFollowUpBanner = Boolean(active && sessionTiming?.followUpActive);
const selectedRow = leaderboard.find((row) => row.key === selectedLeaderboardKey) || null;
if (selectedLeaderboardKey && !selectedRow) {
selectedLeaderboardKey = null;
@@ -3994,6 +4074,7 @@ function renderTiming() {
}`
: `<p>${t("timing.no_active")}</p>`
}
${showFollowUpBanner ? `<p class="finish-banner">${t("timing.follow_up_active")}</p>` : ""}
${showFinishedBanner ? `<p class="finish-banner">${t("timing.race_finished")}</p>` : ""}
${active && normalizeStartMode(active.startMode) === "position" ? renderPositionGrid(active) : ""}
</div>
@@ -4053,6 +4134,30 @@ function renderTiming() {
if (active && selectedRow) {
bindQuickAddActions(active, selectedRow.transponder, "leaderboardModal");
document.getElementById("corrLapPlus")?.addEventListener("click", () => {
applyCompetitorCorrection(active, selectedRow, { lapDelta: 1 });
renderView();
});
document.getElementById("corrLapMinus")?.addEventListener("click", () => {
applyCompetitorCorrection(active, selectedRow, { lapDelta: -1 });
renderView();
});
document.getElementById("corrSecPlus")?.addEventListener("click", () => {
applyCompetitorCorrection(active, selectedRow, { timeMsDelta: 1000 });
renderView();
});
document.getElementById("corr5SecPlus")?.addEventListener("click", () => {
applyCompetitorCorrection(active, selectedRow, { timeMsDelta: 5000 });
renderView();
});
document.getElementById("corrSecMinus")?.addEventListener("click", () => {
applyCompetitorCorrection(active, selectedRow, { timeMsDelta: -1000 });
renderView();
});
document.getElementById("corrReset")?.addEventListener("click", () => {
applyCompetitorCorrection(active, selectedRow, { reset: true });
renderView();
});
}
if (active) {
@@ -4161,6 +4266,7 @@ function renderTiming() {
session.startedAt = Date.now();
session.endedAt = null;
session.finishedByTimer = false;
session.followUpStartedAt = null;
lastFinishAnnouncementSessionId = null;
lastOverlayLeaderKeyBySession[session.id] = null;
lastOverlayTop3BySession[session.id] = [];
@@ -4180,6 +4286,7 @@ function renderTiming() {
session.status = "finished";
session.endedAt = Date.now();
session.finishedByTimer = false;
session.followUpStartedAt = null;
saveState();
updateHeaderState();
renderView();
@@ -4194,6 +4301,7 @@ function renderTiming() {
return;
}
delete state.resultsBySession[session.id];
session.followUpStartedAt = null;
lastFinishAnnouncementSessionId = null;
delete lastOverlayLeaderKeyBySession[session.id];
delete lastOverlayTop3BySession[session.id];
@@ -4316,6 +4424,9 @@ function renderGuide() {
<li>${t("guide.race_format_5")}</li>
<li>${t("guide.race_format_6")}</li>
<li>${t("guide.race_format_7")}</li>
<li>${t("guide.race_format_8")}</li>
<li>${t("guide.race_format_9")}</li>
<li>${t("guide.race_format_10")}</li>
</ul>
</div>
</section>
@@ -4356,6 +4467,19 @@ function renderGuide() {
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("guide.validation_title")}</h3></div>
<div class="panel-body">
<ul>
<li>${t("guide.validation_1")}</li>
<li>${t("guide.validation_2")}</li>
<li>${t("guide.validation_3")}</li>
<li>${t("guide.validation_4")}</li>
<li>${t("guide.validation_5")}</li>
</ul>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("guide.host_title")}</h3></div>
<div class="panel-body">
@@ -4411,7 +4535,9 @@ function renderOverlay() {
const leaderboard = active ? buildLeaderboard(active).slice(0, 12) : [];
const result = active ? ensureSessionResult(active.id) : null;
const sessionTiming = active ? getSessionTiming(active) : null;
const overlayClock = sessionTiming?.untimed
const overlayClock = sessionTiming?.followUpActive
? formatCountdown(sessionTiming?.followUpRemainingMs ?? 0)
: sessionTiming?.untimed
? formatElapsedClock(sessionTiming?.elapsedMs ?? 0)
: formatCountdown(sessionTiming?.remainingMs ?? 0);
const recent = active && result ? getVisiblePassings(result).slice(-8).reverse() : [];
@@ -4424,6 +4550,7 @@ function renderOverlay() {
const fastestRow =
[...leaderboard].filter((row) => Number.isFinite(row.bestLapMs)).sort((left, right) => left.bestLapMs - right.bestLapMs)[0] || null;
const modeLabel = getOverlayModeLabel(overlayViewMode);
const overlayStatusLabel = sessionTiming?.followUpActive ? t("timing.follow_up_active") : active ? getStatusLabel(active.status) : "";
const rotatingPanels = buildOverlayPanels(active, recent);
const activePanel = rotatingPanels.length ? rotatingPanels[overlayRotationIndex % rotatingPanels.length] : null;
@@ -4451,7 +4578,7 @@ function renderOverlay() {
<div class="overlay-meta">
<button id="overlayFullscreen" class="btn overlay-fullscreen-btn" type="button">${t("overlay.fullscreen")}</button>
<div class="overlay-clock">${overlayClock}</div>
<div class="overlay-status">${escapeHtml(getStatusLabel(active.status))}</div>
<div class="overlay-status">${escapeHtml(overlayStatusLabel)}</div>
</div>
</header>
@@ -4718,6 +4845,19 @@ function renderLeaderboardModal(session, row) {
<p>${t("table.last_lap")}: ${formatLap(row.lastLapMs)}</p>
<p>${t("table.own_delta")}: ${escapeHtml(row.lapDelta || "-")}</p>
</div>
<div class="panel-body">
<h4>${t("timing.manual_corrections")}</h4>
<p>${t("timing.lap_adjustment")}: ${Number(row.manualLapAdjustment || 0) || 0}</p>
<p>${t("timing.time_penalty")}: ${(Number(row.manualTimeAdjustmentMs || 0) || 0) > 0 ? "+" : (Number(row.manualTimeAdjustmentMs || 0) || 0) < 0 ? "-" : ""}${formatLap(Math.abs(Number(row.manualTimeAdjustmentMs || 0) || 0))}</p>
<div class="actions-inline">
<button class="btn btn-mini" id="corrLapPlus" type="button">${t("timing.penalty_add_lap")}</button>
<button class="btn btn-mini" id="corrLapMinus" type="button">${t("timing.penalty_remove_lap")}</button>
<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-danger btn-mini" id="corrReset" type="button">${t("timing.penalty_reset")}</button>
</div>
</div>
<div class="panel-body">
<h4>${t("timing.lap_history")}</h4>
${
@@ -4770,6 +4910,7 @@ function renderLeaderboard(rows) {
<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>` : ""}
</td>
<td>${escapeHtml(row.subLabel || row.carName)}</td>
<td>${escapeHtml(row.transponder)}</td>
@@ -4901,22 +5042,22 @@ function renderRecentPassings(session) {
return `<p>${t("timing.no_session_selected")}</p>`;
}
const result = ensureSessionResult(session.id);
const items = getVisiblePassings(result).slice(-20).reverse();
const items = result.passings.slice(-20).reverse();
if (!items.length) {
return `<p>${t("timing.no_passings")}</p>`;
}
return renderTable(
[t("table.time"), t("table.transponder"), t("table.driver"), t("table.car"), t("table.loop"), t("table.strength"), ""],
[t("table.time"), t("table.transponder"), t("table.driver"), t("table.car"), t("table.last_lap"), t("table.status"), ""],
items.map((p, index) => {
return `
<tr>
<tr class="${isCountedPassing(p) ? "" : "passing-invalid"}">
<td>${new Date(p.timestamp).toLocaleTimeString()}</td>
<td>${escapeHtml(p.transponder)}</td>
<td>${escapeHtml(p.teamName ? `${p.teamName}${p.driverName || t("common.unknown_driver")}` : p.driverName || t("common.unknown_driver"))}</td>
<td>${escapeHtml(p.carName || p.subLabel || "-")}</td>
<td>${escapeHtml(p.loopId || "-")}</td>
<td>${p.strength ?? "-"}</td>
<td>${formatLap(p.lapMs)}</td>
<td>${escapeHtml(getPassingValidationLabel(p))}</td>
<td>${renderQuickAddActions(session, p.transponder, `recentPassing-${index}`)}</td>
</tr>
`;
@@ -5297,15 +5438,56 @@ function getVisiblePassings(result) {
return Array.isArray(result?.passings) ? result.passings.filter((passing) => isCountedPassing(passing)) : [];
}
function getPassingValidationLabel(passing) {
if (passing?.validLap === false) {
return passing.invalidReason === "below_min" ? t("timing.invalid_short") : t("timing.invalid_long");
}
return t("timing.valid_passing");
}
function getManualCorrectionSummary(row) {
const laps = Number(row?.manualLapAdjustment || 0) || 0;
const timeMs = Number(row?.manualTimeAdjustmentMs || 0) || 0;
const bits = [];
if (laps) {
bits.push(`${laps > 0 ? "+" : ""}${laps}L`);
}
if (timeMs) {
bits.push(`${timeMs > 0 ? "+" : "-"}${formatLap(Math.abs(timeMs))}`);
}
return bits.join(" • ");
}
function applyCompetitorCorrection(session, row, options = {}) {
const entry = ensureSessionResult(session.id).competitors[row.key];
if (!entry) {
return;
}
if (options.reset) {
entry.manualLapAdjustment = 0;
entry.manualTimeAdjustmentMs = 0;
} else {
entry.manualLapAdjustment = (Number(entry.manualLapAdjustment || 0) || 0) + (Number(options.lapDelta || 0) || 0);
entry.manualTimeAdjustmentMs = (Number(entry.manualTimeAdjustmentMs || 0) || 0) + (Number(options.timeMsDelta || 0) || 0);
}
saveState();
}
function getSessionTiming(session, nowTs = Date.now()) {
const targetMs = getSessionTargetMs(session);
const startedAt = Number(session?.startedAt || 0);
const elapsedMs = startedAt ? Math.max(0, nowTs - startedAt) : 0;
const followUpMs = Math.max(0, Number(session?.followUpSec || 0) || 0) * 1000;
const followUpStartedAt = Number(session?.followUpStartedAt || 0) || 0;
const followUpActive = Boolean(followUpStartedAt && followUpMs > 0);
const followUpRemainingMs = followUpActive ? Math.max(0, followUpMs - Math.max(0, nowTs - followUpStartedAt)) : 0;
return {
targetMs,
elapsedMs,
remainingMs: targetMs === null ? null : Math.max(0, targetMs - elapsedMs),
untimed: targetMs === null,
followUpActive,
followUpRemainingMs,
};
}
@@ -5686,15 +5868,20 @@ function buildLeaderboard(session) {
const isRollingPractice = isFreePractice || isOpenPractice;
const nowTs = Date.now();
const rows = Object.values(result.competitors).map((row) => {
const totalElapsedMs = getCompetitorElapsedMs(session, row);
const distanceToTargetMs = Math.abs(targetMs - totalElapsedMs);
const seedMetric = getCompetitorSeedMetric(session, row);
const passings = getCompetitorPassings(session, row);
const latestPassing = passings.length ? passings[passings.length - 1] : null;
const lastPassingTs = latestPassing ? Number(latestPassing.timestamp || 0) : Number(row.lastTimestamp || 0) || 0;
const rawElapsedMs = lastPassingTs
? Math.max(0, lastPassingTs - Number(row.startTimestamp || session.startedAt || lastPassingTs))
: getCompetitorElapsedMs(session, row);
const manualLapAdjustment = Number(row.manualLapAdjustment || 0) || 0;
const manualTimeAdjustmentMs = Number(row.manualTimeAdjustmentMs || 0) || 0;
const totalElapsedMs = Math.max(0, rawElapsedMs + manualTimeAdjustmentMs);
const distanceToTargetMs = Math.abs(targetMs - totalElapsedMs);
const seedMetric = getCompetitorSeedMetric(session, row);
const previousLapMs = passings.length >= 2 ? Number(passings[passings.length - 2].lapMs || 0) : null;
const lastLapMs = latestPassing ? Number(latestPassing.lapMs || 0) : Number(row.lastLapMs || 0) || 0;
const bestLapMs = Number(row.bestLapMs || 0) || 0;
const lastPassingTs = latestPassing ? Number(latestPassing.timestamp || 0) : Number(row.lastTimestamp || 0) || 0;
const lapDeltaMs =
lastLapMs && previousLapMs && lastLapMs > 0 && previousLapMs > 0 ? lastLapMs - previousLapMs : null;
const predictionBaseMs =
@@ -5714,10 +5901,13 @@ function buildLeaderboard(session) {
: "late";
return {
...row,
laps: Math.max(0, Number(row.laps || 0) + manualLapAdjustment),
lastLapMs,
bestLapMs,
lastTimestamp: lastPassingTs || row.lastTimestamp,
totalElapsedMs,
manualLapAdjustment,
manualTimeAdjustmentMs,
distanceToTargetMs,
seedMetric,
previousLapMs,
@@ -5727,16 +5917,16 @@ function buildLeaderboard(session) {
predictionTone,
comparisonMs:
isRollingPractice
? row.bestLapMs || row.lastLapMs || Number.MAX_SAFE_INTEGER
? bestLapMs || lastLapMs || Number.MAX_SAFE_INTEGER
: useSeedRanking && seedMetric
? seedMetric.totalMs
: totalElapsedMs,
resultDisplay:
isRollingPractice
? formatLap(row.bestLapMs || row.lastLapMs)
? formatLap(bestLapMs || lastLapMs)
: useSeedRanking && seedMetric
? `${seedMetric.lapCount}/${formatRaceClock(seedMetric.totalMs)}`
: `${row.laps}/${formatRaceClock(totalElapsedMs)}`,
: `${Math.max(0, Number(row.laps || 0) + manualLapAdjustment)}/${formatRaceClock(totalElapsedMs)}`,
};
});
@@ -6523,6 +6713,7 @@ function generateQualifyingForRace(event) {
const carsPerHeat = Math.max(2, Number(event.raceConfig?.carsPerHeat || 8) || 8);
const qualDurationMin = Math.max(1, Number(event.raceConfig?.qualDurationMin || 5) || 5);
const qualStartMode = normalizeStartMode(event.raceConfig?.qualStartMode || "staggered");
const followUpSec = Math.max(0, Number(event.raceConfig?.followUpSec || 0) || 0);
const heats = chunkArray(fallbackRows, carsPerHeat);
let created = 0;
@@ -6543,6 +6734,8 @@ function generateQualifyingForRace(event) {
startedAt: null,
endedAt: null,
finishedByTimer: false,
followUpSec,
followUpStartedAt: null,
startMode: qualStartMode,
seedBestLapCount: 2,
staggerGapSec: 3,
@@ -6626,6 +6819,7 @@ function generateFinalsForRace(event) {
const carsPerFinal = Math.max(2, Number(event.raceConfig?.carsPerFinal || 8) || 8);
const finalDurationMin = Math.max(1, Number(event.raceConfig?.finalDurationMin || 5) || 5);
const finalStartMode = normalizeStartMode(event.raceConfig?.finalStartMode || "position");
const followUpSec = Math.max(0, Number(event.raceConfig?.followUpSec || 0) || 0);
const bumpCount = Math.max(0, Number(event.raceConfig?.bumpCount || 0) || 0);
const reserveBumpSlots = Boolean(event.raceConfig?.reserveBumpSlots && bumpCount > 0);
const seededSlotsPerMain = reserveBumpSlots ? Math.max(1, carsPerFinal - bumpCount) : carsPerFinal;
@@ -6649,6 +6843,8 @@ function generateFinalsForRace(event) {
startedAt: null,
endedAt: null,
finishedByTimer: false,
followUpSec,
followUpStartedAt: null,
startMode: finalStartMode,
seedBestLapCount: 0,
staggerGapSec: 0,

View File

@@ -395,6 +395,11 @@ select:focus {
font-size: 0.95rem;
}
.data-table tr.passing-invalid td {
background: rgba(225, 6, 0, 0.08);
color: #ffd0d0;
}
.data-table th {
color: #bdc8e3;
font-size: 0.82rem;
@@ -857,6 +862,7 @@ select:focus {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.56rem;
text-align: right;
}
.overlay-board {