920 lines
42 KiB
JavaScript
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);
|
|
});
|
|
}
|