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:
parent
d92cc38116
commit
995f635d35
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue