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:
@@ -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),
|
Europe — Sonu + Sunny (id 3, 12–28 Apr, created 2026-07-28 from tag 5),
|
||||||
Singapore + Bangkok 2026 (id 4).
|
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
|
### Trip participation is derived, and a trip is shared
|
||||||
|
|
||||||
Rebuilt 2026-08-02. Trips were scoped to `trips.owner_id`, so Sonu saw **no
|
Rebuilt 2026-08-02. Trips were scoped to `trips.owner_id`, so Sonu saw **no
|
||||||
|
|||||||
+216
-88
@@ -8,13 +8,13 @@ import {
|
|||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
ReferenceLine,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Cell,
|
|
||||||
} from "recharts";
|
} 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 { CreateTripModal } from "@/components/create-trip-modal";
|
||||||
import { formatCategory } from "@/lib/categories";
|
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) {
|
function fmtDate(d: string | null) {
|
||||||
if (!d) return null;
|
if (!d) return null;
|
||||||
@@ -25,23 +25,54 @@ function fmt(n: number) {
|
|||||||
return `$${n.toLocaleString("en-AU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
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,
|
label,
|
||||||
value,
|
amount,
|
||||||
|
count,
|
||||||
|
max,
|
||||||
sub,
|
sub,
|
||||||
color,
|
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
amount: number;
|
||||||
|
count: number;
|
||||||
|
max: number;
|
||||||
sub?: string;
|
sub?: string;
|
||||||
color: string;
|
|
||||||
}) {
|
}) {
|
||||||
|
const pct = max > 0 ? Math.max((Math.abs(amount) / max) * 100, 0.6) : 0;
|
||||||
return (
|
return (
|
||||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 relative overflow-hidden">
|
<div className="group grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-1 items-baseline">
|
||||||
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ backgroundColor: color }} />
|
<span className="text-sm text-zinc-300 truncate" title={label}>{label}</span>
|
||||||
<p className="text-xs text-zinc-500 mb-1">{label}</p>
|
<span className="text-sm font-mono tabular-nums text-zinc-200">{fmt(amount)}</span>
|
||||||
<p className="text-2xl font-semibold tabular-nums">{value}</p>
|
<div className="col-span-2 flex items-center gap-2">
|
||||||
{sub && <p className="text-xs text-zinc-600 mt-1 truncate">{sub}</p>}
|
<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>
|
</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 }> }) {
|
export default function TripDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = use(params);
|
const { id } = use(params);
|
||||||
const tripId = Number(id);
|
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
|
// 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.
|
// payer is still in debt overall. This is the number to act on.
|
||||||
const { data: balances = [] } = useParticipantBalances();
|
const { data: balances = [] } = useParticipantBalances();
|
||||||
|
// For the only cross-trip figure worth quoting: the daily rate.
|
||||||
|
const { data: allTrips = [] } = useTrips();
|
||||||
|
|
||||||
if (isLoading || !analytics) {
|
if (isLoading || !analytics) {
|
||||||
return (
|
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 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
|
const dateRange = t.start_date && t.end_date
|
||||||
? `${fmtDate(t.start_date)} – ${fmtDate(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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stat cards */}
|
{/* ── Signature: the two economies of a trip ──
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
The page's thesis, and the answer to "travel is 60% and tells me
|
||||||
{/* Deliberately every payer, not just this owner — a trip cost what the
|
nothing". A trip is paid for twice — once in bookings locked in months
|
||||||
group put into it. The split figures below are owner-scoped, so this
|
ahead, once in daily spending on the ground — and every category except
|
||||||
says whose money it counts to stop the two being read as one lens. */}
|
travel belongs wholly to the second. Showing the ratio first makes the
|
||||||
<StatCard label="Total Spend" value={`$${Number(total_spend).toFixed(2)}`} sub="all payers, net of refunds" color={t.color} />
|
rest of the page legible; showing a lone total never did. */}
|
||||||
<StatCard label="Transactions" value={String(transaction_count)} sub="total" color={t.color} />
|
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 sm:p-6">
|
||||||
<StatCard label="Daily Average" value={`$${Number(daily_average).toFixed(2)}`} sub="per day" color={t.color} />
|
<div className="flex flex-wrap items-end justify-between gap-x-8 gap-y-3">
|
||||||
<StatCard label="Days" value={String(num_days)} sub={dateRange ?? "date range"} color={t.color} />
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Tab bar */}
|
{/* Tab bar */}
|
||||||
@@ -172,7 +296,16 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
|||||||
{/* Daily spend */}
|
{/* Daily spend */}
|
||||||
{daily_spend.length > 0 && (
|
{daily_spend.length > 0 && (
|
||||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
|
<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}>
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
<BarChart data={daily_spend} margin={{ top: 4, right: 8, bottom: 0, left: 8 }}>
|
<BarChart data={daily_spend} margin={{ top: 4, right: 8, bottom: 0, left: 8 }}>
|
||||||
<XAxis
|
<XAxis
|
||||||
@@ -191,69 +324,64 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
|||||||
width={52}
|
width={52}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<DailyTooltip />} cursor={{ fill: "#27272a" }} />
|
<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>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
{/* ── The two phases, each on the axis that carries information ──
|
||||||
{/* Category breakdown */}
|
This pairing is the fix for the travel problem. Before departure
|
||||||
{category_breakdown.length > 0 && (
|
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">
|
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
|
||||||
<h3 className="text-sm font-medium mb-4">By Category</h3>
|
<SectionHead
|
||||||
<ResponsiveContainer width="100%" height={Math.max(120, category_breakdown.length * 32)}>
|
title="Booked ahead"
|
||||||
<BarChart
|
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.`}
|
||||||
data={category_breakdown}
|
/>
|
||||||
layout="vertical"
|
<div className="space-y-3.5">
|
||||||
margin={{ top: 0, right: 60, bottom: 0, left: 100 }}
|
{committed_merchants.map((m) => (
|
||||||
>
|
<BarRow
|
||||||
<XAxis type="number" tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${v}`} />
|
key={m.merchant}
|
||||||
<YAxis
|
label={m.merchant || "Unknown"}
|
||||||
type="category"
|
amount={Number(m.amount)}
|
||||||
dataKey="category"
|
count={m.count}
|
||||||
tick={{ fill: "#a1a1aa", fontSize: 12 }}
|
max={maxCommitted}
|
||||||
axisLine={false}
|
|
||||||
tickLine={false}
|
|
||||||
tickFormatter={formatCategory}
|
|
||||||
width={98}
|
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<CategoryTooltip />} cursor={{ fill: "#27272a" }} />
|
))}
|
||||||
<Bar dataKey="amount" radius={[0, 3, 3, 0]} maxBarSize={22}>
|
</div>
|
||||||
{category_breakdown.map((entry) => (
|
|
||||||
<Cell key={entry.category} fill={CATEGORY_COLORS[entry.category] || "#6366f1"} opacity={0.85} />
|
|
||||||
))}
|
|
||||||
</Bar>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Top merchants */}
|
{on_ground_categories.length > 0 && (
|
||||||
{top_merchants.length > 0 && (
|
|
||||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
|
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
|
||||||
<h3 className="text-sm font-medium mb-4">Top Merchants</h3>
|
<SectionHead
|
||||||
<div className="space-y-3">
|
title={hasPhases ? "On the ground" : "By category"}
|
||||||
{top_merchants.map((m, i) => (
|
note={
|
||||||
<div key={m.merchant} className="flex items-center gap-3">
|
hasPhases
|
||||||
<span className="text-xs text-zinc-600 w-4 tabular-nums text-right">{i + 1}</span>
|
? "Day-to-day spending once you arrived. With the bookings taken out, travel sits among its peers instead of swamping them."
|
||||||
<div className="flex-1 min-w-0">
|
: "This trip has no start date, so there is no departure to split on."
|
||||||
<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 className="space-y-3.5">
|
||||||
</div>
|
{on_ground_categories.map((c) => (
|
||||||
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
<BarRow
|
||||||
<div
|
key={c.category}
|
||||||
className="h-full rounded-full"
|
label={formatCategory(c.category)}
|
||||||
style={{
|
amount={Number(c.amount)}
|
||||||
width: `${(m.amount / maxMerchant) * 100}%`,
|
count={c.count}
|
||||||
backgroundColor: t.color,
|
max={maxOnGround}
|
||||||
opacity: 0.7,
|
sub={onGround > 0 ? `${((Number(c.amount) / onGround) * 100).toFixed(0)}%` : undefined}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -420,7 +548,7 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
|||||||
</div>
|
</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">
|
<div className="text-center py-12 text-zinc-600">
|
||||||
<p className="text-sm">No transactions assigned to this trip yet.</p>
|
<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">
|
<Link href="/transactions" className="text-indigo-400 hover:text-indigo-300 text-sm mt-1 inline-block">
|
||||||
|
|||||||
+89
-1
@@ -927,6 +927,33 @@ export interface TripAnalytics {
|
|||||||
category_breakdown: { category: string; amount: number; count: number }[];
|
category_breakdown: { category: string; amount: number; count: number }[];
|
||||||
daily_spend: { date: string; amount: number }[];
|
daily_spend: { date: string; amount: number }[];
|
||||||
top_merchants: { merchant: string; amount: number; count: 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 }[];
|
tag_breakdown: { tag_id: number; name: string; color: string; amount: number; count: number }[];
|
||||||
participant_splits: {
|
participant_splits: {
|
||||||
participant_id: number;
|
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
|
// COUNT(*) deliberately still counts refund rows: a refund is a transaction
|
||||||
// that occurred on the trip, even though it subtracts from the total.
|
// 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 }>(`
|
queryRaw<{ category: string; amount: number; count: number }>(`
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(o.category_override, t.category, 'other') AS category,
|
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
|
OR paid_to_me.pid IS NOT NULL OR paid_by_me.pid IS NOT NULL
|
||||||
ORDER BY 3 DESC
|
ORDER BY 3 DESC
|
||||||
`, [tripId, viewerId]),
|
`, [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)
|
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,
|
tag_breakdown: tagRows,
|
||||||
participant_splits: splitRows,
|
participant_splits: splitRows,
|
||||||
viewer_is_owner: trip.owner_id === viewerId,
|
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