Extract timing and judging views into module
This commit is contained in:
658
src/app.js
658
src/app.js
@@ -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
887
src/timing_views.js
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user