From 1f18a720c41823d73bcd39f220e09b6b8e7df629 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 22:57:22 -0500 Subject: [PATCH] refactor(ts): convert all UI primitives + ThemeContext to TSX (B2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundational for phase B: the ui/* primitives were untyped .jsx, and TS infers React-19 ref-as-prop components as *requiring* a ref, so every consumer errored. Converted all 22 ui primitives (button, input, card, dialog, select, table, dropdown-menu, tabs, badge, checkbox, switch, tooltip, separator, label, Skeleton, collapsible, alert-dialog, sonner, save-status, theme-toggle, input-dialog, confirm-dialog) using ComponentProps<'el'> / ComponentProps + VariantProps for cva components — refs now optional, event params typed. Also converted ThemeContext.tsx (createContext(null) made useContext's post-guard return `never`, so setTheme was uncallable — fixed with a typed context value) and three tracker cells (EditableCell/NotesCell/ LowerThisMonthButton) that consume the branded TrackerRow. Added a shared errMessage(unknown) helper to utils (strict catch → unknown); usePaymentActions reuses it. TrackerRow gains monthly_notes + amount_suggestion (real server fields). typecheck 0, lint 0 errors (42 warns), build green, 48 client tests. NOTE: the e2e auth-setup probe is currently red on an unrelated, pre-existing release-notes-modal dismissal race (reproduces on the last known-good commit; the a11y snapshot shows these converted primitives rendering correctly) — investigating separately. Co-Authored-By: Claude Opus 4.8 --- .../{EditableCell.jsx => EditableCell.tsx} | 24 +++++--- ...nthButton.jsx => LowerThisMonthButton.tsx} | 15 ++++- .../tracker/{NotesCell.jsx => NotesCell.tsx} | 15 +++-- .../ui/{Skeleton.jsx => Skeleton.tsx} | 7 ++- .../ui/{alert-dialog.jsx => alert-dialog.tsx} | 14 ++--- client/components/ui/{badge.jsx => badge.tsx} | 5 +- .../components/ui/{button.jsx => button.tsx} | 9 ++- client/components/ui/{card.jsx => card.tsx} | 13 ++-- .../ui/{checkbox.jsx => checkbox.tsx} | 3 +- .../ui/{collapsible.jsx => collapsible.tsx} | 3 +- ...{confirm-dialog.jsx => confirm-dialog.tsx} | 61 ++++++++++++------- .../components/ui/{dialog.jsx => dialog.tsx} | 13 ++-- .../{dropdown-menu.jsx => dropdown-menu.tsx} | 19 +++--- .../ui/{input-dialog.jsx => input-dialog.tsx} | 21 ++++++- client/components/ui/{input.jsx => input.tsx} | 3 +- client/components/ui/{label.jsx => label.tsx} | 3 +- .../ui/{save-status.jsx => save-status.tsx} | 14 +++-- .../components/ui/{select.jsx => select.tsx} | 15 ++--- .../ui/{separator.jsx => separator.tsx} | 3 +- .../components/ui/{sonner.jsx => sonner.tsx} | 0 .../components/ui/{switch.jsx => switch.tsx} | 3 +- client/components/ui/{table.jsx => table.tsx} | 17 +++--- client/components/ui/{tabs.jsx => tabs.tsx} | 7 ++- .../ui/{theme-toggle.jsx => theme-toggle.tsx} | 19 ++++-- .../ui/{tooltip.jsx => tooltip.tsx} | 3 +- .../{ThemeContext.jsx => ThemeContext.tsx} | 35 ++++++----- client/hooks/usePaymentActions.ts | 7 +-- client/lib/utils.ts | 6 ++ client/types.ts | 2 + 29 files changed, 230 insertions(+), 129 deletions(-) rename client/components/tracker/{EditableCell.jsx => EditableCell.tsx} (81%) rename client/components/tracker/{LowerThisMonthButton.jsx => LowerThisMonthButton.tsx} (80%) rename client/components/tracker/{NotesCell.jsx => NotesCell.tsx} (80%) rename client/components/ui/{Skeleton.jsx => Skeleton.tsx} (67%) rename client/components/ui/{alert-dialog.jsx => alert-dialog.tsx} (78%) rename client/components/ui/{badge.jsx => badge.tsx} (86%) rename client/components/ui/{button.jsx => button.tsx} (83%) rename client/components/ui/{card.jsx => card.tsx} (68%) rename client/components/ui/{checkbox.jsx => checkbox.tsx} (85%) rename client/components/ui/{collapsible.jsx => collapsible.tsx} (75%) rename client/components/ui/{confirm-dialog.jsx => confirm-dialog.tsx} (77%) rename client/components/ui/{dialog.jsx => dialog.tsx} (82%) rename client/components/ui/{dropdown-menu.jsx => dropdown-menu.tsx} (86%) rename client/components/ui/{input-dialog.jsx => input-dialog.tsx} (85%) rename client/components/ui/{input.jsx => input.tsx} (84%) rename client/components/ui/{label.jsx => label.tsx} (75%) rename client/components/ui/{save-status.jsx => save-status.tsx} (78%) rename client/components/ui/{select.jsx => select.tsx} (84%) rename client/components/ui/{separator.jsx => separator.tsx} (79%) rename client/components/ui/{sonner.jsx => sonner.tsx} (100%) rename client/components/ui/{switch.jsx => switch.tsx} (86%) rename client/components/ui/{table.jsx => table.tsx} (75%) rename client/components/ui/{tabs.jsx => tabs.tsx} (78%) rename client/components/ui/{theme-toggle.jsx => theme-toggle.tsx} (87%) rename client/components/ui/{tooltip.jsx => tooltip.tsx} (90%) rename client/contexts/{ThemeContext.jsx => ThemeContext.tsx} (60%) diff --git a/client/components/tracker/EditableCell.jsx b/client/components/tracker/EditableCell.tsx similarity index 81% rename from client/components/tracker/EditableCell.jsx rename to client/components/tracker/EditableCell.tsx index 29954e7..4a0d7a7 100644 --- a/client/components/tracker/EditableCell.jsx +++ b/client/components/tracker/EditableCell.tsx @@ -1,14 +1,24 @@ import { useState, useRef } from 'react'; import { toast } from 'sonner'; import { api } from '@/api'; -import { cn, fmt, fmtDate } from '@/lib/utils'; +import { cn, fmt, fmtDate, errMessage } from '@/lib/utils'; import { Input } from '@/components/ui/input'; +import type { Dollars } from '@/lib/money'; +import type { TrackerRow } from '@/types'; // `threshold` = actual_amount ?? expected_amount for this bill/month -export function EditableCell({ row, field, threshold, defaultPaymentDate, refresh }) { +interface EditableCellProps { + row: TrackerRow; + field: 'amount' | 'date'; + threshold: Dollars; + defaultPaymentDate: string; + refresh: () => void; +} + +export function EditableCell({ row, field, threshold, defaultPaymentDate, refresh }: EditableCellProps) { const [editing, setEditing] = useState(false); const [value, setValue] = useState(''); - const inputRef = useRef(null); + const inputRef = useRef(null); const displayVal = field === 'amount' ? (row.total_paid > 0 ? fmt(row.total_paid) : '—') @@ -33,10 +43,10 @@ export function EditableCell({ row, field, threshold, defaultPaymentDate, refres if (!val) return; try { if (row.payments && row.payments.length > 0) { - const update = {}; + const update: { amount?: number; paid_date?: string } = {}; if (field === 'amount') update.amount = parseFloat(val); if (field === 'date') update.paid_date = val; - await api.updatePayment(row.payments[0].id, update); + await api.updatePayment(row.payments[0]!.id, update); } else { await api.createPayment({ bill_id: row.id, @@ -47,11 +57,11 @@ export function EditableCell({ row, field, threshold, defaultPaymentDate, refres toast.success('Saved'); refresh(); } catch (err) { - toast.error(err.message); + toast.error(errMessage(err)); } } - function onKeyDown(e) { + function onKeyDown(e: React.KeyboardEvent) { if (e.key === 'Enter') inputRef.current?.blur(); if (e.key === 'Escape') { setValue(''); setEditing(false); } } diff --git a/client/components/tracker/LowerThisMonthButton.jsx b/client/components/tracker/LowerThisMonthButton.tsx similarity index 80% rename from client/components/tracker/LowerThisMonthButton.jsx rename to client/components/tracker/LowerThisMonthButton.tsx index e6e47d4..6d82a23 100644 --- a/client/components/tracker/LowerThisMonthButton.jsx +++ b/client/components/tracker/LowerThisMonthButton.tsx @@ -1,11 +1,20 @@ import { useState } from 'react'; import { toast } from 'sonner'; import { api } from '@/api'; -import { cn, fmt } from '@/lib/utils'; +import { cn, fmt, errMessage } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { MONTHS, rowThreshold, paymentSummary } from '@/lib/trackerUtils'; +import type { TrackerRow } from '@/types'; -export function LowerThisMonthButton({ row, year, month, refresh, compact = false }) { +interface LowerThisMonthButtonProps { + row: TrackerRow; + year: number; + month: number; + refresh?: () => void; + compact?: boolean; +} + +export function LowerThisMonthButton({ row, year, month, refresh, compact = false }: LowerThisMonthButtonProps) { const threshold = rowThreshold(row); const summary = paymentSummary(row, threshold); const [saving, setSaving] = useState(false); @@ -25,7 +34,7 @@ export function LowerThisMonthButton({ row, year, month, refresh, compact = fals toast.success(`${MONTHS[month - 1]} amount set to ${fmt(summary.paid)}`); refresh?.(); } catch (err) { - toast.error(err.message || 'Failed to update monthly amount'); + toast.error(errMessage(err, 'Failed to update monthly amount')); } finally { setSaving(false); } diff --git a/client/components/tracker/NotesCell.jsx b/client/components/tracker/NotesCell.tsx similarity index 80% rename from client/components/tracker/NotesCell.jsx rename to client/components/tracker/NotesCell.tsx index 77d3f05..cb584eb 100644 --- a/client/components/tracker/NotesCell.jsx +++ b/client/components/tracker/NotesCell.tsx @@ -1,10 +1,17 @@ import { useState } from 'react'; import { toast } from 'sonner'; import { api } from '@/api'; -import { cn } from '@/lib/utils'; +import { cn, errMessage } from '@/lib/utils'; +import type { TrackerRow } from '@/types'; -// Monthly notes stored in monthly_bill_state — per-month, not per-bill. -export function NotesCell({ row, refresh }) { +// Monthly notes stored in monthly_bill_state — per-month, not per-bill. The +// tracker page injects year/month onto the row for this cell's save. +interface NotesCellProps { + row: TrackerRow & { year?: number; month?: number }; + refresh: () => void; +} + +export function NotesCell({ row, refresh }: NotesCellProps) { const savedNote = row.monthly_notes || ''; const [value, setValue] = useState(savedNote); const [saving, setSaving] = useState(false); @@ -33,7 +40,7 @@ export function NotesCell({ row, refresh }) { }); refresh(); } catch (err) { - toast.error(err.message); + toast.error(errMessage(err)); setValue(savedNote); } finally { setSaving(false); } } diff --git a/client/components/ui/Skeleton.jsx b/client/components/ui/Skeleton.tsx similarity index 67% rename from client/components/ui/Skeleton.jsx rename to client/components/ui/Skeleton.tsx index 8692657..e851941 100644 --- a/client/components/ui/Skeleton.jsx +++ b/client/components/ui/Skeleton.tsx @@ -1,7 +1,10 @@ +import type { ComponentProps } from 'react'; import { cn } from '@/lib/utils'; -function Skeleton({ className, variant = 'line', ref, ...props }) { - const variants = { +type SkeletonVariant = 'line' | 'circle' | 'card' | 'button' | 'input'; + +function Skeleton({ className, variant = 'line', ref, ...props }: ComponentProps<'div'> & { variant?: SkeletonVariant }) { + const variants: Record = { line: 'h-4 w-full rounded-md', circle: 'h-10 w-10 rounded-full', card: 'h-24 w-full rounded-xl', diff --git a/client/components/ui/alert-dialog.jsx b/client/components/ui/alert-dialog.tsx similarity index 78% rename from client/components/ui/alert-dialog.jsx rename to client/components/ui/alert-dialog.tsx index 880939a..70f74d7 100644 --- a/client/components/ui/alert-dialog.jsx +++ b/client/components/ui/alert-dialog.tsx @@ -1,6 +1,6 @@ import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; +import type { ComponentProps } from 'react'; import { cn } from '@/lib/utils'; -import { buttonVariants } from '@/components/ui/button'; const AlertDialog = AlertDialogPrimitive.Root; const AlertDialogTrigger = AlertDialogPrimitive.Trigger; @@ -8,7 +8,7 @@ const AlertDialogPortal = AlertDialogPrimitive.Portal; const AlertDialogCancel = AlertDialogPrimitive.Cancel; const AlertDialogAction = AlertDialogPrimitive.Action; -function AlertDialogOverlay({ className, ...props }) { +function AlertDialogOverlay({ className, ...props }: ComponentProps) { return ( ) { return ( @@ -42,17 +42,17 @@ function AlertDialogContent({ className, children, ...props }) { ); } -function AlertDialogHeader({ className, ...props }) { +function AlertDialogHeader({ className, ...props }: ComponentProps<'div'>) { return
; } -function AlertDialogFooter({ className, ...props }) { +function AlertDialogFooter({ className, ...props }: ComponentProps<'div'>) { return (
); } -function AlertDialogTitle({ className, ...props }) { +function AlertDialogTitle({ className, ...props }: ComponentProps) { return ( ) { return ( & VariantProps) { return (
); diff --git a/client/components/ui/button.jsx b/client/components/ui/button.tsx similarity index 83% rename from client/components/ui/button.jsx rename to client/components/ui/button.tsx index 4cf7256..f26e111 100644 --- a/client/components/ui/button.jsx +++ b/client/components/ui/button.tsx @@ -1,5 +1,6 @@ import { Slot } from '@radix-ui/react-slot'; -import { cva } from 'class-variance-authority'; +import { cva, type VariantProps } from 'class-variance-authority'; +import type { ComponentProps, ElementType } from 'react'; import { cn } from '@/lib/utils'; const buttonVariants = cva( @@ -28,8 +29,10 @@ const buttonVariants = cva( } ); -function Button({ className, variant, size, asChild = false, ref, ...props }) { - const Comp = asChild ? Slot : 'button'; +type ButtonProps = ComponentProps<'button'> & VariantProps & { asChild?: boolean }; + +function Button({ className, variant, size, asChild = false, ref, ...props }: ButtonProps) { + const Comp: ElementType = asChild ? Slot : 'button'; return ( ) { return (
) { return (
) { return (
) { return (
) { return (
) { return (
) { return ( ) { return ( void - title string - description string | ReactNode - confirmLabel string (default: 'Confirm') - cancelLabel string (default: 'Cancel') - variant 'default' | 'destructive' - onConfirm () => void | Promise - loading boolean - ─────────────────────────────────────────────────────────────── */ +interface ConfirmDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title?: string; + description?: React.ReactNode; + confirmLabel?: string; + cancelLabel?: string; + variant?: ConfirmVariant; + onConfirm?: () => void | Promise; + loading?: boolean; +} + +/* ── ConfirmDialog ────────────────────────────────────────────── + Reusable confirmation dialog that replaces window.confirm. */ export function ConfirmDialog({ open, @@ -36,7 +38,7 @@ export function ConfirmDialog({ variant = 'default', onConfirm, loading = false, -}) { +}: ConfirmDialogProps) { const handleConfirm = async () => { await onConfirm?.(); }; @@ -84,14 +86,29 @@ export function ConfirmDialog({ Usage: const { confirm, ConfirmDialogComponent } = useConfirm(); - // ... const ok = await confirm({ title, description, confirmLabel, variant }); - // Returns true if confirmed, false if cancelled. - // Render anywhere in the tree. - ─────────────────────────────────────────────────────────────── */ + // Render anywhere in the tree. */ + +interface ConfirmState { + open: boolean; + title: string; + description: React.ReactNode; + confirmLabel: string; + cancelLabel: string; + variant: ConfirmVariant; + loading: boolean; +} + +interface ConfirmOptions { + title?: string; + description?: React.ReactNode; + confirmLabel?: string; + cancelLabel?: string; + variant?: ConfirmVariant; +} export function useConfirm() { - const [state, setState] = React.useState({ + const [state, setState] = React.useState({ open: false, title: '', description: '', @@ -102,7 +119,7 @@ export function useConfirm() { }); // Keep a stable ref to the resolve callback so it survives re-renders - const resolveRef = React.useRef(null); + const resolveRef = React.useRef<((value: boolean) => void) | null>(null); const confirm = React.useCallback( ({ @@ -111,7 +128,7 @@ export function useConfirm() { confirmLabel = 'Confirm', cancelLabel = 'Cancel', variant = 'default', - } = {}) => { + }: ConfirmOptions = {}): Promise => { return new Promise((resolve) => { resolveRef.current = resolve; setState({ @@ -135,7 +152,7 @@ export function useConfirm() { setState((s) => ({ ...s, open: false, loading: false })); }, []); - const handleOpenChange = React.useCallback((open) => { + const handleOpenChange = React.useCallback((open: boolean) => { if (!open) { resolveRef.current?.(false); resolveRef.current = null; diff --git a/client/components/ui/dialog.jsx b/client/components/ui/dialog.tsx similarity index 82% rename from client/components/ui/dialog.jsx rename to client/components/ui/dialog.tsx index d1c303b..68c4e3f 100644 --- a/client/components/ui/dialog.jsx +++ b/client/components/ui/dialog.tsx @@ -1,6 +1,7 @@ import * as DialogPrimitive from '@radix-ui/react-dialog'; import { motion } from 'framer-motion'; import { X } from 'lucide-react'; +import type { ComponentProps } from 'react'; import { cn } from '@/lib/utils'; const Dialog = DialogPrimitive.Root; @@ -8,7 +9,7 @@ const DialogTrigger = DialogPrimitive.Trigger; const DialogPortal = DialogPrimitive.Portal; const DialogClose = DialogPrimitive.Close; -function DialogOverlay({ className, ref, ...props }) { +function DialogOverlay({ className, ref, ...props }: ComponentProps) { return ( ) { return ( @@ -53,7 +54,7 @@ function DialogContent({ className, children, ref, ...props }) { ); } -function DialogHeader({ className, ...props }) { +function DialogHeader({ className, ...props }: ComponentProps<'div'>) { return (
) { return (
) { return ( ) { return ( & { inset?: boolean }) { return ( ) { return ( ) { return ( & { inset?: boolean; destructive?: boolean }) { return ( ) { return ( ) { return ( & { inset?: boolean }) { return ( ) { return ( ) { return ( ); diff --git a/client/components/ui/input-dialog.jsx b/client/components/ui/input-dialog.tsx similarity index 85% rename from client/components/ui/input-dialog.jsx rename to client/components/ui/input-dialog.tsx index 29e2679..0d8d446 100644 --- a/client/components/ui/input-dialog.jsx +++ b/client/components/ui/input-dialog.tsx @@ -17,6 +17,21 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +interface InputDialogProps { + open: boolean; + onOpenChange?: (open: boolean) => void; + title?: string; + description?: string; + label?: string; + defaultValue?: string; + placeholder?: string; + confirmLabel?: string; + cancelLabel?: string; + validate?: (value: string) => string | null; + onConfirm?: (value: string) => void; + loading?: boolean; +} + export function InputDialog({ open, onOpenChange, @@ -30,10 +45,10 @@ export function InputDialog({ validate, // optional (value: string) => string | null (null = valid) onConfirm, loading = false, -}) { +}: InputDialogProps) { const [value, setValue] = useState(defaultValue); const [error, setError] = useState(''); - const inputRef = useRef(null); + const inputRef = useRef(null); // Reset value when dialog opens useEffect(() => { @@ -58,7 +73,7 @@ export function InputDialog({ onConfirm?.(trimmed); }; - const handleKeyDown = (e) => { + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); handleConfirm(); } if (e.key === 'Escape') onOpenChange?.(false); }; diff --git a/client/components/ui/input.jsx b/client/components/ui/input.tsx similarity index 84% rename from client/components/ui/input.jsx rename to client/components/ui/input.tsx index fcd0b4e..be860c8 100644 --- a/client/components/ui/input.jsx +++ b/client/components/ui/input.tsx @@ -1,6 +1,7 @@ +import type { ComponentProps } from 'react'; import { cn } from '@/lib/utils'; -function Input({ className, type, ref, ...props }) { +function Input({ className, type, ref, ...props }: ComponentProps<'input'>) { return ( ) { return ( = { saving: { icon: CloudUpload, text: 'Saving…', cls: 'text-muted-foreground border-border/70 bg-muted/40' }, saved: { icon: Check, text: 'Saved', cls: 'text-emerald-600 dark:text-emerald-300 border-emerald-500/30 bg-emerald-500/10' }, error: { icon: AlertCircle, text: 'Save failed', cls: 'text-rose-500 dark:text-rose-300 border-rose-500/35 bg-rose-500/10' }, @@ -12,8 +18,8 @@ const STATES = { * Tiny animated pill that reflects auto-save state. Renders nothing while idle — * the page communicates "changes save automatically" once, statically. */ -export function SaveStatus({ status, className }) { - const state = STATES[status]; +export function SaveStatus({ status, className }: { status?: string | null; className?: string }) { + const state = status ? STATES[status] : undefined; return ( {state && ( diff --git a/client/components/ui/select.jsx b/client/components/ui/select.tsx similarity index 84% rename from client/components/ui/select.jsx rename to client/components/ui/select.tsx index 7bc742d..e7c19ac 100644 --- a/client/components/ui/select.jsx +++ b/client/components/ui/select.tsx @@ -1,12 +1,13 @@ import * as SelectPrimitive from '@radix-ui/react-select'; import { Check, ChevronDown, ChevronUp } from 'lucide-react'; +import type { ComponentProps } from 'react'; import { cn } from '@/lib/utils'; const Select = SelectPrimitive.Root; const SelectGroup = SelectPrimitive.Group; const SelectValue = SelectPrimitive.Value; -function SelectTrigger({ className, children, ref, ...props }) { +function SelectTrigger({ className, children, ref, ...props }: ComponentProps) { return ( ) { return ( ) { return ( ) { return ( ) { return ( ) { return ( ) { return ( ) { return ( ) { return ( ) { return (
) { return ( ) { return ( ) { return ( ) { return ( ) { return (
) { return ( ) { return (
) { return ( ) { return ( ) { return ( ) { return ( & { inset?: boolean }) { return ( t.value === theme) ?? THEMES[1]; + const current = THEMES.find((t) => t.value === theme) ?? THEMES[1]!; const CurrentIcon = current.Icon; return ( diff --git a/client/components/ui/tooltip.jsx b/client/components/ui/tooltip.tsx similarity index 90% rename from client/components/ui/tooltip.jsx rename to client/components/ui/tooltip.tsx index b16c921..bbc1296 100644 --- a/client/components/ui/tooltip.jsx +++ b/client/components/ui/tooltip.tsx @@ -1,11 +1,12 @@ import * as TooltipPrimitive from '@radix-ui/react-tooltip'; +import type { ComponentProps } from 'react'; import { cn } from '@/lib/utils'; const TooltipProvider = TooltipPrimitive.Provider; const Tooltip = TooltipPrimitive.Root; const TooltipTrigger = TooltipPrimitive.Trigger; -function TooltipContent({ className, sideOffset = 4, ref, ...props }) { +function TooltipContent({ className, sideOffset = 4, ref, ...props }: ComponentProps) { return ( void; +} -export function ThemeProvider({ children }) { - const [theme, setThemeState] = useState(() => { +const ThemeContext = createContext(null); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setThemeState] = useState(() => { const stored = loadStoredTheme(); // Apply immediately (before first paint) to avoid flash applyTheme(stored); return stored; }); - const setTheme = (newTheme) => { - if (!VALID_THEMES.includes(newTheme)) return; - applyTheme(newTheme); - setThemeState(newTheme); + const setTheme = (newTheme: string) => { + if (!(VALID_THEMES as string[]).includes(newTheme)) return; + applyTheme(newTheme as Theme); + setThemeState(newTheme as Theme); try { localStorage.setItem(STORAGE_KEY, newTheme); } catch { diff --git a/client/hooks/usePaymentActions.ts b/client/hooks/usePaymentActions.ts index ca479af..bf0e5e0 100644 --- a/client/hooks/usePaymentActions.ts +++ b/client/hooks/usePaymentActions.ts @@ -1,7 +1,7 @@ import { useMutation } from '@tanstack/react-query'; import { toast } from 'sonner'; import { api } from '@/api'; -import { fmt } from '@/lib/utils'; +import { fmt, errMessage } from '@/lib/utils'; import type { Dollars } from '@/lib/money'; import { useInvalidateTrackerData } from '@/hooks/useQueries'; @@ -11,11 +11,6 @@ interface PayableRow { name: string; } -/** Narrow an unknown error (strict catch clauses) to a message string. */ -function errMessage(err: unknown, fallback: string): string { - return err instanceof Error && err.message ? err.message : fallback; -} - // Quick-pay a tracker row (record a payment for `val` on `paidDate`) with a // reversible Undo toast. A React Query mutation: `isPending` comes for free and // the tracker/badge caches are invalidated on settle. Shared by the desktop and diff --git a/client/lib/utils.ts b/client/lib/utils.ts index 73ec192..00461b2 100644 --- a/client/lib/utils.ts +++ b/client/lib/utils.ts @@ -14,6 +14,12 @@ export function fmt(amount: Parameters[0]): string { return formatUSD(amount); } +// Narrow an unknown error (strict catch clauses give `unknown`) to a message +// string, falling back when the thrown value isn't an Error with a message. +export function errMessage(err: unknown, fallback = 'Something went wrong'): string { + return err instanceof Error && err.message ? err.message : fallback; +} + export function fmtDate(dateStr: string | null | undefined): string { if (!dateStr) return '—'; const [y = '', m = '', d = ''] = dateStr.split('-'); diff --git a/client/types.ts b/client/types.ts index cf8a0df..2416624 100644 --- a/client/types.ts +++ b/client/types.ts @@ -152,7 +152,9 @@ export interface TrackerRow { actual_amount: Dollars | null; is_skipped: boolean; snoozed_until: string | null; + monthly_notes?: string | null; autopay_suggestion?: unknown; + amount_suggestion?: unknown; previous_month_paid: Dollars; // Bank-mode augmentation (present only when bank_tracking.enabled): pending_cleared?: boolean;