ci / lint-test (push) Failing after 44s
Six metric-integrity defects from the UI/IA review, plus two found while verifying the review's own claims against the code. The reconciled-row exclusion existed only in queries.ts. Every analytics route counted the superseded manual rows as spend — 48 rows, $4,474.79 of double count, invisible precisely because the transaction list looked right. It is now one fragment both sides import. The spend-pace chart computed its own totals in the browser: gross amounts, debits only, no personal share, no refunds, fees, interest or itemised loan repayments. On live data it ended July at $4,747.31 under a headline reading $3,597.10 — and its own baseline line was drawn from the split-adjusted monthly totals, so the two series in one chart disagreed with each other. Both now come from /api/analytics/daily, built from the same fragments as the headline. Fees aggregated every statement ever imported with no date filter, under a heading with no period, so a lifetime figure read as a current one and grew forever. Now bounded, labelled, and selectable. Comparisons no longer measure a month in progress against complete ones: the in-progress month is out of every baseline, and a selected current month is compared through the same day. Two the review did not catch: - Every analytics window was a day early. toISOString() on a local-midnight Date converts backwards through UTC. Surfaced only once fees started reporting the range it had used. - /monthly rounded per category, /daily per category-day, so the pace chart ended a few cents off the headline above it. Shared currency needed amending rather than applying. Reading s.currency would have labelled every order row AUD, since an order receipt has no statement and carries its own currency — the opposite convention from a foreign charge on an AUD statement, where amount IS AUD. NATIVE_CURRENCY's COALESCE order keeps the two apart. Balances also now count rows whose AUD value is genuinely unknown instead of netting a foreign figure against AUD ones. Latent today: no foreign transaction is currently split. Tag-filtered balance cards no longer claim "owes you". With a filter on, payments are deliberately not subtracted, so the figure is a split total and settling against it would record a payment for a debt that never was. Split-coverage warnings deliberately omitted (user decision).
555 lines
28 KiB
TypeScript
555 lines
28 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useState } from "react";
|
||
import {
|
||
ComposedChart, Bar, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||
} from "recharts";
|
||
import { useMonthlyAnalytics, useSubscriptions, useFees, useTransactions, useUpdateTransaction } from "@/lib/hooks";
|
||
import { CATEGORIES, REGULAR_CATEGORIES, formatCategory } from "@/lib/categories";
|
||
import { CHART, TOOLTIP_STYLE } from "@/lib/category-colors";
|
||
|
||
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
|
||
|
||
/**
|
||
* The API returns an exclusive upper bound (first day of the month after the
|
||
* window). Showing that date verbatim would claim a month the totals exclude,
|
||
* so `exclusive` steps back a day for display.
|
||
*/
|
||
function formatPeriodBound(iso: string | null, exclusive = false): string {
|
||
if (!iso) return "—";
|
||
const d = new Date(`${iso}T00:00:00`);
|
||
if (exclusive) d.setDate(d.getDate() - 1);
|
||
return d.toLocaleDateString("default", { month: "short", year: "numeric" });
|
||
}
|
||
|
||
function fmt(n: number) {
|
||
return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", maximumFractionDigits: 0 }).format(n);
|
||
}
|
||
function fmtTx(amount: number, type: string) {
|
||
const formatted = new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", minimumFractionDigits: 2 }).format(amount);
|
||
return SPEND_TYPES.has(type) ? formatted : `+${formatted}`;
|
||
}
|
||
function fmtExact(n: number) {
|
||
return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", minimumFractionDigits: 2 }).format(n);
|
||
}
|
||
function fmtDate(d: string) {
|
||
return new Date(d).toLocaleDateString("en-AU", { month: "short", year: "numeric" });
|
||
}
|
||
function trend(values: number[]): { pct: number; dir: "up" | "down" | "flat" } {
|
||
if (values.length < 2) return { pct: 0, dir: "flat" };
|
||
const recent = values.slice(-3).reduce((a, b) => a + b, 0) / 3;
|
||
const prior = values.slice(0, 3).reduce((a, b) => a + b, 0) / 3;
|
||
if (prior === 0) return { pct: 0, dir: "flat" };
|
||
const pct = Math.round(((recent - prior) / prior) * 100);
|
||
return { pct: Math.abs(pct), dir: pct > 2 ? "up" : pct < -2 ? "down" : "flat" };
|
||
}
|
||
|
||
const FREQ_LABEL: Record<string, string> = {
|
||
weekly: "Weekly",
|
||
fortnightly: "Fortnightly",
|
||
monthly: "Monthly",
|
||
quarterly: "Quarterly",
|
||
annual: "Annual",
|
||
};
|
||
|
||
// ─── Section wrapper ────────────────────────────────────────────────
|
||
function Section({ title, aside, children }: { title: string; aside?: React.ReactNode; children: React.ReactNode }) {
|
||
return (
|
||
<div className="mb-8">
|
||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 mb-3">
|
||
<h3 className="text-base font-semibold text-zinc-200">{title}</h3>
|
||
{aside}
|
||
</div>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Custom tooltip ──────────────────────────────────────────────────
|
||
function RegularTooltip({ active, payload, label }: { active?: boolean; payload?: { dataKey: string; value: number }[]; label?: string }) {
|
||
if (!active || !payload?.length) return null;
|
||
const regular = payload.find((p) => p.dataKey === "regular")?.value ?? 0;
|
||
const occasional = payload.find((p) => p.dataKey === "occasional")?.value ?? 0;
|
||
return (
|
||
<div style={TOOLTIP_STYLE} className="px-3 py-2 text-xs space-y-1">
|
||
<div className="font-medium text-zinc-300 mb-1">{label}</div>
|
||
<div className="flex justify-between gap-4"><span className="text-indigo-300">Regular</span><span className="font-mono">{fmt(regular)}</span></div>
|
||
<div className="flex justify-between gap-4"><span className="text-zinc-400">Occasional</span><span className="font-mono">{fmt(occasional)}</span></div>
|
||
<div className="flex justify-between gap-4 border-t border-zinc-700 pt-1"><span className="text-zinc-500">Total</span><span className="font-mono">{fmt(regular + occasional)}</span></div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Drill-down row ──────────────────────────────────────────────────
|
||
function DrillDownRow({
|
||
category,
|
||
from,
|
||
to,
|
||
}: {
|
||
category: string;
|
||
from: string;
|
||
to: string;
|
||
}) {
|
||
const { data, isLoading } = useTransactions({ categories: category ? [category] : [], from, to, limit: 200 });
|
||
const updateTx = useUpdateTransaction();
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<tr>
|
||
<td colSpan={2} className="px-4 py-3 text-xs text-zinc-500">Loading...</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
const txns = data?.data ?? [];
|
||
|
||
return (
|
||
<tr>
|
||
<td colSpan={2} className="px-0 py-0">
|
||
<div className="bg-zinc-950 border-t border-zinc-800/50">
|
||
{txns.length === 0 ? (
|
||
<p className="px-6 py-3 text-xs text-zinc-600">No transactions found.</p>
|
||
) : (
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="border-b border-zinc-800">
|
||
<th className="text-left px-6 py-2 text-zinc-600 font-medium">Date</th>
|
||
<th className="text-left px-4 py-2 text-zinc-600 font-medium">Merchant</th>
|
||
<th className="text-right px-4 py-2 text-zinc-600 font-medium">Total</th>
|
||
<th className="text-right px-4 py-2 text-zinc-600 font-medium">My share</th>
|
||
<th className="text-left px-4 py-2 text-zinc-600 font-medium">Category</th>
|
||
<th className="text-right px-4 py-2 text-zinc-600 font-medium">% mine</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{txns.map((t) => {
|
||
// Share is resolved server-side (see getTransactions) so this
|
||
// matches the category total being drilled into. The old
|
||
// `my_share_percent ?? 100` missed transaction_splits rows
|
||
// entirely and assumed 100% when a transaction was allocated
|
||
// wholly to someone else.
|
||
const sharePct = Number(t.my_share_pct ?? 100);
|
||
const grossAmt = Number(t.amount_aud ?? t.amount);
|
||
const effectiveAmt = Number(t.my_amount ?? grossAmt);
|
||
const isDebit = SPEND_TYPES.has(t.transaction_type);
|
||
return (
|
||
<tr key={t.id} className="border-b border-zinc-800/30 hover:bg-zinc-900/30">
|
||
<td className="px-6 py-2 text-zinc-500 whitespace-nowrap">
|
||
{new Date(t.transaction_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })}
|
||
</td>
|
||
<td className="px-4 py-2 text-zinc-300">{t.merchant_name || t.description}</td>
|
||
<td className="px-4 py-2 text-right tabular-nums text-zinc-500">
|
||
{fmtExact(grossAmt)}
|
||
</td>
|
||
<td className={`px-4 py-2 text-right tabular-nums ${isDebit ? "text-zinc-200" : "text-green-400"}`}>
|
||
{fmtTx(effectiveAmt, t.transaction_type)}
|
||
{sharePct < 100 && (
|
||
<span className="text-zinc-600 ml-1">({sharePct}%)</span>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-2">
|
||
<select
|
||
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-0.5 text-xs text-zinc-300 focus:outline-none focus:border-indigo-500"
|
||
defaultValue={t.effective_category ?? "other"}
|
||
onChange={(e) => updateTx.mutate({ id: t.id, category: e.target.value })}
|
||
>
|
||
{CATEGORIES.map((c) => (
|
||
<option key={c} value={c}>{formatCategory(c)}</option>
|
||
))}
|
||
</select>
|
||
</td>
|
||
<td className="px-4 py-2 text-right">
|
||
<select
|
||
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-0.5 text-xs text-zinc-300 focus:outline-none focus:border-indigo-500"
|
||
defaultValue={t.my_share_percent ?? 100}
|
||
onChange={(e) => {
|
||
const val = Number(e.target.value);
|
||
updateTx.mutate({ id: t.id, my_share_percent: val === 100 ? null : val });
|
||
}}
|
||
>
|
||
<option value={100}>100%</option>
|
||
<option value={75}>75%</option>
|
||
<option value={67}>67%</option>
|
||
<option value={50}>50%</option>
|
||
<option value={33}>33%</option>
|
||
<option value={25}>25%</option>
|
||
</select>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
// ─── Monthly Spend Breakdown ─────────────────────────────────────────
|
||
function MonthlyBreakdown({ analytics }: { analytics: NonNullable<ReturnType<typeof useMonthlyAnalytics>["data"]> }) {
|
||
// analytics.months is newest-first; show last 6
|
||
const months = useMemo(() => analytics.months.slice(0, 6), [analytics.months]);
|
||
const [selectedMonth, setSelectedMonth] = useState<string>(months[0] ?? "");
|
||
const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
|
||
|
||
// Reset expanded when month changes
|
||
const handleSelectMonth = (m: string) => {
|
||
setSelectedMonth(m);
|
||
setExpandedCategory(null);
|
||
};
|
||
|
||
const from = selectedMonth + "-01";
|
||
const lastDay = new Date(parseInt(selectedMonth.slice(0, 4)), parseInt(selectedMonth.slice(5, 7)), 0).getDate();
|
||
const to = selectedMonth + "-" + String(lastDay).padStart(2, "0");
|
||
|
||
const categoryData = useMemo(() => {
|
||
return analytics.rows
|
||
.map((row) => ({ category: row.category, amount: Number(row.spent[selectedMonth] ?? 0) }))
|
||
.filter((r) => r.amount > 0)
|
||
.sort((a, b) => b.amount - a.amount);
|
||
}, [analytics.rows, selectedMonth]);
|
||
|
||
const regularRows = categoryData.filter((r) => (REGULAR_CATEGORIES as Set<string>).has(r.category));
|
||
const occasionalRows = categoryData.filter((r) => !(REGULAR_CATEGORIES as Set<string>).has(r.category));
|
||
const regularTotal = regularRows.reduce((s, r) => s + r.amount, 0);
|
||
const occasionalTotal = occasionalRows.reduce((s, r) => s + r.amount, 0);
|
||
|
||
function monthLabel(m: string) {
|
||
const [year, month] = m.split("-");
|
||
return new Date(parseInt(year), parseInt(month) - 1).toLocaleDateString("en-AU", { month: "short", year: "2-digit" });
|
||
}
|
||
|
||
function renderRows(rows: typeof categoryData, dotClass: string) {
|
||
return rows.map((row) => (
|
||
<>
|
||
<tr
|
||
key={row.category}
|
||
className="border-b border-zinc-800/50 hover:bg-zinc-800/20 cursor-pointer transition-colors"
|
||
onClick={() => setExpandedCategory(expandedCategory === row.category ? null : row.category)}
|
||
>
|
||
<td className="px-4 py-2.5">
|
||
<span className="flex items-center gap-2 text-sm text-zinc-300">
|
||
<span className={`w-2 h-2 rounded-full inline-block flex-shrink-0 ${dotClass}`} />
|
||
{formatCategory(row.category)}
|
||
<span className="text-zinc-600 text-xs">{expandedCategory === row.category ? "▲" : "▼"}</span>
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-2.5 text-right tabular-nums text-sm text-zinc-200">{fmt(row.amount)}</td>
|
||
</tr>
|
||
{expandedCategory === row.category && (
|
||
<DrillDownRow
|
||
key={`${row.category}-drill`}
|
||
category={row.category}
|
||
from={from}
|
||
to={to}
|
||
/>
|
||
)}
|
||
</>
|
||
));
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
{/* Month tabs */}
|
||
<div className="flex gap-1 mb-3 flex-wrap">
|
||
{months.map((m) => (
|
||
<button
|
||
key={m}
|
||
onClick={() => handleSelectMonth(m)}
|
||
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||
m === selectedMonth
|
||
? "bg-indigo-600 text-white"
|
||
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
|
||
}`}
|
||
>
|
||
{monthLabel(m)}
|
||
</button>
|
||
))}
|
||
</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">
|
||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Category</th>
|
||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Spend</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{regularRows.length > 0 && (
|
||
<>
|
||
<tr className="bg-zinc-900/30">
|
||
<td className="px-4 py-1.5 text-xs text-indigo-400 font-medium tracking-wide uppercase">Regular</td>
|
||
<td className="px-4 py-1.5 text-right text-xs text-indigo-400 font-medium">{fmt(regularTotal)}</td>
|
||
</tr>
|
||
{renderRows(regularRows, "bg-indigo-500")}
|
||
</>
|
||
)}
|
||
{occasionalRows.length > 0 && (
|
||
<>
|
||
<tr className="bg-zinc-900/30">
|
||
<td className="px-4 py-1.5 text-xs text-zinc-500 font-medium tracking-wide uppercase">Occasional</td>
|
||
<td className="px-4 py-1.5 text-right text-xs text-zinc-500 font-medium">{fmt(occasionalTotal)}</td>
|
||
</tr>
|
||
{renderRows(occasionalRows, "bg-zinc-500")}
|
||
</>
|
||
)}
|
||
{categoryData.length === 0 && (
|
||
<tr>
|
||
<td colSpan={2} className="px-4 py-6 text-center text-xs text-zinc-600">No spend data for this month.</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ─── Main page ────────────────────────────────────────────────────────
|
||
export default function InsightsPage() {
|
||
const { data: analytics } = useMonthlyAnalytics(12);
|
||
const { data: analytics6 } = useMonthlyAnalytics(6);
|
||
const { data: subData } = useSubscriptions();
|
||
const [feeMonths, setFeeMonths] = useState(12);
|
||
const { data: feesData } = useFees(feeMonths);
|
||
|
||
// Build regular/occasional chart data
|
||
const chartData = useMemo(() => {
|
||
if (!analytics) return [];
|
||
return [...analytics.months].reverse().map((month) => {
|
||
let regular = 0;
|
||
let occasional = 0;
|
||
for (const row of analytics.rows) {
|
||
const spend = Number(row.spent[month] ?? 0);
|
||
if ((REGULAR_CATEGORIES as Set<string>).has(row.category)) regular += spend;
|
||
else occasional += spend;
|
||
}
|
||
return {
|
||
month: month.slice(5) + "/" + month.slice(2, 4),
|
||
regular: Math.round(regular),
|
||
occasional: Math.round(occasional),
|
||
total: Math.round(regular + occasional),
|
||
};
|
||
});
|
||
}, [analytics]);
|
||
|
||
const regularValues = chartData.map((d) => d.regular);
|
||
const regularTrend = trend(regularValues);
|
||
const avgRegular = regularValues.length
|
||
? Math.round(regularValues.reduce((a, b) => a + b, 0) / regularValues.length)
|
||
: 0;
|
||
const latestRegular = regularValues[regularValues.length - 1] ?? 0;
|
||
|
||
const activeSubscriptions = subData?.subscriptions.filter((s) => s.is_active) ?? [];
|
||
const inactiveSubscriptions = subData?.subscriptions.filter((s) => !s.is_active) ?? [];
|
||
|
||
return (
|
||
<div className="max-w-4xl">
|
||
<p className="text-[11px] uppercase tracking-[0.18em] text-zinc-500 mb-1">Ledger · patterns</p>
|
||
<h2 className="text-2xl font-display mb-6">Insights</h2>
|
||
|
||
{/* ── 1. Regular vs Occasional ── */}
|
||
<Section title="Regular vs occasional spend">
|
||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl grid grid-cols-3 divide-x divide-zinc-800 overflow-hidden mb-4">
|
||
<div className="px-4 py-3.5">
|
||
<div className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">This month · regular</div>
|
||
<div className="text-xl font-mono tabular-nums text-indigo-300">{fmt(latestRegular)}</div>
|
||
</div>
|
||
<div className="px-4 py-3.5">
|
||
<div className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">12-month average</div>
|
||
<div className="text-xl font-mono tabular-nums text-zinc-200">{fmt(avgRegular)}</div>
|
||
</div>
|
||
<div className="px-4 py-3.5">
|
||
<div className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">Trend · first 3 vs last 3</div>
|
||
<div className={`text-xl font-mono tabular-nums ${regularTrend.dir === "up" ? "text-red-400" : regularTrend.dir === "down" ? "text-emerald-400" : "text-zinc-400"}`}>
|
||
{regularTrend.dir === "up" ? "↑" : regularTrend.dir === "down" ? "↓" : "→"} {regularTrend.pct}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
|
||
<ResponsiveContainer width="100%" height={220}>
|
||
<ComposedChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||
<XAxis dataKey="month" tick={{ fill: CHART.axis, fontSize: 11 }} axisLine={false} tickLine={false} />
|
||
<YAxis tick={{ fill: CHART.axis, fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} width={44} />
|
||
<Tooltip content={<RegularTooltip />} cursor={{ fill: "rgba(232,224,204,0.04)" }} />
|
||
<Bar dataKey="regular" stackId="a" fill={CHART.accent} name="Regular" radius={[0, 0, 0, 0]} />
|
||
<Bar dataKey="occasional" stackId="a" fill={CHART.dim} name="Occasional" radius={[3, 3, 0, 0]} />
|
||
<Line type="monotone" dataKey="regular" stroke={CHART.accentSoft} strokeWidth={2} dot={false} strokeDasharray="4 2" />
|
||
</ComposedChart>
|
||
</ResponsiveContainer>
|
||
<div className="flex gap-4 mt-2 justify-end">
|
||
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm inline-block" style={{ background: CHART.accent }} />Regular (groceries, dining, transport…)</span>
|
||
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm inline-block" style={{ background: CHART.dim }} />Occasional</span>
|
||
</div>
|
||
</div>
|
||
</Section>
|
||
|
||
{/* ── 2. Monthly Spend Breakdown ── */}
|
||
<Section title="Monthly spend breakdown">
|
||
{!analytics6 ? (
|
||
<p className="text-zinc-500 text-sm">Loading...</p>
|
||
) : (
|
||
<MonthlyBreakdown analytics={analytics6} />
|
||
)}
|
||
</Section>
|
||
|
||
{/* ── 3. Recurring Charges ── */}
|
||
<Section title="Recurring charges">
|
||
{!subData ? (
|
||
<p className="text-zinc-500 text-sm">Loading...</p>
|
||
) : subData.subscriptions.length === 0 ? (
|
||
<p className="text-zinc-500 text-sm">No recurring patterns detected yet — more transaction history needed.</p>
|
||
) : (
|
||
<>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-xs text-zinc-500">{activeSubscriptions.length} active · {inactiveSubscriptions.length} inactive</span>
|
||
<span className="text-sm font-medium text-indigo-400">{fmtExact(subData.total_monthly_equiv)}<span className="text-xs text-zinc-500 font-normal ml-1">/ month committed</span></span>
|
||
</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">
|
||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Merchant</th>
|
||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Category</th>
|
||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Frequency</th>
|
||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">My $/mo equiv</th>
|
||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Avg charge</th>
|
||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Since</th>
|
||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Total paid</th>
|
||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Count</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{[...activeSubscriptions, ...inactiveSubscriptions].map((s) => (
|
||
<tr key={s.merchant} className={`border-b border-zinc-800/50 ${s.is_active ? "hover:bg-zinc-800/20" : "opacity-40"} transition-colors`}>
|
||
<td className="px-4 py-3 font-medium">{s.merchant}</td>
|
||
<td className="px-4 py-3 text-zinc-400 text-xs">{formatCategory(s.category ?? "other")}</td>
|
||
<td className="px-4 py-3">
|
||
<span className={`text-xs px-2 py-0.5 rounded-full ${s.is_active ? "bg-indigo-900/40 text-indigo-300" : "bg-zinc-800 text-zinc-500"}`}>
|
||
{FREQ_LABEL[s.frequency] ?? s.frequency}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3 text-right tabular-nums text-zinc-200">{fmtExact(s.monthly_equiv)}</td>
|
||
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{fmtExact(s.avg_amount)}</td>
|
||
<td className="px-4 py-3 text-right text-zinc-500 text-xs whitespace-nowrap">{fmtDate(s.first_seen)}</td>
|
||
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{fmtExact(s.total_paid)}</td>
|
||
<td className="px-4 py-3 text-right text-zinc-500">{s.occurrences}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</>
|
||
)}
|
||
</Section>
|
||
|
||
{/* ── 4. Fees & Interest ── */}
|
||
<Section
|
||
title="Fees & interest"
|
||
aside={
|
||
<div className="flex items-center gap-3">
|
||
{feesData?.period && (
|
||
<span className="text-xs text-zinc-500 tabular-nums">
|
||
{feesData.period.all_time
|
||
? "All time"
|
||
: `${formatPeriodBound(feesData.period.from)} – ${formatPeriodBound(feesData.period.to, true)}`}
|
||
</span>
|
||
)}
|
||
<select
|
||
value={feeMonths}
|
||
onChange={(e) => setFeeMonths(Number(e.target.value))}
|
||
className="bg-zinc-900 border border-zinc-800 rounded px-2 py-1 text-xs text-zinc-300 focus:outline-none focus:border-indigo-500 cursor-pointer"
|
||
aria-label="Fees period"
|
||
>
|
||
<option value={3}>Last 3 months</option>
|
||
<option value={6}>Last 6 months</option>
|
||
<option value={12}>Last 12 months</option>
|
||
<option value={24}>Last 24 months</option>
|
||
<option value={0}>All time</option>
|
||
</select>
|
||
</div>
|
||
}
|
||
>
|
||
{!feesData ? (
|
||
<p className="text-zinc-500 text-sm">Loading...</p>
|
||
) : feesData.by_bank.length === 0 && feesData.transactions.length === 0 ? (
|
||
<p className="text-zinc-500 text-sm">
|
||
No fees or interest recorded {feesData.period?.all_time ? "on any statement" : "in this period"}.
|
||
</p>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{feesData.by_bank.length > 0 && (
|
||
<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">
|
||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Bank</th>
|
||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Fees</th>
|
||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Interest</th>
|
||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Total</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{feesData.by_bank.map((r) => (
|
||
<tr key={r.bank_name} className="border-b border-zinc-800/50">
|
||
<td className="px-4 py-3 font-medium">{r.bank_name}</td>
|
||
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{r.fees > 0 ? fmtExact(r.fees) : <span className="text-zinc-700">—</span>}</td>
|
||
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{r.interest > 0 ? fmtExact(r.interest) : <span className="text-zinc-700">—</span>}</td>
|
||
<td className="px-4 py-3 text-right tabular-nums text-red-400 font-medium">{fmtExact(r.total)}</td>
|
||
</tr>
|
||
))}
|
||
<tr className="bg-zinc-900/50">
|
||
<td className="px-4 py-2.5 text-xs text-zinc-500 font-medium">Total</td>
|
||
<td className="px-4 py-2.5 text-right tabular-nums text-xs text-zinc-400">{fmtExact(feesData.total_fees)}</td>
|
||
<td className="px-4 py-2.5 text-right tabular-nums text-xs text-zinc-400">{fmtExact(feesData.total_interest)}</td>
|
||
<td className="px-4 py-2.5 text-right tabular-nums text-red-400 font-medium">{fmtExact(feesData.total_fees + feesData.total_interest)}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
{feesData.transactions.length > 0 && (
|
||
<div>
|
||
<p className="text-xs text-zinc-500 mb-2">Individual fee / interest transactions</p>
|
||
<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">
|
||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Date</th>
|
||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Bank</th>
|
||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Description</th>
|
||
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Type</th>
|
||
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Amount</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{feesData.transactions.map((t) => (
|
||
<tr key={t.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/20">
|
||
<td className="px-4 py-2.5 text-zinc-500 text-xs whitespace-nowrap">
|
||
{new Date(t.transaction_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" })}
|
||
</td>
|
||
<td className="px-4 py-2.5 text-zinc-400 text-xs">{t.bank_name}</td>
|
||
<td className="px-4 py-2.5 text-zinc-300">{t.description}</td>
|
||
<td className="px-4 py-2.5">
|
||
<span className={`text-xs px-2 py-0.5 rounded-full ${t.transaction_type === "interest" ? "bg-orange-900/40 text-orange-300" : "bg-zinc-800 text-zinc-400"}`}>
|
||
{t.transaction_type}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-2.5 text-right tabular-nums text-red-400">{fmtExact(t.my_amount)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</Section>
|
||
</div>
|
||
);
|
||
}
|