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:
parent
6bb8c63d39
commit
65a477cab1
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue