feat(race-day): add warm-up transponder check before a heat
Race Day gets a 1-3 minute warm-up per heat with a green check per driver when their transponder is seen, countdown announcements (sv/en), quick transponder fix from the panel, and a start warning that still allows starting when someone is missing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
91
src/app.js
91
src/app.js
@@ -4,8 +4,10 @@ import { escapeHtml } from "./html_escape.js";
|
||||
import { getOverlayModeLabel, buildOverlayPanels, renderOverlaySidePanel, renderOverlayLeaderboard, renderTeamOverlay, renderObsOverlay } from "./overlays.js";
|
||||
|
||||
import { renderSettings } from "./settings.js";
|
||||
import { buildRaceDayQueue, buildRaceDayPreflight } from "./race_day_logic.js";
|
||||
import { renderRaceDayView } from "./race_day_view.js";
|
||||
import { buildRaceDayQueue, buildRaceDayPreflight, getRaceDriverIds } from "./race_day_logic.js";
|
||||
import { recordTransponderSighting, buildWarmupCheck } from "./warmup_logic.js";
|
||||
import { findTransponderConflict, getDriverProfileId } from "./driver_profile_logic.js";
|
||||
import { renderRaceDayView, refreshRaceDayWarmup } from "./race_day_view.js";
|
||||
import { beginSession as beginSessionControl, finishSession as finishSessionControl, pauseSession as pauseSessionControl, resumeSession as resumeSessionControl } from "./session_control.js";
|
||||
import { RACE_AUDIO_EVENT_DEFINITIONS, createRaceAudioEngine, normalizeRaceAudioSettings } from "./race_audio.js";
|
||||
|
||||
@@ -239,6 +241,55 @@ const disconnectDecoder = (options = {}) => disconnectDecoderHelper(options, {
|
||||
updateConnectionBadge,
|
||||
});
|
||||
const getTrackRecordMs = (session) => getTrackRecordMsLogic(session, { state });
|
||||
// Pre-heat warm-up / transponder check. Sightings are kept in memory only (never saved):
|
||||
// they just answer "has this transponder crossed the line since the warm-up started".
|
||||
const transponderSightings = new Map();
|
||||
let raceDayWarmup = null;
|
||||
const onTransponderSeen = (transponder) => {
|
||||
recordTransponderSighting(transponderSightings, transponder, Date.now());
|
||||
if (raceDayWarmup && currentView === "race_day") refreshRaceDayWarmup();
|
||||
};
|
||||
function stopRaceDayWarmup() {
|
||||
(raceDayWarmup?.timers || []).forEach((timer) => clearTimeout(timer));
|
||||
if (raceDayWarmup) raceDayWarmup = { ...raceDayWarmup, timers: [], stopped: true };
|
||||
}
|
||||
function startRaceDayWarmup(session, durationMs) {
|
||||
stopRaceDayWarmup();
|
||||
const minutes = Math.round(durationMs / 60000);
|
||||
const warmup = { sessionId: session.id, startedAt: Date.now(), durationMs, timers: [] };
|
||||
triggerRaceAudio("warmup_start", { interrupt: true, variables: { minutes }, logData: { sessionId: session.id } });
|
||||
if (durationMs > 60000) warmup.timers.push(setTimeout(() => triggerRaceAudio("warmup_1_minute", { logData: { sessionId: session.id } }), durationMs - 60000));
|
||||
warmup.timers.push(setTimeout(() => {
|
||||
triggerRaceAudio("warmup_end", { interrupt: true, logData: { sessionId: session.id } });
|
||||
if (currentView === "race_day") refreshRaceDayWarmup();
|
||||
}, durationMs));
|
||||
raceDayWarmup = warmup;
|
||||
}
|
||||
function getRaceDayWarmupCheck(session) {
|
||||
if (!session) return null;
|
||||
const byId = new Map(state.drivers.map((driver) => [driver.id, driver]));
|
||||
const entrantIds = getRaceDriverIds(state, session);
|
||||
const gridOrder = getSessionGridOrder(session).filter((id) => entrantIds.includes(id));
|
||||
const ordered = [...gridOrder, ...entrantIds.filter((id) => !gridOrder.includes(id))];
|
||||
const warmup = raceDayWarmup?.sessionId === session.id ? raceDayWarmup : null;
|
||||
return {
|
||||
warmup,
|
||||
...buildWarmupCheck({ entrants: ordered.map((id) => byId.get(id)).filter(Boolean), drivers: state.drivers, sightings: transponderSightings, since: warmup ? warmup.startedAt : Number.POSITIVE_INFINITY }),
|
||||
};
|
||||
}
|
||||
// Quick fix from the warm-up panel: warns (like the Drivers page) if another person
|
||||
// already uses that transponder, then saves it on the driver straight away.
|
||||
function setDriverTransponder(driverId, transponder) {
|
||||
const value = String(transponder || "").trim();
|
||||
const driver = state.drivers.find((item) => item.id === driverId);
|
||||
if (!driver || !value) return false;
|
||||
const conflict = findTransponderConflict(state.drivers, { transponder: value, profileId: getDriverProfileId(driver), excludeDriverId: driver.id });
|
||||
if (conflict && !window.confirm(t("warmup.transponder_conflict", { transponder: value, name: conflict.name }))) return false;
|
||||
driver.transponder = value;
|
||||
saveState();
|
||||
return true;
|
||||
}
|
||||
|
||||
const processDecoderMessage = (msg, decoderRole = "main") => processDecoderMessageHelper(msg, {
|
||||
state,
|
||||
t,
|
||||
@@ -258,6 +309,7 @@ const processDecoderMessage = (msg, decoderRole = "main") => processDecoderMessa
|
||||
lastOverlayBestLapByKey,
|
||||
triggerRaceAudio,
|
||||
getTrackRecordMs,
|
||||
onTransponderSeen,
|
||||
}, decoderRole);
|
||||
const renderDashboard = () => renderDashboardView({ addTrackLayout, state, dom, t, backend, getActiveSession, getStatusLabel, getSessionTypeLabel, getEventName, getModeLabel, getBackendUrl, formatLap, renderSessionsTable, setCurrentView: (view) => { currentView = view; }, updateViewUrl, renderNav, renderView, connectDecoder, disconnectDecoder, openOverlayWindow, ensureAudioContext, playPassingBeep, playFinishSiren, escapeHtml });
|
||||
const renderClasses = () => renderClassesView({ state, dom, t, selectedClassEditId: () => selectedClassEditId, setSelectedClassEditId: (value) => { selectedClassEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell });
|
||||
@@ -265,6 +317,7 @@ const renderDrivers = () => renderDriversView({ state, dom, t, driverBrandFilter
|
||||
const renderCars = () => renderCarsView({ state, dom, t, carBrandFilter: () => carBrandFilter, setCarBrandFilter: (value) => { carBrandFilter = value; }, selectedCarEditId: () => selectedCarEditId, setSelectedCarEditId: (value) => { selectedCarEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell, normalizeCar });
|
||||
const renderGuide = () => renderGuideView({ dom, t });
|
||||
const renderRaceDay = () => renderRaceDayView({
|
||||
getRaceDayWarmupCheck, startRaceDayWarmup, stopRaceDayWarmup, setDriverTransponder,
|
||||
addTrackLayout, renderView, state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock,
|
||||
getEventName, getSessionTypeLabel, getStatusLabel, getStartModeLabel,
|
||||
getSessionTiming, getSessionGridEntries, buildRaceDayQueue, buildRaceDayPreflight,
|
||||
@@ -1064,6 +1117,22 @@ const TRANSLATIONS = {
|
||||
"guide.tab_run": "Köra ett race",
|
||||
"guide.tab_training": "Träning & resultat",
|
||||
"guide.tab_videos": "Videoguider",
|
||||
"warmup.title": "Uppvärmning / transponderkontroll",
|
||||
"warmup.hint": "Starta uppvärmningen och låt alla köra över linjen. Grön bock = transpondern har setts. Saknas någon kan du byta transponder direkt här, eller koppla en okänd transponder som just passerat.",
|
||||
"warmup.start": "Starta uppvärmning",
|
||||
"warmup.restart": "Starta om uppvärmning",
|
||||
"warmup.stop": "Avsluta uppvärmning",
|
||||
"warmup.minutes": "{minutes} min",
|
||||
"warmup.ended": "Uppvärmningen är slut - kontrollera bockarna och starta heatet.",
|
||||
"warmup.seen_count": "{seen} av {total} sedda",
|
||||
"warmup.no_transponder": "Ingen transponder",
|
||||
"warmup.assign_unknown": "Koppla okänd transponder...",
|
||||
"warmup.new_transponder": "Ny transponder",
|
||||
"warmup.save": "Spara",
|
||||
"warmup.unknown_seen": "Okända transpondrar som passerat",
|
||||
"warmup.start_missing": "Har inte passerat under uppvärmningen: {names}. Starta ändå?",
|
||||
"warmup.transponder_conflict": "Transponder {transponder} används redan av {name}. Spara ändå?",
|
||||
"race_day.check_transponder_check": "Transponderkontroll",
|
||||
"layouts.bar_current": "Banlayout: {label} - sedan {date}",
|
||||
"layouts.bar_none": "Ingen banlayout satt ännu",
|
||||
"layouts.new": "Ny layout",
|
||||
@@ -1856,6 +1925,7 @@ const TRANSLATIONS = {
|
||||
"guide.race_day_7": "7. Session replay i Tidtagning spelar upp sparade passeringar i ett isolerat testläge. Välj session, ladda och kör i 1x, 2x, 5x eller 10x utan att ändra originalresultat eller träningsstatistik.",
|
||||
"guide.race_day_8": "8. Redan när du registrerar eller ändrar en förare under Förare varnar systemet om transpondern redan används av en annan förare (t.ex. i fel klass) och namnger vem - ett komplement till förkontrollens kontroll av dubbla transpondrar inom samma heat. Samma person som kör flera klasser med sin egen transponder flaggas aldrig.",
|
||||
"guide.race_day_9": "9. Kör ni på ett nät där fler har åtkomst (t.ex. arenans WiFi)? Slå på Admin-PIN under Inställningar -> System & backup. Då kräver alla ändringar PIN - resultat, inställningar och uppdatering - medan visning och publika overlay-/träningslänkar fungerar som vanligt. Varje webbläsare loggar in en gång och kommer ihåg det i 30 dagar.",
|
||||
"guide.race_day_10": "10. Transponderkontroll före heatet: välj heatet på Race Day och tryck Starta uppvärmning (1-3 min, med utrop). Varje förare får grön bock när transpondern passerat. Saknas någon, byt transponder direkt i panelen eller välj en av de okända transpondrarna som passerat - vanligaste felet är att någon registrerat fel nummer. Start varnar om någon inte setts men går att starta ändå.",
|
||||
"guide.dashboard_title": "Schemaavvikelse på Översikt",
|
||||
"guide.dashboard_1": "Översikt visar nu skillnaden mellan planerad tid och faktisk körtid för alla startade sessioner.",
|
||||
"guide.dashboard_2": "Planerad tid räknar sessionstid plus follow-up. Faktisk tid räknar verklig tid från start till stopp eller nuvarande tid om heatet fortfarande kör.",
|
||||
@@ -2674,6 +2744,22 @@ const TRANSLATIONS = {
|
||||
"guide.tab_run": "Run a race",
|
||||
"guide.tab_training": "Training & results",
|
||||
"guide.tab_videos": "Video guides",
|
||||
"warmup.title": "Warm-up / transponder check",
|
||||
"warmup.hint": "Start the warm-up and let everyone cross the line. Green tick = the transponder has been seen. If someone is missing, change their transponder right here, or link an unknown transponder that just crossed.",
|
||||
"warmup.start": "Start warm-up",
|
||||
"warmup.restart": "Restart warm-up",
|
||||
"warmup.stop": "End warm-up",
|
||||
"warmup.minutes": "{minutes} min",
|
||||
"warmup.ended": "Warm-up is over - check the ticks and start the heat.",
|
||||
"warmup.seen_count": "{seen} of {total} seen",
|
||||
"warmup.no_transponder": "No transponder",
|
||||
"warmup.assign_unknown": "Link unknown transponder...",
|
||||
"warmup.new_transponder": "New transponder",
|
||||
"warmup.save": "Save",
|
||||
"warmup.unknown_seen": "Unknown transponders that crossed",
|
||||
"warmup.start_missing": "Not seen during warm-up: {names}. Start anyway?",
|
||||
"warmup.transponder_conflict": "Transponder {transponder} is already used by {name}. Save anyway?",
|
||||
"race_day.check_transponder_check": "Transponder check",
|
||||
"layouts.bar_current": "Track layout: {label} - since {date}",
|
||||
"layouts.bar_none": "No track layout set yet",
|
||||
"layouts.new": "New layout",
|
||||
@@ -3466,6 +3552,7 @@ const TRANSLATIONS = {
|
||||
"guide.race_day_7": "7. Session replay in Timing replays saved passings in an isolated test mode. Select a session, load it, and run at 1x, 2x, 5x, or 10x without modifying source results or training statistics.",
|
||||
"guide.race_day_8": "8. Already when you register or edit a driver under Drivers, the system warns if the transponder is already used by a different driver (e.g. in the wrong class) and names who - a complement to the preflight's duplicate-transponder check within a single heat. The same person racing multiple classes on their own transponder is never flagged.",
|
||||
"guide.race_day_9": "9. Running on a network others can reach (e.g. the venue WiFi)? Turn on the Admin PIN under Settings -> System & backup. Every change then needs the PIN - results, settings and updates - while viewing and public overlay/training links work as usual. Each browser logs in once and is remembered for 30 days.",
|
||||
"guide.race_day_10": "10. Transponder check before the heat: select the heat on Race Day and press Start warm-up (1-3 min, with announcements). Each driver gets a green tick once their transponder has crossed. If someone is missing, change their transponder right in the panel or pick one of the unknown transponders that crossed - the most common mistake is a wrongly registered number. Start warns if someone wasn't seen, but can still start.",
|
||||
"guide.dashboard_title": "Schedule drift on overview",
|
||||
"guide.dashboard_1": "Overview now shows the difference between planned time and actual elapsed time for all started sessions.",
|
||||
"guide.dashboard_2": "Planned time includes session duration plus follow-up. Actual time uses real time from start to stop, or the current time if the heat is still running.",
|
||||
|
||||
@@ -275,6 +275,7 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") {
|
||||
findEventTeamForPassing, persistPassingToBackend, pushOverlayEvent, buildLeaderboard,
|
||||
formatLap, announcePassing, saveState, lastOverlayLeaderKeyBySession,
|
||||
lastOverlayTop3BySession, lastOverlayBestLapByKey, triggerRaceAudio = () => {}, getTrackRecordMs = () => null,
|
||||
onTransponderSeen = () => {},
|
||||
} = deps;
|
||||
|
||||
const report = (reason, outcome) => decoderDiagnostics.message(decoderRole, msg, reason, outcome);
|
||||
@@ -289,6 +290,14 @@ export function processDecoderMessageHelper(msg, deps, decoderRole = "main") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Noted before the running-session check on purpose: the pre-heat warm-up / transponder
|
||||
// check needs to see passings while the heat hasn't started yet. Main line only, the
|
||||
// same line that counts laps.
|
||||
if (decoderRole === "main") {
|
||||
const seenTransponder = String(msg.transponder ?? msg.tran_code ?? "").replace("ID:", "").trim();
|
||||
if (seenTransponder) onTransponderSeen(seenTransponder);
|
||||
}
|
||||
|
||||
const session = getActiveSession();
|
||||
if (!session || session.status !== "running") {
|
||||
report("no_running_session");
|
||||
|
||||
@@ -97,7 +97,7 @@ export function renderGuideView({ dom, t }) {
|
||||
|
||||
<div data-guide-panel="run">
|
||||
<div class="guide-section-grid mt-16">
|
||||
${renderGuidePanel("guide.race_day_title", ["guide.race_day_1", "guide.race_day_2", "guide.race_day_3", "guide.race_day_4", "guide.race_day_5", "guide.race_day_6", "guide.race_day_7", "guide.race_day_8", "guide.race_day_9"], "", { t })}
|
||||
${renderGuidePanel("guide.race_day_title", ["guide.race_day_1", "guide.race_day_2", "guide.race_day_3", "guide.race_day_4", "guide.race_day_5", "guide.race_day_6", "guide.race_day_7", "guide.race_day_8", "guide.race_day_9", "guide.race_day_10"], "", { t })}
|
||||
${renderGuidePanel("guide.manage_steps_title", ["guide.manage_steps_1", "guide.manage_steps_2", "guide.manage_steps_3", "guide.manage_steps_4", "guide.manage_steps_5", "guide.manage_steps_6", "guide.manage_steps_7", "guide.manage_steps_8", "guide.manage_steps_9"], "", { t })}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export const RACE_AUDIO_EVENT_DEFINITIONS = Object.freeze([
|
||||
event("start_tone", "start", "high", false, "effects/start_beep.wav"),
|
||||
event("false_start", "start", "critical", true, "effects/warning.wav", 1000), event("restart_race", "start", "high"),
|
||||
event("staggered_release", "start", "normal"),
|
||||
event("warmup_start", "start", "normal"), event("warmup_1_minute", "start", "normal"), event("warmup_end", "start", "high"),
|
||||
event("transponder_pass", "race", "pass", false, "effects/transponder_pass.wav"), event("lap_completed", "race", "pass", false),
|
||||
event("personal_best", "race", "low", true, "effects/personal_best.wav", 1500),
|
||||
event("fastest_lap", "race", "normal", true, "effects/fastest_lap_trophy.wav", 1500),
|
||||
|
||||
@@ -12,7 +12,7 @@ function getEvent(state, session) {
|
||||
return state.events.find((event) => event.id === session?.eventId) || null;
|
||||
}
|
||||
|
||||
function getRaceDriverIds(state, session, event) {
|
||||
export function getRaceDriverIds(state, session, event = getEvent(state, session)) {
|
||||
if (Array.isArray(session?.driverIds) && session.driverIds.length) {
|
||||
return unique(session.driverIds);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ function renderPreflightDetail(item, { t, formatLap, escapeHtml }) {
|
||||
}
|
||||
if (item.id === "duplicate_transponders" && item.duplicates?.length) return escapeHtml(item.duplicates.join(", "));
|
||||
if (item.id === "duplicate_team_members" && item.duplicateTeamMemberNames?.length) return escapeHtml(item.duplicateTeamMemberNames.join(", "));
|
||||
if (item.id === "transponder_check") return `${item.seen}/${item.total}`;
|
||||
if (item.id === "grid") return item.notRequired ? t("race_day.not_required") : `${item.count || 0}/${item.expectedCount || 0}`;
|
||||
return t(`race_day.check_${item.level}`);
|
||||
}
|
||||
@@ -52,6 +53,107 @@ function renderQueueSession(session, positionKey, deps) {
|
||||
</article>`;
|
||||
}
|
||||
|
||||
|
||||
// --- Warm-up / transponder check -------------------------------------------------------
|
||||
// Shown for a selected, not-yet-started race-format heat with driver transponders. The panel
|
||||
// re-renders on its own (refreshRaceDayWarmup) on every passing, without rebuilding the page.
|
||||
let lastWarmupDeps = null;
|
||||
let warmupClockTimer = null;
|
||||
|
||||
function showsWarmupPanel(session) {
|
||||
return Boolean(session && session.status === "ready" && session.mode !== "track" && session.type !== "team_race" && !isPracticeSessionType(session.type));
|
||||
}
|
||||
|
||||
function formatMmSs(ms) {
|
||||
const total = Math.ceil(ms / 1000);
|
||||
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function renderWarmupPanelInner({ t, escapeHtml, getRaceDayWarmupCheck }, session) {
|
||||
const check = getRaceDayWarmupCheck(session);
|
||||
const warmup = check.warmup;
|
||||
const remaining = warmup && !warmup.stopped ? Math.max(0, warmup.startedAt + warmup.durationMs - Date.now()) : 0;
|
||||
const unknownOptions = check.unknownTransponders.map((item) => `<option value="${escapeHtml(item.transponder)}">${escapeHtml(item.ownerName ? `${item.transponder} (${item.ownerName})` : item.transponder)}</option>`).join("");
|
||||
const status = (row) => (!warmup ? "–" : row.seen ? "✅" : "⏳");
|
||||
return `
|
||||
<div class="panel-header">
|
||||
<div><h3>${t("warmup.title")}</h3><p class="hint">${t("warmup.hint")}</p></div>
|
||||
${warmup ? `<span class="pill ${check.seenCount === check.total ? "pill-green" : "pill-warn"}">${t("warmup.seen_count", { seen: check.seenCount, total: check.total })}</span>` : ""}
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="actions">
|
||||
${warmup && remaining > 0
|
||||
? `<strong class="warmup-clock" data-warmup-clock>${formatMmSs(remaining)}</strong><button class="btn" type="button" data-warmup-stop>${t("warmup.stop")}</button>`
|
||||
: `<select data-warmup-minutes>${[1, 2, 3].map((minutes) => `<option value="${minutes}" ${minutes === 2 ? "selected" : ""}>${t("warmup.minutes", { minutes })}</option>`).join("")}</select>
|
||||
<button class="btn btn-primary" type="button" data-warmup-start>${t(warmup ? "warmup.restart" : "warmup.start")}</button>
|
||||
${warmup ? `<span class="hint">${t("warmup.ended")}</span>` : ""}`}
|
||||
</div>
|
||||
<table class="warmup-table mt-16">
|
||||
<tbody>
|
||||
${check.rows.map((row) => `
|
||||
<tr class="${warmup && !row.seen ? "is-missing" : ""}" data-warmup-driver="${escapeHtml(row.driverId)}">
|
||||
<td class="warmup-status">${status(row)}</td>
|
||||
<td><strong>${escapeHtml(row.name)}</strong><br><small>${escapeHtml(row.transponder || t("warmup.no_transponder"))}</small></td>
|
||||
<td>${warmup && !row.seen ? `<div class="warmup-fix">
|
||||
${unknownOptions ? `<select data-warmup-assign><option value="">${t("warmup.assign_unknown")}</option>${unknownOptions}</select>` : ""}
|
||||
<input data-warmup-transponder placeholder="${escapeHtml(t("warmup.new_transponder"))}" inputmode="numeric" />
|
||||
<button class="btn btn-mini" type="button" data-warmup-save>${t("warmup.save")}</button></div>` : ""}</td>
|
||||
</tr>`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
${warmup && check.unknownTransponders.length ? `<p class="hint mt-16">${t("warmup.unknown_seen")}: ${check.unknownTransponders.map((item) => `<span class="pill">${escapeHtml(item.ownerName ? `${item.transponder} (${item.ownerName})` : item.transponder)}</span>`).join(" ")}</p>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function bindWarmupPanel(panel, deps, session) {
|
||||
const { startRaceDayWarmup, stopRaceDayWarmup, setDriverTransponder } = deps;
|
||||
panel.querySelector("[data-warmup-start]")?.addEventListener("click", () => {
|
||||
startRaceDayWarmup(session, Number(panel.querySelector("[data-warmup-minutes]")?.value || 2) * 60000);
|
||||
refreshRaceDayWarmup(true);
|
||||
});
|
||||
panel.querySelector("[data-warmup-stop]")?.addEventListener("click", () => {
|
||||
stopRaceDayWarmup();
|
||||
refreshRaceDayWarmup(true);
|
||||
});
|
||||
panel.querySelectorAll("[data-warmup-driver]").forEach((row) => {
|
||||
const driverId = row.dataset.warmupDriver;
|
||||
row.querySelector("[data-warmup-assign]")?.addEventListener("change", (event) => {
|
||||
if (event.target.value && setDriverTransponder(driverId, event.target.value)) refreshRaceDayWarmup(true);
|
||||
});
|
||||
row.querySelector("[data-warmup-save]")?.addEventListener("click", () => {
|
||||
if (setDriverTransponder(driverId, row.querySelector("[data-warmup-transponder]")?.value)) refreshRaceDayWarmup(true);
|
||||
});
|
||||
});
|
||||
clearInterval(warmupClockTimer);
|
||||
warmupClockTimer = setInterval(() => {
|
||||
if (!panel.isConnected) { clearInterval(warmupClockTimer); return; }
|
||||
const clock = panel.querySelector("[data-warmup-clock]");
|
||||
if (!clock) return;
|
||||
const warmup = deps.getRaceDayWarmupCheck(session).warmup;
|
||||
const remaining = warmup ? Math.max(0, warmup.startedAt + warmup.durationMs - Date.now()) : 0;
|
||||
clock.textContent = formatMmSs(remaining);
|
||||
if (remaining <= 0) refreshRaceDayWarmup(true);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function paintWarmupPanel() {
|
||||
const panel = document.getElementById("raceDayWarmup");
|
||||
if (!panel || !lastWarmupDeps) return;
|
||||
const session = lastWarmupDeps.getSession();
|
||||
if (!showsWarmupPanel(session)) return;
|
||||
panel.innerHTML = renderWarmupPanelInner(lastWarmupDeps, session);
|
||||
bindWarmupPanel(panel, lastWarmupDeps, session);
|
||||
}
|
||||
|
||||
// Re-renders the whole Race Day page (a few ms) so the panel and the preflight row stay in
|
||||
// step. On a passing (force = false) it waits while the operator is typing in any field,
|
||||
// so a half-entered transponder or lap window is never wiped.
|
||||
export function refreshRaceDayWarmup(force = false) {
|
||||
if (!lastWarmupDeps || !document.getElementById("raceDayWarmup")) return;
|
||||
if (!force && ["INPUT", "SELECT", "TEXTAREA"].includes(document.activeElement?.tagName)) return;
|
||||
lastWarmupDeps.renderView();
|
||||
}
|
||||
|
||||
export function renderRaceDayView(deps) {
|
||||
const {
|
||||
state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock,
|
||||
@@ -59,11 +161,16 @@ export function renderRaceDayView(deps) {
|
||||
getSessionTiming, getSessionGridEntries, buildRaceDayQueue, buildRaceDayPreflight,
|
||||
backendAvailable, selectSession, startSession, finishSession, pauseSession, resumeSession,
|
||||
saveLapWindow, navigateTo, getStartLightState, addTrackLayout, renderView,
|
||||
getRaceDayWarmupCheck, startRaceDayWarmup, stopRaceDayWarmup, setDriverTransponder,
|
||||
} = deps;
|
||||
const queue = buildRaceDayQueue(state);
|
||||
const session = queue.current;
|
||||
const preflight = buildRaceDayPreflight(state, session, { backendAvailable });
|
||||
const checks = sortRaceDayChecks(preflight.checks);
|
||||
const warmupForChecks = showsWarmupPanel(session) ? getRaceDayWarmupCheck(session) : null;
|
||||
const checks = sortRaceDayChecks([
|
||||
...preflight.checks,
|
||||
...(warmupForChecks?.warmup ? [{ id: "transponder_check", level: warmupForChecks.missing.length ? "warning" : "ok", seen: warmupForChecks.seenCount, total: warmupForChecks.total }] : []),
|
||||
]);
|
||||
const blockers = checks.filter((item) => item.level === "error").length;
|
||||
const warnings = checks.filter((item) => item.level === "warning").length;
|
||||
const lapWindowCheck = preflight.checks.find((item) => item.id === "lap_window") || null;
|
||||
@@ -113,6 +220,8 @@ export function renderRaceDayView(deps) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
${showsWarmupPanel(session) ? `<section class="panel mt-16" id="raceDayWarmup"></section>` : ""}
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header"><h3>${t("race_day.start_list")}</h3><span class="pill">${grid.length}</span></div>
|
||||
<div class="panel-body race-day-grid-list">
|
||||
@@ -185,8 +294,15 @@ export function renderRaceDayView(deps) {
|
||||
saveLapWindow(session, minLapSec, maxLapSec);
|
||||
});
|
||||
bindTrackLayoutBar(dom.view, { state, t, addTrackLayout, renderView });
|
||||
lastWarmupDeps = { t, escapeHtml, renderView, getRaceDayWarmupCheck, startRaceDayWarmup, stopRaceDayWarmup, setDriverTransponder, getSession: () => buildRaceDayQueue(state).current };
|
||||
paintWarmupPanel();
|
||||
document.getElementById("raceDayStart")?.addEventListener("click", async () => {
|
||||
if (!session || !preflight.ready) return;
|
||||
// Warns (never blocks - a driver may not show up) when the warm-up ran and someone in
|
||||
// the heat still hasn't crossed the line.
|
||||
const warmupCheck = showsWarmupPanel(session) ? getRaceDayWarmupCheck(session) : null;
|
||||
if (warmupCheck?.warmup && warmupCheck.missing.length && !window.confirm(t("warmup.start_missing", { names: warmupCheck.missing.map((row) => row.name).join(", ") }))) return;
|
||||
stopRaceDayWarmup();
|
||||
if (!isSelected) selectSession(session.id, { render: false });
|
||||
const button = document.getElementById("raceDayStart");
|
||||
if (button) button.disabled = true;
|
||||
|
||||
@@ -4447,3 +4447,45 @@ select:focus {
|
||||
font-size: 0.86rem;
|
||||
color: var(--muted, inherit);
|
||||
}
|
||||
|
||||
.warmup-clock {
|
||||
font-size: 1.6rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.warmup-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.warmup-table td {
|
||||
padding: 8px 6px;
|
||||
border-top: 1px solid var(--line);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.warmup-status {
|
||||
width: 2.2em;
|
||||
text-align: center;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.warmup-table tr.is-missing td {
|
||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||
}
|
||||
|
||||
.warmup-fix {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.warmup-fix input {
|
||||
width: 10em;
|
||||
}
|
||||
|
||||
#raceDayWarmup .panel-header .pill {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
45
src/warmup_logic.js
Normal file
45
src/warmup_logic.js
Normal file
@@ -0,0 +1,45 @@
|
||||
// Warm-up / transponder check before a heat: every passing the main decoder sees is
|
||||
// remembered (transponder -> when), independent of whether a session is running, so the
|
||||
// Race Day panel can show which drivers in the selected heat have been seen since the
|
||||
// warm-up started and which unknown transponders crossed the line.
|
||||
const MAX_SIGHTINGS = 500;
|
||||
|
||||
export function recordTransponderSighting(sightings, transponder, at = Date.now()) {
|
||||
const key = String(transponder || "").trim();
|
||||
if (!key) return sightings;
|
||||
const previous = sightings.get(key);
|
||||
sightings.delete(key); // re-insert so Map order stays oldest -> newest
|
||||
sightings.set(key, { transponder: key, lastSeenAt: at, count: (previous?.count || 0) + 1 });
|
||||
while (sightings.size > MAX_SIGHTINGS) sightings.delete(sightings.keys().next().value);
|
||||
return sightings;
|
||||
}
|
||||
|
||||
// entrants: drivers in the heat, in start order. drivers: every registered driver, used to
|
||||
// name the owner of a transponder that crossed the line but doesn't belong to this heat.
|
||||
export function buildWarmupCheck({ entrants = [], drivers = [], sightings = new Map(), since = 0 }) {
|
||||
const seenSince = (transponder) => {
|
||||
const sighting = transponder ? sightings.get(String(transponder).trim()) : null;
|
||||
return sighting && sighting.lastSeenAt >= since ? sighting : null;
|
||||
};
|
||||
const rows = entrants.map((driver) => {
|
||||
const sighting = seenSince(driver.transponder);
|
||||
return { driverId: driver.id, name: driver.name, transponder: String(driver.transponder || ""), seen: Boolean(sighting), lastSeenAt: sighting?.lastSeenAt || null };
|
||||
});
|
||||
const entrantTransponders = new Set(rows.map((row) => row.transponder).filter(Boolean));
|
||||
const unknownTransponders = [...sightings.values()]
|
||||
.filter((sighting) => sighting.lastSeenAt >= since && !entrantTransponders.has(sighting.transponder))
|
||||
.map((sighting) => ({ ...sighting, ownerName: drivers.find((driver) => String(driver.transponder || "") === sighting.transponder)?.name || "" }))
|
||||
.sort((a, b) => b.lastSeenAt - a.lastSeenAt);
|
||||
return {
|
||||
rows,
|
||||
total: rows.length,
|
||||
seenCount: rows.filter((row) => row.seen).length,
|
||||
missing: rows.filter((row) => !row.seen),
|
||||
unknownTransponders,
|
||||
};
|
||||
}
|
||||
|
||||
export function getWarmupRemainingMs(warmup, now = Date.now()) {
|
||||
if (!warmup?.startedAt) return 0;
|
||||
return Math.max(0, warmup.startedAt + warmup.durationMs - now);
|
||||
}
|
||||
Reference in New Issue
Block a user