refactor(tracker): consolidate isPaidStatus + rowOutstanding + toast gap (T5)

Added a single isPaidStatus(status) (+ PAID_STATUSES) to statusService and a
matching client helper in trackerUtils, routing the unambiguous settled-status
checks through it (trackerService, StatusBadge, CalendarPage, rowIsPaid). The
intentionally paid-only counts stay distinct. Replaced two inline
Math.max(r.balance||0,0) with rowOutstanding, and gave the Tracker settings
load a quiet toast instead of a silent swallow. Behavior-preserving.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 18:36:30 -05:00
parent d92cc38116
commit 995f635d35
7 changed files with 34 additions and 12 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 cleanup
- **[Tracker] Consolidated the "paid or autodraft = done" check + tidied a few spots** — the settled-status test (`status === 'paid' || status === 'autodraft'`) was copy-pasted across the server and client; added a single `isPaidStatus(status)` (+ `PAID_STATUSES`) to `services/statusService.js` and a matching `isPaidStatus` to `client/lib/trackerUtils.js`, and routed the unambiguous call sites through it (`trackerService`, `StatusBadge`, `CalendarPage`, and `rowIsPaid`) — the intentionally paid-*only* counts (`count_paid`, `count_autodraft`) are left distinct. Also replaced two inline `Math.max(r.balance || 0, 0)` sums in `getTracker` with the existing `rowOutstanding` helper, and gave the Tracker's display-settings load a quiet toast on failure instead of a silent swallow. Behavior-preserving; full server + client suites green. (Tracker T5)
### 🐛 Tracker & bill-modal hardening
- **[Bill modal] Correctness + error/toast + validator cleanup** — several small fixes in `BillModal`: `handleBlur` used positional guessing that defaulted every unmapped field to `interestRate`'s value (latent, masked by inline validators) — now takes the field value explicitly; the three copy-pasted money validators collapsed into one shared `validateNonNegativeMoney(val, label)` in `client/lib/money.js` (the expected-amount message also went from "positive number" to "non-negative", since 0 is allowed); the save action's duplicate due-day/interest-rate re-validation (which re-checked with toasts what `validateForm` already field-validated) was removed; the save/deactivate/verify-autopay `toast.error(err.message)` calls got fallbacks so a missing message never shows "undefined"; and the save toasts now name the bill ("Rent added" / "Rent updated"). Tests: `client/lib/money.test.js` covers the shared validator. (Tracker BM1)

View File

@ -1,7 +1,7 @@
import React, { useMemo } from 'react';
import { Loader2, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { STATUS_META } from '@/lib/trackerUtils';
import { STATUS_META, isPaidStatus } from '@/lib/trackerUtils';
export const StatusBadge = React.memo(function StatusBadge({ status, clickable, onClick, loading }) {
const meta = useMemo(() => STATUS_META[status] || STATUS_META.upcoming, [status]);
@ -26,7 +26,7 @@ export const StatusBadge = React.memo(function StatusBadge({ status, clickable,
loading && 'opacity-60 cursor-wait',
meta.cls,
)}
title={canClick ? (status === 'paid' || status === 'autodraft' ? 'Click to mark unpaid' : 'Click to mark paid') : undefined}
title={canClick ? (isPaidStatus(status) ? 'Click to mark unpaid' : 'Click to mark paid') : undefined}
>
{loading ? (
<>

View File

@ -86,10 +86,17 @@ export function rowEffectiveStatus(row) {
return (isPaidByThreshold && row.status !== 'paid' && row.status !== 'autodraft') ? 'paid' : row.status;
}
// A bill's month is "settled" when its status is paid or autodraft. Mirrors the
// server's statusService.isPaidStatus — the single low-level check the various
// row/badge/calendar components share.
export function isPaidStatus(status) {
return status === 'paid' || status === 'autodraft';
}
export function rowIsPaid(row) {
const status = rowEffectiveStatus(row);
if (row.autopay_suggestion && status === 'autodraft') return false;
return status === 'paid' || status === 'autodraft';
return isPaidStatus(status);
}
export function rowIsDebt(row) {

View File

@ -16,6 +16,7 @@ import {
import { toast } from 'sonner';
import { api } from '@/api';
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
import { isPaidStatus } from '@/lib/trackerUtils';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
@ -45,7 +46,7 @@ function displayStatus(status) {
}
function statusTone(status) {
if (status === 'paid' || status === 'autodraft') return 'border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300';
if (isPaidStatus(status)) return 'border-emerald-500/30 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300';
if (status === 'skipped') return 'border-border bg-muted/80 text-muted-foreground';
if (status === 'late') return 'border-orange-400/60 bg-orange-500/25 text-orange-800 shadow-sm shadow-orange-950/10 dark:text-orange-100';
if (status === 'missed') return 'border-rose-400/60 bg-rose-500/30 text-rose-800 shadow-sm shadow-rose-950/10 dark:text-rose-100';

View File

@ -284,7 +284,7 @@ export default function TrackerPage() {
useEffect(() => {
api.settings()
.then(settings => setTrackerSettings(prev => ({ ...prev, ...settings })))
.catch(() => {});
.catch(() => toast.error("Couldn't load tracker display settings — showing defaults."));
}, []);
// Listen for late-attribution events fired by BillModal's single-bill sync

View File

@ -1,5 +1,13 @@
const { getSetting } = require('../db/database');
// A bill's month is "settled" when its status is paid or autodraft (assumed paid
// via autopay). Single source of truth so the ~scattered inline
// `status === 'paid' || status === 'autodraft'` checks don't drift.
const PAID_STATUSES = Object.freeze(['paid', 'autodraft']);
function isPaidStatus(status) {
return status === 'paid' || status === 'autodraft';
}
const WEEKDAY_INDEX = {
sunday: 0,
monday: 1,
@ -226,7 +234,7 @@ function buildTrackerRow(bill, payments, year, month, todayStr, options = {}) {
const expectedAmount = Number(bill.expected_amount) || 0;
const totalPaid = safePayments.reduce((sum, p) => sum + (Number(p.amount) || 0), 0);
const hasPayment = safePayments.length > 0;
const isSettled = status === 'paid' || status === 'autodraft';
const isSettled = isPaidStatus(status);
const paidTowardDue = Math.min(totalPaid, expectedAmount);
const overpaidAmount = Math.max(totalPaid - expectedAmount, 0);
const rawBalance = expectedAmount - totalPaid;
@ -282,7 +290,9 @@ module.exports = {
calculateStatus,
getCalendarMonthRange,
getCycleRange,
isPaidStatus,
normalizeCycleType,
PAID_STATUSES,
resolveBucket,
resolveDueDate,
resolveGracePeriodDays,

View File

@ -1,7 +1,7 @@
'use strict';
const { getDb } = require('../db/database');
const { buildTrackerRow, getCycleRange, resolveDueDate } = require('./statusService');
const { buildTrackerRow, getCycleRange, resolveDueDate, isPaidStatus } = require('./statusService');
const { getUserSettings } = require('./userSettings');
const { computeBalanceDelta, applyBalanceDelta } = require('./billsService');
const { computeAmountSuggestionsBatch } = require('./amountSuggestionService');
@ -369,7 +369,7 @@ function buildSafeToSpend({ activeRows, available, todayStr, year, month, dayOfM
const daysUntilPayday = Math.max(0, Math.round((Date.parse(nextPayday) - Date.parse(todayStr)) / 86400000));
const stillDueRows = activeRows
.filter(r => !['paid', 'autodraft'].includes(r.status))
.filter(r => !isPaidStatus(r.status))
.filter(r => rowOutstanding(r) > 0)
.filter(r => r.due_date < nextPayday)
.sort((a, b) => a.due_date.localeCompare(b.due_date) || String(a.name).localeCompare(String(b.name)));
@ -620,7 +620,7 @@ function getTracker(userId, query = {}, now = new Date()) {
const activeRemainingPeriod = dayOfMonth < 15 ? '1st' : '15th';
const periodRows = activeRows.filter(r => r.bucket === activeRemainingPeriod);
const periodPaidTowardDue = sumMoney(periodRows, rowPaidTowardDue);
const periodOutstandingBalance = sumMoney(periodRows, r => Math.max(r.balance || 0, 0));
const periodOutstandingBalance = sumMoney(periodRows, rowOutstanding);
const periodStartingAmount = activeRemainingPeriod === '1st'
? (startingAmounts?.first_amount || 0)
: (startingAmounts?.fifteenth_amount || 0);
@ -649,10 +649,10 @@ function getTracker(userId, query = {}, now = new Date()) {
const activeTotalPaid = sumMoney(activeRows, r => r.total_paid);
const activePaidTowardDue = sumMoney(activeRows, rowPaidTowardDue);
const activeTotalExpected = sumMoney(activeRows, rowDueAmount);
const activeOutstandingBalance = sumMoney(activeRows, r => Math.max(r.balance || 0, 0));
const activeOutstandingBalance = sumMoney(activeRows, rowOutstanding);
const periodBillsTotal = sumMoney(periodRows, rowDueAmount);
const periodPaidCount = periodRows.filter(r => ['paid', 'autodraft'].includes(r.status)).length;
const periodPaidCount = periodRows.filter(r => isPaidStatus(r.status)).length;
const periodTotalCount = periodRows.length;
// When bank tracking is on use the effective balance as the period starting point
@ -662,7 +662,7 @@ function getTracker(userId, query = {}, now = new Date()) {
const periodProjected = roundMoney(periodCashStart - periodBillsTotal);
const monthBillsTotal = activeTotalExpected;
const monthPaidCount = activeRows.filter(r => ['paid', 'autodraft'].includes(r.status)).length;
const monthPaidCount = activeRows.filter(r => isPaidStatus(r.status)).length;
const monthTotalCount = activeRows.length;
const monthProjected = roundMoney(totalStarting - monthBillsTotal);