diff --git a/client/components/BillMerchantRules.jsx b/client/components/BillMerchantRules.jsx index 112ec8a..b2e29c4 100644 --- a/client/components/BillMerchantRules.jsx +++ b/client/components/BillMerchantRules.jsx @@ -115,6 +115,8 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged }) const [retroFeedback, setRetroFeedback] = useState(null); const [showHistoricalDialog, setShowHistoricalDialog] = useState(false); const [togglingAutoAttr, setTogglingAutoAttr] = useState(null); + const [liveResults, setLiveResults] = useState([]); + const [liveSearching, setLiveSearching] = useState(false); const inputRef = useRef(null); const debouncedInput = useDebounce(input.trim(), 380); @@ -158,6 +160,31 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged }) return () => { cancelled = true; }; }, [debouncedInput, billId]); + // Live transaction search when user types something + useEffect(() => { + if (!debouncedInput || debouncedInput.length < 2) { + setLiveResults([]); + return; + } + let cancelled = false; + setLiveSearching(true); + api.subscriptionTransactionMatches({ q: debouncedInput, limit: 20 }) + .then(data => { + if (cancelled) return; + const rows = Array.isArray(data) ? data : (data?.transactions ?? []); + setLiveResults(rows.filter(tx => tx.match_status !== 'matched').map(tx => ({ + id: tx.id, + label: tx.payee || tx.description || tx.memo || '', + normalized: tx.merchant || (tx.payee || tx.description || tx.memo || '').toLowerCase(), + amount: tx.amount, + date: tx.posted_date || '', + })).filter(s => s.label)); + }) + .catch(() => { if (!cancelled) setLiveResults([]); }) + .finally(() => { if (!cancelled) setLiveSearching(false); }); + return () => { cancelled = true; }; + }, [debouncedInput]); + // Popover handles its own outside-click dismissal — no manual handler needed async function handleAdd(merchantText) { @@ -221,11 +248,10 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged }) inputRef.current?.focus(); } - // Filter suggestions: not already a rule, and not already matched to something - const filteredSuggestions = suggestions.filter(s => - !rules.some(r => r.merchant === s.normalized) && - (input.length < 2 || s.label.toLowerCase().includes(input.toLowerCase())) - ).slice(0, 8); + // When typing: use live search results. When blank: use pre-loaded recent 30. + const filteredSuggestions = (input.trim().length >= 2 ? liveResults : suggestions.filter(s => + !rules.some(r => r.merchant === s.normalized) + )).slice(0, 10); if (loading) { return ( @@ -299,10 +325,11 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged }) {/* Suggestions — inline block, no absolute positioning. Avoids overflow-y-auto clipping AND Radix Dialog pointer-event capture. */} - {showSuggestions && filteredSuggestions.length > 0 && ( + {showSuggestions && (liveSearching || filteredSuggestions.length > 0) && (
-

- Recent unmatched transactions +

+ {input.trim().length >= 2 ? 'Matching transactions' : 'Recent unmatched transactions'} + {liveSearching && }

{filteredSuggestions.map(s => { @@ -322,6 +349,9 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged }) ); })} + {!liveSearching && input.trim().length >= 2 && filteredSuggestions.length === 0 && ( +

No transactions found for "{input.trim()}"

+ )}
)} diff --git a/client/components/BillsTableInner.jsx b/client/components/BillsTableInner.jsx index c71a95a..fa189c1 100644 --- a/client/components/BillsTableInner.jsx +++ b/client/components/BillsTableInner.jsx @@ -119,12 +119,12 @@ function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory, S )} - {!!bill.has_merchant_rule && ( + {(!!bill.has_merchant_rule || !!bill.has_linked_transactions) && ( - Bank + L )} {hasHistory && ( diff --git a/client/components/MobileBillRow.jsx b/client/components/MobileBillRow.jsx index 623f2cb..59ba4df 100644 --- a/client/components/MobileBillRow.jsx +++ b/client/components/MobileBillRow.jsx @@ -116,6 +116,9 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o {bill.has_2fa && ( 2FA )} + {(bill.has_merchant_rule || bill.has_linked_transactions) && ( + L + )} {bill.is_subscription && ( S )} diff --git a/client/components/tracker/MobileTrackerRow.jsx b/client/components/tracker/MobileTrackerRow.jsx index 89b6b64..b4b939c 100644 --- a/client/components/tracker/MobileTrackerRow.jsx +++ b/client/components/tracker/MobileTrackerRow.jsx @@ -197,6 +197,14 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, AP )} + {(row.has_merchant_rule || row.has_linked_transactions) && ( + + L + + )} {row.is_subscription && ( )} + {(row.has_merchant_rule || row.has_linked_transactions) && ( + + L + + )} {row.is_subscription && ( + + + ); +} + // ─── SettingsPage ───────────────────────────────────────────────────────────── export default function SettingsPage() { @@ -323,6 +352,7 @@ export default function SettingsPage() { {/* Billing Behavior */} + { @@ -938,9 +941,12 @@ export default function SubscriptionsPage() { async function acceptRecommendation(recommendation) { setBusyId(`rec-${recommendation.id}`); try { - await api.createSubscriptionFromRecommendation(recommendation); + const created = await api.createSubscriptionFromRecommendation(recommendation); toast.success(`${recommendation.name} is now tracked.`); await refreshAll(); + if (getLinkImportPref() && recommendation.merchant && created?.id) { + setImportDialog({ billId: created.id, billName: created.name || recommendation.name }); + } } catch (err) { toast.error(err.message || 'Could not create subscription.'); } finally { @@ -975,6 +981,9 @@ export default function SubscriptionsPage() { toast.success(`Linked ${result.matched_count} transaction${result.matched_count !== 1 ? 's' : ''} to "${result.bill_name}".`); setMatchTarget(null); setRecommendations(prev => prev.filter(r => r.id !== recommendation.id)); + if (getLinkImportPref() && recommendation.merchant) { + setImportDialog({ billId, billName: result.bill_name || recommendation.name }); + } } catch (err) { toast.error(err.message || 'Could not link recommendation to bill.'); } finally { @@ -1474,6 +1483,14 @@ export default function SubscriptionsPage() { onConfirm={matchRecommendationToBill} busy={!!busyId?.startsWith('match-')} /> + + setImportDialog(null)} + onImported={() => { setImportDialog(null); refreshAll(); }} + /> ); } diff --git a/routes/bills.js b/routes/bills.js index 77b0978..cfa5874 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -23,13 +23,17 @@ router.get('/', (req, res) => { const db = getDb(); ensureUserDefaultCategories(req.user.id); const includeInactive = req.query.inactive === 'true'; - // LEFT JOIN on a pre-grouped subquery is one query instead of N+1 correlated EXISTS lookups. + // LEFT JOIN on pre-grouped subqueries — one query instead of N+1 correlated EXISTS lookups. const bills = db.prepare(` SELECT b.*, c.name AS category_name, - CASE WHEN hr.bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_history_ranges + CASE WHEN hr.bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_history_ranges, + CASE WHEN mr.bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_merchant_rule, + CASE WHEN lt.matched_bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_linked_transactions FROM bills b LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL LEFT JOIN (SELECT DISTINCT bill_id FROM bill_history_ranges) hr ON hr.bill_id = b.id + LEFT JOIN (SELECT DISTINCT bill_id FROM bill_merchant_rules) mr ON mr.bill_id = b.id + LEFT JOIN (SELECT DISTINCT matched_bill_id FROM transactions WHERE match_status = 'matched') lt ON lt.matched_bill_id = b.id WHERE b.user_id = ? AND b.deleted_at IS NULL ${includeInactive ? '' : 'AND b.active = 1'} @@ -333,7 +337,10 @@ router.get('/:id', (req, res) => { ) THEN 1 ELSE 0 END AS has_history_ranges, CASE WHEN EXISTS( SELECT 1 FROM bill_merchant_rules WHERE bill_id = b.id AND user_id = b.user_id - ) THEN 1 ELSE 0 END AS has_merchant_rule + ) THEN 1 ELSE 0 END AS has_merchant_rule, + CASE WHEN EXISTS( + SELECT 1 FROM transactions WHERE matched_bill_id = b.id AND match_status = 'matched' AND user_id = b.user_id + ) THEN 1 ELSE 0 END AS has_linked_transactions FROM bills b LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL WHERE b.id = ? AND b.user_id = ? AND b.deleted_at IS NULL diff --git a/services/trackerService.js b/services/trackerService.js index 048f7cd..b6e92b6 100644 --- a/services/trackerService.js +++ b/services/trackerService.js @@ -115,9 +115,13 @@ const FETCH_BILLS_ORDER = { function fetchActiveBills(db, userId, orderKey = 'due_day') { const orderBy = FETCH_BILLS_ORDER[orderKey] ?? FETCH_BILLS_ORDER.due_day; return db.prepare(` - SELECT b.*, c.name AS category_name + SELECT b.*, c.name AS category_name, + CASE WHEN mr.bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_merchant_rule, + CASE WHEN lt.matched_bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_linked_transactions FROM bills b LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL + LEFT JOIN (SELECT DISTINCT bill_id FROM bill_merchant_rules) mr ON mr.bill_id = b.id + LEFT JOIN (SELECT DISTINCT matched_bill_id FROM transactions WHERE match_status = 'matched') lt ON lt.matched_bill_id = b.id WHERE b.active = 1 AND b.user_id = ? AND b.deleted_at IS NULL ORDER BY ${orderBy} `).all(userId);