Files
PenaltyTracker/web/competition.js
T

1323 lines
54 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 canEditPenalty(p) {
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", onclick: () => openPenaltyModal() }, t("add_penalty")),
search,
applyFilter,
el("div", { class: "spacer" }),
isChief() && 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 name = el("input", { type: "text", value: state.competition.name });
const allow = el("input", { type: "checkbox", checked: !!state.competition.allow_any_scorer_edit });
const currentRulesLang = state.competition.rules_language || "en";
const langOptions = (state.ruleLanguages && state.ruleLanguages.length)
? [...state.ruleLanguages].sort()
: I18N_AVAILABLE;
const rulesLang = el("select", null,
...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" } });
return 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", 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")),
),
);
}
// ---- 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)),
),
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();
})();