From 995f635d35ae4bfefe9fae91d5828f7e190fb904 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 3 Jul 2026 18:36:30 -0500 Subject: [PATCH] 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 --- HISTORY.md | 4 ++++ client/components/tracker/StatusBadge.jsx | 4 ++-- client/lib/trackerUtils.js | 9 ++++++++- client/pages/CalendarPage.jsx | 3 ++- client/pages/TrackerPage.jsx | 2 +- services/statusService.js | 12 +++++++++++- services/trackerService.js | 12 ++++++------ 7 files changed, 34 insertions(+), 12 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 8f8d213..2dfa624 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 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) diff --git a/client/components/tracker/StatusBadge.jsx b/client/components/tracker/StatusBadge.jsx index 3a2c560..b50aa66 100644 --- a/client/components/tracker/StatusBadge.jsx +++ b/client/components/tracker/StatusBadge.jsx @@ -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 ? ( <> diff --git a/client/lib/trackerUtils.js b/client/lib/trackerUtils.js index 01c43d2..a76cf48 100644 --- a/client/lib/trackerUtils.js +++ b/client/lib/trackerUtils.js @@ -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) { diff --git a/client/pages/CalendarPage.jsx b/client/pages/CalendarPage.jsx index df91651..35a9c37 100644 --- a/client/pages/CalendarPage.jsx +++ b/client/pages/CalendarPage.jsx @@ -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'; diff --git a/client/pages/TrackerPage.jsx b/client/pages/TrackerPage.jsx index 0dd0984..835987a 100644 --- a/client/pages/TrackerPage.jsx +++ b/client/pages/TrackerPage.jsx @@ -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 diff --git a/services/statusService.js b/services/statusService.js index 95fb7d0..23a2830 100644 --- a/services/statusService.js +++ b/services/statusService.js @@ -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, diff --git a/services/trackerService.js b/services/trackerService.js index b833902..cf01939 100644 --- a/services/trackerService.js +++ b/services/trackerService.js @@ -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);