Files
Live_RC/src/timing_views.js
larssand a449bec07d Group the session picker by event instead of one long flat list
The "select session" dropdown in Timing, and the session replay
picker, listed every session across every event in one long flat
list - unwieldy once a club has generated several races' worth of
practice/qualifying/finals sessions. Both now group into <optgroup>s
by event, most recently created event first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 22:20:34 +02:00

1098 lines
54 KiB
JavaScript

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 `
<div class="session-replay-progress">
<progress max="100" value="${Math.round(snapshot.progress * 100)}"></progress>
<strong>${formatElapsedClock(snapshot.elapsedMs)} / ${formatElapsedClock(snapshot.durationMs)}</strong>
<span class="pill">${escapeHtml(t(`timing.replay_status_${snapshot.status}`))}</span>
</div>
${snapshot.latestPassing
? `<p class="session-replay-latest"><span>${t("timing.replay_latest")}</span><strong>${escapeHtml(snapshot.latestPassing.driverName)}</strong><small>${formatLap(snapshot.latestPassing.lapMs)}</small></p>`
: `<p class="hint">${t("timing.replay_no_visible_passings")}</p>`}
${rows.length
? `<div class="table-wrap"><table><thead><tr><th>${t("table.pos")}</th><th>${t("table.driver")}</th><th>${t("table.laps")}</th><th>${t("table.last_lap")}</th><th>${t("table.best_lap")}</th></tr></thead><tbody>
${rows.map((row, index) => `<tr><td><span class="pos-pill">${index + 1}</span></td><td><strong>${escapeHtml(row.driverName)}</strong><small class="table-subnote">${escapeHtml(row.carName || row.transponder)}</small></td><td>${row.laps}</td><td>${formatLap(row.lastLapMs)}</td><td class="best">${formatLap(row.bestLapMs)}</td></tr>`).join("")}
</tbody></table></div>`
: ""}
`;
}
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 `
<section class="panel mt-16 session-replay-panel">
<div class="panel-header">
<div><h3>${t("timing.session_replay")}</h3><p class="hint">${t("timing.session_replay_hint")}</p></div>
<span class="pill">${t("timing.replay_isolated")}</span>
</div>
<div class="panel-body">
<div class="session-replay-controls">
<select id="sessionReplaySelect">
<option value="">${t("timing.replay_select")}</option>
${groupSessionsByEventRecency(replayable, state)
.map(
({ eventId, sessions }) => `<optgroup label="${escapeHtml(eventId ? getEventName(eventId) : t("timing.other_sessions"))}">
${sessions
.map((session) => `<option value="${escapeHtml(session.id)}" ${session.id === selectedId ? "selected" : ""}>${escapeHtml(session.name)} · ${state.resultsBySession[session.id].passings.length}</option>`)
.join("")}
</optgroup>`
)
.join("")}
</select>
<button id="sessionReplayLoad" class="btn" type="button" ${selectedId ? "" : "disabled"}>${t("timing.replay_load")}</button>
<button id="sessionReplayPlay" class="btn btn-primary" type="button" ${runtime.passings.length ? "" : "disabled"}>${t("timing.replay_play")}</button>
<button id="sessionReplayPause" class="btn" type="button" ${runtime.status === "running" ? "" : "disabled"}>${t("timing.replay_pause")}</button>
<button id="sessionReplayReset" class="btn" type="button" ${runtime.passings.length ? "" : "disabled"}>${t("timing.replay_reset")}</button>
<label>${t("timing.replay_speed")}
<select id="sessionReplaySpeed">
${[1, 2, 5, 10].map((speed) => `<option value="${speed}" ${runtime.speed === speed ? "selected" : ""}>${speed}x</option>`).join("")}
</select>
</label>
</div>
<div data-session-replay-content>
${runtime.passings.length
? renderSessionReplayContent(runtime, { t, escapeHtml, formatLap, formatElapsedClock })
: `<p class="hint">${t(replayable.length ? "timing.replay_load_hint" : "timing.replay_empty")}</p>`}
</div>
</div>
</section>
`;
}
function renderSpeakerToggle(settingKey, labelKey, { state, t }) {
return `
<label class="check-card">
<input type="checkbox" data-speaker-setting="${settingKey}" ${state.settings[settingKey] ? "checked" : ""} />
<span>${t(labelKey)}</span>
</label>
`;
}
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 `
<div class="actions-inline quick-add-actions">
${!quickState.hasDriver ? `<button class="btn btn-mini" id="${idPrefix}-add-driver">${t("timing.add_driver")}</button>` : ""}
${!quickState.hasCar ? `<button class="btn btn-mini" id="${idPrefix}-add-car">${t("timing.add_car")}</button>` : ""}
</div>
`;
}
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) => `<option value="${item.id}" ${item.id === (quickAddDraft.classId || getPreferredClassId(session)) ? "selected" : ""}>${escapeHtml(item.name)}</option>`
)
.join("");
const isDriver = quickAddDraft.type === "driver";
return `
<section class="panel mt-16">
<div class="panel-header"><h3>${t(isDriver ? "timing.quick_add_driver_title" : "timing.quick_add_car_title")}</h3></div>
<form id="quickAddForm" class="panel-body form-grid cols-5">
<input name="transponder" value="${escapeHtml(quickAddDraft.transponder)}" readonly />
<input
name="name"
required
autofocus
placeholder="${t(isDriver ? "drivers.name_placeholder" : "cars.name_placeholder")}"
value="${escapeHtml(quickAddDraft.name || "")}"
/>
<input
name="brand"
placeholder="${t(isDriver ? "drivers.brand_placeholder" : "cars.brand_placeholder")}"
value="${escapeHtml(quickAddDraft.brand || "")}"
/>
${
isDriver
? `<select name="classId">${classOptions}</select>`
: `<div class="hint quick-add-spacer">${t("timing.quick_add_hint")}</div>`
}
<div class="actions-inline">
<button class="btn btn-primary" type="submit">${t("common.save")}</button>
<button class="btn" id="quickAddCancel" type="button">${t("common.cancel")}</button>
</div>
</form>
</section>
`;
}
function renderLeaderboardModal(session, row, deps) {
const { t, escapeHtml, formatLap, formatRaceClock, renderTable, getCompetitorPassings, renderQuickAddActions } = deps;
const passings = getCompetitorPassings(session, row);
return `
<div class="modal-overlay" id="leaderboardModalOverlay">
<div class="modal-card">
<div class="panel-header">
<h3>${t("timing.detail_title")}</h3>
<button class="btn" id="leaderboardModalClose">${t("timing.close_details")}</button>
</div>
<div class="panel-body">
<p><strong>${escapeHtml(row.displayName || row.driverName)}</strong> • ${escapeHtml(row.subLabel || row.carName)}</p>
<p>${t("table.transponder")}: ${escapeHtml(row.transponder)}</p>
${renderQuickAddActions(session, row.transponder, "leaderboardModal")}
<p>${t("table.laps")}: ${row.laps}</p>
<p>${t("timing.total_time")}: ${escapeHtml(row.resultDisplay)}</p>
<p>${t("table.best_lap")}: ${formatLap(row.bestLapMs)}</p>
<p>${t("table.last_lap")}: ${formatLap(row.lastLapMs)}</p>
<p>${t("table.split")}: ${formatLap(row.lastSplitMs)}${Number.isFinite(row.lastSplitDeltaMs) ? ` (${row.lastSplitDeltaMs >= 0 ? "+" : ""}${formatLap(Math.abs(row.lastSplitDeltaMs))})` : ""}</p>
<p>${t("table.pit")}: ${row.pitActive ? t("timing.pit_active") : `${Number(row.pitStops || 0)} / ${formatLap(row.totalPitMs)}`}</p>
<p>${t("table.own_delta")}: ${escapeHtml(row.lapDelta || "-")}</p>
</div>
<div class="panel-body">
<h4>${t("timing.manual_corrections")}</h4>
<p>${t("timing.lap_adjustment")}: ${Number(row.manualLapAdjustment || 0) || 0}</p>
<p>${t("timing.time_penalty")}: ${(Number(row.manualTimeAdjustmentMs || 0) || 0) > 0 ? "+" : (Number(row.manualTimeAdjustmentMs || 0) || 0) < 0 ? "-" : ""}${formatLap(Math.abs(Number(row.manualTimeAdjustmentMs || 0) || 0))}</p>
<div class="actions-inline">
<button class="btn btn-mini" id="corrLapPlus" type="button">${t("timing.penalty_add_lap")}</button>
<button class="btn btn-mini" id="corrLapMinus" type="button">${t("timing.penalty_remove_lap")}</button>
<button class="btn btn-mini" id="corrSecPlus" type="button">${t("timing.penalty_add_sec")}</button>
<button class="btn btn-mini" id="corr5SecPlus" type="button">${t("timing.penalty_add_5sec")}</button>
<button class="btn btn-mini" id="corrSecMinus" type="button">${t("timing.penalty_remove_sec")}</button>
<button class="btn btn-mini" id="corrInvalidateLast" type="button">${t("timing.invalidate_last_lap")}</button>
<button class="btn btn-mini" id="corrRestoreInvalid" type="button">${t("timing.restore_last_invalid")}</button>
<button class="btn btn-danger btn-mini" id="corrReset" type="button">${t("timing.penalty_reset")}</button>
</div>
</div>
<div class="panel-body">
<h4>${t("timing.lap_history")}</h4>
${
passings.length
? renderTable(
[t("table.lap"), t("table.time"), t("table.last_lap")],
passings.map(
(passing, index) => `
<tr>
<td>${index + 1}</td>
<td>${formatRaceClock(Math.max(0, passing.timestamp - (session.startedAt || passing.timestamp)))}</td>
<td>${formatLap(passing.lapMs)}</td>
</tr>
`
)
)
: `<p>${t("timing.no_lap_history")}</p>`
}
</div>
</div>
</div>
`;
}
function renderLeaderboard(rows, deps) {
const { t, escapeHtml, renderTable, getManualCorrectionSummary, formatLap, formatTeamActiveMemberLabel } = deps;
if (!rows.length) {
return `<p>${t("timing.no_laps")}</p>`;
}
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 `
<tr data-sort-values="${sortValuesAttribute([idx + 1, row.displayName || row.driverName, row.subLabel || row.carName, row.transponder, row.laps, row.resultDisplay, row.lastLapMs || null, row.bestLapMs || null, row.leaderGap || row.gap || null, row.gapAhead || null, row.lapDelta || null, row.lastSplitMs || null, [Number(row.pitStops || 0), Number(row.totalPitMs || 0)]])}" class="${row.invalidPending ? "leaderboard-invalid" : ""}">
<td><span class="pos-pill ${posClass}">${idx + 1}</span></td>
<td>
<div class="table-primary">${escapeHtml(row.displayName || row.driverName)}${row.personalTimeUp ? ` <span class="pill pill-green">🏁 ${t("timing.personal_time_up")}</span>` : ""}</div>
${row.teamId ? `<div class="table-subnote">${t("overlay.active_member")}: ${escapeHtml(formatTeamActiveMemberLabel(row))}</div>` : ""}
${getManualCorrectionSummary(row) ? `<div class="table-subnote">${escapeHtml(getManualCorrectionSummary(row))}</div>` : ""}
${row.invalidPending ? `<div class="table-subnote table-subnote-warn">${escapeHtml(row.invalidLabel)}${row.invalidLapMs ? ` • ${formatLap(row.invalidLapMs)}` : ""}</div>` : ""}
</td>
<td>${escapeHtml(row.subLabel || row.carName)}</td>
<td>${escapeHtml(row.transponder)}</td>
<td>${row.laps}</td>
<td>${escapeHtml(row.resultDisplay)}</td>
<td>${formatLap(row.lastLapMs)}</td>
<td class="best">${formatLap(row.bestLapMs)}</td>
<td>${escapeHtml(row.leaderGap || row.gap || "-")}</td>
<td>${escapeHtml(row.gapAhead || "-")}</td>
<td>${escapeHtml(row.lapDelta || "-")}</td>
<td>${formatLap(row.lastSplitMs)}</td>
<td>${row.pitActive ? `<strong>${t("timing.pit_active")}</strong>` : `${Number(row.pitStops || 0)} / ${formatLap(row.totalPitMs)}`}</td>
<td><button id="leaderboard-detail-${row.key}" class="btn btn-mini">${t("timing.details")}</button></td>
</tr>
`;
})
), "timing-leaderboard", [1, 2, 3]);
}
function renderRecentPassings(session, deps) {
const { t, escapeHtml, renderTable, ensureSessionResult, formatLap, getPassingValidationLabel, isCountedPassing, renderQuickAddActions } = deps;
if (!session) {
return `<p>${t("timing.no_session_selected")}</p>`;
}
const result = ensureSessionResult(session.id);
const items = result.passings.slice(-20).reverse();
if (!items.length) {
return `<p>${t("timing.no_passings")}</p>`;
}
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 `
<tr data-sort-values="${sortValuesAttribute([p.timestamp, p.transponder, p.teamName || p.driverName, p.carName || p.subLabel, p.lapMs, getPassingValidationLabel(p)])}" class="${isCountedPassing(p) ? "" : "passing-invalid"}">
<td>${new Date(p.timestamp).toLocaleTimeString()}</td>
<td>${escapeHtml(p.transponder)}</td>
<td>${escapeHtml(p.teamName ? `${p.teamName} • ${p.driverName || t("common.unknown_driver")}` : p.driverName || t("common.unknown_driver"))}</td>
<td>${escapeHtml(p.carName || p.subLabel || "-")}</td>
<td>${formatLap(p.lapMs)}</td>
<td>${escapeHtml(getPassingValidationLabel(p))}</td>
<td>${renderQuickAddActions(session, p.transponder, `recentPassing-${index}`)}</td>
</tr>
`;
})
), "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 = `
<section class="panel">
<div class="panel-header"><h3>${t("timing.decoder_connection")}</h3></div>
<div class="panel-body timing-top-grid">
<div class="timing-compact-card">
<label class="timing-compact-label" for="timingWsUrl">${t("timing.main_decoder")}</label>
<input id="timingWsUrl" value="${escapeHtml(state.settings.wsUrl)}" placeholder="ws://127.0.0.1:9000" />
<label class="toggle"><input id="timingSplitEnabled" type="checkbox" ${state.settings.splitDecoderEnabled ? "checked" : ""} /><span>${t("settings.enable_split_decoder")}</span></label>
<label for="timingSplitWsUrl"><small>${t("timing.split_decoder")} WebSocket</small></label>
<input id="timingSplitWsUrl" value="${escapeHtml(state.settings.splitWsUrl || "")}" placeholder="ws://127.0.0.1:9001" />
<label class="toggle"><input id="timingPitEnabled" type="checkbox" ${state.settings.pitDecoderEnabled ? "checked" : ""} /><span>${t("settings.enable_pit_decoders")}</span></label>
<label for="timingPitEntryWsUrl"><small>${t("timing.pit_entry_decoder")} WebSocket</small></label>
<input id="timingPitEntryWsUrl" value="${escapeHtml(state.settings.pitEntryWsUrl || "")}" placeholder="ws://127.0.0.1:9002" />
<label for="timingPitExitWsUrl"><small>${t("timing.pit_exit_decoder")} WebSocket</small></label>
<input id="timingPitExitWsUrl" value="${escapeHtml(state.settings.pitExitWsUrl || "")}" placeholder="ws://127.0.0.1:9003" />
<div class="actions">
<button id="timingConnect" class="btn btn-primary">${t("timing.connect")}</button>
<button id="timingDisconnect" class="btn">${t("timing.disconnect")}</button>
<button id="timingSimPass" class="btn">${t("timing.simulate")}</button>
</div>
</div>
<div class="timing-compact-card">
<span class="timing-compact-label">${t("timing.status")}</span>
<strong>${t("timing.main_decoder")}: ${state.decoder.connected ? t("timing.connected") : t("timing.disconnected")}</strong>
<small>${t("timing.last_message")}: ${state.decoder.lastMessageAt ? new Date(state.decoder.lastMessageAt).toLocaleString() : "-"}</small>
${state.settings.splitDecoderEnabled ? `<strong>${t("timing.split_decoder")}: ${state.decoder.splitConnected ? t("timing.connected") : t("timing.disconnected")}</strong><small>${t("timing.last_message")}: ${state.decoder.splitLastMessageAt ? new Date(state.decoder.splitLastMessageAt).toLocaleString() : "-"}</small>` : ""}
${state.settings.pitDecoderEnabled ? `<strong>${t("timing.pit_entry_decoder")}: ${state.decoder.pitEntryConnected ? t("timing.connected") : t("timing.disconnected")}</strong><small>${t("timing.last_message")}: ${state.decoder.pitEntryLastMessageAt ? new Date(state.decoder.pitEntryLastMessageAt).toLocaleString() : "-"}</small><strong>${t("timing.pit_exit_decoder")}: ${state.decoder.pitExitConnected ? t("timing.connected") : t("timing.disconnected")}</strong><small>${t("timing.last_message")}: ${state.decoder.pitExitLastMessageAt ? new Date(state.decoder.pitExitLastMessageAt).toLocaleString() : "-"}</small>` : ""}
<p class="error">${escapeHtml(state.decoder.lastError || "")}</p>
<p class="error">${escapeHtml(state.decoder.splitLastError || state.decoder.pitEntryLastError || state.decoder.pitExitLastError || "")}</p>
</div>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("timing.control")}</h3></div>
<div class="panel-body timing-top-grid">
<div class="timing-compact-card timing-compact-card-wide">
<label class="timing-compact-label" for="activeSessionSelect">${t("timing.select_session")}</label>
<select id="activeSessionSelect">
<option value="">${t("timing.select_session")}</option>
${groupSessionsByEventRecency(state.sessions, state)
.map(
({ eventId, sessions }) => `<optgroup label="${escapeHtml(eventId ? getEventName(eventId) : t("timing.other_sessions"))}">
${sessions
.map(
(s) => `<option value="${s.id}" ${state.activeSessionId === s.id ? "selected" : ""}>${escapeHtml(s.name)} • ${escapeHtml(getSessionTypeLabel(s.type))}</option>`
)
.join("")}
</optgroup>`
)
.join("")}
</select>
<div class="actions">
<button id="setActiveSession" class="btn">${t("timing.set_active")}</button>
<button id="startSession" class="btn btn-primary">${t("timing.start")}</button>
<button id="stopSession" class="btn">${t("timing.stop")}</button>
<button id="resetSession" class="btn btn-danger">${t("timing.reset")}</button>
</div>
<div id="raceStartLights" class="race-start-lights" data-state="${escapeHtml(getStartLightState())}">
${[1, 2, 3, 4, 5].map((light) => `<span data-start-light="${light}"></span>`).join("")}
<strong>${escapeHtml(getStartLightState())}</strong>
</div>
</div>
</div>
<div class="panel-body timing-session-summary">
${
active
? `<div class="timing-session-head">
<strong>${escapeHtml(active.name)}</strong>
<span class="pill">${escapeHtml(getSessionTypeLabel(active.type))}</span>
<span class="pill">${escapeHtml(getEventName(active.eventId))}</span>
<span class="pill ${active.status === "running" ? "pill-green" : ""}">${escapeHtml(getStatusLabel(active.status))}</span>
</div>
<div class="timing-session-stats">
<article class="timing-session-stat"><span>${clockLabel}</span><strong data-timing-clock>${clockValue}</strong></article>
<article class="timing-session-stat"><span>${t("timing.started")}</span><strong>${active.startedAt ? new Date(active.startedAt).toLocaleTimeString() : "-"}</strong></article>
<article class="timing-session-stat"><span>${t("table.start_mode")}</span><strong>${escapeHtml(getStartModeLabel(active.startMode))}</strong></article>
<article class="timing-session-stat"><span>${t("timing.seeding_mode")}</span><strong>${active.seedBestLapCount > 0 ? `${active.seedBestLapCount}` : "-"}</strong></article>
<article class="timing-session-stat"><span>${t("timing.total_passings")}</span><strong>${getVisiblePassings(result).length}</strong></article>
</div>
${
active.type === "free_practice"
? `<p class="hint">${t("events.free_practice_note")}</p>`
: active.type === "open_practice"
? `<p class="hint">${t("events.open_practice_note")}</p>`
: ""
}`
: `<p>${t("timing.no_active")}</p>`
}
${showFollowUpBanner ? `<p class="finish-banner">${t("timing.follow_up_active")}</p>` : ""}
${showFinishedBanner ? `<p class="finish-banner">${t("timing.race_finished")}</p>` : ""}
${active && normalizeStartMode(active.startMode) === "position" ? renderPositionGrid(active) : ""}
</div>
</section>
${active ? renderQuickAddPanel(active, helperDeps) : ""}
${renderSessionReplayPanel(sessionReplay, { state, t, escapeHtml, getEventName, formatLap, formatElapsedClock })}
<section class="panel mt-16">
<div class="panel-header"><h3>${t("timing.speaker_panel")}</h3></div>
<div class="panel-body">
<p class="hint">${t("timing.speaker_panel_hint")}</p>
<div class="race-control-cues">
<button class="btn btn-danger" type="button" data-race-audio-cue="track_call">${t("audio.track_call")}</button>
<button class="btn" type="button" data-race-audio-cue="track_clear">${t("audio.track_clear")}</button>
<button class="btn btn-danger" type="button" data-race-audio-cue="red_flag">${t("audio.red_flag")}</button>
<button class="btn" type="button" data-race-audio-cue="race_paused">${t("audio.race_pause")}</button>
<button class="btn" type="button" data-race-audio-cue="race_resumed">${t("audio.race_resume")}</button>
<button class="btn" type="button" data-race-audio-cue="false_start">${t("audio.false_start")}</button>
<button class="btn" type="button" data-race-audio-cue="marshal_warning">${t("audio.marshal_warning")}</button>
</div>
<div class="check-grid">
${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 })}
</div>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("timing.leaderboard")}</h3></div>
<div class="panel-body">
${renderLeaderboard(leaderboard, helperDeps)}
</div>
</section>
${channelAnalysis ? `
<section class="panel mt-16">
<div class="panel-header"><h3>${t("timing.channel_analysis")}</h3></div>
<div class="panel-body">${channelAnalysis}</div>
</section>
` : ""}
<section class="panel mt-16">
<div class="panel-header"><h3>${t("timing.recent_passings")}</h3></div>
<div class="panel-body">
${renderRecentPassings(active, { ...helperDeps, ensureSessionResult, renderQuickAddActions: (session, transponder, idPrefix) => renderQuickAddActions(session, transponder, idPrefix, helperDeps) })}
</div>
</section>
${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 = `
<section class="panel">
<div class="panel-header"><h3>${t("judging.title")}</h3></div>
<div class="panel-body"><p>${t("judging.no_active_session")}</p></div>
</section>
`;
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 = `
<section class="panel">
<div class="panel-header">
<h3>${t("judging.title")}</h3>
<span class="pill pill-green">${escapeHtml(active.name)}</span>
</div>
<div class="panel-body">
<p>${t("judging.active_session")}: <strong>${escapeHtml(active.name)}</strong> • ${escapeHtml(getSessionTypeLabel(active.type))}</p>
</div>
</section>
<section class="panel-row">
<section class="panel">
<div class="panel-header">
<h3>${t("judging.select_competitor")}</h3>
<select id="judgingCompetitorFilter">
<option value="all" ${getJudgingCompetitorFilter() === "all" ? "selected" : ""}>${t("judging.filter_all")}</option>
<option value="invalid" ${getJudgingCompetitorFilter() === "invalid" ? "selected" : ""}>${t("judging.filter_invalid")}</option>
<option value="corrected" ${getJudgingCompetitorFilter() === "corrected" ? "selected" : ""}>${t("judging.filter_corrected")}</option>
<option value="team" ${getJudgingCompetitorFilter() === "team" ? "selected" : ""}>${t("judging.filter_team")}</option>
</select>
</div>
<div class="panel-body">
${renderTable(
[t("table.pos"), t("table.driver"), t("table.result"), t("table.best_lap"), ""],
filteredRows.map(
(row, index) => `
<tr class="${row.key === getSelectedJudgeKey() ? "judge-selected-row" : ""}">
<td>${index + 1}</td>
<td>
<div class="table-primary">${escapeHtml(row.displayName || row.driverName)}</div>
<div class="table-subnote">${escapeHtml(row.subLabel || row.transponder || "-")}</div>
</td>
<td>${escapeHtml(row.resultDisplay || "-")}</td>
<td>${formatLap(row.bestLapMs)}</td>
<td><button class="btn btn-mini" id="judge-select-${row.key}">${t("timing.details")}</button></td>
</tr>
`
)
)}
</div>
</section>
<section class="panel">
<div class="panel-header"><h3>${t("judging.manual_actions")}</h3></div>
<div class="panel-body">
${
selectedRow
? `
<p><strong>${escapeHtml(selectedRow.displayName || selectedRow.driverName)}</strong> • ${escapeHtml(selectedRow.subLabel || selectedRow.carName || "-")}</p>
<p>${t("table.laps")}: ${selectedRow.laps}</p>
<p>${t("table.best_lap")}: ${formatLap(selectedRow.bestLapMs)}</p>
<p>${t("table.last_lap")}: ${formatLap(selectedRow.lastLapMs)}</p>
<div class="actions">
<button class="btn" id="judgeLapPlus">${t("timing.penalty_add_lap")}</button>
<button class="btn" id="judgeLapMinus">${t("timing.penalty_remove_lap")}</button>
<button class="btn" id="judgeSecPlus">${t("timing.penalty_add_sec")}</button>
<button class="btn" id="judgeFivePlus">${t("timing.penalty_add_5sec")}</button>
<button class="btn" id="judgeSecMinus">${t("timing.penalty_remove_sec")}</button>
<button class="btn" id="judgeInvalidate">${t("timing.invalidate_last_lap")}</button>
<button class="btn" id="judgeRestore">${t("timing.restore_last_invalid")}</button>
<button class="btn btn-danger" id="judgeReset">${t("timing.penalty_reset")}</button>
</div>
<div class="mt-16">
<h4>${t("timing.lap_history")}</h4>
${
selectedPassings.length
? renderTable(
[t("table.lap"), t("table.last_lap"), t("table.status")],
selectedPassings.map(
(passing, index) => `
<tr class="${isCountedPassing(passing) ? "" : "passing-invalid"}">
<td>${index + 1}</td>
<td>${formatLap(passing.lapMs)}</td>
<td>${escapeHtml(getPassingValidationLabel(passing))}</td>
</tr>
`
)
)
: `<p>${t("timing.no_lap_history")}</p>`
}
</div>
`
: `<p>${t("judging.selected_none")}</p>`
}
</div>
</section>
</section>
<section class="panel mt-16">
<div class="panel-header">
<h3>${t("judging.action_log")}</h3>
<div class="actions-inline">
<select id="judgingLogFilter">
<option value="all" ${getJudgingLogFilter() === "all" ? "selected" : ""}>${t("judging.filter_all")}</option>
<option value="corrections" ${getJudgingLogFilter() === "corrections" ? "selected" : ""}>${t("judging.filter_log_corrections")}</option>
<option value="invalid" ${getJudgingLogFilter() === "invalid" ? "selected" : ""}>${t("judging.filter_log_invalidations")}</option>
<option value="undo" ${getJudgingLogFilter() === "undo" ? "selected" : ""}>${t("judging.filter_log_undo")}</option>
</select>
<button class="btn" id="judgingExportLog" type="button">${t("judging.export_log")}</button>
<button class="btn" id="judgingUndoLast" type="button">${t("judging.undo_last")}</button>
</div>
</div>
<div class="panel-body">
${
actionLog.length
? renderTable(
[t("table.time"), t("table.driver"), t("events.actions"), t("table.status"), ""],
actionLog.map(
(entry) => `
<tr>
<td>${new Date(entry.ts).toLocaleTimeString()}</td>
<td>${escapeHtml(entry.displayName || "-")}</td>
<td>${escapeHtml(entry.action || "-")}</td>
<td>${escapeHtml(entry.detail || "-")}${entry.undoneAt ? ` • ${escapeHtml(t("judging.filter_log_undo"))}` : ""}</td>
<td>${entry.undo && !entry.undoneAt ? `<button class="btn btn-mini" id="judge-undo-${entry.id}" type="button">${t("judging.undo_action")}</button>` : ""}</td>
</tr>
`
)
)
: `<p>${t("judging.no_action_log")}</p>`
}
</div>
</section>
`;
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);
});
}