import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { BarChart2, Calendar, CreditCard, Loader2, Navigation, Plus, Receipt, Search, Settings, Snowflake, Tag, Upload, User, X, } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api'; import { cn, fmt } from '@/lib/utils'; import { scheduleLabel, scheduleValue } from '@/lib/billingSchedule'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; // ─── Navigation commands ────────────────────────────────────────────────────── const MONTHS = [ 'January','February','March','April','May','June', 'July','August','September','October','November','December', ]; const NAV_COMMANDS = [ { id: 'nav-tracker', label: 'Go to Tracker', icon: Receipt, path: '/', group: 'Navigate' }, { id: 'nav-bills', label: 'Go to Bills', icon: CreditCard, path: '/bills', group: 'Navigate' }, { id: 'nav-calendar', label: 'Go to Calendar', icon: Calendar, path: '/calendar', group: 'Navigate' }, { id: 'nav-summary', label: 'Go to Summary', icon: BarChart2, path: '/summary', group: 'Navigate' }, { id: 'nav-analytics', label: 'Go to Analytics', icon: BarChart2, path: '/analytics', group: 'Navigate' }, { id: 'nav-snowball', label: 'Go to Snowball', icon: Snowflake, path: '/snowball', group: 'Navigate' }, { id: 'nav-categories', label: 'Go to Categories', icon: Tag, path: '/categories', group: 'Navigate' }, { id: 'nav-data', label: 'Go to Data', icon: Upload, path: '/data', group: 'Navigate' }, { id: 'nav-settings', label: 'Go to Settings', icon: Settings, path: '/settings', group: 'Navigate' }, { id: 'nav-profile', label: 'Go to Profile', icon: User, path: '/profile', group: 'Navigate' }, { id: 'action-new-bill', label: 'Add a new bill', icon: Plus, path: '/bills?new=1', group: 'Actions' }, ]; // Generate jump-to-month commands for the current year ± 1 function buildMonthCommands() { const now = new Date(); const year = now.getFullYear(); const commands = []; for (let y = year - 1; y <= year + 1; y++) { for (let m = 1; m <= 12; m++) { commands.push({ id: `jump-${y}-${m}`, label: `Jump to ${MONTHS[m - 1]} ${y}`, icon: Calendar, path: `/?year=${y}&month=${m}`, group: 'Jump to Month', }); } } return commands; } // ─── Helpers ────────────────────────────────────────────────────────────────── function amountSearchText(...values) { return values .filter(value => value !== null && value !== undefined && Number.isFinite(Number(value))) .flatMap(value => { const num = Number(value); return [String(num), num.toFixed(2), `$${num.toFixed(2)}`]; }) .join(' '); } function billSearchText(bill) { return [ bill.name, bill.category_name, bill.notes, scheduleValue(bill), scheduleLabel(bill), bill.bucket, bill.website, amountSearchText( bill.expected_amount, bill.current_balance, bill.minimum_payment, bill.interest_rate, ), ].filter(Boolean).join(' ').toLowerCase(); } function shortcutLabel() { if (typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)) { return 'Cmd K'; } return 'Ctrl K'; } // ─── Result item components ─────────────────────────────────────────────────── function BillResult({ bill, onOpenBills, onOpenTracker }) { return (
); } function CommandResult({ cmd, onRun }) { const Icon = cmd.icon || Navigation; return ( ); } // ─── Main component ─────────────────────────────────────────────────────────── export default function CommandPalette() { const navigate = useNavigate(); const inputRef = useRef(null); const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); const [bills, setBills] = useState([]); const [loading, setLoading] = useState(false); const [loaded, setLoaded] = useState(false); const monthCommands = useMemo(() => buildMonthCommands(), []); useEffect(() => { const openPalette = () => setOpen(true); const onKeyDown = (event) => { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); setOpen(value => !value); } }; window.addEventListener('command-palette:open', openPalette); window.addEventListener('keydown', onKeyDown); return () => { window.removeEventListener('command-palette:open', openPalette); window.removeEventListener('keydown', onKeyDown); }; }, []); useEffect(() => { if (!open) return; window.setTimeout(() => inputRef.current?.focus(), 0); if (loaded || loading) return; setLoading(true); api.allBills({ inactive: true }) .then(rows => { setBills(Array.isArray(rows) ? rows : []); setLoaded(true); }) .catch(err => { toast.error(err.message || 'Failed to load bills'); }) .finally(() => setLoading(false)); }, [loaded, loading, open]); const close = () => { setOpen(false); setQuery(''); }; const openBills = (bill) => { const params = new URLSearchParams({ search: bill.name }); if (!bill.active) params.set('inactive', '1'); navigate(`/bills?${params.toString()}`); close(); }; const openTracker = (bill) => { const params = new URLSearchParams({ search: bill.name }); navigate(`/?${params.toString()}`); close(); }; const runCommand = (cmd) => { navigate(cmd.path); close(); }; const allCommands = useMemo(() => [...NAV_COMMANDS, ...monthCommands], [monthCommands]); const { billResults, commandResults } = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) { const sortedBills = [...bills] .sort((a, b) => { if (!!a.active !== !!b.active) return a.active ? -1 : 1; return String(a.name || '').localeCompare(String(b.name || '')); }) .slice(0, 6); return { billResults: sortedBills, commandResults: NAV_COMMANDS.slice(0, 4), }; } const matchedBills = [...bills] .filter(bill => billSearchText(bill).includes(q)) .sort((a, b) => { if (!!a.active !== !!b.active) return a.active ? -1 : 1; return String(a.name || '').localeCompare(String(b.name || '')); }) .slice(0, 8); const matchedCommands = allCommands .filter(cmd => cmd.label.toLowerCase().includes(q) || cmd.group.toLowerCase().includes(q)) .slice(0, 5); return { billResults: matchedBills, commandResults: matchedCommands }; }, [bills, query, allCommands]); const hasResults = billResults.length > 0 || commandResults.length > 0; return ( Command palette
setQuery(event.target.value)} onKeyDown={event => { if (event.key === 'Enter') { if (billResults[0]) openBills(billResults[0]); else if (commandResults[0]) runCommand(commandResults[0]); } }} placeholder="Search bills or type a command…" className="h-9 border-0 bg-transparent px-0 text-base shadow-none focus-visible:ring-0" /> {query && ( )}
{loading ? (
Loading…
) : !hasResults ? (
No results.
) : ( <> {commandResults.length > 0 && (

Commands

{commandResults.map(cmd => ( ))}
)} {billResults.length > 0 && (
{commandResults.length > 0 && (

Bills

)}
{billResults.map(bill => ( ))}
)} )}
Enter to open · Tab to focus {shortcutLabel()}
); }