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