// ===== App.jsx — root: private code-gated trips, header, people, charges, panels ===== const EMPTY = []; const MY_KEY = "tally_mytrips_v1"; // [{ id, code, owner }] — which trips this browser can open const loadMine = () => { try { const a = JSON.parse(localStorage.getItem(MY_KEY)); return Array.isArray(a) ? a : []; } catch (e) { return []; } }; const saveMine = (trips) => { try { localStorage.setItem(MY_KEY, JSON.stringify(trips.map((t) => ({ id: t.id, code: t.code, owner: !!t.owner })))); } catch (e) {} }; // ---- admin key: host-level access to every trip (set on the server via TALLY_ADMIN_KEY) ---- const ADMIN_KEY_LS = "tally_admin_v1"; let adminKey = (() => { try { return localStorage.getItem(ADMIN_KEY_LS) || null; } catch (e) { return null; } })(); const setAdminKey = (k) => { adminKey = k || null; try { if (k) localStorage.setItem(ADMIN_KEY_LS, k); else localStorage.removeItem(ADMIN_KEY_LS); } catch (e) {} }; const tripHeaders = (code, extra) => { const h = { "X-Trip-Code": code || "", ...(extra || {}) }; if (adminKey) h["X-Admin-Key"] = adminKey; return h; }; // ---- API. Trip requests carry the share code (plus the admin key when signed in) ---- const api = { async getConfig() { try { const r = await fetch("/api/config"); return r.ok ? r.json() : { adminEnabled: false }; } catch (e) { return { adminEnabled: false }; } }, async getTrip(id, code) { const r = await fetch("/api/trips/" + id, { headers: tripHeaders(code) }); if (!r.ok) throw r; return r.json(); }, async createTrip(name) { const r = await fetch("/api/trips", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) }); if (!r.ok) throw r; return r.json(); }, async saveTrip(trip, code) { const r = await fetch("/api/trips/" + trip.id, { method: "PUT", headers: tripHeaders(code, { "Content-Type": "application/json" }), body: JSON.stringify(trip) }); if (!r.ok) throw r; return r.json(); }, async deleteTrip(id, code) { await fetch("/api/trips/" + id, { method: "DELETE", headers: tripHeaders(code) }); }, async join(code) { const r = await fetch("/api/join", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code }) }); if (!r.ok) throw r; return r.json(); }, async adminTrips(key) { const r = await fetch("/api/admin/trips", { headers: { "X-Admin-Key": key || adminKey || "" } }); if (!r.ok) throw r; return r.json(); }, }; const splitModeLabel = (m) => ({ equal: "Split equally", exact: "Custom amounts", percent: "By percentage", shares: "By shares" }[m] || "Split equally"); const SORTS = [ { id: "date_desc", label: "Newest first" }, { id: "date_asc", label: "Oldest first" }, { id: "amt_desc", label: "Highest amount" }, { id: "amt_asc", label: "Lowest amount" }, ]; // ---------- Splash ---------- function Splash({ text, sub, children }) { return (
T
{text}
{sub &&
{sub}
} {children}
); } // ---------- Join form (enter a code) ---------- function JoinForm({ onJoin }) { const [code, setCode] = useState(""); const [err, setErr] = useState(""); const [busy, setBusy] = useState(false); const submit = async () => { const c = code.trim(); if (!c || busy) return; setBusy(true); setErr(""); try { await onJoin(c); } catch (e) { setErr("That code didn't match any trip."); setBusy(false); } }; return (
{ setCode(e.target.value.toUpperCase()); setErr(""); }} onKeyDown={(e) => e.key === "Enter" && submit()} placeholder="Enter trip code" className="mono" style={{ flex: 1, letterSpacing: "0.12em", textTransform: "uppercase" }} />
{err &&
{err}
}
); } // ---------- Welcome (no trips yet) ---------- function Welcome({ onCreate, onJoin, adminEnabled, onAdmin }) { return (
T
Tally
Split trip costs with friends

OR JOIN ONE
Got a code from a friend?
{adminEnabled && (
)}
); } // ---------- Join modal (when you already have trips) ---------- function JoinModal({ onJoin, onClose }) { useEffect(() => { const h = (e) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); }, []); return (
{ if (e.target === e.currentTarget) onClose(); }}>

Join a trip


Enter the code a friend shared with you to open their trip.
{ await onJoin(c); onClose(); }} />
); } // ---------- Admin sign-in modal ---------- function AdminModal({ onLogin, onClose }) { const [key, setKey] = useState(""); const [err, setErr] = useState(""); const [busy, setBusy] = useState(false); useEffect(() => { const h = (e) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); }, []); const submit = async () => { const k = key.trim(); if (!k || busy) return; setBusy(true); setErr(""); try { await onLogin(k); } catch (e) { setErr("That admin key wasn't accepted."); setBusy(false); } }; return (
{ if (e.target === e.currentTarget) onClose(); }}>

Admin sign-in


Enter the admin key to see and edit every trip on this server.
{ setKey(e.target.value); setErr(""); }} onKeyDown={(e) => e.key === "Enter" && submit()} placeholder="Admin key" style={{ flex: 1 }} />
{err &&
{err}
}
); } // ---------- Copy button ---------- function CopyBtn({ text, label = "Copy", small }) { const [done, setDone] = useState(false); const copy = async () => { try { await navigator.clipboard.writeText(text); } catch (e) { const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.select(); try { document.execCommand("copy"); } catch (e2) {} document.body.removeChild(ta); } setDone(true); setTimeout(() => setDone(false), 1500); }; return ( ); } // ---------- Share modal (codes + links) ---------- function ShareModal({ trip, onClose }) { useEffect(() => { const h = (e) => { if (e.key === "Escape") onClose(); }; window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); }, []); const base = location.origin + location.pathname; const link = (code) => base + "?join=" + code; const Block = ({ accent, icon, title, blurb, code }) => (
{title}
{blurb}
{code}
); return (
{ if (e.target === e.currentTarget) onClose(); }}>

Invite to {trip.name}


Send your friends the site link, then a code below. They open the link, tap Join a trip, and enter it. (Or just send the one-tap invite link.)
Plain site link (they'll enter a code)
); } // ---------- People bar ---------- function PeopleBar({ people, setPeople, txns, canEdit }) { const [name, setName] = useState(""); const add = () => { const n = name.trim(); if (!n) return; setPeople((ps) => [...ps, { id: uid(), name: n }]); setName(""); }; const remove = (id) => { const used = txns.some((t) => t.payers.some((p) => p.personId === id) || t.participants.includes(id)); if (used && !confirm("This person is in one or more charges. Remove them anyway? Their charges will stay but may need fixing.")) return; setPeople((ps) => ps.filter((p) => p.id !== id)); }; const rename = (id, v) => setPeople((ps) => ps.map((p) => (p.id === id ? { ...p, name: v } : p))); return (
Who's on the trip {people.length}
{people.length > 0 && (
{people.map((p, i) => (
{canEdit ? ( rename(p.id, e.target.value)} style={{ background: "transparent", border: "none", height: "auto", width: Math.max(60, p.name.length * 8.5 + 8), padding: 0, color: "var(--text)", fontWeight: 500, fontSize: 14.5 }} /> ) : ( {p.name} )} {canEdit && }
))}
)} {canEdit && (
setName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && add()} placeholder={people.length ? "Add another person…" : "Add the first person…"} />
{people.length < 2 && ( Add everyone splitting costs — you can rename or remove anyone later. )}
)}
); } // ---------- Expense row ---------- function ExpenseRow({ txn, people, onEdit, onDelete, onOpen, canEdit }) { const payerNames = txn.payers.map((p) => people.find((x) => x.id === p.personId)).filter(Boolean); const multi = txn.payers.length > 1; return (
onOpen(txn)} title="View charge details" style={{ display: "flex", alignItems: "center", gap: 15, padding: "14px 18px", borderBottom: "1px solid var(--border)" }}>
{txn.description} {fmtDate(txn.date)}
{multi ? p.personId)} people={people} size={21} /> : (payerNames[0] && x.id === payerNames[0].id)} size={21} />)} {multi ? `${txn.payers.length} paid` : (payerNames[0]?.name || "—") + " paid"} {splitModeLabel(txn.splitMode)}
{txn.note && (
{txn.note}
)}
{money(txn.amount)}
{canEdit && (
)}
); } // ---------- Charges toolbar ---------- function ChargesToolbar({ search, setSearch, sort, setSort, filterCat, setFilterCat, txns }) { const present = useMemo(() => new Set(txns.map((t) => t.category)), [txns]); return (
setSearch(e.target.value)} placeholder="Search charges…" style={{ height: 38, paddingLeft: 36, fontSize: 14 }} /> {search && }
( )}> {(close) => ( <> {CATEGORIES.filter((c) => present.has(c.id)).map((c) => ( ))} )} ( )}> {(close) => SORTS.map((s) => ( ))}
); } // ---------- App ---------- function App() { const [store, setStore] = useState({ status: "loading", trips: [], activeId: null, admin: false, adminEnabled: false }); const storeRef = useRef(store); useEffect(() => { storeRef.current = store; }); const [modal, setModal] = useState(null); const [personId, setPersonId] = useState(null); const [chargeId, setChargeId] = useState(null); const [showExport, setShowExport] = useState(false); const [showShare, setShowShare] = useState(false); const [showJoin, setShowJoin] = useState(false); const [showAdmin, setShowAdmin] = useState(false); const [search, setSearch] = useState(""); const [sort, setSort] = useState("date_desc"); const [filterCat, setFilterCat] = useState("all"); const saveTimer = useRef(null); const pendingSave = useRef(false); const active = store.trips.find((t) => t.id === store.activeId); const canEdit = !!active && active.role !== "view"; const syncUrl = (id) => { try { const u = new URL(location.href); u.searchParams.set("trip", id); u.searchParams.delete("join"); history.replaceState(null, "", u); } catch (e) {} }; // add/replace a trip in the store + persist membership const upsertTrip = (t, code, owner) => { setStore((s) => { const exists = s.trips.find((x) => x.id === t.id); const merged = { ...t, code, owner: exists ? exists.owner : owner, admin: s.admin }; const trips = exists ? s.trips.map((x) => (x.id === t.id ? merged : x)) : [...s.trips, merged]; if (!s.admin) saveMine(trips); return { ...s, status: "ready", trips, activeId: t.id }; }); syncUrl(t.id); }; const doJoin = async (code) => { const c = code.trim().toUpperCase(); const t = await api.join(c); // throws on bad code upsertTrip(t, c, false); setSearch(""); setFilterCat("all"); return t; }; // initial load useEffect(() => { let alive = true; (async () => { const cfg = await api.getConfig(); const adminEnabled = !!cfg.adminEnabled; // admin mode: load every trip if (adminKey) { try { const all = await api.adminTrips(); if (!alive) return; const trips = all.map((t) => ({ ...t, code: t.editCode, owner: true, admin: true })); const urlTrip = new URLSearchParams(location.search).get("trip"); const activeId = (urlTrip && trips.find((t) => t.id === urlTrip)) ? urlTrip : (trips[0] ? trips[0].id : null); setStore({ status: "ready", admin: true, adminEnabled, trips, activeId }); if (activeId) syncUrl(activeId); return; } catch (e) { setAdminKey(null); /* bad/old admin key → fall back to normal */ } } const mine = loadMine(); const trips = []; for (const e of mine) { try { const t = await api.getTrip(e.id, e.code); trips.push({ ...t, code: e.code, owner: !!e.owner }); } catch (err) { /* code revoked or trip gone — drop it */ } } if (!alive) return; const params = new URLSearchParams(location.search); const joinCode = params.get("join"); const urlTrip = params.get("trip"); if (joinCode) { const existing = trips.find((t) => t.code === joinCode.toUpperCase()); if (existing) { setStore({ status: "ready", admin: false, adminEnabled, trips, activeId: existing.id }); syncUrl(existing.id); return; } try { const t = await api.join(joinCode.toUpperCase()); const merged = { ...t, code: joinCode.toUpperCase(), owner: false }; const all = [...trips, merged]; setStore({ status: "ready", admin: false, adminEnabled, trips: all, activeId: t.id }); saveMine(all); syncUrl(t.id); return; } catch (e) { /* invalid link code — fall through */ } } const activeId = (urlTrip && trips.find((t) => t.id === urlTrip)) ? urlTrip : (trips[0] ? trips[0].id : null); setStore({ status: "ready", admin: false, adminEnabled, trips, activeId }); if (activeId) syncUrl(activeId); // deep link to a trip we can't open yet → prompt for the code if (urlTrip && !trips.find((t) => t.id === urlTrip)) setShowJoin(true); })(); return () => { alive = false; }; }, []); const scheduleSave = (tripId) => { pendingSave.current = true; clearTimeout(saveTimer.current); saveTimer.current = setTimeout(async () => { const t = storeRef.current.trips.find((x) => x.id === tripId); if (!t || t.role === "view") { pendingSave.current = false; return; } try { const res = await api.saveTrip(t, t.code); setStore((s) => ({ ...s, trips: s.trips.map((x) => (x.id === tripId ? { ...x, rev: res.rev } : x)) })); } catch (e) {} pendingSave.current = false; }, 450); }; const patchTrip = (patch) => { const cur = storeRef.current.trips.find((t) => t.id === storeRef.current.activeId); if (!cur || cur.role === "view") return; const id = storeRef.current.activeId; setStore((s) => ({ ...s, trips: s.trips.map((t) => (t.id === s.activeId ? { ...t, ...(typeof patch === "function" ? patch(t) : patch) } : t)) })); scheduleSave(id); }; const setPeople = (fn) => patchTrip((t) => ({ people: typeof fn === "function" ? fn(t.people) : fn })); const setTxns = (fn) => patchTrip((t) => ({ txns: typeof fn === "function" ? fn(t.txns) : fn })); const setTripName = (v) => patchTrip({ name: v }); const saveTxn = (t) => { setTxns((cur) => { const i = cur.findIndex((x) => x.id === t.id); if (i >= 0) { const n = [...cur]; n[i] = t; return n; } return [t, ...cur]; }); setModal(null); }; const deleteTxn = (id) => setTxns((cur) => cur.filter((x) => x.id !== id)); const addPayment = (pm) => patchTrip((t) => ({ payments: [...(t.payments || []), { id: uid(), ...pm, ts: Date.now() }] })); const removePayment = (id) => patchTrip((t) => ({ payments: (t.payments || []).filter((p) => p.id !== id) })); // trips const switchTrip = (id) => { setStore((s) => ({ ...s, activeId: id })); setSearch(""); setFilterCat("all"); syncUrl(id); }; const createTrip = async () => { const name = prompt("Name this trip", "New trip"); if (name === null) return; try { const t = await api.createTrip(name.trim() || "New trip"); upsertTrip(t, t.editCode, true); setSearch(""); setFilterCat("all"); } catch (e) { alert("Couldn't create the trip — check your connection and try again."); } }; const removeFromMine = (id) => { setStore((s) => { const trips = s.trips.filter((t) => t.id !== id); const activeId = id === s.activeId ? (trips[0] ? trips[0].id : null) : s.activeId; if (!s.admin) saveMine(trips); if (activeId) syncUrl(activeId); return { ...s, trips, activeId }; }); }; const deleteTrip = async (id) => { const t = store.trips.find((x) => x.id === id); try { if (t && t.owner) await api.deleteTrip(id, t.code); } catch (e) {} removeFromMine(id); }; const leaveTrip = (id) => removeFromMine(id); // admin const adminLogin = async (key) => { const all = await api.adminTrips(key); // throws on bad key setAdminKey(key); const trips = all.map((t) => ({ ...t, code: t.editCode, owner: true, admin: true })); setStore((s) => ({ ...s, status: "ready", admin: true, trips, activeId: trips[0] ? trips[0].id : null })); if (trips[0]) syncUrl(trips[0].id); setSearch(""); setFilterCat("all"); setShowAdmin(false); }; const adminLogout = () => { setAdminKey(null); location.reload(); }; // live sync — pull others' edits without clobbering active typing or a pending save useEffect(() => { if (store.status !== "ready" || !store.activeId) return; const iv = setInterval(async () => { if (pendingSave.current) return; if (document.querySelector(".modal-backdrop")) return; const ae = document.activeElement; if (ae && (ae.tagName === "INPUT" || ae.tagName === "TEXTAREA")) return; const cur = storeRef.current.trips.find((t) => t.id === storeRef.current.activeId); if (!cur) return; try { const fresh = await api.getTrip(cur.id, cur.code); setStore((s) => { const c2 = s.trips.find((t) => t.id === fresh.id); if (!c2 || c2.rev === fresh.rev) return s; return { ...s, trips: s.trips.map((t) => (t.id === fresh.id ? { ...fresh, code: t.code, owner: t.owner } : t)) }; }); } catch (e) {} }, 5000); return () => clearInterval(iv); }, [store.status, store.activeId]); // filtered + sorted charges (hook must run every render) const txnsForCalc = active ? active.txns : EMPTY; const shown = useMemo(() => { const list = txnsForCalc.filter((t) => (filterCat === "all" || t.category === filterCat) && (!search.trim() || t.description.toLowerCase().includes(search.trim().toLowerCase()))); const by = { date_desc: (a, b) => (b.date || "").localeCompare(a.date || "") || b.createdAt - a.createdAt, date_asc: (a, b) => (a.date || "").localeCompare(b.date || "") || a.createdAt - b.createdAt, amt_desc: (a, b) => b.amount - a.amount, amt_asc: (a, b) => a.amount - b.amount, }[sort]; return [...list].sort(by); }, [txnsForCalc, filterCat, search, sort]); if (store.status === "loading") return ; if (!active) { return ( <> setShowAdmin(true)} /> {showJoin && setShowJoin(false)} />} {showAdmin && setShowAdmin(false)} />} ); } const { people, txns, payments } = active; const canAdd = canEdit && people.length >= 2; const toolbarVisible = txns.length > 2; return (
{/* Header */}
T
setTripName(e.target.value)} className="display trip-title" readOnly={!canEdit} style={{ background: "transparent", border: "none", height: "auto", padding: 0, color: "var(--text)", fontSize: 26, fontWeight: 700, letterSpacing: "-0.02em", width: Math.max(120, active.name.length * 15 + 20), cursor: canEdit ? "text" : "default" }} /> {store.admin && Admin} {!canEdit && View only}
Trip cost splitter
{canEdit && ( )} setShowJoin(true)} onDelete={deleteTrip} onLeave={leaveTrip} admin={store.admin} adminEnabled={store.adminEnabled} onAdmin={() => setShowAdmin(true)} onExitAdmin={adminLogout} /> {canEdit && ( )}
{/* Main grid */}
Charges {txns.length > 0 && {txns.length}}
{txns.length > 0 && canAdd && }

{!canEdit && txns.length === 0 ? (
No charges yet
Nothing has been logged for this trip.
) : !canEdit ? null : people.length < 2 ? (
Add your travelers first
You need at least 2 people before logging charges.
) : txns.length === 0 ? (
No charges yet
Log every cab, dinner, and hotel — split however you like.
) : null} {txns.length > 0 && (
{toolbarVisible && ( <>
)} {shown.length === 0 ? (
No charges match {search ? `"${search}"` : "this filter"}.
) : shown.map((t) => setChargeId(x.id)} canEdit={canEdit} />)}
)}
{/* Right column */}
setShowExport(true)} />
{modal !== null && canEdit && ( setModal(null)} /> )} {personId && setPersonId(null)} />} {chargeId && txns.find((t) => t.id === chargeId) && t.id === chargeId)} people={people} onClose={() => setChargeId(null)} />} {showExport && setShowExport(false)} />} {showShare && canEdit && setShowShare(false)} />} {showJoin && setShowJoin(false)} />} {showAdmin && setShowAdmin(false)} />}
); } ReactDOM.createRoot(document.getElementById("root")).render();