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:
parent
3c51464bec
commit
8c30a7ab09
|
|
@ -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] 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] 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
|
### 🧪 Money-service test coverage
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,15 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
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.
|
* 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.
|
* Pending changes are flushed on unmount so navigating away never loses edits.
|
||||||
*/
|
*/
|
||||||
export function useAutoSave(saveFn, defaultDelay = 400) {
|
export function useAutoSave<T>(saveFn: SaveFn<T>, defaultDelay = 400): UseAutoSave<T> {
|
||||||
const [status, setStatus] = useState('idle');
|
const [status, setStatus] = useState<AutoSaveStatus>('idle');
|
||||||
const timer = useRef(null);
|
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const pending = useRef(null);
|
const pending = useRef<T | null>(null);
|
||||||
const saveRef = useRef(saveFn);
|
const saveRef = useRef<SaveFn<T>>(saveFn);
|
||||||
saveRef.current = saveFn;
|
saveRef.current = saveFn;
|
||||||
|
|
||||||
const run = useCallback(async (payload) => {
|
const run = useCallback(async (payload: T) => {
|
||||||
pending.current = null;
|
pending.current = null;
|
||||||
setStatus('saving');
|
setStatus('saving');
|
||||||
try {
|
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;
|
pending.current = payload;
|
||||||
clearTimeout(timer.current);
|
clearTimeout(timer.current);
|
||||||
timer.current = setTimeout(() => run(payload), delay);
|
timer.current = setTimeout(() => run(payload), delay);
|
||||||
|
|
@ -3,11 +3,11 @@ import { api } from '@/api';
|
||||||
|
|
||||||
const SETTING_KEY = 'search_bars_collapsed';
|
const SETTING_KEY = 'search_bars_collapsed';
|
||||||
|
|
||||||
function settingToBool(value) {
|
function settingToBool(value: unknown): boolean {
|
||||||
return value === true || value === 'true' || value === '1' || value === 1;
|
return value === true || value === 'true' || value === '1' || value === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSearchPanelPreference() {
|
export function useSearchPanelPreference(): [boolean, (nextCollapsed: boolean) => void] {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -23,7 +23,7 @@ export function useSearchPanelPreference() {
|
||||||
return () => { mounted = false; };
|
return () => { mounted = false; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const saveCollapsed = useCallback((nextCollapsed) => {
|
const saveCollapsed = useCallback((nextCollapsed: boolean) => {
|
||||||
setCollapsed(nextCollapsed);
|
setCollapsed(nextCollapsed);
|
||||||
api.saveSettings({ [SETTING_KEY]: nextCollapsed ? 'true' : 'false' }).catch(() => {});
|
api.saveSettings({ [SETTING_KEY]: nextCollapsed ? 'true' : 'false' }).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -1,10 +1,24 @@
|
||||||
// __APP_VERSION__ is injected by Vite at build time from package.json.
|
// __APP_VERSION__ is injected by Vite at build time from package.json.
|
||||||
// Do not hardcode a version string here — update package.json instead.
|
// 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_VERSION = __APP_VERSION__;
|
||||||
export const APP_NAME = 'BillTracker';
|
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,
|
version: APP_VERSION,
|
||||||
date: '2026-05-31',
|
date: '2026-05-31',
|
||||||
highlights: [
|
highlights: [
|
||||||
Loading…
Reference in New Issue