diff --git a/HISTORY.md b/HISTORY.md index aaade2b..b8e2e83 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -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`), `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` 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` and `get`/`post`/`put`/`patch`/`del` 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` 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 diff --git a/client/api.ts b/client/api.ts index 53b6c44..0acf133 100644 --- a/client/api.ts +++ b/client/api.ts @@ -19,7 +19,7 @@ async function getCsrfToken(): Promise { // with the money-critical/consumed ones given real shapes below. type Id = number | string; type Body = unknown; -type QueryParams = Record; +export type QueryParams = Record; /** Error thrown by the API client, carrying the server's structured fields. */ export interface ApiError extends Error { diff --git a/client/hooks/useQueries.js b/client/hooks/useQueries.ts similarity index 83% rename from client/hooks/useQueries.js rename to client/hooks/useQueries.ts index 64313e9..31c713f 100644 --- a/client/hooks/useQueries.js +++ b/client/hooks/useQueries.ts @@ -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, }); }