Files
PenaltyTracker/web/competition.js
T
CodingPhoenix 357888e310 Fix WebSocket status label not updating and move it into the topbar
The onopen/onclose handlers only toggled the CSS class on the status
dot, never the separate text span, so it stayed "offline" after a
reconnect. Replace the querySelector-based toggling with an
updateWsIndicator() helper that updates both the dot and its label
from stored element refs.

The indicator now lives in the topbar (via renderTopbar's extra slot)
instead of next to the competition name, with its own .ws-indicator
flex wrapper so the dot and label line up vertically.
2026-09-06 06:23:42 +02:00

1412 lines
58 KiB
JavaScript

(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(); } });
let selectedFile = null;
const fileNameSpan = el("span", { class: "muted small", style: { marginLeft: "0.5rem" } }, "");
const fileInput = el("input", { type: "file", accept: ".csv,text/csv", style: { display: "none" },
onchange: (e) => {
selectedFile = e.target.files[0] || null;
fileNameSpan.textContent = selectedFile ? selectedFile.name : "";
},
});
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")),
el("p", { class: "muted small", style: { marginBottom: "0.5rem" } }, t("csv_separator_hint")),
el("div", { style: { marginBottom: "0.75rem", display: "flex", alignItems: "center" } },
fileInput,
el("button", { onclick: () => fileInput.click() }, t("upload_file")),
fileNameSpan,
),
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 {
const data = selectedFile || textarea.value;
if (!data || (typeof data === "string" && !data.trim())) {
alert(t("csv_empty")); return;
}
await API.importPilots(competitionId, data);
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 wsIndicator() {
state.wsDot = el("span", { class: "connection-status " + (state.wsOnline ? "online" : "offline") });
state.wsText = el("span", { class: "muted" }, state.wsOnline ? t("online") : t("offline"));
return el("span", { class: "ws-indicator" }, state.wsDot, state.wsText);
}
function updateWsIndicator() {
if (state.wsDot) {
state.wsDot.classList.toggle("online", state.wsOnline);
state.wsDot.classList.toggle("offline", !state.wsOnline);
}
if (state.wsText) state.wsText.textContent = state.wsOnline ? t("online") : t("offline");
}
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: [wsIndicator(), 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,
),
));
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; updateWsIndicator(); },
onclose: () => { state.wsOnline = false; updateWsIndicator(); },
onmessage: handleWSMessage,
});
render();
})();