From 65a477cab19572aec411a653c540bad17860a571 Mon Sep 17 00:00:00 2001 From: null Date: Sun, 5 Jul 2026 12:16:03 -0500 Subject: [PATCH] refactor(ts): type snowball projection + settings responses (Track C) Promoted SnowballProjection (+details) and SnowballSettings into @/types; wired snowball (Bill[]), snowballSettings/saveSnowballSettings, and snowballProjection with get/_fetch. Removed the projection/settings/ bills casts. Plan-lifecycle SnowballPlan casts kept (component-owned type). typecheck/lint/build clean. Co-Authored-By: Claude Opus 4.8 --- HISTORY.md | 1 + client/api.ts | 9 +++--- client/pages/SnowballPage.tsx | 54 +++++++++-------------------------- client/types.ts | 34 ++++++++++++++++++++++ 4 files changed, 54 insertions(+), 44 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index f689a80..848f403 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] Snowball projection + settings typed.** Promoted `SnowballProjection` (+ `SnowballProjectionDetail`/`AvalancheProjectionDetail`/`SnowballDebtProjection`) and `SnowballSettings` into `@/types`, and wired `snowball` (β†’ `Bill[]`), `snowballSettings`/`saveSnowballSettings` (β†’ `SnowballSettings`), and `snowballProjection` (β†’ `SnowballProjection`). Removed the projection/settings/bills casts. (The plan-lifecycle endpoints still cast to `SnowballPlan`, which is a component-owned type in `PlanHistoryPanel` β€” left as-is to avoid a cross-file type move for marginal gain.) - **[Client] Subscriptions domain typed.** Promoted the `SubscriptionsPage` shapes into `@/types` (`Subscription`, `SubscriptionSummary`/`…TopType`, `SubscriptionsResponse`, `Recommendation` + `RecommendationEvidence`/`…Transaction`/`ExistingBillMatch`/`CatalogMatch`, `SubscriptionTx`) and wired `subscriptions`/`subscriptionRecommendations`/`subscriptionTransactionMatches`/`createSubscriptionFromRecommendation`/`matchRecommendationToBill` with `get`/`post`. Dropped the `(r: any)` in `useSubscriptionRecommendations` (now typed) and the 5 page casts; removed an unused `CatalogMatch` import (Track E). - **[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. diff --git a/client/api.ts b/client/api.ts index 8733b36..5a8ab52 100644 --- a/client/api.ts +++ b/client/api.ts @@ -4,6 +4,7 @@ import type { SpendingSummary, SpendingTransactionsResponse, SpendingIncomeResponse, SpendingRule, CopyBudgetsResponse, CategoryGroup, SubscriptionsResponse, Recommendation, SubscriptionTx, + SnowballProjection, SnowballSettings, } from '@/types'; // Fetch CSRF token from the server once and cache in memory. @@ -323,11 +324,11 @@ export const api = { undoAutoMatch: (id: Id) => post(`/payments/${id}/undo-auto`), // Snowball - snowball: () => get('/snowball'), - snowballSettings: () => get('/snowball/settings'), - saveSnowballSettings: (data: Body) => _fetch('PATCH', '/snowball/settings', data), + snowball: () => get('/snowball'), + snowballSettings: () => get('/snowball/settings'), + saveSnowballSettings: (data: Body) => _fetch('PATCH', '/snowball/settings', data), saveSnowballOrder: (items: Body) => _fetch('PATCH', '/snowball/order', items), - snowballProjection: (params: QueryParams = {}) => get(`/snowball/projection${queryString(params)}`), + snowballProjection: (params: QueryParams = {}) => get(`/snowball/projection${queryString(params)}`), snowballPlans: () => get('/snowball/plans'), snowballActivePlan: () => get('/snowball/plans/active'), startSnowballPlan: (data: Body) => post('/snowball/plans', data), diff --git a/client/pages/SnowballPage.tsx b/client/pages/SnowballPage.tsx index f694a3b..82e5b7e 100644 --- a/client/pages/SnowballPage.tsx +++ b/client/pages/SnowballPage.tsx @@ -19,7 +19,13 @@ import PlanStatusBanner, { type ActivePlan } from '@/components/snowball/PlanSta import PlanHistoryPanel, { type SnowballPlan } from '@/components/snowball/PlanHistoryPanel'; import * as AlertDialog from '@radix-ui/react-alert-dialog'; import { formatUSD, formatUSDWhole, asDollars } from '@/lib/money'; -import type { Bill, Category } from '@/types'; +import type { + Bill, Category, + SnowballProjection as Projection, + SnowballProjectionDetail as SnowballProj, + AvalancheProjectionDetail as AvalancheProj, + SnowballSettings, +} from '@/types'; // ── formatters ──────────────────────────────────────────────────────────────── function fmt(val: number | null | undefined): string { @@ -51,38 +57,6 @@ function isRamseyOrdered(debts: Bill[]): boolean { return debts.every((debt, index) => debt.id === sorted[index]?.id); } -interface DebtProj { - id: number; - name: string; - payoff_display?: string; - months?: number; - total_interest?: number; - current_balance?: number; -} -interface SnowballProj { - debts: DebtProj[]; - skipped: { name: string }[]; - payoff_display?: string; - months_to_freedom?: number; - total_interest_paid?: number; - capped?: boolean; -} -interface AvalancheProj { - months_to_freedom?: number; - total_interest_paid?: number; - payoff_display?: string; -} -interface Projection { - snowball?: SnowballProj; - avalanche?: AvalancheProj; -} - -interface SnowballSettings { - extra_payment?: number; - ramsey_mode?: boolean; - ready_current_on_bills?: boolean; - ready_emergency_fund?: boolean; -} interface ReadinessItem { id: string; @@ -347,11 +321,11 @@ function ReadinessStrip({ items, readyCount, totalCount, allReady, onToggle, dis export default function SnowballPage() { const queryClient = useQueryClient(); const { data: billsRaw = [], isPending: billsPending, error: snowballError } = useSnowball(); - const bills = (billsRaw ?? []) as Bill[]; + const bills = billsRaw ?? []; const { data: categoriesRaw = [] } = useCategories(); const categories = (categoriesRaw ?? []) as Category[]; const { data: settingsRaw } = useSnowballSettings(); - const settings = settingsRaw as SnowballSettings | undefined; + const settings = settingsRaw; const { data: activePlanRaw = null } = useSnowballActivePlan(); const activePlan = (activePlanRaw ?? null) as SnowballPlan | null; const { data: allPlansRaw = [] } = useSnowballPlans(); @@ -395,7 +369,7 @@ export default function SnowballPage() { try { const extra = parseFloat(typedExtraRef.current); const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {}; - setProjection(await api.snowballProjection(params) as Projection); + setProjection(await api.snowballProjection(params)); setProjectionError(false); } catch { setProjectionError(true); @@ -491,7 +465,7 @@ export default function SnowballPage() { if (val === extraPaymentRef.current) return; setSavingSettings(true); try { - const result = await api.saveSnowballSettings({ extra_payment: val === '' ? 0 : parseFloat(val) }) as SnowballSettings; + const result = await api.saveSnowballSettings({ extra_payment: val === '' ? 0 : parseFloat(val) }); const saved = Number(result.extra_payment) > 0 ? String(result.extra_payment) : ''; extraPaymentRef.current = saved; setExtraPayment(saved); @@ -504,7 +478,7 @@ export default function SnowballPage() { const handleRamseyModeChange = async (checked: boolean) => { setSavingSettings(true); try { - const result = await api.saveSnowballSettings({ ramsey_mode: checked }) as SnowballSettings; + const result = await api.saveSnowballSettings({ ramsey_mode: checked }); const nextMode = result.ramsey_mode !== false; setRamseyMode(nextMode); setDirty(false); @@ -530,7 +504,7 @@ export default function SnowballPage() { setSavingSettings(true); try { - const result = await api.saveSnowballSettings(payload) as SnowballSettings; + const result = await api.saveSnowballSettings(payload); setReadyCurrentOnBills(!!result.ready_current_on_bills); setReadyEmergencyFund(!!result.ready_emergency_fund); } catch (err) { @@ -583,7 +557,7 @@ export default function SnowballPage() { const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {}; try { setProjectionLoading(true); - const result = await api.snowballProjection(params) as Projection; + const result = await api.snowballProjection(params); setProjection(result); setProjectionError(false); } catch { diff --git a/client/types.ts b/client/types.ts index ba8963a..41376ff 100644 --- a/client/types.ts +++ b/client/types.ts @@ -203,6 +203,40 @@ export interface Recommendation { transactions?: RecommendationTransaction[]; [key: string]: unknown; } +// The snowball projection (GET /snowball/projection) and settings +// (PATCH /snowball/settings). Amounts are dollars kept as plain `number`. +export interface SnowballDebtProjection { + id: number; + name: string; + payoff_display?: string; + months?: number; + total_interest?: number; + current_balance?: number; +} +export interface SnowballProjectionDetail { + debts: SnowballDebtProjection[]; + skipped: { name: string }[]; + payoff_display?: string; + months_to_freedom?: number; + total_interest_paid?: number; + capped?: boolean; +} +export interface AvalancheProjectionDetail { + months_to_freedom?: number; + total_interest_paid?: number; + payoff_display?: string; +} +export interface SnowballProjection { + snowball?: SnowballProjectionDetail; + avalanche?: AvalancheProjectionDetail; +} +export interface SnowballSettings { + extra_payment?: number; + ramsey_mode?: boolean; + ready_current_on_bills?: boolean; + ready_emergency_fund?: boolean; +} + // A raw bank transaction on the subscription-match search β€” amount in CENTS. export interface SubscriptionTx { id: number;