From 8265b4a97f921252d59abc1030383f00fade1d06 Mon Sep 17 00:00:00 2001 From: null Date: Sun, 5 Jul 2026 12:06:40 -0500 Subject: [PATCH] refactor(ts): type the spending API responses (Track C) Promoted SpendingPage's local shapes into @/types (SpendingCategoryEntry, SpendingSummary, SpendingTransaction(s), SpendingIncome*, SpendingRule, CategoryGroup, CopyBudgetsResponse) and wired the /spending/* + category- groups endpoints with get/post. Hooks return typed data; 6 casts removed; 2 orphaned type aliases dropped. typecheck/lint/build clean. Co-Authored-By: Claude Opus 4.8 --- HISTORY.md | 1 + client/api.ts | 14 +++++---- client/pages/SpendingPage.tsx | 58 ++++++++++------------------------- client/types.ts | 34 ++++++++++++++++++++ 4 files changed, 59 insertions(+), 48 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 450d53f..4a1d5e6 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -3,6 +3,7 @@ ### 🔌 API response typing cleanup (Track C) — promoting page shapes into `@/types`, endpoint by endpoint +- **[Client] Spending domain typed.** Promoted `SpendingPage`'s local response interfaces into `@/types` (`SpendingCategoryEntry`, `SpendingSummary`, `SpendingTransaction`/`…Response`, `SpendingIncomeTx`/`…Response`, `SpendingRule`, `CategoryGroup`, `CopyBudgetsResponse`) and wired `spendingSummary`/`spendingTransactions`/`spendingIncome`/`spendingCategoryRules`/`copySpendingBudgets`/`categoryGroups` with `get`/`post`. `useSpendingSummary`/`useSpendingTransactions`/`useCategoryGroups` now return typed data; the 6 spending `as {…}` casts are gone. Removed 2 now-unused type aliases the cast-removal orphaned (Track E). - **[Client] Bank-ledger domain typed end-to-end.** Promoted the `BankTransactionsPage` local interfaces (`Tx`/`Account`/`Ledger`/`LedgerSummary`/`CategoryBreakdown`/auto-categorize preview) into `@/types` as `BankLedgerTransaction` (extends the existing `BankTransaction`, so raw amounts stay branded `Cents`), `BankAccount`, `BankLedger`, `BankLedgerSummary`, `BankCategoryBreakdown`, `AutoCategorizePreview`. Wired the endpoints in `api.ts` (`bankTransactionsLedger` → `get`, `matchTransaction`/`unmatchTransaction` → `{ transaction }`, `ignoreTransaction`/`unignoreTransaction` → the transaction, `applyTransactionMerchantMatch`/`autoCategorizeTransactions` → their result shapes), so `useBankLedger`'s `data` is now typed and **all 9 `as {…}` casts in the page are gone** — a server field rename now fails in `@/types`, not silently at 9 call sites. Dropped a now-dead `Cents` import (Track E). typecheck/lint/build clean. ### 🧮 Cross-surface money reconciliation harness (Track B) — pins "same number, same truth" diff --git a/client/api.ts b/client/api.ts index ece1f80..2695def 100644 --- a/client/api.ts +++ b/client/api.ts @@ -1,6 +1,8 @@ import type { Bill, Category, Payment, TrackerResponse, BankLedger, BankLedgerTransaction, AutoCategorizePreview, + SpendingSummary, SpendingTransactionsResponse, SpendingIncomeResponse, + SpendingRule, CopyBudgetsResponse, CategoryGroup, } from '@/types'; // Fetch CSRF token from the server once and cache in memory. @@ -114,14 +116,14 @@ export const api = { acknowledgeVersion: () => post('/auth/acknowledge-version'), loginHistory: () => get('/auth/login-history'), // Spending - spendingSummary: (p?: QueryParams) => get('/spending/summary', p), - spendingTransactions:(p?: QueryParams) => get('/spending/transactions', p), + spendingSummary: (p?: QueryParams) => get('/spending/summary', p), + spendingTransactions:(p?: QueryParams) => get('/spending/transactions', p), categorizeTransaction: (id: Id, d: Body) => patch(`/spending/transactions/${id}/category`, d), spendingBudgets: (p?: QueryParams) => get('/spending/budgets', p), setSpendingBudget: (d: Body) => put('/spending/budgets', d), - copySpendingBudgets: (d: Body) => post('/spending/budgets/copy', d), - spendingIncome: (p?: QueryParams) => get('/spending/income', p), - spendingCategoryRules: () => get('/spending/category-rules'), + copySpendingBudgets: (d: Body) => post('/spending/budgets/copy', d), + spendingIncome: (p?: QueryParams) => get('/spending/income', p), + spendingCategoryRules: () => get<{ rules?: SpendingRule[] }>('/spending/category-rules'), addSpendingRule: (d: Body) => post('/spending/category-rules', d), deleteSpendingRule: (id: Id) => del(`/spending/category-rules/${id}`), @@ -344,7 +346,7 @@ export const api = { restoreCategory: (id: Id) => post(`/categories/${id}/restore`), // Category groups - categoryGroups: () => get('/categories/groups'), + categoryGroups: () => get('/categories/groups'), createCategoryGroup: (data: Body) => post('/categories/groups', data), updateCategoryGroup: (id: Id, data: Body) => put(`/categories/groups/${id}`, data), deleteCategoryGroup: (id: Id) => del(`/categories/groups/${id}`), diff --git a/client/pages/SpendingPage.tsx b/client/pages/SpendingPage.tsx index a690cc5..5f72254 100644 --- a/client/pages/SpendingPage.tsx +++ b/client/pages/SpendingPage.tsx @@ -20,7 +20,16 @@ import { import { CategoryPicker } from '@/components/transactions/CategoryPicker'; import { formatUSD, asDollars } from '@/lib/money'; import { errMessage } from '@/lib/utils'; -import type { Category } from '@/types'; +import type { + Category, + SpendingCategoryEntry as CatEntry, + SpendingSummary, + SpendingTransaction as SpendingTx, + SpendingTransactionsResponse as SpendingTxResponse, + SpendingIncomeTx as IncomeTx, + SpendingRule, + CategoryGroup, +} from '@/types'; const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; @@ -35,43 +44,8 @@ function settingEnabled(value: unknown, fallback = true): boolean { 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: @@ -232,7 +206,7 @@ function IncomeSection({ year, month, totalIncome }: { year: number; month: numb const load = useCallback(async (p = 1) => { setLoading(true); try { - const d = await api.spendingIncome({ year, month, page: p }) as IncomeResponse; + const d = await api.spendingIncome({ year, month, page: p }); setRows(d.transactions || []); setPages(d.pages || 1); setPage(p); @@ -315,7 +289,7 @@ function RulesManager({ categories }: { categories: Category[] }) { const load = useCallback(async () => { setLoading(true); - try { setRules(((await api.spendingCategoryRules()) as { rules?: SpendingRule[] }).rules || []); } + try { setRules((await api.spendingCategoryRules()).rules || []); } catch { toast.error('Failed to load rules'); } finally { setLoading(false); } }, []); @@ -693,15 +667,15 @@ export default function SpendingPage() { const [txPage, setTxPage] = useState(1); 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 summary = summaryRaw; const { data: txDataRaw, isFetching: txLoading, error: txErrObj } = useSpendingTransactions({ year, month, activeCat, page: txPage }); - const txData = txDataRaw as SpendingTxResponse | undefined; + const txData = txDataRaw; const transactions = useMemo(() => txData?.transactions || [], [txData]); const txTotal = txData?.total || 0; const txPages = txData?.pages || 1; const { data: categories = [], error: catErrObj } = useSpendingCategories(); const { data: categoryGroupsRaw = [] } = useCategoryGroups(); - const categoryGroups = categoryGroupsRaw as CategoryGroup[]; + const categoryGroups = categoryGroupsRaw; 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; @@ -769,7 +743,7 @@ export default function SpendingPage() { const handleCopyBudgets = async () => { setCopying(true); try { - const d = await api.copySpendingBudgets({ year, month }) as CopyBudgetsResponse; + const d = await api.copySpendingBudgets({ year, month }); if (d.copied === 0) { toast.info('No budgets found in the previous month to copy.'); } else { diff --git a/client/types.ts b/client/types.ts index e770bcf..0e6f49c 100644 --- a/client/types.ts +++ b/client/types.ts @@ -88,6 +88,40 @@ export interface AutoCategorizePreview { categories?: { name: string; count?: number }[]; } +// The spending page (GET /spending/*). Amounts are dollars (server fromCents); +// kept as plain `number` because the page works in numbers via a local formatter. +export interface SpendingCategoryEntry { + category_id: number | 'other' | null; + category_name?: string; + amount: number; + budget?: number | null; + tx_count?: number; + avg_3mo?: number; + group_id?: number | null; +} +export interface SpendingSummary { + by_category?: SpendingCategoryEntry[]; + total_spending?: number; + income?: number; + uncategorized_amount?: number; + uncategorized_count?: number; +} +export interface SpendingTransaction { + id: number; + payee?: string | null; + date?: string | null; + amount: number; + spending_category_id?: number | string | null; + spending_category_name?: string | null; + [key: string]: unknown; +} +export interface SpendingTransactionsResponse { transactions?: SpendingTransaction[]; total?: number; pages?: number } +export interface SpendingIncomeTx { id: number; payee?: string | null; date?: string | null; amount: number } +export interface SpendingIncomeResponse { transactions?: SpendingIncomeTx[]; total?: number; pages?: number } +export interface SpendingRule { id: number; merchant: string; category_name?: string; category_id?: number } +export interface CategoryGroup { id: number; name: string } +export interface CopyBudgetsResponse { copied: number; budgets?: { category_id: number; amount: number }[] } + // A bill's month status. Mirrors the server's statusService. export type TrackerStatus = | 'paid' | 'autodraft' | 'upcoming' | 'due_soon' | 'late' | 'missed' | 'skipped';