From 3b1e7e4da95c789d2d399a5fb76fe83a5f7a16dd Mon Sep 17 00:00:00 2001 From: null Date: Sat, 4 Jul 2026 23:29:39 -0500 Subject: [PATCH] =?UTF-8?q?refactor(ts):=20SpendingPage=20=E2=86=92=20Type?= =?UTF-8?q?Script?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YNAB-style spending view typed: CatEntry (category_id number|'other'| null) with a RealCatEntry narrowing for the budget-indexed paths, SpendingSummary/SpendingTx/IncomeTx/SpendingRule/CategoryGroup local types, budgets as Record, React Query setQueryData for optimistic budget/categorize edits, cast-at-boundary for api.* results. Dropped a pre-existing dead 'total' state. Co-Authored-By: Claude Opus 4.8 --- .../{SpendingPage.jsx => SpendingPage.tsx} | 276 +++++++++++------- 1 file changed, 177 insertions(+), 99 deletions(-) rename client/pages/{SpendingPage.jsx => SpendingPage.tsx} (83%) diff --git a/client/pages/SpendingPage.jsx b/client/pages/SpendingPage.tsx similarity index 83% rename from client/pages/SpendingPage.jsx rename to client/pages/SpendingPage.tsx index 4abe71a..a690cc5 100644 --- a/client/pages/SpendingPage.jsx +++ b/client/pages/SpendingPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef, type FormEvent } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { useSpendingSummary, useSpendingTransactions, useSpendingCategories, useCategoryGroups, @@ -18,39 +18,86 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { CategoryPicker } from '@/components/transactions/CategoryPicker'; -import { formatUSD } from '@/lib/money'; +import { formatUSD, asDollars } from '@/lib/money'; +import { errMessage } from '@/lib/utils'; +import type { Category } from '@/types'; const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; -function fmt(n) { - return formatUSD(n); +function fmt(v: number | null | undefined): string { + return formatUSD(asDollars(Number(v) || 0)); } -function settingEnabled(value, fallback = true) { +function settingEnabled(value: unknown, fallback = true): boolean { if (value === undefined || value === null || value === '') return fallback; return value === true || value === 'true'; } +type Level = 'over' | 'warn' | 'ok'; + +interface CatEntry { + category_id: number | 'other' | null; + category_name?: string; + amount: number; + budget?: number | null; + tx_count?: number; + avg_3mo?: number; + group_id?: number | null; +} +type RealCatEntry = CatEntry & { category_id: number }; + +interface SpendingSummary { + by_category?: CatEntry[]; + total_spending?: number; + income?: number; + uncategorized_amount?: number; + uncategorized_count?: number; +} + +interface SpendingTx { + id: number; + payee?: string | null; + date?: string | null; + amount: number; + spending_category_id?: number | string | null; + spending_category_name?: string | null; + [key: string]: unknown; +} +interface SpendingTxResponse { transactions?: SpendingTx[]; total?: number; pages?: number } + +interface IncomeTx { id: number; payee?: string | null; date?: string | null; amount: number } +interface IncomeResponse { transactions?: IncomeTx[]; total?: number; pages?: number } + +interface SpendingRule { id: number; merchant: string; category_name?: string; category_id?: number } +interface CategoryGroup { id: number; name: string } +interface CopyBudgetsResponse { copied: number; budgets?: { category_id: number; amount: number }[] } + +type Budgets = Record; + // pctBar() returns a progress-bar percent plus a YNAB-style status level: // - 'over': spending has exceeded the budget (Available < 0) // - 'warn': spending has crossed ~80% of the budget // - 'ok': on track -function pctBar(amount, budget) { +function pctBar(amount: number, budget: number | null | undefined): { pct: number; level: Level; over: boolean } | null { if (budget == null) return null; const pct = Math.min(100, Math.round((amount / budget) * 100)); - const level = amount > budget ? 'over' : (budget > 0 && amount / budget >= 0.8) ? 'warn' : 'ok'; + const level: Level = amount > budget ? 'over' : (budget > 0 && amount / budget >= 0.8) ? 'warn' : 'ok'; return { pct, level, over: level === 'over' }; } -const LEVEL_BAR_CLASS = { over: 'bg-destructive', warn: 'bg-amber-500', ok: 'bg-primary' }; -const LEVEL_TEXT_CLASS = { over: 'text-destructive', warn: 'text-amber-500', ok: 'text-emerald-500' }; +const LEVEL_BAR_CLASS: Record = { over: 'bg-destructive', warn: 'bg-amber-500', ok: 'bg-primary' }; +const LEVEL_TEXT_CLASS: Record = { over: 'text-destructive', warn: 'text-amber-500', ok: 'text-emerald-500' }; // ── Transaction row ────────────────────────────────────────────────────────── -function TxRow({ tx, categories, onCategorize }) { +function TxRow({ tx, categories, onCategorize }: { + tx: SpendingTx; + categories: Category[]; + onCategorize: (txId: number, categoryId: number | string | null, categoryName: string | null) => void; +}) { const [saving, setSaving] = useState(false); - const [rememberPrompt, setRememberPrompt] = useState(null); // { categoryId, categoryName } - const dismissTimer = useRef(null); + const [rememberPrompt, setRememberPrompt] = useState<{ categoryId: number | string | null; categoryName: string | null } | null>(null); + const dismissTimer = useRef | undefined>(undefined); // Auto-dismiss the "remember" prompt after 7 seconds useEffect(() => { @@ -59,7 +106,7 @@ function TxRow({ tx, categories, onCategorize }) { return () => clearTimeout(dismissTimer.current); }, [rememberPrompt]); - const handleSelect = async (categoryId) => { + const handleSelect = async (categoryId: number | string | null) => { setSaving(true); setRememberPrompt(null); try { @@ -69,7 +116,7 @@ function TxRow({ tx, categories, onCategorize }) { // Offer to remember the merchant rule (only when assigning a real category) if (categoryId) setRememberPrompt({ categoryId, categoryName: catName }); } catch (err) { - toast.error(err.message || 'Failed to categorize'); + toast.error(errMessage(err, 'Failed to categorize')); } finally { setSaving(false); } @@ -83,7 +130,7 @@ function TxRow({ tx, categories, onCategorize }) { await api.categorizeTransaction(tx.id, { category_id: rememberPrompt.categoryId, save_rule: true }); toast.success(`Rule saved — future ${tx.payee} transactions will be auto-categorized.`); } catch (err) { - toast.error(err.message || 'Failed to save rule'); + toast.error(errMessage(err, 'Failed to save rule')); } }; @@ -125,13 +172,19 @@ function TxRow({ tx, categories, onCategorize }) { // ── Budget edit inline ─────────────────────────────────────────────────────── -function BudgetEditor({ categoryId, year, month, initial, onSaved }) { +function BudgetEditor({ categoryId, year, month, initial, onSaved }: { + categoryId: number; + year: number; + month: number; + initial: number | null; + onSaved: (categoryId: number, amount: number | null) => void; +}) { const [editing, setEditing] = useState(false); - const [val, setVal] = useState(initial ?? ''); + const [val, setVal] = useState(initial != null ? String(initial) : ''); const save = async () => { const amount = val === '' ? null : parseFloat(val); - if (val !== '' && (isNaN(amount) || amount < 0)) { toast.error('Enter a valid amount'); return; } + if (val !== '' && (amount == null || isNaN(amount) || amount < 0)) { toast.error('Enter a valid amount'); return; } try { await api.setSpendingBudget({ category_id: categoryId, year, month, amount }); onSaved(categoryId, amount); @@ -169,10 +222,9 @@ function BudgetEditor({ categoryId, year, month, initial, onSaved }) { // ── Income section ─────────────────────────────────────────────────────────── -function IncomeSection({ year, month, totalIncome }) { +function IncomeSection({ year, month, totalIncome }: { year: number; month: number; totalIncome?: number }) { const [open, setOpen] = useState(false); - const [rows, setRows] = useState([]); - const [total, setTotal] = useState(0); + const [rows, setRows] = useState([]); const [page, setPage] = useState(1); const [pages, setPages] = useState(1); const [loading, setLoading] = useState(false); @@ -180,13 +232,12 @@ function IncomeSection({ year, month, totalIncome }) { const load = useCallback(async (p = 1) => { setLoading(true); try { - const d = await api.spendingIncome({ year, month, page: p }); + const d = await api.spendingIncome({ year, month, page: p }) as IncomeResponse; setRows(d.transactions || []); - setTotal(d.total || 0); setPages(d.pages || 1); setPage(p); } catch (err) { - toast.error(err.message || 'Failed to load income transactions'); + toast.error(errMessage(err, 'Failed to load income transactions')); } finally { setLoading(false); } @@ -254,9 +305,9 @@ function IncomeSection({ year, month, totalIncome }) { // ── Rules manager ──────────────────────────────────────────────────────────── -function RulesManager({ categories }) { +function RulesManager({ categories }: { categories: Category[] }) { const [open, setOpen] = useState(false); - const [rules, setRules] = useState([]); + const [rules, setRules] = useState([]); const [loading, setLoading] = useState(false); const [newMerchant, setNewMerchant] = useState(''); const [newCategory, setNewCategory] = useState(''); @@ -264,21 +315,21 @@ function RulesManager({ categories }) { const load = useCallback(async () => { setLoading(true); - try { setRules((await api.spendingCategoryRules()).rules || []); } + try { setRules(((await api.spendingCategoryRules()) as { rules?: SpendingRule[] }).rules || []); } catch { toast.error('Failed to load rules'); } finally { setLoading(false); } }, []); useEffect(() => { if (open) load(); }, [open, load]); - const deleteRule = async (id) => { + const deleteRule = async (id: number) => { try { await api.deleteSpendingRule(id); setRules(prev => prev.filter(r => r.id !== id)); } catch { toast.error('Failed to delete rule'); } }; - const addRule = async (e) => { + const addRule = async (e: FormEvent) => { e.preventDefault(); if (!newMerchant.trim() || !newCategory) return; setAdding(true); @@ -288,7 +339,7 @@ function RulesManager({ categories }) { await load(); toast.success('Rule saved.'); } catch (err) { - toast.error(err.message || 'Failed to save rule'); + toast.error(errMessage(err, 'Failed to save rule')); } finally { setAdding(false); } }; @@ -360,7 +411,11 @@ function RulesManager({ categories }) { // ── Spending settings menu ─────────────────────────────────────────────────── -function SpendingSettingsMenu({ settings, onToggle, saving }) { +function SpendingSettingsMenu({ settings, onToggle, saving }: { + settings: Record; + onToggle: (key: string, checked: boolean) => void; + saving: boolean; +}) { return ( @@ -407,20 +462,26 @@ function SpendingSettingsMenu({ settings, onToggle, saving }) { // ── Cover overspending ─────────────────────────────────────────────────────── -function CoverOverspendPicker({ cat, siblings, budgets, year, month, onCovered }) { +function CoverOverspendPicker({ cat, siblings, budgets, year, month, onCovered }: { + cat: RealCatEntry; + siblings: RealCatEntry[]; + budgets: Budgets; + year: number; + month: number; + onCovered: (updates: Record) => void; +}) { const [open, setOpen] = useState(false); const [saving, setSaving] = useState(false); const catBudget = budgets[cat.category_id] ?? cat.budget ?? 0; const overspend = Math.max(0, cat.amount - catBudget); - if (overspend <= 0) return null; const options = siblings .filter(s => s.category_id !== cat.category_id) .map(s => ({ ...s, available: (budgets[s.category_id] ?? s.budget ?? 0) - s.amount })) .filter(s => s.available > 0); - const cover = async (source) => { + const cover = async (source: RealCatEntry & { available: number }) => { const sourceBudget = budgets[source.category_id] ?? source.budget ?? 0; const amount = Math.min(overspend, source.available); setSaving(true); @@ -433,12 +494,14 @@ function CoverOverspendPicker({ cat, siblings, budgets, year, month, onCovered } toast.success(`Covered ${fmt(amount)} from ${source.category_name}.`); setOpen(false); } catch (err) { - toast.error(err.message || 'Failed to cover overspending'); + toast.error(errMessage(err, 'Failed to cover overspending')); } finally { setSaving(false); } }; + if (overspend <= 0) return null; + if (options.length === 0) { return No category has budget left to cover this; } @@ -474,14 +537,19 @@ function CoverOverspendPicker({ cat, siblings, budgets, year, month, onCovered } // ── Category groups manager ────────────────────────────────────────────────── -function CategoryGroupManager({ categories, groups, onGroupsChanged, onCategoriesChanged }) { +function CategoryGroupManager({ categories, groups, onGroupsChanged, onCategoriesChanged }: { + categories: Category[]; + groups: CategoryGroup[]; + onGroupsChanged: () => Promise | void; + onCategoriesChanged: () => Promise | void; +}) { const [open, setOpen] = useState(false); const [newGroupName, setNewGroupName] = useState(''); const [adding, setAdding] = useState(false); - const [editingId, setEditingId] = useState(null); + const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(''); - const addGroup = async (e) => { + const addGroup = async (e: FormEvent) => { e.preventDefault(); if (!newGroupName.trim()) return; setAdding(true); @@ -491,39 +559,39 @@ function CategoryGroupManager({ categories, groups, onGroupsChanged, onCategorie await onGroupsChanged(); toast.success('Group created.'); } catch (err) { - toast.error(err.message || 'Failed to create group'); + toast.error(errMessage(err, 'Failed to create group')); } finally { setAdding(false); } }; - const renameGroup = async (id) => { + const renameGroup = async (id: number) => { if (!editName.trim()) return; try { await api.updateCategoryGroup(id, { name: editName.trim() }); setEditingId(null); await onGroupsChanged(); } catch (err) { - toast.error(err.message || 'Failed to rename group'); + toast.error(errMessage(err, 'Failed to rename group')); } }; - const deleteGroup = async (id) => { + const deleteGroup = async (id: number) => { try { await api.deleteCategoryGroup(id); await onGroupsChanged(); toast.success('Group deleted.'); } catch (err) { - toast.error(err.message || 'Failed to delete group'); + toast.error(errMessage(err, 'Failed to delete group')); } }; - const setCategoryGroup = async (cat, groupId) => { + const setCategoryGroup = async (cat: Category, groupId: string) => { try { await api.updateCategory(cat.id, { name: cat.name, spending_enabled: true, group_id: groupId === '' ? null : Number(groupId) }); await onCategoriesChanged(); } catch (err) { - toast.error(err.message || 'Failed to update category group'); + toast.error(errMessage(err, 'Failed to update category group')); } }; @@ -614,6 +682,8 @@ function CategoryGroupManager({ categories, groups, onGroupsChanged, onCategorie // ── Main page ──────────────────────────────────────────────────────────────── +interface GroupBucket { group: { id: number | string; name: string }; entries: RealCatEntry[] } + export default function SpendingPage() { const now = new Date(); const [year, setYear] = useState(now.getFullYear()); @@ -621,29 +691,34 @@ export default function SpendingPage() { const queryClient = useQueryClient(); const [txPage, setTxPage] = useState(1); - const [activeCat, setActiveCat] = useState(undefined); // undefined = all - const { data: summary = null, isPending: loading, error: summaryErrObj } = useSpendingSummary(year, month); - const { data: txData, isFetching: txLoading, error: txErrObj } = useSpendingTransactions({ year, month, activeCat, page: txPage }); + const [activeCat, setActiveCat] = useState(undefined); // undefined = all + const { data: summaryRaw = null, isPending: loading, error: summaryErrObj } = useSpendingSummary(year, month); + const summary = summaryRaw as SpendingSummary | null; + const { data: txDataRaw, isFetching: txLoading, error: txErrObj } = useSpendingTransactions({ year, month, activeCat, page: txPage }); + const txData = txDataRaw as SpendingTxResponse | undefined; const transactions = useMemo(() => txData?.transactions || [], [txData]); const txTotal = txData?.total || 0; const txPages = txData?.pages || 1; const { data: categories = [], error: catErrObj } = useSpendingCategories(); - const { data: categoryGroups = [] } = useCategoryGroups(); + const { data: categoryGroupsRaw = [] } = useCategoryGroups(); + const categoryGroups = categoryGroupsRaw as CategoryGroup[]; const summaryError = summaryErrObj ? (summaryErrObj.message || 'Failed to load spending summary') : null; const txError = txErrObj ? (txErrObj.message || 'Failed to load transactions') : null; const catError = catErrObj ? (catErrObj.message || 'Failed to load categories') : null; // Optimistic edits write through the query cache so existing call sites work. - const setSummary = useCallback((u) => queryClient.setQueryData(['spending-summary', year, month], - prev => (typeof u === 'function' ? u(prev) : u)), [queryClient, year, month]); - const setTransactions = useCallback((u) => queryClient.setQueryData( - ['spending-transactions', year, month, activeCat ?? 'all', txPage], - prev => { - const nextList = typeof u === 'function' ? u(prev?.transactions || []) : u; - return prev ? { ...prev, transactions: nextList } : prev; - }), [queryClient, year, month, activeCat, txPage]); - const [budgets, setBudgets] = useState({}); // categoryId → amount (seeded from summary) + const setSummary = useCallback( + (u: (prev: SpendingSummary | null | undefined) => SpendingSummary | null | undefined) => + queryClient.setQueryData(['spending-summary', year, month], prev => u(prev)), + [queryClient, year, month]); + const setTransactions = useCallback( + (u: (prev: SpendingTx[]) => SpendingTx[]) => + queryClient.setQueryData( + ['spending-transactions', year, month, activeCat ?? 'all', txPage], + prev => (prev ? { ...prev, transactions: u(prev.transactions || []) } : prev)), + [queryClient, year, month, activeCat, txPage]); + const [budgets, setBudgets] = useState({}); // categoryId → amount (seeded from summary) const [copying, setCopying] = useState(false); - const [spendingSettings, setSpendingSettings] = useState({}); + const [spendingSettings, setSpendingSettings] = useState>({}); const [savingSpendingSetting, setSavingSpendingSetting] = useState(false); // loadCategories is stable — categories don't vary by month @@ -661,7 +736,7 @@ export default function SpendingPage() { }, [txPage, queryClient]); useEffect(() => { - api.settings().then(setSpendingSettings).catch(() => {}); + api.settings().then(s => setSpendingSettings((s as Record) || {})).catch(() => {}); }, []); // Reset to page 1 whenever the month or category filter changes. @@ -671,21 +746,21 @@ export default function SpendingPage() { // (this is what loadSummary used to do inline). useEffect(() => { if (!summary) return; - const bmap = {}; - (summary.by_category || []).forEach(c => { if (c.category_id && c.budget != null) bmap[c.category_id] = c.budget; }); + const bmap: Budgets = {}; + (summary.by_category || []).forEach(c => { if (c.category_id != null && c.budget != null) bmap[c.category_id] = c.budget; }); setBudgets(bmap); }, [summary]); - async function saveSpendingSetting(patch, successMessage) { + async function saveSpendingSetting(patch: Record, successMessage?: string) { setSavingSpendingSetting(true); setSpendingSettings(prev => ({ ...prev, ...patch })); try { const next = await api.saveSettings(patch); - setSpendingSettings(prev => ({ ...prev, ...next })); + setSpendingSettings(prev => ({ ...prev, ...(next as Record) })); if (successMessage) toast.success(successMessage); } catch (err) { - toast.error(err.message || 'Failed to update spending setting'); - api.settings().then(s => setSpendingSettings(prev => ({ ...prev, ...s }))).catch(() => {}); + toast.error(errMessage(err, 'Failed to update spending setting')); + api.settings().then(s => setSpendingSettings(prev => ({ ...prev, ...(s as Record) }))).catch(() => {}); } finally { setSavingSpendingSetting(false); } @@ -694,67 +769,69 @@ export default function SpendingPage() { const handleCopyBudgets = async () => { setCopying(true); try { - const d = await api.copySpendingBudgets({ year, month }); + const d = await api.copySpendingBudgets({ year, month }) as CopyBudgetsResponse; if (d.copied === 0) { toast.info('No budgets found in the previous month to copy.'); } else { // Update local budget state from server response - const bmap = {}; + const bmap: Budgets = {}; (d.budgets || []).forEach(b => { bmap[b.category_id] = b.amount; }); setBudgets(bmap); setSummary(prev => prev ? { ...prev, - by_category: prev.by_category.map(c => - c.category_id && bmap[c.category_id] != null - ? { ...c, budget: bmap[c.category_id] } - : c - ), + by_category: (prev.by_category || []).map(c => { + const key = c.category_id; + const val = key == null ? undefined : bmap[key]; + return val != null ? { ...c, budget: val } : c; + }), } : prev); toast.success(`${d.copied} budget${d.copied !== 1 ? 's' : ''} copied from last month.`); } } catch (err) { - toast.error(err.message || 'Failed to copy budgets'); + toast.error(errMessage(err, 'Failed to copy budgets')); } finally { setCopying(false); } }; - const navMonth = (dir) => { + const navMonth = (dir: number) => { let m = month + dir, y = year; if (m > 12) { m = 1; y++; } if (m < 1) { m = 12; y--; } setMonth(m); setYear(y); setActiveCat(undefined); setTxPage(1); }; - const handleCategorize = (txId, categoryId, categoryName) => { + const handleCategorize = (txId: number, categoryId: number | string | null, categoryName: string | null) => { setTransactions(prev => prev.map(t => t.id === txId ? { ...t, spending_category_id: categoryId, spending_category_name: categoryName } : t )); loadSummary(); }; - const handleBudgetSaved = (categoryId, amount) => { + const handleBudgetSaved = (categoryId: number, amount: number | null) => { setBudgets(prev => ({ ...prev, [categoryId]: amount })); setSummary(prev => { if (!prev) return prev; return { ...prev, - by_category: prev.by_category.map(c => + by_category: (prev.by_category || []).map(c => c.category_id === categoryId ? { ...c, budget: amount } : c ), }; }); }; - const handleBudgetsCovered = (updates) => { + const handleBudgetsCovered = (updates: Record) => { setBudgets(prev => ({ ...prev, ...updates })); setSummary(prev => { if (!prev) return prev; return { ...prev, - by_category: prev.by_category.map(c => - c.category_id in updates ? { ...c, budget: updates[c.category_id] } : c - ), + by_category: (prev.by_category || []).map(c => { + const key = c.category_id; + const val = key == null ? undefined : updates[key]; + return val != null ? { ...c, budget: val } : c; + }), }; }); }; @@ -769,7 +846,7 @@ export default function SpendingPage() { await loadSummary(); }; - const selectCat = (catId) => { + const selectCat = (catId: number | null) => { setActiveCat(prev => prev === catId ? undefined : catId); setTxPage(1); }; @@ -796,11 +873,12 @@ export default function SpendingPage() { ); } - const realCatEntries = summary?.by_category?.filter(c => typeof c.category_id === 'number') || []; + const realCatEntries = (summary?.by_category || []).filter( + (c): c is RealCatEntry => typeof c.category_id === 'number'); const uncatEntry = summary?.by_category?.find(c => c.category_id === null) || null; const otherEntry = summary?.by_category?.find(c => c.category_id === 'other') || null; - const totalBudgeted = Object.values(budgets).reduce((sum, b) => sum + (Number(b) || 0), 0); + const totalBudgeted = Object.values(budgets).reduce((sum, b) => sum + (Number(b) || 0), 0); const totalSpending = summary?.total_spending || 0; const income = summary?.income || 0; const readyToAssign = income - totalBudgeted; @@ -811,12 +889,12 @@ export default function SpendingPage() { const showCover = settingEnabled(spendingSettings.spending_show_cover_overspend); const showGroups = settingEnabled(spendingSettings.spending_group_categories, false); - const groupedEntries = (() => { + const groupedEntries: GroupBucket[] | null = (() => { if (!showGroups || categoryGroups.length === 0) return null; - const byGroup = new Map(categoryGroups.map(g => [g.id, { group: g, entries: [] }])); - const ungrouped = []; + const byGroup = new Map(categoryGroups.map(g => [g.id, { group: g, entries: [] }])); + const ungrouped: RealCatEntry[] = []; realCatEntries.forEach(c => { - if (c.group_id && byGroup.has(c.group_id)) byGroup.get(c.group_id).entries.push(c); + if (c.group_id && byGroup.has(c.group_id)) byGroup.get(c.group_id)!.entries.push(c); else ungrouped.push(c); }); const groups = [...byGroup.values()].filter(g => g.entries.length > 0); @@ -824,7 +902,7 @@ export default function SpendingPage() { return groups; })(); - const renderCategoryRow = (cat) => { + const renderCategoryRow = (cat: RealCatEntry) => { const budget = budgets[cat.category_id] ?? cat.budget ?? null; const bar = pctBar(cat.amount, budget); const isActive = activeCat === cat.category_id; @@ -846,7 +924,7 @@ export default function SpendingPage() {
{cat.tx_count} transaction{cat.tx_count !== 1 ? 's' : ''} - {showAvg && cat.avg_3mo > 0 && ( + {showAvg && (cat.avg_3mo ?? 0) > 0 && ( avg {fmt(cat.avg_3mo)}/mo )} @@ -882,9 +960,9 @@ export default function SpendingPage() { Uncategorized - {fmt(uncatEntry.amount)} + {fmt(uncatEntry?.amount)}
-

{uncatEntry.tx_count} transaction{uncatEntry.tx_count !== 1 ? 's' : ''}

+

{uncatEntry?.tx_count} transaction{uncatEntry?.tx_count !== 1 ? 's' : ''}

); @@ -897,9 +975,9 @@ export default function SpendingPage() { Other categories - {fmt(otherEntry.amount)} + {fmt(otherEntry?.amount)} -

{otherEntry.tx_count} transaction{otherEntry.tx_count !== 1 ? 's' : ''}

+

{otherEntry?.tx_count} transaction{otherEntry?.tx_count !== 1 ? 's' : ''}

); @@ -938,7 +1016,7 @@ export default function SpendingPage() {
{summaryError} -
@@ -978,8 +1056,8 @@ export default function SpendingPage() {

Uncategorized

{fmt(summary?.uncategorized_amount)}

- {summary?.uncategorized_count > 0 && ( -

{summary.uncategorized_count} transaction{summary.uncategorized_count !== 1 ? 's' : ''}

+ {(summary?.uncategorized_count ?? 0) > 0 && ( +

{summary?.uncategorized_count} transaction{summary?.uncategorized_count !== 1 ? 's' : ''}

)}
@@ -999,7 +1077,7 @@ export default function SpendingPage() {
{catError} -