refactor(ts): AnalyticsPage → TypeScript (SVG charts + forecast); drop unused Skeleton import

This commit is contained in:
null 2026-07-04 22:48:28 -05:00
parent f15046bce3
commit 818df6d410
1 changed files with 79 additions and 37 deletions

View File

@ -1,18 +1,46 @@
import React, { useMemo, useState } from 'react'; import { useMemo, useState, type ReactNode } from 'react';
import { formatUSD, formatUSDWhole } from '@/lib/money'; import { formatUSD, formatUSDWhole, asDollars } from '@/lib/money';
import { Printer, RefreshCw, RotateCcw } from 'lucide-react'; import { Printer, RefreshCw, RotateCcw } from 'lucide-react';
import { useAnalyticsSummary } from '@/hooks/useQueries'; import { useAnalyticsSummary } from '@/hooks/useQueries';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/Skeleton';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
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 }
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;
}
const RANGE_OPTIONS = [6, 12, 24, 36]; const RANGE_OPTIONS = [6, 12, 24, 36];
const MONTH_OPTIONS = [ const MONTH_OPTIONS: [string, string][] = [
['1', 'January'], ['2', 'February'], ['3', 'March'], ['4', 'April'], ['1', 'January'], ['2', 'February'], ['3', 'March'], ['4', 'April'],
['5', 'May'], ['6', 'June'], ['7', 'July'], ['8', 'August'], ['5', 'May'], ['6', 'June'], ['7', 'July'], ['8', 'August'],
['9', 'September'], ['10', 'October'], ['11', 'November'], ['12', 'December'], ['9', 'September'], ['10', 'October'], ['11', 'November'], ['12', 'December'],
]; ];
const CHART_OPTIONS = [ const CHART_OPTIONS: [string, string][] = [
['monthlyTrend', 'Monthly trend'], ['monthlyTrend', 'Monthly trend'],
['expectedActual', 'Expected vs actual'], ['expectedActual', 'Expected vs actual'],
['categorySpend', 'Category spend'], ['categorySpend', 'Category spend'],
@ -26,24 +54,24 @@ function currentMonth() {
return { year: now.getFullYear(), month: now.getMonth() + 1 }; return { year: now.getFullYear(), month: now.getMonth() + 1 };
} }
function money(value) { function money(value: number | null | undefined): string {
return formatUSDWhole(value); return formatUSDWhole(asDollars(Number(value) || 0));
} }
function fullMoney(value) { function fullMoney(value: number | null | undefined): string {
return formatUSD(value); return formatUSD(asDollars(Number(value) || 0));
} }
function formatRange(range) { function formatRange(range?: AnalyticsRange): string {
if (!range?.start || !range?.end) return 'Selected range'; if (!range?.start || !range?.end) return 'Selected range';
return `${range.start.slice(0, 7)} through ${range.end.slice(0, 7)}`; return `${range.start.slice(0, 7)} through ${range.end.slice(0, 7)}`;
} }
function hasData(rows, keys) { function hasData(rows: readonly unknown[] | undefined, keys: string[]): boolean {
return rows?.some(row => keys.some(key => Number(row[key]) > 0)); return !!rows?.some(row => keys.some(key => Number((row as Record<string, unknown>)[key]) > 0));
} }
function EmptyState({ label = 'No analytics data for this selection.' }) { function EmptyState({ label = 'No analytics data for this selection.' }: { label?: string }) {
return ( 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"> <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} {label}
@ -51,7 +79,12 @@ function EmptyState({ label = 'No analytics data for this selection.' }) {
); );
} }
function ChartCard({ title, subtitle, children, summary }) { function ChartCard({ title, subtitle, children, summary }: {
title: ReactNode;
subtitle?: ReactNode;
children: ReactNode;
summary?: ReactNode;
}) {
return ( return (
<section className="analytics-chart surface-elevated p-5"> <section className="analytics-chart surface-elevated p-5">
<div className="mb-4 flex items-start justify-between gap-4"> <div className="mb-4 flex items-start justify-between gap-4">
@ -66,7 +99,7 @@ function ChartCard({ title, subtitle, children, summary }) {
); );
} }
function SvgFrame({ children, height = 260, label = 'Chart' }) { function SvgFrame({ children, height = 260, label = 'Chart' }: { children: ReactNode; height?: number; label?: string }) {
return ( return (
<div className="w-full overflow-hidden rounded-lg border border-border/60 bg-background/60"> <div className="w-full overflow-hidden rounded-lg border border-border/60 bg-background/60">
<svg viewBox={`0 0 720 ${height}`} role="img" aria-label={label} className="h-auto w-full"> <svg viewBox={`0 0 720 ${height}`} role="img" aria-label={label} className="h-auto w-full">
@ -76,7 +109,7 @@ function SvgFrame({ children, height = 260, label = 'Chart' }) {
); );
} }
function LineChart({ rows, area = false }) { function LineChart({ rows, area = false }: { rows: TrendRow[]; area?: boolean }) {
if (!hasData(rows, ['total'])) return <EmptyState />; if (!hasData(rows, ['total'])) return <EmptyState />;
const width = 720; const width = 720;
@ -121,7 +154,7 @@ function LineChart({ rows, area = false }) {
); );
} }
function GroupedBarChart({ rows }) { function GroupedBarChart({ rows }: { rows: ExpectedActualRow[] }) {
if (!hasData(rows, ['expected', 'actual'])) return <EmptyState />; if (!hasData(rows, ['expected', 'actual'])) return <EmptyState />;
const width = 720; const width = 720;
@ -172,7 +205,7 @@ function GroupedBarChart({ rows }) {
); );
} }
function DonutChart({ rows }) { function DonutChart({ rows }: { rows: CategorySpendRow[] }) {
const total = rows.reduce((sum, row) => sum + Number(row.total || 0), 0); const total = rows.reduce((sum, row) => sum + Number(row.total || 0), 0);
if (!total) return <EmptyState />; if (!total) return <EmptyState />;
@ -226,14 +259,14 @@ function DonutChart({ rows }) {
); );
} }
const HEATMAP_CLASS = { const HEATMAP_CLASS: Record<string, string> = {
paid: 'bg-emerald-500/85 border-emerald-400/40', paid: 'bg-emerald-500/85 border-emerald-400/40',
skipped: 'bg-sky-500/70 border-sky-400/40', skipped: 'bg-sky-500/70 border-sky-400/40',
missed: 'bg-red-500/75 border-red-400/40', missed: 'bg-red-500/75 border-red-400/40',
no_data: 'bg-muted border-border', no_data: 'bg-muted border-border',
}; };
function Heatmap({ heatmap }) { function Heatmap({ heatmap }: { heatmap?: HeatmapData }) {
const rows = heatmap?.rows || []; const rows = heatmap?.rows || [];
const months = heatmap?.months || []; const months = heatmap?.months || [];
if (!rows.length || !months.length) return <EmptyState />; if (!rows.length || !months.length) return <EmptyState />;
@ -260,7 +293,7 @@ function Heatmap({ heatmap }) {
<p className="truncate text-[11px] text-muted-foreground">{row.category_name}</p> <p className="truncate text-[11px] text-muted-foreground">{row.category_name}</p>
</div> </div>
{months.map(month => { {months.map(month => {
const cell = row.cells.find(item => item.month === month.key) || { status: 'no_data', amount_paid: 0 }; const cell: HeatmapCell = row.cells.find(item => item.month === month.key) || { month: month.key, status: 'no_data', amount_paid: 0 };
return ( return (
<div key={`${row.bill_id}-${month.key}`} className="flex items-center justify-center px-1 py-2"> <div key={`${row.bill_id}-${month.key}`} className="flex items-center justify-center px-1 py-2">
<span <span
@ -275,12 +308,12 @@ function Heatmap({ heatmap }) {
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground"> <div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
{[ {([
['paid', 'Paid'], ['paid', 'Paid'],
['skipped', 'Skipped'], ['skipped', 'Skipped'],
['missed', 'Missed'], ['missed', 'Missed'],
['no_data', 'No data'], ['no_data', 'No data'],
].map(([status, label]) => ( ] as [string, string][]).map(([status, label]) => (
<span key={status} className="inline-flex items-center gap-1.5"> <span key={status} className="inline-flex items-center gap-1.5">
<span className={cn('h-3 w-3 rounded border', HEATMAP_CLASS[status])} /> <span className={cn('h-3 w-3 rounded border', HEATMAP_CLASS[status])} />
{label} {label}
@ -293,7 +326,7 @@ function Heatmap({ heatmap }) {
// ─── Forecast ──────────────────────────────────────────────────────────────── // ─── Forecast ────────────────────────────────────────────────────────────────
function linearForecast(rows, horizonMonths) { function linearForecast(rows: TrendRow[], horizonMonths: number): ForecastRow[] {
if (rows.length < 3) return []; if (rows.length < 3) return [];
const n = rows.length; const n = rows.length;
@ -309,8 +342,10 @@ function linearForecast(rows, horizonMonths) {
const residuals = ys.map((y, i) => y - (slope * i + intercept)); const residuals = ys.map((y, i) => y - (slope * i + intercept));
const stdDev = Math.sqrt(residuals.reduce((s, r) => s + r * r, 0) / n); const stdDev = Math.sqrt(residuals.reduce((s, r) => s + r * r, 0) / n);
const lastRow = rows[n - 1]; const lastRow = rows[n - 1]!;
const [lastYear, lastMonthNum] = lastRow.month.split('-').map(Number); const parts = lastRow.month.split('-').map(Number);
const lastYear = parts[0] ?? 0;
const lastMonthNum = parts[1] ?? 1;
return Array.from({ length: horizonMonths }, (_, i) => { return Array.from({ length: horizonMonths }, (_, i) => {
const x = n + i; const x = n + i;
@ -328,7 +363,7 @@ function linearForecast(rows, horizonMonths) {
}); });
} }
function ForecastChart({ historical, forecast }) { function ForecastChart({ historical, forecast }: { historical: TrendRow[]; forecast: ForecastRow[] }) {
const allRows = [...historical, ...forecast]; const allRows = [...historical, ...forecast];
if (!allRows.length) return <EmptyState />; if (!allRows.length) return <EmptyState />;
@ -340,7 +375,7 @@ function ForecastChart({ historical, forecast }) {
const maxVal = Math.max(...historical.map(r => r.total), ...forecast.map(r => r.high), 1); const maxVal = Math.max(...historical.map(r => r.total), ...forecast.map(r => r.high), 1);
function toXY(index, value) { function toXY(index: number, value: number) {
const x = pad.left + (allRows.length === 1 ? chartW / 2 : (index / (allRows.length - 1)) * chartW); const x = pad.left + (allRows.length === 1 ? chartW / 2 : (index / (allRows.length - 1)) * chartW);
const y = pad.top + chartH - (value / maxVal) * chartH; const y = pad.top + chartH - (value / maxVal) * chartH;
return { x, y }; return { x, y };
@ -355,11 +390,12 @@ function ForecastChart({ historical, forecast }) {
})); }));
const histLine = histPoints.map(p => `${p.x},${p.y}`).join(' '); const histLine = histPoints.map(p => `${p.x},${p.y}`).join(' ');
const lastHist = histPoints[histPoints.length - 1];
const dividerX = histPoints.length ? histPoints[histPoints.length - 1].x : null; const dividerX = lastHist ? lastHist.x : null;
const foreLine = forePoints.length && histPoints.length const foreLine = forePoints.length && lastHist
? `${histPoints[histPoints.length - 1].x},${histPoints[histPoints.length - 1].y} ` + ? `${lastHist.x},${lastHist.y} ` +
forePoints.map(p => `${p.x},${p.y}`).join(' ') forePoints.map(p => `${p.x},${p.y}`).join(' ')
: ''; : '';
@ -367,7 +403,7 @@ function ForecastChart({ historical, forecast }) {
? [...forePoints.map(p => `${p.x},${p.yHigh}`), ...forePoints.slice().reverse().map(p => `${p.x},${p.yLow}`)].join(' ') ? [...forePoints.map(p => `${p.x},${p.yHigh}`), ...forePoints.slice().reverse().map(p => `${p.x},${p.yLow}`)].join(' ')
: ''; : '';
const showLabel = (index) => allRows.length <= 14 || index % Math.ceil(allRows.length / 14) === 0; const showLabel = (index: number) => allRows.length <= 14 || index % Math.ceil(allRows.length / 14) === 0;
return ( return (
<SvgFrame height={height} label="Forecast chart"> <SvgFrame height={height} label="Forecast chart">
@ -396,7 +432,7 @@ function ForecastChart({ historical, forecast }) {
{/* Historical line + area fill */} {/* Historical line + area fill */}
<polygon <polygon
points={`${pad.left},${pad.top + chartH} ${histLine} ${histPoints[histPoints.length - 1]?.x ?? pad.left},${pad.top + chartH}`} points={`${pad.left},${pad.top + chartH} ${histLine} ${lastHist?.x ?? pad.left},${pad.top + chartH}`}
fill="#7c3aed" opacity="0.10" fill="#7c3aed" opacity="0.10"
/> />
<polyline points={histLine} fill="none" stroke="#7c3aed" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" /> <polyline points={histLine} fill="none" stroke="#7c3aed" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" />
@ -458,7 +494,7 @@ function ForecastChart({ historical, forecast }) {
); );
} }
function Field({ label, children }) { function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
return ( return (
<label className="space-y-1.5"> <label className="space-y-1.5">
<span className="block text-xs font-medium text-muted-foreground">{label}</span> <span className="block text-xs font-medium text-muted-foreground">{label}</span>
@ -467,7 +503,12 @@ function Field({ label, children }) {
); );
} }
function ControlSelect({ value, onChange, children, className }) { function ControlSelect({ value, onChange, children, className }: {
value: string;
onChange: (value: string) => void;
children: ReactNode;
className?: string;
}) {
return ( return (
<select <select
value={value} value={value}
@ -489,7 +530,7 @@ export default function AnalyticsPage() {
const [includeInactive, setIncludeInactive] = useState(false); const [includeInactive, setIncludeInactive] = useState(false);
const [includeSkipped, setIncludeSkipped] = useState(true); const [includeSkipped, setIncludeSkipped] = useState(true);
const [trendMode, setTrendMode] = useState('line'); const [trendMode, setTrendMode] = useState('line');
const [visible, setVisible] = useState({ const [visible, setVisible] = useState<ChartVisibility>({
monthlyTrend: true, monthlyTrend: true,
expectedActual: true, expectedActual: true,
categorySpend: true, categorySpend: true,
@ -509,7 +550,8 @@ export default function AnalyticsPage() {
}), [billId, categoryId, includeInactive, includeSkipped, month, months, year]); }), [billId, categoryId, includeInactive, includeSkipped, month, months, year]);
// React Query handles caching, dedup, cancellation, and out-of-order responses. // React Query handles caching, dedup, cancellation, and out-of-order responses.
const { data, isPending, isFetching, error: queryError, refetch } = useAnalyticsSummary(params); const { data: dataRaw, isPending, isFetching, error: queryError, refetch } = useAnalyticsSummary(params);
const data = dataRaw as AnalyticsData | undefined;
const loading = isPending; const loading = isPending;
const error = queryError ? (queryError.message || 'Failed to load analytics.') : ''; const error = queryError ? (queryError.message || 'Failed to load analytics.') : '';