diff --git a/FUTURE.md b/FUTURE.md index 44f1c26..7872470 100644 --- a/FUTURE.md +++ b/FUTURE.md @@ -143,16 +143,16 @@ The `payments` table has a `method` column (free-text) but no way to see "how mu ### πŸ”΅ No Keyboard Navigation or Shortcuts β€” LOW **Added:** 2026-05-11 by Ripley -**Status:** Partial β€” Esc closes modals βœ…, Cmd+K opens command palette βœ…, arrow key tracker navigation ❌ +**Status:** Done β€” Esc closes modals βœ…, Cmd+K opens command palette βœ…, arrow key tracker navigation βœ… **Description:** -Only a skip link exists for keyboard accessibility. No `Cmd+K` to find a bill, no `Esc` to close modals, no arrow keys to navigate the tracker grid. +Skip link, command palette, and tracker-row keyboard navigation are all implemented. **Implementation Notes:** - βœ… `Esc` closes any open modal/dialog (via Radix Dialog default) -- βœ… `Cmd+K` / `Ctrl+K` opens command palette (`CommandPalette.jsx`) -- ❌ Arrow keys navigate tracker rows when grid is focused -- Remaining effort: 1-2 hours +- βœ… `Cmd+K` / `Ctrl+K` opens command palette (`CommandPalette.tsx`) +- βœ… Arrow keys / `j`/`k` navigate tracker rows, `Enter` edits, `p` toggles paid (`TrackerRow.tsx` `handleRowKeyDown`, focusable `[data-tracker-row]`) +- Nice-to-have: a `?`-triggered shortcut legend for discoverability --- diff --git a/client/components/tracker/EditableCell.tsx b/client/components/tracker/EditableCell.tsx deleted file mode 100644 index b77d3a2..0000000 --- a/client/components/tracker/EditableCell.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { useState, useRef } from 'react'; -import { toast } from 'sonner'; -import { api } from '@/api'; -import { createPaymentOrConfirm } from '@/lib/paymentActions'; -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 -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 displayVal = field === 'amount' - ? (row.total_paid > 0 ? fmt(row.total_paid) : 'β€”') - : (row.last_paid_date ? fmtDate(row.last_paid_date) : 'β€”'); - - const isEmpty = field === 'amount' ? row.total_paid <= 0 : !row.last_paid_date; - // Mismatch when paid amount differs from the effective threshold for this month - const mismatch = field === 'amount' && row.total_paid > 0 && row.total_paid !== threshold; - - function startEdit() { - if (editing) return; - setValue(field === 'amount' - ? (row.total_paid > 0 ? String(row.total_paid) : '') - : (row.last_paid_date || '')); - setEditing(true); - setTimeout(() => { inputRef.current?.focus(); inputRef.current?.select(); }, 0); - } - - async function commit() { - setEditing(false); - const val = value.trim(); - if (!val) return; - try { - if (row.payments && row.payments.length > 0) { - 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); - toast.success('Saved'); - refresh(); - } else { - await createPaymentOrConfirm( - { - bill_id: row.id, - amount: field === 'amount' ? parseFloat(val) : threshold, - paid_date: field === 'date' ? val : defaultPaymentDate, - }, - () => { toast.success('Saved'); refresh(); }, - ); - } - } catch (err) { - toast.error(errMessage(err)); - } - } - - function onKeyDown(e: React.KeyboardEvent) { - if (e.key === 'Enter') inputRef.current?.blur(); - if (e.key === 'Escape') { setValue(''); setEditing(false); } - } - - if (editing) { - return ( - setValue(e.target.value)} - onBlur={commit} - onKeyDown={onKeyDown} - className="h-7 w-28 text-right font-mono text-sm bg-background/80 border-border/60" - /> - ); - } - - return ( - - {displayVal} - - ); -} diff --git a/client/components/tracker/MobileTrackerRow.tsx b/client/components/tracker/MobileTrackerRow.tsx index 7378387..7bf184b 100644 --- a/client/components/tracker/MobileTrackerRow.tsx +++ b/client/components/tracker/MobileTrackerRow.tsx @@ -11,7 +11,6 @@ import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, } from '@/components/ui/alert-dialog'; -import PaymentModal from '@/components/tracker/PaymentModal'; import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils'; import { useQuickPay, useTogglePaid } from '@/hooks/usePaymentActions'; import { StatusBadge } from '@/components/tracker/StatusBadge'; @@ -20,7 +19,7 @@ import { LowerThisMonthButton } from '@/components/tracker/LowerThisMonthButton' import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog'; import { NotesCell } from '@/components/tracker/NotesCell'; import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions'; -import type { Payment, TrackerRow } from '@/types'; +import type { TrackerRow } from '@/types'; function MiniSparkline({ values }: { values?: number[] | null }) { if (!values || values.length < 2) return null; @@ -78,7 +77,6 @@ interface MobileTrackerRowProps { export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted = false }: MobileTrackerRowProps) { const amountRef = useRef(null); - const [editPayment, setEditPayment] = useState(null); const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false); const [confirmUnpay, setConfirmUnpay] = useState(false); const [suggestionLoading, setSuggestionLoading] = useState(false); @@ -378,15 +376,6 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, - {editPayment && ( - setEditPayment(null)} - onSave={refresh} - /> - )} - {paymentLedgerOpen && ( void; - onSaved: () => void; -} - -function MonthlyStateDialog({ row, year, month, open, onOpenChange, onSaved }: MonthlyStateDialogProps) { - const [actualAmount, setActualAmount] = useState(''); - const [notes, setNotes] = useState(''); - const [isSkipped, setIsSkipped] = useState(false); - const [saving, setSaving] = useState(false); - - // Populate from current row state when dialog opens - useEffect(() => { - if (open) { - setActualAmount(row.actual_amount != null ? String(row.actual_amount) : ''); - setNotes(row.monthly_notes || ''); - setIsSkipped(!!row.is_skipped); - } - }, [open, row]); - - async function handleSave(e: React.FormEvent) { - e.preventDefault(); - const amt = actualAmount.trim() ? parseFloat(actualAmount) : null; - if (amt !== null && (isNaN(amt) || amt < 0)) { - toast.error('Amount must be a positive number or empty'); - return; - } - setSaving(true); - try { - await api.saveBillMonthlyState(row.id, { - year, - month, - actual_amount: amt, - notes: notes.trim() || null, - is_skipped: isSkipped, - }); - toast.success(`${MONTHS[month - 1]} state saved`); - onSaved(); - onOpenChange(false); - } catch (err) { - toast.error(errMessage(err)); - } finally { - setSaving(false); - } - } - - return ( - - - - - {row.name} - - {MONTHS[month - 1]} {year} - - -

- Monthly overrides β€” changes only affect {MONTHS[month - 1]} -

-
- -
- {/* Actual amount this month */} -
- - setActualAmount(e.target.value)} - className="font-mono bg-background/50 border-border/60" - /> -

- Leave blank to use the template default ({fmt(row.expected_amount)}). -

-
- - {/* Monthly notes */} -
- - setNotes(e.target.value)} - placeholder="e.g. higher than usual, double-billed…" - className="bg-background/50 border-border/60" - /> -
- - {/* Skip this month */} - -
- - - - - -
-
- ); -} - -export default MonthlyStateDialog; diff --git a/client/components/tracker/OverdueCommandCenter.tsx b/client/components/tracker/OverdueCommandCenter.tsx index 0787d22..4a4d6dd 100644 --- a/client/components/tracker/OverdueCommandCenter.tsx +++ b/client/components/tracker/OverdueCommandCenter.tsx @@ -193,15 +193,11 @@ export default function OverdueCommandCenter({ rows, year, month, refresh, onPay
- {overdueRows.length === 0 - ? 'No active overdue bills' - : `${overdueRows.length} overdue ${overdueRows.length === 1 ? 'bill' : 'bills'}`} + {`${overdueRows.length} overdue ${overdueRows.length === 1 ? 'bill' : 'bills'}`} + + + {fmt(asDollars(totalOverdue))} - {overdueRows.length > 0 && ( - - {fmt(asDollars(totalOverdue))} - - )}
{snoozedRows.length > 0 && ( diff --git a/client/components/tracker/TrackerRow.tsx b/client/components/tracker/TrackerRow.tsx index 144cfb0..1933041 100644 --- a/client/components/tracker/TrackerRow.tsx +++ b/client/components/tracker/TrackerRow.tsx @@ -14,8 +14,6 @@ import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, } from '@/components/ui/alert-dialog'; -import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog'; -import PaymentModal from '@/components/tracker/PaymentModal'; import { paymentDateForTrackerMonth, paymentSummary, ROW_STATUS_CLS } from '@/lib/trackerUtils'; import { useQuickPay, useTogglePaid } from '@/hooks/usePaymentActions'; import { DEFAULT_TRACKER_TABLE_COLUMNS } from '@/lib/trackerTableColumns'; @@ -24,7 +22,7 @@ import { PaymentProgress } from '@/components/tracker/PaymentProgress'; import { PaymentLedgerDialog } from '@/components/tracker/PaymentLedgerDialog'; import { NotesCell } from '@/components/tracker/NotesCell'; import { AutopaySuggestionActions } from '@/components/tracker/AutopaySuggestionActions'; -import type { Payment, TrackerRow as TrackerRowData } from '@/types'; +import type { TrackerRow as TrackerRowData } from '@/types'; import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow'; // Cast type for the HTML5 drag handlers, which conflict with framer-motion's own @@ -46,9 +44,7 @@ interface TrackerRowProps { export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveControls, dragProps, isDrifted, visibleColumns = DEFAULT_TRACKER_TABLE_COLUMNS }: TrackerRowProps) { const amountRef = useRef(null); - const [editPayment, setEditPayment] = useState(null); const [paymentLedgerOpen, setPaymentLedgerOpen] = useState(false); - const [showMbs, setShowMbs] = useState(false); const [confirmUnpay, setConfirmUnpay] = useState(false); const [suggestionLoading, setSuggestionLoading] = useState(false); const [optimisticActual, setOptimisticActual] = useState(undefined); @@ -690,15 +686,6 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC )} - {editPayment && ( - setEditPayment(null)} - onSave={refresh} - /> - )} - {paymentLedgerOpen && ( )} - {showMbs && ( - - )} - diff --git a/client/components/ui/confirm-dialog.tsx b/client/components/ui/confirm-dialog.tsx deleted file mode 100644 index bd3d778..0000000 --- a/client/components/ui/confirm-dialog.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import * as React from 'react'; -import { Loader2 } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogFooter, - DialogTitle, - DialogDescription, -} from '@/components/ui/dialog'; -import { Button } from '@/components/ui/button'; - -type ConfirmVariant = 'default' | 'destructive'; - -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, - onOpenChange, - title, - description, - confirmLabel = 'Confirm', - cancelLabel = 'Cancel', - variant = 'default', - onConfirm, - loading = false, -}: ConfirmDialogProps) { - const handleConfirm = async () => { - await onConfirm?.(); - }; - - const handleCancel = () => { - if (!loading) onOpenChange(false); - }; - - return ( - - - - {title} - {description && ( - {description} - )} - - - - - - - - - - ); -} - -/* ── useConfirm hook ──────────────────────────────────────────── - Imperative API for ConfirmDialog. - - Usage: - const { confirm, ConfirmDialogComponent } = useConfirm(); - const ok = await confirm({ title, description, confirmLabel, variant }); - // 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({ - open: false, - title: '', - description: '', - confirmLabel: 'Confirm', - cancelLabel: 'Cancel', - variant: 'default', - loading: false, - }); - - // Keep a stable ref to the resolve callback so it survives re-renders - const resolveRef = React.useRef<((value: boolean) => void) | null>(null); - - const confirm = React.useCallback( - ({ - title = 'Are you sure?', - description = '', - confirmLabel = 'Confirm', - cancelLabel = 'Cancel', - variant = 'default', - }: ConfirmOptions = {}): Promise => { - return new Promise((resolve) => { - resolveRef.current = resolve; - setState({ - open: true, - title, - description, - confirmLabel, - cancelLabel, - variant, - loading: false, - }); - }); - }, - [] - ); - - const handleConfirm = React.useCallback(async () => { - setState((s) => ({ ...s, loading: true })); - resolveRef.current?.(true); - resolveRef.current = null; - setState((s) => ({ ...s, open: false, loading: false })); - }, []); - - const handleOpenChange = React.useCallback((open: boolean) => { - if (!open) { - resolveRef.current?.(false); - resolveRef.current = null; - setState((s) => ({ ...s, open: false })); - } - }, []); - - const ConfirmDialogComponent = React.useCallback( - () => ( - - ), - [state, handleConfirm, handleOpenChange] - ); - - return { confirm, ConfirmDialogComponent }; -} diff --git a/client/components/ui/separator.tsx b/client/components/ui/separator.tsx deleted file mode 100644 index a2d8bf2..0000000 --- a/client/components/ui/separator.tsx +++ /dev/null @@ -1,21 +0,0 @@ -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 }: ComponentProps) { - return ( - - ); -} - -export { Separator }; diff --git a/package-lock.json b/package-lock.json index 359f1e4..e9e9f66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,6 @@ "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-select": "^2.1.2", - "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1", @@ -4512,52 +4511,6 @@ } } }, - "node_modules/@radix-ui/react-separator": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", - "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.4" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", - "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.4" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-slot": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", diff --git a/package.json b/package.json index cc35e80..40f8db0 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,6 @@ "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-select": "^2.1.2", - "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1", diff --git a/roadmap.md b/roadmap.md index f9b2ee5..9218b28 100644 --- a/roadmap.md +++ b/roadmap.md @@ -107,20 +107,19 @@ The `payments` table has a `method` column (free-text) but no way to see "how mu --- -### πŸ”΅ No Keyboard Navigation or Shortcuts β€” LOW -**Status:** Partially implemented +### πŸ”΅ No Keyboard Navigation or Shortcuts β€” DONE +**Status:** Implemented **Description:** -Only a skip link exists for keyboard accessibility. No `Cmd+K` to find a bill, no `Esc` to close modals, no arrow keys to navigate the tracker grid. +A skip link, command palette, and tracker-row keyboard navigation all exist. **Implementation Status:** - βœ… `Esc` closes any open modal/dialog (via Radix Dialog default) -- βœ… `Cmd+K` / `Ctrl+K` opens command palette (`CommandPalette.jsx`) -- ❌ Arrow keys navigate tracker rows when grid is focused +- βœ… `Cmd+K` / `Ctrl+K` opens command palette (`CommandPalette.tsx`) +- βœ… Arrow keys / `j`/`k` navigate tracker rows, `Enter` edits, `p` toggles paid (`TrackerRow.tsx` `handleRowKeyDown`, focusable `[data-tracker-row]`) **Remaining Work:** -- Implement arrow key navigation for tracker rows -- Estimated effort: 1-2 hours +- None. A `?`-triggered shortcut legend for discoverability is a possible nice-to-have. ---