From 57e7ede6708763b138819f708ba2130a0262ac6c Mon Sep 17 00:00:00 2001 From: null Date: Sun, 5 Jul 2026 13:13:32 -0500 Subject: [PATCH] refactor(ts): close the 4 client any's + document a best-effort catch (Phase 1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseJsonSafe/upload-body → a structured ResponseBody type (typed error envelope + index sig) instead of any, no cast churn; declineRecommendation → Recommendation; snowballPlans endpoint typed so the hook drops its (d: any). Annotated the Spending settings-load catch as best-effort (Finding #8) — cosmetic options fall back to defaults, no mount-time nag. Client 0 any's. Co-Authored-By: Claude Opus 4.8 --- client/api.ts | 17 +++++++++++------ client/hooks/useQueries.ts | 2 +- client/pages/SpendingPage.tsx | 2 ++ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/client/api.ts b/client/api.ts index c296e4e..3ceb13b 100644 --- a/client/api.ts +++ b/client/api.ts @@ -54,11 +54,16 @@ const MUTATING_METHODS = ['POST', 'PUT', 'DELETE', 'PATCH']; // still bounding a truly dead request so it surfaces as an error, not a freeze. const REQUEST_TIMEOUT_MS = 120_000; -async function parseJsonSafe(res: Response): Promise { +// A parsed JSON response body. The error-envelope fields the client reads are +// typed; any other (success) field passes through the index signature. Avoids an +// `any` at the parse boundary without forcing casts at every read site. +type ResponseBody = { message?: string; error?: string; code?: string; details?: unknown[]; token?: string } & Record; + +async function parseJsonSafe(res: Response): Promise { if (res.status === 204) return null; const text = await res.text(); if (!text) return null; - try { return JSON.parse(text); } catch { return null; } + try { return JSON.parse(text) as ResponseBody; } catch { return null; } } async function _fetch(method: string, path: string, body?: unknown, _retried = false): Promise { @@ -192,8 +197,8 @@ export const api = { credentials: 'include', }); if (!res.ok) { - let data: any = {}; - try { data = await res.json(); } catch {} + let data: ResponseBody = {}; + try { data = await res.json(); } catch { /* non-JSON error body */ } throw new Error(data.error || `HTTP ${res.status}`); } return { @@ -319,7 +324,7 @@ export const api = { subscriptionTransactionMatches: (params: QueryParams = {}) => get(`/subscriptions/transaction-matches${queryString(params)}`), updateSubscription: (id: Id, data: Body) => _fetch('PATCH', `/subscriptions/${id}`, data), createSubscriptionFromRecommendation: (data: Body) => post<{ id?: number; name?: string }>('/subscriptions/recommendations/create', data), - declineRecommendation: (recommendation: any) => post('/subscriptions/recommendations/decline', { + declineRecommendation: (recommendation: Recommendation) => post('/subscriptions/recommendations/decline', { decline_key: recommendation?.decline_key || recommendation, catalog_id: recommendation?.catalog_match?.id, merchant: recommendation?.merchant, @@ -348,7 +353,7 @@ export const api = { saveSnowballSettings: (data: Body) => _fetch('PATCH', '/snowball/settings', data), saveSnowballOrder: (items: Body) => _fetch('PATCH', '/snowball/order', items), snowballProjection: (params: QueryParams = {}) => get(`/snowball/projection${queryString(params)}`), - snowballPlans: () => get('/snowball/plans'), + snowballPlans: () => get<{ plans?: unknown[] }>('/snowball/plans'), snowballActivePlan: () => get('/snowball/plans/active'), startSnowballPlan: (data: Body) => post('/snowball/plans', data), updateSnowballPlan: (id: Id, d: Body) => _fetch('PATCH', `/snowball/plans/${id}`, d), diff --git a/client/hooks/useQueries.ts b/client/hooks/useQueries.ts index 59ef451..0d064ec 100644 --- a/client/hooks/useQueries.ts +++ b/client/hooks/useQueries.ts @@ -218,7 +218,7 @@ export function useSnowballActivePlan() { export function useSnowballPlans() { return useQuery({ queryKey: ['snowball-plans'], - queryFn: () => api.snowballPlans().then((d: any) => d?.plans ?? []).catch(() => []), + queryFn: () => api.snowballPlans().then(d => d?.plans ?? []).catch(() => []), staleTime: 1000 * 60 * 2, }); } diff --git a/client/pages/SpendingPage.tsx b/client/pages/SpendingPage.tsx index 5f72254..ce499be 100644 --- a/client/pages/SpendingPage.tsx +++ b/client/pages/SpendingPage.tsx @@ -710,6 +710,8 @@ export default function SpendingPage() { }, [txPage, queryClient]); useEffect(() => { + // best-effort: the page-options are cosmetic; on a load failure we fall back + // to defaults rather than block the page or nag with a mount-time toast. api.settings().then(s => setSpendingSettings((s as Record) || {})).catch(() => {}); }, []);