diff --git a/CLAUDE.md b/CLAUDE.md index 2224c78..0e11396 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -252,6 +252,60 @@ trip never incurred a cost it cancelled. Europe — Sonu + Sunny (id 3, 12–28 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 diff --git a/src/app/trips/[id]/page.tsx b/src/app/trips/[id]/page.tsx index f617808..501750d 100644 --- a/src/app/trips/[id]/page.tsx +++ b/src/app/trips/[id]/page.tsx @@ -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 ( -
{label}
-{value}
- {sub &&{sub}
} +{note}
{formatCategory(payload[0].payload.category)}
-${Number(payload[0].value).toFixed(2)}
-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 +
+On the ground
++ {fmt(onGroundDaily)} / day +
+ {dayRateRank &&{dayRateRank}
} ++ + {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 +
++ Almost nothing was booked before this trip started — its flights and + stays are likely filed against another trip. +
+ )} +No transactions assigned to this trip yet.
diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 972467a..d137b76 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -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, }; }