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
|
### 🔌 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] 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] 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.
|
- **[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,
|
SpendingSummary, SpendingTransactionsResponse, SpendingIncomeResponse,
|
||||||
SpendingRule, CopyBudgetsResponse, CategoryGroup,
|
SpendingRule, CopyBudgetsResponse, CategoryGroup,
|
||||||
SubscriptionsResponse, Recommendation, SubscriptionTx,
|
SubscriptionsResponse, Recommendation, SubscriptionTx,
|
||||||
|
SnowballProjection, SnowballSettings,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
|
||||||
// Fetch CSRF token from the server once and cache in memory.
|
// 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`),
|
undoAutoMatch: (id: Id) => post(`/payments/${id}/undo-auto`),
|
||||||
|
|
||||||
// Snowball
|
// Snowball
|
||||||
snowball: () => get('/snowball'),
|
snowball: () => get<Bill[]>('/snowball'),
|
||||||
snowballSettings: () => get('/snowball/settings'),
|
snowballSettings: () => get<SnowballSettings>('/snowball/settings'),
|
||||||
saveSnowballSettings: (data: Body) => _fetch('PATCH', '/snowball/settings', data),
|
saveSnowballSettings: (data: Body) => _fetch<SnowballSettings>('PATCH', '/snowball/settings', data),
|
||||||
saveSnowballOrder: (items: Body) => _fetch('PATCH', '/snowball/order', items),
|
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'),
|
snowballPlans: () => get('/snowball/plans'),
|
||||||
snowballActivePlan: () => get('/snowball/plans/active'),
|
snowballActivePlan: () => get('/snowball/plans/active'),
|
||||||
startSnowballPlan: (data: Body) => post('/snowball/plans', data),
|
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 PlanHistoryPanel, { type SnowballPlan } from '@/components/snowball/PlanHistoryPanel';
|
||||||
import * as AlertDialog from '@radix-ui/react-alert-dialog';
|
import * as AlertDialog from '@radix-ui/react-alert-dialog';
|
||||||
import { formatUSD, formatUSDWhole, asDollars } from '@/lib/money';
|
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 ────────────────────────────────────────────────────────────────
|
// ── formatters ────────────────────────────────────────────────────────────────
|
||||||
function fmt(val: number | null | undefined): string {
|
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);
|
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 {
|
interface ReadinessItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -347,11 +321,11 @@ function ReadinessStrip({ items, readyCount, totalCount, allReady, onToggle, dis
|
||||||
export default function SnowballPage() {
|
export default function SnowballPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: billsRaw = [], isPending: billsPending, error: snowballError } = useSnowball();
|
const { data: billsRaw = [], isPending: billsPending, error: snowballError } = useSnowball();
|
||||||
const bills = (billsRaw ?? []) as Bill[];
|
const bills = billsRaw ?? [];
|
||||||
const { data: categoriesRaw = [] } = useCategories();
|
const { data: categoriesRaw = [] } = useCategories();
|
||||||
const categories = (categoriesRaw ?? []) as Category[];
|
const categories = (categoriesRaw ?? []) as Category[];
|
||||||
const { data: settingsRaw } = useSnowballSettings();
|
const { data: settingsRaw } = useSnowballSettings();
|
||||||
const settings = settingsRaw as SnowballSettings | undefined;
|
const settings = settingsRaw;
|
||||||
const { data: activePlanRaw = null } = useSnowballActivePlan();
|
const { data: activePlanRaw = null } = useSnowballActivePlan();
|
||||||
const activePlan = (activePlanRaw ?? null) as SnowballPlan | null;
|
const activePlan = (activePlanRaw ?? null) as SnowballPlan | null;
|
||||||
const { data: allPlansRaw = [] } = useSnowballPlans();
|
const { data: allPlansRaw = [] } = useSnowballPlans();
|
||||||
|
|
@ -395,7 +369,7 @@ export default function SnowballPage() {
|
||||||
try {
|
try {
|
||||||
const extra = parseFloat(typedExtraRef.current);
|
const extra = parseFloat(typedExtraRef.current);
|
||||||
const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {};
|
const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {};
|
||||||
setProjection(await api.snowballProjection(params) as Projection);
|
setProjection(await api.snowballProjection(params));
|
||||||
setProjectionError(false);
|
setProjectionError(false);
|
||||||
} catch {
|
} catch {
|
||||||
setProjectionError(true);
|
setProjectionError(true);
|
||||||
|
|
@ -491,7 +465,7 @@ export default function SnowballPage() {
|
||||||
if (val === extraPaymentRef.current) return;
|
if (val === extraPaymentRef.current) return;
|
||||||
setSavingSettings(true);
|
setSavingSettings(true);
|
||||||
try {
|
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) : '';
|
const saved = Number(result.extra_payment) > 0 ? String(result.extra_payment) : '';
|
||||||
extraPaymentRef.current = saved;
|
extraPaymentRef.current = saved;
|
||||||
setExtraPayment(saved);
|
setExtraPayment(saved);
|
||||||
|
|
@ -504,7 +478,7 @@ export default function SnowballPage() {
|
||||||
const handleRamseyModeChange = async (checked: boolean) => {
|
const handleRamseyModeChange = async (checked: boolean) => {
|
||||||
setSavingSettings(true);
|
setSavingSettings(true);
|
||||||
try {
|
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;
|
const nextMode = result.ramsey_mode !== false;
|
||||||
setRamseyMode(nextMode);
|
setRamseyMode(nextMode);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
|
|
@ -530,7 +504,7 @@ export default function SnowballPage() {
|
||||||
|
|
||||||
setSavingSettings(true);
|
setSavingSettings(true);
|
||||||
try {
|
try {
|
||||||
const result = await api.saveSnowballSettings(payload) as SnowballSettings;
|
const result = await api.saveSnowballSettings(payload);
|
||||||
setReadyCurrentOnBills(!!result.ready_current_on_bills);
|
setReadyCurrentOnBills(!!result.ready_current_on_bills);
|
||||||
setReadyEmergencyFund(!!result.ready_emergency_fund);
|
setReadyEmergencyFund(!!result.ready_emergency_fund);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -583,7 +557,7 @@ export default function SnowballPage() {
|
||||||
const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {};
|
const params = Number.isFinite(extra) && extra >= 0 ? { extra } : {};
|
||||||
try {
|
try {
|
||||||
setProjectionLoading(true);
|
setProjectionLoading(true);
|
||||||
const result = await api.snowballProjection(params) as Projection;
|
const result = await api.snowballProjection(params);
|
||||||
setProjection(result);
|
setProjection(result);
|
||||||
setProjectionError(false);
|
setProjectionError(false);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,40 @@ export interface Recommendation {
|
||||||
transactions?: RecommendationTransaction[];
|
transactions?: RecommendationTransaction[];
|
||||||
[key: string]: unknown;
|
[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.
|
// A raw bank transaction on the subscription-match search — amount in CENTS.
|
||||||
export interface SubscriptionTx {
|
export interface SubscriptionTx {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue