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">
|
||||
|
||||
Reference in New Issue
Block a user