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
+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,
};
}