From 4a76eb9b92209b41a73e2506a00acbc0064885fb Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 18:47:32 -0500 Subject: [PATCH] feat(tracker): optimistic pay/skip + toast.promise on syncs (M1/M2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1: marking a bill paid/unpaid flips the row instantly via local optimistic state (cleared when fresh data arrives, rolled back on error) on both desktop and mobile rows, instead of waiting for the server round-trip. M2: bank sync (Tracker) and the bill-modal Sync use sonner toast.promise — one toast transitioning loading -> done -> error, replacing the manual spinner-flag + separate success/error toasts. Co-Authored-By: Claude Opus 4.8 --- HISTORY.md | 4 ++ client/components/BillModal.jsx | 21 +++++--- .../components/tracker/MobileTrackerRow.jsx | 29 ++++++++--- client/components/tracker/TrackerRow.jsx | 32 +++++++++--- client/pages/TrackerPage.jsx | 49 ++++++++++--------- 5 files changed, 88 insertions(+), 47 deletions(-) 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); }