"use client"; import { use, useState } from "react"; import Link from "next/link"; import { BarChart, Bar, XAxis, YAxis, Tooltip, ReferenceLine, ResponsiveContainer, } from "recharts"; import { useTripAnalytics, useTrip, useTransactions, useParticipantBalances, useTrips } from "@/lib/hooks"; import { CreateTripModal } from "@/components/create-trip-modal"; import { formatCategory } from "@/lib/categories"; import { CHART, TOOLTIP_STYLE } from "@/lib/category-colors"; function fmtDate(d: string | null) { if (!d) return null; return new Date(d).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" }); } function fmt(n: number) { return `$${n.toLocaleString("en-AU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; } /** * A labelled horizontal magnitude bar. * * One hue for every row, never a colour per category. The category name is right * there as a direct label, so a hue per row would double-encode identity the label * already carries — and the app's 27-colour CATEGORY_COLORS set fails CVD * separation on this surface anyway (validated: `other` vs `shopping` at ΔE 5.0 * protan, below the floor). Length carries the magnitude; that is the whole job. */ function BarRow({ label, amount, count, max, sub, }: { label: string; amount: number; count: number; max: number; sub?: string; }) { const pct = max > 0 ? Math.max((Math.abs(amount) / max) * 100, 0.6) : 0; return (
{label} {fmt(amount)}
{sub ?? `${count} ${count === 1 ? "charge" : "charges"}`}
); } /** A section heading that states what the section is FOR, not just what it holds. */ function SectionHead({ title, note }: { title: string; note: string }) { return (

{title}

{note}

); } function DailyTooltip({ active, payload, label }: { active?: boolean; payload?: { value: number }[]; label?: string }) { if (!active || !payload?.length) return null; return (

{label ? new Date(label).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" }) : ""}

${Number(payload[0].value).toFixed(2)}

); } export default function TripDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); const tripId = Number(id); const { data: analytics, isLoading } = useTripAnalytics(tripId); const { data: trip } = useTrip(tripId); const [tab, setTab] = useState<"overview" | "transactions">("overview"); const [editModal, setEditModal] = useState(false); // A trip is all the expenses on one trip, so a participant sees every row on // it, not only their own. The server re-checks participation — this flag is a // request, not a grant. const { data: txData } = useTransactions({ trip_id: id, limit: 500, trip_all_rows: true }); // Unscoped, deliberately: the trip figure alone cannot tell you whether to pay // anyone, because a trip whose payment over-covered it reads negative while the // payer is still in debt overall. This is the number to act on. const { data: balances = [] } = useParticipantBalances(); // For the only cross-trip figure worth quoting: the daily rate. const { data: allTrips = [] } = useTrips(); if (isLoading || !analytics) { return (
{[...Array(4)].map((_, i) =>
)}
); } const { total_spend, transaction_count, num_days, daily_spend, tag_breakdown, participant_splits, phases, committed_merchants, on_ground_categories, on_ground_daily, } = analytics; const t = analytics.trip; const committed = Number(phases.committed); const onGround = Number(phases.on_ground); const total = committed + onGround; const committedPct = total > 0 ? (committed / total) * 100 : 0; const onGroundDaily = Number(on_ground_daily); // No start_date means no knowable departure, so there is no split to draw — the // query already folds everything into on-ground in that case. const hasPhases = Boolean(t.start_date); const maxCommitted = committed_merchants[0]?.amount ?? 1; const maxOnGround = on_ground_categories[0]?.amount ?? 1; const meanDaily = daily_spend.length ? daily_spend.reduce((s, d) => s + Number(d.amount), 0) / daily_spend.length : 0; // Where this trip's daily burn sits against the others. $677.88/day in Europe // against $83.39 in Auckland is the kind of thing a single trip page can never // say on its own, and it is the only figure here that is comparable at all — // totals are not, because trips differ in length. const dayRateRank = (() => { const rated = allTrips .filter((x) => x.start_date && x.end_date && Number(x.total_spend) > 0) .map((x) => { const days = Math.max(1, Math.round( (new Date(x.end_date!).getTime() - new Date(x.start_date!).getTime()) / 86400000 ) + 1); return { id: x.id, rate: Number(x.total_spend) / days }; }) .sort((a, b) => b.rate - a.rate); if (rated.length < 2) return null; const idx = rated.findIndex((x) => x.id === t.id); if (idx === -1) return null; if (idx === 0) return `your priciest day-to-day of ${rated.length} trips`; if (idx === rated.length - 1) return `your cheapest day-to-day of ${rated.length} trips`; return `${idx + 1}${["st", "nd", "rd"][idx] ?? "th"} priciest of ${rated.length} trips`; })(); const dateRange = t.start_date && t.end_date ? `${fmtDate(t.start_date)} – ${fmtDate(t.end_date)}` : t.start_date ? `From ${fmtDate(t.start_date)}` : null; return (
{/* Hero */}
← Trips

{t.name}

{dateRange &&

{dateRange}

} {t.description &&

{t.description}

}
{/* ── Signature: the two economies of a trip ── The page's thesis, and the answer to "travel is 60% and tells me nothing". A trip is paid for twice — once in bookings locked in months ahead, once in daily spending on the ground — and every category except travel belongs wholly to the second. Showing the ratio first makes the rest of the page legible; showing a lone total never did. */}

What the trip cost

{/* Sans, not the display face, and proportional figures — a serif or tabular-nums hero reads as decoration at this size. */}

{fmt(Number(total_spend))}

all payers, net of refunds · {transaction_count} charges over {num_days} days

{hasPhases && (

On the ground

{fmt(onGroundDaily)} / day

{dayRateRank &&

{dayRateRank}

}
)}
{hasPhases && total > 0 && (
{/* Two ordinal steps of one hue, validated against this surface, with a 2px gap so the boundary is a real edge rather than a colour change. Both segments are direct-labelled, so no legend is needed. */}

{fmt(committed)} committed{t.start_date ? ` before ${fmtDate(t.start_date)}` : ""}

{phases.committed_count} bookings · {committedPct.toFixed(0)}% of the trip

{fmt(onGround)} spent on the ground

{phases.on_ground_count} charges · {(100 - committedPct).toFixed(0)}% of the trip

{committed < 1 && ( // Europe — Sonu + Sunny sits at $184.84 committed against Europe 2026's // $22,050.51, because the flights and stays for both legs were filed on // the first trip. Worth saying, or the ratio reads as missing data.

Almost nothing was booked before this trip started — its flights and stays are likely filed against another trip.

)}
)}
{/* Tab bar */}
{(["overview", "transactions"] as const).map((tabName) => ( ))}
{tab === "overview" && (
{/* Daily spend */} {daily_spend.length > 0 && (
mean {fmt(meanDaily)}
new Date(v).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })} interval="preserveStartEnd" /> `$${v}`} width={52} /> } cursor={{ fill: "#27272a" }} /> {/* Same axis, same unit — a mean line, not a second scale. */} {meanDaily > 0 && ( )}
)} {/* ── The two phases, each on the axis that carries information ── This pairing is the fix for the travel problem. Before departure every row is a flight, a stay or a rail ticket, so `travel` is 99% of it and category says nothing — merchant is what distinguishes Agoda $4,490 from Air India $3,454. After departure travel drops to a peer among dining, transport and groceries, and category is finally worth charting. Same rows, two axes, chosen per phase. */}
{hasPhases && committed_merchants.length > 0 && (
{committed_merchants.map((m) => ( ))}
)} {on_ground_categories.length > 0 && (
{on_ground_categories.map((c) => ( 0 ? `${((Number(c.amount) / onGround) * 100).toFixed(0)}%` : undefined} /> ))}
)}
{/* Tag breakdown */} {tag_breakdown.length > 0 && (

By Tag

{tag_breakdown.map((tag) => (
{tag.name} {tag.count} txns ${Number(tag.amount).toFixed(2)}
))}
)} {/* Participant splits */} {participant_splits.length > 0 && (

Participant Splits

View in Shared →
{["Person", "This trip", "How it adds up", "Overall balance"].map((h) => ( ))} {/* One net figure per person — but a negative one is NOT a bill. This is the distinction the page got wrong twice. A payment is allocated to a scope as a lump sum, and the grouped-payment allocation assigned each trip enough to clear the payer's GROSS share. So when the other side of the trip is netted off, a fully-paid trip goes negative by exactly the amount the payer over-covered: Europe reads -$802.75 because Sonu paid $8,004.04 against a net share of $7,201.30. That surplus is not a debt the viewer must settle. It is already carried in the overall balance — Sonu still owes $5,313.38 overall — so "you owe them" was flatly wrong. Scope nets sum to the overall figure; a negative here just means this scope was over-covered and the excess sits in another. So: negative WITH a payment into the scope is an overpayment, and the overall column is where the actionable number lives. Negative with NO payment is genuinely owed, because then the viewer's share of the other person's spending simply exceeds theirs. All three of today's negatives are the former. Sign convention matches Shared: positive means they owe you. */} {participant_splits.map((p) => { const owedGross = Number(p.owed_gross); const paidToMe = Number(p.paid_to_me); const iOweGross = Number(p.i_owe_gross); const paidByMe = Number(p.paid_by_me); const net = Number(p.owed) - Number(p.i_owe); const square = Math.abs(net) < 0.005; const overpaid = net < -0.005 && paidToMe > 0.005; const unconverted = p.unconverted_count + p.i_owe_unconverted_count; const parts = [ owedGross > 0.005 ? `their share ${fmt(owedGross)}` : null, paidToMe > 0.005 ? `they paid ${fmt(paidToMe)}` : null, iOweGross > 0.005 ? `your share of their spend ${fmt(iOweGross)}` : null, paidByMe > 0.005 ? `you paid ${fmt(paidByMe)}` : null, ].filter(Boolean); const overall = balances.find((b) => b.id === p.participant_id); const overallNet = overall ? Number(overall.total_owed) : null; return ( {/* The only figure anyone should act on. Without it a over-covered scope reads as "pay them" when they are still in debt to you overall. */} ); })}
{h}
{p.name} 0 ? "text-amber-400" : overpaid ? "text-emerald-400" : "text-blue-400"}> ${Math.abs(net).toFixed(2)} {square ? "all square" : net > 0 ? "still owed" : overpaid ? "covered — they paid over" : "you owe them"} {unconverted > 0 && ( approx · {unconverted} unconverted )} {parts.length ? parts.join(" · ") : "no split activity on this trip"} {overpaid && ( trip covered; the {fmt(Math.abs(net))} surplus sits on the overall balance, not owing to them )} {overallNet === null ? ( ) : ( <> 0 ? "text-amber-400" : "text-blue-400"}> ${Math.abs(overallNet).toFixed(2)} {Math.abs(overallNet) < 0.005 ? "all square" : overallNet > 0 ? "owes you" : "you owe them"} )}
{/* This note used to say a per-trip figure could not be computed, because payments carried no trip attribution. Migration 0022 added split_payments.trip_id, so it can and now does — the figures above are net of payments scoped to this trip. What the note has to say instead is which payments are NOT in them. */}

This trip is their share of what you paid, less what they paid you, less your share of what they paid. A payment is allocated to a trip as a lump sum, so one that covered someone’s full share leaves this column negative by whatever it over-covered — that surplus is carried in{" "} Overall balance, and is not money owed to them. Settle against the overall figure, never a single trip.
Only payments scoped to this trip count here, so a debt settled by a payment left on the household tab still reads as outstanding — set the scope when recording one. See Shared for the full picture.

)} {on_ground_categories.length === 0 && committed_merchants.length === 0 && daily_spend.length === 0 && (

No transactions assigned to this trip yet.

Go to Transactions to assign some →
)}
)} {tab === "transactions" && (
{!txData?.data.length ? (

No transactions assigned to this trip yet.

Go to Transactions to assign some →
) : (
{["Date", "Description", "Merchant", "Category", "Amount"].map((h) => ( ))} {txData.data.map((tx) => ( ))}
{h}
{new Date(tx.transaction_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })} {tx.description} {tx.effective_merchant || "—"} {formatCategory(tx.effective_category)} ${Number(tx.amount).toFixed(2)}
)}
)} {editModal && trip && ( setEditModal(false)} /> )}
); }