// ===== util.jsx — money helpers, share calc, settlement, categories, atoms ===== const { useState, useEffect, useRef, useMemo } = React; const uid = () => Math.random().toString(36).slice(2, 10); // ---- money: integer cents to avoid float drift ---- const toCents = (n) => Math.round((Number(n) || 0) * 100); const fromCents = (c) => c / 100; // percentage of a whole, to 1 decimal place (splits can be fractional) const pctOf = (part, whole) => (whole > 0 ? (part / whole) * 100 : 0).toFixed(1); function money(n, { sign = false, dash = true } = {}) { const c = Math.round((Number(n) || 0) * 100); if (c === 0 && dash) return "$0.00"; const abs = Math.abs(c) / 100; const s = "$" + abs.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); if (sign) return (c < 0 ? "−" : "+") + s; return (c < 0 ? "−" : "") + s; } const todayISO = () => { const d = new Date(); // local date, not UTC (toISOString rolls over in the evening west of UTC) return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; }; function fmtDate(iso) { if (!iso) return ""; const [y, m, d] = iso.split("-").map(Number); const dt = new Date(y, m - 1, d); return dt.toLocaleDateString("en-US", { month: "short", day: "numeric" }); } // ---- categories ---- const CATEGORIES = [ { id: "lodging", label: "Lodging", icon: "bed", hue: 255 }, { id: "food", label: "Food & drink", icon: "food", hue: 44 }, { id: "transport", label: "Transport", icon: "car", hue: 162 }, { id: "activities", label: "Activities", icon: "star", hue: 320 }, { id: "groceries", label: "Groceries", icon: "cart", hue: 130 }, { id: "shopping", label: "Shopping", icon: "bag", hue: 205 }, { id: "fees", label: "Fees & misc", icon: "dots", hue: 20 }, ]; const catOf = (id) => CATEGORIES.find((c) => c.id === id) || CATEGORIES[CATEGORIES.length - 1]; const catColor = (id) => `oklch(0.73 0.115 ${catOf(id).hue})`; function categoryTotals(txns) { const map = {}; txns.forEach((t) => { const id = t.category || "fees"; map[id] = (map[id] || 0) + toCents(t.amount); }); return Object.entries(map) .map(([id, c]) => ({ id, label: catOf(id).label, icon: catOf(id).icon, value: fromCents(c), color: catColor(id) })) .filter((x) => x.value > 0) .sort((a, b) => b.value - a.value); } // Avatar palette const AV_HUES = [44, 162, 250, 320, 96, 200, 18, 285, 130, 0]; const personColor = (i) => `oklch(0.74 0.118 ${AV_HUES[i % AV_HUES.length]})`; // ---- per-transaction shares, returns {personId: cents} ---- function distributeRemainder(raw, rem) { // raw: [{id, c}] sorted desc by c; nudge by ±1 to absorb rounding let k = 0; while (rem !== 0 && raw.length) { raw[k % raw.length].c += rem > 0 ? 1 : -1; rem += rem > 0 ? -1 : 1; k++; } } function computeShares(txn, people) { const parts = txn.participants.filter((id) => people.some((p) => p.id === id)); const total = toCents(txn.amount); const out = {}; if (parts.length === 0 || total === 0) { parts.forEach((id) => (out[id] = 0)); return out; } if (txn.splitMode === "equal") { const base = Math.floor(total / parts.length); let rem = total - base * parts.length; parts.forEach((id, i) => (out[id] = base + (i < rem ? 1 : 0))); } else if (txn.splitMode === "exact") { parts.forEach((id) => (out[id] = toCents(txn.splits?.[id]) || 0)); } else if (txn.splitMode === "percent") { let allocated = 0; const raw = parts.map((id) => { const c = Math.round((total * (Number(txn.splits?.[id]) || 0)) / 100); allocated += c; return { id, c }; }); raw.sort((a, b) => b.c - a.c); distributeRemainder(raw, total - allocated); raw.forEach((r) => (out[r.id] = r.c)); } else if (txn.splitMode === "shares") { const sh = parts.map((id) => ({ id, sh: Math.max(0, Number(txn.splits?.[id]) || 0) })); const totalSh = sh.reduce((s, x) => s + x.sh, 0); if (totalSh <= 0) { const base = Math.floor(total / parts.length); let rem = total - base * parts.length; parts.forEach((id, i) => (out[id] = base + (i < rem ? 1 : 0))); } else { let allocated = 0; const raw = sh.map((x) => { const c = Math.round((total * x.sh) / totalSh); allocated += c; return { id: x.id, c }; }); raw.sort((a, b) => b.c - a.c); distributeRemainder(raw, total - allocated); raw.forEach((r) => (out[r.id] = r.c)); } } return out; } function splitStatus(txn) { const total = toCents(txn.amount); const parts = txn.participants; if (txn.splitMode === "equal") return { ok: true }; if (txn.splitMode === "shares") { const totalSh = parts.reduce((s, id) => s + Math.max(0, Number(txn.splits?.[id]) || 0), 0); return { ok: totalSh > 0, totalShares: totalSh }; } if (txn.splitMode === "exact") { const sum = parts.reduce((s, id) => s + (toCents(txn.splits?.[id]) || 0), 0); return { ok: sum === total, sum: fromCents(sum), target: fromCents(total), remaining: fromCents(total - sum) }; } if (txn.splitMode === "percent") { const sum = parts.reduce((s, id) => s + (Number(txn.splits?.[id]) || 0), 0); return { ok: Math.abs(sum - 100) < 0.005, sum, target: 100, remaining: Math.round((100 - sum) * 100) / 100 }; } return { ok: true }; } function payerStatus(txn) { const total = toCents(txn.amount); const sum = txn.payers.reduce((s, p) => s + (toCents(p.amount) || 0), 0); return { ok: sum === total, sum: fromCents(sum), remaining: fromCents(total - sum) }; } // ---- net balances (cents). +ve => is owed money. Includes settle payments ---- function computeBalances(people, txns, payments = []) { const bal = {}; people.forEach((p) => (bal[p.id] = 0)); txns.forEach((t) => { t.payers.forEach((pay) => { if (bal[pay.personId] != null) bal[pay.personId] += toCents(pay.amount) || 0; }); const shares = computeShares(t, people); Object.entries(shares).forEach(([pid, c]) => { if (bal[pid] != null) bal[pid] -= c; }); }); // a settle payment: `from` hands cash to `to` → reduces from's debt, reduces to's credit payments.forEach((pm) => { if (bal[pm.from] != null) bal[pm.from] += toCents(pm.amt) || 0; if (bal[pm.to] != null) bal[pm.to] -= toCents(pm.amt) || 0; }); return bal; } // ---- minimal-transfer settlement ---- function settle(balCents) { const creditors = [], debtors = []; Object.entries(balCents).forEach(([id, c]) => { if (c > 0) creditors.push({ id, amt: c }); else if (c < 0) debtors.push({ id, amt: -c }); }); creditors.sort((a, b) => b.amt - a.amt); debtors.sort((a, b) => b.amt - a.amt); const transfers = []; let ci = 0, di = 0, guard = 0; while (ci < creditors.length && di < debtors.length && guard < 10000) { guard++; const c = creditors[ci], d = debtors[di]; const m = Math.min(c.amt, d.amt); if (m > 0) transfers.push({ from: d.id, to: c.id, amt: fromCents(m) }); c.amt -= m; d.amt -= m; if (c.amt === 0) ci++; if (d.amt === 0) di++; } return transfers; } // ---- per-person detail rollup ---- function personRollup(personId, people, txns, payments = []) { let paid = 0, owed = 0; const inCharges = []; txns.forEach((t) => { const shares = computeShares(t, people); const myShare = shares[personId] || 0; const myPay = t.payers.filter((p) => p.personId === personId).reduce((s, p) => s + (toCents(p.amount) || 0), 0); if (myShare > 0 || myPay > 0) { paid += myPay; owed += myShare; inCharges.push({ txn: t, share: fromCents(myShare), paid: fromCents(myPay) }); } }); inCharges.sort((a, b) => (b.txn.date || "").localeCompare(a.txn.date || "")); return { paid: fromCents(paid), owed: fromCents(owed), inCharges }; } // ---- export summary text ---- function buildSummary(trip) { const { name, people, txns, payments = [] } = trip; const bal = computeBalances(people, txns, payments); const transfers = settle(bal); const total = txns.reduce((s, t) => s + (Number(t.amount) || 0), 0); const L = []; L.push(name); L.push("─".repeat(Math.max(8, name.length))); L.push(`Total spend: ${money(total)} · ${txns.length} charges · ${people.length} people`); L.push(""); L.push("NET BALANCES"); people.forEach((p) => { const b = fromCents(bal[p.id] || 0); L.push(` • ${p.name}: ${Math.abs(b) < 0.005 ? "settled up" : money(b, { sign: true })}`); }); L.push(""); L.push("WHO PAYS WHOM"); if (!transfers.length) L.push(" ✓ All settled — nobody owes anything."); transfers.forEach((t) => { const f = people.find((p) => p.id === t.from), to = people.find((p) => p.id === t.to); L.push(` ${f?.name} → ${to?.name}: ${money(t.amt)}`); }); if (payments.length) { L.push(""); L.push("ALREADY PAID"); payments.forEach((pm) => { const f = people.find((p) => p.id === pm.from), to = people.find((p) => p.id === pm.to); L.push(` ✓ ${f?.name} → ${to?.name}: ${money(pm.amt)}`); }); } // per-person breakdown (cost + % of total spend) L.push(""); L.push("PER PERSON"); people.forEach((p) => { const roll = personRollup(p.id, people, txns, payments); const pct = pctOf(roll.owed, total); const b = fromCents(bal[p.id] || 0); const net = Math.abs(b) < 0.005 ? "settled up" : money(b, { sign: true }); L.push(` ${p.name} — paid ${money(roll.paid)}, share ${money(roll.owed)} (${pct}%), net ${net}`); }); const cats = categoryTotals(txns); if (cats.length) { L.push(""); L.push("BY CATEGORY"); cats.forEach((c) => L.push(` ${c.label}: ${money(c.value)} (${pctOf(c.value, total)}%)`)); } // itemized charges with each person's share if (txns.length) { L.push(""); L.push("CHARGES (DETAIL)"); [...txns].sort((a, b) => (a.date || "").localeCompare(b.date || "")).forEach((t) => { const shares = computeShares(t, people); const payer = t.payers.length === 1 ? (people.find((p) => p.id === t.payers[0].personId)?.name || "?") : t.payers.map((p) => `${people.find((x) => x.id === p.personId)?.name || "?"} ${money(p.amount)}`).join(", "); L.push(""); L.push(` ${fmtDate(t.date)} · ${t.description} — ${money(t.amount)} [${catOf(t.category).label}]`); L.push(` paid by ${payer}`); if (t.note) L.push(` note: ${t.note}`); Object.entries(shares).forEach(([pid, c]) => { L.push(` ${people.find((p) => p.id === pid)?.name || "?"}: ${money(fromCents(c))}`); }); }); } L.push(""); L.push("Made with Tally"); return L.join("\n"); } // ---------- atoms ---------- function Avatar({ person, size = 30, idx = 0, ring = false }) { const initials = (person?.name || "?").trim().split(/\s+/).slice(0, 2).map((w) => w[0]).join("").toUpperCase() || "?"; return ( {initials} ); } function AvatarStack({ ids, people, size = 26, max = 5 }) { const list = ids.map((id) => people.findIndex((p) => p.id === id)).filter((i) => i >= 0); const shown = list.slice(0, max); const extra = list.length - shown.length; return ( {shown.map((i, k) => ( ))} {extra > 0 && ( +{extra} )} ); } function CatIcon({ id, size = 16 }) { return ; } function Donut({ segments, total, size = 168, thickness = 20, centerLabel, centerSub }) { const r = (size - thickness) / 2; const c = 2 * Math.PI * r; const gap = segments.length > 1 ? 0.012 * c : 0; let offset = 0; return (
{total > 0 && segments.map((s, i) => { const frac = s.value / total; const len = Math.max(0, frac * c - gap); const el = ( ); offset += frac * c; return el; })}
{centerLabel}
{centerSub &&
{centerSub}
}
); } function Icon({ name, size = 18, stroke = 1.8 }) { const p = { plus: "M12 5v14M5 12h14", trash: "M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m2 0-.7 12a2 2 0 0 1-2 1.9H8.7a2 2 0 0 1-2-1.9L6 7", edit: "M4 20h4L19 9a2 2 0 0 0-3-3L5 17v3z M14 6l3 3", arrow: "M5 12h14M13 6l6 6-6 6", close: "M6 6l12 12M18 6 6 18", check: "M5 12.5 10 17l9-10", user: "M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM4.5 20a7.5 7.5 0 0 1 15 0", receipt: "M5 3h14v18l-3-2-2 2-2-2-2 2-2-2-3 2V3Z M9 8h6M9 12h6", wallet: "M3 7h15a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Zm0 0 1-3h12M16 13h.01", split: "M6 3v6a4 4 0 0 0 4 4h4a4 4 0 0 1 4 4v4M18 3v6", bed: "M2 17v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5M2 17h20M2 13h20M2 17v3M22 17v3M6 10V8a2 2 0 0 1 2-2h2v4", food: "M4 3v6a2 2 0 0 0 4 0V3M6 11v10M16 3c-1.5 0-2.5 2.2-2.5 5 0 2.3 1 3.8 2 4.3V21M16 3v18", car: "M5 13l1.5-4.6A2 2 0 0 1 8.4 7h7.2a2 2 0 0 1 1.9 1.4L19 13M4 13h16a1 1 0 0 1 1 1v3H3v-3a1 1 0 0 1 1-1ZM7 17v2M17 17v2M6.5 14h.01M17.5 14h.01", star: "M12 3.5l2.5 5.4 5.9.8-4.3 4.1 1.1 5.9L12 17l-5.2 2.8 1.1-5.9-4.3-4.1 5.9-.8L12 3.5Z", cart: "M3 4h2l2.2 11.2a1 1 0 0 0 1 .8h8.4a1 1 0 0 0 1-.8L20 7H6M9 20a1 1 0 1 0 0-2 1 1 0 0 0 0 2ZM17 20a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z", bag: "M6 8h12l-1 12.2a1 1 0 0 1-1 .8H8a1 1 0 0 1-1-.8L6 8ZM9 8V6a3 3 0 0 1 6 0v2", dots: "M5 12h.01M12 12h.01M19 12h.01", calendar: "M4 6.5A1.5 1.5 0 0 1 5.5 5h13A1.5 1.5 0 0 1 20 6.5v12A1.5 1.5 0 0 1 18.5 20h-13A1.5 1.5 0 0 1 4 18.5v-12ZM4 9.5h16M8 3.5v3M16 3.5v3", search: "M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM21 21l-4.3-4.3", copy: "M9 9h9.5a1.5 1.5 0 0 1 1.5 1.5V20a1.5 1.5 0 0 1-1.5 1.5H9A1.5 1.5 0 0 1 7.5 20V10.5A1.5 1.5 0 0 1 9 9ZM5 15.5H4.5A1.5 1.5 0 0 1 3 14V4.5A1.5 1.5 0 0 1 4.5 3H14a1.5 1.5 0 0 1 1.5 1.5V5", download: "M12 4v11M7.5 11 12 15.5 16.5 11M5 19.5h14", share: "M4 12v7.5a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V12M16 6l-4-4-4 4M12 2.5V15", chevron: "M6 9l6 6 6-6", chevronUp: "M6 15l6-6 6 6", scale: "M12 3.5v17M7.5 20.5h9M5 7.5h14M5 7.5 2.7 13a2.3 2.3 0 0 0 4.6 0L5 7.5ZM19 7.5 16.7 13a2.3 2.3 0 0 0 4.6 0L19 7.5ZM6 6 12 5l6 1", folder: "M3 7.5A1.5 1.5 0 0 1 4.5 6h4l2 2.2h7A1.5 1.5 0 0 1 19 9.7V17.5A1.5 1.5 0 0 1 17.5 19h-13A1.5 1.5 0 0 1 3 17.5V7.5Z", checkCircle: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM8 12l2.8 2.8L16 9", undo: "M9 14.5 4.5 10 9 5.5M5 10h9.5a5 5 0 0 1 0 10H11", sort: "M4 7h16M7 12h10M10 17h4", minus: "M5 12h14", coins: "M8 9.5a5 2.5 0 1 0 0-5 5 2.5 0 0 0 0 5ZM3 7v5c0 1.4 2.2 2.5 5 2.5s5-1.1 5-2.5V7M16 9.5c2.6.2 4.5 1.2 4.5 2.5 0 1.4-2.2 2.5-5 2.5M11 17c0 1.4 2.2 2.5 5 2.5s5-1.1 5-2.5v-5", }[name]; return ( ); } Object.assign(window, { uid, toCents, fromCents, pctOf, money, todayISO, fmtDate, personColor, CATEGORIES, catOf, catColor, categoryTotals, computeShares, splitStatus, payerStatus, computeBalances, settle, personRollup, buildSummary, Avatar, AvatarStack, CatIcon, Donut, Icon, useState, useEffect, useRef, useMemo, });