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 { 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<HTMLInputElement>(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<HTMLInputElement>) {
if (e.key === 'Enter') inputRef.current?.blur();
if (e.key === 'Escape') { setValue(''); setEditing(false); }
}

View File

@ -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);
}

View File

@ -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); }
}

View File

@ -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<SkeletonVariant, string> = {
line: 'h-4 w-full rounded-md',
circle: 'h-10 w-10 rounded-full',
card: 'h-24 w-full rounded-xl',

View File

@ -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<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
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 (
<AlertDialogPortal>
<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} />;
}
function AlertDialogFooter({ className, ...props }) {
function AlertDialogFooter({ className, ...props }: ComponentProps<'div'>) {
return (
<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 (
<AlertDialogPrimitive.Title
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 (
<AlertDialogPrimitive.Description
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';
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 (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);

View File

@ -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<typeof buttonVariants> & { asChild?: boolean };
function Button({ className, variant, size, asChild = false, ref, ...props }: ButtonProps) {
const Comp: ElementType = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}

View File

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

View File

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

View File

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

View File

@ -11,20 +11,22 @@ import {
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
/* ConfirmDialog
Reusable confirmation dialog that replaces window.confirm.
type ConfirmVariant = 'default' | 'destructive';
Props:
open boolean
onOpenChange (open: boolean) => void
title string
description string | ReactNode
confirmLabel string (default: 'Confirm')
cancelLabel string (default: 'Cancel')
variant 'default' | 'destructive'
onConfirm () => void | Promise<void>
loading boolean
*/
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title?: string;
description?: React.ReactNode;
confirmLabel?: string;
cancelLabel?: string;
variant?: ConfirmVariant;
onConfirm?: () => void | Promise<void>;
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 <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() {
const [state, setState] = React.useState({
const [state, setState] = React.useState<ConfirmState>({
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<boolean> => {
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;

View File

@ -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<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
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 (
<DialogPortal>
<DialogOverlay />
@ -53,7 +54,7 @@ function DialogContent({ className, children, ref, ...props }) {
);
}
function DialogHeader({ className, ...props }) {
function DialogHeader({ className, ...props }: ComponentProps<'div'>) {
return (
<div
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 (
<div
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 (
<DialogPrimitive.Title
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 (
<DialogPrimitive.Description
ref={ref}

View File

@ -1,5 +1,6 @@
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
@ -9,7 +10,7 @@ const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
function DropdownMenuSubTrigger({ className, inset, children, ...props }) {
function DropdownMenuSubTrigger({ className, inset, children, ...props }: ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }) {
return (
<DropdownMenuPrimitive.SubTrigger
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 (
<DropdownMenuPrimitive.SubContent
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 (
<DropdownMenuPrimitive.Portal>
<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 (
<DropdownMenuPrimitive.Item
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 (
<DropdownMenuPrimitive.CheckboxItem
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 (
<DropdownMenuPrimitive.RadioItem
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 (
<DropdownMenuPrimitive.Label
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 (
<DropdownMenuPrimitive.Separator
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 (
<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 { 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<HTMLInputElement>(null);
// Reset value when dialog opens
useEffect(() => {
@ -58,7 +73,7 @@ export function InputDialog({
onConfirm?.(trimmed);
};
const handleKeyDown = (e) => {
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { e.preventDefault(); handleConfirm(); }
if (e.key === 'Escape') onOpenChange?.(false);
};

View File

@ -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 (
<input
type={type}

View File

@ -1,12 +1,13 @@
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva } from 'class-variance-authority';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils';
const labelVariants = cva(
'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 (
<LabelPrimitive.Root
ref={ref}

View File

@ -1,8 +1,14 @@
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';
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' },
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 (
<AnimatePresence mode="wait">
{state && (

View File

@ -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<typeof SelectPrimitive.Trigger>) {
return (
<SelectPrimitive.Trigger
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 (
<SelectPrimitive.ScrollUpButton
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 (
<SelectPrimitive.ScrollDownButton
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 (
<SelectPrimitive.Portal>
<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 (
<SelectPrimitive.Label
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 (
<SelectPrimitive.Item
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 (
<SelectPrimitive.Separator
ref={ref}

View File

@ -1,7 +1,8 @@
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import type { ComponentProps } from 'react';
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 (
<SeparatorPrimitive.Root
ref={ref}

View File

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

View File

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

View File

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

View File

@ -1,5 +1,6 @@
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 { useTheme } from '@/contexts/ThemeContext';
@ -8,7 +9,7 @@ import { useTheme } from '@/contexts/ThemeContext';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
function DropdownMenuContent({ className, sideOffset = 6, ref, ...props }) {
function DropdownMenuContent({ className, sideOffset = 6, ref, ...props }: ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<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 (
<DropdownMenuPrimitive.Item
ref={ref}
@ -46,17 +47,23 @@ function DropdownMenuItem({ className, inset, ref, ...props }) {
/* ── Theme options config ───────────────────────────────────── */
const THEMES = [
interface ThemeOption {
value: string;
label: string;
Icon: LucideIcon;
}
const THEMES: ThemeOption[] = [
{ value: 'light', label: 'Light', Icon: Sun },
{ value: 'dark', label: 'Dark', Icon: Moon },
];
/* ── ThemeToggle ────────────────────────────────────────────── */
export function ThemeToggle({ className }) {
export function ThemeToggle({ className }: { className?: string }) {
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;
return (

View File

@ -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<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<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'
@ -9,11 +9,13 @@ import { createContext, useContext, useEffect, useState } from 'react';
* dark 'dark'
*/
const STORAGE_KEY = 'bt-theme';
const VALID_THEMES = ['light', 'dark'];
const DEFAULT_THEME = 'dark';
type Theme = 'light' | '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;
root.classList.remove('dark');
if (theme === 'dark') {
@ -22,31 +24,36 @@ function applyTheme(theme) {
// 'light' → no classes
}
function loadStoredTheme() {
function loadStoredTheme(): Theme {
try {
const stored = localStorage.getItem(STORAGE_KEY);
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 {
// localStorage unavailable
}
return DEFAULT_THEME;
}
const ThemeContext = createContext(null);
interface ThemeContextValue {
theme: Theme;
setTheme: (theme: string) => void;
}
export function ThemeProvider({ children }) {
const [theme, setThemeState] = useState(() => {
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
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 {

View File

@ -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

View File

@ -14,6 +14,12 @@ export function fmt(amount: Parameters<typeof formatUSD>[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('-');

View File

@ -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;