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 { formatUSD, formatUSDWhole } from '@/lib/money';
import { useMemo, useState, type ReactNode } from 'react';
import { formatUSD, formatUSDWhole, asDollars } from '@/lib/money';
import { Printer, RefreshCw, RotateCcw } from 'lucide-react';
import { useAnalyticsSummary } from '@/hooks/useQueries';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/Skeleton';
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 MONTH_OPTIONS = [
const MONTH_OPTIONS: [string, string][] = [
['1', 'January'], ['2', 'February'], ['3', 'March'], ['4', 'April'],
['5', 'May'], ['6', 'June'], ['7', 'July'], ['8', 'August'],
['9', 'September'], ['10', 'October'], ['11', 'November'], ['12', 'December'],
];
const CHART_OPTIONS = [
const CHART_OPTIONS: [string, string][] = [
['monthlyTrend', 'Monthly trend'],
['expectedActual', 'Expected vs actual'],
['categorySpend', 'Category spend'],
@ -26,24 +54,24 @@ function currentMonth() {
return { year: now.getFullYear(), month: now.getMonth() + 1 };
}
function money(value) {
return formatUSDWhole(value);
function money(value: number | null | undefined): string {
return formatUSDWhole(asDollars(Number(value) || 0));
}
function fullMoney(value) {
return formatUSD(value);
function fullMoney(value: number | null | undefined): string {
return formatUSD(asDollars(Number(value) || 0));
}
function formatRange(range) {
function formatRange(range?: AnalyticsRange): string {
if (!range?.start || !range?.end) return 'Selected range';
return `${range.start.slice(0, 7)} through ${range.end.slice(0, 7)}`;
}
function hasData(rows, keys) {
return rows?.some(row => keys.some(key => Number(row[key]) > 0));
function hasData(rows: readonly unknown[] | undefined, keys: string[]): boolean {
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 (
<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}
@ -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 (
<section className="analytics-chart surface-elevated p-5">
<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 (
<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">
@ -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 />;
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 />;
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);
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',
skipped: 'bg-sky-500/70 border-sky-400/40',
missed: 'bg-red-500/75 border-red-400/40',
no_data: 'bg-muted border-border',
};
function Heatmap({ heatmap }) {
function Heatmap({ heatmap }: { heatmap?: HeatmapData }) {
const rows = heatmap?.rows || [];
const months = heatmap?.months || [];
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>
</div>
{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 (
<div key={`${row.bill_id}-${month.key}`} className="flex items-center justify-center px-1 py-2">
<span
@ -275,12 +308,12 @@ function Heatmap({ heatmap }) {
</div>
</div>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
{[
{([
['paid', 'Paid'],
['skipped', 'Skipped'],
['missed', 'Missed'],
['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 className={cn('h-3 w-3 rounded border', HEATMAP_CLASS[status])} />
{label}
@ -293,7 +326,7 @@ function Heatmap({ heatmap }) {
// ─── Forecast ────────────────────────────────────────────────────────────────
function linearForecast(rows, horizonMonths) {
function linearForecast(rows: TrendRow[], horizonMonths: number): ForecastRow[] {
if (rows.length < 3) return [];
const n = rows.length;
@ -309,8 +342,10 @@ function linearForecast(rows, horizonMonths) {
const residuals = ys.map((y, i) => y - (slope * i + intercept));
const stdDev = Math.sqrt(residuals.reduce((s, r) => s + r * r, 0) / n);
const lastRow = rows[n - 1];
const [lastYear, lastMonthNum] = lastRow.month.split('-').map(Number);
const lastRow = rows[n - 1]!;
const parts = lastRow.month.split('-').map(Number);
const lastYear = parts[0] ?? 0;
const lastMonthNum = parts[1] ?? 1;
return Array.from({ length: horizonMonths }, (_, 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];
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);
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 y = pad.top + chartH - (value / maxVal) * chartH;
return { x, y };
@ -355,11 +390,12 @@ function ForecastChart({ historical, forecast }) {
}));
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
? `${histPoints[histPoints.length - 1].x},${histPoints[histPoints.length - 1].y} ` +
const foreLine = forePoints.length && lastHist
? `${lastHist.x},${lastHist.y} ` +
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(' ')
: '';
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 (
<SvgFrame height={height} label="Forecast chart">
@ -396,7 +432,7 @@ function ForecastChart({ historical, forecast }) {
{/* Historical line + area fill */}
<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"
/>
<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 (
<label className="space-y-1.5">
<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 (
<select
value={value}
@ -489,7 +530,7 @@ export default function AnalyticsPage() {
const [includeInactive, setIncludeInactive] = useState(false);
const [includeSkipped, setIncludeSkipped] = useState(true);
const [trendMode, setTrendMode] = useState('line');
const [visible, setVisible] = useState({
const [visible, setVisible] = useState<ChartVisibility>({
monthlyTrend: true,
expectedActual: true,
categorySpend: true,
@ -509,7 +550,8 @@ export default function AnalyticsPage() {
}), [billId, categoryId, includeInactive, includeSkipped, month, months, year]);
// 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 error = queryError ? (queryError.message || 'Failed to load analytics.') : '';