2026-07-04 22:48:28 -05:00
|
|
|
|
import { useMemo, useState, type ReactNode } from 'react';
|
|
|
|
|
|
import { formatUSD, formatUSDWhole, asDollars } from '@/lib/money';
|
2026-05-04 13:14:32 -05:00
|
|
|
|
import { Printer, RefreshCw, RotateCcw } from 'lucide-react';
|
2026-07-03 20:04:14 -05:00
|
|
|
|
import { useAnalyticsSummary } from '@/hooks/useQueries';
|
2026-05-04 13:14:32 -05:00
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
interface TrendRow {
|
|
|
|
|
|
month: string;
|
|
|
|
|
|
label?: string;
|
|
|
|
|
|
total: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
interface ExpectedActualRow {
|
|
|
|
|
|
month: string;
|
|
|
|
|
|
label?: string;
|
|
|
|
|
|
expected: number;
|
|
|
|
|
|
actual: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
interface CategorySpendRow {
|
|
|
|
|
|
category_name: string;
|
|
|
|
|
|
total: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
interface HeatmapCell {
|
|
|
|
|
|
month: string;
|
|
|
|
|
|
status: string;
|
|
|
|
|
|
amount_paid?: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
interface HeatmapMonth {
|
|
|
|
|
|
key: string;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
interface HeatmapRow {
|
|
|
|
|
|
bill_id: number;
|
|
|
|
|
|
bill_name: string;
|
|
|
|
|
|
category_name?: string;
|
|
|
|
|
|
cells: HeatmapCell[];
|
|
|
|
|
|
}
|
|
|
|
|
|
interface HeatmapData {
|
|
|
|
|
|
rows?: HeatmapRow[];
|
|
|
|
|
|
months?: HeatmapMonth[];
|
|
|
|
|
|
}
|
|
|
|
|
|
interface ForecastRow {
|
|
|
|
|
|
month: string;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
total: number;
|
|
|
|
|
|
low: number;
|
|
|
|
|
|
high: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
interface AnalyticsRange {
|
|
|
|
|
|
start?: string;
|
|
|
|
|
|
end?: string;
|
|
|
|
|
|
}
|
2026-07-04 22:48:28 -05:00
|
|
|
|
interface AnalyticsData {
|
|
|
|
|
|
monthly_spending?: TrendRow[];
|
|
|
|
|
|
expected_vs_actual?: ExpectedActualRow[];
|
|
|
|
|
|
category_spend?: CategorySpendRow[];
|
|
|
|
|
|
heatmap?: HeatmapData;
|
|
|
|
|
|
categories?: { id: number | string; name: string }[];
|
|
|
|
|
|
bills?: { id: number | string; name: string; active?: number }[];
|
|
|
|
|
|
range?: AnalyticsRange;
|
|
|
|
|
|
generated_at?: string;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface ChartVisibility {
|
|
|
|
|
|
monthlyTrend: boolean;
|
|
|
|
|
|
expectedActual: boolean;
|
|
|
|
|
|
categorySpend: boolean;
|
|
|
|
|
|
heatmap: boolean;
|
|
|
|
|
|
forecast: boolean;
|
|
|
|
|
|
[key: string]: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-04 13:14:32 -05:00
|
|
|
|
const RANGE_OPTIONS = [6, 12, 24, 36];
|
2026-07-04 22:48:28 -05:00
|
|
|
|
const MONTH_OPTIONS: [string, string][] = [
|
2026-07-06 14:23:53 -05:00
|
|
|
|
['1', 'January'],
|
|
|
|
|
|
['2', 'February'],
|
|
|
|
|
|
['3', 'March'],
|
|
|
|
|
|
['4', 'April'],
|
|
|
|
|
|
['5', 'May'],
|
|
|
|
|
|
['6', 'June'],
|
|
|
|
|
|
['7', 'July'],
|
|
|
|
|
|
['8', 'August'],
|
|
|
|
|
|
['9', 'September'],
|
|
|
|
|
|
['10', 'October'],
|
|
|
|
|
|
['11', 'November'],
|
|
|
|
|
|
['12', 'December'],
|
2026-05-04 13:14:32 -05:00
|
|
|
|
];
|
2026-07-04 22:48:28 -05:00
|
|
|
|
const CHART_OPTIONS: [string, string][] = [
|
2026-05-04 13:14:32 -05:00
|
|
|
|
['monthlyTrend', 'Monthly trend'],
|
|
|
|
|
|
['expectedActual', 'Expected vs actual'],
|
|
|
|
|
|
['categorySpend', 'Category spend'],
|
|
|
|
|
|
['heatmap', 'Pay heatmap'],
|
2026-05-28 21:11:29 -05:00
|
|
|
|
['forecast', 'Spending forecast'],
|
2026-05-04 13:14:32 -05:00
|
|
|
|
];
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const PALETTE = [
|
|
|
|
|
|
'#7c3aed',
|
|
|
|
|
|
'#10b981',
|
|
|
|
|
|
'#ec4899',
|
|
|
|
|
|
'#3b82f6',
|
|
|
|
|
|
'#f59e0b',
|
|
|
|
|
|
'#14b8a6',
|
|
|
|
|
|
'#ef4444',
|
|
|
|
|
|
'#8b5cf6',
|
|
|
|
|
|
];
|
2026-05-04 13:14:32 -05:00
|
|
|
|
|
|
|
|
|
|
function currentMonth() {
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
return { year: now.getFullYear(), month: now.getMonth() + 1 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 22:48:28 -05:00
|
|
|
|
function money(value: number | null | undefined): string {
|
|
|
|
|
|
return formatUSDWhole(asDollars(Number(value) || 0));
|
2026-05-04 13:14:32 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 22:48:28 -05:00
|
|
|
|
function fullMoney(value: number | null | undefined): string {
|
|
|
|
|
|
return formatUSD(asDollars(Number(value) || 0));
|
2026-05-04 13:14:32 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 22:48:28 -05:00
|
|
|
|
function formatRange(range?: AnalyticsRange): string {
|
2026-05-04 13:14:32 -05:00
|
|
|
|
if (!range?.start || !range?.end) return 'Selected range';
|
|
|
|
|
|
return `${range.start.slice(0, 7)} through ${range.end.slice(0, 7)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 22:48:28 -05:00
|
|
|
|
function hasData(rows: readonly unknown[] | undefined, keys: string[]): boolean {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
return !!rows?.some((row) =>
|
|
|
|
|
|
keys.some((key) => Number((row as Record<string, unknown>)[key]) > 0),
|
|
|
|
|
|
);
|
2026-05-04 13:14:32 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 22:48:28 -05:00
|
|
|
|
function EmptyState({ label = 'No analytics data for this selection.' }: { label?: string }) {
|
2026-05-04 13:14:32 -05:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex min-h-[220px] items-center justify-center rounded-lg border border-dashed border-border/70 bg-muted/20 px-4 text-center text-sm text-muted-foreground">
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
function ChartCard({
|
|
|
|
|
|
title,
|
|
|
|
|
|
subtitle,
|
|
|
|
|
|
children,
|
|
|
|
|
|
summary,
|
|
|
|
|
|
}: {
|
2026-07-04 22:48:28 -05:00
|
|
|
|
title: ReactNode;
|
|
|
|
|
|
subtitle?: ReactNode;
|
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
|
summary?: ReactNode;
|
|
|
|
|
|
}) {
|
2026-05-04 13:14:32 -05:00
|
|
|
|
return (
|
|
|
|
|
|
<section className="analytics-chart surface-elevated p-5">
|
|
|
|
|
|
<div className="mb-4 flex items-start justify-between gap-4">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2 className="text-sm font-semibold tracking-tight">{title}</h2>
|
|
|
|
|
|
{subtitle && <p className="mt-0.5 text-xs text-muted-foreground">{subtitle}</p>}
|
|
|
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
{summary && (
|
|
|
|
|
|
<div className="shrink-0 text-right text-sm font-semibold tabular-nums">{summary}</div>
|
|
|
|
|
|
)}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
function SvgFrame({
|
|
|
|
|
|
children,
|
|
|
|
|
|
height = 260,
|
|
|
|
|
|
label = 'Chart',
|
|
|
|
|
|
}: {
|
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
|
height?: number;
|
|
|
|
|
|
label?: string;
|
|
|
|
|
|
}) {
|
2026-05-04 13:14:32 -05:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="w-full overflow-hidden rounded-lg border border-border/60 bg-background/60">
|
2026-07-02 20:36:09 -05:00
|
|
|
|
<svg viewBox={`0 0 720 ${height}`} role="img" aria-label={label} className="h-auto w-full">
|
2026-05-04 13:14:32 -05:00
|
|
|
|
{children}
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 22:48:28 -05:00
|
|
|
|
function LineChart({ rows, area = false }: { rows: TrendRow[]; area?: boolean }) {
|
2026-05-04 13:14:32 -05:00
|
|
|
|
if (!hasData(rows, ['total'])) return <EmptyState />;
|
|
|
|
|
|
|
|
|
|
|
|
const width = 720;
|
|
|
|
|
|
const height = 260;
|
|
|
|
|
|
const pad = { left: 58, right: 24, top: 24, bottom: 46 };
|
|
|
|
|
|
const chartW = width - pad.left - pad.right;
|
|
|
|
|
|
const chartH = height - pad.top - pad.bottom;
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const max = Math.max(...rows.map((r) => r.total), 1);
|
2026-05-04 13:14:32 -05:00
|
|
|
|
const points = rows.map((row, index) => {
|
|
|
|
|
|
const x = pad.left + (rows.length === 1 ? chartW / 2 : (index / (rows.length - 1)) * chartW);
|
|
|
|
|
|
const y = pad.top + chartH - (row.total / max) * chartH;
|
|
|
|
|
|
return { ...row, x, y };
|
|
|
|
|
|
});
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const line = points.map((p) => `${p.x},${p.y}`).join(' ');
|
2026-05-04 13:14:32 -05:00
|
|
|
|
const areaPoints = `${pad.left},${pad.top + chartH} ${line} ${pad.left + chartW},${pad.top + chartH}`;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-07-02 20:36:09 -05:00
|
|
|
|
<SvgFrame height={height} label="Trend line chart">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
{[0, 0.25, 0.5, 0.75, 1].map((tick) => {
|
2026-05-04 13:14:32 -05:00
|
|
|
|
const y = pad.top + chartH - tick * chartH;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<g key={tick}>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<line
|
|
|
|
|
|
x1={pad.left}
|
|
|
|
|
|
x2={pad.left + chartW}
|
|
|
|
|
|
y1={y}
|
|
|
|
|
|
y2={y}
|
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
|
opacity="0.09"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<text x="12" y={y + 4} fontSize="12" fill="currentColor" opacity="0.58">
|
|
|
|
|
|
{money(max * tick)}
|
|
|
|
|
|
</text>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</g>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
{area && <polygon points={areaPoints} fill="#7c3aed" opacity="0.16" />}
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<polyline
|
|
|
|
|
|
points={line}
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
stroke="#7c3aed"
|
|
|
|
|
|
strokeWidth="4"
|
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
|
/>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
{points.map((point, index) => (
|
|
|
|
|
|
<g key={point.month}>
|
|
|
|
|
|
<circle cx={point.x} cy={point.y} r="4.5" fill="#7c3aed" />
|
|
|
|
|
|
{(rows.length <= 12 || index % 3 === 0) && (
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<text
|
|
|
|
|
|
x={point.x}
|
|
|
|
|
|
y={height - 18}
|
|
|
|
|
|
fontSize="12"
|
|
|
|
|
|
fill="currentColor"
|
|
|
|
|
|
opacity="0.65"
|
|
|
|
|
|
textAnchor="middle"
|
|
|
|
|
|
>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
{point.label}
|
|
|
|
|
|
</text>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<title>{`${point.label}: ${fullMoney(point.total)}`}</title>
|
|
|
|
|
|
</g>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SvgFrame>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 22:48:28 -05:00
|
|
|
|
function GroupedBarChart({ rows }: { rows: ExpectedActualRow[] }) {
|
2026-05-04 13:14:32 -05:00
|
|
|
|
if (!hasData(rows, ['expected', 'actual'])) return <EmptyState />;
|
|
|
|
|
|
|
|
|
|
|
|
const width = 720;
|
|
|
|
|
|
const height = 280;
|
|
|
|
|
|
const pad = { left: 58, right: 24, top: 24, bottom: 50 };
|
|
|
|
|
|
const chartW = width - pad.left - pad.right;
|
|
|
|
|
|
const chartH = height - pad.top - pad.bottom;
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const max = Math.max(...rows.flatMap((r) => [r.expected, r.actual]), 1);
|
2026-05-04 13:14:32 -05:00
|
|
|
|
const groupW = chartW / rows.length;
|
|
|
|
|
|
const barW = Math.max(5, Math.min(17, groupW * 0.28));
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-07-02 20:36:09 -05:00
|
|
|
|
<SvgFrame height={height} label="Grouped bar chart">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
{[0, 0.5, 1].map((tick) => {
|
2026-05-04 13:14:32 -05:00
|
|
|
|
const y = pad.top + chartH - tick * chartH;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<g key={tick}>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<line
|
|
|
|
|
|
x1={pad.left}
|
|
|
|
|
|
x2={pad.left + chartW}
|
|
|
|
|
|
y1={y}
|
|
|
|
|
|
y2={y}
|
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
|
opacity="0.09"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<text x="12" y={y + 4} fontSize="12" fill="currentColor" opacity="0.58">
|
|
|
|
|
|
{money(max * tick)}
|
|
|
|
|
|
</text>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</g>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
{rows.map((row, index) => {
|
|
|
|
|
|
const center = pad.left + index * groupW + groupW / 2;
|
|
|
|
|
|
const expectedH = (row.expected / max) * chartH;
|
|
|
|
|
|
const actualH = (row.actual / max) * chartH;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<g key={row.month}>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<rect
|
|
|
|
|
|
x={center - barW - 1}
|
|
|
|
|
|
y={pad.top + chartH - expectedH}
|
|
|
|
|
|
width={barW}
|
|
|
|
|
|
height={expectedH}
|
|
|
|
|
|
rx="4"
|
|
|
|
|
|
fill="#8b5cf6"
|
|
|
|
|
|
>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<title>{`${row.label} expected: ${fullMoney(row.expected)}`}</title>
|
|
|
|
|
|
</rect>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<rect
|
|
|
|
|
|
x={center + 1}
|
|
|
|
|
|
y={pad.top + chartH - actualH}
|
|
|
|
|
|
width={barW}
|
|
|
|
|
|
height={actualH}
|
|
|
|
|
|
rx="4"
|
|
|
|
|
|
fill="#10b981"
|
|
|
|
|
|
>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<title>{`${row.label} actual: ${fullMoney(row.actual)}`}</title>
|
|
|
|
|
|
</rect>
|
|
|
|
|
|
{(rows.length <= 12 || index % 3 === 0) && (
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<text
|
|
|
|
|
|
x={center}
|
|
|
|
|
|
y={height - 18}
|
|
|
|
|
|
fontSize="12"
|
|
|
|
|
|
fill="currentColor"
|
|
|
|
|
|
opacity="0.65"
|
|
|
|
|
|
textAnchor="middle"
|
|
|
|
|
|
>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
{row.label}
|
|
|
|
|
|
</text>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</g>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
<g transform={`translate(${width - 190}, 18)`} fontSize="12" fill="currentColor">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<rect width="10" height="10" rx="2" fill="#8b5cf6" />
|
|
|
|
|
|
<text x="16" y="10">
|
|
|
|
|
|
Expected
|
|
|
|
|
|
</text>
|
|
|
|
|
|
<rect x="92" width="10" height="10" rx="2" fill="#10b981" />
|
|
|
|
|
|
<text x="108" y="10">
|
|
|
|
|
|
Actual
|
|
|
|
|
|
</text>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</g>
|
|
|
|
|
|
</SvgFrame>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 22:48:28 -05:00
|
|
|
|
function DonutChart({ rows }: { rows: CategorySpendRow[] }) {
|
2026-05-04 13:14:32 -05:00
|
|
|
|
const total = rows.reduce((sum, row) => sum + Number(row.total || 0), 0);
|
|
|
|
|
|
if (!total) return <EmptyState />;
|
|
|
|
|
|
|
|
|
|
|
|
let cumulative = 0;
|
|
|
|
|
|
const radius = 78;
|
|
|
|
|
|
const circumference = 2 * Math.PI * radius;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="grid gap-5 md:grid-cols-[260px_1fr] md:items-center">
|
|
|
|
|
|
<div className="flex justify-center">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<svg
|
|
|
|
|
|
viewBox="0 0 220 220"
|
|
|
|
|
|
role="img"
|
|
|
|
|
|
aria-label="Category breakdown donut chart"
|
|
|
|
|
|
className="h-56 w-56"
|
|
|
|
|
|
>
|
|
|
|
|
|
<circle
|
|
|
|
|
|
cx="110"
|
|
|
|
|
|
cy="110"
|
|
|
|
|
|
r={radius}
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
|
strokeWidth="30"
|
|
|
|
|
|
opacity="0.08"
|
|
|
|
|
|
/>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
{rows.map((row, index) => {
|
|
|
|
|
|
const value = Number(row.total || 0);
|
|
|
|
|
|
const dash = (value / total) * circumference;
|
|
|
|
|
|
const segment = (
|
|
|
|
|
|
<circle
|
|
|
|
|
|
key={row.category_name}
|
|
|
|
|
|
cx="110"
|
|
|
|
|
|
cy="110"
|
|
|
|
|
|
r={radius}
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
stroke={PALETTE[index % PALETTE.length]}
|
|
|
|
|
|
strokeWidth="30"
|
|
|
|
|
|
strokeDasharray={`${dash} ${circumference - dash}`}
|
|
|
|
|
|
strokeDashoffset={-cumulative}
|
|
|
|
|
|
transform="rotate(-90 110 110)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<title>{`${row.category_name}: ${fullMoney(value)}`}</title>
|
|
|
|
|
|
</circle>
|
|
|
|
|
|
);
|
|
|
|
|
|
cumulative += dash;
|
|
|
|
|
|
return segment;
|
|
|
|
|
|
})}
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<text
|
|
|
|
|
|
x="110"
|
|
|
|
|
|
y="104"
|
|
|
|
|
|
textAnchor="middle"
|
|
|
|
|
|
fontSize="13"
|
|
|
|
|
|
fill="currentColor"
|
|
|
|
|
|
opacity="0.65"
|
|
|
|
|
|
>
|
|
|
|
|
|
Total
|
|
|
|
|
|
</text>
|
|
|
|
|
|
<text
|
|
|
|
|
|
x="110"
|
|
|
|
|
|
y="126"
|
|
|
|
|
|
textAnchor="middle"
|
|
|
|
|
|
fontSize="22"
|
|
|
|
|
|
fontWeight="700"
|
|
|
|
|
|
fill="currentColor"
|
|
|
|
|
|
>
|
|
|
|
|
|
{money(total)}
|
|
|
|
|
|
</text>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</svg>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
{rows.map((row, index) => (
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<div
|
|
|
|
|
|
key={row.category_name}
|
|
|
|
|
|
className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-muted/20 px-3 py-2 text-sm"
|
|
|
|
|
|
>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<span className="flex min-w-0 items-center gap-2">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<span
|
|
|
|
|
|
className="h-3 w-3 shrink-0 rounded-sm"
|
|
|
|
|
|
style={{ backgroundColor: PALETTE[index % PALETTE.length] }}
|
|
|
|
|
|
/>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<span className="truncate">{row.category_name}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="shrink-0 font-medium tabular-nums">{fullMoney(row.total)}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 22:48:28 -05:00
|
|
|
|
const HEATMAP_CLASS: Record<string, string> = {
|
2026-05-04 13:14:32 -05:00
|
|
|
|
paid: 'bg-emerald-500/85 border-emerald-400/40',
|
|
|
|
|
|
skipped: 'bg-sky-500/70 border-sky-400/40',
|
|
|
|
|
|
missed: 'bg-red-500/75 border-red-400/40',
|
|
|
|
|
|
no_data: 'bg-muted border-border',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-04 22:48:28 -05:00
|
|
|
|
function Heatmap({ heatmap }: { heatmap?: HeatmapData }) {
|
2026-05-04 13:14:32 -05:00
|
|
|
|
const rows = heatmap?.rows || [];
|
|
|
|
|
|
const months = heatmap?.months || [];
|
|
|
|
|
|
if (!rows.length || !months.length) return <EmptyState />;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-09 13:03:36 -05:00
|
|
|
|
<div className="overflow-x-auto">
|
|
|
|
|
|
<div className="space-y-4 min-w-[760px]">
|
|
|
|
|
|
<div className="rounded-lg border border-border/60">
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<div
|
|
|
|
|
|
className="grid border-b border-border/60 bg-muted/30 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"
|
|
|
|
|
|
style={{ gridTemplateColumns: `180px repeat(${months.length}, minmax(38px, 1fr))` }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="px-3 py-2">Bill</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
{months.map((month) => (
|
|
|
|
|
|
<div key={month.key} className="px-1 py-2 text-center">
|
|
|
|
|
|
{month.label}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
{rows.map((row) => (
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<div
|
|
|
|
|
|
key={row.bill_id}
|
|
|
|
|
|
className="grid border-b border-border/40 last:border-b-0"
|
|
|
|
|
|
style={{ gridTemplateColumns: `180px repeat(${months.length}, minmax(38px, 1fr))` }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="min-w-0 px-3 py-2">
|
|
|
|
|
|
<p className="truncate text-sm font-medium">{row.bill_name}</p>
|
|
|
|
|
|
<p className="truncate text-[11px] text-muted-foreground">{row.category_name}</p>
|
|
|
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
{months.map((month) => {
|
|
|
|
|
|
const cell: HeatmapCell = row.cells.find((item) => item.month === month.key) || {
|
|
|
|
|
|
month: month.key,
|
|
|
|
|
|
status: 'no_data',
|
|
|
|
|
|
amount_paid: 0,
|
|
|
|
|
|
};
|
2026-05-04 13:14:32 -05:00
|
|
|
|
return (
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<div
|
|
|
|
|
|
key={`${row.bill_id}-${month.key}`}
|
|
|
|
|
|
className="flex items-center justify-center px-1 py-2"
|
|
|
|
|
|
>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<span
|
2026-07-06 14:23:53 -05:00
|
|
|
|
className={cn(
|
|
|
|
|
|
'h-5 w-5 rounded border',
|
|
|
|
|
|
HEATMAP_CLASS[cell.status] || HEATMAP_CLASS.no_data,
|
|
|
|
|
|
)}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
title={`${row.bill_name}, ${month.label}: ${cell.status.replace('_', ' ')}${cell.amount_paid ? ` (${fullMoney(cell.amount_paid)})` : ''}`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
{(
|
|
|
|
|
|
[
|
|
|
|
|
|
['paid', 'Paid'],
|
|
|
|
|
|
['skipped', 'Skipped'],
|
|
|
|
|
|
['missed', 'Missed'],
|
|
|
|
|
|
['no_data', 'No data'],
|
|
|
|
|
|
] as [string, string][]
|
|
|
|
|
|
).map(([status, label]) => (
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<span key={status} className="inline-flex items-center gap-1.5">
|
|
|
|
|
|
<span className={cn('h-3 w-3 rounded border', HEATMAP_CLASS[status])} />
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-28 21:11:29 -05:00
|
|
|
|
// ─── Forecast ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-07-04 22:48:28 -05:00
|
|
|
|
function linearForecast(rows: TrendRow[], horizonMonths: number): ForecastRow[] {
|
2026-05-28 21:11:29 -05:00
|
|
|
|
if (rows.length < 3) return [];
|
|
|
|
|
|
|
|
|
|
|
|
const n = rows.length;
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const ys = rows.map((r) => Number(r.total) || 0);
|
2026-05-28 21:11:29 -05:00
|
|
|
|
const xMean = (n - 1) / 2;
|
|
|
|
|
|
const yMean = ys.reduce((a, b) => a + b, 0) / n;
|
|
|
|
|
|
|
|
|
|
|
|
const sxy = ys.reduce((sum, y, i) => sum + (i - xMean) * (y - yMean), 0);
|
|
|
|
|
|
const sxx = ys.reduce((sum, _, i) => sum + (i - xMean) ** 2, 0);
|
|
|
|
|
|
const slope = sxy / sxx;
|
|
|
|
|
|
const intercept = yMean - slope * xMean;
|
|
|
|
|
|
|
|
|
|
|
|
const residuals = ys.map((y, i) => y - (slope * i + intercept));
|
|
|
|
|
|
const stdDev = Math.sqrt(residuals.reduce((s, r) => s + r * r, 0) / n);
|
|
|
|
|
|
|
2026-07-04 22:48:28 -05:00
|
|
|
|
const lastRow = rows[n - 1]!;
|
|
|
|
|
|
const parts = lastRow.month.split('-').map(Number);
|
|
|
|
|
|
const lastYear = parts[0] ?? 0;
|
|
|
|
|
|
const lastMonthNum = parts[1] ?? 1;
|
2026-05-28 21:11:29 -05:00
|
|
|
|
|
|
|
|
|
|
return Array.from({ length: horizonMonths }, (_, i) => {
|
|
|
|
|
|
const x = n + i;
|
|
|
|
|
|
const projected = Math.max(0, slope * x + intercept);
|
|
|
|
|
|
const d = new Date(Date.UTC(lastYear, lastMonthNum - 1 + i + 1, 1));
|
|
|
|
|
|
const month = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
|
|
|
|
|
|
const label = d.toLocaleString('en-US', { month: 'short', year: '2-digit', timeZone: 'UTC' });
|
|
|
|
|
|
return {
|
|
|
|
|
|
month,
|
|
|
|
|
|
label,
|
|
|
|
|
|
total: Number(projected.toFixed(2)),
|
|
|
|
|
|
low: Number(Math.max(0, projected - stdDev).toFixed(2)),
|
|
|
|
|
|
high: Number((projected + stdDev).toFixed(2)),
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
function ForecastChart({
|
|
|
|
|
|
historical,
|
|
|
|
|
|
forecast,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
historical: TrendRow[];
|
|
|
|
|
|
forecast: ForecastRow[];
|
|
|
|
|
|
}) {
|
2026-05-28 21:11:29 -05:00
|
|
|
|
const allRows = [...historical, ...forecast];
|
|
|
|
|
|
if (!allRows.length) return <EmptyState />;
|
|
|
|
|
|
|
|
|
|
|
|
const width = 720;
|
|
|
|
|
|
const height = 260;
|
|
|
|
|
|
const pad = { left: 58, right: 24, top: 36, bottom: 46 };
|
|
|
|
|
|
const chartW = width - pad.left - pad.right;
|
|
|
|
|
|
const chartH = height - pad.top - pad.bottom;
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const maxVal = Math.max(...historical.map((r) => r.total), ...forecast.map((r) => r.high), 1);
|
2026-05-28 21:11:29 -05:00
|
|
|
|
|
2026-07-04 22:48:28 -05:00
|
|
|
|
function toXY(index: number, value: number) {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const x =
|
|
|
|
|
|
pad.left + (allRows.length === 1 ? chartW / 2 : (index / (allRows.length - 1)) * chartW);
|
2026-05-28 21:11:29 -05:00
|
|
|
|
const y = pad.top + chartH - (value / maxVal) * chartH;
|
|
|
|
|
|
return { x, y };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const histPoints = historical.map((row, i) => ({ ...row, ...toXY(i, row.total) }));
|
|
|
|
|
|
const forePoints = forecast.map((row, i) => ({
|
|
|
|
|
|
...row,
|
|
|
|
|
|
...toXY(historical.length + i, row.total),
|
|
|
|
|
|
yHigh: toXY(historical.length + i, row.high).y,
|
2026-07-06 14:23:53 -05:00
|
|
|
|
yLow: toXY(historical.length + i, row.low).y,
|
2026-05-28 21:11:29 -05:00
|
|
|
|
}));
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const histLine = histPoints.map((p) => `${p.x},${p.y}`).join(' ');
|
2026-07-04 22:48:28 -05:00
|
|
|
|
const lastHist = histPoints[histPoints.length - 1];
|
2026-05-28 21:11:29 -05:00
|
|
|
|
|
2026-07-04 22:48:28 -05:00
|
|
|
|
const dividerX = lastHist ? lastHist.x : null;
|
2026-05-28 21:11:29 -05:00
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const foreLine =
|
|
|
|
|
|
forePoints.length && lastHist
|
|
|
|
|
|
? `${lastHist.x},${lastHist.y} ` + forePoints.map((p) => `${p.x},${p.y}`).join(' ')
|
|
|
|
|
|
: '';
|
|
|
|
|
|
|
|
|
|
|
|
const bandPoly =
|
|
|
|
|
|
forePoints.length > 1
|
|
|
|
|
|
? [
|
|
|
|
|
|
...forePoints.map((p) => `${p.x},${p.yHigh}`),
|
|
|
|
|
|
...forePoints
|
|
|
|
|
|
.slice()
|
|
|
|
|
|
.reverse()
|
|
|
|
|
|
.map((p) => `${p.x},${p.yLow}`),
|
|
|
|
|
|
].join(' ')
|
|
|
|
|
|
: '';
|
|
|
|
|
|
|
|
|
|
|
|
const showLabel = (index: number) =>
|
|
|
|
|
|
allRows.length <= 14 || index % Math.ceil(allRows.length / 14) === 0;
|
2026-05-28 21:11:29 -05:00
|
|
|
|
|
|
|
|
|
|
return (
|
2026-07-02 20:36:09 -05:00
|
|
|
|
<SvgFrame height={height} label="Forecast chart">
|
2026-05-28 21:11:29 -05:00
|
|
|
|
{/* Grid */}
|
2026-07-06 14:23:53 -05:00
|
|
|
|
{[0, 0.25, 0.5, 0.75, 1].map((tick) => {
|
2026-05-28 21:11:29 -05:00
|
|
|
|
const y = pad.top + chartH - tick * chartH;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<g key={tick}>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<line
|
|
|
|
|
|
x1={pad.left}
|
|
|
|
|
|
x2={pad.left + chartW}
|
|
|
|
|
|
y1={y}
|
|
|
|
|
|
y2={y}
|
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
|
opacity="0.08"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<text x="12" y={y + 4} fontSize="12" fill="currentColor" opacity="0.58">
|
|
|
|
|
|
{money(maxVal * tick)}
|
|
|
|
|
|
</text>
|
2026-05-28 21:11:29 -05:00
|
|
|
|
</g>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Forecast region shading */}
|
|
|
|
|
|
{dividerX && (
|
|
|
|
|
|
<rect
|
2026-07-06 14:23:53 -05:00
|
|
|
|
x={dividerX}
|
|
|
|
|
|
y={pad.top}
|
|
|
|
|
|
width={pad.left + chartW - dividerX}
|
|
|
|
|
|
height={chartH}
|
|
|
|
|
|
fill="currentColor"
|
|
|
|
|
|
opacity="0.025"
|
2026-05-28 21:11:29 -05:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Confidence band */}
|
|
|
|
|
|
{bandPoly && <polygon points={bandPoly} fill="#7c3aed" opacity="0.13" />}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Historical line + area fill */}
|
|
|
|
|
|
<polygon
|
2026-07-04 22:48:28 -05:00
|
|
|
|
points={`${pad.left},${pad.top + chartH} ${histLine} ${lastHist?.x ?? pad.left},${pad.top + chartH}`}
|
2026-07-06 14:23:53 -05:00
|
|
|
|
fill="#7c3aed"
|
|
|
|
|
|
opacity="0.10"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<polyline
|
|
|
|
|
|
points={histLine}
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
stroke="#7c3aed"
|
|
|
|
|
|
strokeWidth="3.5"
|
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
|
strokeLinejoin="round"
|
2026-05-28 21:11:29 -05:00
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Forecast line (dashed, connects from last historical point) */}
|
|
|
|
|
|
{foreLine && (
|
|
|
|
|
|
<polyline
|
|
|
|
|
|
points={foreLine}
|
2026-07-06 14:23:53 -05:00
|
|
|
|
fill="none"
|
|
|
|
|
|
stroke="#7c3aed"
|
|
|
|
|
|
strokeWidth="2.5"
|
|
|
|
|
|
strokeDasharray="9,6"
|
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
|
strokeLinejoin="round"
|
2026-05-28 21:11:29 -05:00
|
|
|
|
opacity="0.7"
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Divider tick */}
|
|
|
|
|
|
{dividerX && (
|
|
|
|
|
|
<line
|
2026-07-06 14:23:53 -05:00
|
|
|
|
x1={dividerX}
|
|
|
|
|
|
x2={dividerX}
|
|
|
|
|
|
y1={pad.top}
|
|
|
|
|
|
y2={pad.top + chartH}
|
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
|
strokeDasharray="4,4"
|
|
|
|
|
|
opacity="0.2"
|
2026-05-28 21:11:29 -05:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Historical dots + labels */}
|
|
|
|
|
|
{histPoints.map((p, i) => (
|
|
|
|
|
|
<g key={p.month}>
|
|
|
|
|
|
<circle cx={p.x} cy={p.y} r="4" fill="#7c3aed" />
|
|
|
|
|
|
{showLabel(i) && (
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<text
|
|
|
|
|
|
x={p.x}
|
|
|
|
|
|
y={height - 18}
|
|
|
|
|
|
fontSize="11"
|
|
|
|
|
|
fill="currentColor"
|
|
|
|
|
|
opacity="0.6"
|
|
|
|
|
|
textAnchor="middle"
|
|
|
|
|
|
>
|
2026-05-28 21:11:29 -05:00
|
|
|
|
{p.label}
|
|
|
|
|
|
</text>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<title>{`${p.label}: ${fullMoney(p.total)}`}</title>
|
|
|
|
|
|
</g>
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Forecast dots + labels */}
|
|
|
|
|
|
{forePoints.map((p, i) => (
|
|
|
|
|
|
<g key={p.month}>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<circle
|
|
|
|
|
|
cx={p.x}
|
|
|
|
|
|
cy={p.y}
|
|
|
|
|
|
r="4"
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
stroke="#7c3aed"
|
|
|
|
|
|
strokeWidth="2"
|
|
|
|
|
|
opacity="0.75"
|
|
|
|
|
|
/>
|
2026-05-28 21:11:29 -05:00
|
|
|
|
{showLabel(historical.length + i) && (
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<text
|
|
|
|
|
|
x={p.x}
|
|
|
|
|
|
y={height - 18}
|
|
|
|
|
|
fontSize="11"
|
|
|
|
|
|
fill="currentColor"
|
|
|
|
|
|
opacity="0.45"
|
|
|
|
|
|
textAnchor="middle"
|
|
|
|
|
|
>
|
2026-05-28 21:11:29 -05:00
|
|
|
|
{p.label}
|
|
|
|
|
|
</text>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<title>{`${p.label} (projected): ${fullMoney(p.total)} · range ${fullMoney(p.low)}–${fullMoney(p.high)}`}</title>
|
|
|
|
|
|
</g>
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Legend */}
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<g
|
|
|
|
|
|
transform={`translate(${pad.left + 8}, 18)`}
|
|
|
|
|
|
fontSize="11"
|
|
|
|
|
|
fill="currentColor"
|
|
|
|
|
|
opacity="0.7"
|
|
|
|
|
|
>
|
2026-05-28 21:11:29 -05:00
|
|
|
|
<line x1="0" x2="16" y1="5" y2="5" stroke="#7c3aed" strokeWidth="3" />
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<text x="21" y="9">
|
|
|
|
|
|
Historical
|
|
|
|
|
|
</text>
|
|
|
|
|
|
<line
|
|
|
|
|
|
x1="97"
|
|
|
|
|
|
x2="113"
|
|
|
|
|
|
y1="5"
|
|
|
|
|
|
y2="5"
|
|
|
|
|
|
stroke="#7c3aed"
|
|
|
|
|
|
strokeWidth="2.5"
|
|
|
|
|
|
strokeDasharray="5,4"
|
|
|
|
|
|
/>
|
2026-05-28 21:11:29 -05:00
|
|
|
|
<rect x="120" y="0" width="10" height="10" rx="2" fill="#7c3aed" opacity="0.15" />
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<text x="135" y="9">
|
|
|
|
|
|
Projected ± 1 σ
|
|
|
|
|
|
</text>
|
2026-05-28 21:11:29 -05:00
|
|
|
|
</g>
|
|
|
|
|
|
</SvgFrame>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 22:48:28 -05:00
|
|
|
|
function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
|
2026-05-04 13:14:32 -05:00
|
|
|
|
return (
|
|
|
|
|
|
<label className="space-y-1.5">
|
|
|
|
|
|
<span className="block text-xs font-medium text-muted-foreground">{label}</span>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</label>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
function ControlSelect({
|
|
|
|
|
|
value,
|
|
|
|
|
|
onChange,
|
|
|
|
|
|
children,
|
|
|
|
|
|
className,
|
|
|
|
|
|
}: {
|
2026-07-04 22:48:28 -05:00
|
|
|
|
value: string;
|
|
|
|
|
|
onChange: (value: string) => void;
|
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
|
className?: string;
|
|
|
|
|
|
}) {
|
2026-05-04 13:14:32 -05:00
|
|
|
|
return (
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={value}
|
2026-07-06 14:23:53 -05:00
|
|
|
|
onChange={(e) => onChange(e.target.value)}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'h-9 rounded-md border border-input bg-background px-3 text-sm shadow-sm focus:outline-none focus:ring-[3px] focus:ring-ring/50',
|
|
|
|
|
|
className,
|
|
|
|
|
|
)}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function AnalyticsPage() {
|
|
|
|
|
|
const initial = currentMonth();
|
|
|
|
|
|
const [year, setYear] = useState(initial.year);
|
|
|
|
|
|
const [month, setMonth] = useState(initial.month);
|
|
|
|
|
|
const [months, setMonths] = useState(12);
|
|
|
|
|
|
const [categoryId, setCategoryId] = useState('');
|
|
|
|
|
|
const [billId, setBillId] = useState('');
|
|
|
|
|
|
const [includeInactive, setIncludeInactive] = useState(false);
|
|
|
|
|
|
const [includeSkipped, setIncludeSkipped] = useState(true);
|
|
|
|
|
|
const [trendMode, setTrendMode] = useState('line');
|
2026-07-04 22:48:28 -05:00
|
|
|
|
const [visible, setVisible] = useState<ChartVisibility>({
|
2026-05-04 13:14:32 -05:00
|
|
|
|
monthlyTrend: true,
|
|
|
|
|
|
expectedActual: true,
|
|
|
|
|
|
categorySpend: true,
|
|
|
|
|
|
heatmap: true,
|
2026-05-28 21:11:29 -05:00
|
|
|
|
forecast: true,
|
2026-05-04 13:14:32 -05:00
|
|
|
|
});
|
2026-05-28 21:11:29 -05:00
|
|
|
|
const [forecastHorizon, setForecastHorizon] = useState(6);
|
2026-05-04 13:14:32 -05:00
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const params = useMemo(
|
|
|
|
|
|
() => ({
|
|
|
|
|
|
year,
|
|
|
|
|
|
month,
|
|
|
|
|
|
months,
|
|
|
|
|
|
category_id: categoryId,
|
|
|
|
|
|
bill_id: billId,
|
|
|
|
|
|
include_inactive: includeInactive,
|
|
|
|
|
|
include_skipped: includeSkipped,
|
|
|
|
|
|
}),
|
|
|
|
|
|
[billId, categoryId, includeInactive, includeSkipped, month, months, year],
|
|
|
|
|
|
);
|
2026-05-04 13:14:32 -05:00
|
|
|
|
|
2026-07-03 20:04:14 -05:00
|
|
|
|
// React Query handles caching, dedup, cancellation, and out-of-order responses.
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const {
|
|
|
|
|
|
data: dataRaw,
|
|
|
|
|
|
isPending,
|
|
|
|
|
|
isFetching,
|
|
|
|
|
|
error: queryError,
|
|
|
|
|
|
refetch,
|
|
|
|
|
|
} = useAnalyticsSummary(params);
|
2026-07-04 22:48:28 -05:00
|
|
|
|
const data = dataRaw as AnalyticsData | undefined;
|
2026-07-03 20:04:14 -05:00
|
|
|
|
const loading = isPending;
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const error = queryError ? queryError.message || 'Failed to load analytics.' : '';
|
2026-05-04 13:14:32 -05:00
|
|
|
|
|
2026-05-28 21:11:29 -05:00
|
|
|
|
const forecastRows = useMemo(
|
|
|
|
|
|
() => linearForecast(data?.monthly_spending || [], forecastHorizon),
|
|
|
|
|
|
[data?.monthly_spending, forecastHorizon],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-05-04 13:14:32 -05:00
|
|
|
|
const reset = () => {
|
|
|
|
|
|
const next = currentMonth();
|
|
|
|
|
|
setYear(next.year);
|
|
|
|
|
|
setMonth(next.month);
|
|
|
|
|
|
setMonths(12);
|
|
|
|
|
|
setCategoryId('');
|
|
|
|
|
|
setBillId('');
|
|
|
|
|
|
setIncludeInactive(false);
|
|
|
|
|
|
setIncludeSkipped(true);
|
|
|
|
|
|
setTrendMode('line');
|
2026-05-28 21:11:29 -05:00
|
|
|
|
setForecastHorizon(6);
|
2026-07-06 14:23:53 -05:00
|
|
|
|
setVisible({
|
|
|
|
|
|
monthlyTrend: true,
|
|
|
|
|
|
expectedActual: true,
|
|
|
|
|
|
categorySpend: true,
|
|
|
|
|
|
heatmap: true,
|
|
|
|
|
|
forecast: true,
|
|
|
|
|
|
});
|
2026-05-04 13:14:32 -05:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const totalCategorySpend =
|
|
|
|
|
|
data?.category_spend?.reduce((sum, row) => sum + Number(row.total || 0), 0) || 0;
|
|
|
|
|
|
const activeCharts =
|
|
|
|
|
|
CHART_OPTIONS.filter(([key]) => visible[key])
|
|
|
|
|
|
.map(([, label]) => label)
|
|
|
|
|
|
.join(', ') || 'None';
|
2026-05-04 13:14:32 -05:00
|
|
|
|
const filterSummary = [
|
2026-07-06 14:23:53 -05:00
|
|
|
|
categoryId
|
|
|
|
|
|
? `Category: ${data?.categories?.find((c) => String(c.id) === String(categoryId))?.name || categoryId}`
|
|
|
|
|
|
: 'All categories',
|
|
|
|
|
|
billId
|
|
|
|
|
|
? `Bill: ${data?.bills?.find((b) => String(b.id) === String(billId))?.name || billId}`
|
|
|
|
|
|
: 'All bills',
|
2026-05-04 13:14:32 -05:00
|
|
|
|
includeInactive ? 'Includes inactive bills' : 'Active bills only',
|
|
|
|
|
|
includeSkipped ? 'Shows skipped months' : 'Hides skipped months',
|
|
|
|
|
|
].join(' | ');
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="analytics-page space-y-6">
|
|
|
|
|
|
<div className="analytics-report-meta hidden">
|
2026-07-05 17:18:56 -05:00
|
|
|
|
<h1>Bill Tracker Analytics</h1>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<p>{formatRange(data?.range)}</p>
|
|
|
|
|
|
<p>{filterSummary}</p>
|
|
|
|
|
|
<p>Visible charts: {activeCharts}</p>
|
|
|
|
|
|
<p>Generated {new Date(data?.generated_at || Date.now()).toLocaleString()}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="analytics-screen-header flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h1 className="text-2xl font-bold tracking-tight">Analytics</h1>
|
|
|
|
|
|
<p className="mt-0.5 text-sm text-muted-foreground">
|
|
|
|
|
|
Spending trends, category breakdowns, and payment history.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="analytics-actions flex flex-wrap gap-2">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => refetch()}
|
|
|
|
|
|
disabled={isFetching}
|
|
|
|
|
|
className="flex-1 sm:flex-none"
|
|
|
|
|
|
>
|
2026-07-03 20:04:14 -05:00
|
|
|
|
<RefreshCw className={cn('h-3.5 w-3.5', isFetching && 'animate-spin')} />
|
2026-05-04 13:14:32 -05:00
|
|
|
|
Refresh
|
|
|
|
|
|
</Button>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => window.print()}
|
|
|
|
|
|
className="flex-1 sm:flex-none"
|
|
|
|
|
|
>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<Printer className="h-3.5 w-3.5" />
|
|
|
|
|
|
Print
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button size="sm" onClick={() => window.print()} className="flex-1 sm:flex-none">
|
|
|
|
|
|
<Printer className="h-3.5 w-3.5" />
|
|
|
|
|
|
Print / Save PDF
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<section className="analytics-controls surface-elevated p-4">
|
|
|
|
|
|
<div className="grid gap-4 lg:grid-cols-[1fr_auto] lg:items-end">
|
2026-05-28 21:11:29 -05:00
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-7">
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<Field label="Ending month">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<ControlSelect value={String(month)} onChange={(value) => setMonth(Number(value))}>
|
|
|
|
|
|
{MONTH_OPTIONS.map(([value, label]) => (
|
|
|
|
|
|
<option key={value} value={value}>
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</ControlSelect>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
<Field label="Ending year">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="2000"
|
|
|
|
|
|
max="2100"
|
|
|
|
|
|
value={year}
|
2026-07-06 14:23:53 -05:00
|
|
|
|
onChange={(e) => setYear(Number(e.target.value))}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm shadow-sm focus:outline-none focus:ring-[3px] focus:ring-ring/50"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
<Field label="Range">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<ControlSelect value={String(months)} onChange={(value) => setMonths(Number(value))}>
|
|
|
|
|
|
{RANGE_OPTIONS.map((value) => (
|
|
|
|
|
|
<option key={value} value={value}>
|
|
|
|
|
|
{value} months
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</ControlSelect>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
<Field label="Category">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<ControlSelect
|
|
|
|
|
|
value={categoryId}
|
|
|
|
|
|
onChange={(value) => {
|
|
|
|
|
|
setCategoryId(value);
|
|
|
|
|
|
setBillId('');
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<option value="">All categories</option>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
{(data?.categories || []).map((category) => (
|
|
|
|
|
|
<option key={category.id} value={category.id}>
|
|
|
|
|
|
{category.name}
|
|
|
|
|
|
</option>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
))}
|
|
|
|
|
|
</ControlSelect>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
<Field label="Bill">
|
|
|
|
|
|
<ControlSelect value={billId} onChange={setBillId}>
|
|
|
|
|
|
<option value="">All bills</option>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
{(data?.bills || []).map((bill) => (
|
|
|
|
|
|
<option key={bill.id} value={bill.id}>
|
|
|
|
|
|
{bill.name}
|
|
|
|
|
|
{bill.active ? '' : ' (inactive)'}
|
|
|
|
|
|
</option>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
))}
|
|
|
|
|
|
</ControlSelect>
|
|
|
|
|
|
</Field>
|
|
|
|
|
|
<Field label="Trend style">
|
|
|
|
|
|
<ControlSelect value={trendMode} onChange={setTrendMode}>
|
|
|
|
|
|
<option value="line">Line</option>
|
|
|
|
|
|
<option value="area">Area</option>
|
|
|
|
|
|
</ControlSelect>
|
|
|
|
|
|
</Field>
|
2026-05-28 21:11:29 -05:00
|
|
|
|
<Field label="Forecast">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<ControlSelect
|
|
|
|
|
|
value={String(forecastHorizon)}
|
|
|
|
|
|
onChange={(v) => setForecastHorizon(Number(v))}
|
|
|
|
|
|
>
|
2026-05-28 21:11:29 -05:00
|
|
|
|
<option value="3">3 months</option>
|
|
|
|
|
|
<option value="6">6 months</option>
|
|
|
|
|
|
<option value="12">12 months</option>
|
|
|
|
|
|
</ControlSelect>
|
|
|
|
|
|
</Field>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
<Button type="button" variant="outline" onClick={reset}>
|
|
|
|
|
|
<RotateCcw className="h-4 w-4" />
|
|
|
|
|
|
Reset filters
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="mt-4 grid gap-3 border-t border-border/60 pt-4 md:grid-cols-2 xl:grid-cols-4">
|
|
|
|
|
|
{CHART_OPTIONS.map(([key, label]) => (
|
|
|
|
|
|
<label key={key} className="flex items-center gap-2 text-sm">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={visible[key]}
|
2026-07-06 14:23:53 -05:00
|
|
|
|
onChange={(e) => setVisible((prev) => ({ ...prev, [key]: e.target.checked }))}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
className="h-4 w-4 rounded border-input bg-background accent-primary"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</label>
|
|
|
|
|
|
))}
|
|
|
|
|
|
<label className="flex items-center gap-2 text-sm">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={includeInactive}
|
2026-07-06 14:23:53 -05:00
|
|
|
|
onChange={(e) => setIncludeInactive(e.target.checked)}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
className="h-4 w-4 rounded border-input bg-background accent-primary"
|
|
|
|
|
|
/>
|
|
|
|
|
|
Include inactive bills
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label className="flex items-center gap-2 text-sm">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={includeSkipped}
|
2026-07-06 14:23:53 -05:00
|
|
|
|
onChange={(e) => setIncludeSkipped(e.target.checked)}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
className="h-4 w-4 rounded border-input bg-background accent-primary"
|
|
|
|
|
|
/>
|
|
|
|
|
|
Show skipped months
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="analytics-range text-sm text-muted-foreground">
|
|
|
|
|
|
{data ? (
|
|
|
|
|
|
<>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
Reporting on{' '}
|
|
|
|
|
|
<span className="font-medium text-foreground">{formatRange(data.range)}</span>.
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<span className="ml-2">{filterSummary}</span>
|
|
|
|
|
|
</>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
) : (
|
|
|
|
|
|
'Preparing analytics...'
|
|
|
|
|
|
)}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{loading && (
|
|
|
|
|
|
<div className="grid gap-5 lg:grid-cols-2">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
{[1, 2, 3, 4].map((item) => (
|
|
|
|
|
|
<div key={item} className="h-80 animate-pulse rounded-2xl bg-muted/50" />
|
|
|
|
|
|
))}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{!loading && error && (
|
|
|
|
|
|
<div className="rounded-lg border border-destructive/25 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
|
|
|
|
|
{error}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{!loading && !error && data && (
|
|
|
|
|
|
<div className="analytics-chart-grid grid gap-5 xl:grid-cols-2">
|
|
|
|
|
|
{visible.monthlyTrend && (
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<ChartCard
|
|
|
|
|
|
title="Monthly spending trend"
|
|
|
|
|
|
subtitle="Actual payments grouped by paid month."
|
|
|
|
|
|
>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<LineChart rows={data.monthly_spending || []} area={trendMode === 'area'} />
|
|
|
|
|
|
</ChartCard>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{visible.expectedActual && (
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<ChartCard
|
|
|
|
|
|
title="Expected vs actual spend"
|
|
|
|
|
|
subtitle="Expected uses monthly override amount when present, otherwise the bill estimate."
|
|
|
|
|
|
>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<GroupedBarChart rows={data.expected_vs_actual || []} />
|
|
|
|
|
|
</ChartCard>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{visible.categorySpend && (
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<ChartCard
|
|
|
|
|
|
title="Spending by category"
|
|
|
|
|
|
subtitle="Payments grouped by bill category."
|
|
|
|
|
|
summary={fullMoney(totalCategorySpend)}
|
|
|
|
|
|
>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<DonutChart rows={data.category_spend || []} />
|
|
|
|
|
|
</ChartCard>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{visible.heatmap && (
|
|
|
|
|
|
<div className="xl:col-span-2">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<ChartCard
|
|
|
|
|
|
title="Pay-on-time heatmap"
|
|
|
|
|
|
subtitle="Bill status by month. Future/current unpaid months show as no data."
|
|
|
|
|
|
>
|
2026-05-04 13:14:32 -05:00
|
|
|
|
<Heatmap heatmap={data.heatmap} />
|
|
|
|
|
|
</ChartCard>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-05-28 21:11:29 -05:00
|
|
|
|
{visible.forecast && (
|
|
|
|
|
|
<div className="xl:col-span-2">
|
|
|
|
|
|
<ChartCard
|
|
|
|
|
|
title={`Spending forecast · next ${forecastHorizon} months`}
|
|
|
|
|
|
subtitle={
|
|
|
|
|
|
(data.monthly_spending || []).length < 3
|
|
|
|
|
|
? 'Need at least 3 months of history to project.'
|
|
|
|
|
|
: 'Linear extrapolation from historical payments. Shaded band shows ± 1 standard deviation of past residuals.'
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{(data.monthly_spending || []).length < 3 ? (
|
|
|
|
|
|
<EmptyState label="Not enough history to project — select a wider range." />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<ForecastChart
|
|
|
|
|
|
historical={data.monthly_spending || []}
|
|
|
|
|
|
forecast={forecastRows}
|
|
|
|
|
|
/>
|
2026-05-28 21:11:29 -05:00
|
|
|
|
<div className="mt-4 overflow-x-auto">
|
|
|
|
|
|
<table className="w-full text-sm">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr className="border-b border-border/60 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
|
|
|
|
|
<th className="py-2 pr-4 text-left">Month</th>
|
|
|
|
|
|
<th className="py-2 pr-4 text-right">Projected</th>
|
|
|
|
|
|
<th className="py-2 pr-4 text-right">Low estimate</th>
|
|
|
|
|
|
<th className="py-2 text-right">High estimate</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody className="divide-y divide-border/40">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
{forecastRows.map((row) => (
|
|
|
|
|
|
<tr
|
|
|
|
|
|
key={row.month}
|
|
|
|
|
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
|
|
|
|
>
|
2026-05-28 21:11:29 -05:00
|
|
|
|
<td className="py-2 pr-4 font-medium text-foreground">{row.label}</td>
|
2026-07-06 14:23:53 -05:00
|
|
|
|
<td className="tracker-number py-2 pr-4 text-right font-semibold">
|
|
|
|
|
|
{fullMoney(row.total)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="tracker-number py-2 pr-4 text-right font-medium text-muted-foreground">
|
|
|
|
|
|
{fullMoney(row.low)}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
<td className="tracker-number py-2 text-right font-medium text-muted-foreground">
|
|
|
|
|
|
{fullMoney(row.high)}
|
|
|
|
|
|
</td>
|
2026-05-28 21:11:29 -05:00
|
|
|
|
</tr>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</ChartCard>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-05-04 13:14:32 -05:00
|
|
|
|
{!Object.values(visible).some(Boolean) && (
|
|
|
|
|
|
<div className="xl:col-span-2">
|
|
|
|
|
|
<EmptyState label="Select at least one chart to show analytics." />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="analytics-print-footer hidden text-xs text-muted-foreground">
|
2026-07-06 14:23:53 -05:00
|
|
|
|
Generated from Bill Tracker Analytics on{' '}
|
|
|
|
|
|
{new Date(data?.generated_at || Date.now()).toLocaleString()}.
|
2026-05-04 13:14:32 -05:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|