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.
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;
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<T = unknown>(method: string, path: string, body?: unknown, _retried = false): Promise<T> {
@ -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<SubscriptionTx[] | { transactions?: SubscriptionTx[] }>(`/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<SnowballSettings>('PATCH', '/snowball/settings', data),
saveSnowballOrder: (items: Body) => _fetch('PATCH', '/snowball/order', items),
snowballProjection: (params: QueryParams = {}) => get<SnowballProjection>(`/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),

View File

@ -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,
});
}

View File

@ -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<string, unknown>) || {})).catch(() => {});
}, []);