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 { 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.') : '';
|
||||
|
||||
Loading…
Reference in New Issue