import { sortableTable, sortValuesAttribute } from "./list_sorting.js"; import { advanceSessionReplay, buildSessionReplaySnapshot, loadSessionReplay, pauseSessionReplay, playSessionReplay, resetSessionReplay, setSessionReplaySpeed, } from "./session_replay.js"; // With several races each generating a practice + multiple qualifying heats + finals, a // flat list of every session quickly becomes long and hard to scan. Groups sessions by // their event, most-recently-created event first (state.events is already append-order), // keeping each event's own sessions in their existing (already chronological) order. function groupSessionsByEventRecency(sessions, state) { const knownEventIds = new Set(state.events.map((event) => event.id)); const orderedEventIds = [...state.events].map((event) => event.id).reverse(); const groups = new Map(); sessions.forEach((session) => { const key = knownEventIds.has(session.eventId) ? session.eventId : "__unknown__"; if (!groups.has(key)) groups.set(key, []); groups.get(key).push(session); }); const orderedKeys = [...orderedEventIds.filter((id) => groups.has(id)), ...(groups.has("__unknown__") ? ["__unknown__"] : [])]; return orderedKeys.map((key) => ({ eventId: key === "__unknown__" ? null : key, sessions: groups.get(key) })); } function stopSessionReplayTimer(runtime) { if (runtime?.timer) clearInterval(runtime.timer); if (runtime) runtime.timer = null; } function renderSessionReplayContent(runtime, { t, escapeHtml, formatLap, formatElapsedClock }) { const snapshot = buildSessionReplaySnapshot(runtime); const rows = snapshot.leaderboard.slice(0, 12); return `
${formatElapsedClock(snapshot.elapsedMs)} / ${formatElapsedClock(snapshot.durationMs)} ${escapeHtml(t(`timing.replay_status_${snapshot.status}`))}
${snapshot.latestPassing ? `

${t("timing.replay_latest")}${escapeHtml(snapshot.latestPassing.driverName)}${formatLap(snapshot.latestPassing.lapMs)}

` : `

${t("timing.replay_no_visible_passings")}

`} ${rows.length ? `
${rows.map((row, index) => ``).join("")}
${t("table.pos")}${t("table.driver")}${t("table.laps")}${t("table.last_lap")}${t("table.best_lap")}
${index + 1}${escapeHtml(row.driverName)}${escapeHtml(row.carName || row.transponder)}${row.laps}${formatLap(row.lastLapMs)}${formatLap(row.bestLapMs)}
` : ""} `; } function updateSessionReplayPanel(runtime, deps) { const content = document.querySelector("[data-session-replay-content]"); if (content) content.innerHTML = renderSessionReplayContent(runtime, deps); } function startSessionReplayTimer(runtime, deps) { stopSessionReplayTimer(runtime); runtime.timer = setInterval(() => { advanceSessionReplay(runtime); updateSessionReplayPanel(runtime, deps); if (runtime.status !== "running") stopSessionReplayTimer(runtime); }, 100); } function renderSessionReplayPanel(runtime, deps) { const { state, t, escapeHtml, getEventName, formatLap, formatElapsedClock } = deps; if (runtime.status === "running") advanceSessionReplay(runtime); const replayable = state.sessions.filter((session) => state.resultsBySession?.[session.id]?.passings?.length); const selectedId = runtime.sessionId || replayable[0]?.id || ""; return `

${t("timing.session_replay")}

${t("timing.session_replay_hint")}

${t("timing.replay_isolated")}
${runtime.passings.length ? renderSessionReplayContent(runtime, { t, escapeHtml, formatLap, formatElapsedClock }) : `

${t(replayable.length ? "timing.replay_load_hint" : "timing.replay_empty")}

`}
`; } function renderSpeakerToggle(settingKey, labelKey, { state, t }) { return ` `; } function getQuickAddState(transponder, { state }) { const normalized = String(transponder || "").trim(); const driver = state.drivers.find((item) => String(item.transponder || "").trim() === normalized) || null; const car = state.cars.find((item) => String(item.transponder || "").trim() === normalized) || null; return { transponder: normalized, hasDriver: Boolean(driver), hasCar: Boolean(car), }; } function getPreferredClassId(session, { state }) { const event = state.events.find((item) => item.id === session?.eventId); if (event?.classId) { return event.classId; } return state.classes[0]?.id || ""; } function beginQuickAddDraft(session, type, transponder, deps) { const { state, setQuickAddDraft, renderView, getPreferredClassId } = deps; const normalized = String(transponder || "").trim(); if (!normalized) { return; } if (type === "driver" && state.drivers.some((item) => String(item.transponder || "").trim() === normalized)) { return; } if (type === "car" && state.cars.some((item) => String(item.transponder || "").trim() === normalized)) { return; } setQuickAddDraft({ type, transponder: normalized, classId: getPreferredClassId(session), name: type === "driver" ? normalized : `Car ${normalized}`, }); renderView(); } function renderQuickAddActions(session, transponder, idPrefix, deps) { const { t, getQuickAddState } = deps; const quickState = getQuickAddState(transponder); if (!quickState.transponder || (quickState.hasDriver && quickState.hasCar)) { return ""; } return `
${!quickState.hasDriver ? `` : ""} ${!quickState.hasCar ? `` : ""}
`; } function bindQuickAddActions(session, transponder, idPrefix, deps) { const { beginQuickAddDraft } = deps; document.getElementById(`${idPrefix}-add-driver`)?.addEventListener("click", () => { beginQuickAddDraft(session, "driver", transponder); }); document.getElementById(`${idPrefix}-add-car`)?.addEventListener("click", () => { beginQuickAddDraft(session, "car", transponder); }); } function renderQuickAddPanel(session, deps) { const { state, t, escapeHtml, getQuickAddDraft, getPreferredClassId } = deps; const quickAddDraft = getQuickAddDraft(); if (!quickAddDraft || !quickAddDraft.transponder) { return ""; } const classOptions = state.classes .map( (item) => `` ) .join(""); const isDriver = quickAddDraft.type === "driver"; return `

${t(isDriver ? "timing.quick_add_driver_title" : "timing.quick_add_car_title")}

${ isDriver ? `` : `
${t("timing.quick_add_hint")}
` }
`; } function renderLeaderboardModal(session, row, deps) { const { t, escapeHtml, formatLap, formatRaceClock, renderTable, getCompetitorPassings, renderQuickAddActions } = deps; const passings = getCompetitorPassings(session, row); return ` `; } function renderLeaderboard(rows, deps) { const { t, escapeHtml, renderTable, getManualCorrectionSummary, formatLap, formatTeamActiveMemberLabel } = deps; if (!rows.length) { return `

${t("timing.no_laps")}

`; } return sortableTable(renderTable( [ t("table.pos"), t("table.driver"), t("table.car"), t("table.transponder"), t("table.laps"), t("table.result"), t("table.last_lap"), t("table.best_lap"), t("table.leader_gap"), t("table.ahead_gap"), t("table.own_delta"), t("table.split"), t("table.pit"), "", ], rows.map((row, idx) => { const posClass = idx === 0 ? "pos-1" : idx === 1 ? "pos-2" : idx === 2 ? "pos-3" : ""; return ` ${idx + 1}
${escapeHtml(row.displayName || row.driverName)}${row.personalTimeUp ? ` 🏁 ${t("timing.personal_time_up")}` : ""}
${row.teamId ? `
${t("overlay.active_member")}: ${escapeHtml(formatTeamActiveMemberLabel(row))}
` : ""} ${getManualCorrectionSummary(row) ? `
${escapeHtml(getManualCorrectionSummary(row))}
` : ""} ${row.invalidPending ? `
${escapeHtml(row.invalidLabel)}${row.invalidLapMs ? ` • ${formatLap(row.invalidLapMs)}` : ""}
` : ""} ${escapeHtml(row.subLabel || row.carName)} ${escapeHtml(row.transponder)} ${row.laps} ${escapeHtml(row.resultDisplay)} ${formatLap(row.lastLapMs)} ${formatLap(row.bestLapMs)} ${escapeHtml(row.leaderGap || row.gap || "-")} ${escapeHtml(row.gapAhead || "-")} ${escapeHtml(row.lapDelta || "-")} ${formatLap(row.lastSplitMs)} ${row.pitActive ? `${t("timing.pit_active")}` : `${Number(row.pitStops || 0)} / ${formatLap(row.totalPitMs)}`} `; }) ), "timing-leaderboard", [1, 2, 3]); } function renderRecentPassings(session, deps) { const { t, escapeHtml, renderTable, ensureSessionResult, formatLap, getPassingValidationLabel, isCountedPassing, renderQuickAddActions } = deps; if (!session) { return `

${t("timing.no_session_selected")}

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

${t("timing.no_passings")}

`; } return sortableTable(renderTable( [t("table.time"), t("table.transponder"), t("table.driver"), t("table.car"), t("table.last_lap"), t("table.status"), ""], items.map((p, index) => { return ` ${new Date(p.timestamp).toLocaleTimeString()} ${escapeHtml(p.transponder)} ${escapeHtml(p.teamName ? `${p.teamName} • ${p.driverName || t("common.unknown_driver")}` : p.driverName || t("common.unknown_driver"))} ${escapeHtml(p.carName || p.subLabel || "-")} ${formatLap(p.lapMs)} ${escapeHtml(getPassingValidationLabel(p))} ${renderQuickAddActions(session, p.transponder, `recentPassing-${index}`)} `; }) ), "timing-passings", [1, 2, 3, 5]); } export function renderTimingView(deps) { const leaderboardModalScrollTop = document.querySelector("#leaderboardModalOverlay .modal-card")?.scrollTop || 0; const { state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, renderTable, uid, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings, getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid, connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, startOperationalSession, finishOperationalSession, cancelRaceStart, triggerRaceAudio, getStartLightState, saveState, updateHeaderState, renderView, normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey, setSelectedLeaderboardKey, getQuickAddDraft, setQuickAddDraft, getPassingValidationLabel, isCountedPassing, getCompetitorPassings, getManualCorrectionSummary, formatTeamActiveMemberLabel, formatRaceClock, renderSessionChannelAnalysis, setLastFinishAnnouncementSessionId, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, overlayEvents, setOverlayEvents, sessionReplay, } = deps; const helperDeps = { state, t, escapeHtml, renderTable, formatLap, formatRaceClock, getCompetitorPassings, getQuickAddDraft, getPassingValidationLabel, isCountedPassing, getManualCorrectionSummary, formatTeamActiveMemberLabel, }; helperDeps.getPreferredClassId = (session) => getPreferredClassId(session, { state }); helperDeps.getQuickAddState = (transponder) => getQuickAddState(transponder, { state }); helperDeps.setQuickAddDraft = setQuickAddDraft; helperDeps.renderView = renderView; helperDeps.beginQuickAddDraft = (session, type, transponder) => beginQuickAddDraft(session, type, transponder, helperDeps); helperDeps.renderQuickAddActions = (session, transponder, idPrefix) => renderQuickAddActions(session, transponder, idPrefix, helperDeps); const active = getActiveSession(); const result = active ? ensureSessionResult(active.id) : null; const leaderboard = active ? buildLeaderboard(active) : []; const sessionTiming = active ? getSessionTiming(active) : null; 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 === getSelectedLeaderboardKey()) || null; const channelAnalysis = active ? renderSessionChannelAnalysis(active) : ""; if (getSelectedLeaderboardKey() && !selectedRow) { setSelectedLeaderboardKey(null); } dom.view.innerHTML = `

${t("timing.decoder_connection")}

${t("timing.status")} ${t("timing.main_decoder")}: ${state.decoder.connected ? t("timing.connected") : t("timing.disconnected")} ${t("timing.last_message")}: ${state.decoder.lastMessageAt ? new Date(state.decoder.lastMessageAt).toLocaleString() : "-"} ${state.settings.splitDecoderEnabled ? `${t("timing.split_decoder")}: ${state.decoder.splitConnected ? t("timing.connected") : t("timing.disconnected")}${t("timing.last_message")}: ${state.decoder.splitLastMessageAt ? new Date(state.decoder.splitLastMessageAt).toLocaleString() : "-"}` : ""} ${state.settings.pitDecoderEnabled ? `${t("timing.pit_entry_decoder")}: ${state.decoder.pitEntryConnected ? t("timing.connected") : t("timing.disconnected")}${t("timing.last_message")}: ${state.decoder.pitEntryLastMessageAt ? new Date(state.decoder.pitEntryLastMessageAt).toLocaleString() : "-"}${t("timing.pit_exit_decoder")}: ${state.decoder.pitExitConnected ? t("timing.connected") : t("timing.disconnected")}${t("timing.last_message")}: ${state.decoder.pitExitLastMessageAt ? new Date(state.decoder.pitExitLastMessageAt).toLocaleString() : "-"}` : ""}

${escapeHtml(state.decoder.lastError || "")}

${escapeHtml(state.decoder.splitLastError || state.decoder.pitEntryLastError || state.decoder.pitExitLastError || "")}

${t("timing.control")}

${[1, 2, 3, 4, 5].map((light) => ``).join("")} ${escapeHtml(getStartLightState())}
${ active ? `
${escapeHtml(active.name)} ${escapeHtml(getSessionTypeLabel(active.type))} ${escapeHtml(getEventName(active.eventId))} ${escapeHtml(getStatusLabel(active.status))}
${clockLabel}${clockValue}
${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")}${getVisiblePassings(result).length}
${ active.type === "free_practice" ? `

${t("events.free_practice_note")}

` : active.type === "open_practice" ? `

${t("events.open_practice_note")}

` : "" }` : `

${t("timing.no_active")}

` } ${showFollowUpBanner ? `

${t("timing.follow_up_active")}

` : ""} ${showFinishedBanner ? `

${t("timing.race_finished")}

` : ""} ${active && normalizeStartMode(active.startMode) === "position" ? renderPositionGrid(active) : ""}
${active ? renderQuickAddPanel(active, helperDeps) : ""} ${renderSessionReplayPanel(sessionReplay, { state, t, escapeHtml, getEventName, formatLap, formatElapsedClock })}

${t("timing.speaker_panel")}

${t("timing.speaker_panel_hint")}

${renderSpeakerToggle("speakerPassingCueEnabled", "settings.speaker_passing_cue", { state, t })} ${renderSpeakerToggle("speakerLeaderCueEnabled", "settings.speaker_leader_cue", { state, t })} ${renderSpeakerToggle("speakerBestLapCueEnabled", "settings.speaker_bestlap_cue", { state, t })} ${renderSpeakerToggle("speakerTop3CueEnabled", "settings.speaker_top3_cue", { state, t })} ${renderSpeakerToggle("speakerSessionStartCueEnabled", "settings.speaker_start_cue", { state, t })} ${renderSpeakerToggle("speakerFinishCueEnabled", "settings.speaker_finish_cue", { state, t })}

${t("timing.leaderboard")}

${renderLeaderboard(leaderboard, helperDeps)}
${channelAnalysis ? `

${t("timing.channel_analysis")}

${channelAnalysis}
` : ""}

${t("timing.recent_passings")}

${renderRecentPassings(active, { ...helperDeps, ensureSessionResult, renderQuickAddActions: (session, transponder, idPrefix) => renderQuickAddActions(session, transponder, idPrefix, helperDeps) })}
${selectedRow && active ? renderLeaderboardModal(active, selectedRow, { ...helperDeps, renderQuickAddActions: (session, transponder, idPrefix) => renderQuickAddActions(session, transponder, idPrefix, helperDeps) }) : ""} `; if (selectedRow && leaderboardModalScrollTop > 0) { const modalCard = document.querySelector("#leaderboardModalOverlay .modal-card"); if (modalCard instanceof HTMLElement) modalCard.scrollTop = leaderboardModalScrollTop; } document.getElementById("timingConnect")?.addEventListener("click", () => { const input = document.getElementById("timingWsUrl"); if (input && input.value.trim()) { state.settings.wsUrl = input.value.trim(); } state.settings.splitDecoderEnabled = Boolean(document.getElementById("timingSplitEnabled")?.checked); state.settings.splitWsUrl = String(document.getElementById("timingSplitWsUrl")?.value || "").trim(); state.settings.pitDecoderEnabled = Boolean(document.getElementById("timingPitEnabled")?.checked); state.settings.pitEntryWsUrl = String(document.getElementById("timingPitEntryWsUrl")?.value || "").trim(); state.settings.pitExitWsUrl = String(document.getElementById("timingPitExitWsUrl")?.value || "").trim(); saveState(); connectDecoder(); }); document.getElementById("timingDisconnect")?.addEventListener("click", disconnectDecoder); const replayRenderDeps = { t, escapeHtml, formatLap, formatElapsedClock }; document.getElementById("sessionReplayLoad")?.addEventListener("click", () => { const sessionId = String(document.getElementById("sessionReplaySelect")?.value || ""); const sourceSession = state.sessions.find((session) => session.id === sessionId); if (!sourceSession) return; stopSessionReplayTimer(sessionReplay); loadSessionReplay(sessionReplay, sourceSession, state.resultsBySession[sourceSession.id]); renderView(); }); document.getElementById("sessionReplayPlay")?.addEventListener("click", () => { if (!playSessionReplay(sessionReplay)) return; renderView(); startSessionReplayTimer(sessionReplay, replayRenderDeps); }); document.getElementById("sessionReplayPause")?.addEventListener("click", () => { pauseSessionReplay(sessionReplay); stopSessionReplayTimer(sessionReplay); renderView(); }); document.getElementById("sessionReplayReset")?.addEventListener("click", () => { stopSessionReplayTimer(sessionReplay); resetSessionReplay(sessionReplay); renderView(); }); document.getElementById("sessionReplaySpeed")?.addEventListener("change", (event) => { setSessionReplaySpeed(sessionReplay, Number(event.currentTarget?.value || 1)); updateSessionReplayPanel(sessionReplay, replayRenderDeps); }); leaderboard.forEach((row) => { document.getElementById(`leaderboard-detail-${row.key}`)?.addEventListener("click", () => { setSelectedLeaderboardKey(row.key); renderView(); }); }); if (active && selectedRow) { bindQuickAddActions(active, selectedRow.transponder, "leaderboardModal", helperDeps); 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("corrInvalidateLast")?.addEventListener("click", () => { invalidateCompetitorLastLap(active, selectedRow); renderView(); }); document.getElementById("corrRestoreInvalid")?.addEventListener("click", () => { restoreCompetitorLastInvalidLap(active, selectedRow); renderView(); }); document.getElementById("corrReset")?.addEventListener("click", () => { applyCompetitorCorrection(active, selectedRow, { reset: true }); renderView(); }); } if (active) { ensureSessionResult(active.id) .passings.slice(-20) .reverse() .forEach((passing, index) => { bindQuickAddActions(active, passing.transponder, `recentPassing-${index}`, helperDeps); }); } document.getElementById("quickAddCancel")?.addEventListener("click", () => { setQuickAddDraft(null); renderView(); }); document.getElementById("quickAddForm")?.addEventListener("submit", (event) => { event.preventDefault(); const quickAddDraft = getQuickAddDraft(); if (!active || !quickAddDraft) { return; } const form = new FormData(event.currentTarget); const name = String(form.get("name") || "").trim(); if (!name) { return; } const transponder = String(form.get("transponder") || "").trim(); const brand = String(form.get("brand") || "").trim(); if (!transponder) { return; } if (quickAddDraft.type === "driver") { if (!state.drivers.some((item) => String(item.transponder || "").trim() === transponder)) { state.drivers.push( normalizeDriver({ id: uid("driver"), name, classId: String(form.get("classId") || getPreferredClassId(active, { state })), brand, transponder, }) ); } } else if (!state.cars.some((item) => String(item.transponder || "").trim() === transponder)) { state.cars.push( normalizeCar({ id: uid("car"), name, brand, transponder, }) ); } setQuickAddDraft(null); saveState(); renderView(); }); document.getElementById("leaderboardModalClose")?.addEventListener("click", () => { setSelectedLeaderboardKey(null); renderView(); }); document.getElementById("leaderboardModalOverlay")?.addEventListener("click", (event) => { if (event.target?.id === "leaderboardModalOverlay") { setSelectedLeaderboardKey(null); renderView(); } }); document.getElementById("timingSimPass")?.addEventListener("click", () => { const tp = prompt(t("timing.prompt_transponder"), "232323"); if (!tp) { return; } const changed = processDecoderMessage({ msg: "PASSING", transponder: tp, rtc_time: new Date().toISOString(), strength: 0, resend: false, loop_id: "sim", }); if (changed) renderView(); }); document.getElementById("setActiveSession")?.addEventListener("click", () => { const select = document.getElementById("activeSessionSelect"); if (!select || !select.value) { return; } state.activeSessionId = select.value; saveState(); updateHeaderState(); renderView(); }); document.getElementById("startSession")?.addEventListener("click", async () => { const session = getActiveSession(); const startButton = document.getElementById("startSession"); if (!session || startButton?.disabled) return; if (startButton) startButton.disabled = true; const updateStartDisplay = (label, lights = 0) => { if (startButton && label) startButton.textContent = label; const panel = document.getElementById("raceStartLights"); if (!panel) return; panel.dataset.state = label; panel.querySelector("strong")?.replaceChildren(document.createTextNode(label)); panel.querySelectorAll("[data-start-light]").forEach((node) => node.classList.toggle("is-on", Number(node.dataset.startLight) <= lights)); }; const result = await startOperationalSession(session, { onCountdown: (seconds) => updateStartDisplay(String(seconds), 0), onLight: (light) => updateStartDisplay("LIGHT_" + light, light), onArmed: () => updateStartDisplay("WAIT_RANDOM", 5), }); if (!result.ok) { if (result.message) alert(result.message); if (startButton) startButton.disabled = false; updateStartDisplay(t("timing.start"), 0); } }); document.getElementById("stopSession")?.addEventListener("click", () => { const session = getActiveSession(); if (session) finishOperationalSession(session); }); document.getElementById("resetSession")?.addEventListener("click", () => { const session = getActiveSession(); if (!session) { return; } if (!confirm(t("timing.clear_confirm"))) { return; } delete state.resultsBySession[session.id]; session.followUpStartedAt = null; setLastFinishAnnouncementSessionId(null); delete lastOverlayLeaderKeyBySession[session.id]; delete lastOverlayTop3BySession[session.id]; setOverlayEvents([]); saveState(); renderView(); }); document.querySelectorAll("[data-race-audio-cue]").forEach((node) => { node.addEventListener("click", () => { const cue = node.dataset.raceAudioCue; const session = getActiveSession(); if (cue === "false_start") { cancelRaceStart(); const startButton = document.getElementById("startSession"); if (startButton) { startButton.disabled = false; startButton.textContent = t("timing.start"); } } else if (cue === "race_paused" && session?.status === "running") { session.status = "paused"; session.pausedAt = Date.now(); saveState(); } else if (cue === "race_resumed" && session?.status === "paused") { const pausedDuration = Math.max(0, Date.now() - Number(session.pausedAt || Date.now())); session.totalPausedMs = Math.max(0, Number(session.totalPausedMs || 0)) + pausedDuration; if (session.followUpStartedAt) session.followUpPausedMs = Math.max(0, Number(session.followUpPausedMs || 0)) + pausedDuration; session.pausedAt = null; session.status = "running"; saveState(); } triggerRaceAudio(cue, { interrupt: true, force: true, logData: { raceId: session?.eventId, sessionId: session?.id } }); // Persist after dispatch so the audio-event log includes this manual race-control call. saveState(); if (["race_paused", "race_resumed"].includes(cue)) { updateHeaderState(); renderView(); } }); }); document.querySelectorAll("[data-speaker-setting]").forEach((node) => { node.addEventListener("change", (event) => { const input = event.currentTarget; if (!(input instanceof HTMLInputElement)) { return; } state.settings[input.dataset.speakerSetting] = input.checked; saveState(); }); }); } export function renderJudgingView(deps) { const { dom, t, escapeHtml, formatLap, renderTable, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTypeLabel, getJudgeFilteredRows, getJudgeFilteredLog, getCompetitorPassings, isCountedPassing, getPassingValidationLabel, undoJudgingAdjustment, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, renderView, getSelectedJudgeKey, setSelectedJudgeKey, getJudgingCompetitorFilter, setJudgingCompetitorFilter, getJudgingLogFilter, setJudgingLogFilter, } = deps; const active = getActiveSession(); if (!active) { dom.view.innerHTML = `

${t("judging.title")}

${t("judging.no_active_session")}

`; return; } const result = ensureSessionResult(active.id); const leaderboard = buildLeaderboard(active); const filteredRows = getJudgeFilteredRows(leaderboard, getJudgingCompetitorFilter()); if (getSelectedJudgeKey() && !filteredRows.some((row) => row.key === getSelectedJudgeKey())) { setSelectedJudgeKey(null); } if (!getSelectedJudgeKey() && filteredRows.length) { setSelectedJudgeKey(filteredRows[0].key); } const selectedRow = leaderboard.find((row) => row.key === getSelectedJudgeKey()) || null; const selectedPassings = selectedRow ? getCompetitorPassings(active, selectedRow, { includeInvalid: true }) : []; const actionLog = getJudgeFilteredLog(Array.isArray(result.adjustments) ? result.adjustments.slice(-50).reverse() : [], getJudgingLogFilter()); const latestUndoable = (result.adjustments || []).slice().reverse().find((entry) => !entry.undoneAt && entry.undo); dom.view.innerHTML = `

${t("judging.title")}

${escapeHtml(active.name)}

${t("judging.active_session")}: ${escapeHtml(active.name)} • ${escapeHtml(getSessionTypeLabel(active.type))}

${t("judging.select_competitor")}

${renderTable( [t("table.pos"), t("table.driver"), t("table.result"), t("table.best_lap"), ""], filteredRows.map( (row, index) => ` ${index + 1}
${escapeHtml(row.displayName || row.driverName)}
${escapeHtml(row.subLabel || row.transponder || "-")}
${escapeHtml(row.resultDisplay || "-")} ${formatLap(row.bestLapMs)} ` ) )}

${t("judging.manual_actions")}

${ selectedRow ? `

${escapeHtml(selectedRow.displayName || selectedRow.driverName)} • ${escapeHtml(selectedRow.subLabel || selectedRow.carName || "-")}

${t("table.laps")}: ${selectedRow.laps}

${t("table.best_lap")}: ${formatLap(selectedRow.bestLapMs)}

${t("table.last_lap")}: ${formatLap(selectedRow.lastLapMs)}

${t("timing.lap_history")}

${ selectedPassings.length ? renderTable( [t("table.lap"), t("table.last_lap"), t("table.status")], selectedPassings.map( (passing, index) => ` ${index + 1} ${formatLap(passing.lapMs)} ${escapeHtml(getPassingValidationLabel(passing))} ` ) ) : `

${t("timing.no_lap_history")}

` }
` : `

${t("judging.selected_none")}

` }

${t("judging.action_log")}

${ actionLog.length ? renderTable( [t("table.time"), t("table.driver"), t("events.actions"), t("table.status"), ""], actionLog.map( (entry) => ` ${new Date(entry.ts).toLocaleTimeString()} ${escapeHtml(entry.displayName || "-")} ${escapeHtml(entry.action || "-")} ${escapeHtml(entry.detail || "-")}${entry.undoneAt ? ` • ${escapeHtml(t("judging.filter_log_undo"))}` : ""} ${entry.undo && !entry.undoneAt ? `` : ""} ` ) ) : `

${t("judging.no_action_log")}

` }
`; leaderboard.forEach((row) => { document.getElementById(`judge-select-${row.key}`)?.addEventListener("click", () => { setSelectedJudgeKey(row.key); renderJudgingView(deps); }); }); document.getElementById("judgingCompetitorFilter")?.addEventListener("change", (event) => { const input = event.currentTarget; if (!(input instanceof HTMLSelectElement)) { return; } setJudgingCompetitorFilter(input.value); renderJudgingView(deps); }); document.getElementById("judgingLogFilter")?.addEventListener("change", (event) => { const input = event.currentTarget; if (!(input instanceof HTMLSelectElement)) { return; } setJudgingLogFilter(input.value); renderJudgingView(deps); }); document.getElementById("judgingExportLog")?.addEventListener("click", () => { const rows = (result.adjustments || []).map((entry) => ({ time: new Date(entry.ts).toISOString(), competitor: entry.displayName || "-", action: entry.action || "-", detail: entry.detail || "-", category: entry.category || "-", undoneAt: entry.undoneAt ? new Date(entry.undoneAt).toISOString() : "", })); const blob = new Blob([JSON.stringify({ session: active.name, items: rows }, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${active.name.replaceAll(/\s+/g, "_")}_judging_log.json`; link.click(); URL.revokeObjectURL(url); }); document.getElementById("judgingUndoLast")?.addEventListener("click", () => { if (!latestUndoable) { alert(t("judging.no_undo")); return; } undoJudgingAdjustment(active, latestUndoable.id); renderJudgingView(deps); }); actionLog.forEach((entry) => { if (!entry.undo || entry.undoneAt) { return; } document.getElementById(`judge-undo-${entry.id}`)?.addEventListener("click", () => { undoJudgingAdjustment(active, entry.id); renderJudgingView(deps); }); }); if (!selectedRow) { return; } document.getElementById("judgeLapPlus")?.addEventListener("click", () => { applyCompetitorCorrection(active, selectedRow, { lapDelta: 1 }); renderJudgingView(deps); }); document.getElementById("judgeLapMinus")?.addEventListener("click", () => { applyCompetitorCorrection(active, selectedRow, { lapDelta: -1 }); renderJudgingView(deps); }); document.getElementById("judgeSecPlus")?.addEventListener("click", () => { applyCompetitorCorrection(active, selectedRow, { timeMsDelta: 1000 }); renderJudgingView(deps); }); document.getElementById("judgeFivePlus")?.addEventListener("click", () => { applyCompetitorCorrection(active, selectedRow, { timeMsDelta: 5000 }); renderJudgingView(deps); }); document.getElementById("judgeSecMinus")?.addEventListener("click", () => { applyCompetitorCorrection(active, selectedRow, { timeMsDelta: -1000 }); renderJudgingView(deps); }); document.getElementById("judgeInvalidate")?.addEventListener("click", () => { invalidateCompetitorLastLap(active, selectedRow); renderJudgingView(deps); }); document.getElementById("judgeRestore")?.addEventListener("click", () => { restoreCompetitorLastInvalidLap(active, selectedRow); renderJudgingView(deps); }); document.getElementById("judgeReset")?.addEventListener("click", () => { applyCompetitorCorrection(active, selectedRow, { reset: true }); renderJudgingView(deps); }); }