diff --git a/HISTORY.md b/HISTORY.md index dc9ebab..fbae9ba 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -9,6 +9,10 @@ - **[Tracker] Killed the getTracker N+1 (was ~2–3 DB round-trips × N bills every home-page load)** — inside `bills.map`, `getTracker` ran a payments query per bill (`fetchPaymentsForBillCycle`) plus `computeAmountSuggestion` per bill, and the suggestion alone fired up to 12 queries per bill (6 months × 2) — roughly 70–450 queries for a 35-bill account on every Tracker load. Now one query fetches all bills' cycle payments (grouped in JS by each bill's own range) and two queries compute all amount suggestions (`computeAmountSuggestionsBatch`), replacing the per-bill loops. Behavior-preserving — `tests/amountSuggestionService.test.js` pins the batched suggestion to be byte-identical to the per-bill function, and the `trackerService` tests still pass unchanged. (Tracker P1) +### 🚀 Tracker modern UX + +- **[Tracker] Optimistic pay/skip + `toast.promise` on long syncs** — marking a bill paid/unpaid now flips the row **instantly** (optimistic local state) and rolls back on error, instead of waiting for the server round-trip before updating — on both the desktop and mobile rows. And the bank sync (Tracker) and the bill-modal "Sync" now use sonner's `toast.promise` — one toast that transitions loading → done → error, replacing the manual spinner-flag + separate success/error toasts. (Tracker M1/M2) + ### ✨ Tracker features & toast quality - **[Tracker] "Pay all due" per bucket + reversible quick-pay with specific toasts** — each bucket header now has a **Pay all due (N)** action that records a payment for every unpaid, occurrence-gated bill in that bucket in one `bulkPay` call, behind a confirm dialog (showing the count + total) and a single **Undo** toast that deletes the just-created payments. Quick-pay (the inline "Add" on a row) and mark-paid now show a **specific** toast ("Rent — $1,200 paid") and quick-pay gained an **Undo** action to match un-pay — it was the only reversible action without one. (Per-bill snooze on overdue rows already exists in the Overdue Command Center.) (Tracker T4) diff --git a/client/components/BillModal.jsx b/client/components/BillModal.jsx index d3377a7..831e873 100644 --- a/client/components/BillModal.jsx +++ b/client/components/BillModal.jsx @@ -1189,17 +1189,22 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa title="Scan unmatched bank transactions and import any matching payments for this bill" onClick={async () => { setSyncingPayments(true); + // One toast that transitions loading → done. + const promise = api.syncBillSimplefinPayments(sourceBill.id); + toast.promise(promise, { + loading: 'Scanning bank history…', + success: (result) => result.added > 0 + ? `${result.added} payment${result.added !== 1 ? 's' : ''} imported from bank history.` + : 'No new matching transactions found.', + error: (err) => err.message || 'Sync failed.', + }); try { - const result = await api.syncBillSimplefinPayments(sourceBill.id); + const result = await promise; if (result.added > 0) { - toast.success(`${result.added} payment${result.added !== 1 ? 's' : ''} imported from bank history.`); // Imported payments must refresh the payment list AND the - // Tracker behind the modal (the row may now be covered) — - // same as the unmatch handlers. + // Tracker behind the modal (the row may now be covered). await Promise.all([loadPayments(), loadLinkedTransactions()]); onSave?.(); - } else { - toast.info('No new matching transactions found.'); } // Surface late-attribution prompts to the tracker page if (result.late_attributions?.length) { @@ -1207,8 +1212,8 @@ export default function BillModal({ bill, initialBill, categories, onClose, onSa detail: { attributions: result.late_attributions }, })); } - } catch (err) { - toast.error(err.message || 'Sync failed.'); + } catch { + // toast.promise already surfaced the error } finally { setSyncingPayments(false); } diff --git a/client/components/tracker/MobileTrackerRow.jsx b/client/components/tracker/MobileTrackerRow.jsx index 0ce8061..02bbeae 100644 --- a/client/components/tracker/MobileTrackerRow.jsx +++ b/client/components/tracker/MobileTrackerRow.jsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { motion } from 'framer-motion'; import { ArrowDown, ArrowUp, GripVertical, Pencil, TrendingUp } from 'lucide-react'; import { toast } from 'sonner'; @@ -46,18 +46,30 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, const [confirmUnpay, setConfirmUnpay] = useState(false); const [suggestionLoading, setSuggestionLoading] = useState(false); const [quickPaySaving, setQuickPaySaving] = useState(false); + // Optimistic paid/unpaid flip — instant on tap, rolled back on error, cleared + // when fresh server data arrives (effect below). + const [optimisticPaid, setOptimisticPaid] = useState(undefined); const threshold = row.actual_amount != null ? row.actual_amount : row.expected_amount; const defaultPaymentDate = paymentDateForTrackerMonth(year, month, row.due_day); const isSkipped = !!row.is_skipped; const hasAutopaySuggestion = !!row.autopay_suggestion && !isSkipped; const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold; - const isPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold; + const serverIsPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold; + const isPaid = optimisticPaid !== undefined ? optimisticPaid : serverIsPaid; const effectiveStatus = isSkipped ? 'skipped' - : (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft') + : optimisticPaid === true ? 'paid' - : row.status; + : optimisticPaid === false + ? row.status + : (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft') + ? 'paid' + : row.status; + + useEffect(() => { + setOptimisticPaid(undefined); + }, [row.status, row.total_paid, row.actual_amount]); const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || ''); const remaining = Math.max((threshold || 0) - (row.total_paid || 0), 0); const summary = paymentSummary(row, threshold); @@ -92,13 +104,15 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, } async function performTogglePaid() { + const wasPaid = isPaid; + setOptimisticPaid(!wasPaid); // instant flip; effect/rollback reconciles try { const result = await api.togglePaid(row.id, { - amount: isPaid ? undefined : threshold, + amount: wasPaid ? undefined : threshold, year: year, month: month, }); - if (isPaid && result.paymentId) { + if (wasPaid && result.paymentId) { toast.success('Payment moved to recovery', { action: { label: 'Undo', @@ -113,11 +127,12 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill, }, }, }); - } else { + } else if (!wasPaid) { toast.success(`${row.name} — ${fmt(threshold)} paid`); } refresh(); } catch (err) { + setOptimisticPaid(undefined); // roll back the instant flip toast.error(err.message || 'Failed to toggle payment status'); } } diff --git a/client/components/tracker/TrackerRow.jsx b/client/components/tracker/TrackerRow.jsx index 1127604..47eef48 100644 --- a/client/components/tracker/TrackerRow.jsx +++ b/client/components/tracker/TrackerRow.jsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useTransition } from 'react'; +import React, { useState, useRef, useTransition, useEffect } from 'react'; import { ArrowDown, ArrowUp, CheckCircle2, Clock, GripVertical, Pencil, TrendingUp, X } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { toast } from 'sonner'; @@ -32,6 +32,9 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC const [loading, setLoading] = useState(false); const [suggestionLoading, setSuggestionLoading] = useState(false); const [optimisticActual, setOptimisticActual] = useState(undefined); + // Optimistic paid/unpaid flip: the row updates instantly on pay/skip and rolls + // back on error. Cleared when real data arrives (see effect below). + const [optimisticPaid, setOptimisticPaid] = useState(undefined); const [showUpdateNudge, setShowUpdateNudge] = useState(false); const [nudgeAmount, setNudgeAmount] = useState(null); const [, startTransition] = useTransition(); @@ -53,16 +56,26 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC // Paid when total payments >= effective threshold const isPaidByThreshold = row.total_paid > 0 && row.total_paid >= threshold; - const isPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold; + const serverIsPaid = row.status === 'paid' || (row.status === 'autodraft' && !hasAutopaySuggestion) || isPaidByThreshold; + const isPaid = optimisticPaid !== undefined ? optimisticPaid : serverIsPaid; const summary = paymentSummary(row, threshold); // Effective status to show: - // skipped > paid (threshold-based) > backend status + // skipped > optimistic flip > paid (threshold-based) > backend status const effectiveStatus = isSkipped ? 'skipped' - : (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft') + : optimisticPaid === true ? 'paid' - : row.status; + : optimisticPaid === false + ? row.status + : (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft') + ? 'paid' + : row.status; + + // Drop the optimistic override once fresh server data for this row arrives. + useEffect(() => { + setOptimisticPaid(undefined); + }, [row.status, row.total_paid, row.actual_amount]); const rowBg = isSkipped ? '' : (ROW_STATUS_CLS[effectiveStatus] || ''); @@ -94,14 +107,16 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC } async function performTogglePaid() { + const wasPaid = isPaid; + setOptimisticPaid(!wasPaid); // instant flip; effect/rollback reconciles setLoading?.(true); try { const result = await api.togglePaid(row.id, { - amount: isPaid ? undefined : threshold, + amount: wasPaid ? undefined : threshold, year: year, month: month, }); - if (isPaid && result.paymentId) { + if (wasPaid && result.paymentId) { toast.success('Payment moved to recovery', { action: { label: 'Undo', @@ -116,11 +131,12 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC }, }, }); - } else { + } else if (!wasPaid) { toast.success(`${row.name} — ${fmt(threshold)} paid`); } refresh?.(); } catch (err) { + setOptimisticPaid(undefined); // roll back the instant flip toast.error(err.message || 'Failed to toggle payment status'); } finally { setLoading?.(false); diff --git a/client/pages/TrackerPage.jsx b/client/pages/TrackerPage.jsx index 835987a..5cf23fc 100644 --- a/client/pages/TrackerPage.jsx +++ b/client/pages/TrackerPage.jsx @@ -307,35 +307,36 @@ export default function TrackerPage() { updateParams({ year: ny, month: nm }); } + function bankSyncMessage(result) { + const matched = result.auto_matched ?? 0; + const newTx = result.transactions_new ?? 0; + const billNames = result.matched_bills ?? []; + if (matched > 0 && billNames.length > 0) { + return `Synced — ${billNames.join(', ')} ✓` + + (matched > billNames.length ? ` (+${matched - billNames.length} more)` : ''); + } + if (matched > 0) return `Synced — ${matched} payment${matched === 1 ? '' : 's'} matched`; + if (newTx > 0) return `Synced — ${newTx} new transaction${newTx === 1 ? '' : 's'}, no automatic matches`; + return 'Synced — no new transactions'; + } + async function handleBankSync() { setBankSyncing(true); + // One toast that transitions loading → done, instead of a manual spinner + + // a separate success/error toast. + const promise = api.syncAllSources(); + toast.promise(promise, { + loading: 'Syncing bank…', + success: bankSyncMessage, + error: (err) => err.message || 'Bank sync failed', + }); try { - const result = await api.syncAllSources(); - const matched = result.auto_matched ?? 0; - const newTx = result.transactions_new ?? 0; - const billNames = result.matched_bills ?? []; - const attributions = result.late_attributions ?? []; - - if (matched > 0 && billNames.length > 0) { - toast.success( - `Synced — ${billNames.join(', ')} ✓` + - (matched > billNames.length ? ` (+${matched - billNames.length} more)` : ''), - { duration: 5000 } - ); - } else if (matched > 0) { - toast.success(`Synced — ${matched} payment${matched === 1 ? '' : 's'} matched`); - } else if (newTx > 0) { - toast.success(`Synced — ${newTx} new transaction${newTx === 1 ? '' : 's'}, no automatic matches`); - } else { - toast.success('Synced — no new transactions'); - } - + const result = await promise; // Surface late-attribution prompts (payments that just crossed a month boundary) - if (attributions.length > 0) setLateAttributions(attributions); - + if ((result.late_attributions ?? []).length > 0) setLateAttributions(result.late_attributions); invalidateData(); - } catch (err) { - toast.error(err.message || 'Bank sync failed'); + } catch { + // toast.promise already surfaced the error } finally { setBankSyncing(false); }