B1: React Query deeper adoption (P1–P3) #86
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue