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
+54
View File
@@ -252,6 +252,60 @@ trip never incurred a cost it cancelled.
Europe — Sonu + Sunny (id 3, 1228 Apr, created 2026-07-28 from tag 5),
Singapore + Bangkok 2026 (id 4).
### Why `travel` looked useless on a trip page, and the fix (2026-08-02)
`travel` was ~60% of every trip and told you nothing. The tempting fix — a finer
travel taxonomy (flights / stays / getting around) — needs a hand-maintained
merchant list, which is the trap ticket #19 already describes. It is also the
wrong diagnosis.
**Measured on Europe 2026, `travel` is the only category that spans both phases
of a trip. Every other category is 100% on-the-ground — dining, transport,
entertainment, groceries and shopping are all exactly $0.00 before departure.**
So the category chart was not a bad chart; it was two different economies stacked
into one, and travel was the only thing visible in the union.
The fix is to split on `trips.start_date` and use the axis that carries
information in each phase:
- **Booked ahead** (before `start_date`, $22,050.51 / 57% on Europe) — everything
is a flight, a stay or a rail ticket, so category is a constant and **merchant**
is the axis: Agoda $4,490.52, Air India $3,454.32, Luxury Escapes $3,283.80.
- **On the ground** (on/after `start_date`, $16,946.94) — travel falls to
$8,240.74 among dining $4,452.18, transport $2,937.70, entertainment $698.14,
groceries $589.22. **Category is finally worth charting.**
`getTripAnalytics` returns `phases`, `committed_merchants`, `on_ground_categories`
and `on_ground_daily`. A trip with a NULL `start_date` has no knowable departure,
so the SQL folds everything into on-ground rather than reporting it as committed.
**The daily rate is the only figure comparable between trips**, because totals are
not — trips differ in length. Europe $677.88/day, Sonu + Sunny $573.57, Singapore
+ Bangkok $309.76, Auckland $83.39. The page ranks the current trip against the
others from the already-loaded `useTrips()` list.
**A trip with near-zero committed spend is a filing artefact, not a cheap trip.**
Europe — Sonu + Sunny shows $184.84 committed against Europe 2026's $22,050.51
because both legs' flights and stays were filed on the first trip. The page says
so rather than letting the ratio read as missing data.
Two chart rules this page now follows, both from the `dataviz` skill and both
previously broken here:
- **One series → one colour.** The category bars use a single copper hue with the
category as a direct label. `CATEGORY_COLORS` was a per-bar rainbow, which
double-encodes identity the label already carries — and the trip subset **fails**
CVD validation on this surface (`other``shopping` ΔE 5.0 protan, below the
floor of 6). Do not reintroduce per-category colour on a labelled bar chart.
- **No serif and no `tabular-nums` on the hero figure.** Fraunces is for section
headings; a display face on a large number reads as decoration, and equal-width
digits make it look loose.
The phase split bar is two ordinal steps of one hue (`#7c4820``#d28a47`),
validated with `--ordinal` against the `#171410` card surface, with a 2px gap so
the boundary is an edge rather than a colour change. Both segments are
direct-labelled, so it needs no legend.
### Trip participation is derived, and a trip is shared
Rebuilt 2026-08-02. Trips were scoped to `trips.owner_id`, so Sonu saw **no
+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">
+89 -1
View File
@@ -927,6 +927,33 @@ export interface TripAnalytics {
category_breakdown: { category: string; amount: number; count: number }[];
daily_spend: { date: string; amount: number }[];
top_merchants: { merchant: string; amount: number; count: number }[];
/**
* A trip has two economies, and mixing them is what made `travel` look like an
* uninformative 60% slab: it is the ONLY category that spans both. Measured on
* Europe 2026, every other category is 100% on-the-ground — dining, transport,
* entertainment, groceries and shopping are all exactly $0.00 before departure.
*
* So the fix is not a finer travel taxonomy (which would need a hand-maintained
* merchant list, the trap ticket #19 already describes). It is to split by phase
* and use the axis that carries information in each: merchant before departure,
* where everything is a flight or a booking, and category after it, where travel
* drops to a normal-sized slice among peers.
*
* `committed` is dated before `start_date`; everything else is `on_ground`. A trip
* with no start_date has no knowable split, so it all reads as on-ground.
*/
phases: {
committed: number;
committed_count: number;
on_ground: number;
on_ground_count: number;
};
/** Pre-departure spend by merchant — the bookings that make up the commitment. */
committed_merchants: { merchant: string; amount: number; count: number }[];
/** On-the-ground spend by category, where category is finally worth charting. */
on_ground_categories: { category: string; amount: number; count: number }[];
/** On-ground spend per day of the trip window. The comparable rate between trips. */
on_ground_daily: number;
tag_breakdown: { tag_id: number; name: string; color: string; amount: number; count: number }[];
participant_splits: {
participant_id: number;
@@ -1064,7 +1091,10 @@ export async function getTripAnalytics(tripId: number, viewerId: number): Promis
//
// COUNT(*) deliberately still counts refund rows: a refund is a transaction
// that occurred on the trip, even though it subtracts from the total.
const [categoryRows, dailyRows, merchantRows, tagRows, splitRows] = await Promise.all([
const [
categoryRows, dailyRows, merchantRows, tagRows, splitRows,
phaseRows, committedMerchantRows, onGroundCategoryRows,
] = await Promise.all([
queryRaw<{ category: string; amount: number; count: number }>(`
SELECT
COALESCE(o.category_override, t.category, 'other') AS category,
@@ -1242,6 +1272,58 @@ export async function getTripAnalytics(tripId: number, viewerId: number): Promis
OR paid_to_me.pid IS NOT NULL OR paid_by_me.pid IS NOT NULL
ORDER BY 3 DESC
`, [tripId, viewerId]),
// ── The phase split, and the right axis on each side of it ──
//
// $3 is the trip's start_date. NULL makes every comparison NULL, so a trip with
// no dates collapses to all-on-ground rather than erroring or silently
// reporting everything as committed.
queryRaw<{ committed: number; committed_count: number; on_ground: number; on_ground_count: number }>(`
SELECT
COALESCE(SUM(CASE WHEN t.transaction_date < $2::date THEN ${SPEND_SIGNED} END), 0)::float AS committed,
COUNT(*) FILTER (WHERE t.transaction_date < $2::date)::int AS committed_count,
COALESCE(SUM(CASE WHEN t.transaction_date >= $2::date OR $2 IS NULL THEN ${SPEND_SIGNED} END), 0)::float AS on_ground,
COUNT(*) FILTER (WHERE t.transaction_date >= $2::date OR $2 IS NULL)::int AS on_ground_count
FROM transaction_overrides o
JOIN transactions t ON t.id = o.transaction_id
WHERE o.trip_id = $1
AND ${NET_SPEND_ROWS}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
`, [tripId, trip.start_date]),
queryRaw<{ merchant: string; amount: number; count: number }>(`
SELECT
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) AS merchant,
SUM(${SPEND_SIGNED})::float AS amount,
COUNT(*)::int AS count
FROM transaction_overrides o
JOIN transactions t ON t.id = o.transaction_id
WHERE o.trip_id = $1
AND t.transaction_date < $2::date
AND ${NET_SPEND_ROWS}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
GROUP BY 1
ORDER BY 2 DESC
LIMIT 12
`, [tripId, trip.start_date]),
queryRaw<{ category: string; amount: number; count: number }>(`
SELECT
COALESCE(o.category_override, t.category, 'other') AS category,
SUM(${SPEND_SIGNED})::float AS amount,
COUNT(*)::int AS count
FROM transaction_overrides o
JOIN transactions t ON t.id = o.transaction_id
WHERE o.trip_id = $1
AND (t.transaction_date >= $2::date OR $2 IS NULL)
AND ${NET_SPEND_ROWS}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
GROUP BY 1
ORDER BY 2 DESC
`, [tripId, trip.start_date]),
]);
const num_days = (trip.start_date && trip.end_date)
@@ -1260,6 +1342,12 @@ export async function getTripAnalytics(tripId: number, viewerId: number): Promis
tag_breakdown: tagRows,
participant_splits: splitRows,
viewer_is_owner: trip.owner_id === viewerId,
phases: phaseRows[0] ?? { committed: 0, committed_count: 0, on_ground: 0, on_ground_count: 0 },
committed_merchants: committedMerchantRows,
on_ground_categories: onGroundCategoryRows,
// Per day of the trip window, not per day of the whole span — the commitment
// was made over months and dividing it by trip length would be meaningless.
on_ground_daily: (phaseRows[0]?.on_ground ?? 0) / num_days,
};
}