Split a trip into its two economies, so travel stops being a 60% slab
ci / lint-test (push) Successful in 48s

travel dominated every trip page and said nothing. The tempting fix is a finer
travel taxonomy, which needs a hand-maintained merchant list — the trap #19
already describes — and it is also the wrong diagnosis.

travel is the only category that spans both phases of a trip. Every other one is
100% on-the-ground: on Europe 2026, dining, transport, entertainment, groceries
and shopping are all exactly $0.00 before departure. The chart was not bad, it
was two economies stacked into one, and travel was the only thing visible in the
union.

So split on start_date and use the axis that carries information in each phase.
Booked ahead ($22,050.51, 57%) is all flights and stays, so merchant is the axis
— Agoda $4,490, Air India $3,454, Luxury Escapes $3,284. On the ground
($16,946.94) travel falls to $8,241 among dining $4,452 and transport $2,938, and
category is finally worth charting.

The hero is the ratio, not a lone total, with the on-ground daily rate beside it
— the only figure comparable between trips, since totals are not: Europe
$677.88/day against Auckland $83.39. A trip with near-zero committed spend says
so, because Sonu + Sunny's $184.84 is a filing artefact (both legs' bookings sit
on the first trip), not a cheap trip.

Two dataviz rules this page was breaking. Category bars now use one copper hue
with the name as a direct label: the per-bar rainbow double-encoded identity the
label already carries, and the trip subset fails CVD validation on this surface
(other vs shopping at delta-E 5.0 protan, below the floor of 6). And the hero
figure drops the serif and tabular-nums, which read as decoration at that size.
The phase bar is two ordinal steps of one hue, validated with --ordinal against
the card surface, with a 2px gap so the boundary is an edge.

278 passing, build clean. Data verified against the database directly; I could
not render the page in a browser to eyeball the layout.
This commit is contained in:
2026-08-02 22:20:55 +10:00
parent 2d341e24a0
commit 9e4b518f57
3 changed files with 359 additions and 89 deletions
+216 -88
View File
@@ -8,13 +8,13 @@ import {
XAxis,
YAxis,
Tooltip,
ReferenceLine,
ResponsiveContainer,
Cell,
} from "recharts";
import { useTripAnalytics, useTrip, useTransactions, useParticipantBalances } from "@/lib/hooks";
import { useTripAnalytics, useTrip, useTransactions, useParticipantBalances, useTrips } from "@/lib/hooks";
import { CreateTripModal } from "@/components/create-trip-modal";
import { formatCategory } from "@/lib/categories";
import { CATEGORY_COLORS, TOOLTIP_STYLE } from "@/lib/category-colors";
import { CHART, TOOLTIP_STYLE } from "@/lib/category-colors";
function fmtDate(d: string | null) {
if (!d) return null;
@@ -25,23 +25,54 @@ function fmt(n: number) {
return `$${n.toLocaleString("en-AU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
function StatCard({
/**
* 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,
value,
amount,
count,
max,
sub,
color,
}: {
label: string;
value: string;
amount: number;
count: number;
max: number;
sub?: string;
color: string;
}) {
const pct = max > 0 ? Math.max((Math.abs(amount) / max) * 100, 0.6) : 0;
return (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 relative overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ backgroundColor: color }} />
<p className="text-xs text-zinc-500 mb-1">{label}</p>
<p className="text-2xl font-semibold tabular-nums">{value}</p>
{sub && <p className="text-xs text-zinc-600 mt-1 truncate">{sub}</p>}
<div className="group grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-1 items-baseline">
<span className="text-sm text-zinc-300 truncate" title={label}>{label}</span>
<span className="text-sm font-mono tabular-nums text-zinc-200">{fmt(amount)}</span>
<div className="col-span-2 flex items-center gap-2">
<div className="h-1.5 flex-1 bg-zinc-800/70 overflow-hidden rounded-sm">
<div
className="h-full transition-[width] duration-500 motion-reduce:transition-none"
style={{ width: `${pct}%`, background: CHART.accent, borderRadius: "0 4px 4px 0" }}
/>
</div>
<span className="text-[11px] text-zinc-600 tabular-nums w-16 text-right shrink-0">
{sub ?? `${count} ${count === 1 ? "charge" : "charges"}`}
</span>
</div>
</div>
);
}
/** A section heading that states what the section is FOR, not just what it holds. */
function SectionHead({ title, note }: { title: string; note: string }) {
return (
<div className="mb-4">
<h3 className="text-sm font-display text-zinc-100">{title}</h3>
<p className="text-xs text-zinc-500 mt-0.5 leading-relaxed">{note}</p>
</div>
);
}
@@ -58,16 +89,6 @@ function DailyTooltip({ active, payload, label }: { active?: boolean; payload?:
);
}
function CategoryTooltip({ active, payload }: { active?: boolean; payload?: { payload: { category: string }; value: number }[] }) {
if (!active || !payload?.length) return null;
return (
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs">
<p className="text-zinc-400 mb-1">{formatCategory(payload[0].payload.category)}</p>
<p className="text-zinc-100 font-medium">${Number(payload[0].value).toFixed(2)}</p>
</div>
);
}
export default function TripDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const tripId = Number(id);
@@ -86,6 +107,8 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
// 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 (
@@ -98,9 +121,48 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
);
}
const { total_spend, transaction_count, num_days, daily_average, category_breakdown, daily_spend, top_merchants, tag_breakdown, participant_splits } = analytics;
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 maxMerchant = top_merchants[0]?.amount ?? 1;
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)}`
@@ -138,15 +200,77 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
</div>
</div>
{/* Stat cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{/* Deliberately every payer, not just this owner — a trip cost what the
group put into it. The split figures below are owner-scoped, so this
says whose money it counts to stop the two being read as one lens. */}
<StatCard label="Total Spend" value={`$${Number(total_spend).toFixed(2)}`} sub="all payers, net of refunds" color={t.color} />
<StatCard label="Transactions" value={String(transaction_count)} sub="total" color={t.color} />
<StatCard label="Daily Average" value={`$${Number(daily_average).toFixed(2)}`} sub="per day" color={t.color} />
<StatCard label="Days" value={String(num_days)} sub={dateRange ?? "date range"} color={t.color} />
{/* ── 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. */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 sm:p-6">
<div className="flex flex-wrap items-end justify-between gap-x-8 gap-y-3">
<div>
<p className="text-[11px] uppercase tracking-[0.16em] text-zinc-500">What the trip cost</p>
{/* Sans, not the display face, and proportional figures — a serif or
tabular-nums hero reads as decoration at this size. */}
<p className="text-4xl font-semibold text-zinc-50 mt-1 leading-none">{fmt(Number(total_spend))}</p>
<p className="text-xs text-zinc-500 mt-1.5">
all payers, net of refunds · {transaction_count} charges over {num_days} days
</p>
</div>
{hasPhases && (
<div className="text-right">
<p className="text-[11px] uppercase tracking-[0.16em] text-zinc-500">On the ground</p>
<p className="text-2xl font-semibold text-zinc-100 mt-1 leading-none">
{fmt(onGroundDaily)}<span className="text-sm font-normal text-zinc-500"> / day</span>
</p>
{dayRateRank && <p className="text-xs text-zinc-500 mt-1.5">{dayRateRank}</p>}
</div>
)}
</div>
{hasPhases && total > 0 && (
<div className="mt-6">
{/* 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. */}
<div className="flex gap-[2px] h-2.5" role="img"
aria-label={`${fmt(committed)} committed before departure, ${fmt(onGround)} spent on the ground`}>
<div className="rounded-l-sm rounded-r-[1px]" style={{ width: `${committedPct}%`, background: "#7c4820" }} />
<div className="rounded-r-sm rounded-l-[1px]" style={{ width: `${100 - committedPct}%`, background: "#d28a47" }} />
</div>
<div className="flex flex-wrap justify-between gap-x-6 gap-y-2 mt-3">
<div>
<p className="text-sm text-zinc-200">
<span className="inline-block w-2 h-2 rounded-sm mr-1.5 align-middle" style={{ background: "#7c4820" }} />
{fmt(committed)}
<span className="text-zinc-500"> committed{t.start_date ? ` before ${fmtDate(t.start_date)}` : ""}</span>
</p>
<p className="text-[11px] text-zinc-600 mt-0.5 ml-3.5">
{phases.committed_count} bookings · {committedPct.toFixed(0)}% of the trip
</p>
</div>
<div className="sm:text-right">
<p className="text-sm text-zinc-200">
<span className="inline-block w-2 h-2 rounded-sm mr-1.5 align-middle" style={{ background: "#d28a47" }} />
{fmt(onGround)}
<span className="text-zinc-500"> spent on the ground</span>
</p>
<p className="text-[11px] text-zinc-600 mt-0.5 ml-3.5 sm:ml-0">
{phases.on_ground_count} charges · {(100 - committedPct).toFixed(0)}% of the trip
</p>
</div>
</div>
{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.
<p className="text-[11px] text-zinc-600 mt-3 pt-3 border-t border-zinc-800/70">
Almost nothing was booked before this trip started its flights and
stays are likely filed against another trip.
</p>
)}
</div>
)}
</div>
{/* Tab bar */}
@@ -172,7 +296,16 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
{/* Daily spend */}
{daily_spend.length > 0 && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<h3 className="text-sm font-medium mb-4">Daily Spend</h3>
<div className="flex items-start justify-between gap-4 flex-wrap mb-4">
<SectionHead
title="Day by day"
note="Every day money moved, bookings included — the tall early bars are usually the flights."
/>
<span className="text-[11px] text-zinc-500 shrink-0 flex items-center gap-1.5">
<span className="w-4 border-t border-dashed inline-block" style={{ borderColor: CHART.axis }} />
mean {fmt(meanDaily)}
</span>
</div>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={daily_spend} margin={{ top: 4, right: 8, bottom: 0, left: 8 }}>
<XAxis
@@ -191,69 +324,64 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
width={52}
/>
<Tooltip content={<DailyTooltip />} cursor={{ fill: "#27272a" }} />
<Bar dataKey="amount" fill={t.color} radius={[3, 3, 0, 0]} maxBarSize={40} opacity={0.85} />
{/* Same axis, same unit — a mean line, not a second scale. */}
{meanDaily > 0 && (
<ReferenceLine y={meanDaily} stroke={CHART.axis} strokeDasharray="3 3" strokeWidth={1} />
)}
<Bar dataKey="amount" fill={CHART.accent} radius={[4, 4, 0, 0]} maxBarSize={40} />
</BarChart>
</ResponsiveContainer>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Category breakdown */}
{category_breakdown.length > 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. */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-start">
{hasPhases && committed_merchants.length > 0 && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<h3 className="text-sm font-medium mb-4">By Category</h3>
<ResponsiveContainer width="100%" height={Math.max(120, category_breakdown.length * 32)}>
<BarChart
data={category_breakdown}
layout="vertical"
margin={{ top: 0, right: 60, bottom: 0, left: 100 }}
>
<XAxis type="number" tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${v}`} />
<YAxis
type="category"
dataKey="category"
tick={{ fill: "#a1a1aa", fontSize: 12 }}
axisLine={false}
tickLine={false}
tickFormatter={formatCategory}
width={98}
<SectionHead
title="Booked ahead"
note={`Locked in before ${t.start_date ? fmtDate(t.start_date) : "departure"}. It is all flights and stays here, so the merchant is what tells them apart — not the category.`}
/>
<div className="space-y-3.5">
{committed_merchants.map((m) => (
<BarRow
key={m.merchant}
label={m.merchant || "Unknown"}
amount={Number(m.amount)}
count={m.count}
max={maxCommitted}
/>
<Tooltip content={<CategoryTooltip />} cursor={{ fill: "#27272a" }} />
<Bar dataKey="amount" radius={[0, 3, 3, 0]} maxBarSize={22}>
{category_breakdown.map((entry) => (
<Cell key={entry.category} fill={CATEGORY_COLORS[entry.category] || "#6366f1"} opacity={0.85} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
))}
</div>
</div>
)}
{/* Top merchants */}
{top_merchants.length > 0 && (
{on_ground_categories.length > 0 && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<h3 className="text-sm font-medium mb-4">Top Merchants</h3>
<div className="space-y-3">
{top_merchants.map((m, i) => (
<div key={m.merchant} className="flex items-center gap-3">
<span className="text-xs text-zinc-600 w-4 tabular-nums text-right">{i + 1}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm truncate">{m.merchant || "Unknown"}</span>
<span className="text-sm font-mono tabular-nums ml-2 flex-shrink-0">${Number(m.amount).toFixed(2)}</span>
</div>
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full"
style={{
width: `${(m.amount / maxMerchant) * 100}%`,
backgroundColor: t.color,
opacity: 0.7,
}}
/>
</div>
</div>
</div>
<SectionHead
title={hasPhases ? "On the ground" : "By category"}
note={
hasPhases
? "Day-to-day spending once you arrived. With the bookings taken out, travel sits among its peers instead of swamping them."
: "This trip has no start date, so there is no departure to split on."
}
/>
<div className="space-y-3.5">
{on_ground_categories.map((c) => (
<BarRow
key={c.category}
label={formatCategory(c.category)}
amount={Number(c.amount)}
count={c.count}
max={maxOnGround}
sub={onGround > 0 ? `${((Number(c.amount) / onGround) * 100).toFixed(0)}%` : undefined}
/>
))}
</div>
</div>
@@ -420,7 +548,7 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
</div>
)}
{category_breakdown.length === 0 && daily_spend.length === 0 && (
{on_ground_categories.length === 0 && committed_merchants.length === 0 && daily_spend.length === 0 && (
<div className="text-center py-12 text-zinc-600">
<p className="text-sm">No transactions assigned to this trip yet.</p>
<Link href="/transactions" className="text-indigo-400 hover:text-indigo-300 text-sm mt-1 inline-block">