refactor(ts): AnalyticsPage → TypeScript (SVG charts + forecast); drop unused Skeleton import
This commit is contained in:
parent
f15046bce3
commit
818df6d410
|
|
@ -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.') : '';
|
||||||
|
|
||||||
Loading…
Reference in New Issue