split-free.openapps.xyz

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.
Owned app · rev 9ed962174401
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.
← All files
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
9ed9621744 · 5 hours ago
Bind agent address in policy constants
7effc1cbbd · 5 hours ago
Quality pass: cleaner group page, phone polish, fuller API, sourced comparison
5345219781 · 5 hours ago
Bounded source sync
277fb9ad96 · 1 day ago
Bounded source sync
e944552325 · 1 day ago