"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 (
{label}
{value}
{sub &&
{sub}
}
);
}
function DailyTooltip({ active, payload, label }: { active?: boolean; payload?: { value: number }[]; label?: string }) {
if (!active || !payload?.length) return null;
return (
{label ? new Date(label).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" }) : ""}
${Number(payload[0].value).toFixed(2)}
);
}
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);
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 (
{[...Array(4)].map((_, i) =>
)}
);
}
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 (
{/* Hero */}
← Trips
{t.name}
{dateRange &&
{dateRange}
}
{t.description &&
{t.description}
}
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
{/* 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. */}
{/* Tab bar */}
{(["overview", "transactions"] as const).map((tabName) => (
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}
))}
{tab === "overview" && (
{/* Daily spend */}
{daily_spend.length > 0 && (
Daily Spend
new Date(v).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })}
interval="preserveStartEnd"
/>
`$${v}`}
width={52}
/>
} cursor={{ fill: "#27272a" }} />
)}
{/* Category breakdown */}
{category_breakdown.length > 0 && (
By Category
`$${v}`} />
} cursor={{ fill: "#27272a" }} />
{category_breakdown.map((entry) => (
|
))}
)}
{/* Top merchants */}
{top_merchants.length > 0 && (
Top Merchants
{top_merchants.map((m, i) => (
{i + 1}
{m.merchant || "Unknown"}
${Number(m.amount).toFixed(2)}
))}
)}
{/* Tag breakdown */}
{tag_breakdown.length > 0 && (
By Tag
{tag_breakdown.map((tag) => (
{tag.name}
{tag.count} txns
${Number(tag.amount).toFixed(2)}
))}
)}
{/* Participant splits */}
{participant_splits.length > 0 && (
Participant Splits
View in Shared →
{["Person", "Outstanding on this trip"].map((h) => (
{h}
))}
{/* A negative outstanding means they have paid more towards this
trip than their share of it — which reads as a typo unless the
sign is spelled out. Shown as a magnitude plus a word, the same
way Shared does it, so the two pages agree on what a direction
means. */}
{participant_splits.map((p) => {
const owed = Number(p.owed);
const square = Math.abs(owed) < 0.005;
const theyOweMe = owed > 0;
return (
{p.name}
${Math.abs(owed).toFixed(2)}
{square ? "all square" : theyOweMe ? "owes you" : "ahead — you owe them"}
{p.unconverted_count > 0 && (
approx · {p.unconverted_count} unconverted
)}
);
})}
{/* This note used to say a per-trip figure could not be computed,
because payments carried no trip attribution. Migration 0022 added
split_payments.trip_id, so it can and now does — the figures above
are net of payments scoped to this trip. What the note has to say
instead is which payments are NOT in them. */}
Net of payments recorded against this trip. Payments on the ongoing
household tab are not counted here —
see Shared for
the overall balance.
)}
{category_breakdown.length === 0 && daily_spend.length === 0 && (
No transactions assigned to this trip yet.
Go to Transactions to assign some →
)}
)}
{tab === "transactions" && (
{!txData?.data.length ? (
No transactions assigned to this trip yet.
Go to Transactions to assign some →
) : (
{["Date", "Description", "Merchant", "Category", "Amount"].map((h) => (
{h}
))}
{txData.data.map((tx) => (
{new Date(tx.transaction_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })}
{tx.description}
{tx.effective_merchant || "—"}
{formatCategory(tx.effective_category)}
${Number(tx.amount).toFixed(2)}
))}
)}
)}
{editModal && trip && (
setEditModal(false)} />
)}
);
}