Files
finance-app/src/app/trips/[id]/page.tsx
T
siddharthd 3f04cbd5e7
ci / lint-test (push) Successful in 35s
fix(trips): stop reporting a settlement breakdown that cannot be computed
The trip view showed Total Owed / Settled / Unsettled per participant, with the
last two derived from transaction_splits.settled. Nothing sets that flag - its
only writer was /api/splits/settle, which no UI calls - so it is false on all 673
splits and every trip reported 100% unsettled, including trips already paid in
full. Molina has paid $20,782.79 against $19,556.07 of splits and the Europe trip
still showed her entire share outstanding.

A correct per-trip figure is not computable either: split_payments records only
from, to, amount and date, so a payment cannot be attributed to a trip. The trip
view now shows each participant's share and points at Shared for what is actually
owed, which is where settlement genuinely lives.

Also removes /api/splits/settle. It was unreachable from the UI but live on its
URL, and a single call with participant_id would mark every one of that person's
splits settled - writing a flag nothing reads. Settlement will be reintroduced
against settlement contexts (docs/shared-expenses-design.md).

getParticipantBalances is deliberately untouched: it computes splits minus
payments, which is coherent. Excluding settled splits there while still
subtracting the payments that settled them would double-count.
2026-07-26 16:13:20 +10:00

374 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { use, useState } from "react";
import Link from "next/link";
import {
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
} from "recharts";
import { useTripAnalytics, useTrip, useTransactions } from "@/lib/hooks";
import { CreateTripModal } from "@/components/create-trip-modal";
import { formatCategory } from "@/lib/categories";
import { CATEGORY_COLORS, TOOLTIP_STYLE } from "@/lib/category-colors";
function fmtDate(d: string | null) {
if (!d) return null;
return new Date(d).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" });
}
function StatCard({
label,
value,
sub,
color,
}: {
label: string;
value: string;
sub?: string;
color: string;
}) {
return (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 relative overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ backgroundColor: color }} />
<p className="text-xs text-zinc-500 mb-1">{label}</p>
<p className="text-2xl font-semibold tabular-nums">{value}</p>
{sub && <p className="text-xs text-zinc-600 mt-1 truncate">{sub}</p>}
</div>
);
}
function DailyTooltip({ active, payload, label }: { active?: boolean; payload?: { value: number }[]; label?: string }) {
if (!active || !payload?.length) return null;
return (
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs">
<p className="text-zinc-400 mb-1">
{label ? new Date(label).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" }) : ""}
</p>
<p className="text-zinc-100 font-medium">${Number(payload[0].value).toFixed(2)}</p>
</div>
);
}
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 }> }) {
const { id } = use(params);
const tripId = Number(id);
const { data: analytics, isLoading } = useTripAnalytics(tripId);
const { data: trip } = useTrip(tripId);
const [tab, setTab] = useState<"overview" | "transactions">("overview");
const [editModal, setEditModal] = useState(false);
const { data: txData } = useTransactions({ trip_id: id, limit: 500 });
if (isLoading || !analytics) {
return (
<div className="space-y-6">
<div className="h-32 bg-zinc-900 rounded-2xl animate-pulse" />
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => <div key={i} className="h-24 bg-zinc-900 rounded-xl animate-pulse" />)}
</div>
</div>
);
}
const { total_spend, transaction_count, num_days, daily_average, category_breakdown, daily_spend, top_merchants, tag_breakdown, participant_splits } = analytics;
const t = analytics.trip;
const maxMerchant = top_merchants[0]?.amount ?? 1;
const dateRange = t.start_date && t.end_date
? `${fmtDate(t.start_date)} ${fmtDate(t.end_date)}`
: t.start_date
? `From ${fmtDate(t.start_date)}`
: null;
return (
<div className="space-y-6">
{/* Hero */}
<div
className="relative rounded-2xl overflow-hidden p-6"
style={{
background: `linear-gradient(135deg, ${t.color}28 0%, #18181b 60%)`,
borderLeft: `3px solid ${t.color}`,
}}
>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<div className="flex items-center gap-2 mb-1">
<Link href="/trips" className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors">
Trips
</Link>
</div>
<h1 className="text-2xl font-bold">{t.name}</h1>
{dateRange && <p className="text-sm text-zinc-400 mt-1">{dateRange}</p>}
{t.description && <p className="text-sm text-zinc-500 mt-1">{t.description}</p>}
</div>
<button
onClick={() => setEditModal(true)}
className="px-3 py-1.5 bg-zinc-800/80 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm transition-colors flex-shrink-0"
>
Edit Trip
</button>
</div>
</div>
{/* Stat cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatCard label="Total Spend" value={`$${Number(total_spend).toFixed(2)}`} sub="all transactions" color={t.color} />
<StatCard label="Transactions" value={String(transaction_count)} sub="total" color={t.color} />
<StatCard label="Daily Average" value={`$${Number(daily_average).toFixed(2)}`} sub="per day" color={t.color} />
<StatCard label="Days" value={String(num_days)} sub={dateRange ?? "date range"} color={t.color} />
</div>
{/* Tab bar */}
<div className="flex gap-0 border-b border-zinc-800">
{(["overview", "transactions"] as const).map((tabName) => (
<button
key={tabName}
onClick={() => setTab(tabName)}
className={`px-5 py-2.5 text-sm capitalize transition-colors border-b-2 -mb-px ${
tab === tabName
? "border-current text-white font-medium"
: "border-transparent text-zinc-500 hover:text-zinc-300"
}`}
style={tab === tabName ? { borderColor: t.color } : {}}
>
{tabName}
</button>
))}
</div>
{tab === "overview" && (
<div className="space-y-5">
{/* Daily spend */}
{daily_spend.length > 0 && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<h3 className="text-sm font-medium mb-4">Daily Spend</h3>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={daily_spend} margin={{ top: 4, right: 8, bottom: 0, left: 8 }}>
<XAxis
dataKey="date"
tick={{ fill: "#71717a", fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => new Date(v).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fill: "#71717a", fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `$${v}`}
width={52}
/>
<Tooltip content={<DailyTooltip />} cursor={{ fill: "#27272a" }} />
<Bar dataKey="amount" fill={t.color} radius={[3, 3, 0, 0]} maxBarSize={40} opacity={0.85} />
</BarChart>
</ResponsiveContainer>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Category breakdown */}
{category_breakdown.length > 0 && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<h3 className="text-sm font-medium mb-4">By Category</h3>
<ResponsiveContainer width="100%" height={Math.max(120, category_breakdown.length * 32)}>
<BarChart
data={category_breakdown}
layout="vertical"
margin={{ top: 0, right: 60, bottom: 0, left: 100 }}
>
<XAxis type="number" tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${v}`} />
<YAxis
type="category"
dataKey="category"
tick={{ fill: "#a1a1aa", fontSize: 12 }}
axisLine={false}
tickLine={false}
tickFormatter={formatCategory}
width={98}
/>
<Tooltip content={<CategoryTooltip />} cursor={{ fill: "#27272a" }} />
<Bar dataKey="amount" radius={[0, 3, 3, 0]} maxBarSize={22}>
{category_breakdown.map((entry) => (
<Cell key={entry.category} fill={CATEGORY_COLORS[entry.category] || "#6366f1"} opacity={0.85} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}
{/* Top merchants */}
{top_merchants.length > 0 && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<h3 className="text-sm font-medium mb-4">Top Merchants</h3>
<div className="space-y-3">
{top_merchants.map((m, i) => (
<div key={m.merchant} className="flex items-center gap-3">
<span className="text-xs text-zinc-600 w-4 tabular-nums text-right">{i + 1}</span>
<div className="flex-1 min-w-0">
<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>
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full"
style={{
width: `${(m.amount / maxMerchant) * 100}%`,
backgroundColor: t.color,
opacity: 0.7,
}}
/>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Tag breakdown */}
{tag_breakdown.length > 0 && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<h3 className="text-sm font-medium mb-3">By Tag</h3>
<div className="flex flex-wrap gap-2">
{tag_breakdown.map((tag) => (
<div
key={tag.tag_id}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-zinc-800 bg-zinc-800/50"
>
<span className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: tag.color }} />
<span className="text-sm font-medium">{tag.name}</span>
<span className="text-xs text-zinc-500">{tag.count} txns</span>
<span className="text-sm font-mono tabular-nums text-zinc-300">${Number(tag.amount).toFixed(2)}</span>
</div>
))}
</div>
</div>
)}
{/* Participant splits */}
{participant_splits.length > 0 && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl overflow-hidden">
<div className="px-5 py-3 border-b border-zinc-800 flex items-center justify-between">
<h3 className="text-sm font-medium">Participant Splits</h3>
<Link href="/shared" className="text-xs text-zinc-500 hover:text-zinc-300">
View in Shared
</Link>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
{["Person", "Share of this trip"].map((h) => (
<th
key={h}
className={`px-5 py-2.5 text-xs text-zinc-500 font-medium ${h === "Person" ? "text-left" : "text-right"}`}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{participant_splits.map((p) => (
<tr key={p.participant_id} className="border-b border-zinc-800/50 last:border-0">
<td className="px-5 py-3 font-medium">{p.name}</td>
<td className="px-5 py-3 text-right tabular-nums font-mono">${Number(p.owed).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
{/* Settled/unsettled was reported from transaction_splits.settled,
which nothing sets — so every trip showed 100% unsettled forever,
including ones already paid in full. Settlement is tracked across
the whole relationship, not per trip: payments carry no trip
attribution, so a per-trip figure cannot be computed. */}
<p className="px-5 py-2.5 text-xs text-zinc-500 border-t border-zinc-800">
Settlement is tracked across all shared expenses, not per trip
see <Link href="/shared" className="text-zinc-400 hover:text-zinc-200 underline">Shared</Link> for
what is actually owed.
</p>
</div>
)}
{category_breakdown.length === 0 && daily_spend.length === 0 && (
<div className="text-center py-12 text-zinc-600">
<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">
Go to Transactions to assign some
</Link>
</div>
)}
</div>
)}
{tab === "transactions" && (
<div>
{!txData?.data.length ? (
<div className="text-center py-12 text-zinc-600">
<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">
Go to Transactions to assign some
</Link>
</div>
) : (
<div className="border border-zinc-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900/60">
{["Date", "Description", "Merchant", "Category", "Amount"].map((h) => (
<th
key={h}
className={`px-4 py-2.5 text-xs text-zinc-500 font-medium ${h === "Amount" ? "text-right" : "text-left"}`}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{txData.data.map((tx) => (
<tr key={tx.id} className="border-b border-zinc-800/40 last:border-0 hover:bg-zinc-900/40 transition-colors">
<td className="px-4 py-2.5 text-xs text-zinc-400 whitespace-nowrap">
{new Date(tx.transaction_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })}
</td>
<td className="px-4 py-2.5 max-w-xs truncate text-zinc-300">{tx.description}</td>
<td className="px-4 py-2.5 text-zinc-400 truncate">{tx.effective_merchant || "—"}</td>
<td className="px-4 py-2.5 text-zinc-500 text-xs">{formatCategory(tx.effective_category)}</td>
<td className="px-4 py-2.5 text-right tabular-nums font-mono text-red-400">
${Number(tx.amount).toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{editModal && trip && (
<CreateTripModal trip={trip} onClose={() => setEditModal(false)} />
)}
</div>
);
}