refactor(ts): convert small hooks + version to TypeScript (TS8)

- useAutoSave.ts — generic useAutoSave<T>; AutoSaveStatus union; timer ref
  typed ReturnType<typeof setTimeout>; pending ref T|null narrows on != null.
- useSearchPanelPreference.ts — typed [boolean, setter] tuple return.
- version.ts — ReleaseNotes/ReleaseHighlight shapes; the Vite-injected
  __APP_VERSION__ global declared inline (declare const).

No behavior change. useAutoSave.test.jsx imports the .ts transparently and
passes. typecheck 0, lint 0 errors (47 warns), build green, 48 client tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 22:01:30 -05:00
parent 3c51464bec
commit 8c30a7ab09
4 changed files with 37 additions and 13 deletions

View File

@ -5,7 +5,7 @@
- **[Client] Adopted TypeScript incrementally, starting with the money boundary** — the move off plain JS. Upgraded `jsconfig``tsconfig` with full `strict` + `noUncheckedIndexedAccess`, but `allowJs` + `checkJs:false` so every existing `.js`/`.jsx` keeps resolving and running untouched — only `.ts`/`.tsx` are type-checked, so it's a file-by-file migration with nothing breaking in between. Added `npm run typecheck` (`tsc --noEmit`) and wired it into `npm run ci`. Installed TypeScript 6 + `@types/react`/`react-dom` 19.
- **[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).
- **[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).
### 🧪 Money-service test coverage

View File

@ -1,5 +1,15 @@
import { useCallback, useEffect, useRef, useState } from 'react';
export type AutoSaveStatus = 'idle' | 'saving' | 'saved' | 'error';
type SaveFn<T> = (payload: T) => unknown;
export interface UseAutoSave<T> {
status: AutoSaveStatus;
schedule: (payload: T, delay?: number) => void;
flush: () => void;
}
/**
* Debounced auto-save with a status the UI can render.
*
@ -10,14 +20,14 @@ import { useCallback, useEffect, useRef, useState } from 'react';
*
* Pending changes are flushed on unmount so navigating away never loses edits.
*/
export function useAutoSave(saveFn, defaultDelay = 400) {
const [status, setStatus] = useState('idle');
const timer = useRef(null);
const pending = useRef(null);
const saveRef = useRef(saveFn);
export function useAutoSave<T>(saveFn: SaveFn<T>, defaultDelay = 400): UseAutoSave<T> {
const [status, setStatus] = useState<AutoSaveStatus>('idle');
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const pending = useRef<T | null>(null);
const saveRef = useRef<SaveFn<T>>(saveFn);
saveRef.current = saveFn;
const run = useCallback(async (payload) => {
const run = useCallback(async (payload: T) => {
pending.current = null;
setStatus('saving');
try {
@ -28,7 +38,7 @@ export function useAutoSave(saveFn, defaultDelay = 400) {
}
}, []);
const schedule = useCallback((payload, delay = defaultDelay) => {
const schedule = useCallback((payload: T, delay = defaultDelay) => {
pending.current = payload;
clearTimeout(timer.current);
timer.current = setTimeout(() => run(payload), delay);

View File

@ -3,11 +3,11 @@ import { api } from '@/api';
const SETTING_KEY = 'search_bars_collapsed';
function settingToBool(value) {
function settingToBool(value: unknown): boolean {
return value === true || value === 'true' || value === '1' || value === 1;
}
export function useSearchPanelPreference() {
export function useSearchPanelPreference(): [boolean, (nextCollapsed: boolean) => void] {
const [collapsed, setCollapsed] = useState(false);
useEffect(() => {
@ -23,7 +23,7 @@ export function useSearchPanelPreference() {
return () => { mounted = false; };
}, []);
const saveCollapsed = useCallback((nextCollapsed) => {
const saveCollapsed = useCallback((nextCollapsed: boolean) => {
setCollapsed(nextCollapsed);
api.saveSettings({ [SETTING_KEY]: nextCollapsed ? 'true' : 'false' }).catch(() => {});
}, []);

View File

@ -1,10 +1,24 @@
// __APP_VERSION__ is injected by Vite at build time from package.json.
// Do not hardcode a version string here — update package.json instead.
/* global __APP_VERSION__ */
declare const __APP_VERSION__: string;
export const APP_VERSION = __APP_VERSION__;
export const APP_NAME = 'BillTracker';
export const RELEASE_NOTES = {
export interface ReleaseHighlight {
icon: string;
title: string;
desc: string;
}
export interface ReleaseNotes {
version: string;
date: string;
highlights: ReleaseHighlight[];
image: { src: string; alt: string };
}
export const RELEASE_NOTES: ReleaseNotes = {
version: APP_VERSION,
date: '2026-05-31',
highlights: [