Source
This app’s code and revision history are public. Download a copy or explore the files below.
Public
The app’s code and revision history are public.
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 20ace674a35b849ffda63b30
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
src/Group.tsx
// A group: who is in it, what was spent, who owes whom, and the fewest payments to settle up.
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { get, set, useQuery, serverTimestamp } from "@bounded-sh/client";
import { me, explain } from "./lib/bounded";
import { navigate } from "./lib/router";
import { rememberGroup, forgetGroup, myPersonId, setMyPersonId } from "./lib/mine";
import { Modal, Menu, Icon, useSheet, toast, share, copy } from "./ui";
import { GroupSkeleton } from "./Skeleton";
import { SITE_URL } from "../functions/lib/config";
import {
balances, settle, owedBy, formatMoney, toMinor, fromMinor, minorDigits, validateExpense, parseShares, randomId, newestFirst, CURRENCIES,
type Expense, type Person, type SplitMode, type Transfer,
} from "../functions/lib/split";
interface Group { name: string; currency: string; createdAt: number }
type Row<T> = T & { id: string };
type Sheet =
| { type: "expense"; expense?: Row<Expense>; prefill?: { title?: string; amount?: string } }
| { type: "payment"; payment?: Row<Expense>; from?: string; to?: string; amount?: number }
| { type: "people"; addingSelf?: boolean }
| { type: "settings" };
export function GroupPage({ groupId }: { groupId: string }) {
const [state, setState] = useState<"joining" | "ready" | "missing" | "error">("joining");
const [error, setError] = useState("");
const [attempt, setAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
setState("joining");
(async () => {
try {
const uid = await me();
const joined = await get(`groups/${groupId}/joins/${uid}`);
if (!joined) {
try {
await set(`groups/${groupId}/joins/${uid}`, { at: serverTimestamp() });
} catch (err) {
if (cancelled) return;
// Offline looks like a refusal too; only a real refusal means the group is not there.
if (!navigator.onLine) { setError(explain(err)); setState("error"); return; }
forgetGroup(groupId);
setState("missing");
return;
}
}
if (!cancelled) setState("ready");
} catch (err) {
if (!cancelled) { setError(explain(err)); setState("error"); }
}
})();
return () => { cancelled = true; };
}, [groupId, attempt]);
if (state === "joining") return <GroupSkeleton />;
if (state === "missing") return <Missing />;
if (state === "error") return (
<div className="wrap page-pad narrow">
<div className="error-state" role="alert">
<h1 className="state-title">Split could not open this group</h1>
<p>{error}</p>
<button className="btn" type="button" onClick={() => setAttempt((n) => n + 1)}>Try again</button>
</div>
</div>
);
return <GroupView groupId={groupId} />;
}
function Missing() {
return (
<div className="wrap page-pad narrow">
<div className="empty-state">
<img className="art" src="/art/card-split.webp" alt="" width="180" height="166" />
<h1 className="state-title">No group at this link</h1>
<p>Check the link you were sent. Group links are long, and every character counts.</p>
<a className="btn" href="/" onClick={(e) => { e.preventDefault(); navigate("/"); }}>Start a new group</a>
</div>
</div>
);
}
/** Scrolls a message that just appeared into view, so an error at the end of a long sheet is seen. */
const reveal = (el: HTMLElement | null) => el?.scrollIntoView({ block: "nearest" });
/** YYYY-MM-DD in the person's own time zone. */
function localDay(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
/** Noon on that day in the person's time zone, as seconds: the stored date of an expense. */
function daySeconds(day: string): number {
return Math.floor(Date.parse(`${day}T12:00:00`) / 1000);
}
function dayLabel(sec: number): string {
const d = new Date(sec * 1000);
const today = new Date();
const y = new Date(); y.setDate(today.getDate() - 1);
if (d.toDateString() === today.toDateString()) return "Today";
if (d.toDateString() === y.toDateString()) return "Yesterday";
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric", ...(d.getFullYear() !== today.getFullYear() ? { year: "numeric" } : {}) });
}
function useOnline() {
const [online, setOnline] = useState(navigator.onLine);
useEffect(() => {
const on = () => setOnline(true), off = () => setOnline(false);
window.addEventListener("online", on);
window.addEventListener("offline", off);
return () => { window.removeEventListener("online", on); window.removeEventListener("offline", off); };
}, []);
return online;
}
function GroupView({ groupId }: { groupId: string }) {
const { data: group, error: groupError } = useQuery(`groups/${groupId}`) as { data: Group | null | undefined; error: Error | null };
const { data: peopleRaw } = useQuery(`groups/${groupId}/people`, { limit: 1000 }) as { data: Row<Person>[] | undefined };
// Live queries return 100 rows unless asked for more, and 1,000 at most.
const { data: expensesRaw } = useQuery(`groups/${groupId}/expenses`, { limit: 1000 }) as { data: Row<Expense>[] | undefined };
const [hidden, setHidden] = useState<Set<string>>(() => new Set());
const people = useMemo(() => [...(peopleRaw || [])].sort((a, b) => a.name.localeCompare(b.name)), [peopleRaw]);
const expenses = useMemo(
() => (expensesRaw || []).filter((e) => !e.deleted && !hidden.has(e.id)).sort((a, b) => newestFirst(a, b) || b.id.localeCompare(a.id)),
[expensesRaw, hidden],
);
const active = people.filter((p) => !p.removed);
const [chosen, setChosen] = useState(() => myPersonId(groupId));
const meId = chosen && active.some((p) => p.id === chosen) ? chosen : null;
const [sheet, setSheet] = useState<Sheet | null>(null);
const [tab, setTab] = useState<"expenses" | "balances">("expenses");
const [query, setQuery] = useState("");
const [isNew, setIsNew] = useState(() => { try { return localStorage.getItem(`split:new:${groupId}`) === "1"; } catch { return false; } });
const online = useOnline();
const seen = useRef<Set<string> | null>(null);
const linkHandled = useRef(false);
const ready = group !== undefined && peopleRaw !== undefined && expensesRaw !== undefined;
if (ready && seen.current === null) seen.current = new Set(expenses.map((e) => e.id));
useEffect(() => { for (const e of expenses) seen.current?.add(e.id); });
useEffect(() => {
if (!group?.name) return;
document.title = `${group.name} | Split`;
rememberGroup(groupId, group.name);
}, [group?.name, groupId]);
// Links an assistant can send: ?add (with optional title and amount) opens a new expense, ?settle opens a payment.
useEffect(() => {
if (!ready || !group || linkHandled.current) return;
linkHandled.current = true;
const q = new URLSearchParams(location.search);
if (q.has("add")) setSheet({ type: "expense", prefill: { title: q.get("title") ?? undefined, amount: q.get("amount") ?? undefined } });
else if (q.has("settle")) {
const owed = settle(balances(people, expenses));
const t = owed.find((x) => x.from === meId || x.to === meId) ?? owed[0];
setSheet(t ? { type: "payment", from: t.from, to: t.to, amount: t.amount } : { type: "payment" });
}
if (location.search) history.replaceState(null, "", location.pathname);
}, [ready, group]);
if (!ready) {
if (groupError) return (
<div className="wrap page-pad narrow">
<div className="error-state" role="alert">
<h1 className="state-title">Split could not load this group</h1>
<p>{explain(groupError)}</p>
<button className="btn" type="button" onClick={() => location.reload()}>Try again</button>
</div>
</div>
);
return <GroupSkeleton />;
}
if (!group) return <Missing />;
const cur = group.currency;
const money = (m: number) => formatMoney(m, cur);
const bal = balances(people, expenses);
const transfers = settle(bal);
const spent = expenses.filter((e) => e.kind === "expense");
const total = spent.reduce((a, e) => a + e.amount, 0);
const paid: Record<string, number> = {};
const part: Record<string, number> = {};
for (const e of spent) {
paid[e.paidBy] = (paid[e.paidBy] || 0) + e.amount;
for (const [id, v] of Object.entries(owedBy(e))) part[id] = (part[id] || 0) + v;
}
const nameOf = (id: string) => people.find((p) => p.id === id)?.name ?? "Someone";
const url = `${SITE_URL}/g/${groupId}`;
const myBal = meId ? bal[meId] || 0 : 0;
const mine = meId ? transfers.filter((t) => t.from === meId || t.to === meId) : [];
const choose = (id: string | null) => { setMyPersonId(groupId, id); setChosen(id); };
const shareLink = () => share({ url, title: group.name }, "Link copied. Send it to the group.");
const shareSummary = () => {
const lines = transfers.length
? transfers.map((t) => `${nameOf(t.from)} pays ${nameOf(t.to)} ${money(t.amount)}`)
: ["Everyone is settled up."];
share({ url, title: group.name, text: `${group.name}: who owes whom\n${lines.join("\n")}\nSee every expense:` }, "Copied. Paste it in your group chat.");
};
const settleUp = () => {
const t: Transfer | undefined = mine[0] ?? transfers[0];
if (t) setSheet({ type: "payment", from: t.from, to: t.to, amount: t.amount });
else setSheet({ type: "payment", from: meId ?? undefined });
};
const removeExpense = async (e: Row<Expense>) => {
const label = e.kind === "payment" ? "payment" : `"${e.title}"`;
setHidden((s) => new Set(s).add(e.id));
const unhide = () => setHidden((s) => { const n = new Set(s); n.delete(e.id); return n; });
try {
await set(`groups/${groupId}/expenses/${e.id}`, { deleted: true });
toast(`Deleted ${label}`, {
label: "Undo",
run: () => { set(`groups/${groupId}/expenses/${e.id}`, { deleted: null }).then(unhide, (err) => toast(explain(err))); },
});
} catch (err) {
unhide();
toast(explain(err));
}
};
const term = query.trim().toLowerCase();
const shown = term
? expenses.filter((e) => [e.title, nameOf(e.paidBy), ...Object.keys(parseShares(e.shares)).map(nameOf), fromMinor(e.amount, cur)].some((s) => s.toLowerCase().includes(term)))
: expenses;
const byDay: [string, Row<Expense>[]][] = [];
for (const e of shown) {
const label = dayLabel(e.date);
const last = byDay[byDay.length - 1];
if (last && last[0] === label) last[1].push(e); else byDay.push([label, [e]]);
}
const actions = (
<>
<button className="btn btn-line" type="button" onClick={settleUp} disabled={active.length < 2}>Settle up</button>
<button className="btn" type="button" onClick={() => setSheet({ type: "expense" })} disabled={!active.length}><Icon name="plus" />Add expense</button>
</>
);
return (
<div className="group">
<header className="group-head">
<div className="wrap group-head-inner">
<div className="group-title-block">
<h1 className="group-title">{group.name}</h1>
<p className="group-sub">{active.length} {active.length === 1 ? "person" : "people"} · {money(total)} spent</p>
</div>
<div className="group-actions">
<button className="btn btn-line" type="button" onClick={shareLink}><Icon name="link" />Share link</button>
<Menu label="More">
<button type="button" onClick={() => setSheet({ type: "people" })}>People</button>
<button type="button" onClick={() => setSheet({ type: "settings" })}>Name and currency</button>
<hr />
<button type="button" onClick={shareSummary}>Share who owes whom</button>
<button type="button" onClick={() => exportCsv(group, people, expenses)} disabled={!expenses.length}>Export to CSV</button>
</Menu>
</div>
</div>
</header>
<div className="wrap group-body">
{!online && <p className="notice notice-error" role="status">You are offline. Changes will not save until you reconnect.</p>}
{isNew && (
<div className="notice notice-ok invite enter">
<span>Group created. Send the link to everyone so they can add expenses too.</span>
<span className="row">
<button className="btn btn-sm" type="button" onClick={shareLink}>Share link</button>
<button className="btn btn-sm btn-quiet" type="button" onClick={() => { try { localStorage.removeItem(`split:new:${groupId}`); } catch { /* blocked */ } setIsNew(false); }}>Dismiss</button>
</span>
</div>
)}
<section className="you card" aria-label="Your balance">
{meId ? (
<div className="you-main">
<p className="you-who">You are <strong>{nameOf(meId)}</strong> <button className="link-btn" type="button" onClick={() => choose(null)}>Change</button></p>
<p className={`you-balance ${myBal > 0 ? "pos" : myBal < 0 ? "neg" : ""}`}>
{myBal > 0 ? <>You get back {money(myBal)}</> : myBal < 0 ? <>You owe {money(-myBal)}</> : <>You are settled up</>}
</p>
{mine.length > 0 && (
<p className="you-next">
{mine.map((t, i) => (
<span key={i}>{t.from === meId ? `Pay ${nameOf(t.to)} ${money(t.amount)}` : `${nameOf(t.from)} pays you ${money(t.amount)}`}</span>
))}
</p>
)}
</div>
) : (
<div className="who">
<h2 className="who-title">Which one are you?</h2>
<p className="muted">Pick your name to see what you owe. This device remembers it.</p>
<div className="who-names">
{active.map((p) => <button key={p.id} className="btn btn-line" type="button" onClick={() => choose(p.id)}>{p.name}</button>)}
<button className="btn btn-quiet" type="button" onClick={() => setSheet({ type: "people", addingSelf: true })}>Not listed? Add yourself</button>
</div>
</div>
)}
<div className="you-actions">{actions}</div>
</section>
<div className="tabs" role="tablist" aria-label="Group">
<button role="tab" type="button" aria-selected={tab === "expenses"} aria-controls="expenses" onClick={() => setTab("expenses")}>Expenses</button>
<button role="tab" type="button" aria-selected={tab === "balances"} aria-controls="balances" onClick={() => setTab("balances")}>
Balances{transfers.length ? <span className="tab-count">{transfers.length} to settle</span> : null}
</button>
</div>
<div className="group-grid" data-tab={tab}>
<section className="expenses" id="expenses" aria-label="Expenses">
{expenses.length >= 8 && (
<label className="search">
<Icon name="search" />
<input className="input" type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search expenses" aria-label="Search expenses" enterKeyHint="search" />
</label>
)}
{!expenses.length ? (
<div className="empty-state">
<img className="art" src="/art/card-split.webp" alt="" width="180" height="166" />
<h2>No expenses yet</h2>
<p>Add the first one. Everyone with the link sees it right away.</p>
<button className="btn" type="button" onClick={() => setSheet({ type: "expense" })} disabled={!active.length}>Add expense</button>
</div>
) : !shown.length ? (
<p className="muted no-match">No expenses match "{query.trim()}".</p>
) : byDay.map(([label, rows]) => (
<div className="day" key={label}>
<h3 className="day-label">{label}</h3>
<ul className="expense-list">
{rows.map((e) => (
<li key={e.id} className={seen.current?.has(e.id) ? undefined : "enter"}>
<ExpenseRow e={e} meId={meId} nameOf={nameOf} money={money} onOpen={() => setSheet(e.kind === "payment" ? { type: "payment", payment: e } : { type: "expense", expense: e })} />
</li>
))}
</ul>
</div>
))}
</section>
<aside className="balances" id="balances" aria-label="Balances">
<div className="card balance-card">
<h2 className="card-title">Balances</h2>
<ul className="balance-list">
{people.filter((p) => !p.removed || bal[p.id]).map((p) => {
const v = bal[p.id] || 0;
return (
<li key={p.id}>
<span className="balance-who">
<span>{p.name}{p.id === meId ? <span className="muted"> (you)</span> : null}</span>
<span className="balance-meta">Paid {money(paid[p.id] || 0)} · Share {money(part[p.id] || 0)}</span>
</span>
<span className={`num ${v > 0 ? "pos" : v < 0 ? "neg" : "muted"}`}>{v > 0 ? `gets back ${money(v)}` : v < 0 ? `owes ${money(-v)}` : "settled"}</span>
</li>
);
})}
</ul>
</div>
<div className="card balance-card">
<h2 className="card-title">Settle up</h2>
{transfers.length ? (
<>
<p className="muted small">The fewest payments that settle everything. Record each one after the money changes hands.</p>
<ul className="settle-list">
{transfers.map((t) => (
<li key={`${t.from}-${t.to}`}>
<span><strong>{nameOf(t.from)}</strong> pays <strong>{nameOf(t.to)}</strong> <span className="num">{money(t.amount)}</span></span>
<button className="btn btn-sm btn-line" type="button" onClick={() => setSheet({ type: "payment", from: t.from, to: t.to, amount: t.amount })}
aria-label={`Record that ${nameOf(t.from)} paid ${nameOf(t.to)} ${money(t.amount)}`}>Mark paid</button>
</li>
))}
</ul>
<button className="btn btn-quiet btn-sm share-summary" type="button" onClick={shareSummary}>Share this list with the group</button>
</>
) : <p className="muted small">{expenses.length ? "Everyone is settled up." : "Nothing to settle yet."}</p>}
</div>
</aside>
</div>
<div className="action-bar group-bar">{actions}</div>
</div>
{sheet?.type === "expense" && (
<ExpenseSheet groupId={groupId} currency={cur} people={people} meId={meId} expense={sheet.expense} prefill={sheet.prefill}
onDelete={removeExpense} onClose={() => setSheet(null)} />
)}
{sheet?.type === "payment" && (
<PaymentSheet groupId={groupId} currency={cur} people={people} meId={meId} payment={sheet.payment}
initial={{ from: sheet.from, to: sheet.to, amount: sheet.amount }} onDelete={removeExpense} onClose={() => setSheet(null)} />
)}
{sheet?.type === "people" && <PeopleSheet groupId={groupId} people={people} bal={bal} currency={cur} meId={meId} addingSelf={sheet.addingSelf} onChoose={choose} onClose={() => setSheet(null)} />}
{sheet?.type === "settings" && <SettingsSheet groupId={groupId} group={group} hasExpenses={expenses.length > 0} url={url} onClose={() => setSheet(null)} />}
</div>
);
}
function ExpenseRow({ e, meId, nameOf, money, onOpen }: {
e: Row<Expense>; meId: string | null; nameOf: (id: string) => string; money: (m: number) => string; onOpen: () => void;
}) {
const parts = owedBy(e);
const payment = e.kind === "payment";
const mine = meId ? (e.paidBy === meId ? e.amount : 0) - (parts[meId] || 0) : 0;
let side: ReactNode = <span className="num">{money(e.amount)}</span>;
if (meId && !payment) {
side = mine > 0 ? <><span className="side-label pos">you lent</span><span className="num pos">{money(mine)}</span></>
: mine < 0 ? <><span className="side-label neg">you owe</span><span className="num neg">{money(-mine)}</span></>
: <span className="side-label">not involved</span>;
}
return (
<button className="expense" type="button" onClick={onOpen}>
<span className={payment ? "expense-icon payment" : "expense-icon"} aria-hidden="true">{payment ? <Icon name="arrow" size={16} /> : e.title.trim().charAt(0).toUpperCase() || "?"}</span>
<span className="expense-main">
<span className="expense-title">{payment ? `${nameOf(e.paidBy)} paid ${nameOf(Object.keys(parts)[0] || "")}` : e.title}</span>
<span className="expense-meta">{payment ? "Payment" : `${nameOf(e.paidBy)} paid ${money(e.amount)}`}</span>
</span>
<span className="expense-side">{side}</span>
</button>
);
}
function exportCsv(group: Group, people: Row<Person>[], expenses: Row<Expense>[]) {
const nameOf = (id: string) => people.find((p) => p.id === id)?.name ?? "";
const cols = ["Date", "Type", "Description", "Paid by", "Amount", "Currency", ...people.map((p) => p.name)];
const rows = expenses.slice().sort((a, b) => newestFirst(b, a)).map((e) => {
const parts = owedBy(e);
return [localDay(new Date(e.date * 1000)), e.kind, e.kind === "payment" ? `Payment to ${nameOf(Object.keys(parts)[0] || "")}` : e.title, nameOf(e.paidBy),
fromMinor(e.amount, group.currency), group.currency, ...people.map((p) => (parts[p.id] ? fromMinor(parts[p.id], group.currency) : ""))];
});
const csv = [cols, ...rows].map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(",")).join("\n");
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([csv], { type: "text/csv" }));
a.download = `${group.name.replace(/[^\w-]+/g, "-")}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
}
function ExpenseSheet({ groupId, currency, people, meId, expense, prefill, onDelete, onClose }: {
groupId: string; currency: string; people: Row<Person>[]; meId: string | null; expense?: Row<Expense>;
prefill?: { title?: string; amount?: string }; onDelete: (e: Row<Expense>) => void; onClose: () => void;
}) {
const sheet = useSheet(onClose);
const initialShares = expense ? parseShares(expense.shares) : {};
// Removed people stay in the list only when this expense already involves them.
const active = people.filter((p) => !p.removed || (expense && (expense.paidBy === p.id || initialShares[p.id])));
const startMode: SplitMode = expense?.splitMode ?? "equal";
const [title, setTitle] = useState(expense?.title ?? prefill?.title?.slice(0, 120) ?? "");
const [amount, setAmount] = useState(expense ? fromMinor(expense.amount, currency) : prefill?.amount ?? "");
const [paidBy, setPaidBy] = useState(expense?.paidBy ?? meId ?? active[0]?.id ?? "");
const [date, setDate] = useState(() => localDay(new Date((expense?.date ?? Date.now() / 1000) * 1000)));
const [mode, setMode] = useState<SplitMode>(startMode);
const [included, setIncluded] = useState<Record<string, boolean>>(() => Object.fromEntries(active.map((p) => [p.id, expense ? Boolean(initialShares[p.id]) : !p.removed])));
const [weights, setWeights] = useState<Record<string, string>>(() => Object.fromEntries(active.map((p) => [p.id, startMode === "shares" ? String(initialShares[p.id] ?? 0) : "1"])));
const [exact, setExact] = useState<Record<string, string>>(() => Object.fromEntries(active.map((p) => [p.id, startMode === "exact" && initialShares[p.id] ? fromMinor(initialShares[p.id], currency) : ""])));
const [busy, setBusy] = useState(false);
const [errors, setErrors] = useState<{ title?: string; amount?: string; split?: string; form?: string }>({});
const minor = toMinor(amount || "0", currency);
const shares: Record<string, number> = {};
for (const p of active) {
if (mode === "equal" && included[p.id]) shares[p.id] = 1;
if (mode === "shares") { const w = Number(weights[p.id]); if (w > 0) shares[p.id] = w; }
if (mode === "exact") { const v = toMinor(exact[p.id] || "0", currency); if (v > 0) shares[p.id] = v; }
}
const draft = { title: title.trim(), amount: Number.isFinite(minor) ? minor : 0, paidBy, splitMode: mode, shares: JSON.stringify(shares), kind: "expense" as const };
const preview = Number.isFinite(minor) && minor > 0 ? owedBy(draft) : {};
const shareTotal = Object.values(shares).reduce((a, b) => a + b, 0);
const allIn = active.every((p) => included[p.id]);
const save = async () => {
const next = {
title: title.trim() ? undefined : "Give the expense a name.",
amount: !Number.isFinite(minor) || minor <= 0 ? `Enter the amount as a number, like ${minorDigits(currency) ? "12.50" : "1200"}.` : undefined,
};
if (next.title || next.amount) {
setErrors(next);
document.getElementById(next.title ? "expense-title" : "expense-amount")?.focus();
return;
}
const err = validateExpense(draft, new Set(active.map((p) => p.id)));
if (err) return setErrors({ split: err });
setBusy(true);
try {
const uid = await me();
const id = expense?.id ?? randomId(12);
await set(`groups/${groupId}/expenses/${id}`, expense
? { ...draft, date: daySeconds(date) }
: { ...draft, date: daySeconds(date), createdBy: uid, createdAt: serverTimestamp() });
toast(expense ? "Saved" : `Added "${draft.title}"`);
sheet.close();
} catch (e) {
setErrors({ form: explain(e) });
setBusy(false);
}
};
return (
<Modal title={expense ? "Edit expense" : "Add expense"} sheet={sheet}
footer={<>
<button className="btn btn-line" type="button" onClick={sheet.close}>Cancel</button>
<button className="btn" type="submit" form="expense-form" aria-busy={busy || undefined}><span>{expense ? "Save" : "Add expense"}</span></button>
</>}>
<form id="expense-form" className="stack" onSubmit={(e) => { e.preventDefault(); save(); }} noValidate>
<div className="field">
<label htmlFor="expense-title">What was it for?</label>
<input id="expense-title" className="input" value={title} maxLength={120} placeholder="Groceries" autoComplete="off" enterKeyHint="next"
onChange={(e) => { setTitle(e.target.value); setErrors((x) => ({ ...x, title: undefined })); }}
aria-invalid={errors.title ? true : undefined} aria-describedby={errors.title ? "expense-title-error" : undefined}
data-autofocus={!expense || undefined} />
{errors.title && <p className="field-error" id="expense-title-error">{errors.title}</p>}
</div>
<div className="two">
<div className="field">
<label htmlFor="expense-amount">Amount</label>
<div className={`money-input${errors.amount ? " invalid" : ""}`}><span aria-hidden="true">{currency}</span>
<input id="expense-amount" className="input" value={amount} inputMode="decimal" placeholder={minorDigits(currency) ? "0.00" : "0"} autoComplete="off" enterKeyHint="done"
onChange={(e) => { setAmount(e.target.value); setErrors((x) => ({ ...x, amount: undefined, split: undefined })); }}
aria-invalid={errors.amount ? true : undefined} aria-describedby={errors.amount ? "expense-amount-error" : undefined} />
</div>
{errors.amount && <p className="field-error" id="expense-amount-error">{errors.amount}</p>}
</div>
<div className="field">
<label htmlFor="expense-date">Date</label>
<input id="expense-date" className="input" type="date" value={date} onChange={(e) => setDate(e.target.value || localDay(new Date()))} />
</div>
</div>
<div className="field">
<label htmlFor="expense-payer">Paid by</label>
<select id="expense-payer" className="select" value={paidBy} onChange={(e) => setPaidBy(e.target.value)}>
{active.map((p) => <option key={p.id} value={p.id}>{p.name}{p.id === meId ? " (you)" : ""}</option>)}
</select>
</div>
<fieldset className="field split-field">
<legend className="label">Split</legend>
<div className="segmented" role="radiogroup" aria-label="How to split">
{(["equal", "shares", "exact"] as SplitMode[]).map((m) => (
<button key={m} type="button" role="radio" aria-checked={mode === m} onClick={() => { setMode(m); setErrors((x) => ({ ...x, split: undefined })); }}>
{m === "equal" ? "Equally" : m === "shares" ? "By shares" : "By amount"}
</button>
))}
</div>
{mode === "shares" && <small>Shares can be percentages: 60 and 40 split it 60/40.</small>}
<ul className="split-list" data-mode={mode}>
{active.map((p) => (
<li key={p.id}>
{mode === "equal" ? (
<label className="check"><input type="checkbox" checked={Boolean(included[p.id])} onChange={(e) => { setIncluded((s) => ({ ...s, [p.id]: e.target.checked })); setErrors((x) => ({ ...x, split: undefined })); }} />{p.name}</label>
) : <span className="split-name">{p.name}</span>}
{mode === "shares" && <input className="input input-sm" value={weights[p.id] ?? ""} onChange={(e) => setWeights((s) => ({ ...s, [p.id]: e.target.value }))} inputMode="decimal" aria-label={`Shares for ${p.name}`} />}
{mode === "exact" && <input className="input input-sm" value={exact[p.id] ?? ""} onChange={(e) => { setExact((s) => ({ ...s, [p.id]: e.target.value })); setErrors((x) => ({ ...x, split: undefined })); }} inputMode="decimal" placeholder={minorDigits(currency) ? "0.00" : "0"} aria-label={`Amount for ${p.name}`} />}
{mode !== "exact" && (
<span className="split-amount num">
{preview[p.id] ? formatMoney(preview[p.id], currency) : ""}
{mode === "shares" && shares[p.id] ? <small>{Math.round((shares[p.id] / shareTotal) * 100)}%</small> : null}
</span>
)}
</li>
))}
</ul>
{mode === "equal" && !allIn && (
<button className="btn btn-quiet btn-sm split-all" type="button" onClick={() => setIncluded(Object.fromEntries(active.map((p) => [p.id, true])))}>Include everyone</button>
)}
{mode === "exact" && Number.isFinite(minor) && minor > 0 && (
<small className={shareTotal === minor ? "pos" : "neg"} aria-live="polite">
{shareTotal === minor ? "Adds up to the total." : shareTotal < minor ? `${formatMoney(minor - shareTotal, currency)} left to assign` : `${formatMoney(shareTotal - minor, currency)} more than the total`}
</small>
)}
{errors.split && <p className="field-error" role="alert" ref={reveal}>{errors.split}</p>}
</fieldset>
{errors.form && <p className="notice notice-error" role="alert" ref={reveal}>{errors.form}</p>}
{expense && (
<button className="btn btn-quiet btn-danger delete-btn" type="button" onClick={() => { onDelete(expense); sheet.close(); }}>Delete expense</button>
)}
</form>
</Modal>
);
}
function PaymentSheet({ groupId, currency, people, meId, payment, initial, onDelete, onClose }: {
groupId: string; currency: string; people: Row<Person>[]; meId: string | null; payment?: Row<Expense>;
initial: { from?: string; to?: string; amount?: number }; onDelete: (e: Row<Expense>) => void; onClose: () => void;
}) {
const sheet = useSheet(onClose);
const paidTo = payment ? Object.keys(parseShares(payment.shares))[0] : undefined;
const list = people.filter((p) => !p.removed || p.id === payment?.paidBy || p.id === paidTo);
const firstFrom = payment?.paidBy ?? initial.from ?? meId ?? list[0]?.id ?? "";
const [from, setFrom] = useState(firstFrom);
const [to, setTo] = useState(paidTo ?? initial.to ?? list.find((p) => p.id !== firstFrom)?.id ?? "");
const [amount, setAmount] = useState(() => {
const m = payment?.amount ?? initial.amount;
return m ? fromMinor(m, currency) : "";
});
const [date, setDate] = useState(() => localDay(new Date((payment?.date ?? Date.now() / 1000) * 1000)));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const nameOf = (id: string) => people.find((p) => p.id === id)?.name ?? "Someone";
const save = async () => {
const minor = toMinor(amount, currency);
if (!Number.isFinite(minor) || minor <= 0) { setError(`Enter the amount as a number, like ${minorDigits(currency) ? "12.50" : "1200"}.`); document.getElementById("payment-amount")?.focus(); return; }
if (from === to) return setError("Pick two different people.");
setBusy(true);
try {
const uid = await me();
const doc = { title: "Payment", amount: minor, paidBy: from, splitMode: "exact", shares: JSON.stringify({ [to]: minor }), kind: "payment", date: daySeconds(date) };
await set(`groups/${groupId}/expenses/${payment?.id ?? randomId(12)}`, payment ? doc : { ...doc, createdBy: uid, createdAt: serverTimestamp() });
toast(payment ? "Saved" : `Recorded: ${nameOf(from)} paid ${nameOf(to)} ${formatMoney(minor, currency)}`);
sheet.close();
} catch (e) {
setError(explain(e));
setBusy(false);
}
};
return (
<Modal title={payment ? "Payment" : "Record a payment"} sheet={sheet} footer={<>
<button className="btn btn-line" type="button" onClick={sheet.close}>Cancel</button>
<button className="btn" type="submit" form="payment-form" aria-busy={busy || undefined}><span>{payment ? "Save" : "Record payment"}</span></button>
</>}>
<form id="payment-form" className="stack" onSubmit={(e) => { e.preventDefault(); save(); }} noValidate>
{!payment && <p className="muted">Record a payment after the money changes hands, in cash or any app. Split does not move money.</p>}
<div className="two">
<div className="field"><label htmlFor="payment-from">From</label>
<select id="payment-from" className="select" value={from} onChange={(e) => { setFrom(e.target.value); setError(null); }}>{list.map((p) => <option key={p.id} value={p.id}>{p.name}{p.id === meId ? " (you)" : ""}</option>)}</select>
</div>
<div className="field"><label htmlFor="payment-to">To</label>
<select id="payment-to" className="select" value={to} onChange={(e) => { setTo(e.target.value); setError(null); }}>{list.map((p) => <option key={p.id} value={p.id}>{p.name}{p.id === meId ? " (you)" : ""}</option>)}</select>
</div>
</div>
<div className="two">
<div className="field"><label htmlFor="payment-amount">Amount</label>
<div className="money-input"><span aria-hidden="true">{currency}</span><input id="payment-amount" className="input" value={amount} onChange={(e) => { setAmount(e.target.value); setError(null); }} inputMode="decimal" autoComplete="off" placeholder={minorDigits(currency) ? "0.00" : "0"} /></div>
</div>
<div className="field"><label htmlFor="payment-date">Date</label>
<input id="payment-date" className="input" type="date" value={date} onChange={(e) => setDate(e.target.value || localDay(new Date()))} />
</div>
</div>
{error && <p className="notice notice-error" role="alert" ref={reveal}>{error}</p>}
{payment && (
<button className="btn btn-quiet btn-danger delete-btn" type="button" onClick={() => { onDelete(payment); sheet.close(); }}>Delete payment</button>
)}
</form>
</Modal>
);
}
function PeopleSheet({ groupId, people, bal, currency, meId, addingSelf, onChoose, onClose }: {
groupId: string; people: Row<Person>[]; bal: Record<string, number>; currency: string; meId: string | null; addingSelf?: boolean;
onChoose: (id: string | null) => void; onClose: () => void;
}) {
const sheet = useSheet(onClose);
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null);
const active = people.filter((p) => !p.removed);
const add = async () => {
const n = name.trim();
if (!n) { setError("Type a name first."); return; }
if (active.some((p) => p.name.toLowerCase() === n.toLowerCase())) return setError(`${n} is already in the group.`);
setBusy(true);
try {
const id = randomId(10);
await set(`groups/${groupId}/people/${id}`, { name: n, createdAt: serverTimestamp() });
setName("");
setError(null);
if (addingSelf && !meId) {
onChoose(id);
toast(`Added you as ${n}`);
sheet.close();
} else toast(`Added ${n}`);
} catch (e) { setError(explain(e)); }
setBusy(false);
};
const rename = async () => {
if (!renaming) return;
const p = people.find((x) => x.id === renaming.id);
const n = renaming.name.trim();
if (!p || !n || n === p.name) { setRenaming(null); return; }
if (active.some((x) => x.id !== p.id && x.name.toLowerCase() === n.toLowerCase())) return setError(`${n} is already in the group.`);
try {
await set(`groups/${groupId}/people/${p.id}`, { name: n });
setRenaming(null);
setError(null);
} catch (e) { setError(explain(e)); }
};
const remove = async (p: Row<Person>) => {
if (bal[p.id]) return setError(`${p.name} ${bal[p.id] > 0 ? "gets back" : "owes"} ${formatMoney(Math.abs(bal[p.id]), currency)}. Settle up before removing them.`);
try {
await set(`groups/${groupId}/people/${p.id}`, { removed: true });
setError(null);
toast(`Removed ${p.name}`, { label: "Undo", run: () => { set(`groups/${groupId}/people/${p.id}`, { removed: null }).catch((e) => toast(explain(e))); } });
} catch (e) { setError(explain(e)); }
};
return (
<Modal title="People" sheet={sheet}>
<form className="add-person" onSubmit={(e) => { e.preventDefault(); add(); }} noValidate>
<label className="sr-only" htmlFor="new-person">Name of the person to add</label>
<input id="new-person" className="input" value={name} onChange={(e) => { setName(e.target.value); setError(null); }} placeholder={addingSelf ? "Your name" : "Add a person"} maxLength={40} autoComplete="off" enterKeyHint="done" data-autofocus={addingSelf || undefined} />
<button className="btn" type="submit" aria-busy={busy || undefined}><span>Add</span></button>
</form>
{error && <p className="notice notice-error" role="alert">{error}</p>}
<ul className="people-list">
{active.map((p) => (
<li key={p.id}>
{renaming?.id === p.id ? (
<form className="rename" onSubmit={(e) => { e.preventDefault(); rename(); }}>
<label className="sr-only" htmlFor={`rename-${p.id}`}>New name for {p.name}</label>
<input id={`rename-${p.id}`} className="input" value={renaming.name} onChange={(e) => setRenaming({ id: p.id, name: e.target.value })} maxLength={40} autoFocus enterKeyHint="done" />
<button className="btn btn-sm" type="submit">Save</button>
<button className="btn btn-sm btn-quiet" type="button" onClick={() => setRenaming(null)}>Cancel</button>
</form>
) : (
<>
<span className="person-name">{p.name}{p.id === meId ? <span className="muted"> (you)</span> : null}</span>
<span className="row person-actions">
<button className="btn btn-sm btn-quiet" type="button" onClick={() => setRenaming({ id: p.id, name: p.name })} aria-label={`Rename ${p.name}`}>Rename</button>
<button className="btn btn-sm btn-quiet btn-danger" type="button" onClick={() => remove(p)} aria-label={`Remove ${p.name}`}>Remove</button>
</span>
</>
)}
</li>
))}
</ul>
<p className="hint">People are names, not accounts. Anyone with the link can add, rename or remove them.</p>
</Modal>
);
}
function SettingsSheet({ groupId, group, hasExpenses, url, onClose }: {
groupId: string; group: Group; hasExpenses: boolean; url: string; onClose: () => void;
}) {
const sheet = useSheet(onClose);
const [name, setName] = useState(group.name);
const [currency, setCurrency] = useState(group.currency);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const save = async () => {
if (!name.trim()) { setError("The group needs a name."); return; }
setBusy(true);
try {
await set(`groups/${groupId}`, { name: name.trim(), currency });
toast("Saved");
sheet.close();
} catch (e) { setError(explain(e)); setBusy(false); }
};
return (
<Modal title="Name and currency" sheet={sheet} footer={<>
<button className="btn btn-line" type="button" onClick={sheet.close}>Cancel</button>
<button className="btn" type="submit" form="settings-form" aria-busy={busy || undefined}><span>Save</span></button>
</>}>
<form id="settings-form" className="stack" onSubmit={(e) => { e.preventDefault(); save(); }} noValidate>
<div className="field"><label htmlFor="group-name-edit">Name</label>
<input id="group-name-edit" className="input" value={name} onChange={(e) => { setName(e.target.value); setError(null); }} maxLength={80} autoComplete="off" aria-invalid={error ? true : undefined} />
</div>
<div className="field"><label htmlFor="group-currency">Currency</label>
<select id="group-currency" className="select" value={currency} onChange={(e) => setCurrency(e.target.value)} disabled={hasExpenses} aria-describedby={hasExpenses ? "currency-fixed" : undefined}>
{CURRENCIES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
{hasExpenses && <small id="currency-fixed">The currency is fixed once the group has expenses.</small>}
</div>
<div className="field"><span className="label">Group link</span>
<div className="link-row"><code className="link-code">{url}</code><button className="btn btn-sm btn-line" type="button" onClick={() => copy(url, "Link copied")}>Copy</button></div>
<small>Anyone with this link can see and change the group.</small>
</div>
{error && <p className="notice notice-error" role="alert">{error}</p>}
</form>
</Modal>
);
}
Change history
Publish quality pass from owner chat
Bind agent address in policy constants
Quality pass: cleaner group page, phone polish, fuller API, sourced comparison
Bounded source sync
Bounded source sync