chore: roadmap audit v0.34.2 — remove completed FUTURE.md items, update partial statuses

This commit is contained in:
null 2026-05-30 15:18:45 -05:00
parent e0aae788d0
commit 799189059b
8 changed files with 867 additions and 3 deletions

1
.gitignore vendored
View File

@ -2,7 +2,6 @@
DEVELOPMENT_LOG.md
PROJECT.md
STRUCTURE.md
FUTURE.md
BUILD_SUMMARY.md
SCRIPTS.md
project-requirements.md

208
FUTURE.md Normal file
View File

@ -0,0 +1,208 @@
# Bill Tracker — Future Improvements
**This document tracks potential future enhancements for Bill Tracker.**
**Last Updated:** 2026-05-30
**Current Version:** v0.34.2
## How to Use This Document
This file is a living document. Agents should:
1. Read this file before proposing changes
2. Add new recommendations with priority levels
3. Never add completed items — move those to HISTORY.md instead
4. Reference this file when dispatching improvement tasks
5. Only Ripley can remove items from this list. Notify Ripley if something needs to be removed.
### Priority Format
All items must include the priority emoji in their heading, matching the section they belong to:
| Priority | Emoji | Heading Format |
|----------|-------|---------------|
| CRITICAL | 🔴 | `### 🔴 Title — CRITICAL` |
| HIGH | 🟠 | `### 🟠 Title — HIGH` |
| MEDIUM | 🟡 | `### 🟡 Title — MEDIUM` |
| LOW | 🔵 | `### 🔵 Title — LOW` |
| NICE TO HAVE | 💭 | `### 💭 Title — NICE TO HAVE` |
Items are grouped under their priority section heading (`## 🔴 CRITICAL`, `## 🟠 HIGH`, etc.) and sorted most-impactful-first within each tier.
## Pending Recommendations
## 🟡 MEDIUM
### 🟡 Projected Cash Flow — MEDIUM
**Priority:** MEDIUM
**Added:** 2026-05-16 by Ripley (from _null's prioritized roadmap)
**Description:**
Show users what's coming: "You'll have $X left before the 15th", "Upcoming bills before next paycheck", and a "Safe-to-spend" estimate based on starting amount, unpaid bills, and scheduled income. Fits naturally with the existing 1st/15th bucket model.
**Scope:**
- "Remaining after bills" projection per bucket (1st half / 15th half)
- "Upcoming bills before next paycheck" list
- "Safe-to-spend" estimate based on starting balance minus unpaid bills
- Scheduled income support (payday amounts)
**Rationale:**
- The 1st/15th bucket model is already built — cash flow projection is the natural next step
- Most valuable feature for day-to-day money management
- Turns a bill tracker into a financial planning tool
**Implementation Notes:**
- Requires user to enter starting balance and payday amounts (new settings fields)
- Calculate: starting amount - unpaid bills due before next payday = safe-to-spend
- Files to modify: `TrackerPage.jsx`, `routes/tracker.js`, `user_settings` table (new fields)
- Estimated effort: 8-10 hours
---
### 🟡 Recurring Payment Rules — MEDIUM
**Priority:** MEDIUM
**Added:** 2026-05-16 by Ripley (from _null's prioritized roadmap)
**Status:** Partial — infrastructure built (auto_mark_paid column, confirm/dismiss APIs, UI for suggestions), but no proactive suggestion scheduler generating payments on due date.
**Description:**
Auto-mark certain bills as paid on due date if `autodraft_status = assumed_paid`. Or create suggested payments awaiting confirmation. Good for autopay-heavy users.
**Scope:**
- Bills with autopay/autodraft get a "suggested payment" on their due date
- User confirms or dismisses the suggestion
- Auto-mark option: bills can be set to automatically mark as paid on due date
**Implementation Notes:**
- ✅ `auto_mark_paid` column + bill edit checkbox
- ✅ `applyAutopaySuggestions()` in trackerService handles auto-mark + suggestion generation
- ✅ Confirm (`POST /api/payments/autopay-suggestions/:billId/confirm`) and dismiss (`POST /.../dismiss`) endpoints
- ✅ Suggestion UI in TrackerPage with badge + confirm/dismiss buttons
- ❌ No proactive suggestion engine — only runs when tracker loads
- ❌ No scheduled task/cron to evaluate bills and create suggestions on due date
- Estimated effort remaining: 2-3 hours
---
### 🟡 Calendar Agenda Mode — MEDIUM
**Priority:** MEDIUM
**Added:** 2026-05-16 by Ripley (from _null's prioritized roadmap)
**Description:**
Replace the month-grid calendar with an agenda view: Today / This Week / Next 14 Days. Group bills by "needs action," "autopay," "already paid." More useful when actually paying bills.
**Rationale:**
- Month grids are pretty but not actionable
- Agenda mode answers "what do I need to do right now?"
- Groups by status makes it immediately clear what needs attention
**Implementation Notes:**
- New view toggle on CalendarPage: Grid vs Agenda
- Agenda shows: Overdue → Today → This Week → Next 14 Days
- Each group sorted by due date, with action status badges
- Files to modify: `CalendarPage.jsx`, `routes/calendar.js`
- Estimated effort: 6-8 hours
---
### 🟡 Filtered Exports — MEDIUM
**Priority:** MEDIUM (upgraded from LOW)
**Added:** 2026-05-11 by Ripley (from _null's prioritized roadmap)
**Description:**
Export only utilities, debts, overdue, date range, tax-relevant categories. Currently exports everything with no filtering.
**Rationale:**
- Users need "all Q1 utility bills" or "overdue payments this year" for reconciliation and tax prep
- `/api/export/user-excel` exports everything — no query params for date range, category, or status
**Implementation Notes:**
- Add query params to export endpoints: `category_id`, `start`, `end`, `status` (paid/unpaid/overdue)
- Files to modify: `routes/export.js`, `client/pages/DataPage.jsx`
- Estimated effort: 6 hours
---
## 🔵 LOW
### 🔵 Payment Method Tracking and Summary — LOW
**Priority:** LOW
**Added:** 2026-05-11 by Ripley
**Description:**
The `payments` table has a `method` column (free-text) but no way to see "how much did I pay via autopay vs manual vs credit card this month."
**Implementation Notes:**
- Standardize payment methods: enum or controlled list (autopay, bank_transfer, credit_card, check, cash, other)
- Add payment method breakdown to analytics or summary page
- Files to modify: `routes/payments.js`, `AnalyticsPage.jsx`, schema migration
- Estimated effort: 4-6 hours
---
### 🔵 No Keyboard Navigation or Shortcuts — LOW
**Added:** 2026-05-11 by Ripley
**Status:** Partial — Esc closes modals ✅, Cmd+K opens command palette ✅, arrow key tracker navigation ❌
**Description:**
Only a skip link exists for keyboard accessibility. No `Cmd+K` to find a bill, no `Esc` to close modals, no arrow keys to navigate the tracker grid.
**Implementation Notes:**
- ✅ `Esc` closes any open modal/dialog (via Radix Dialog default)
- ✅ `Cmd+K` / `Ctrl+K` opens command palette (`CommandPalette.jsx`)
- ❌ Arrow keys navigate tracker rows when grid is focused
- Remaining effort: 1-2 hours
---
### 🔵 Add comprehensive unit and integration tests
**Added:** 2026-05-08 by Scarlett
**Description:**
Currently no unit tests exist for components or hooks. The only testing is functional tests in `test-functional.js`.
**Implementation Notes:**
- Set up Jest + React Testing Library (or vitest)
- Test key components: BillModal, TrackerPage row, BillsTableInner
- Test hooks: useAuth, custom form hooks
- Test utility functions in `client/lib/utils.js`
- Estimated effort: 8-12 hours for baseline coverage
---
### 🔵 Missing Bill Grouping and Reorganization API
**Added:** 2026-05-08 by Neo
**Description:**
No way to reorder bills, drag-and-drop, or group by custom criteria.
**Implementation Notes:**
- `sort_order` column to bills table (default NULL, ordered first by sort_order then due_day)
- `PUT /api/bills/reorder` endpoint accepting `{bill_id: new_index}`
- `PUT /api/bills/:id/archived` to soft-archive
- Estimated effort: 6 hours
---
## 💭 NICE TO HAVE
### 💭 Add consistent form state management pattern
**Priority:** MEH
**Added:** 2026-05-08 by Scarlett
**Description:**
Form state management is inconsistent across components. Some use `useState` for each field, others use form libraries.
**Implementation Notes:**
- Consider react-hook-form for complex forms
- Create reusable form field components (InputField, SelectField, etc.)
- Standardize validation approach
- Estimated effort: 4-6 hours

View File

@ -1,5 +1,21 @@
# Bill Tracker — Changelog
## v0.34.2
### 🧹 Roadmap Audit
- **Audited all FUTURE.md items** against current codebase:
- Removed: Architecture: Business Logic Extraction (IS_IMPLEMENTED)
- Removed: Debt Snowball Readiness Checklist (IS_IMPLEMENTED)
- Updated status: Keyboard Navigation/Shortcuts → partial (Esc + Cmd+K done, arrow-key grid not)
- Confirmed not implemented: Projected Cash Flow, Recurring Payment Rules (partial), Calendar Agenda, Filtered Exports, Payment Method Tracking, Unit Tests, Bill Grouping, Form State Management — all remain in FUTURE.md
### 🔧 Changed
- **Bump**`0.34.1``0.34.2`
---
## v0.34.1
### 🚀 Features

View File

@ -43,6 +43,7 @@ const DataPage = lazy(() => import('@/pages/DataPage'));
const ProfilePage = lazy(() => import('@/pages/ProfilePage'));
const SnowballPage = lazy(() => import('@/pages/SnowballPage'));
const HealthPage = lazy(() => import('@/pages/HealthPage'));
const PayoffPage = lazy(() => import('@/pages/PayoffPage'));
function RequireAuth({ children, role }) {
const { user, singleUserMode } = useAuth();
@ -209,6 +210,7 @@ export default function App() {
<Route path="analytics" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><AnalyticsPage /></Suspense></ErrorBoundary>} />
<Route path="settings" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SettingsPage /></Suspense></ErrorBoundary>} />
<Route path="snowball" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SnowballPage /></Suspense></ErrorBoundary>} />
<Route path="payoff" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><PayoffPage /></Suspense></ErrorBoundary>} />
<Route path="data" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><DataPage /></Suspense></ErrorBoundary>} />
<Route path="profile" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><ProfilePage /></Suspense></ErrorBoundary>} />
<Route path="*" element={<Navigate to="/" replace />} />

View File

@ -1,7 +1,7 @@
import { useState, useMemo } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import {
Activity, BarChart3, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
Activity, BarChart3, Calculator, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
Search, Settings, ShieldCheck, Tag, TrendingDown, User, X,
Repeat,
} from 'lucide-react';
@ -41,6 +41,7 @@ const trackerItems = [
{ to: '/categories', icon: Tag, label: 'Categories' },
{ to: '/health', icon: ClipboardCheck, label: 'Health' },
{ to: '/snowball', icon: TrendingDown, label: 'Snowball' },
{ to: '/payoff', icon: Calculator, label: 'Payoff' },
];
function TrackerMenu({ onNavigate, badge }) {

View File

@ -0,0 +1,146 @@
import React from 'react';
const W = 720;
const H = 300;
const PAD = { left: 68, right: 24, top: 20, bottom: 56 };
const CW = W - PAD.left - PAD.right;
const CH = H - PAD.top - PAD.bottom;
function money(v) {
const n = Number(v) || 0;
if (n >= 1000) return `$${(n / 1000).toFixed(0)}k`;
return `$${n.toFixed(0)}`;
}
function fullMoney(v) {
return (Number(v) || 0).toLocaleString(undefined, {
style: 'currency', currency: 'USD', maximumFractionDigits: 2,
});
}
function buildPoints(track, startBalance, maxMonths) {
const all = [{ month: 0, balance: startBalance }, ...track];
return all.map(({ month, balance }) => ({
x: PAD.left + (month / maxMonths) * CW,
y: PAD.top + CH - (balance / startBalance) * CH,
month,
balance,
}));
}
function toLine(pts) {
return pts.map(p => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
}
export default function PayoffChart({ minTrack = [], currentTrack = [], simTrack = [], startBalance = 1 }) {
const maxMonths = Math.max(minTrack.length, currentTrack.length, simTrack.length, 12);
const bal = Math.max(startBalance, 1);
const minPts = buildPoints(minTrack, bal, maxMonths);
const currentPts = buildPoints(currentTrack, bal, maxMonths);
const simPts = buildPoints(simTrack, bal, maxMonths);
const xStep = maxMonths <= 24 ? 6 : maxMonths <= 60 ? 12 : 24;
const xLabels = [];
for (let m = xStep; m <= maxMonths; m += xStep) {
xLabels.push(m);
}
const yTicks = [0, 0.25, 0.5, 0.75, 1];
const showCurrent = currentTrack.length > 0 &&
currentTrack.some((c, i) => (minTrack[i]?.balance ?? null) !== c.balance);
return (
<div className="w-full overflow-hidden rounded-xl border border-border/60 bg-background/60">
<svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label="Loan payoff chart" className="h-auto w-full">
{/* Grid + Y axis */}
{yTicks.map(tick => {
const y = PAD.top + CH - tick * CH;
return (
<g key={tick}>
<line x1={PAD.left} x2={PAD.left + CW} y1={y} y2={y}
stroke="currentColor" opacity="0.08" strokeWidth="1" />
<text x={PAD.left - 6} y={y + 4} fontSize="11" fill="currentColor"
opacity="0.5" textAnchor="end">
{money(bal * tick)}
</text>
</g>
);
})}
{/* X axis labels */}
{xLabels.map(m => {
const x = PAD.left + (m / maxMonths) * CW;
return (
<text key={m} x={x} y={H - 38} fontSize="11" fill="currentColor"
opacity="0.5" textAnchor="middle">
{m}mo
</text>
);
})}
{/* X axis baseline */}
<line x1={PAD.left} x2={PAD.left + CW} y1={PAD.top + CH} y2={PAD.top + CH}
stroke="currentColor" opacity="0.12" strokeWidth="1" />
{/* Min-only track (slate dashed) */}
{minPts.length > 1 && (
<polyline points={toLine(minPts)} fill="none"
stroke="#94a3b8" strokeWidth="1.5"
strokeDasharray="6,4" strokeLinecap="round" strokeLinejoin="round"
/>
)}
{/* Current snowball plan (indigo dashed) */}
{showCurrent && currentPts.length > 1 && (
<polyline points={toLine(currentPts)} fill="none"
stroke="#818cf8" strokeWidth="1.5"
strokeDasharray="9,5" strokeLinecap="round" strokeLinejoin="round"
/>
)}
{/* Simulation track (amber solid, prominent) */}
{simPts.length > 1 && (
<>
<polyline points={toLine(simPts)} fill="none"
stroke="#f59e0b" strokeWidth="3"
strokeLinecap="round" strokeLinejoin="round"
/>
{/* Endpoint dot */}
{(() => {
const last = simPts[simPts.length - 1];
return <circle cx={last.x} cy={last.y} r="4" fill="#f59e0b" />;
})()}
</>
)}
{/* Tooltips at 6-month intervals on sim track */}
{simPts.filter(p => p.month > 0 && p.month % 6 === 0).map(p => (
<circle key={p.month} cx={p.x} cy={p.y} r="3" fill="#f59e0b" opacity="0.7">
<title>{`Month ${p.month}: ${fullMoney(p.balance)} remaining`}</title>
</circle>
))}
{/* Legend */}
<g transform={`translate(${PAD.left}, ${H - 22})`} fontSize="11" fill="currentColor" opacity="0.7">
<line x1="0" x2="16" y1="0" y2="0" stroke="#94a3b8" strokeWidth="1.5" strokeDasharray="4,3" />
<text x="20" y="4">Min only</text>
{showCurrent && (
<>
<line x1="80" x2="96" y1="0" y2="0" stroke="#818cf8" strokeWidth="1.5" strokeDasharray="6,4" />
<text x="100" y="4">Snowball plan</text>
</>
)}
<line x1={showCurrent ? 198 : 80} x2={showCurrent ? 214 : 96} y1="0" y2="0"
stroke="#f59e0b" strokeWidth="2.5" />
<text x={showCurrent ? 218 : 100} y="4">Simulation</text>
</g>
</svg>
</div>
);
}

492
client/pages/PayoffPage.jsx Normal file
View File

@ -0,0 +1,492 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertCircle, ArrowRight, Calculator, RefreshCw, RotateCcw, TrendingDown } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import PayoffChart from '@/components/snowball/PayoffChart';
// Helpers
function fmt(v) {
return (Number(v) || 0).toLocaleString(undefined, {
style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2,
});
}
function fmtShort(v) {
const n = Number(v) || 0;
return n.toLocaleString(undefined, { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
}
function buildPayoffSchedule(balance, annualRatePct, monthlyPayment, oneTimeExtra = 0) {
if (!balance || balance <= 0 || !monthlyPayment || monthlyPayment <= 0) return [];
const rate = (annualRatePct || 0) / 100 / 12;
if (rate > 0 && monthlyPayment <= balance * rate) return [];
let bal = balance;
const months = [];
for (let i = 0; i < 600; i++) {
const interest = Math.round(bal * rate * 100) / 100;
const pmt = Math.min(bal + interest, i === 0 ? monthlyPayment + oneTimeExtra : monthlyPayment);
const principal = Math.max(0, pmt - interest);
bal = Math.round(Math.max(0, bal - principal) * 100) / 100;
months.push({ month: i + 1, balance: bal, interest });
if (bal < 0.01) break;
}
return months;
}
function payoffLabel(track, now = new Date()) {
if (!track.length) return null;
const d = new Date(now.getFullYear(), now.getMonth() + track.length, 1);
return d.toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
}
function numMonths(track) {
if (!track.length) return null;
const y = Math.floor(track.length / 12);
const m = track.length % 12;
if (y === 0) return `${m} mo`;
if (m === 0) return `${y} yr`;
return `${y} yr ${m} mo`;
}
// Stat card
function StatCard({ label, value, sub, color = 'amber' }) {
const colors = {
amber: 'bg-amber-500/8 border-amber-400/20 text-amber-500 dark:text-amber-400',
teal: 'bg-teal-500/8 border-teal-400/20 text-teal-500 dark:text-teal-400',
slate: 'bg-muted/40 border-border/60 text-foreground',
};
return (
<div className={cn('rounded-xl border p-4 text-center', colors[color])}>
<p className="text-[11px] font-medium uppercase tracking-widest text-muted-foreground mb-1">{label}</p>
<p className={cn('text-2xl font-bold font-mono tabular-nums', colors[color])}>{value}</p>
{sub && <p className="text-[11px] text-muted-foreground mt-0.5">{sub}</p>}
</div>
);
}
// Input row
function InputRow({ label, hint, children }) {
return (
<div className="space-y-1.5">
<div className="flex items-baseline justify-between">
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</Label>
{hint && <span className="text-[11px] text-muted-foreground">{hint}</span>}
</div>
{children}
</div>
);
}
// Empty states
function EmptyDebts() {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/10 px-6 py-16 text-center">
<TrendingDown className="h-10 w-10 text-muted-foreground/40 mb-4" />
<p className="text-sm font-medium text-foreground">No debts with a balance found</p>
<p className="text-xs text-muted-foreground mt-1">
Add a current balance to your bills on the{' '}
<a href="/snowball" className="underline text-primary hover:opacity-80">Snowball page</a>.
</p>
</div>
);
}
function NoSelection() {
return (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/10 px-6 py-16 text-center">
<Calculator className="h-10 w-10 text-muted-foreground/40 mb-4" />
<p className="text-sm font-medium text-foreground">Select a loan or debt to begin</p>
<p className="text-xs text-muted-foreground mt-1">Choose from the dropdown above to run your simulation.</p>
</div>
);
}
// PayoffPage
export default function PayoffPage() {
const [bills, setBills] = useState([]);
const [extraPayment, setExtraPayment] = useState(0);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
const [selectedId, setSelectedId] = useState(null);
// Per-simulation state (reset when bill changes)
const [simPayment, setSimPayment] = useState('');
const [simRate, setSimRate] = useState('');
const [oneTimeExtra, setOneTimeExtra] = useState('');
const [applying, setApplying] = useState(false);
const loadData = useCallback(() => {
setLoading(true);
setLoadError(null);
Promise.all([api.snowball(), api.snowballSettings()])
.then(([billData, settings]) => {
const debtBills = (billData || []).filter(b => (b.current_balance ?? 0) > 0);
setBills(debtBills);
setExtraPayment(Number(settings?.extra_payment) || 0);
if (debtBills.length > 0 && !selectedId) {
setSelectedId(debtBills[0].id);
}
})
.catch(err => setLoadError(err.message || 'Failed to load data'))
.finally(() => setLoading(false));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { loadData(); }, [loadData]);
const bill = useMemo(() => bills.find(b => b.id === selectedId) ?? null, [bills, selectedId]);
const isAttack = bills[0]?.id === selectedId;
// Reset sim inputs whenever the selected bill changes
useEffect(() => {
if (!bill) return;
setSimPayment(String(Math.max(bill.minimum_payment ?? 0, bill.expected_amount ?? 0)));
setSimRate(String(bill.interest_rate ?? 0));
setOneTimeExtra('');
}, [bill?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Derived simulation tracks
const simPaymentN = Math.max(0, Number(simPayment) || 0);
const simRateN = Math.max(0, Number(simRate) || 0);
const oneTimeExtraN = Math.max(0, Number(oneTimeExtra) || 0);
const minPayment = bill?.minimum_payment ?? 0;
const { minTrack, currentTrack, simTrack } = useMemo(() => {
if (!bill) return { minTrack: [], currentTrack: [], simTrack: [] };
const b = bill.current_balance;
const min = minPayment > 0 ? minPayment : 0.01;
const currentPmt = isAttack ? min + extraPayment : min;
return {
minTrack: buildPayoffSchedule(b, simRateN, min),
currentTrack: buildPayoffSchedule(b, simRateN, currentPmt),
simTrack: buildPayoffSchedule(b, simRateN, simPaymentN, oneTimeExtraN),
};
}, [bill, simRateN, simPaymentN, oneTimeExtraN, minPayment, isAttack, extraPayment]);
const minInterest = useMemo(() => minTrack.reduce((s, m) => s + m.interest, 0), [minTrack]);
const simInterest = useMemo(() => simTrack.reduce((s, m) => s + m.interest, 0), [simTrack]);
const interestSavings = Math.max(0, minInterest - simInterest);
const timeSavings = Math.max(0, minTrack.length - simTrack.length);
const simTotalPaid = simInterest + (bill?.current_balance ?? 0);
const simPayoffLabel = payoffLabel(simTrack);
const minPayoffLabel = payoffLabel(minTrack);
const simDuration = numMonths(simTrack);
const paymentBelowMin = simPaymentN > 0 && simPaymentN < minPayment && minPayment > 0;
const paymentTooLow = bill && simPaymentN > 0 && simTrack.length === 0;
const defaultSimPayment = bill
? String(Math.max(bill.minimum_payment ?? 0, bill.expected_amount ?? 0))
: '';
const defaultRate = bill ? String(bill.interest_rate ?? 0) : '';
const isDirty = simPayment !== defaultSimPayment || simRate !== defaultRate || oneTimeExtra !== '';
const handleReset = () => {
if (!bill) return;
setSimPayment(defaultSimPayment);
setSimRate(defaultRate);
setOneTimeExtra('');
};
const handleApply = async () => {
if (!bill || applying) return;
setApplying(true);
try {
await api.updateBill(bill.id, { expected_amount: simPaymentN });
toast.success(`"${bill.name}" updated to ${fmt(simPaymentN)}/mo`, {
action: {
label: 'Undo',
onClick: async () => {
await api.updateBill(bill.id, { expected_amount: bill.expected_amount });
toast.success('Reverted');
loadData();
},
},
});
loadData();
} catch {
toast.error('Failed to update bill');
} finally {
setApplying(false);
}
};
// Render
if (loading) {
return (
<div className="space-y-6 animate-pulse">
<div className="h-8 w-64 rounded-lg bg-muted/50" />
<div className="h-4 w-96 rounded bg-muted/50" />
<div className="grid grid-cols-1 lg:grid-cols-[360px_1fr] gap-6">
<div className="space-y-4">
{[1, 2, 3, 4].map(i => (
<div key={i} className="h-16 rounded-xl bg-muted/40" />
))}
</div>
<div className="h-96 rounded-xl bg-muted/40" />
</div>
</div>
);
}
if (loadError) {
return (
<div className="flex flex-col items-center justify-center py-24 text-center rounded-xl border border-destructive/20 bg-destructive/5">
<AlertCircle className="h-10 w-10 text-destructive mb-3" />
<p className="text-sm font-medium">Failed to load data</p>
<p className="mt-1 text-xs text-muted-foreground">{loadError}</p>
<Button size="sm" variant="outline" onClick={loadData} className="mt-4 gap-1.5 text-xs">
<RefreshCw className="h-3 w-3" /> Try again
</Button>
</div>
);
}
return (
<div>
{/* Page header */}
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Payoff Simulator</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Explore how extra payments reduce interest and shorten your payoff timeline.
</p>
</div>
{isDirty && (
<Button size="sm" variant="ghost" onClick={handleReset} className="gap-1.5 text-xs shrink-0 mt-1">
<RotateCcw className="h-3 w-3" /> Reset
</Button>
)}
</div>
{/* Bill selector */}
<div className="mb-6">
{bills.length === 0 ? (
<EmptyDebts />
) : (
<Select value={selectedId ? String(selectedId) : ''} onValueChange={v => setSelectedId(Number(v))}>
<SelectTrigger className="w-72">
<SelectValue placeholder="Select a loan or debt…" />
</SelectTrigger>
<SelectContent>
{bills.map(b => (
<SelectItem key={b.id} value={String(b.id)}>
<span className="font-medium">{b.name}</span>
{b.current_balance ? (
<span className="ml-2 text-muted-foreground font-mono text-xs">
{fmt(b.current_balance)}
</span>
) : null}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Main content: left panel + right panel */}
{!bill ? (
bills.length > 0 ? <NoSelection /> : null
) : (
<div className="grid grid-cols-1 lg:grid-cols-[340px_1fr] gap-6 items-start">
{/* ── Left panel ── */}
<div className="table-surface p-5 space-y-5">
{/* Required minimum */}
<div className="rounded-lg bg-muted/30 px-4 py-3 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Required Minimum
</span>
<span className="font-mono text-lg font-bold tabular-nums">
{minPayment > 0 ? fmt(minPayment) : <span className="text-muted-foreground text-sm">Not set</span>}
</span>
</div>
{minPayment <= 0 && (
<p className="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1.5">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
Set a minimum payment on the Snowball page for best results.
</p>
)}
{/* Interest rate */}
<InputRow label="Interest Rate" hint="Override to test scenarios">
<div className="flex items-center gap-2">
<Input
type="number" min="0" max="99" step="0.01"
value={simRate}
onChange={e => setSimRate(e.target.value)}
className="font-mono"
placeholder="0.00"
/>
<span className="text-sm text-muted-foreground shrink-0">%</span>
</div>
</InputRow>
{/* Monthly payment */}
<InputRow label="Monthly Payment">
<Input
type="number" min="0" step="1"
value={simPayment}
onChange={e => setSimPayment(e.target.value)}
className="font-mono"
placeholder="0.00"
/>
{paymentBelowMin && (
<p className="text-[11px] text-amber-600 dark:text-amber-400 flex items-center gap-1 mt-1">
<AlertCircle className="h-3 w-3 shrink-0" />
Below minimum payment of {fmt(minPayment)}
</p>
)}
{paymentTooLow && !paymentBelowMin && (
<p className="text-[11px] text-destructive flex items-center gap-1 mt-1">
<AlertCircle className="h-3 w-3 shrink-0" />
Payment too low to overcome interest
</p>
)}
{simPaymentN > 0 && simPaymentN !== (bill?.expected_amount ?? 0) && !paymentTooLow && (
<button
type="button"
onClick={handleApply}
disabled={applying}
className="mt-1.5 text-[11px] text-primary hover:underline flex items-center gap-1 disabled:opacity-50"
>
<ArrowRight className="h-3 w-3" />
{applying ? 'Applying…' : `Apply ${fmt(simPaymentN)}/mo to my budget`}
</button>
)}
</InputRow>
{/* One-time extra */}
<InputRow label="One-time Extra This Month" hint="Optional lump sum">
<div className="flex items-center gap-1">
<Input
type="number" min="0" step="100"
value={oneTimeExtra}
onChange={e => setOneTimeExtra(e.target.value)}
className="font-mono"
placeholder="0.00"
/>
<div className="flex flex-col gap-0.5">
<button
type="button"
className="rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-muted transition-colors"
onClick={() => setOneTimeExtra(String(Math.max(0, oneTimeExtraN + 100)))}
></button>
<button
type="button"
className="rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-muted transition-colors"
onClick={() => setOneTimeExtra(String(Math.max(0, oneTimeExtraN - 100)))}
></button>
</div>
</div>
</InputRow>
{/* Divider */}
<div className="border-t border-border/50" />
{/* Payoff date summary */}
<div className="space-y-2">
{simPayoffLabel ? (
<div className="flex items-baseline justify-between">
<span className="text-xs text-muted-foreground uppercase tracking-wider font-semibold">Payoff</span>
<div className="text-right">
<span className="text-xl font-bold font-mono text-amber-500 dark:text-amber-400">
{simPayoffLabel}
</span>
{simDuration && (
<p className="text-[11px] text-muted-foreground">{simDuration}</p>
)}
</div>
</div>
) : (
<p className="text-xs text-muted-foreground text-center">Enter a payment to see payoff date</p>
)}
{minPayoffLabel && simPayoffLabel && minPayoffLabel !== simPayoffLabel && (
<div className="flex items-baseline justify-between">
<span className="text-[11px] text-muted-foreground">Minimum only</span>
<span className="text-[11px] text-muted-foreground font-mono line-through">{minPayoffLabel}</span>
</div>
)}
</div>
</div>
{/* ── Right panel ── */}
<div className="space-y-4">
{/* Chart */}
{simTrack.length > 0 ? (
<PayoffChart
minTrack={minTrack}
currentTrack={currentTrack}
simTrack={simTrack}
startBalance={bill.current_balance}
/>
) : (
<div className="flex items-center justify-center rounded-xl border border-dashed border-border/60 bg-muted/10 h-[300px] text-sm text-muted-foreground">
{simPaymentN <= 0 ? 'Enter a monthly payment to see the chart' : 'Payment too low to pay off this debt'}
</div>
)}
{/* Stats row */}
{simTrack.length > 0 && (
<>
<div className="grid grid-cols-2 gap-3">
<StatCard
label="Interest Savings"
value={fmtShort(interestSavings)}
sub="vs minimum only"
color={interestSavings > 0 ? 'teal' : 'slate'}
/>
<StatCard
label="Time Savings"
value={timeSavings > 0 ? `${timeSavings} mo` : '—'}
sub={timeSavings > 0 ? 'months sooner' : 'same timeline'}
color={timeSavings > 0 ? 'amber' : 'slate'}
/>
</div>
{/* Breakdown */}
<div className="table-surface divide-y divide-border/50">
<div className="px-5 py-3 flex items-center justify-between">
<span className="text-sm text-muted-foreground">Balance today</span>
<span className="font-mono text-sm font-semibold">{fmt(bill.current_balance)}</span>
</div>
<div className="px-5 py-3 flex items-center justify-between">
<span className="text-sm text-muted-foreground">Total interest</span>
<span className="font-mono text-sm font-semibold text-rose-500">{fmt(simInterest)}</span>
</div>
<div className="px-5 py-3 flex items-center justify-between">
<span className="text-sm font-medium">Total paid</span>
<span className="font-mono text-sm font-bold">{fmt(simTotalPaid)}</span>
</div>
</div>
</>
)}
</div>
</div>
)}
</div>
);
}

View File

@ -1,6 +1,6 @@
{
"name": "bill-tracker",
"version": "0.34.1",
"version": "0.34.2",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {