527 lines
18 KiB
TypeScript
527 lines
18 KiB
TypeScript
/**
|
|
* rFunds demo — client-side WebSocket controller.
|
|
*
|
|
* Connects via DemoSync, extracts expenses and budget from shapes,
|
|
* renders/updates budget overview, expense list, balances, settlements,
|
|
* category breakdown, and per-person stats. Supports inline expense editing.
|
|
*/
|
|
|
|
import { DemoSync } from "@lib/demo-sync-vanilla";
|
|
import type { DemoShape } from "@lib/demo-sync-vanilla";
|
|
|
|
// ── Types ──
|
|
|
|
interface ExpenseShape extends DemoShape {
|
|
type: "demo-expense";
|
|
description: string;
|
|
amount: number;
|
|
currency: string;
|
|
paidBy: string;
|
|
split: "equal" | "custom";
|
|
category: "transport" | "accommodation" | "activity" | "food";
|
|
date: string;
|
|
}
|
|
|
|
interface BudgetShape extends DemoShape {
|
|
type: "folk-budget";
|
|
budgetTitle: string;
|
|
currency: string;
|
|
budgetTotal: number;
|
|
spent: number;
|
|
categories: { name: string; budget: number; spent: number }[];
|
|
}
|
|
|
|
function isExpense(shape: DemoShape): shape is ExpenseShape {
|
|
return shape.type === "demo-expense" && typeof (shape as ExpenseShape).amount === "number";
|
|
}
|
|
|
|
function isBudget(shape: DemoShape): shape is BudgetShape {
|
|
return shape.type === "folk-budget";
|
|
}
|
|
|
|
// ── Constants ──
|
|
|
|
const MEMBERS = ["Maya", "Liam", "Priya", "Omar"];
|
|
|
|
const MEMBER_COLORS: Record<string, string> = {
|
|
Maya: "#10b981",
|
|
Liam: "#06b6d4",
|
|
Priya: "#8b5cf6",
|
|
Omar: "#f59e0b",
|
|
};
|
|
|
|
const MEMBER_BG: Record<string, string> = {
|
|
Maya: "rd-bg-emerald",
|
|
Liam: "rd-bg-cyan",
|
|
Priya: "rd-bg-violet",
|
|
Omar: "rd-bg-amber",
|
|
};
|
|
|
|
const CATEGORY_ICONS: Record<string, string> = {
|
|
transport: "\u{1F682}",
|
|
accommodation: "\u{1F3E8}",
|
|
activity: "\u26F7",
|
|
food: "\u{1F372}",
|
|
};
|
|
|
|
const CATEGORY_LABELS: Record<string, string> = {
|
|
transport: "Transport",
|
|
accommodation: "Accommodation",
|
|
activity: "Activities",
|
|
food: "Food & Drink",
|
|
};
|
|
|
|
const CATEGORY_TEXT_CLASS: Record<string, string> = {
|
|
transport: "rd-cyan",
|
|
accommodation: "rd-violet",
|
|
activity: "rd-amber",
|
|
food: "rd-rose",
|
|
};
|
|
|
|
// ── Helpers ──
|
|
|
|
function fmt(amount: number): string {
|
|
return `\u20AC${amount.toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`;
|
|
}
|
|
|
|
function formatDate(dateStr: string): string {
|
|
try {
|
|
const d = new Date(dateStr);
|
|
return d.toLocaleDateString("en-GB", { day: "numeric", month: "short" });
|
|
} catch {
|
|
return dateStr;
|
|
}
|
|
}
|
|
|
|
function esc(s: string): string {
|
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
|
|
// ── Balance computation ──
|
|
|
|
interface BalanceEntry {
|
|
name: string;
|
|
paid: number;
|
|
owes: number;
|
|
balance: number;
|
|
}
|
|
|
|
function computeBalances(expenses: ExpenseShape[]): BalanceEntry[] {
|
|
const total = expenses.reduce((s, e) => s + e.amount, 0);
|
|
const perPerson = total / MEMBERS.length;
|
|
const paid: Record<string, number> = {};
|
|
MEMBERS.forEach((m) => (paid[m] = 0));
|
|
expenses.forEach((e) => (paid[e.paidBy] = (paid[e.paidBy] || 0) + e.amount));
|
|
return MEMBERS.map((name) => ({
|
|
name,
|
|
paid: paid[name] || 0,
|
|
owes: perPerson,
|
|
balance: (paid[name] || 0) - perPerson,
|
|
}));
|
|
}
|
|
|
|
// ── Settlement computation (greedy) ──
|
|
|
|
interface Settlement {
|
|
from: string;
|
|
to: string;
|
|
amount: number;
|
|
}
|
|
|
|
function computeSettlements(balances: BalanceEntry[]): Settlement[] {
|
|
const debtors = balances
|
|
.filter((b) => b.balance < -0.01)
|
|
.map((b) => ({ ...b, balance: -b.balance }));
|
|
const creditors = balances
|
|
.filter((b) => b.balance > 0.01)
|
|
.map((b) => ({ ...b }));
|
|
|
|
debtors.sort((a, b) => b.balance - a.balance);
|
|
creditors.sort((a, b) => b.balance - a.balance);
|
|
|
|
const settlements: Settlement[] = [];
|
|
let di = 0,
|
|
ci = 0;
|
|
|
|
while (di < debtors.length && ci < creditors.length) {
|
|
const amount = Math.min(debtors[di].balance, creditors[ci].balance);
|
|
if (amount > 0.01) {
|
|
settlements.push({
|
|
from: debtors[di].name,
|
|
to: creditors[ci].name,
|
|
amount: Math.round(amount * 100) / 100,
|
|
});
|
|
}
|
|
debtors[di].balance -= amount;
|
|
creditors[ci].balance -= amount;
|
|
if (debtors[di].balance < 0.01) di++;
|
|
if (creditors[ci].balance < 0.01) ci++;
|
|
}
|
|
|
|
return settlements;
|
|
}
|
|
|
|
// ── DOM refs ──
|
|
|
|
const connBadge = document.getElementById("rd-conn-badge") as HTMLElement;
|
|
const resetBtn = document.getElementById("rd-reset-btn") as HTMLButtonElement;
|
|
const loadingEl = document.getElementById("rd-loading") as HTMLElement;
|
|
const emptyEl = document.getElementById("rd-empty") as HTMLElement;
|
|
|
|
const budgetSection = document.getElementById("rd-budget-section") as HTMLElement;
|
|
const expensesSection = document.getElementById("rd-expenses-section") as HTMLElement;
|
|
const spendingSection = document.getElementById("rd-spending-section") as HTMLElement;
|
|
const personSection = document.getElementById("rd-person-section") as HTMLElement;
|
|
|
|
const budgetTotalEl = document.getElementById("rd-budget-total") as HTMLElement;
|
|
const budgetSpentEl = document.getElementById("rd-budget-spent") as HTMLElement;
|
|
const budgetRemainingEl = document.getElementById("rd-budget-remaining") as HTMLElement;
|
|
const budgetRemainingLabel = document.getElementById("rd-budget-remaining-label") as HTMLElement;
|
|
const budgetPctLabel = document.getElementById("rd-budget-pct-label") as HTMLElement;
|
|
const budgetLeftLabel = document.getElementById("rd-budget-left-label") as HTMLElement;
|
|
const budgetBar = document.getElementById("rd-budget-bar") as HTMLElement;
|
|
|
|
const expenseList = document.getElementById("rd-expense-list") as HTMLElement;
|
|
const expenseCount = document.getElementById("rd-expense-count") as HTMLElement;
|
|
const expenseTotal = document.getElementById("rd-expense-total") as HTMLElement;
|
|
const balancesBody = document.getElementById("rd-balances-body") as HTMLElement;
|
|
const settlementsBody = document.getElementById("rd-settlements-body") as HTMLElement;
|
|
|
|
// ── DemoSync ──
|
|
|
|
const sync = new DemoSync({ filter: ["demo-expense", "folk-budget"] });
|
|
|
|
// Editing state
|
|
let editingExpenseId: string | null = null;
|
|
|
|
// Show loading spinner immediately
|
|
loadingEl.style.display = "";
|
|
|
|
// ── Connection events ──
|
|
|
|
sync.addEventListener("connected", () => {
|
|
connBadge.className = "rd-status rd-status--connected";
|
|
connBadge.textContent = "Connected";
|
|
resetBtn.disabled = false;
|
|
});
|
|
|
|
sync.addEventListener("disconnected", () => {
|
|
connBadge.className = "rd-status rd-status--disconnected";
|
|
connBadge.textContent = "Disconnected";
|
|
resetBtn.disabled = true;
|
|
});
|
|
|
|
// ── Snapshot -> render ──
|
|
|
|
sync.addEventListener("snapshot", ((e: CustomEvent) => {
|
|
const shapes: Record<string, DemoShape> = e.detail.shapes;
|
|
|
|
const expenses = Object.values(shapes)
|
|
.filter(isExpense)
|
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
|
|
const budget = Object.values(shapes).find(isBudget) ?? null;
|
|
|
|
// Hide loading
|
|
loadingEl.style.display = "none";
|
|
|
|
const hasData = expenses.length > 0 || budget !== null;
|
|
|
|
if (!hasData) {
|
|
emptyEl.style.display = "";
|
|
budgetSection.style.display = "none";
|
|
expensesSection.style.display = "none";
|
|
spendingSection.style.display = "none";
|
|
personSection.style.display = "none";
|
|
return;
|
|
}
|
|
emptyEl.style.display = "none";
|
|
|
|
// ── Computed values ──
|
|
const totalSpent = expenses.reduce((s, ex) => s + ex.amount, 0);
|
|
const budgetTotal = budget?.budgetTotal ?? 4000;
|
|
const budgetSpent = budget?.spent ?? totalSpent;
|
|
const budgetRemaining = budgetTotal - budgetSpent;
|
|
const budgetPct = budgetTotal > 0 ? Math.min(100, Math.round((budgetSpent / budgetTotal) * 100)) : 0;
|
|
|
|
const balances = computeBalances(expenses);
|
|
const settlements = computeSettlements(balances);
|
|
|
|
// Category breakdown from budget shape or computed from expenses
|
|
let categoryBreakdown: { name: string; budget: number; spent: number }[];
|
|
if (budget?.categories && budget.categories.length > 0) {
|
|
categoryBreakdown = budget.categories;
|
|
} else {
|
|
const cats: Record<string, number> = {};
|
|
expenses.forEach((ex) => (cats[ex.category] = (cats[ex.category] || 0) + ex.amount));
|
|
categoryBreakdown = Object.entries(cats).map(([name, spent]) => ({
|
|
name,
|
|
budget: Math.round(budgetTotal / 4),
|
|
spent,
|
|
}));
|
|
}
|
|
|
|
// ── Update Budget Overview ──
|
|
budgetSection.style.display = "";
|
|
budgetTotalEl.textContent = fmt(budgetTotal);
|
|
budgetSpentEl.textContent = fmt(budgetSpent);
|
|
budgetRemainingEl.textContent = fmt(Math.abs(budgetRemaining));
|
|
budgetRemainingEl.className = `rd-stat__value ${budgetRemaining >= 0 ? "rd-cyan" : "rd-rose"}`;
|
|
budgetRemainingLabel.textContent = budgetRemaining >= 0 ? "Remaining" : "Over Budget";
|
|
budgetPctLabel.textContent = `${budgetPct}% used`;
|
|
budgetLeftLabel.textContent = `${fmt(Math.abs(budgetRemaining))} ${budgetRemaining >= 0 ? "left" : "over"}`;
|
|
budgetBar.style.width = `${budgetPct}%`;
|
|
budgetBar.className = `rd-progress__fill ${budgetPct >= 90 ? "rd-progress__fill--rose" : budgetPct >= 70 ? "rd-progress__fill--amber" : "rd-progress__fill--emerald"}`;
|
|
|
|
// Category breakdown
|
|
const catSection = document.getElementById("rd-category-breakdown")!;
|
|
const catCards = catSection.querySelectorAll<HTMLElement>("[data-category]");
|
|
catCards.forEach((card) => {
|
|
const key = card.dataset.category!;
|
|
const catData = categoryBreakdown.find(
|
|
(c) => c.name.toLowerCase() === key,
|
|
);
|
|
const spent = catData?.spent ?? 0;
|
|
const catBudget = catData?.budget ?? Math.round(budgetTotal / 4);
|
|
const catPct = catBudget > 0 ? Math.min(100, Math.round((spent / catBudget) * 100)) : 0;
|
|
|
|
const amountsEl = card.querySelector("[data-cat-amounts]") as HTMLElement;
|
|
const barEl = card.querySelector("[data-cat-bar]") as HTMLElement;
|
|
const pctEl = card.querySelector("[data-cat-pct]") as HTMLElement;
|
|
|
|
if (amountsEl) amountsEl.textContent = `${fmt(spent)} / ${fmt(catBudget)}`;
|
|
if (barEl) barEl.style.width = `${catPct}%`;
|
|
if (pctEl) pctEl.textContent = `${catPct}% used`;
|
|
});
|
|
|
|
// ── Update Expenses ──
|
|
if (expenses.length > 0) {
|
|
expensesSection.style.display = "";
|
|
expenseCount.textContent = `Expenses (${expenses.length})`;
|
|
expenseTotal.textContent = fmt(totalSpent);
|
|
|
|
expenseList.innerHTML = expenses
|
|
.map((ex) => {
|
|
const catIcon = CATEGORY_ICONS[ex.category] || "\u{1F4B0}";
|
|
const catLabel = CATEGORY_LABELS[ex.category] || ex.category;
|
|
const catTextClass = CATEGORY_TEXT_CLASS[ex.category] || "rd-text-muted";
|
|
const perPerson = fmt(Math.round((ex.amount / MEMBERS.length) * 100) / 100);
|
|
|
|
return `<div class="rd-item" data-expense-id="${esc(ex.id)}">
|
|
<div style="width:2.5rem; height:2.5rem; border-radius:0.75rem; display:flex; align-items:center; justify-content:center; font-size:1.125rem; background:rgba(51,65,85,0.4); flex-shrink:0;">
|
|
${catIcon}
|
|
</div>
|
|
<div style="flex:1; min-width:0;">
|
|
<p style="font-size:0.875rem; color:#e2e8f0; font-weight:500; margin:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">${esc(ex.description)}</p>
|
|
<div style="display:flex; align-items:center; gap:0.375rem; font-size:0.75rem; color:#64748b; margin-top:0.125rem; flex-wrap:wrap;">
|
|
<span class="${catTextClass}">${catLabel}</span>
|
|
<span>\u00B7</span>
|
|
<span>Paid by ${esc(ex.paidBy)}</span>
|
|
<span>\u00B7</span>
|
|
<span>${ex.split === "equal" ? `Split ${MEMBERS.length} ways` : "Custom split"}</span>
|
|
<span>\u00B7</span>
|
|
<span>${formatDate(ex.date)}</span>
|
|
</div>
|
|
</div>
|
|
<button class="rd-expense-amount" data-edit-expense="${esc(ex.id)}" data-amount="${ex.amount}" style="background:none; border:none; cursor:pointer; text-align:right; padding:0.25rem 0.5rem; border-radius:0.5rem; transition:background 0.15s; flex-shrink:0;" title="Click to edit amount">
|
|
<span style="font-size:0.875rem; font-weight:600; color:#e2e8f0; display:block;">${fmt(ex.amount)}</span>
|
|
<span style="font-size:0.75rem; color:#64748b; display:block;">${perPerson}/person</span>
|
|
</button>
|
|
</div>`;
|
|
})
|
|
.join("");
|
|
} else {
|
|
expensesSection.style.display = "none";
|
|
}
|
|
|
|
// ── Update Balances ──
|
|
balancesBody.innerHTML = balances
|
|
.map((b) => {
|
|
const bgClass = MEMBER_BG[b.name] || "rd-bg-slate";
|
|
const initial = b.name[0];
|
|
const balanceColor = b.balance >= 0 ? "rd-emerald" : "rd-rose";
|
|
const balanceStr = `${b.balance >= 0 ? "+" : ""}${fmt(Math.round(b.balance * 100) / 100)}`;
|
|
|
|
return `<div style="display:flex; align-items:center; gap:0.75rem; margin-bottom:0.75rem;">
|
|
<div class="rd-avatar ${bgClass}" style="width:2rem; height:2rem; font-size:0.75rem;">${initial}</div>
|
|
<div style="flex:1; min-width:0;">
|
|
<p style="font-size:0.875rem; color:#e2e8f0; margin:0;">${esc(b.name)}</p>
|
|
<p style="font-size:0.75rem; color:#64748b; margin:0;">Paid ${fmt(b.paid)}</p>
|
|
</div>
|
|
<span style="font-size:0.875rem; font-weight:600;" class="${balanceColor}">${balanceStr}</span>
|
|
</div>`;
|
|
})
|
|
.join("");
|
|
|
|
// ── Update Settlements ──
|
|
if (settlements.length > 0) {
|
|
settlementsBody.innerHTML =
|
|
settlements
|
|
.map(
|
|
(s) => `<div style="display:flex; align-items:center; gap:0.5rem; background:rgba(51,65,85,0.3); border-radius:0.75rem; padding:0.75rem; margin-bottom:0.5rem;">
|
|
<span style="font-size:0.875rem; font-weight:500; color:#fb7185;">${esc(s.from)}</span>
|
|
<span style="flex:1; text-align:center;">
|
|
<span style="font-size:0.75rem; color:#64748b;">\u2192</span>
|
|
<span style="display:block; font-size:0.875rem; font-weight:600; color:white;">${fmt(s.amount)}</span>
|
|
</span>
|
|
<span style="font-size:0.875rem; font-weight:500; color:#34d399;">${esc(s.to)}</span>
|
|
</div>`,
|
|
)
|
|
.join("") +
|
|
`<p style="font-size:0.75rem; color:#64748b; text-align:center; margin:0.5rem 0 0;">
|
|
${settlements.length} payment${settlements.length !== 1 ? "s" : ""} to settle all debts
|
|
</p>`;
|
|
} else {
|
|
settlementsBody.innerHTML = `<p style="font-size:0.875rem; color:#64748b; text-align:center;">All settled up!</p>`;
|
|
}
|
|
|
|
// ── Update Spending by Category ──
|
|
if (expenses.length > 0) {
|
|
spendingSection.style.display = "";
|
|
const catKeys = ["transport", "accommodation", "activity", "food"] as const;
|
|
catKeys.forEach((cat) => {
|
|
const catExpenses = expenses.filter((e) => e.category === cat);
|
|
const catTotal = catExpenses.reduce((s, e) => s + e.amount, 0);
|
|
const catPct = totalSpent > 0 ? Math.round((catTotal / totalSpent) * 100) : 0;
|
|
|
|
const card = document.querySelector<HTMLElement>(`[data-spending-cat="${cat}"]`);
|
|
if (!card) return;
|
|
const amountEl = card.querySelector("[data-spending-amount]") as HTMLElement;
|
|
const barEl = card.querySelector("[data-spending-bar]") as HTMLElement;
|
|
const pctEl = card.querySelector("[data-spending-pct]") as HTMLElement;
|
|
|
|
if (amountEl) amountEl.textContent = fmt(catTotal);
|
|
if (barEl) barEl.style.width = `${catPct}%`;
|
|
if (pctEl) pctEl.textContent = `${catPct}% of total`;
|
|
});
|
|
} else {
|
|
spendingSection.style.display = "none";
|
|
}
|
|
|
|
// ── Update Per Person ──
|
|
if (expenses.length > 0) {
|
|
personSection.style.display = "";
|
|
balances.forEach((b) => {
|
|
const card = document.querySelector<HTMLElement>(`[data-person="${b.name}"]`);
|
|
if (!card) return;
|
|
const paidPct = totalSpent > 0 ? Math.round((b.paid / totalSpent) * 100) : 0;
|
|
const paidEl = card.querySelector("[data-person-paid]") as HTMLElement;
|
|
const pctEl = card.querySelector("[data-person-pct]") as HTMLElement;
|
|
const balanceEl = card.querySelector("[data-person-balance]") as HTMLElement;
|
|
|
|
if (paidEl) paidEl.textContent = fmt(b.paid);
|
|
if (pctEl) pctEl.textContent = `paid (${paidPct}%)`;
|
|
if (balanceEl) {
|
|
const roundedBalance = Math.round(b.balance * 100) / 100;
|
|
const label = b.balance >= 0 ? "Gets back " : "Owes ";
|
|
balanceEl.textContent = `${label}${fmt(Math.abs(roundedBalance))}`;
|
|
balanceEl.className = b.balance >= 0 ? "rd-emerald" : "rd-rose";
|
|
balanceEl.style.fontSize = "0.875rem";
|
|
balanceEl.style.fontWeight = "600";
|
|
balanceEl.style.margin = "0.25rem 0 0";
|
|
}
|
|
});
|
|
} else {
|
|
personSection.style.display = "none";
|
|
}
|
|
}) as EventListener);
|
|
|
|
// ── Inline expense editing via event delegation ──
|
|
|
|
document.addEventListener("click", (e) => {
|
|
const target = e.target as HTMLElement;
|
|
|
|
// Click on amount button to start editing
|
|
const amountBtn = target.closest<HTMLElement>("[data-edit-expense]");
|
|
if (amountBtn && !editingExpenseId) {
|
|
const expenseId = amountBtn.dataset.editExpense!;
|
|
const currentAmount = parseFloat(amountBtn.dataset.amount!);
|
|
editingExpenseId = expenseId;
|
|
|
|
amountBtn.innerHTML = `
|
|
<div style="display:flex; align-items:center; gap:0.25rem;">
|
|
<span style="font-size:0.875rem; color:#94a3b8;">\u20AC</span>
|
|
<input type="number" value="${currentAmount}" step="0.01" min="0"
|
|
style="width:5rem; background:#334155; border:1px solid rgba(16,185,129,0.5); border-radius:0.5rem; padding:0.25rem 0.5rem; font-size:0.875rem; color:white; text-align:right; outline:none;"
|
|
id="rd-edit-input" autofocus>
|
|
<button id="rd-edit-save" style="background:rgba(16,185,129,0.1); color:#34d399; border:none; cursor:pointer; font-size:0.75rem; padding:0.125rem 0.375rem; border-radius:0.25rem;">\u2713</button>
|
|
<button id="rd-edit-cancel" style="background:rgba(51,65,85,0.5); color:#94a3b8; border:none; cursor:pointer; font-size:0.75rem; padding:0.125rem 0.375rem; border-radius:0.25rem;">\u2717</button>
|
|
</div>`;
|
|
|
|
const input = document.getElementById("rd-edit-input") as HTMLInputElement;
|
|
if (input) {
|
|
input.focus();
|
|
input.select();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Save edit
|
|
if (target.id === "rd-edit-save" || target.closest("#rd-edit-save")) {
|
|
saveEdit();
|
|
return;
|
|
}
|
|
|
|
// Cancel edit
|
|
if (target.id === "rd-edit-cancel" || target.closest("#rd-edit-cancel")) {
|
|
cancelEdit();
|
|
return;
|
|
}
|
|
});
|
|
|
|
// Handle keyboard events on edit input
|
|
document.addEventListener("keydown", (e) => {
|
|
if (!editingExpenseId) return;
|
|
const input = document.getElementById("rd-edit-input") as HTMLInputElement;
|
|
if (!input || e.target !== input) return;
|
|
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
saveEdit();
|
|
} else if (e.key === "Escape") {
|
|
e.preventDefault();
|
|
cancelEdit();
|
|
}
|
|
});
|
|
|
|
function saveEdit(): void {
|
|
if (!editingExpenseId) return;
|
|
const input = document.getElementById("rd-edit-input") as HTMLInputElement;
|
|
if (!input) return;
|
|
|
|
const newAmount = parseFloat(input.value);
|
|
if (!isNaN(newAmount) && newAmount >= 0) {
|
|
sync.updateShape(editingExpenseId, {
|
|
amount: Math.round(newAmount * 100) / 100,
|
|
});
|
|
}
|
|
editingExpenseId = null;
|
|
}
|
|
|
|
function cancelEdit(): void {
|
|
editingExpenseId = null;
|
|
// Re-render will happen on next snapshot; force it by dispatching current state
|
|
sync.dispatchEvent(
|
|
new CustomEvent("snapshot", {
|
|
detail: { shapes: sync.shapes },
|
|
}),
|
|
);
|
|
}
|
|
|
|
// ── Reset button ──
|
|
|
|
resetBtn.addEventListener("click", async () => {
|
|
resetBtn.disabled = true;
|
|
try {
|
|
await sync.resetDemo();
|
|
} catch (err) {
|
|
console.error("Reset failed:", err);
|
|
} finally {
|
|
if (sync.connected) resetBtn.disabled = false;
|
|
}
|
|
});
|
|
|
|
// ── Connect ──
|
|
|
|
sync.connect();
|