rspace-online/modules/rfunds/components/funds-demo.ts

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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// ── 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();