Files
Live_RC/src/timing_views.js
2026-08-25 20:00:55 +02:00

920 lines
42 KiB
JavaScript

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 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 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)}</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>
`;
})
);
}
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 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 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>
`;
})
);
}
export function renderTimingView(deps) {
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,
ensureAudioContext, validateTrackSessionForStart, announceRaceStarted, announceRaceFinished, saveState, updateHeaderState, renderView,
normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey, setSelectedLeaderboardKey,
getQuickAddDraft, setQuickAddDraft, getPassingValidationLabel, isCountedPassing, getCompetitorPassings,
getManualCorrectionSummary, formatTeamActiveMemberLabel, formatRaceClock, renderSessionChannelAnalysis,
setLastFinishAnnouncementSessionId, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, overlayEvents, setOverlayEvents,
} = 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>
${state.sessions
.map(
(s) => `<option value="${s.id}" ${state.activeSessionId === s.id ? "selected" : ""}>${escapeHtml(
getEventName(s.eventId)
)} • ${escapeHtml(s.name)} • ${escapeHtml(getSessionTypeLabel(s.type))}</option>`
)
.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>
</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>${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) : ""}
<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="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) }) : ""}
`;
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);
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;
}
processDecoderMessage({
msg: "PASSING",
transponder: tp,
rtc_time: new Date().toISOString(),
strength: 0,
resend: false,
loop_id: "sim",
});
});
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", () => {
const session = getActiveSession();
if (!session) {
return;
}
ensureAudioContext();
if (session.mode === "track") {
const trackValidation = validateTrackSessionForStart(session);
if (!trackValidation.ok) {
alert(trackValidation.message);
return;
}
}
session.status = "running";
session.startedAt = Date.now();
session.endedAt = null;
session.finishedByTimer = false;
session.followUpStartedAt = null;
setLastFinishAnnouncementSessionId(null);
lastOverlayLeaderKeyBySession[session.id] = null;
lastOverlayTop3BySession[session.id] = [];
setOverlayEvents([]);
ensureSessionResult(session.id);
announceRaceStarted(session);
saveState();
updateHeaderState();
renderView();
});
document.getElementById("stopSession")?.addEventListener("click", () => {
const session = getActiveSession();
if (!session) {
return;
}
session.status = "finished";
session.endedAt = Date.now();
session.finishedByTimer = false;
session.followUpStartedAt = null;
setLastFinishAnnouncementSessionId(session.id);
announceRaceFinished();
saveState();
updateHeaderState();
renderView();
});
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-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);
});
}