refactor(ts): close the 4 client any's + document a best-effort catch (Phase 1b)

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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-05 13:13:32 -05:00
parent 398caec29d
commit 57e7ede670
3 changed files with 14 additions and 7 deletions

View File

@ -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. // still bounding a truly dead request so it surfaces as an error, not a freeze.
const REQUEST_TIMEOUT_MS = 120_000; const REQUEST_TIMEOUT_MS = 120_000;
async function parseJsonSafe(res: Response): Promise<any> { // 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<string, unknown>;
async function parseJsonSafe(res: Response): Promise<ResponseBody | null> {
if (res.status === 204) return null; if (res.status === 204) return null;
const text = await res.text(); const text = await res.text();
if (!text) return null; 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<T = unknown>(method: string, path: string, body?: unknown, _retried = false): Promise<T> { async function _fetch<T = unknown>(method: string, path: string, body?: unknown, _retried = false): Promise<T> {
@ -192,8 +197,8 @@ export const api = {
credentials: 'include', credentials: 'include',
}); });
if (!res.ok) { if (!res.ok) {
let data: any = {}; let data: ResponseBody = {};
try { data = await res.json(); } catch {} try { data = await res.json(); } catch { /* non-JSON error body */ }
throw new Error(data.error || `HTTP ${res.status}`); throw new Error(data.error || `HTTP ${res.status}`);
} }
return { return {
@ -319,7 +324,7 @@ export const api = {
subscriptionTransactionMatches: (params: QueryParams = {}) => get<SubscriptionTx[] | { transactions?: SubscriptionTx[] }>(`/subscriptions/transaction-matches${queryString(params)}`), subscriptionTransactionMatches: (params: QueryParams = {}) => get<SubscriptionTx[] | { transactions?: SubscriptionTx[] }>(`/subscriptions/transaction-matches${queryString(params)}`),
updateSubscription: (id: Id, data: Body) => _fetch('PATCH', `/subscriptions/${id}`, data), updateSubscription: (id: Id, data: Body) => _fetch('PATCH', `/subscriptions/${id}`, data),
createSubscriptionFromRecommendation: (data: Body) => post<{ id?: number; name?: string }>('/subscriptions/recommendations/create', 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, decline_key: recommendation?.decline_key || recommendation,
catalog_id: recommendation?.catalog_match?.id, catalog_id: recommendation?.catalog_match?.id,
merchant: recommendation?.merchant, merchant: recommendation?.merchant,
@ -348,7 +353,7 @@ export const api = {
saveSnowballSettings: (data: Body) => _fetch<SnowballSettings>('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<SnowballProjection>(`/snowball/projection${queryString(params)}`), snowballProjection: (params: QueryParams = {}) => get<SnowballProjection>(`/snowball/projection${queryString(params)}`),
snowballPlans: () => get('/snowball/plans'), snowballPlans: () => get<{ plans?: unknown[] }>('/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),
updateSnowballPlan: (id: Id, d: Body) => _fetch('PATCH', `/snowball/plans/${id}`, d), updateSnowballPlan: (id: Id, d: Body) => _fetch('PATCH', `/snowball/plans/${id}`, d),

View File

@ -218,7 +218,7 @@ export function useSnowballActivePlan() {
export function useSnowballPlans() { export function useSnowballPlans() {
return useQuery({ return useQuery({
queryKey: ['snowball-plans'], queryKey: ['snowball-plans'],
queryFn: () => api.snowballPlans().then((d: any) => d?.plans ?? []).catch(() => []), queryFn: () => api.snowballPlans().then(d => d?.plans ?? []).catch(() => []),
staleTime: 1000 * 60 * 2, staleTime: 1000 * 60 * 2,
}); });
} }

View File

@ -710,6 +710,8 @@ export default function SpendingPage() {
}, [txPage, queryClient]); }, [txPage, queryClient]);
useEffect(() => { 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<string, unknown>) || {})).catch(() => {}); api.settings().then(s => setSpendingSettings((s as Record<string, unknown>) || {})).catch(() => {});
}, []); }, []);