diff --git a/client/pages/AnalyticsPage.jsx b/client/pages/AnalyticsPage.tsx similarity index 88% rename from client/pages/AnalyticsPage.jsx rename to client/pages/AnalyticsPage.tsx index 211dff9..bbdb2cb 100644 --- a/client/pages/AnalyticsPage.jsx +++ b/client/pages/AnalyticsPage.tsx @@ -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)[key]) > 0)); } -function EmptyState({ label = 'No analytics data for this selection.' }) { +function EmptyState({ label = 'No analytics data for this selection.' }: { label?: string }) { return (
{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 (
@@ -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 (
@@ -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 ; 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 ; 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 ; @@ -226,14 +259,14 @@ function DonutChart({ rows }) { ); } -const HEATMAP_CLASS = { +const HEATMAP_CLASS: Record = { 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 ; @@ -260,7 +293,7 @@ function Heatmap({ heatmap }) {

{row.category_name}

{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 (
- {[ + {([ ['paid', 'Paid'], ['skipped', 'Skipped'], ['missed', 'Missed'], ['no_data', 'No data'], - ].map(([status, label]) => ( + ] as [string, string][]).map(([status, label]) => ( {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 ; @@ -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 ( @@ -396,7 +432,7 @@ function ForecastChart({ historical, forecast }) { {/* Historical line + area fill */} @@ -458,7 +494,7 @@ function ForecastChart({ historical, forecast }) { ); } -function Field({ label, children }) { +function Field({ label, children }: { label: ReactNode; children: ReactNode }) { return (