B1: React Query deeper adoption (P1–P3) #86

Closed
null wants to merge 340 commits from branch/b1-react-query into main
5 changed files with 88 additions and 47 deletions
Showing only changes of commit 4a76eb9b92 - Show all commits

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