/** * Fair Share — a shared expense ledger. * * Track who paid for what in a group, split each expense among the people who * shared it, and see net balances plus a minimal "settle up" plan of who should * pay whom. Inspired by group expense-splitting apps. * * Scope: * - `people` and `expenses` are the shared ledger. Cells default to per-space * scope, so anyone in the space sees and edits the same ledger. * - `myName` is per-user: each viewer picks which person they are, which powers * a personal "you are owed / you owe" summary and a row highlight. * - Form drafts are per-session cells, so concurrent viewers don't share each * other's half-typed input or chip toggles. * * Identity: items are matched with `equals()` (the pattern idiom) — never via * synthetic id fields, which read as Cells inside `.map()` and break `===`. * People are referenced from expenses by their (unique) name, the natural key. * * Money is computed in integer cents with largest-remainder allocation, so * displayed shares always sum back to the total and balances tie out exactly. */ import { computed, Default, equals, handler, NAME, pattern, type PerUser, safeDateNow, UI, wish, Writable, } from "commonfabric"; // ============ TYPES ============ interface Person { name: string; // Optional avatar snapshot (image URL or emoji/glyph), sourced from the // person's shared profile when they "join with your profile". Display-only — // `name` stays the natural key, so the money/balance logic is unaffected. avatar?: string; } interface Expense { description: string; amount: number; paidBy: string; // Person.name sharedBy: string[]; // Person.name[]; empty => split among everyone date: string; // YYYY-MM-DD } interface Balance { name: string; avatar?: string; paid: number; share: number; net: number; // paid - share; positive => is owed, negative => owes } interface Settlement { from: string; // debtor name to: string; // creditor name amount: number; } interface State { people: Writable>; expenses: Writable>; myName: PerUser>; } // ============ HELPERS ============ const getTodayDate = (): string => new Date(safeDateNow()).toISOString().split("T")[0]; const toCents = (n: number): number => Math.round((n || 0) * 100); const money = (n: number): string => `$${(n || 0).toFixed(2)}`; // Allocate `cents` across `names` so the parts sum back to `cents` exactly. // Largest-remainder: the first `remainder` recipients get one extra cent. const splitCents = (cents: number, names: string[]): Map => { const out = new Map(); const n = names.length; if (n === 0) return out; const base = Math.floor(cents / n); const remainder = cents - base * n; names.forEach((name, i) => out.set(name, base + (i < remainder ? 1 : 0))); return out; }; const computeSettlements = (balances: Balance[]): Settlement[] => { const creditors = balances .map((b) => ({ name: b.name, rem: Math.round(b.net * 100) })) .filter((b) => b.rem > 0) .sort((a, b) => b.rem - a.rem); const debtors = balances .map((b) => ({ name: b.name, rem: -Math.round(b.net * 100) })) .filter((b) => b.rem > 0) .sort((a, b) => b.rem - a.rem); const result: Settlement[] = []; let i = 0; let j = 0; while (i < debtors.length && j < creditors.length) { const d = debtors[i]; const c = creditors[j]; const cents = Math.min(d.rem, c.rem); if (cents > 0) { result.push({ from: d.name, to: c.name, amount: cents / 100 }); } d.rem -= cents; c.rem -= cents; if (d.rem === 0) i++; if (c.rem === 0) j++; } return result; }; // Snapshot the current viewer's shared profile (name + avatar) into the shared // `people` ledger and select them as "you". This is the participant/roster // idiom: each viewer contributes their own #profile snapshot on join, rather // than the app querying everyone's private profile state (see // docs/specs/shared-profile-rosters.md). `name` stays the natural key. // Exported for tests. export const joinWithProfile = handler< unknown, { people: Writable; myName: Writable; name: string; avatar: string; } >((_event, { people, myName, name, avatar }) => { const n = (name ?? "").trim(); if (!n) return; const av = (avatar ?? "").trim(); const cur = people.get(); const idx = cur.findIndex((p) => p.name === n); if (idx < 0) { people.push(av ? { name: n, avatar: av } : { name: n }); } else if (av && !cur[idx].avatar) { // Backfill the avatar snapshot if this name was added by hand earlier. // Write through the element's cell — replacing the array slot with a // fresh object literal would re-mint the person's entity identity and // orphan previously-held references (selection cells, expense rows read // earlier). See packages/patterns/primitives/editable-list.tsx. people.key(idx).key("avatar").set(av); } myName.set(n); }); // ============ PATTERN ============ export default pattern(({ people, expenses, myName }) => { // --- Per-session form drafts (local to each viewer) --- const personDraft = Writable.perSession.of(""); const descDraft = Writable.perSession.of(""); const amountDraft = Writable.perSession.of(""); const paidByDraft = Writable.perSession.of(""); // Person.name const splitWith = Writable.perSession.of([]); // Person.name[] // --- Identity (resolve per-user name once at top level) --- const me = (myName ?? "").trim(); // The current viewer's *shared profile* (resolved via wish). `#profile` is the // live cell bound to ; the field targets give the name/avatar // we snapshot into the ledger on "join". Profile-count-agnostic: resolves the // viewer's default profile. const profileWish = wish({ query: "#profile" }); const profileNameWish = wish({ query: "#profileName" }); const profileAvatarWish = wish({ query: "#profileAvatar" }); const myProfileName = computed(() => (profileNameWish.result ?? "").trim()); const myProfileAvatar = computed(() => (profileAvatarWish.result ?? "").trim() ); const hasProfile = computed(() => (profileNameWish.result ?? "").trim() !== "" ); // --- Derived data --- const peopleOptions = computed(() => people.get().map((p) => ({ label: p.name, value: p.name })) ); const total = computed(() => { let cents = 0; for (const e of expenses.get()) cents += toCents(e.amount); return cents / 100; }); const balances = computed(() => { const ppl = people.get(); const paidCents = new Map(); const shareCents = new Map(); for (const p of ppl) { paidCents.set(p.name, 0); shareCents.set(p.name, 0); } for (const e of expenses.get()) { const cents = toCents(e.amount); if (paidCents.has(e.paidBy)) { paidCents.set(e.paidBy, paidCents.get(e.paidBy)! + cents); } const everyone = ppl.map((p) => p.name); const names = (e.sharedBy && e.sharedBy.length ? e.sharedBy : everyone) .filter((name) => shareCents.has(name)); if (names.length === 0) continue; const alloc = splitCents(cents, names); for (const name of names) { shareCents.set(name, shareCents.get(name)! + (alloc.get(name) ?? 0)); } } return ppl.map((p) => { const paid = (paidCents.get(p.name) ?? 0) / 100; const share = (shareCents.get(p.name) ?? 0) / 100; return { name: p.name, avatar: p.avatar ?? "", paid, share, net: paid - share, }; }); }); const settlements = computed(() => computeSettlements(balances)); const myNet = computed(() => { if (!me) return null; const mine = balances.find((b) => b.name === me); return mine ? mine.net : null; }); // Display helper for the "split with" chips in the form. const splitChips = computed(() => people.get().map((p) => ({ name: p.name, included: splitWith.get().length === 0 || splitWith.get().includes(p.name), })) ); return { [NAME]: "Fair Share", [UI]: ( Fair Share A shared ledger for group expenses — track who paid, split fairly, and settle up. {/* ===== You ===== */} { /* Identity via shared profile: the trusted badge shows who you are, and "Join" snapshots your profile name+avatar into the ledger. */ } You are !hasProfile)} onClick={joinWithProfile({ people, myName, name: myProfileName, avatar: myProfileAvatar, })} > Join with your profile {/* Fallback: pick yourself from the people added by hand. */} or pick {computed(() => { const net = myNet; if (net === null) { return ( Pick who you are to see your balance. ); } return ( 0 ? "accent" : net < 0 ? "danger" : "neutral"} > {net > 0 ? `You are owed ${money(net)}` : net < 0 ? `You owe ${money(-net)}` : "You're settled up"} ); })} {/* ===== People ===== */} People { /* Bare .map() — wrapping it in computed() breaks the transformer's equals() schema inference (array items lose `comparable`), which silently breaks removal. Empty state is a separate sibling. */ } {people.map((person) => ( { const cur = people.get(); const idx = cur.findIndex((p) => equals(person, p)); if (idx < 0) return; const name = { ...cur[idx] }.name; // Cascade-clean so balances stay zero-sum: drop expenses // they paid (money has no creditor now) and remove them // from every other split. Built with a plain for-loop — // chaining .filter()/.map() on the reactive .get() array // makes the transformer rewrite them to // .filterWithPattern()/.mapWithPattern(), which throw at // runtime here. Kept expenses are pushed by REFERENCE // (not `{ ...e }` clones) and split changes are written // through the element's cell — fresh literals would // re-mint every kept expense's entity identity and // orphan previously-held references. const allExpenses = expenses.get(); const cleaned: Expense[] = []; for (let i = 0; i < allExpenses.length; i++) { const e = allExpenses[i]; if (e.paidBy === name) continue; const had = [...(e.sharedBy ?? [])]; // Empty sharedBy means "split among everyone" — it stays // implicit-everyone (now a smaller group), so keep it as-is. if (had.length === 0) { cleaned.push(e); continue; } const shared = had.filter((s) => s !== name); // An explicit split emptied by this removal is dropped. if (shared.length === 0) continue; if (shared.length !== had.length) { expenses.key(i).key("sharedBy").set(shared); } cleaned.push(e); } people.set(cur.toSpliced(idx, 1)); expenses.set(cleaned); }} /> ))} {computed(() => people.get().length === 0 ? Add people to get started. : null )} { const name = personDraft.get().trim(); if (!name) return; if (people.get().some((p) => p.name === name)) return; people.push({ name }); personDraft.set(""); }} > Add {/* ===== Add expense ===== */} Add expense Description Amount Paid by Split between {computed(() => splitChips.map((c) => ( { const cur = splitWith.get(); // First explicit toggle starts from "everyone". const base = cur.length === 0 ? people.get().map((p) => p.name) : cur; splitWith.set( base.includes(c.name) ? base.filter((x) => x !== c.name) : [...base, c.name], ); }} style={{ opacity: c.included ? "1" : "0.4" }} /> )) )} Tap to toggle. Everyone is included by default. { const description = descDraft.get().trim(); const amount = toCents(parseFloat(amountDraft.get())) / 100; const paidBy = paidByDraft.get(); const ppl = people.get(); // Number.isFinite rejects NaN AND ±Infinity (e.g. "1e999"), // which would otherwise poison totals/splits. if (!description || !Number.isFinite(amount) || amount <= 0) { return; } if (!ppl.some((p) => p.name === paidBy)) return; const everyone = ppl.map((p) => p.name); const selected = splitWith.get().filter((nm) => ppl.some((p) => p.name === nm) ); const sharedBy = selected.length === 0 ? everyone : selected; if (sharedBy.length === 0) return; expenses.push({ description, amount, paidBy, sharedBy, date: getTodayDate(), }); descDraft.set(""); amountDraft.set(""); splitWith.set([]); }} > Add expense {/* ===== Expense list ===== */} Expenses Total {computed(() => money(total))} {/* Bare .map() — see People note above. */} {expenses.map((expense) => ( {expense.description} {computed(() => { const n = expense.sharedBy?.length || 0; return `${expense.paidBy} paid · split ${n} way${ n === 1 ? "" : "s" } · ${expense.date}`; })} {computed(() => money(expense.amount))} { const cur = expenses.get(); const idx = cur.findIndex((el) => equals(expense, el)); if (idx >= 0) expenses.set(cur.toSpliced(idx, 1)); }} > × ))} {computed(() => expenses.get().length === 0 ? No expenses yet. : null )} {/* ===== Balances ===== */} Balances { /* Render the list as a stable array and keep the empty state as a separate sibling. A single computed() that returns an array OR a single node makes the reactive diff transition array<->object, which throws TypeMismatchError. */ } {computed(() => balances.map((b) => ( {b.name === me ? `${b.name} (you)` : b.name} 0 ? "accent" : b.net < 0 ? "danger" : "neutral"} > {b.net > 0 ? `is owed ${money(b.net)}` : b.net < 0 ? `owes ${money(-b.net)}` : "settled"} )) )} {computed(() => balances.length === 0 ? No balances to show. : null )} {/* ===== Settle up ===== */} Settle up {computed(() => settlements.map((s) => ( {s.from} {s.to} {money(s.amount)} )) )} {computed(() => settlements.length === 0 ? Everyone is settled up. 🎉 : null )} ), // Shared ledger + per-user identity (re-exported for cross-piece reads). people, expenses, myName, balances, settlements, total, }; });