diff --git a/src/app.js b/src/app.js index c2c0571..4f0fc06 100644 --- a/src/app.js +++ b/src/app.js @@ -207,6 +207,10 @@ 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.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)", + "events.max_lap_time_hint": "Varv långsammare än detta räknas inte som giltigt varv och bryter lap-basen för nästa varv.", "events.race_driver_scope": "Race i denna klass använder alla förare i vald klass om sessionen inte har egen deltagarlista.", "events.reserve_bump_slots_hint": "Reserverar tomma platser i högre finaler så bumpade förare kan flyttas in utan att skriva över seedade platser.", "events.team_race": "Lagrace", @@ -724,6 +728,10 @@ 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.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)", + "events.max_lap_time_hint": "Laps slower than this are not counted as valid laps and reset the lap base for the next lap.", "events.race_driver_scope": "Race mode uses all drivers in the event class unless a session has its own participant list.", "events.reserve_bump_slots_hint": "Reserve empty slots in higher finals so bumped drivers can be inserted without overwriting seeded spots.", "events.team_race": "Team Race", @@ -1570,6 +1578,8 @@ 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"), + 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), reserveBumpSlots: event?.raceConfig?.reserveBumpSlots !== false, driverIds: Array.isArray(event?.raceConfig?.driverIds) ? event.raceConfig.driverIds : [], @@ -3118,6 +3128,16 @@ function renderEventManager(eventId) { ` )} + ${renderRaceFormatField( + "events.min_lap_time", + "events.min_lap_time_hint", + `` + )} + ${renderRaceFormatField( + "events.max_lap_time", + "events.max_lap_time_hint", + `` + )} ${renderRaceFormatField( "events.bump_count", "events.bump_count_hint", @@ -3672,6 +3692,8 @@ 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")), + 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 || [], @@ -3950,7 +3972,7 @@ function renderTiming() {
${t("timing.started")}${active.startedAt ? new Date(active.startedAt).toLocaleTimeString() : "-"}
${t("table.start_mode")}${escapeHtml(getStartModeLabel(active.startMode))}
${t("timing.seeding_mode")}${active.seedBestLapCount > 0 ? `${active.seedBestLapCount}` : "-"}
-
${t("timing.total_passings")}${result.passings.length}
+
${t("timing.total_passings")}${getVisiblePassings(result).length}
${ active.type === "free_practice" @@ -4381,7 +4403,7 @@ function renderOverlay() { const overlayClock = sessionTiming?.untimed ? formatElapsedClock(sessionTiming?.elapsedMs ?? 0) : formatCountdown(sessionTiming?.remainingMs ?? 0); - const recent = active && result ? result.passings.slice(-8).reverse() : []; + const recent = active && result ? getVisiblePassings(result).slice(-8).reverse() : []; const event = active ? state.events.find((item) => item.id === active.eventId) : null; const branding = resolveEventBranding(event); const practiceRows = event ? buildPracticeStandings(event) : []; @@ -4442,7 +4464,7 @@ function renderOverlay() { (passing) => `
${escapeHtml(passing.displayName || passing.teamName || passing.driverName || t("common.unknown_driver"))} - ${new Date(passing.timestamp).toLocaleTimeString()} + ${formatLap(passing.lapMs)}
` ) @@ -4503,7 +4525,7 @@ function renderOverlay() { ${formatLap(fastestRow?.bestLapMs)}
${escapeHtml(fastestRow?.displayName || fastestRow?.driverName || "-")}
-
${t("table.laps")}: ${topRow?.laps || 0} | ${t("timing.total_passings")}: ${result?.passings.length || 0}
+
${t("table.laps")}: ${topRow?.laps || 0} | ${t("timing.total_passings")}: ${getVisiblePassings(result).length || 0}
${renderOverlayLeaderboard(leaderboard)} @@ -4563,7 +4585,7 @@ function buildOverlayPanels(active, recent) { (passing) => `
${escapeHtml(passing.displayName || passing.teamName || passing.driverName || passing.transponder || t("common.unknown_driver"))} - ${new Date(passing.timestamp).toLocaleTimeString()} + ${formatLap(passing.lapMs)}
` ) @@ -4840,7 +4862,7 @@ function renderTeamOverlay(rows, result, sessionTiming) {
${t("timing.total_passings")} - ${result?.passings.length || 0} + ${getVisiblePassings(result).length || 0} ${sessionTiming?.untimed ? t("timing.elapsed") : t("timing.remaining")}
@@ -4868,7 +4890,7 @@ function renderRecentPassings(session) { return `

${t("timing.no_session_selected")}

`; } const result = ensureSessionResult(session.id); - const items = result.passings.slice(-20).reverse(); + const items = getVisiblePassings(result).slice(-20).reverse(); if (!items.length) { return `

${t("timing.no_passings")}

`; } @@ -5245,6 +5267,25 @@ function getSessionTargetMs(session) { return Math.max(1, Number(session?.durationMin || 0)) * 60 * 1000; } +function getSessionLapWindow(session) { + const event = state.events.find((item) => item.id === session?.eventId); + if (event?.mode !== "race") { + return { minLapMs: 0, maxLapMs: Number.POSITIVE_INFINITY }; + } + const minLapMs = Math.max(0, Number(event?.raceConfig?.minLapMs || 0) || 0); + const configuredMaxLapMs = Math.max(0, Number(event?.raceConfig?.maxLapMs || 60000) || 60000); + const maxLapMs = configuredMaxLapMs > 0 ? Math.max(configuredMaxLapMs, minLapMs || 0) : Number.POSITIVE_INFINITY; + return { minLapMs, maxLapMs }; +} + +function isCountedPassing(passing) { + return passing?.validLap !== false; +} + +function getVisiblePassings(result) { + return Array.isArray(result?.passings) ? result.passings.filter((passing) => isCountedPassing(passing)) : []; +} + function getSessionTiming(session, nowTs = Date.now()) { const targetMs = getSessionTargetMs(session); const startedAt = Number(session?.startedAt || 0); @@ -5439,13 +5480,15 @@ function processDecoderMessage(msg) { const baseTs = entry.lastTimestamp || entry.startTimestamp || session.startedAt || timestamp; const lapMs = Math.max(0, timestamp - baseTs); - - entry.laps += 1; - entry.lastLapMs = lapMs; - entry.lastTimestamp = timestamp; - - if (lapMs > 500 && (!entry.bestLapMs || lapMs < entry.bestLapMs)) { - entry.bestLapMs = lapMs; + const { minLapMs, maxLapMs } = getSessionLapWindow(session); + let validLap = true; + let invalidReason = ""; + if (minLapMs > 0 && lapMs > 0 && lapMs < minLapMs) { + validLap = false; + invalidReason = "below_min"; + } else if (Number.isFinite(maxLapMs) && maxLapMs > 0 && lapMs > maxLapMs) { + validLap = false; + invalidReason = "above_max"; } const passing = { @@ -5461,11 +5504,31 @@ function processDecoderMessage(msg) { carName: entry.carName, competitorKey: key, lapMs, + validLap, + invalidReason, strength: msg.strength, loopId: String(msg.loop_id || ""), resend: Boolean(msg.resend), }; + if (!validLap) { + result.passings.push(passing); + if (invalidReason === "above_max") { + entry.lastTimestamp = timestamp; + } + persistPassingToBackend(session.id, passing); + saveState(); + return; + } + + entry.laps += 1; + entry.lastLapMs = lapMs; + entry.lastTimestamp = timestamp; + + if (lapMs > 500 && (!entry.bestLapMs || lapMs < entry.bestLapMs)) { + entry.bestLapMs = lapMs; + } + result.passings.push(passing); persistPassingToBackend(session.id, passing); pushOverlayEvent("passing", `${entry.displayName || entry.driverName} • ${formatLap(entry.lastLapMs)}`); @@ -5887,10 +5950,13 @@ function getCompetitorElapsedMs(session, row) { return Math.max(0, row.lastTimestamp - startTs); } -function getCompetitorPassings(session, row) { +function getCompetitorPassings(session, row, options = {}) { const result = ensureSessionResult(session.id); return result.passings .filter((passing) => { + if (!options.includeInvalid && !isCountedPassing(passing)) { + return false; + } if (passing.competitorKey) { return passing.competitorKey === row.key; } @@ -6121,13 +6187,16 @@ function buildTeamStintLog(session, row) { return []; } + const { maxLapMs } = getSessionLapWindow(session); + const stintGapMs = Number.isFinite(maxLapMs) ? maxLapMs : Number.POSITIVE_INFINITY; const stints = []; let current = null; passings.forEach((passing) => { const memberLabel = formatTeamActiveMemberLabel(passing); const memberKey = `${passing.driverId || passing.driverName || "-"}|${passing.carId || passing.carName || "-"}`; - if (!current || current.memberKey !== memberKey) { + const gapBreak = current && Number.isFinite(stintGapMs) && Math.max(0, passing.timestamp - current.endTs) > stintGapMs; + if (!current || current.memberKey !== memberKey || gapBreak) { current = { memberKey, memberLabel, diff --git a/src/styles.css b/src/styles.css index b7526c6..cb27a36 100644 --- a/src/styles.css +++ b/src/styles.css @@ -861,8 +861,8 @@ select:focus { .overlay-board { display: grid; - grid-template-columns: minmax(0, 1.7fr) minmax(260px, 0.55fr); - gap: 12px; + grid-template-columns: minmax(0, 1.85fr) minmax(210px, 0.38fr); + gap: 10px; } .overlay-board-tv { @@ -1105,27 +1105,28 @@ select:focus { .overlay-side { display: grid; - gap: 10px; + gap: 8px; + font-size: 0.84rem; } .overlay-side-card { - padding: 10px; + padding: 8px; } .overlay-rotating-card { - min-height: 240px; + min-height: 220px; } .overlay-side-card h3 { - margin: 0 0 6px; - font-size: 0.95rem; + margin: 0 0 4px; + font-size: 0.86rem; } .overlay-passing { display: flex; justify-content: space-between; - gap: 8px; - padding: 5px 0; + gap: 6px; + padding: 4px 0; border-bottom: 1px solid var(--line); } @@ -1133,6 +1134,23 @@ select:focus { border-bottom: 0; } +.overlay-side-card .overlay-passing strong { + font-size: 0.8rem; + line-height: 1.15; +} + +.overlay-side-card .overlay-passing span { + font-family: Orbitron, sans-serif; + font-size: 0.72rem; + color: var(--text); + white-space: nowrap; +} + +.overlay-side-card .pill { + padding: 3px 7px; + font-size: 0.58rem; +} + .overlay-race-list { display: grid; gap: 6px;