"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 (
);
}
/** 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 (
);
}
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. */}
{/* 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 (
{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
)}
{/* 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. */}
{/* 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.