/** * 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 = { Maya: "#10b981", Liam: "#06b6d4", Priya: "#8b5cf6", Omar: "#f59e0b", }; const MEMBER_BG: Record = { Maya: "rd-bg-emerald", Liam: "rd-bg-cyan", Priya: "rd-bg-violet", Omar: "rd-bg-amber", }; const CATEGORY_ICONS: Record = { transport: "\u{1F682}", accommodation: "\u{1F3E8}", activity: "\u26F7", food: "\u{1F372}", }; const CATEGORY_LABELS: Record = { transport: "Transport", accommodation: "Accommodation", activity: "Activities", food: "Food & Drink", }; const CATEGORY_TEXT_CLASS: Record = { 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, """); } // ── 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 = {}; 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 = 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 = {}; 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("[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 `
${catIcon}

${esc(ex.description)}

${catLabel} \u00B7 Paid by ${esc(ex.paidBy)} \u00B7 ${ex.split === "equal" ? `Split ${MEMBERS.length} ways` : "Custom split"} \u00B7 ${formatDate(ex.date)}
`; }) .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 `
${initial}

${esc(b.name)}

Paid ${fmt(b.paid)}

${balanceStr}
`; }) .join(""); // ── Update Settlements ── if (settlements.length > 0) { settlementsBody.innerHTML = settlements .map( (s) => `
${esc(s.from)} \u2192 ${fmt(s.amount)} ${esc(s.to)}
`, ) .join("") + `

${settlements.length} payment${settlements.length !== 1 ? "s" : ""} to settle all debts

`; } else { settlementsBody.innerHTML = `

All settled up!

`; } // ── 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(`[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(`[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("[data-edit-expense]"); if (amountBtn && !editingExpenseId) { const expenseId = amountBtn.dataset.editExpense!; const currentAmount = parseFloat(amountBtn.dataset.amount!); editingExpenseId = expenseId; amountBtn.innerHTML = `
\u20AC
`; 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();