// ===== 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) => (