(async function () { const root = document.getElementById("app"); const user = await bootstrapAuth({ requireAuth: true, forbidIfMustChange: true }); if (!user) return; const params = queryParams(); const competitionId = parseInt(params.id, 10); if (!competitionId) { navigate("competitions"); return; } let competition; try { competition = await API.getCompetition(competitionId); } catch (e) { navigate("competitions"); return; } const state = { competition, pilots: [], penalties: [], members: [], users: [], rules: [], rulesByNumber: {}, ruleLanguages: [], ws: null, wsOnline: false, tab: params.tab || "penalties", sort: { col: "date", dir: "desc" }, pilotSort: { col: "number", dir: "asc" }, filterApplied: "all", // "all" | "applied" | "open" filterText: "", }; async function loadAll() { await Promise.all([loadPilots(), loadPenalties(), loadMembers(), loadRules(), loadRuleLanguages()]); if (user.is_system_admin) await loadUsers(); } async function loadPilots() { state.pilots = await API.listPilots(competitionId); } async function loadPenalties() { state.penalties = await API.listPenalties(competitionId); } async function loadMembers() { state.members = await API.listMembers(competitionId); } async function loadUsers() { state.users = await API.listUsers(); } async function loadRules() { const lang = state.competition.rules_language || user.language; state.rules = await API.listRules(lang); state.rulesByNumber = {}; for (const r of state.rules) state.rulesByNumber[r.number] = r; } async function loadRuleLanguages() { try { state.ruleLanguages = await API.listRuleLanguages(); } catch (e) { state.ruleLanguages = []; } } function isChief() { const r = state.competition.role; return r === "system_admin" || r === "chief_scorer"; } function isSysAdmin() { return state.competition.role === "system_admin"; } function isClosed() { return !!state.competition.closed; } function canEditPenalty(p) { if (isClosed()) return false; if (isChief()) return true; if (p.created_by === user.id) return true; return !!state.competition.allow_any_scorer_edit; } function pilotCountForRule(pilotNumber, ruleNumber, excludeId) { if (!pilotNumber || !ruleNumber) return 0; let n = 0; for (const p of state.penalties) { if (p.pilot_number === pilotNumber && p.rule_number === ruleNumber && p.id !== excludeId) n++; } return n; } // ---- Penalties tab ----------------------------------------------------- const PENALTY_COLS = [ { key: "flight", label: "flight" }, { key: "date", label: "date" }, { key: "pilot_number", label: "pilot_number" }, { key: "pilot_name", label: "pilot_name" }, { key: "rule_number", label: "rule_number_short" }, { key: "task", label: "task" }, { key: "penalties_text", label: "penalty_values" }, { key: "description", label: "description" }, { key: "created_by_name", label: "created_by" }, { key: "transferred", label: "applied" }, ]; function sortPenalties(list) { const { col, dir } = state.sort; const sign = dir === "asc" ? 1 : -1; return [...list].sort((a, b) => { let av = a[col], bv = b[col]; if (typeof av === "boolean") av = av ? 1 : 0; if (typeof bv === "boolean") bv = bv ? 1 : 0; if (av == null) av = ""; if (bv == null) bv = ""; if (typeof av === "number" && typeof bv === "number") return (av - bv) * sign; return String(av).localeCompare(String(bv), undefined, { numeric: true }) * sign; }); } function filterPenalties(list) { if (state.filterApplied === "applied") list = list.filter((p) => p.transferred); else if (state.filterApplied === "open") list = list.filter((p) => !p.transferred); const q = state.filterText.trim().toLowerCase(); if (q) { list = list.filter((p) => (p.flight || "").toLowerCase().includes(q) || (p.date || "").toLowerCase().includes(q) || (p.pilot_number || "").toLowerCase().includes(q) || (p.pilot_name || "").toLowerCase().includes(q) || (p.rule_number || "").toLowerCase().includes(q) || (p.task || "").toLowerCase().includes(q) || (p.penalties_text || "").toLowerCase().includes(q) || (p.description || "").toLowerCase().includes(q) || (p.created_by_name || "").toLowerCase().includes(q) ); } return list; } function renderPenaltiesTab() { const card = el("div"); const search = el("input", { type: "search", placeholder: t("search_penalties"), style: { minWidth: "240px", flex: "1" }, value: state.filterText, oninput: (e) => { state.filterText = e.target.value; patchPenaltyTable(); } }); const applyFilter = el("select", { onchange: (e) => { state.filterApplied = e.target.value; patchPenaltyTable(); } }, el("option", { value: "all", selected: state.filterApplied === "all" }, t("filter_all")), el("option", { value: "open", selected: state.filterApplied === "open" }, t("filter_open")), el("option", { value: "applied", selected: state.filterApplied === "applied" }, t("filter_applied")), ); const toolbar = el("div", { class: "toolbar" }, el("button", { class: "primary", disabled: isClosed(), onclick: () => { if (!isClosed()) openPenaltyModal(); } }, t("add_penalty")), search, applyFilter, el("div", { class: "spacer" }), isChief() && !isClosed() && el("button", { onclick: openApplyByTaskModal }, t("apply_by_task")), isChief() && el("button", { onclick: async () => { try { const blob = await API.exportPenaltiesCSV(competitionId); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `penalties_${competitionId}.csv`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } catch (e) { alert(e.message); } } }, t("export_csv")), ); card.appendChild(toolbar); const tableWrap = el("div", { class: "table-wrap", id: "penalty-table-wrap" }); const table = el("table", { id: "penalty-table" }); const thead = el("thead"); const headRow = el("tr"); for (const c of PENALTY_COLS) { headRow.appendChild(el("th", { title: c.title ? t(c.title) : null, onclick: () => { if (state.sort.col === c.key) state.sort.dir = state.sort.dir === "asc" ? "desc" : "asc"; else { state.sort.col = c.key; state.sort.dir = "asc"; } patchPenaltyTable(); } }, t(c.label), el("span", { class: "sort-ind" }, state.sort.col === c.key ? (state.sort.dir === "asc" ? "▲" : "▼") : ""), )); } headRow.appendChild(el("th", null, t("actions"))); thead.appendChild(headRow); table.appendChild(thead); const tbody = el("tbody", { id: "penalty-tbody" }); table.appendChild(tbody); tableWrap.appendChild(table); card.appendChild(tableWrap); const status = el("div", { class: "muted", id: "penalty-count", style: { marginTop: "0.5rem" } }); card.appendChild(status); setTimeout(patchPenaltyTable, 0); return card; } function patchPenaltyTable() { const tbody = document.getElementById("penalty-tbody"); if (!tbody) return; const list = sortPenalties(filterPenalties(state.penalties)); const status = document.getElementById("penalty-count"); if (status) status.textContent = t("showing_n_of_m", { n: list.length, m: state.penalties.length }); const headRow = document.querySelector("#penalty-table thead tr"); if (headRow) { const ths = headRow.querySelectorAll("th"); PENALTY_COLS.forEach((c, i) => { const ind = ths[i].querySelector(".sort-ind"); if (ind) ind.textContent = state.sort.col === c.key ? (state.sort.dir === "asc" ? "▲" : "▼") : ""; }); } const existing = new Map(); for (const tr of Array.from(tbody.children)) existing.set(tr.dataset.id, tr); const wanted = new Set(); let prev = null; for (const p of list) { const idStr = String(p.id); wanted.add(idStr); let tr = existing.get(idStr); if (!tr) tr = buildPenaltyRow(p); else updatePenaltyRow(tr, p); if (prev) { if (prev.nextSibling !== tr) tbody.insertBefore(tr, prev.nextSibling); } else { if (tbody.firstChild !== tr) tbody.insertBefore(tr, tbody.firstChild); } prev = tr; } for (const [k, tr] of existing) if (!wanted.has(k)) tr.remove(); } function buildPenaltyRow(p) { const tr = document.createElement("tr"); tr.dataset.id = String(p.id); for (const c of PENALTY_COLS) { const td = document.createElement("td"); td.dataset.col = c.key; tr.appendChild(td); } const actions = document.createElement("td"); actions.dataset.col = "_actions"; tr.appendChild(actions); updatePenaltyRow(tr, p); return tr; } function updatePenaltyRow(tr, p) { tr.classList.toggle("transferred", !!p.transferred); for (const c of PENALTY_COLS) { const td = tr.querySelector(`td[data-col="${c.key}"]`); if (!td) continue; if (c.key === "transferred") { td.innerHTML = ""; const lbl = document.createElement("label"); lbl.className = "switch"; const inp = document.createElement("input"); inp.type = "checkbox"; inp.checked = !!p.transferred; inp.disabled = !canEditPenalty(p); inp.addEventListener("change", async () => { try { await API.updatePenalty(competitionId, p.id, { transferred: inp.checked }); } catch (e) { inp.checked = !inp.checked; alert(e.message); } }); const slider = document.createElement("span"); slider.className = "slider"; lbl.appendChild(inp); lbl.appendChild(slider); td.appendChild(lbl); } else { const val = p[c.key]; td.textContent = val == null ? "" : String(val); } } const actions = tr.querySelector('td[data-col="_actions"]'); actions.innerHTML = ""; const summary = document.createElement("button"); summary.className = "action-btn"; summary.textContent = t("summary"); summary.addEventListener("click", () => openPenaltySummary(p)); actions.appendChild(summary); if (canEditPenalty(p)) { const edit = document.createElement("button"); edit.className = "action-btn"; edit.textContent = t("edit"); edit.addEventListener("click", () => openPenaltyModal(p)); const del = document.createElement("button"); del.className = "action-btn danger"; del.textContent = t("delete"); del.addEventListener("click", async () => { if (!confirm(t("confirm_delete"))) return; await API.deletePenalty(competitionId, p.id); }); actions.appendChild(edit); actions.appendChild(del); } } function openPenaltySummary(p) { const rule = p.rule_number ? state.rulesByNumber[p.rule_number] : null; const count = pilotCountForRule(p.pilot_number, p.rule_number, p.id); const backdrop = el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } }); function escalationViz(r) { if (!r) return el("span", null, ""); if (r.escalation_mode === "same") return el("span", { class: "muted small" }, t("escalation_same")); if (r.escalation_mode === "doubled") return el("span", { class: "muted small" }, t("escalation_doubled")); if (r.escalation_mode === "escalate") { const wrap = el("div", { class: "tier-row" }); (r.escalation_tiers || []).forEach((tier, i, arr) => { wrap.appendChild(el("span", { class: "tier-pill" }, tier)); if (i < arr.length - 1) wrap.appendChild(el("span", { class: "tier-arrow" }, "→")); }); return wrap; } return el("span", null, ""); } const incidentsBox = el("div", { style: { display: "none", marginTop: "0.5rem" } }); const incidentsBtn = el("button", { class: "action-btn", disabled: count === 0, onclick: () => { if (incidentsBox.style.display === "none") { incidentsBox.innerHTML = ""; const prior = state.penalties .filter((x) => x.pilot_number === p.pilot_number && x.rule_number === p.rule_number && x.id !== p.id) .sort((a, b) => naturalCompare(a.date, b.date) || (a.id - b.id)); const tbl = el("table", { class: "mini-table" }); tbl.appendChild(el("thead", null, el("tr", null, el("th", null, t("flight")), el("th", null, t("date")), el("th", null, t("task")), el("th", null, t("penalty_values")), el("th", null, t("description")), el("th", null, t("applied")), ))); const tb = el("tbody"); for (const pp of prior) { tb.appendChild(el("tr", null, el("td", null, pp.flight || ""), el("td", null, pp.date || ""), el("td", null, pp.task || ""), el("td", null, el("span", { class: "badge accent" }, pp.penalties_text || "")), el("td", null, pp.description || ""), el("td", null, pp.transferred ? t("yes") : t("no")), )); } tbl.appendChild(tb); incidentsBox.appendChild(tbl); incidentsBox.style.display = "block"; incidentsBtn.textContent = t("hide_incidents"); } else { incidentsBox.style.display = "none"; incidentsBox.innerHTML = ""; incidentsBtn.textContent = t("show_incidents") + " (" + count + ")"; } } }, t("show_incidents") + " (" + count + ")"); backdrop.appendChild(el("div", { class: "modal" }, el("h3", null, t("penalty_summary")), el("div", { class: "kv" }, el("span", { class: "k" }, t("pilot_number")), el("span", null, p.pilot_number + (p.pilot_name ? " — " + p.pilot_name : ""))), el("div", { class: "kv" }, el("span", { class: "k" }, t("flight")), el("span", null, p.flight || "—")), el("div", { class: "kv" }, el("span", { class: "k" }, t("date")), el("span", null, p.date || "—")), el("div", { class: "kv" }, el("span", { class: "k" }, t("task")), el("span", null, p.task || "—")), el("div", { class: "kv" }, el("span", { class: "k" }, t("penalty_values")), el("span", { class: "badge accent" }, p.penalties_text || "—")), el("div", { class: "kv" }, el("span", { class: "k" }, t("description")), el("span", null, p.description || "—")), el("div", { class: "kv" }, el("span", { class: "k" }, t("created_by")), el("span", null, p.created_by_name || "—")), el("div", { class: "kv" }, el("span", { class: "k" }, t("applied")), el("span", { class: p.transferred ? "badge accent" : "badge" }, p.transferred ? t("yes") : t("no"))), el("div", { class: "row", style: { marginTop: "0.5rem" } }, incidentsBtn), incidentsBox, el("h4", { style: { marginTop: "1rem", marginBottom: "0.25rem" } }, t("rule")), rule ? el("div", { class: "rule-card" }, el("div", { class: "rule-head" }, el("span", { class: "rule-num" }, rule.number), el("span", { class: "rule-text" }, rule.text), ), el("div", { class: "kv" }, el("span", { class: "k" }, t("suggested_penalty")), el("span", { class: "badge accent" }, rule.suggested_penalty || "—"), ), el("div", { class: "kv" }, el("span", { class: "k" }, t("escalation")), escalationViz(rule), ), ) : el("div", { class: "muted" }, p.rule_number ? (p.rule_number + " — " + t("rule_not_found")) : t("none")), el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, el("button", { onclick: () => backdrop.remove() }, t("close")), ), )); document.body.appendChild(backdrop); } // Apply workflow: select task → step through each pilot's open penalties one // at a time → review overview → save all reviewed as applied. function openApplyByTaskModal() { const wiz = { task: null, // selected task string ("" for empty-task) pilots: [], // [{ pilotNumber, pilotName, penalties: [...] }] cursor: 0, // index into wiz.pilots reviewedIds: [], // penalty IDs the user has stepped past }; const backdrop = el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target === backdrop) attemptClose(); } }); const modal = el("div", { class: "modal" }); backdrop.appendChild(modal); document.body.appendChild(backdrop); function attemptClose() { if (wiz.reviewedIds.length === 0) { backdrop.remove(); return; } const choice = confirm(t("confirm_save_partial", { n: wiz.reviewedIds.length })); if (choice) { savePartial(); } else { backdrop.remove(); } } async function savePartial() { try { await API.applyPenalties(competitionId, { ids: wiz.reviewedIds, applied: true }); backdrop.remove(); await loadPenalties(); patchPenaltyTable(); } catch (e) { alert(e.message); } } function pilotLookup(num) { return state.pilots.find((p) => p.number === num); } function renderTaskSelect() { const groups = {}; for (const p of state.penalties) { if (p.transferred) continue; const task = p.task || ""; if (!groups[task]) groups[task] = 0; groups[task]++; } const tasks = Object.keys(groups).sort(naturalCompare); modal.innerHTML = ""; modal.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", alignItems: "center" } }, el("h3", { style: { margin: 0 } }, t("apply_by_task")), el("button", { class: "ghost", onclick: attemptClose }, "✕"), )); modal.appendChild(el("p", { class: "muted" }, t("apply_select_task"))); if (tasks.length === 0) { modal.appendChild(el("div", { class: "muted", style: { padding: "1rem 0" } }, t("no_open_penalties"))); modal.appendChild(el("div", { class: "row", style: { justifyContent: "flex-end" } }, el("button", { onclick: attemptClose }, t("close")), )); return; } const list = el("div"); for (const task of tasks) { const label = task === "" ? t("no_task") : task; list.appendChild(el("div", { class: "row apply-row", style: { justifyContent: "space-between", padding: "0.4rem 0", borderBottom: "1px solid var(--border)" } }, el("div", null, el("strong", null, label), " ", el("span", { class: "muted" }, t("open") + ": " + groups[task])), el("button", { class: "primary", onclick: () => startWalk(task) }, t("start_apply")), )); } modal.appendChild(list); modal.appendChild(el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, el("button", { onclick: attemptClose }, t("close")), )); } function startWalk(task) { wiz.task = task; // Group open penalties for this task by pilot, sorted by pilot number. const byPilot = new Map(); for (const p of state.penalties) { if (p.transferred) continue; if ((p.task || "") !== task) continue; const key = p.pilot_number || ""; if (!byPilot.has(key)) { byPilot.set(key, { pilotNumber: p.pilot_number || "", pilotName: p.pilot_name || "", penalties: [], }); } byPilot.get(key).penalties.push(p); } wiz.pilots = Array.from(byPilot.values()) .sort((a, b) => naturalCompare(a.pilotNumber, b.pilotNumber)); wiz.cursor = 0; wiz.reviewedIds = []; if (wiz.pilots.length === 0) renderOverview(); else renderPilotStep(); } function renderPilotStep() { const entry = wiz.pilots[wiz.cursor]; const pilot = pilotLookup(entry.pilotNumber); const stepNum = wiz.cursor + 1; const stepTotal = wiz.pilots.length; modal.innerHTML = ""; modal.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", alignItems: "center" } }, el("h3", { style: { margin: 0 } }, t("apply_by_task") + " — ", wiz.task === "" ? t("no_task") : wiz.task, ), el("button", { class: "ghost", onclick: attemptClose }, "✕"), )); modal.appendChild(el("div", { class: "muted", style: { marginBottom: "0.5rem" } }, t("step_x_of_y", { x: stepNum, y: stepTotal }))); modal.appendChild(el("h4", null, t("pilot") + ": ", entry.pilotNumber, entry.pilotName ? " — " + entry.pilotName : "", )); if (pilot) { modal.appendChild(el("div", { class: "kv" }, el("span", { class: "k" }, t("country")), el("span", null, pilot.country || "—"))); modal.appendChild(el("div", { class: "kv" }, el("span", { class: "k" }, t("balloon_id")), el("span", null, pilot.balloon_id || "—"))); } const tbl = el("table", { class: "mini-table", style: { marginTop: "0.5rem" } }); tbl.appendChild(el("thead", null, el("tr", null, el("th", null, t("flight")), el("th", null, t("date")), el("th", null, t("rule_number_short")), el("th", null, t("rule")), el("th", null, t("penalty_values")), el("th", null, t("description")), ))); const tb = el("tbody"); for (const p of entry.penalties) { const rule = p.rule_number ? state.rulesByNumber[p.rule_number] : null; const ruleText = rule ? rule.text : (p.rule_number ? t("rule_not_found") : ""); tb.appendChild(el("tr", null, el("td", null, p.flight || ""), el("td", null, p.date || ""), el("td", null, p.rule_number || ""), el("td", { class: "wrap-cell" }, ruleText), el("td", null, el("span", { class: "badge accent" }, p.penalties_text || "")), el("td", null, p.description || ""), )); } tbl.appendChild(tb); modal.appendChild(tbl); modal.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginTop: "1rem" } }, el("button", { onclick: attemptClose }, t("cancel")), el("div", { class: "row" }, wiz.cursor > 0 && el("button", { onclick: () => { // step back: don't remove already-reviewed pilots (they remain // in reviewedIds — going back is just preview). wiz.cursor--; renderPilotStep(); } }, "← " + t("back")), el("button", { class: "primary", onclick: () => { for (const p of entry.penalties) { if (!wiz.reviewedIds.includes(p.id)) wiz.reviewedIds.push(p.id); } wiz.cursor++; if (wiz.cursor >= wiz.pilots.length) renderOverview(); else renderPilotStep(); } }, wiz.cursor + 1 >= wiz.pilots.length ? t("to_overview") + " →" : t("next") + " →"), ), )); } function renderOverview() { modal.innerHTML = ""; modal.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", alignItems: "center" } }, el("h3", { style: { margin: 0 } }, t("apply_overview") + " — ", wiz.task === "" ? t("no_task") : wiz.task, ), el("button", { class: "ghost", onclick: attemptClose }, "✕"), )); const reviewedSet = new Set(wiz.reviewedIds); const items = []; for (const entry of wiz.pilots) { for (const p of entry.penalties) { if (reviewedSet.has(p.id)) items.push(p); } } modal.appendChild(el("p", { class: "muted" }, t("apply_overview_explain", { n: items.length }))); if (items.length === 0) { modal.appendChild(el("div", { class: "muted", style: { padding: "1rem 0" } }, t("nothing_to_apply"))); } else { const tbl = el("table", { class: "mini-table" }); tbl.appendChild(el("thead", null, el("tr", null, el("th", null, t("pilot_number")), el("th", null, t("pilot_name")), el("th", null, t("flight")), el("th", null, t("rule_number_short")), el("th", null, t("rule")), el("th", null, t("penalty_values")), el("th", null, t("description")), ))); const tb = el("tbody"); items.sort((a, b) => naturalCompare(a.pilot_number, b.pilot_number) || naturalCompare(a.flight, b.flight) || (a.id - b.id)); for (const p of items) { const rule = p.rule_number ? state.rulesByNumber[p.rule_number] : null; const ruleText = rule ? rule.text : (p.rule_number ? t("rule_not_found") : ""); tb.appendChild(el("tr", null, el("td", null, p.pilot_number || ""), el("td", null, p.pilot_name || ""), el("td", null, p.flight || ""), el("td", null, p.rule_number || ""), el("td", { class: "wrap-cell" }, ruleText), el("td", null, el("span", { class: "badge accent" }, p.penalties_text || "")), el("td", null, p.description || ""), )); } tbl.appendChild(tb); modal.appendChild(el("div", { class: "table-wrap" }, tbl)); } modal.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginTop: "1rem" } }, el("button", { onclick: () => { // go back to last pilot step (preview) if (wiz.pilots.length > 0) { wiz.cursor = wiz.pilots.length - 1; renderPilotStep(); } else { renderTaskSelect(); } } }, "← " + t("back")), el("div", { class: "row" }, el("button", { onclick: attemptClose }, t("cancel")), el("button", { class: "primary", disabled: items.length === 0, onclick: async (e) => { e.target.disabled = true; try { await API.applyPenalties(competitionId, { ids: wiz.reviewedIds, applied: true }); backdrop.remove(); await loadPenalties(); patchPenaltyTable(); } catch (err) { alert(err.message); e.target.disabled = false; } } }, t("save")), ), )); } renderTaskSelect(); } function openPenaltyModal(penalty) { const p = penalty || {}; const backdrop = el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } }); const flight = el("input", { type: "text", value: p.flight || "" }); const date = el("input", { type: "date", value: p.date || new Date().toISOString().slice(0, 10) }); const pilotSelect = el("select", { onchange: () => refreshPrior() }, el("option", { value: "" }, "—"), ...sortPilots(state.pilots).map((pl) => el("option", { value: pl.number, selected: pl.number === p.pilot_number }, `${pl.number} — ${pl.last_name}, ${pl.first_name}`)) ); const existingRule = p.rule_number ? state.rulesByNumber[p.rule_number] : null; const ruleSearch = el("input", { type: "text", placeholder: t("search_rule"), value: existingRule ? `${existingRule.number} — ${existingRule.text}` : (p.rule_number || "") }); const ruleNumber = el("input", { type: "text", placeholder: t("rule_number_short"), value: p.rule_number || "" }); const suggestionBox = el("div", { class: "rule-card", style: { display: "none" } }); const searchResults = el("div", { class: "results", style: { display: "none" } }); const priorBox = el("div", { class: "prior-box", style: { display: "none" } }); function escalationViz(r) { if (r.escalation_mode === "same") return el("span", { class: "muted small" }, t("escalation_same")); if (r.escalation_mode === "doubled") return el("span", { class: "muted small" }, t("escalation_doubled")); if (r.escalation_mode === "escalate") { const wrap = el("div", { class: "tier-row" }); (r.escalation_tiers || []).forEach((tier, i, arr) => { wrap.appendChild(el("span", { class: "tier-pill" }, tier)); if (i < arr.length - 1) wrap.appendChild(el("span", { class: "tier-arrow" }, "→")); }); return wrap; } return el("span", null, ""); } function refreshSuggestion() { const rNum = ruleNumber.value.trim(); const r = state.rulesByNumber[rNum]; if (!r) { suggestionBox.style.display = "none"; return; } suggestionBox.style.display = "block"; suggestionBox.innerHTML = ""; suggestionBox.appendChild(el("div", { class: "rule-head" }, el("span", { class: "rule-num" }, r.number), el("span", { class: "rule-text" }, r.text), )); suggestionBox.appendChild(el("div", { class: "kv" }, el("span", { class: "k" }, t("suggested_penalty")), el("span", { class: "badge accent" }, r.suggested_penalty), )); suggestionBox.appendChild(el("div", { class: "kv" }, el("span", { class: "k" }, t("escalation")), escalationViz(r), )); } function refreshPrior() { const rNum = ruleNumber.value.trim(); const pNum = pilotSelect.value; if (!rNum || !pNum) { priorBox.style.display = "none"; return; } const prior = state.penalties.filter((x) => x.pilot_number === pNum && x.rule_number === rNum && x.id !== p.id ); priorBox.innerHTML = ""; if (prior.length === 0) { priorBox.style.display = "none"; return; } priorBox.style.display = "block"; priorBox.appendChild(el("div", { class: "prior-title" }, t("prior_penalties") + " (" + prior.length + ")" )); const tbl = el("table", { class: "mini-table" }); tbl.appendChild(el("thead", null, el("tr", null, el("th", null, t("flight")), el("th", null, t("date")), el("th", null, t("penalty_values")), el("th", null, t("description")), ))); const tb = el("tbody"); prior.sort((a, b) => naturalCompare(a.date, b.date) || (a.id - b.id)); for (const pp of prior) { tb.appendChild(el("tr", null, el("td", null, pp.flight), el("td", null, pp.date), el("td", null, el("span", { class: "badge accent" }, pp.penalties_text)), el("td", null, pp.description), )); } tbl.appendChild(tb); priorBox.appendChild(tbl); } refreshSuggestion(); refreshPrior(); ruleNumber.addEventListener("input", () => { refreshSuggestion(); refreshPrior(); }); function searchRules(q) { q = q.trim().toLowerCase(); if (!q) return []; return state.rules.filter((r) => r.number.toLowerCase().includes(q) || r.text.toLowerCase().includes(q) ).slice(0, 30); } ruleSearch.addEventListener("input", () => { const items = searchRules(ruleSearch.value); searchResults.innerHTML = ""; if (items.length === 0) { searchResults.style.display = "none"; return; } searchResults.style.display = "block"; for (const r of items) { const it = el("div", { class: "item", onclick: () => { ruleNumber.value = r.number; ruleSearch.value = `${r.number} — ${r.text}`; searchResults.style.display = "none"; refreshSuggestion(); refreshPrior(); } }, el("span", { class: "num" }, r.number), r.text ); searchResults.appendChild(it); } }); ruleSearch.addEventListener("blur", () => setTimeout(() => searchResults.style.display = "none", 200)); const task = el("input", { type: "text", value: p.task || "" }); const penaltiesText = el("input", { type: "text", value: p.penalties_text || "", placeholder: "e.g. 50 CP, Warning, +50m, No Result" }); const description = el("textarea", null, p.description || ""); const transferred = el("input", { type: "checkbox", checked: !!p.transferred }); backdrop.appendChild(el("div", { class: "modal" }, el("h3", null, p.id ? t("edit") : t("add_penalty")), el("div", { class: "grid" }, el("div", { class: "field" }, el("label", null, t("flight")), flight), el("div", { class: "field" }, el("label", null, t("date")), date), el("div", { class: "field" }, el("label", null, t("pilot_number")), pilotSelect), ), el("div", { class: "field", style: { marginTop: "0.5rem" } }, el("label", null, t("search_rule")), el("div", { class: "search-box" }, ruleSearch, searchResults), ), el("div", { class: "field" }, el("label", null, t("rule_number_short")), ruleNumber), suggestionBox, priorBox, el("div", { class: "grid", style: { marginTop: "0.5rem" } }, el("div", { class: "field" }, el("label", null, t("task")), task), el("div", { class: "field" }, el("label", null, t("penalty_values")), penaltiesText), ), el("div", { class: "field" }, el("label", null, t("description")), description), isChief() && el("label", { class: "row", style: { marginTop: "0.5rem" } }, transferred, " " + t("applied")), el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, el("button", { onclick: () => backdrop.remove() }, t("cancel")), el("button", { class: "primary", onclick: async () => { const body = { flight: flight.value, date: date.value, pilot_number: pilotSelect.value, rule_number: ruleNumber.value, task: task.value, penalties_text: penaltiesText.value, description: description.value, }; if (isChief()) body.transferred = transferred.checked; try { if (p.id) await API.updatePenalty(competitionId, p.id, body); else await API.createPenalty(competitionId, body); backdrop.remove(); } catch (e) { alert(e.message); } } }, t("save")), ), )); document.body.appendChild(backdrop); } // ---- Pilots tab -------------------------------------------------------- const PILOT_COLS = [ { key: "number", label: "number" }, { key: "last_name", label: "last_name" }, { key: "first_name", label: "first_name" }, { key: "country", label: "country" }, { key: "balloon_id", label: "balloon_id" }, ]; function sortPilots(list) { const { col, dir } = state.pilotSort; const sign = dir === "asc" ? 1 : -1; return [...list].sort((a, b) => naturalCompare(a[col], b[col]) * sign); } function renderPilotsTab() { const card = el("div"); if (isChief()) { card.appendChild(el("div", { class: "toolbar" }, el("button", { class: "primary", onclick: openPilotModal }, t("add_pilot")), el("button", { onclick: openImportModal }, t("import_csv")), )); } if (state.pilots.length === 0) { card.appendChild(el("div", { class: "muted" }, t("no_pilots"))); return card; } const table = el("table"); const headRow = el("tr"); for (const c of PILOT_COLS) { headRow.appendChild(el("th", { onclick: () => { if (state.pilotSort.col === c.key) state.pilotSort.dir = state.pilotSort.dir === "asc" ? "desc" : "asc"; else { state.pilotSort.col = c.key; state.pilotSort.dir = "asc"; } render(); } }, t(c.label), el("span", { class: "sort-ind" }, state.pilotSort.col === c.key ? (state.pilotSort.dir === "asc" ? "▲" : "▼") : ""), )); } if (isChief()) headRow.appendChild(el("th", null, t("actions"))); table.appendChild(el("thead", null, headRow)); const tbody = el("tbody"); for (const pl of sortPilots(state.pilots)) { tbody.appendChild(el("tr", null, el("td", null, pl.number), el("td", null, pl.last_name), el("td", null, pl.first_name), el("td", null, pl.country), el("td", null, pl.balloon_id), isChief() && el("td", null, el("button", { class: "action-btn", onclick: () => openPilotModal(pl) }, t("edit")), el("button", { class: "action-btn danger", onclick: async () => { if (!confirm(t("confirm_delete"))) return; await API.deletePilot(competitionId, pl.id); await loadPilots(); render(); } }, t("delete")), ), )); } table.appendChild(tbody); card.appendChild(el("div", { class: "table-wrap" }, table)); return card; } function openPilotModal(pilot) { const p = pilot || {}; const backdrop = el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } }); const number = el("input", { type: "text", value: p.number || "" }); const lastName = el("input", { type: "text", value: p.last_name || "" }); const firstName = el("input", { type: "text", value: p.first_name || "" }); const country = el("input", { type: "text", value: p.country || "" }); const balloon = el("input", { type: "text", value: p.balloon_id || "" }); backdrop.appendChild(el("div", { class: "modal" }, el("h3", null, p.id ? t("edit") : t("add_pilot")), el("div", { class: "grid" }, el("div", { class: "field" }, el("label", null, t("number")), number), el("div", { class: "field" }, el("label", null, t("last_name")), lastName), el("div", { class: "field" }, el("label", null, t("first_name")), firstName), el("div", { class: "field" }, el("label", null, t("country")), country), el("div", { class: "field" }, el("label", null, t("balloon_id")), balloon), ), el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, el("button", { onclick: () => backdrop.remove() }, t("cancel")), el("button", { class: "primary", onclick: async () => { const body = { number: number.value.trim(), last_name: lastName.value.trim(), first_name: firstName.value.trim(), country: country.value.trim(), balloon_id: balloon.value.trim(), }; try { if (p.id) await API.updatePilot(competitionId, p.id, body); else await API.createPilot(competitionId, body); await loadPilots(); backdrop.remove(); render(); } catch (e) { alert(e.message); } } }, t("save")), ), )); document.body.appendChild(backdrop); } function openImportModal() { const backdrop = el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } }); const textarea = el("textarea", { placeholder: t("csv_paste"), style: { width: "100%", minHeight: "180px" } }); backdrop.appendChild(el("div", { class: "modal" }, el("h3", null, t("import_csv")), textarea, el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, el("button", { onclick: () => backdrop.remove() }, t("cancel")), el("button", { class: "primary", onclick: async () => { try { await API.importPilots(competitionId, textarea.value); await loadPilots(); backdrop.remove(); render(); } catch (e) { alert(e.message); } } }, t("import_csv")), ), )); document.body.appendChild(backdrop); } // ---- Rules tab --------------------------------------------------------- function ruleEscalationViz(r) { if (r.escalation_mode === "same") return el("span", { class: "muted small" }, t("escalation_same")); if (r.escalation_mode === "doubled") return el("span", { class: "muted small" }, t("escalation_doubled")); if (r.escalation_mode === "escalate") { const wrap = el("div", { class: "tier-row" }); (r.escalation_tiers || []).forEach((tier, i, arr) => { wrap.appendChild(el("span", { class: "tier-pill" }, tier)); if (i < arr.length - 1) wrap.appendChild(el("span", { class: "tier-arrow" }, "→")); }); return wrap; } return el("span", null, ""); } const RULE_COLS = [ { key: "number", label: "rule_number_short", className: "col-num" }, { key: "text", label: "rule", className: "col-text" }, { key: "suggested_penalty", label: "suggested_penalty", className: "col-suggested" }, { key: "escalation_mode", label: "escalation", className: "col-escalation" }, ]; function escalationSortKey(r) { if (r.escalation_mode === "same") return "1"; if (r.escalation_mode === "doubled") return "2"; if (r.escalation_mode === "escalate") return "3"; return "9"; } function renderRulesTab() { if (!state.ruleSort) state.ruleSort = { col: "number", dir: "asc" }; const card = el("div"); const search = el("input", { type: "search", placeholder: t("search_rule"), style: { flex: "1", minWidth: "240px" }, value: state.ruleFilter || "", oninput: (e) => { state.ruleFilter = e.target.value; refresh(); } }); const count = el("div", { class: "muted", style: { margin: "0.5rem 0" } }); const tableWrap = el("div", { class: "table-wrap" }); const table = el("table", { class: "rules-table" }); const thead = el("thead"); const headRow = el("tr"); for (const c of RULE_COLS) { headRow.appendChild(el("th", { class: c.className, onclick: () => { if (state.ruleSort.col === c.key) state.ruleSort.dir = state.ruleSort.dir === "asc" ? "desc" : "asc"; else { state.ruleSort.col = c.key; state.ruleSort.dir = "asc"; } refresh(); } }, t(c.label), el("span", { class: "sort-ind" }, state.ruleSort.col === c.key ? (state.ruleSort.dir === "asc" ? "▲" : "▼") : ""), )); } thead.appendChild(headRow); table.appendChild(thead); const tbody = el("tbody"); table.appendChild(tbody); tableWrap.appendChild(table); function refresh() { tbody.innerHTML = ""; const ths = headRow.querySelectorAll("th"); RULE_COLS.forEach((c, i) => { const ind = ths[i].querySelector(".sort-ind"); if (ind) ind.textContent = state.ruleSort.col === c.key ? (state.ruleSort.dir === "asc" ? "▲" : "▼") : ""; }); const q = (state.ruleFilter || "").trim().toLowerCase(); const filtered = q ? state.rules.filter((r) => r.number.toLowerCase().includes(q) || r.text.toLowerCase().includes(q) || (r.suggested_penalty || "").toLowerCase().includes(q)) : [...state.rules]; const { col, dir } = state.ruleSort; const sign = dir === "asc" ? 1 : -1; filtered.sort((a, b) => { const av = col === "escalation_mode" ? escalationSortKey(a) : (a[col] || ""); const bv = col === "escalation_mode" ? escalationSortKey(b) : (b[col] || ""); return naturalCompare(av, bv) * sign; }); count.textContent = t("showing_n_of_m", { n: filtered.length, m: state.rules.length }); if (filtered.length === 0) { tbody.appendChild(el("tr", null, el("td", { colspan: String(RULE_COLS.length), class: "muted" }, t("none")))); return; } for (const r of filtered) { tbody.appendChild(el("tr", null, el("td", { class: "col-num" }, el("span", { class: "rule-num" }, r.number)), el("td", { class: "col-text wrap-cell" }, r.text), el("td", { class: "col-suggested wrap-cell" }, r.suggested_penalty ? el("span", { class: "badge accent" }, r.suggested_penalty) : el("span", { class: "muted" }, "—")), el("td", { class: "col-escalation" }, ruleEscalationViz(r)), )); } } card.appendChild(el("div", { class: "toolbar" }, search)); card.appendChild(count); card.appendChild(tableWrap); setTimeout(refresh, 0); return card; } // ---- Members tab ------------------------------------------------------- function renderMembersTab() { const card = el("div"); card.appendChild(el("div", { class: "toolbar" }, el("button", { class: "primary", onclick: openMemberModal }, t("add_member")), user.is_system_admin && el("button", { onclick: openUserModalForCompetition }, t("add_user")), )); if (state.members.length === 0) { card.appendChild(el("div", { class: "muted" }, t("no_members"))); return card; } const table = el("table"); table.appendChild(el("thead", null, el("tr", null, el("th", null, t("username")), el("th", null, t("display_name")), el("th", null, t("role")), el("th", null, t("actions")), ))); const tbody = el("tbody"); for (const m of state.members) { tbody.appendChild(el("tr", null, el("td", null, m.username), el("td", null, m.display_name), el("td", null, t(m.role)), el("td", null, el("button", { class: "action-btn danger", onclick: async () => { if (!confirm(t("confirm_delete"))) return; await API.removeMember(competitionId, m.user_id); await loadMembers(); render(); } }, t("remove")), ), )); } table.appendChild(tbody); card.appendChild(el("div", { class: "table-wrap" }, table)); return card; } function openMemberModal() { const backdrop = el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } }); const username = el("input", { type: "text", placeholder: t("username"), oninput: (e) => { e.target.value = e.target.value.toLowerCase(); } }); const roleSelect = el("select", null, el("option", { value: "scorer" }, t("scorer")), el("option", { value: "chief_scorer" }, t("chief_scorer")), ); backdrop.appendChild(el("div", { class: "modal" }, el("h3", null, t("add_member")), el("p", { class: "muted" }, t("username")), username, el("div", { class: "field", style: { marginTop: "0.5rem" } }, el("label", null, t("role")), roleSelect), el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, el("button", { onclick: () => backdrop.remove() }, t("cancel")), el("button", { class: "primary", onclick: async () => { let users = []; try { users = await API.listUsers(); } catch (e) { alert(t("forbidden")); return; } const wanted = username.value.trim().toLowerCase(); const u = users.find((x) => x.username === wanted); if (!u) { alert(t("user_not_found")); return; } await API.addMember(competitionId, { user_id: u.id, role: roleSelect.value }); await loadMembers(); backdrop.remove(); render(); } }, t("save")), ), )); document.body.appendChild(backdrop); } function openUserModalForCompetition() { const backdrop = el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } }); const username = el("input", { type: "text", oninput: (e) => { e.target.value = e.target.value.toLowerCase(); } }); const password = el("input", { type: "password" }); const displayName = el("input", { type: "text" }); const langSelect = el("select", null, ...I18N_AVAILABLE.map((l) => el("option", { value: l }, I18N_NAMES[l])) ); const roleSelect = el("select", null, el("option", { value: "scorer" }, t("scorer")), el("option", { value: "chief_scorer" }, t("chief_scorer")), ); backdrop.appendChild(el("div", { class: "modal" }, el("h3", null, t("add_user")), el("div", { class: "field" }, el("label", null, t("username")), username), el("div", { class: "field" }, el("label", null, t("password")), password), el("div", { class: "field" }, el("label", null, t("display_name")), displayName), el("div", { class: "field" }, el("label", null, t("language")), langSelect), el("div", { class: "field" }, el("label", null, t("role")), roleSelect), el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, el("button", { onclick: () => backdrop.remove() }, t("cancel")), el("button", { class: "primary", onclick: async () => { if (!username.value.trim() || !password.value) return; try { const created = await API.createUser({ username: username.value.trim().toLowerCase(), password: password.value, display_name: displayName.value, language: langSelect.value, is_system_admin: false, }); await API.addMember(competitionId, { user_id: created.id, role: roleSelect.value }); await loadMembers(); backdrop.remove(); render(); } catch (err) { alert(err.message); } } }, t("create")), ), )); document.body.appendChild(backdrop); } // ---- Settings tab ------------------------------------------------------ function renderSettingsTab() { const readonly = isClosed() && !isSysAdmin(); const name = el("input", { type: "text", value: state.competition.name, disabled: readonly }); const allow = el("input", { type: "checkbox", checked: !!state.competition.allow_any_scorer_edit, disabled: readonly }); const currentRulesLang = state.competition.rules_language || "en"; const langOptions = (state.ruleLanguages && state.ruleLanguages.length) ? [...state.ruleLanguages].sort() : I18N_AVAILABLE; const rulesLang = el("select", { disabled: readonly }, ...langOptions.map((l) => el("option", { value: l, selected: l === currentRulesLang }, I18N_NAMES[l] || l)) ); const msg = el("div", { class: "muted", style: { color: "var(--accent)", display: "none" } }); const card = el("div", { class: "card" }, el("h2", null, t("settings_tab")), el("div", { class: "field" }, el("label", null, t("competition_name")), name), el("div", { class: "field" }, el("label", null, t("rules_language")), rulesLang, el("div", { class: "muted small" }, t("rules_language_hint"))), el("label", { class: "row", style: { marginTop: "0.5rem" } }, allow, " " + t("allow_any_scorer_edit")), msg, el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, el("button", { class: "primary", disabled: readonly, onclick: async () => { try { await API.updateCompetition(competitionId, { name: name.value, allow_any_scorer_edit: allow.checked, rules_language: rulesLang.value, }); const c = await API.getCompetition(competitionId); state.competition = c; await loadRules(); msg.textContent = t("saved"); msg.style.display = "block"; } catch (e) { alert(e.message); } } }, t("save_settings")), ), ); if (isSysAdmin()) { const adminCard = el("div", { class: "card", style: { marginTop: "1rem" } }); adminCard.appendChild(el("h2", null, t("admin_zone"))); if (isClosed()) { adminCard.appendChild(el("div", { class: "muted", style: { marginBottom: "0.5rem" } }, t("competition_closed_explain"), state.competition.closed_at ? " (" + state.competition.closed_at + ")" : "", )); adminCard.appendChild(el("div", { class: "row", style: { gap: "0.5rem", flexWrap: "wrap" } }, el("button", { class: "primary", onclick: async () => { if (!confirm(t("confirm_reopen_competition"))) return; try { await API.reopenCompetition(competitionId); state.competition = await API.getCompetition(competitionId); render(); } catch (e) { alert((e.data && e.data.error) || e.message); } } }, t("reopen_competition")), el("button", { class: "danger", onclick: async () => { if (!confirm(t("confirm_delete_competition"))) return; try { await API.deleteCompetition(competitionId); navigate("competitions"); } catch (e) { alert((e.data && e.data.error) || e.message); } } }, t("delete_competition")), )); } else { adminCard.appendChild(el("div", { class: "muted", style: { marginBottom: "0.5rem" } }, t("close_competition_explain"))); adminCard.appendChild(el("div", { class: "row", style: { gap: "0.5rem", flexWrap: "wrap" } }, el("button", { class: "primary", onclick: async () => { if (!confirm(t("confirm_close_competition"))) return; try { const res = await API.closeCompetition(competitionId); state.competition = await API.getCompetition(competitionId); if (res && res.backup) alert(t("backup_written", { file: res.backup })); render(); } catch (e) { alert((e.data && e.data.error) || e.message); } } }, t("close_competition")), el("button", { class: "danger", onclick: async () => { if (!confirm(t("confirm_delete_competition"))) return; try { await API.deleteCompetition(competitionId); navigate("competitions"); } catch (e) { alert((e.data && e.data.error) || e.message); } } }, t("delete_competition")), )); } return el("div", null, card, adminCard); } return card; } // ---- Main render ------------------------------------------------------- function render() { clearNode(root); const backBtn = el("button", { class: "ghost", onclick: () => { if (state.ws) { state.ws.close(); state.ws = null; } navigate("competitions"); } }, "← " + t("back")); root.appendChild(renderTopbar(user, { extra: backBtn })); const container = el("div", { class: "container" }); container.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.5rem" } }, el("h2", { style: { margin: 0 } }, state.competition.name, " ", el("span", { class: "badge accent" }, t(state.competition.role)), isClosed() ? " " : null, isClosed() ? el("span", { class: "badge warn" }, t("closed")) : null, ), el("div", { class: "row" }, el("span", { class: "connection-status " + (state.wsOnline ? "online" : "offline") }), el("span", { class: "muted" }, state.wsOnline ? t("online") : t("offline")), ), )); const tabs = el("div", { class: "tabs" }); const tabDefs = [ ["penalties", t("penalties")], ["pilots", t("pilots")], ["rules", t("rules")], ]; if (isChief()) { tabDefs.push(["members", t("members")]); tabDefs.push(["settings", t("settings_tab")]); } for (const [id, label] of tabDefs) { tabs.appendChild(el("button", { class: state.tab === id ? "active" : "", onclick: () => { state.tab = id; render(); } }, label)); } container.appendChild(tabs); if (state.tab === "penalties") container.appendChild(renderPenaltiesTab()); else if (state.tab === "pilots") container.appendChild(renderPilotsTab()); else if (state.tab === "rules") container.appendChild(renderRulesTab()); else if (state.tab === "members") container.appendChild(renderMembersTab()); else if (state.tab === "settings") container.appendChild(renderSettingsTab()); root.appendChild(container); } function handleWSMessage(msg) { if (msg.type === "penalty_created") { if (!state.penalties.some((p) => p.id === msg.payload.id)) { state.penalties.unshift(msg.payload); patchPenaltyTable(); } } else if (msg.type === "penalty_updated") { const idx = state.penalties.findIndex((p) => p.id === msg.payload.id); if (idx >= 0) { state.penalties[idx] = msg.payload; patchPenaltyTable(); } } else if (msg.type === "penalty_deleted") { state.penalties = state.penalties.filter((p) => p.id !== msg.payload.id); patchPenaltyTable(); } else if (msg.type === "penalties_applied") { loadPenalties().then(() => patchPenaltyTable()); } else if (msg.type === "pilot_changed") { Promise.all([loadPilots(), loadPenalties()]).then(() => { if (state.tab === "penalties") patchPenaltyTable(); else render(); }); } else if (msg.type === "competition_updated") { API.getCompetition(competitionId).then(async (c) => { const prevLang = state.competition.rules_language; state.competition = c; if (c.rules_language !== prevLang) await loadRules(); render(); }); } } await loadAll(); state.ws = openCompetitionWS(competitionId, { onopen: () => { state.wsOnline = true; const e = document.querySelector(".connection-status"); if (e) { e.classList.add("online"); e.classList.remove("offline"); } }, onclose: () => { state.wsOnline = false; const e = document.querySelector(".connection-status"); if (e) { e.classList.remove("online"); e.classList.add("offline"); } }, onmessage: handleWSMessage, }); render(); })();