refactor(ts): convert React Query hooks to TypeScript (TS10)

hooks/useQueries.js -> .ts. Typed params and QueryParams-typed query builders
(QueryParams now exported from api.ts). Query result data stays unknown until
endpoint response shapes are typed.

TypeScript caught dead config: three hooks passed `cacheTime` to useQuery, which
React Query v5 renamed to `gcTime` and silently ignores — so those caches were
garbage-collected at the 5-min default instead of the intended 30 min / 2 hr.
Renamed to gcTime so the intended retention takes effect (memory-retention only;
no fetch or correctness change).

Verified: typecheck 0, lint 0 errors (47 warns), build green, 48 client tests,
17/17 e2e probe (every page renders).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 22:13:05 -05:00
parent 9b805e60b2
commit d02d3c9c72
3 changed files with 35 additions and 17 deletions

View File

@ -7,6 +7,7 @@
- **[Client] Branded `Cents`/`Dollars` types** — converted `client/lib/money.js``money.ts` with `type Cents = number & { __unit: 'cents' }` / `type Dollars = number & { __unit: 'dollars' }` plus `asCents`/`asDollars`/`centsToDollars`/`dollarsToCents`. The formatters now require the correct branded unit (a bare number won't type-check), so a typed caller **physically cannot format cents as dollars** (the 100×-too-big bug — cf. QA-B9-01) or vice-versa. A never-imported `money.type-test.ts` guard uses `@ts-expect-error` to assert each unit mixup is a real compile error, so `typecheck` fails loudly if the branding ever regresses (verified: removing a guard makes tsc error `Argument of type 1234 is not assignable to DollarsInput`). Existing `.jsx` callers are unaffected (checkJs off). Full CI (lint + typecheck + server 181 + client 48 + build) green.
- **[Client] ESLint now type-aware, and the migration is proceeding file-by-file** — extended the flat config with a `client/**/*.{ts,tsx}` block (typescript-eslint parser, same react-hooks/react-refresh enforcement, TS-aware unused-vars) so the `.ts` files get the same correctness linting as the `.jsx` ones. Converted, each its own commit and verified green (typecheck + lint + build + 48 client tests): `client/lib/utils.ts` (the app-wide `cn`/`fmt`/date/format helpers — `fmt` now inherits `formatUSD`'s branded-dollars input), `client/hooks/usePaymentActions.ts` (the shared quick-pay/toggle-paid `useMutation` hooks — typed payloads + `Dollars` on the money amounts so the brand flows to callers, strict-catch errors narrowed via an `errMessage(unknown)` helper), `client/lib/trackerUtils.ts` (the row/status/sort helpers — introduces a `TrackerRow` domain interface and a `TrackerStatus` union; row money fields stay `number` until the API is typed), and a batch of pure leaf utilities — `reorder.ts` (generic `moveInArray<T>`), `billingSchedule.ts` (a `Schedule` union), `billDrafts.ts` (`SourceBill`/`Template`/`Category` shapes for `makeBillDraft`), `trackerTableColumns.ts`, and `cashflowUtils.ts` (typed SVG timeline geometry for the Safe-to-Spend card). The `.test.js` suites that import these resolve the `.ts` transparently and still pass (48 client tests). Also converted two small hooks — `useAutoSave.ts` (a generic `useAutoSave<T>` debounced-save hook with an `AutoSaveStatus` union) and `useSearchPanelPreference.ts` (a typed `[boolean, setter]` tuple) — and `version.ts` (release-notes shapes; the Vite-injected `__APP_VERSION__` is declared inline).
- **[Client] Typed the API client (`client/api.js` → `api.ts`) — the fetch layer's infrastructure** — the central request layer is now TypeScript: a generic `_fetch<T>` and `get`/`post`/`put`/`patch`/`del<T>` helpers, an `ApiError` interface carrying the server's `status`/`code`/`details`/`data`, a `QueryParams` type for the query-string builder, and `File`-typed upload helpers. Endpoint **response** shapes are typed incrementally — most methods resolve to `Promise<unknown>` for now (callers narrow), with the handful already consumed by typed `.ts` files given real shapes: `quickPay``PaymentRecord` (`id`), `togglePaid``TogglePaidResult` (`paymentId?`), `settings` → a settings map. Doing so immediately surfaced a **latent narrowing bug** in `usePaymentActions`: the un-pay Undo closure read `result.paymentId` (now typed `number | undefined`) inside a deferred async callback where TS correctly re-widens the `if`-narrowed value — fixed by capturing the id in a const. The 16 files that imported `@/api.js` with an explicit extension were normalized to `@/api` so Vite/TS resolve the `.ts`. Verified end-to-end: typecheck + lint + build + 48 client tests, and the **17/17 e2e probe** (every page renders, all API paths respond) confirms the central-module rename is runtime-safe.
- **[Client] Typed the React Query hooks (`hooks/useQueries.js` → `.ts`) — and it caught dead config** — the shared `useTracker`/`useBills`/`useSummary`/`useSpending*`/`useSnowball*`/`useBankLedger`/etc. hooks are now TypeScript (typed params + `QueryParams`-typed query builders, exported from `api.ts`). Converting surfaced that three hooks passed **`cacheTime`** to `useQuery` — an option **React Query v5 renamed to `gcTime` and silently ignores** (so those caches were being GC'd at the 5-min default, not the intended 30 min / 2 hr). TypeScript rejected the unknown property; renamed to `gcTime` so the intended cache-retention actually takes effect (memory-retention only — no fetch/correctness change). Query result data is `unknown` until the endpoint response shapes are typed. Verified: typecheck + lint + build + 48 tests + 17/17 e2e probe.
### 🧪 Money-service test coverage

View File

@ -19,7 +19,7 @@ async function getCsrfToken(): Promise<string> {
// with the money-critical/consumed ones given real shapes below.
type Id = number | string;
type Body = unknown;
type QueryParams = Record<string, string | number | boolean | null | undefined>;
export type QueryParams = Record<string, string | number | boolean | null | undefined>;
/** Error thrown by the API client, carrying the server's structured fields. */
export interface ApiError extends Error {

View File

@ -1,14 +1,14 @@
import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import { useCallback } from 'react';
import { api } from '@/api';
import { api, type QueryParams } from '@/api';
// Custom hook for fetching tracker data
export function useTracker(year, month) {
export function useTracker(year: number, month: number) {
return useQuery({
queryKey: ['tracker', year, month],
queryFn: () => api.tracker(year, month),
staleTime: 1000 * 60 * 5, // 5 minutes
cacheTime: 1000 * 60 * 30, // 30 minutes
gcTime: 1000 * 60 * 30, // 30 minutes
});
}
@ -18,7 +18,7 @@ export function useBills() {
queryKey: ['bills'],
queryFn: () => api.allBills(),
staleTime: 1000 * 60 * 5, // 5 minutes
cacheTime: 1000 * 60 * 30, // 30 minutes
gcTime: 1000 * 60 * 30, // 30 minutes
});
}
@ -28,7 +28,7 @@ export function useCategories() {
queryKey: ['categories'],
queryFn: () => api.categories(),
staleTime: 1000 * 60 * 60, // 1 hour
cacheTime: 1000 * 60 * 60 * 2, // 2 hours
gcTime: 1000 * 60 * 60 * 2, // 2 hours
});
}
@ -72,7 +72,7 @@ export function useInvalidateTrackerData() {
// to it is instant. No-op if it's already cached and fresh.
export function usePrefetchTracker() {
const queryClient = useQueryClient();
return useCallback((year, month) => {
return useCallback((year: number, month: number) => {
queryClient.prefetchQuery({
queryKey: ['tracker', year, month],
queryFn: () => api.tracker(year, month),
@ -86,7 +86,7 @@ export function usePrefetchTracker() {
// dedup, cancellation, and out-of-order responses — no manual sequence guards.
// keepPreviousData keeps the last result visible while a new month/filter loads.
export function useAnalyticsSummary(params) {
export function useAnalyticsSummary(params?: QueryParams) {
return useQuery({
queryKey: ['analytics-summary', params],
queryFn: () => api.analyticsSummary(params),
@ -111,7 +111,7 @@ export function useDeletedBills() {
});
}
export function useSummary(year, month) {
export function useSummary(year: number, month: number) {
return useQuery({
queryKey: ['summary', year, month],
queryFn: () => api.summary(year, month),
@ -123,7 +123,17 @@ export function useSummary(year, month) {
});
}
export function useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize }) {
interface BankLedgerArgs {
accountId: string;
flow: string;
page: number;
query: string;
sortBy: string;
sortDir: string;
pageSize: number;
}
export function useBankLedger({ accountId, flow, page, query, sortBy, sortDir, pageSize }: BankLedgerArgs) {
return useQuery({
queryKey: ['bank-ledger', accountId, flow, page, query, sortBy, sortDir],
queryFn: () => api.bankTransactionsLedger({
@ -140,7 +150,7 @@ export function useBankLedger({ accountId, flow, page, query, sortBy, sortDir, p
});
}
export function useSpendingSummary(year, month) {
export function useSpendingSummary(year: number, month: number) {
return useQuery({
queryKey: ['spending-summary', year, month],
queryFn: () => api.spendingSummary({ year, month }),
@ -149,11 +159,18 @@ export function useSpendingSummary(year, month) {
});
}
export function useSpendingTransactions({ year, month, activeCat, page }) {
interface SpendingTransactionsArgs {
year: number;
month: number;
activeCat?: number | string | null;
page: number;
}
export function useSpendingTransactions({ year, month, activeCat, page }: SpendingTransactionsArgs) {
return useQuery({
queryKey: ['spending-transactions', year, month, activeCat ?? 'all', page],
queryFn: () => {
const params = { year, month, page, limit: 50 };
const params: QueryParams = { year, month, page, limit: 50 };
if (activeCat === null) params.category_id = 'null';
else if (activeCat !== undefined) params.category_id = activeCat;
return api.spendingTransactions(params);
@ -167,8 +184,8 @@ export function useSpendingCategories() {
return useQuery({
queryKey: ['spending-categories'],
queryFn: async () => {
const d = await api.categories();
return (d.categories || d || []).filter(c => !c.deleted_at && c.spending_enabled);
const d = await api.categories() as any;
return (d.categories || d || []).filter((c: any) => !c.deleted_at && c.spending_enabled);
},
staleTime: 1000 * 60 * 5,
});
@ -201,7 +218,7 @@ export function useSnowballActivePlan() {
export function useSnowballPlans() {
return useQuery({
queryKey: ['snowball-plans'],
queryFn: () => api.snowballPlans().then(d => d?.plans ?? []).catch(() => []),
queryFn: () => api.snowballPlans().then((d: any) => d?.plans ?? []).catch(() => []),
staleTime: 1000 * 60 * 2,
});
}
@ -217,7 +234,7 @@ export function useSubscriptions() {
export function useSubscriptionRecommendations() {
return useQuery({
queryKey: ['subscription-recommendations'],
queryFn: () => api.subscriptionRecommendations().then(r => r.recommendations || []),
queryFn: () => api.subscriptionRecommendations().then((r: any) => r.recommendations || []),
staleTime: 1000 * 60 * 5,
});
}