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
? `${t("table.pos")} ${t("table.driver")} ${t("table.laps")} ${t("table.last_lap")} ${t("table.best_lap")}
${rows.map((row, index) => `${index + 1} ${escapeHtml(row.driverName)} ${escapeHtml(row.carName || row.transponder)} ${row.laps} ${formatLap(row.lastLapMs)} ${formatLap(row.bestLapMs)} `).join("")}
`
: ""}
`;
}
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.replay_select")}
${groupSessionsByEventRecency(replayable, state)
.map(
({ eventId, sessions }) => `
${sessions
.map((session) => `${escapeHtml(session.name)} · ${state.resultsBySession[session.id].passings.length} `)
.join("")}
`
)
.join("")}
${t("timing.replay_load")}
${t("timing.replay_play")}
${t("timing.replay_pause")}
${t("timing.replay_reset")}
${t("timing.replay_speed")}
${[1, 2, 5, 10].map((speed) => `${speed}x `).join("")}
${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 `
${t(labelKey)}
`;
}
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 ? `${t("timing.add_driver")} ` : ""}
${!quickState.hasCar ? `${t("timing.add_car")} ` : ""}
`;
}
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) => `${escapeHtml(item.name)} `
)
.join("");
const isDriver = quickAddDraft.type === "driver";
return `
`;
}
function renderLeaderboardModal(session, row, deps) {
const { t, escapeHtml, formatLap, formatRaceClock, renderTable, getCompetitorPassings, renderQuickAddActions } = deps;
const passings = getCompetitorPassings(session, row);
return `
${escapeHtml(row.displayName || row.driverName)} • ${escapeHtml(row.subLabel || row.carName)}
${t("table.transponder")}: ${escapeHtml(row.transponder)}
${renderQuickAddActions(session, row.transponder, "leaderboardModal")}
${t("table.laps")}: ${row.laps}
${t("timing.total_time")}: ${escapeHtml(row.resultDisplay)}
${t("table.best_lap")}: ${formatLap(row.bestLapMs)}
${t("table.last_lap")}: ${formatLap(row.lastLapMs)}
${t("table.split")}: ${formatLap(row.lastSplitMs)}${Number.isFinite(row.lastSplitDeltaMs) ? ` (${row.lastSplitDeltaMs >= 0 ? "+" : ""}${formatLap(Math.abs(row.lastSplitDeltaMs))})` : ""}
${t("table.pit")}: ${row.pitActive ? t("timing.pit_active") : `${Number(row.pitStops || 0)} / ${formatLap(row.totalPitMs)}`}
${t("table.own_delta")}: ${escapeHtml(row.lapDelta || "-")}
${t("timing.manual_corrections")}
${t("timing.lap_adjustment")}: ${Number(row.manualLapAdjustment || 0) || 0}
${t("timing.time_penalty")}: ${(Number(row.manualTimeAdjustmentMs || 0) || 0) > 0 ? "+" : (Number(row.manualTimeAdjustmentMs || 0) || 0) < 0 ? "-" : ""}${formatLap(Math.abs(Number(row.manualTimeAdjustmentMs || 0) || 0))}
${t("timing.penalty_add_lap")}
${t("timing.penalty_remove_lap")}
${t("timing.penalty_add_sec")}
${t("timing.penalty_add_5sec")}
${t("timing.penalty_remove_sec")}
${t("timing.invalidate_last_lap")}
${t("timing.restore_last_invalid")}
${t("timing.penalty_reset")}
${t("timing.lap_history")}
${
passings.length
? renderTable(
[t("table.lap"), t("table.time"), t("table.last_lap")],
passings.map(
(passing, index) => `
${index + 1}
${formatRaceClock(Math.max(0, passing.timestamp - (session.startedAt || passing.timestamp)))}
${formatLap(passing.lapMs)}
`
)
)
: `
${t("timing.no_lap_history")}
`
}
`;
}
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)}`}
${t("timing.details")}
`;
})
), "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.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.select_session")}
${t("timing.select_session")}
${groupSessionsByEventRecency(state.sessions, state)
.map(
({ eventId, sessions }) => `
${sessions
.map(
(s) => `${escapeHtml(s.name)} • ${escapeHtml(getSessionTypeLabel(s.type))} `
)
.join("")}
`
)
.join("")}
${t("timing.set_active")}
${t("timing.start")}
${t("timing.stop")}
${t("timing.reset")}
${[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_hint")}
${t("audio.track_call")}
${t("audio.track_clear")}
${t("audio.red_flag")}
${t("audio.race_pause")}
${t("audio.race_resume")}
${t("audio.false_start")}
${t("audio.marshal_warning")}
${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 })}
${renderLeaderboard(leaderboard, helperDeps)}
${channelAnalysis ? `
` : ""}
${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.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.active_session")}: ${escapeHtml(active.name)} • ${escapeHtml(getSessionTypeLabel(active.type))}
${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("timing.details")}
`
)
)}
${
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.penalty_add_lap")}
${t("timing.penalty_remove_lap")}
${t("timing.penalty_add_sec")}
${t("timing.penalty_add_5sec")}
${t("timing.penalty_remove_sec")}
${t("timing.invalidate_last_lap")}
${t("timing.restore_last_invalid")}
${t("timing.penalty_reset")}
${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")}
`
}
${
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.undo_action")} ` : ""}
`
)
)
: `
${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);
});
}