Extract timing and judging views into module

This commit is contained in:
larssand
2026-03-26 08:28:43 +01:00
parent 9b068d36d2
commit e39e1a53fe
2 changed files with 892 additions and 653 deletions

View File

@@ -10,6 +10,9 @@ import { getSessionTypeLabel as getSessionTypeLabelLogic, getStatusLabel as getS
import { renderDashboardView, renderClassesView, renderDriversView, renderCarsView } from "./core_views.js";
import { renderTimingView, renderJudgingView } from "./timing_views.js";
import { renderTeamStintLog as renderTeamStintLogHelper, renderTeamRaceStandings as renderTeamRaceStandingsHelper, getSessionSortWeight as getSessionSortWeightHelper, getDriverDisplayById as getDriverDisplayByIdHelper, renderPositionGrid as renderPositionGridHelper, renderGridEditor as renderGridEditorHelper, getFinalMainLayouts as getFinalMainLayoutsHelper, renderFinalMatrix as renderFinalMatrixHelper, buildPrintBrandBlock as buildPrintBrandBlockHelper, buildRaceStartListsHtml as buildRaceStartListsHtmlHelper, buildRaceResultsHtml as buildRaceResultsHtmlHelper, buildTeamRaceResultsHtml as buildTeamRaceResultsHtmlHelper } from "./race_render_helpers.js";
import { getManualCorrectionSummary as getManualCorrectionSummaryLogic, applyCompetitorCorrection as applyCompetitorCorrectionLogic, recalculateCompetitorFromPassings as recalculateCompetitorFromPassingsLogic, invalidateCompetitorLastLap as invalidateCompetitorLastLapLogic, restoreCompetitorLastInvalidLap as restoreCompetitorLastInvalidLapLogic, findPassingByUndoMarker as findPassingByUndoMarkerLogic, undoJudgingAdjustment as undoJudgingAdjustmentLogic, getJudgeFilteredRows as getJudgeFilteredRowsLogic, getJudgeFilteredLog as getJudgeFilteredLogLogic } from "./judging_logic.js";
@@ -100,6 +103,8 @@ const renderDashboard = () => renderDashboardView({ state, dom, t, backend, getA
const renderClasses = () => renderClassesView({ state, dom, t, selectedClassEditId: () => selectedClassEditId, setSelectedClassEditId: (value) => { selectedClassEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell });
const renderDrivers = () => renderDriversView({ state, dom, t, driverBrandFilter: () => driverBrandFilter, setDriverBrandFilter: (value) => { driverBrandFilter = value; }, selectedDriverEditId: () => selectedDriverEditId, setSelectedDriverEditId: (value) => { selectedDriverEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell, normalizeDriver, getClassName });
const renderCars = () => renderCarsView({ state, dom, t, carBrandFilter: () => carBrandFilter, setCarBrandFilter: (value) => { carBrandFilter = value; }, selectedCarEditId: () => selectedCarEditId, setSelectedCarEditId: (value) => { selectedCarEditId = value; }, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell, normalizeCar });
const renderTiming = () => renderTimingView({ state, dom, t, escapeHtml, formatLap, formatCountdown, formatElapsedClock, formatRaceClock, renderTable, uid, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTiming, getVisiblePassings, getEventName, getSessionTypeLabel, getStatusLabel, normalizeStartMode, getStartModeLabel, renderPositionGrid, connectDecoder, disconnectDecoder, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, ensureAudioContext, validateTrackSessionForStart, pushOverlayEvent, saveState, updateHeaderState, renderView, normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey: () => selectedLeaderboardKey, setSelectedLeaderboardKey: (value) => { selectedLeaderboardKey = value; }, getQuickAddDraft: () => quickAddDraft, setQuickAddDraft: (value) => { quickAddDraft = value; }, getPassingValidationLabel, isCountedPassing, getCompetitorPassings, getManualCorrectionSummary, formatTeamActiveMemberLabel, lastFinishAnnouncementSessionId: () => lastFinishAnnouncementSessionId, setLastFinishAnnouncementSessionId: (value) => { lastFinishAnnouncementSessionId = value; }, lastOverlayLeaderKeyBySession, lastOverlayTop3BySession, overlayEvents: () => overlayEvents, setOverlayEvents: (value) => { overlayEvents = value; } });
const renderJudging = () => renderJudgingView({ state, dom, t, escapeHtml, formatLap, renderTable, getActiveSession, ensureSessionResult, buildLeaderboard, getSessionTypeLabel, getJudgeFilteredRows, getJudgeFilteredLog, getCompetitorPassings, isCountedPassing, getPassingValidationLabel, undoJudgingAdjustment, applyCompetitorCorrection, invalidateCompetitorLastLap, restoreCompetitorLastInvalidLap, renderView, getSelectedJudgeKey: () => selectedJudgeKey, setSelectedJudgeKey: (value) => { selectedJudgeKey = value; }, getJudgingCompetitorFilter: () => judgingCompetitorFilter, setJudgingCompetitorFilter: (value) => { judgingCompetitorFilter = value; }, getJudgingLogFilter: () => judgingLogFilter, setJudgingLogFilter: (value) => { judgingLogFilter = value; } });
const applyCompetitorCorrection = (session, row, options = {}) => applyCompetitorCorrectionLogic(session, row, options, { ensureSessionResult, uid, t, formatLap, saveState });
const recalculateCompetitorFromPassings = (session, rowKey) => recalculateCompetitorFromPassingsLogic(session, rowKey, { ensureSessionResult, getCompetitorPassings, isCountedPassing });
const invalidateCompetitorLastLap = (session, row) => invalidateCompetitorLastLapLogic(session, row, { ensureSessionResult, getCompetitorPassings, isCountedPassing, recalculateCompetitorFromPassings, uid, t, formatLap, saveState });
@@ -5125,659 +5130,6 @@ function renderAssignmentList(eventId) {
});
}
function renderTiming() {
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 === selectedLeaderboardKey) || null;
if (selectedLeaderboardKey && !selectedRow) {
selectedLeaderboardKey = 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("settings.decoder")}</label>
<input id="timingWsUrl" value="${escapeHtml(state.settings.wsUrl)}" placeholder="ws://127.0.0.1:9000" />
<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>${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>
<p class="error">${escapeHtml(state.decoder.lastError || "")}</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) : ""}
<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")}
${renderSpeakerToggle("speakerLeaderCueEnabled", "settings.speaker_leader_cue")}
${renderSpeakerToggle("speakerBestLapCueEnabled", "settings.speaker_bestlap_cue")}
${renderSpeakerToggle("speakerTop3CueEnabled", "settings.speaker_top3_cue")}
${renderSpeakerToggle("speakerSessionStartCueEnabled", "settings.speaker_start_cue")}
${renderSpeakerToggle("speakerFinishCueEnabled", "settings.speaker_finish_cue")}
</div>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("timing.leaderboard")}</h3></div>
<div class="panel-body">
${renderLeaderboard(leaderboard)}
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("timing.recent_passings")}</h3></div>
<div class="panel-body">
${renderRecentPassings(active)}
</div>
</section>
${selectedRow && active ? renderLeaderboardModal(active, selectedRow) : ""}
`;
document.getElementById("timingConnect")?.addEventListener("click", () => {
const input = document.getElementById("timingWsUrl");
if (input && input.value.trim()) {
state.settings.wsUrl = input.value.trim();
saveState();
}
connectDecoder();
});
document.getElementById("timingDisconnect")?.addEventListener("click", disconnectDecoder);
leaderboard.forEach((row) => {
document.getElementById(`leaderboard-detail-${row.key}`)?.addEventListener("click", () => {
selectedLeaderboardKey = row.key;
renderView();
});
});
if (active && selectedRow) {
bindQuickAddActions(active, selectedRow.transponder, "leaderboardModal");
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}`);
});
}
document.getElementById("quickAddCancel")?.addEventListener("click", () => {
quickAddDraft = null;
renderView();
});
document.getElementById("quickAddForm")?.addEventListener("submit", (event) => {
event.preventDefault();
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)),
brand,
transponder,
})
);
}
} else if (!state.cars.some((item) => String(item.transponder || "").trim() === transponder)) {
state.cars.push(
normalizeCar({
id: uid("car"),
name,
brand,
transponder,
})
);
}
quickAddDraft = null;
saveState();
renderView();
});
document.getElementById("leaderboardModalClose")?.addEventListener("click", () => {
selectedLeaderboardKey = null;
renderView();
});
document.getElementById("leaderboardModalOverlay")?.addEventListener("click", (event) => {
if (event.target?.id === "leaderboardModalOverlay") {
selectedLeaderboardKey = 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;
lastFinishAnnouncementSessionId = null;
lastOverlayLeaderKeyBySession[session.id] = null;
lastOverlayTop3BySession[session.id] = [];
overlayEvents = [];
ensureSessionResult(session.id);
pushOverlayEvent("start", `${session.name}${t("timing.start")}`);
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;
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;
lastFinishAnnouncementSessionId = null;
delete lastOverlayLeaderKeyBySession[session.id];
delete lastOverlayTop3BySession[session.id];
overlayEvents = [];
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();
});
});
}
function renderJudging() {
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, judgingCompetitorFilter);
if (selectedJudgeKey && !filteredRows.some((row) => row.key === selectedJudgeKey)) {
selectedJudgeKey = null;
}
if (!selectedJudgeKey && filteredRows.length) {
selectedJudgeKey = filteredRows[0].key;
}
const selectedRow = leaderboard.find((row) => row.key === selectedJudgeKey) || null;
const selectedPassings = selectedRow ? getCompetitorPassings(active, selectedRow, { includeInvalid: true }) : [];
const actionLog = getJudgeFilteredLog(Array.isArray(result.adjustments) ? result.adjustments.slice(-50).reverse() : [], judgingLogFilter);
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" ${judgingCompetitorFilter === "all" ? "selected" : ""}>${t("judging.filter_all")}</option>
<option value="invalid" ${judgingCompetitorFilter === "invalid" ? "selected" : ""}>${t("judging.filter_invalid")}</option>
<option value="corrected" ${judgingCompetitorFilter === "corrected" ? "selected" : ""}>${t("judging.filter_corrected")}</option>
<option value="team" ${judgingCompetitorFilter === "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 === selectedJudgeKey ? "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" ${judgingLogFilter === "all" ? "selected" : ""}>${t("judging.filter_all")}</option>
<option value="corrections" ${judgingLogFilter === "corrections" ? "selected" : ""}>${t("judging.filter_log_corrections")}</option>
<option value="invalid" ${judgingLogFilter === "invalid" ? "selected" : ""}>${t("judging.filter_log_invalidations")}</option>
<option value="undo" ${judgingLogFilter === "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", () => {
selectedJudgeKey = row.key;
renderJudging();
});
});
document.getElementById("judgingCompetitorFilter")?.addEventListener("change", (event) => {
const input = event.currentTarget;
if (!(input instanceof HTMLSelectElement)) {
return;
}
judgingCompetitorFilter = input.value;
renderJudging();
});
document.getElementById("judgingLogFilter")?.addEventListener("change", (event) => {
const input = event.currentTarget;
if (!(input instanceof HTMLSelectElement)) {
return;
}
judgingLogFilter = input.value;
renderJudging();
});
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);
renderJudging();
});
actionLog.forEach((entry) => {
if (!entry.undo || entry.undoneAt) {
return;
}
document.getElementById(`judge-undo-${entry.id}`)?.addEventListener("click", () => {
undoJudgingAdjustment(active, entry.id);
renderJudging();
});
});
if (!selectedRow) {
return;
}
document.getElementById("judgeLapPlus")?.addEventListener("click", () => {
applyCompetitorCorrection(active, selectedRow, { lapDelta: 1 });
renderJudging();
});
document.getElementById("judgeLapMinus")?.addEventListener("click", () => {
applyCompetitorCorrection(active, selectedRow, { lapDelta: -1 });
renderJudging();
});
document.getElementById("judgeSecPlus")?.addEventListener("click", () => {
applyCompetitorCorrection(active, selectedRow, { timeMsDelta: 1000 });
renderJudging();
});
document.getElementById("judgeFivePlus")?.addEventListener("click", () => {
applyCompetitorCorrection(active, selectedRow, { timeMsDelta: 5000 });
renderJudging();
});
document.getElementById("judgeSecMinus")?.addEventListener("click", () => {
applyCompetitorCorrection(active, selectedRow, { timeMsDelta: -1000 });
renderJudging();
});
document.getElementById("judgeInvalidate")?.addEventListener("click", () => {
invalidateCompetitorLastLap(active, selectedRow);
renderJudging();
});
document.getElementById("judgeRestore")?.addEventListener("click", () => {
restoreCompetitorLastInvalidLap(active, selectedRow);
renderJudging();
});
document.getElementById("judgeReset")?.addEventListener("click", () => {
applyCompetitorCorrection(active, selectedRow, { reset: true });
renderJudging();
});
}
function renderSpeakerToggle(settingKey, labelKey) {
return `
<label class="check-card">
<input type="checkbox" data-speaker-setting="${settingKey}" ${state.settings[settingKey] ? "checked" : ""} />
<span>${t(labelKey)}</span>
</label>
`;
}
function renderQuickAddPanel(session) {
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 renderGuidePanel(titleKey, itemKeys, extras = "") {
return `
<section class="panel">

887
src/timing_views.js Normal file
View File

@@ -0,0 +1,887 @@
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.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"),
"",
],
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><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, pushOverlayEvent, saveState, updateHeaderState, renderView,
normalizeDriver, normalizeCar, processDecoderMessage, getSelectedLeaderboardKey, setSelectedLeaderboardKey,
getQuickAddDraft, setQuickAddDraft, getPassingValidationLabel, isCountedPassing, getCompetitorPassings,
getManualCorrectionSummary, formatTeamActiveMemberLabel, formatRaceClock,
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;
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("settings.decoder")}</label>
<input id="timingWsUrl" value="${escapeHtml(state.settings.wsUrl)}" placeholder="ws://127.0.0.1:9000" />
<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>${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>
<p class="error">${escapeHtml(state.decoder.lastError || "")}</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>
<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();
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);
pushOverlayEvent("start", `${session.name}${t("timing.start")}`);
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;
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);
});
}