// ===== 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(); }}>
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(); }}>
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 }) => (
);
return (
{ if (e.target === e.currentTarget) onClose(); }}>
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 (
);
}
// ---------- 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();