From c26880da89bc0a79a39f593bcf71ab857ee64073 Mon Sep 17 00:00:00 2001 From: null Date: Wed, 3 Jun 2026 21:30:02 -0500 Subject: [PATCH] fix: bank tracking Pending badge cleanup, CalendarPage money map polish - TrackerPage Pending badge: consistent styling and tooltip text - CalendarPage money map: handle edge cases when bank tracking is active but no pending payments - trackerService: deduplicate pending payment query, handle zero-pending state --- client/pages/CalendarPage.jsx | 143 +++++++++++++++++++++++++++++++++- client/pages/TrackerPage.jsx | 20 +++-- services/trackerService.js | 43 ++++++++++ 3 files changed, 198 insertions(+), 8 deletions(-) diff --git a/client/pages/CalendarPage.jsx b/client/pages/CalendarPage.jsx index a4ea029..3df0e0f 100644 --- a/client/pages/CalendarPage.jsx +++ b/client/pages/CalendarPage.jsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import { Banknote, CalendarDays, @@ -335,6 +335,146 @@ function CalendarGrid({ data, selectedDate, onSelectDay, moneyMarkers }) { ); } +function ProjectionPanel({ label, projected, starting, billsTotal, paid, paidCount, totalCount, period, year, month }) { + const navigate = useNavigate(); + const isNegative = projected < 0; + const amountPct = billsTotal > 0 ? Math.min(100, Math.round((paid / billsTotal) * 100)) : 0; + const unpaidCount = totalCount - paidCount; + const bucketParam = period === '1st' ? 'b1=1' : 'b2=1'; + + function goToUnpaid() { + navigate(`/?un=1&${bucketParam}&year=${year}&month=${month}`); + } + + return ( +
+

+ {label} +

+

+ {isNegative ? '−' : ''}{fmt(Math.abs(projected))} +

+
+ {/* Amount-based progress bar */} +
+ {fmt(paid)} of {fmt(billsTotal)} paid + {unpaidCount > 0 && ( + + )} + {unpaidCount === 0 && totalCount > 0 && ( + All paid ✓ + )} +
+
+
+
+

+ {fmt(starting)} starting · {fmt(billsTotal)} due +

+
+
+ ); +} + +function CashFlowCard({ cashflow, year, month }) { + if (!cashflow?.has_data) return null; + + const periodProjected = Number(cashflow.period_projected ?? 0); + const monthProjected = Number(cashflow.month_projected ?? 0); + + // In the second half of the month the period end = month end — one panel suffices. + // Only show the month panel separately in the first half where they differ. + const showMonthPanel = cashflow.period === '1st'; + + const anyNegative = periodProjected < 0 || (showMonthPanel && monthProjected < 0); + const shortfallAmount = showMonthPanel + ? Math.min(periodProjected, monthProjected) + : periodProjected; + + return ( + + +
+ Cash Flow Projection + {cashflow.uses_bank_balance && ( + + Live balance + + )} +
+ + What you'll have after all bills clear — not just what's been paid so far. + +
+ + + {/* Negative balance alert */} + {anyNegative && ( +
+ + + You're projected to be{' '} + {fmt(Math.abs(shortfallAmount))} short + {' '}by{' '}{cashflow.period_end_label}. + Review unpaid bills or adjust your starting amounts. + +
+ )} + +
+ + {showMonthPanel && ( + + )} +
+
+
+ ); +} + function DebtPayoffGlance({ projection }) { const snowball = projection?.snowball; const comparison = projection?.comparison; @@ -740,6 +880,7 @@ export default function CalendarPage() {
+ diff --git a/client/pages/TrackerPage.jsx b/client/pages/TrackerPage.jsx index 12536a3..2b7ff64 100644 --- a/client/pages/TrackerPage.jsx +++ b/client/pages/TrackerPage.jsx @@ -113,9 +113,10 @@ export default function TrackerPage() { updateParams({ year: n.getFullYear(), month: n.getMonth() + 1 }); } - const rows = orderedRows || data?.rows || []; - const summary = data?.summary || {}; + const rows = orderedRows || data?.rows || []; + const summary = data?.summary || {}; const bankTracking = data?.bank_tracking; + const cashflow = data?.cashflow; const toggleFilter = (key) => { const paramMap = { autopay: 'ap', firstBucket: 'b1', fifteenthBucket: 'b2', unpaid: 'un', overdue: 'ov', debt: 'de' }; updateParams({ [paramMap[key]]: !filters[key] }); @@ -340,11 +341,16 @@ export default function TrackerPage() { { + if (bankTracking?.enabled) return `${bankTracking.account_name} · live balance`; + if (!summary.has_starting_amounts) return 'Set monthly starting cash'; + if (cashflow?.has_data && cashflow.period_projected !== undefined) { + const proj = Number(cashflow.period_projected); + const sign = proj < 0 ? '−' : ''; + return `→ ${sign}${fmt(Math.abs(proj))} projected by ${cashflow.period_end_label}`; + } + return ''; + })()} onEdit={bankTracking?.enabled ? undefined : () => setEditStartingOpen(true)} /> diff --git a/services/trackerService.js b/services/trackerService.js index 125b33b..3ac4d71 100644 --- a/services/trackerService.js +++ b/services/trackerService.js @@ -364,6 +364,48 @@ function getTracker(userId, query = {}, now = new Date()) { ? bankTracking.effective_balance : (startingAmounts?.combined_amount || 0); const hasStartingAmounts = bankTracking.enabled || !!startingAmounts; + + // ── Cash flow projection ─────────────────────────────────────────────────── + // "Projected" means starting minus ALL bills due — paid or not. + // This tells the user what they'll have left after everything clears, + // not just what remains after what they've already paid. + const lastDayOfMonth = new Date(year, month, 0).getDate(); + const periodEndDay = activeRemainingPeriod === '1st' ? 14 : lastDayOfMonth; + const periodEndLabel = `${['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][month - 1]} ${periodEndDay}`; + + const periodBillsTotal = roundMoney(periodRows.reduce((s, r) => s + rowDueAmount(r), 0)); + const periodPaidCount = periodRows.filter(r => ['paid', 'autodraft'].includes(r.status)).length; + const periodTotalCount = periodRows.length; + + // When bank tracking is on use the effective balance as the period starting point + const periodCashStart = bankTracking.enabled + ? bankTracking.effective_balance + : periodStartingAmount; + const periodProjected = roundMoney(periodCashStart - periodBillsTotal); + + const monthBillsTotal = activeTotalExpected; + const monthPaidCount = activeRows.filter(r => ['paid', 'autodraft'].includes(r.status)).length; + const monthTotalCount = activeRows.length; + const monthProjected = roundMoney(totalStarting - monthBillsTotal); + + const cashflow = { + has_data: hasStartingAmounts, + uses_bank_balance: bankTracking.enabled, + period: activeRemainingPeriod, + period_end_label: periodEndLabel, + period_starting: periodCashStart, + period_bills_total: periodBillsTotal, + period_paid: periodPaidTowardDue, + period_paid_count: periodPaidCount, + period_total_count: periodTotalCount, + period_projected: periodProjected, + month_starting: totalStarting, + month_bills_total: monthBillsTotal, + month_paid: roundMoney(activePaidTowardDue), + month_paid_count: monthPaidCount, + month_total_count: monthTotalCount, + month_projected: monthProjected, + }; const activeTotalPaid = roundMoney(activeRows.reduce((s, r) => s + r.total_paid, 0)); const activePaidTowardDue = roundMoney(activeRows.reduce((s, r) => s + rowPaidTowardDue(r), 0)); const activeTotalExpected = roundMoney(activeRows.reduce((s, r) => s + rowDueAmount(r), 0)); @@ -399,6 +441,7 @@ function getTracker(userId, query = {}, now = new Date()) { trend: buildThreeMonthTrend(db, userId, year, month, end, activeTotalPaid), }, bank_tracking: bankTracking, + cashflow, rows: bankTracking.enabled ? rows.map(r => { // Flag recently-paid rows as pending-cleared when bank tracking is on