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<T>/_fetch<T>. 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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-05 12:16:03 -05:00
parent 6bb8c63d39
commit 65a477cab1
4 changed files with 54 additions and 44 deletions

View File

@ -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<T>`/`post<T>`. 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<T>`/`post<T>`. `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<BankLedger>`, `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.

View File

@ -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<Bill[]>('/snowball'),
snowballSettings: () => get<SnowballSettings>('/snowball/settings'),
saveSnowballSettings: (data: Body) => _fetch<SnowballSettings>('PATCH', '/snowball/settings', data),
saveSnowballOrder: (items: Body) => _fetch('PATCH', '/snowball/order', items),
snowballProjection: (params: QueryParams = {}) => get(`/snowball/projection${queryString(params)}`),
snowballProjection: (params: QueryParams = {}) => get<SnowballProjection>(`/snowball/projection${queryString(params)}`),
snowballPlans: () => get('/snowball/plans'),
snowballActivePlan: () => get('/snowball/plans/active'),
startSnowballPlan: (data: Body) => post('/snowball/plans', data),

View File

@ -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 {

View File

@ -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;