refactor(ts): convert all UI primitives + ThemeContext to TSX (B2)

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<typeof
Radix.X> + 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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 22:57:22 -05:00
parent dc675fbecd
commit 1f18a720c4
29 changed files with 230 additions and 129 deletions

View File

@ -1,14 +1,24 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; 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 { 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 // `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 [editing, setEditing] = useState(false);
const [value, setValue] = useState(''); const [value, setValue] = useState('');
const inputRef = useRef(null); const inputRef = useRef<HTMLInputElement>(null);
const displayVal = field === 'amount' const displayVal = field === 'amount'
? (row.total_paid > 0 ? fmt(row.total_paid) : '—') ? (row.total_paid > 0 ? fmt(row.total_paid) : '—')
@ -33,10 +43,10 @@ export function EditableCell({ row, field, threshold, defaultPaymentDate, refres
if (!val) return; if (!val) return;
try { try {
if (row.payments && row.payments.length > 0) { 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 === 'amount') update.amount = parseFloat(val);
if (field === 'date') update.paid_date = 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 { } else {
await api.createPayment({ await api.createPayment({
bill_id: row.id, bill_id: row.id,
@ -47,11 +57,11 @@ export function EditableCell({ row, field, threshold, defaultPaymentDate, refres
toast.success('Saved'); toast.success('Saved');
refresh(); refresh();
} catch (err) { } catch (err) {
toast.error(err.message); toast.error(errMessage(err));
} }
} }
function onKeyDown(e) { function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') inputRef.current?.blur(); if (e.key === 'Enter') inputRef.current?.blur();
if (e.key === 'Escape') { setValue(''); setEditing(false); } if (e.key === 'Escape') { setValue(''); setEditing(false); }
} }

View File

@ -1,11 +1,20 @@
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { cn, fmt } from '@/lib/utils'; import { cn, fmt, errMessage } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { MONTHS, rowThreshold, paymentSummary } from '@/lib/trackerUtils'; 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 threshold = rowThreshold(row);
const summary = paymentSummary(row, threshold); const summary = paymentSummary(row, threshold);
const [saving, setSaving] = useState(false); 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)}`); toast.success(`${MONTHS[month - 1]} amount set to ${fmt(summary.paid)}`);
refresh?.(); refresh?.();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to update monthly amount'); toast.error(errMessage(err, 'Failed to update monthly amount'));
} finally { } finally {
setSaving(false); setSaving(false);
} }

View File

@ -1,10 +1,17 @@
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; 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. // Monthly notes stored in monthly_bill_state — per-month, not per-bill. The
export function NotesCell({ row, refresh }) { // 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 savedNote = row.monthly_notes || '';
const [value, setValue] = useState(savedNote); const [value, setValue] = useState(savedNote);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -33,7 +40,7 @@ export function NotesCell({ row, refresh }) {
}); });
refresh(); refresh();
} catch (err) { } catch (err) {
toast.error(err.message); toast.error(errMessage(err));
setValue(savedNote); setValue(savedNote);
} finally { setSaving(false); } } finally { setSaving(false); }
} }

View File

@ -1,7 +1,10 @@
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function Skeleton({ className, variant = 'line', ref, ...props }) { type SkeletonVariant = 'line' | 'circle' | 'card' | 'button' | 'input';
const variants = {
function Skeleton({ className, variant = 'line', ref, ...props }: ComponentProps<'div'> & { variant?: SkeletonVariant }) {
const variants: Record<SkeletonVariant, string> = {
line: 'h-4 w-full rounded-md', line: 'h-4 w-full rounded-md',
circle: 'h-10 w-10 rounded-full', circle: 'h-10 w-10 rounded-full',
card: 'h-24 w-full rounded-xl', card: 'h-24 w-full rounded-xl',

View File

@ -1,6 +1,6 @@
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
const AlertDialog = AlertDialogPrimitive.Root; const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger; const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
@ -8,7 +8,7 @@ const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogCancel = AlertDialogPrimitive.Cancel; const AlertDialogCancel = AlertDialogPrimitive.Cancel;
const AlertDialogAction = AlertDialogPrimitive.Action; const AlertDialogAction = AlertDialogPrimitive.Action;
function AlertDialogOverlay({ className, ...props }) { function AlertDialogOverlay({ className, ...props }: ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return ( return (
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
className={cn( className={cn(
@ -20,7 +20,7 @@ function AlertDialogOverlay({ className, ...props }) {
); );
} }
function AlertDialogContent({ className, children, ...props }) { function AlertDialogContent({ className, children, ...props }: ComponentProps<typeof AlertDialogPrimitive.Content>) {
return ( return (
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay /> <AlertDialogOverlay />
@ -42,17 +42,17 @@ function AlertDialogContent({ className, children, ...props }) {
); );
} }
function AlertDialogHeader({ className, ...props }) { function AlertDialogHeader({ className, ...props }: ComponentProps<'div'>) {
return <div className={cn('flex flex-col gap-2', className)} {...props} />; return <div className={cn('flex flex-col gap-2', className)} {...props} />;
} }
function AlertDialogFooter({ className, ...props }) { function AlertDialogFooter({ className, ...props }: ComponentProps<'div'>) {
return ( return (
<div className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} /> <div className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)} {...props} />
); );
} }
function AlertDialogTitle({ className, ...props }) { function AlertDialogTitle({ className, ...props }: ComponentProps<typeof AlertDialogPrimitive.Title>) {
return ( return (
<AlertDialogPrimitive.Title <AlertDialogPrimitive.Title
className={cn('text-base font-semibold text-foreground', className)} className={cn('text-base font-semibold text-foreground', className)}
@ -61,7 +61,7 @@ function AlertDialogTitle({ className, ...props }) {
); );
} }
function AlertDialogDescription({ className, ...props }) { function AlertDialogDescription({ className, ...props }: ComponentProps<typeof AlertDialogPrimitive.Description>) {
return ( return (
<AlertDialogPrimitive.Description <AlertDialogPrimitive.Description
className={cn('text-sm text-muted-foreground leading-relaxed', className)} className={cn('text-sm text-muted-foreground leading-relaxed', className)}

View File

@ -1,4 +1,5 @@
import { cva } from 'class-variance-authority'; import { cva, type VariantProps } from 'class-variance-authority';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const badgeVariants = cva( const badgeVariants = cva(
@ -25,7 +26,7 @@ const badgeVariants = cva(
} }
); );
function Badge({ className, variant, ...props }) { function Badge({ className, variant, ...props }: ComponentProps<'div'> & VariantProps<typeof badgeVariants>) {
return ( return (
<div className={cn(badgeVariants({ variant }), className)} {...props} /> <div className={cn(badgeVariants({ variant }), className)} {...props} />
); );

View File

@ -1,5 +1,6 @@
import { Slot } from '@radix-ui/react-slot'; 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'; import { cn } from '@/lib/utils';
const buttonVariants = cva( const buttonVariants = cva(
@ -28,8 +29,10 @@ const buttonVariants = cva(
} }
); );
function Button({ className, variant, size, asChild = false, ref, ...props }) { type ButtonProps = ComponentProps<'button'> & VariantProps<typeof buttonVariants> & { asChild?: boolean };
const Comp = asChild ? Slot : 'button';
function Button({ className, variant, size, asChild = false, ref, ...props }: ButtonProps) {
const Comp: ElementType = asChild ? Slot : 'button';
return ( return (
<Comp <Comp
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}

View File

@ -1,6 +1,7 @@
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function Card({ className, ref, ...props }) { function Card({ className, ref, ...props }: ComponentProps<'div'>) {
return ( return (
<div <div
ref={ref} ref={ref}
@ -10,7 +11,7 @@ function Card({ className, ref, ...props }) {
); );
} }
function CardHeader({ className, ref, ...props }) { function CardHeader({ className, ref, ...props }: ComponentProps<'div'>) {
return ( return (
<div <div
ref={ref} ref={ref}
@ -20,7 +21,7 @@ function CardHeader({ className, ref, ...props }) {
); );
} }
function CardTitle({ className, ref, ...props }) { function CardTitle({ className, ref, ...props }: ComponentProps<'div'>) {
return ( return (
<div <div
ref={ref} ref={ref}
@ -30,7 +31,7 @@ function CardTitle({ className, ref, ...props }) {
); );
} }
function CardDescription({ className, ref, ...props }) { function CardDescription({ className, ref, ...props }: ComponentProps<'div'>) {
return ( return (
<div <div
ref={ref} ref={ref}
@ -40,7 +41,7 @@ function CardDescription({ className, ref, ...props }) {
); );
} }
function CardContent({ className, ref, ...props }) { function CardContent({ className, ref, ...props }: ComponentProps<'div'>) {
return ( return (
<div <div
ref={ref} ref={ref}
@ -50,7 +51,7 @@ function CardContent({ className, ref, ...props }) {
); );
} }
function CardFooter({ className, ref, ...props }) { function CardFooter({ className, ref, ...props }: ComponentProps<'div'>) {
return ( return (
<div <div
ref={ref} ref={ref}

View File

@ -1,8 +1,9 @@
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react'; import { Check } from 'lucide-react';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function Checkbox({ className, ref, ...props }) { function Checkbox({ className, ref, ...props }: ComponentProps<typeof CheckboxPrimitive.Root>) {
return ( return (
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
ref={ref} ref={ref}

View File

@ -1,9 +1,10 @@
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'; import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
import type { ComponentProps } from 'react';
const Collapsible = CollapsiblePrimitive.Root; const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
function CollapsibleContent({ ref, ...props }) { function CollapsibleContent({ ref, ...props }: ComponentProps<typeof CollapsiblePrimitive.Content>) {
return ( return (
<CollapsiblePrimitive.Content <CollapsiblePrimitive.Content
ref={ref} ref={ref}

View File

@ -11,20 +11,22 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
/* ConfirmDialog type ConfirmVariant = 'default' | 'destructive';
Reusable confirmation dialog that replaces window.confirm.
Props: interface ConfirmDialogProps {
open boolean open: boolean;
onOpenChange (open: boolean) => void onOpenChange: (open: boolean) => void;
title string title?: string;
description string | ReactNode description?: React.ReactNode;
confirmLabel string (default: 'Confirm') confirmLabel?: string;
cancelLabel string (default: 'Cancel') cancelLabel?: string;
variant 'default' | 'destructive' variant?: ConfirmVariant;
onConfirm () => void | Promise<void> onConfirm?: () => void | Promise<void>;
loading boolean loading?: boolean;
*/ }
/* ConfirmDialog
Reusable confirmation dialog that replaces window.confirm. */
export function ConfirmDialog({ export function ConfirmDialog({
open, open,
@ -36,7 +38,7 @@ export function ConfirmDialog({
variant = 'default', variant = 'default',
onConfirm, onConfirm,
loading = false, loading = false,
}) { }: ConfirmDialogProps) {
const handleConfirm = async () => { const handleConfirm = async () => {
await onConfirm?.(); await onConfirm?.();
}; };
@ -84,14 +86,29 @@ export function ConfirmDialog({
Usage: Usage:
const { confirm, ConfirmDialogComponent } = useConfirm(); const { confirm, ConfirmDialogComponent } = useConfirm();
// ...
const ok = await confirm({ title, description, confirmLabel, variant }); const ok = await confirm({ title, description, confirmLabel, variant });
// Returns true if confirmed, false if cancelled. // Render <ConfirmDialogComponent /> anywhere in the tree. */
// Render <ConfirmDialogComponent /> 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() { export function useConfirm() {
const [state, setState] = React.useState({ const [state, setState] = React.useState<ConfirmState>({
open: false, open: false,
title: '', title: '',
description: '', description: '',
@ -102,7 +119,7 @@ export function useConfirm() {
}); });
// Keep a stable ref to the resolve callback so it survives re-renders // 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( const confirm = React.useCallback(
({ ({
@ -111,7 +128,7 @@ export function useConfirm() {
confirmLabel = 'Confirm', confirmLabel = 'Confirm',
cancelLabel = 'Cancel', cancelLabel = 'Cancel',
variant = 'default', variant = 'default',
} = {}) => { }: ConfirmOptions = {}): Promise<boolean> => {
return new Promise((resolve) => { return new Promise((resolve) => {
resolveRef.current = resolve; resolveRef.current = resolve;
setState({ setState({
@ -135,7 +152,7 @@ export function useConfirm() {
setState((s) => ({ ...s, open: false, loading: false })); setState((s) => ({ ...s, open: false, loading: false }));
}, []); }, []);
const handleOpenChange = React.useCallback((open) => { const handleOpenChange = React.useCallback((open: boolean) => {
if (!open) { if (!open) {
resolveRef.current?.(false); resolveRef.current?.(false);
resolveRef.current = null; resolveRef.current = null;

View File

@ -1,6 +1,7 @@
import * as DialogPrimitive from '@radix-ui/react-dialog'; import * as DialogPrimitive from '@radix-ui/react-dialog';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root; const Dialog = DialogPrimitive.Root;
@ -8,7 +9,7 @@ const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal; const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close; const DialogClose = DialogPrimitive.Close;
function DialogOverlay({ className, ref, ...props }) { function DialogOverlay({ className, ref, ...props }: ComponentProps<typeof DialogPrimitive.Overlay>) {
return ( return (
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
@ -21,7 +22,7 @@ function DialogOverlay({ className, ref, ...props }) {
); );
} }
function DialogContent({ className, children, ref, ...props }) { function DialogContent({ className, children, ref, ...props }: ComponentProps<typeof DialogPrimitive.Content>) {
return ( return (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
@ -53,7 +54,7 @@ function DialogContent({ className, children, ref, ...props }) {
); );
} }
function DialogHeader({ className, ...props }) { function DialogHeader({ className, ...props }: ComponentProps<'div'>) {
return ( return (
<div <div
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
@ -62,7 +63,7 @@ function DialogHeader({ className, ...props }) {
); );
} }
function DialogFooter({ className, ...props }) { function DialogFooter({ className, ...props }: ComponentProps<'div'>) {
return ( return (
<div <div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
@ -71,7 +72,7 @@ function DialogFooter({ className, ...props }) {
); );
} }
function DialogTitle({ className, ref, ...props }) { function DialogTitle({ className, ref, ...props }: ComponentProps<typeof DialogPrimitive.Title>) {
return ( return (
<DialogPrimitive.Title <DialogPrimitive.Title
ref={ref} ref={ref}
@ -81,7 +82,7 @@ function DialogTitle({ className, ref, ...props }) {
); );
} }
function DialogDescription({ className, ref, ...props }) { function DialogDescription({ className, ref, ...props }: ComponentProps<typeof DialogPrimitive.Description>) {
return ( return (
<DialogPrimitive.Description <DialogPrimitive.Description
ref={ref} ref={ref}

View File

@ -1,5 +1,6 @@
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react'; import { Check, ChevronRight, Circle } from 'lucide-react';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root; const DropdownMenu = DropdownMenuPrimitive.Root;
@ -9,7 +10,7 @@ const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub; const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
function DropdownMenuSubTrigger({ className, inset, children, ...props }) { function DropdownMenuSubTrigger({ className, inset, children, ...props }: ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }) {
return ( return (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
className={cn( className={cn(
@ -26,7 +27,7 @@ function DropdownMenuSubTrigger({ className, inset, children, ...props }) {
); );
} }
function DropdownMenuSubContent({ className, ...props }) { function DropdownMenuSubContent({ className, ...props }: ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return ( return (
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
className={cn( className={cn(
@ -40,7 +41,7 @@ function DropdownMenuSubContent({ className, ...props }) {
); );
} }
function DropdownMenuContent({ className, sideOffset = 4, ...props }) { function DropdownMenuContent({ className, sideOffset = 4, ...props }: ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return ( return (
<DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content <DropdownMenuPrimitive.Content
@ -57,7 +58,7 @@ function DropdownMenuContent({ className, sideOffset = 4, ...props }) {
); );
} }
function DropdownMenuItem({ className, inset, destructive, ...props }) { function DropdownMenuItem({ className, inset, destructive, ...props }: ComponentProps<typeof DropdownMenuPrimitive.Item> & { inset?: boolean; destructive?: boolean }) {
return ( return (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
className={cn( className={cn(
@ -72,7 +73,7 @@ function DropdownMenuItem({ className, inset, destructive, ...props }) {
); );
} }
function DropdownMenuCheckboxItem({ className, children, checked, ...props }) { function DropdownMenuCheckboxItem({ className, children, checked, ...props }: ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return ( return (
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
className={cn( className={cn(
@ -93,7 +94,7 @@ function DropdownMenuCheckboxItem({ className, children, checked, ...props }) {
); );
} }
function DropdownMenuRadioItem({ className, children, ...props }) { function DropdownMenuRadioItem({ className, children, ...props }: ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return ( return (
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
className={cn( className={cn(
@ -113,7 +114,7 @@ function DropdownMenuRadioItem({ className, children, ...props }) {
); );
} }
function DropdownMenuLabel({ className, inset, ...props }) { function DropdownMenuLabel({ className, inset, ...props }: ComponentProps<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }) {
return ( return (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
className={cn('px-2 py-1.5 text-xs font-medium text-muted-foreground', inset && 'pl-8', className)} className={cn('px-2 py-1.5 text-xs font-medium text-muted-foreground', inset && 'pl-8', className)}
@ -122,7 +123,7 @@ function DropdownMenuLabel({ className, inset, ...props }) {
); );
} }
function DropdownMenuSeparator({ className, ...props }) { function DropdownMenuSeparator({ className, ...props }: ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return ( return (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
className={cn('-mx-1 my-1 h-px bg-border', className)} className={cn('-mx-1 my-1 h-px bg-border', className)}
@ -132,7 +133,7 @@ function DropdownMenuSeparator({ className, ...props }) {
); );
} }
function DropdownMenuShortcut({ className, ...props }) { function DropdownMenuShortcut({ className, ...props }: ComponentProps<'span'>) {
return ( return (
<span className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} {...props} /> <span className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} {...props} />
); );

View File

@ -17,6 +17,21 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; 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({ export function InputDialog({
open, open,
onOpenChange, onOpenChange,
@ -30,10 +45,10 @@ export function InputDialog({
validate, // optional (value: string) => string | null (null = valid) validate, // optional (value: string) => string | null (null = valid)
onConfirm, onConfirm,
loading = false, loading = false,
}) { }: InputDialogProps) {
const [value, setValue] = useState(defaultValue); const [value, setValue] = useState(defaultValue);
const [error, setError] = useState(''); const [error, setError] = useState('');
const inputRef = useRef(null); const inputRef = useRef<HTMLInputElement>(null);
// Reset value when dialog opens // Reset value when dialog opens
useEffect(() => { useEffect(() => {
@ -58,7 +73,7 @@ export function InputDialog({
onConfirm?.(trimmed); onConfirm?.(trimmed);
}; };
const handleKeyDown = (e) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { e.preventDefault(); handleConfirm(); } if (e.key === 'Enter') { e.preventDefault(); handleConfirm(); }
if (e.key === 'Escape') onOpenChange?.(false); if (e.key === 'Escape') onOpenChange?.(false);
}; };

View File

@ -1,6 +1,7 @@
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function Input({ className, type, ref, ...props }) { function Input({ className, type, ref, ...props }: ComponentProps<'input'>) {
return ( return (
<input <input
type={type} type={type}

View File

@ -1,12 +1,13 @@
import * as LabelPrimitive from '@radix-ui/react-label'; import * as LabelPrimitive from '@radix-ui/react-label';
import { cva } from 'class-variance-authority'; import { cva } from 'class-variance-authority';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const labelVariants = cva( const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70' 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
); );
function Label({ className, ref, ...props }) { function Label({ className, ref, ...props }: ComponentProps<typeof LabelPrimitive.Root>) {
return ( return (
<LabelPrimitive.Root <LabelPrimitive.Root
ref={ref} ref={ref}

View File

@ -1,8 +1,14 @@
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import { Check, CloudUpload, AlertCircle } from 'lucide-react'; import { Check, CloudUpload, AlertCircle, type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const STATES = { interface SaveState {
icon: LucideIcon;
text: string;
cls: string;
}
const STATES: Record<string, SaveState> = {
saving: { icon: CloudUpload, text: 'Saving…', cls: 'text-muted-foreground border-border/70 bg-muted/40' }, 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' }, 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' }, 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 * Tiny animated pill that reflects auto-save state. Renders nothing while idle
* the page communicates "changes save automatically" once, statically. * the page communicates "changes save automatically" once, statically.
*/ */
export function SaveStatus({ status, className }) { export function SaveStatus({ status, className }: { status?: string | null; className?: string }) {
const state = STATES[status]; const state = status ? STATES[status] : undefined;
return ( return (
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{state && ( {state && (

View File

@ -1,12 +1,13 @@
import * as SelectPrimitive from '@radix-ui/react-select'; import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react'; import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root; const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group; const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value; const SelectValue = SelectPrimitive.Value;
function SelectTrigger({ className, children, ref, ...props }) { function SelectTrigger({ className, children, ref, ...props }: ComponentProps<typeof SelectPrimitive.Trigger>) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
@ -26,7 +27,7 @@ function SelectTrigger({ className, children, ref, ...props }) {
); );
} }
function SelectScrollUpButton({ className, ref, ...props }) { function SelectScrollUpButton({ className, ref, ...props }: ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return ( return (
<SelectPrimitive.ScrollUpButton <SelectPrimitive.ScrollUpButton
ref={ref} ref={ref}
@ -38,7 +39,7 @@ function SelectScrollUpButton({ className, ref, ...props }) {
); );
} }
function SelectScrollDownButton({ className, ref, ...props }) { function SelectScrollDownButton({ className, ref, ...props }: ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return ( return (
<SelectPrimitive.ScrollDownButton <SelectPrimitive.ScrollDownButton
ref={ref} ref={ref}
@ -50,7 +51,7 @@ function SelectScrollDownButton({ className, ref, ...props }) {
); );
} }
function SelectContent({ className, children, position = 'popper', ref, ...props }) { function SelectContent({ className, children, position = 'popper', ref, ...props }: ComponentProps<typeof SelectPrimitive.Content>) {
return ( return (
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
<SelectPrimitive.Content <SelectPrimitive.Content
@ -82,7 +83,7 @@ function SelectContent({ className, children, position = 'popper', ref, ...props
); );
} }
function SelectLabel({ className, ref, ...props }) { function SelectLabel({ className, ref, ...props }: ComponentProps<typeof SelectPrimitive.Label>) {
return ( return (
<SelectPrimitive.Label <SelectPrimitive.Label
ref={ref} ref={ref}
@ -92,7 +93,7 @@ function SelectLabel({ className, ref, ...props }) {
); );
} }
function SelectItem({ className, children, ref, ...props }) { function SelectItem({ className, children, ref, ...props }: ComponentProps<typeof SelectPrimitive.Item>) {
return ( return (
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} ref={ref}
@ -113,7 +114,7 @@ function SelectItem({ className, children, ref, ...props }) {
); );
} }
function SelectSeparator({ className, ref, ...props }) { function SelectSeparator({ className, ref, ...props }: ComponentProps<typeof SelectPrimitive.Separator>) {
return ( return (
<SelectPrimitive.Separator <SelectPrimitive.Separator
ref={ref} ref={ref}

View File

@ -1,7 +1,8 @@
import * as SeparatorPrimitive from '@radix-ui/react-separator'; import * as SeparatorPrimitive from '@radix-ui/react-separator';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function Separator({ className, orientation = 'horizontal', decorative = true, ref, ...props }) { function Separator({ className, orientation = 'horizontal', decorative = true, ref, ...props }: ComponentProps<typeof SeparatorPrimitive.Root>) {
return ( return (
<SeparatorPrimitive.Root <SeparatorPrimitive.Root
ref={ref} ref={ref}

View File

@ -1,7 +1,8 @@
import * as SwitchPrimitive from '@radix-ui/react-switch'; import * as SwitchPrimitive from '@radix-ui/react-switch';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function Switch({ className, ...props }) { function Switch({ className, ...props }: ComponentProps<typeof SwitchPrimitive.Root>) {
return ( return (
<SwitchPrimitive.Root <SwitchPrimitive.Root
className={cn( className={cn(

View File

@ -1,7 +1,8 @@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
function Table({ className, ref, ...props }) { function Table({ className, ref, ...props }: ComponentProps<'table'>) {
return ( return (
<div className="relative w-full overflow-auto"> <div className="relative w-full overflow-auto">
<table <table
@ -17,7 +18,7 @@ function Table({ className, ref, ...props }) {
); );
} }
function TableHeader({ className, ref, ...props }) { function TableHeader({ className, ref, ...props }: ComponentProps<'thead'>) {
return ( return (
<thead <thead
ref={ref} ref={ref}
@ -30,7 +31,7 @@ function TableHeader({ className, ref, ...props }) {
); );
} }
function TableBody({ className, ref, ...props }) { function TableBody({ className, ref, ...props }: ComponentProps<'tbody'>) {
return ( return (
<tbody <tbody
ref={ref} ref={ref}
@ -43,7 +44,7 @@ function TableBody({ className, ref, ...props }) {
); );
} }
function TableFooter({ className, ref, ...props }) { function TableFooter({ className, ref, ...props }: ComponentProps<'tfoot'>) {
return ( return (
<tfoot <tfoot
ref={ref} ref={ref}
@ -57,7 +58,7 @@ function TableFooter({ className, ref, ...props }) {
); );
} }
function TableRow({ className, ref, ...props }) { function TableRow({ className, ref, ...props }: ComponentProps<typeof motion.tr>) {
return ( return (
<motion.tr <motion.tr
ref={ref} ref={ref}
@ -73,7 +74,7 @@ function TableRow({ className, ref, ...props }) {
); );
} }
function TableHead({ className, ref, ...props }) { function TableHead({ className, ref, ...props }: ComponentProps<'th'>) {
return ( return (
<th <th
ref={ref} ref={ref}
@ -90,7 +91,7 @@ function TableHead({ className, ref, ...props }) {
); );
} }
function TableCell({ className, ref, ...props }) { function TableCell({ className, ref, ...props }: ComponentProps<'td'>) {
return ( return (
<td <td
ref={ref} ref={ref}
@ -106,7 +107,7 @@ function TableCell({ className, ref, ...props }) {
); );
} }
function TableCaption({ className, ref, ...props }) { function TableCaption({ className, ref, ...props }: ComponentProps<'caption'>) {
return ( return (
<caption <caption
ref={ref} ref={ref}

View File

@ -1,9 +1,10 @@
import * as TabsPrimitive from '@radix-ui/react-tabs'; import * as TabsPrimitive from '@radix-ui/react-tabs';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const Tabs = TabsPrimitive.Root; const Tabs = TabsPrimitive.Root;
function TabsList({ className, ref, ...props }) { function TabsList({ className, ref, ...props }: ComponentProps<typeof TabsPrimitive.List>) {
return ( return (
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
@ -16,7 +17,7 @@ function TabsList({ className, ref, ...props }) {
); );
} }
function TabsTrigger({ className, ref, ...props }) { function TabsTrigger({ className, ref, ...props }: ComponentProps<typeof TabsPrimitive.Trigger>) {
return ( return (
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
@ -29,7 +30,7 @@ function TabsTrigger({ className, ref, ...props }) {
); );
} }
function TabsContent({ className, ref, ...props }) { function TabsContent({ className, ref, ...props }: ComponentProps<typeof TabsPrimitive.Content>) {
return ( return (
<TabsPrimitive.Content <TabsPrimitive.Content
ref={ref} ref={ref}

View File

@ -1,5 +1,6 @@
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Sun, Moon } from 'lucide-react'; import { Sun, Moon, type LucideIcon } from 'lucide-react';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTheme } from '@/contexts/ThemeContext'; import { useTheme } from '@/contexts/ThemeContext';
@ -8,7 +9,7 @@ import { useTheme } from '@/contexts/ThemeContext';
const DropdownMenu = DropdownMenuPrimitive.Root; const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
function DropdownMenuContent({ className, sideOffset = 6, ref, ...props }) { function DropdownMenuContent({ className, sideOffset = 6, ref, ...props }: ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return ( return (
<DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content <DropdownMenuPrimitive.Content
@ -28,7 +29,7 @@ function DropdownMenuContent({ className, sideOffset = 6, ref, ...props }) {
); );
} }
function DropdownMenuItem({ className, inset, ref, ...props }) { function DropdownMenuItem({ className, inset, ref, ...props }: ComponentProps<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }) {
return ( return (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
@ -46,17 +47,23 @@ function DropdownMenuItem({ className, inset, ref, ...props }) {
/* ── Theme options config ───────────────────────────────────── */ /* ── Theme options config ───────────────────────────────────── */
const THEMES = [ interface ThemeOption {
value: string;
label: string;
Icon: LucideIcon;
}
const THEMES: ThemeOption[] = [
{ value: 'light', label: 'Light', Icon: Sun }, { value: 'light', label: 'Light', Icon: Sun },
{ value: 'dark', label: 'Dark', Icon: Moon }, { value: 'dark', label: 'Dark', Icon: Moon },
]; ];
/* ── ThemeToggle ────────────────────────────────────────────── */ /* ── ThemeToggle ────────────────────────────────────────────── */
export function ThemeToggle({ className }) { export function ThemeToggle({ className }: { className?: string }) {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const current = THEMES.find((t) => t.value === theme) ?? THEMES[1]; const current = THEMES.find((t) => t.value === theme) ?? THEMES[1]!;
const CurrentIcon = current.Icon; const CurrentIcon = current.Icon;
return ( return (

View File

@ -1,11 +1,12 @@
import * as TooltipPrimitive from '@radix-ui/react-tooltip'; import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider; const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root; const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger; const TooltipTrigger = TooltipPrimitive.Trigger;
function TooltipContent({ className, sideOffset = 4, ref, ...props }) { function TooltipContent({ className, sideOffset = 4, ref, ...props }: ComponentProps<typeof TooltipPrimitive.Content>) {
return ( return (
<TooltipPrimitive.Portal> <TooltipPrimitive.Portal>
<TooltipPrimitive.Content <TooltipPrimitive.Content

View File

@ -1,4 +1,4 @@
import { createContext, useContext, useEffect, useState } from 'react'; import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
/** /**
* Themes: 'light' | 'dark' * Themes: 'light' | 'dark'
@ -9,11 +9,13 @@ import { createContext, useContext, useEffect, useState } from 'react';
* dark 'dark' * dark 'dark'
*/ */
const STORAGE_KEY = 'bt-theme'; type Theme = 'light' | 'dark';
const VALID_THEMES = ['light', 'dark'];
const DEFAULT_THEME = 'dark';
function applyTheme(theme) { const STORAGE_KEY = 'bt-theme';
const VALID_THEMES: Theme[] = ['light', 'dark'];
const DEFAULT_THEME: Theme = 'dark';
function applyTheme(theme: Theme) {
const root = document.documentElement; const root = document.documentElement;
root.classList.remove('dark'); root.classList.remove('dark');
if (theme === 'dark') { if (theme === 'dark') {
@ -22,31 +24,36 @@ function applyTheme(theme) {
// 'light' → no classes // 'light' → no classes
} }
function loadStoredTheme() { function loadStoredTheme(): Theme {
try { try {
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'dark-purple') return 'dark'; if (stored === 'dark-purple') return 'dark';
if (stored && VALID_THEMES.includes(stored)) return stored; if (stored && (VALID_THEMES as string[]).includes(stored)) return stored as Theme;
} catch { } catch {
// localStorage unavailable // localStorage unavailable
} }
return DEFAULT_THEME; return DEFAULT_THEME;
} }
const ThemeContext = createContext(null); interface ThemeContextValue {
theme: Theme;
setTheme: (theme: string) => void;
}
export function ThemeProvider({ children }) { const ThemeContext = createContext<ThemeContextValue | null>(null);
const [theme, setThemeState] = useState(() => {
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
const stored = loadStoredTheme(); const stored = loadStoredTheme();
// Apply immediately (before first paint) to avoid flash // Apply immediately (before first paint) to avoid flash
applyTheme(stored); applyTheme(stored);
return stored; return stored;
}); });
const setTheme = (newTheme) => { const setTheme = (newTheme: string) => {
if (!VALID_THEMES.includes(newTheme)) return; if (!(VALID_THEMES as string[]).includes(newTheme)) return;
applyTheme(newTheme); applyTheme(newTheme as Theme);
setThemeState(newTheme); setThemeState(newTheme as Theme);
try { try {
localStorage.setItem(STORAGE_KEY, newTheme); localStorage.setItem(STORAGE_KEY, newTheme);
} catch { } catch {

View File

@ -1,7 +1,7 @@
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { fmt } from '@/lib/utils'; import { fmt, errMessage } from '@/lib/utils';
import type { Dollars } from '@/lib/money'; import type { Dollars } from '@/lib/money';
import { useInvalidateTrackerData } from '@/hooks/useQueries'; import { useInvalidateTrackerData } from '@/hooks/useQueries';
@ -11,11 +11,6 @@ interface PayableRow {
name: string; 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 // 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 // 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 // the tracker/badge caches are invalidated on settle. Shared by the desktop and

View File

@ -14,6 +14,12 @@ export function fmt(amount: Parameters<typeof formatUSD>[0]): string {
return formatUSD(amount); 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 { export function fmtDate(dateStr: string | null | undefined): string {
if (!dateStr) return '—'; if (!dateStr) return '—';
const [y = '', m = '', d = ''] = dateStr.split('-'); const [y = '', m = '', d = ''] = dateStr.split('-');

View File

@ -152,7 +152,9 @@ export interface TrackerRow {
actual_amount: Dollars | null; actual_amount: Dollars | null;
is_skipped: boolean; is_skipped: boolean;
snoozed_until: string | null; snoozed_until: string | null;
monthly_notes?: string | null;
autopay_suggestion?: unknown; autopay_suggestion?: unknown;
amount_suggestion?: unknown;
previous_month_paid: Dollars; previous_month_paid: Dollars;
// Bank-mode augmentation (present only when bank_tracking.enabled): // Bank-mode augmentation (present only when bank_tracking.enabled):
pending_cleared?: boolean; pending_cleared?: boolean;