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}

} +
+ {label} + {fmt(amount)} +
+
+
+
+ + {sub ?? `${count} ${count === 1 ? "charge" : "charges"}`} + +
+
+ ); +} + +/** A section heading that states what the section is FOR, not just what it holds. */ +function SectionHead({ title, note }: { title: string; note: string }) { + return ( +
+

{title}

+

{note}

); } @@ -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 ( -
-

{formatCategory(payload[0].payload.category)}

-

${Number(payload[0].value).toFixed(2)}

-
- ); -} - 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
- {/* Stat cards */} -
- {/* 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. */} - - - - + {/* ── 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. */} +
+
+
+

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 +

+
+ {hasPhases && ( +
+

On the ground

+

+ {fmt(onGroundDaily)} / day +

+ {dayRateRank &&

{dayRateRank}

} +
+ )} +
+ + {hasPhases && total > 0 && ( +
+ {/* 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. */} +
+
+
+
+
+
+

+ + {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 +

+
+
+ {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. +

+ Almost nothing was booked before this trip started — its flights and + stays are likely filed against another trip. +

+ )} +
+ )}
{/* Tab bar */} @@ -172,7 +296,16 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin {/* Daily spend */} {daily_spend.length > 0 && (
-

Daily Spend

+
+ + + + mean {fmt(meanDaily)} + +
} cursor={{ fill: "#27272a" }} /> - + {/* Same axis, same unit — a mean line, not a second scale. */} + {meanDaily > 0 && ( + + )} +
)} -
- {/* 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. */} +
+ {hasPhases && committed_merchants.length > 0 && (
-

By Category

- - - `$${v}`} /> - +
+ {committed_merchants.map((m) => ( + - } cursor={{ fill: "#27272a" }} /> - - {category_breakdown.map((entry) => ( - - ))} - - - + ))} +
)} - {/* Top merchants */} - {top_merchants.length > 0 && ( + {on_ground_categories.length > 0 && (
-

Top Merchants

-
- {top_merchants.map((m, i) => ( -
- {i + 1} -
-
- {m.merchant || "Unknown"} - ${Number(m.amount).toFixed(2)} -
-
-
-
-
-
+ +
+ {on_ground_categories.map((c) => ( + 0 ? `${((Number(c.amount) / onGround) * 100).toFixed(0)}%` : undefined} + /> ))}
@@ -420,7 +548,7 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
)} - {category_breakdown.length === 0 && daily_spend.length === 0 && ( + {on_ground_categories.length === 0 && committed_merchants.length === 0 && daily_spend.length === 0 && (

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