feat(tracker): optimistic pay/skip + toast.promise on syncs (M1/M2)

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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 18:47:32 -05:00
parent 55b515c401
commit 4a76eb9b92
5 changed files with 88 additions and 47 deletions

View File

@ -9,6 +9,10 @@
- **[Tracker] Killed the getTracker N+1 (was ~23 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 70450 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)

View File

@ -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);
}

View File

@ -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');
}
}

View File

@ -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);

View File

@ -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);
}