function clampDigits(raw) { const parsed = Number(raw); if (!Number.isFinite(parsed)) return 4; return Math.max(1, Math.min(4, Math.round(parsed))); } function sanitizeValue(raw, maxDigits) { return String(raw || "") .replace(/\D+/g, "") .slice(0, maxDigits); } function sanitizeName(raw) { return String(raw || "") .replace(/\s+/g, " ") .trimStart(); } function isBlankRow(row) { return !row || (!String(row.value || "").trim() && !String(row.name || "").trim()); } function normalizeRows(raw, maxDigits) { if (!Array.isArray(raw)) return []; return raw .filter((row) => row && typeof row === "object" && !Array.isArray(row)) .map((row) => ({ value: sanitizeValue(row.value, maxDigits), name: sanitizeName(row.name), })); } function ensureTrailingBlankRow(rows) { const next = rows.map((row) => ({ value: String(row.value || ""), name: String(row.name || ""), })); if (!next.length || !isBlankRow(next[next.length - 1])) { next.push({ value: "", name: "" }); } return next; } function persistedRows(rows) { return rows .filter((row) => !isBlankRow(row)) .map((row) => ({ value: String(row.value || ""), name: String(row.name || ""), })); } function totalValue(rows) { return persistedRows(rows).reduce((sum, row) => sum + (Number.parseInt(row.value, 10) || 0), 0); } function normalizeConfig(state) { const maxDigits = clampDigits(state.max_digits); return { leftLabel: String(state.left_label || "Value").trim() || "Value", rightLabel: String(state.right_label || "Item").trim() || "Item", totalLabel: String(state.total_label || "Total").trim() || "Total", totalSuffix: String(state.total_suffix || "").trim(), maxDigits, score: typeof state.score === "number" && Number.isFinite(state.score) ? Math.max(0, Math.min(100, state.score)) : 24, rows: ensureTrailingBlankRow(normalizeRows(state.rows, maxDigits)), }; } function configState(config) { return { left_label: config.leftLabel, right_label: config.rightLabel, total_label: config.totalLabel, total_suffix: config.totalSuffix, max_digits: config.maxDigits, score: config.score, rows: persistedRows(config.rows), }; } function autoscore(config) { if (typeof config.score === "number" && Number.isFinite(config.score) && config.score > 0) { return config.score; } return persistedRows(config.rows).length ? 24 : 16; } export function mount({ root, state, host }) { const labelsEl = root.querySelector(".list-total-card-ui__labels"); const rowsEl = root.querySelector(".list-total-card-ui__rows"); const statusEl = root.querySelector(".list-total-card-ui__status"); const totalLabelEl = root.querySelector(".list-total-card-ui__total-label"); const totalEl = root.querySelector(".list-total-card-ui__total-value"); if ( !(labelsEl instanceof HTMLElement) || !(rowsEl instanceof HTMLElement) || !(statusEl instanceof HTMLElement) || !(totalLabelEl instanceof HTMLElement) || !(totalEl instanceof HTMLElement) ) { return; } const leftLabelEl = labelsEl.children.item(0); const rightLabelEl = labelsEl.children.item(1); if (!(leftLabelEl instanceof HTMLElement) || !(rightLabelEl instanceof HTMLElement)) return; let config = normalizeConfig(state); let saveTimer = null; let busy = false; const clearSaveTimer = () => { if (saveTimer !== null) { window.clearTimeout(saveTimer); saveTimer = null; } }; const setStatus = (text, kind) => { statusEl.textContent = text || ""; statusEl.dataset.kind = kind || ""; }; const publishLiveContent = () => { host.setLiveContent({ kind: "list_total", item_count: persistedRows(config.rows).length, total: totalValue(config.rows), total_suffix: config.totalSuffix || null, score: autoscore(config), }); }; const renderTotal = () => { const total = totalValue(config.rows); totalEl.textContent = `${total.toLocaleString()}${config.totalSuffix || ""}`; publishLiveContent(); }; const persist = async () => { clearSaveTimer(); busy = true; setStatus("Saving", "ok"); try { const nextState = configState(config); await host.replaceState(nextState); config = normalizeConfig(nextState); setStatus("", ""); } catch (error) { console.error("List total card save failed", error); setStatus("Unavailable", "error"); } finally { busy = false; render(); } }; const schedulePersist = () => { clearSaveTimer(); saveTimer = window.setTimeout(() => { void persist(); }, 280); }; const normalizeRowsAfterBlur = () => { config.rows = ensureTrailingBlankRow(persistedRows(config.rows)); render(); schedulePersist(); }; const renderRows = () => { rowsEl.innerHTML = ""; config.rows.forEach((row, index) => { const rowEl = document.createElement("div"); rowEl.className = "list-total-card-ui__row"; const valueInput = document.createElement("input"); valueInput.className = "list-total-card-ui__input list-total-card-ui__value"; valueInput.type = "text"; valueInput.inputMode = "numeric"; valueInput.maxLength = config.maxDigits; valueInput.placeholder = "0"; valueInput.value = row.value; valueInput.disabled = busy; const nameInput = document.createElement("input"); nameInput.className = "list-total-card-ui__input list-total-card-ui__name"; nameInput.type = "text"; nameInput.placeholder = "Item"; nameInput.value = row.name; nameInput.disabled = busy; valueInput.addEventListener("input", () => { config.rows[index].value = sanitizeValue(valueInput.value, config.maxDigits); valueInput.value = config.rows[index].value; if (index === config.rows.length - 1 && !isBlankRow(config.rows[index])) { config.rows = ensureTrailingBlankRow(config.rows); render(); schedulePersist(); return; } renderTotal(); schedulePersist(); }); nameInput.addEventListener("input", () => { config.rows[index].name = sanitizeName(nameInput.value); nameInput.value = config.rows[index].name; if (index === config.rows.length - 1 && !isBlankRow(config.rows[index])) { config.rows = ensureTrailingBlankRow(config.rows); render(); schedulePersist(); return; } renderTotal(); schedulePersist(); }); valueInput.addEventListener("blur", normalizeRowsAfterBlur); nameInput.addEventListener("blur", normalizeRowsAfterBlur); rowEl.append(valueInput, nameInput); rowsEl.appendChild(rowEl); }); }; const render = () => { leftLabelEl.textContent = config.leftLabel; rightLabelEl.textContent = config.rightLabel; totalLabelEl.textContent = config.totalLabel; renderRows(); renderTotal(); }; host.setRefreshHandler(() => { config.rows = ensureTrailingBlankRow(config.rows); render(); }); render(); return { update({ state: nextState }) { config = normalizeConfig(nextState); render(); }, destroy() { clearSaveTimer(); host.setRefreshHandler(null); host.setLiveContent(null); }, }; }