Split a trip into its two economies, so travel stops being a 60% slab
ci / lint-test (push) Successful in 48s
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:
+89
-1
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user