"use client"; import { Fragment, memo, useCallback, useMemo, useState, useRef, useEffect } from "react"; import { useSharedTransactions, useParticipantBalances, useParticipants, useCreateParticipant, useRecordPayment, usePaymentHistory, useDeletePayment, useCurrentUser, useTags, useTrips, type SplitPayment, } from "@/lib/hooks"; import type { SharedTransactionRow } from "@/lib/queries"; import { EditTransactionModal } from "@/components/edit-transaction-modal"; import { OrderDetails } from "@/components/order-details"; import { formatCategory } from "@/lib/categories"; import { CATEGORY_COLORS } from "@/lib/category-colors"; // One formatter, reused. `toLocaleDateString` constructs a fresh // Intl.DateTimeFormat per call, and this page renders every split row at once — // 2,558 calls per render was 520ms of the expand-click freeze on its own. const DATE_FMT = new Intl.DateTimeFormat("en-AU", { day: "numeric", month: "short", year: "numeric" }); function formatDate(d: string) { return DATE_FMT.format(new Date(d)); } const SPEND_TYPES = new Set(["debit", "fee", "interest"]); function formatAmount(n: number, type?: string, currency?: string) { // A bare "$" on a non-AUD row was the visible half of the problem: the row // read as dollars while the participant balances converted to AUD, so the // two disagreed on screen with nothing to explain why. const value = Number(n).toFixed(2); const formatted = !currency || currency === "AUD" ? `$${value}` : `${currency} ${value}`; return type && !SPEND_TYPES.has(type) ? `+${formatted}` : formatted; } // ── Tag multi-select ────────────────────────────────────────────────────────── function TagFilter({ value, onChange }: { value: string[]; onChange: (v: string[]) => void }) { const { data: tags = [] } = useTags(); const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { function handler(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); } document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, []); const toggle = (id: string) => { let next: string[]; if (value.includes(id)) { next = value.filter((x) => x !== id); } else if (id === "untagged") { next = ["untagged"]; } else { next = [...value.filter((x) => x !== "untagged"), id]; } onChange(next); }; const label = value.length === 0 ? "All Tags" : value.includes("untagged") ? "No tags" : value.length === 1 ? (tags.find((t) => String(t.id) === value[0])?.name ?? "1 tag") : `${value.length} tags`; return (
{open && (
{tags.map((t) => ( ))}
)}
); } // ── Add Participant ─────────────────────────────────────────────────────────── function AddParticipantForm({ onDone }: { onDone: () => void }) { const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [error, setError] = useState(""); const create = useCreateParticipant(); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); if (!name.trim()) { setError("Name is required"); return; } try { await create.mutateAsync({ name: name.trim(), email: email.trim() || undefined }); onDone(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to create"); } } return (

Add Participant

setName(e.target.value)} className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-zinc-500" /> setEmail(e.target.value)} className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-zinc-500" />
{error &&

{error}

}
); } // ── Record Payment modal ────────────────────────────────────────────────────── function RecordPaymentModal({ participant, currentUserId, currentBalance, onClose, }: { participant: { id: number; name: string }; currentUserId: number; currentBalance: number; // positive = they owe me, negative = I owe them onClose: () => void; }) { const record = useRecordPayment(); const { data: trips = [] } = useTrips(); const theyOweMe = currentBalance > 0; // Default direction matches the debt direction const [amount, setAmount] = useState(Math.abs(currentBalance).toFixed(2)); const [date, setDate] = useState(new Date().toISOString().slice(0, 10)); const [notes, setNotes] = useState(""); // direction: "received" = they paid me, "sent" = I paid them const [direction, setDirection] = useState<"received" | "sent">(theyOweMe ? "received" : "sent"); // Which tab this settles. "" = the ongoing household tab (trip_id NULL). const [tripId, setTripId] = useState(""); const [error, setError] = useState(""); async function handleSave() { setError(""); const amt = parseFloat(amount); if (!amt || amt <= 0) { setError("Enter a valid amount"); return; } try { await record.mutateAsync({ from_participant_id: direction === "received" ? participant.id : currentUserId, to_participant_id: direction === "received" ? currentUserId : participant.id, amount: amt, payment_date: date, notes: notes || undefined, trip_id: tripId ? Number(tripId) : null, }); onClose(); } catch (e) { setError(e instanceof Error ? e.message : "Failed to record payment"); } } return (
e.stopPropagation()}>

Record Payment

{/* Direction toggle */}
$ setAmount(e.target.value)} className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm pl-6" />
setDate(e.target.value)} className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" />
{/* Scope. Until now every payment recorded here landed on the household tab, because the API dropped trip_id — so a $11k Europe settlement silently reduced the ongoing household balance instead. */}

Covering more than one tab? Record it once per tab — the parts add back up to the transfer.

setNotes(e.target.value)} placeholder="e.g. Bank transfer, cash" className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" />
{error &&

{error}

}
); } // ── Payment history inline ──────────────────────────────────────────────────── function PaymentHistory({ participantId, currentUserId }: { participantId: number; currentUserId: number }) { const { data: payments = [], isLoading } = usePaymentHistory(participantId); const deletePayment = useDeletePayment(); if (isLoading) return

Loading payments…

; if (payments.length === 0) return

No payments recorded

; return (

Payment history

{payments.map((p: SplitPayment) => { const theyPaidMe = p.to_participant_id === currentUserId; return (
{theyPaidMe ? "+" : "-"}${Number(p.amount).toFixed(2)} {formatDate(p.payment_date)} {/* Scope, so a grouped transfer stops looking like a duplicate: two rows of the same amount and date differ only by which tab they settle, and that was invisible until the API returned trip_id. */} {p.trip_name ?? "Household"} {p.notes && {p.notes}}
); })}
); } // ── Split transaction row ───────────────────────────────────────────────────── // Memoized: this table renders every split row at once (1,279 today, no // pagination), so any state change on the page — expanding a receipt, each of // its two query results arriving — re-rendered all of them, ~800ms per pass on // a fast machine and a multi-second browser freeze on a slow one. With memo, // only the row whose `isExpanded` flipped re-renders. Every prop must stay // referentially stable: `tx` objects come straight out of the query cache, and // the callbacks are a state setter and a useCallback. const SharedTxRow = memo(function SharedTxRow({ tx, isExpanded, meId, onToggle, onEdit, }: { tx: SharedTransactionRow; isExpanded: boolean; meId: number | undefined; onToggle: (id: number) => void; onEdit: (tx: SharedTransactionRow) => void; }) { const splits = Array.isArray(tx.splits) ? tx.splits : []; return ( {formatDate(tx.transaction_date)} {formatDate(tx.created_at)}
{tx.order_platform && ( )}

{tx.effective_merchant || tx.description}

{tx.effective_merchant && (

{tx.description}

)} {tx.notes && (

{tx.notes}

)} {/* Category is the effective one — the override wins over the extracted value, the same COALESCE every other view uses, so a correction made elsewhere shows up here too. */} {tx.effective_category ? ( {formatCategory(tx.effective_category)} ) : ( uncategorised )} {formatAmount(tx.amount, tx.transaction_type, tx.currency)} {tx.currency !== "AUD" && ( // Splits settle on the AUD figure, so show it next to the // native one rather than leaving the two to differ silently. {tx.amount_unconverted ? "AUD value unknown" : `≈ ${formatAmount(Number(tx.amount_aud), tx.transaction_type, "AUD")} AUD`} )} {/* Whose money actually left. This is the effective owner — COALESCE(t.owner_id, s.owner_id) — so it is the account the spend came out of, which is what every balance on this page is computed from. "Me" matches the split chips rather than printing your own name twice in one row. */} {tx.owner_id === meId ? "Me" : tx.owner_name}
{splits.map((s) => ( {s.participant_id === meId ? "Me" : s.name} {s.share_percent}% ))}
{isExpanded && ( )}
); }); // ── Main page ───────────────────────────────────────────────────────────────── type SortCol = "transaction_date" | "created_at" | "amount"; /** * Sort direction marker for a column header. * * Declared at module scope and given the sort state as props, rather than * closing over it inside SharedPage: a component defined during render is a * new type on every render, so React unmounts and remounts it each time * instead of updating it. */ function SortIcon({ col, sortCol, sortDir }: { col: SortCol; sortCol: SortCol; sortDir: "asc" | "desc" }) { if (sortCol !== col) return ↕; return {sortDir === "desc" ? "↓" : "↑"}; } export default function SharedPage() { const [tagIds, setTagIds] = useState([]); const [participantId, setParticipantId] = useState(undefined); const [sortCol, setSortCol] = useState("transaction_date"); const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); const [search, setSearch] = useState(""); const realTagIds = tagIds.filter((id) => id !== "untagged"); const { data: participants = [] } = useParticipants(); const { data: rawTransactions = [], isLoading: txLoading } = useSharedTransactions(tagIds, participantId); // Filtered client-side, like the sort above and unlike the transactions page. // This endpoint returns every split row in one go (1,267 today) with no // pagination, so there is nothing for a server round-trip to narrow — and a // server search would have to be added to a query the balance cards share. // // Deliberately does NOT match participant names: the participant dropdown // already does that properly, and typing "sonu" matching every row she is // split on would make the box look broken. The payer IS matched, because // nothing else on the page filters by who paid. const transactions = useMemo( () => [...rawTransactions] .filter((tx) => { const q = search.trim().toLowerCase(); if (!q) return true; return [ tx.description, tx.effective_merchant, tx.notes, tx.effective_category ? formatCategory(tx.effective_category) : null, tx.owner_name, ].some((f) => f?.toLowerCase().includes(q)); }) .sort((a, b) => { const av = sortCol === "amount" ? Number(a.amount) : new Date(a[sortCol]).getTime(); const bv = sortCol === "amount" ? Number(b.amount) : new Date(b[sortCol]).getTime(); return sortDir === "desc" ? bv - av : av - bv; }), [rawTransactions, search, sortCol, sortDir] ); function toggleSort(col: SortCol) { if (sortCol === col) setSortDir((d) => (d === "desc" ? "asc" : "desc")); else { setSortCol(col); setSortDir("desc"); } } const { data: balances = [], isLoading: balLoading } = useParticipantBalances(realTagIds); const { data: allTags = [] } = useTags(); const { data: me } = useCurrentUser(); // Names the tag scope when one is active. Non-empty means the cards below are // split totals rather than payable balances. const tagScopeLabel = realTagIds.length === 0 ? null : realTagIds.length === 1 ? (allTags.find((t) => String(t.id) === realTagIds[0])?.name ?? "this tag") : `${realTagIds.length} tags`; const [addingParticipant, setAddingParticipant] = useState(false); const [paymentModal, setPaymentModal] = useState<{ id: number; name: string; balance: number } | null>(null); const [showHistory, setShowHistory] = useState(null); const [editModal, setEditModal] = useState(null); // Rendering is windowed even though the data is not: the query returns every // split row (1,279 as of 2026-08, growing ~600/yr) so search and sort stay // instant over full history, but the DOM stops at visibleCount — an unbounded // table was what made expanding a receipt freeze the browser. "Show more" // extends the window; changing any filter resets it. const [visibleCount, setVisibleCount] = useState(100); // Reset during render behind a previous-value guard rather than in an effect. // Note the key joins `tagIds` by value: as a raw dependency it was a fresh // array on many renders, so the window was being reset more often than // "changing any filter" claims — including after "Show more", which is the // one interaction this must not undo. const windowKey = `${search}|${sortCol}|${sortDir}|${tagIds.join(",")}|${participantId ?? ""}`; const [lastWindowKey, setLastWindowKey] = useState(windowKey); if (windowKey !== lastWindowKey) { setLastWindowKey(windowKey); setVisibleCount(100); } const visible = (transactions as SharedTransactionRow[]).slice(0, visibleCount); // Receipt expansion, same pattern as the transactions page. The shared // viewer is a split participant, so /api/transactions/[id]/order // authorises them — the item list is part of what was shared. const [expanded, setExpanded] = useState>(new Set()); // Stable identity so SharedTxRow's memo holds — an inline closure here would // change every render and re-render all 1,279 rows anyway. const toggleExpanded = useCallback((id: number) => { setExpanded((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); return (

Shared Expenses

setSearch(e.target.value)} placeholder="Search description, merchant, category, payer…" aria-label="Search split transactions" className="w-64 bg-zinc-800 border border-zinc-700 rounded-lg pl-8 pr-2 py-1.5 text-sm placeholder:text-zinc-600 focus:outline-none focus:border-zinc-500" /> ⌕
{!addingParticipant && ( )}
{addingParticipant && setAddingParticipant(false)} />} {/* Balance cards */} {realTagIds.length === 0 && tagIds.includes("untagged") ? null : realTagIds.length > 0 && (

Showing split totals for selected tag — payments excluded (payments settle overall debt, not per-tag)

)}
{balLoading ? (

Loading balances...

) : balances.length === 0 ? (

No participants yet.

) : ( balances.map((b) => { const theyOweMe = b.total_owed > 0; const net = Math.abs(b.total_owed); const settled = net < 0.005; return (

{b.name}

{/* With a tag filter on, payments are deliberately not subtracted — so this is a split total, not a payable balance, and must not claim to be one. */} {tagScopeLabel ? `split total in ${tagScopeLabel}` : settled ? "all square" : theyOweMe ? "owes you" : "you owe"}

${net.toFixed(2)}

{b.unconverted_count > 0 && (

approx · {b.unconverted_count} unconverted

)}
{/* Settling against a tag-scoped total would record a payment for a figure that never was the debt. */} {!tagScopeLabel && ( )}
{showHistory === b.id && me && ( )}
); }) )}
{/* Transaction list */}

Split Transactions

{search.trim() && !txLoading && ( {transactions.length} of {rawTransactions.length} match “{search.trim()}” )}
{txLoading ? (

Loading...

) : transactions.length === 0 ? ( // "None yet" is wrong when a search simply matched nothing, and it reads // as though the splits were lost. search.trim() ? (

Nothing matches “{search.trim()}”.{" "}

) : (

No split transactions yet. Use the Split button on any transaction.

) ) : ( {/* min-w raised from 520px with the Category and Paid-by columns: the wrapper scrolls horizontally, so a too-small minimum crushes cells rather than letting them scroll. */} {/* Paid by sits next to Splits deliberately: together they are the two halves of the question this page exists to answer — whose money went out, and whose share it was. */} {visible.map((tx) => ( ))}
toggleSort("transaction_date")} > Date toggleSort("created_at")} > Imported Description Category toggleSort("amount")} > Amount Paid by Splits
)} {!txLoading && transactions.length > visible.length && (
showing {visible.length} of {transactions.length}
)}
{/* Payment modal */} {paymentModal && me && ( setPaymentModal(null)} /> )} {editModal && ( setEditModal(null)} /> )}
); }