diff --git a/.gitignore b/.gitignore index f324448..c54ee95 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ DEVELOPMENT_LOG.md PROJECT.md STRUCTURE.md -FUTURE.md BUILD_SUMMARY.md SCRIPTS.md project-requirements.md diff --git a/FUTURE.md b/FUTURE.md new file mode 100644 index 0000000..a44ef38 --- /dev/null +++ b/FUTURE.md @@ -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 diff --git a/HISTORY.md b/HISTORY.md index 604fb38..495f2aa 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -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 diff --git a/client/App.jsx b/client/App.jsx index 639fb05..8e05037 100644 --- a/client/App.jsx +++ b/client/App.jsx @@ -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() { }>} /> }>} /> }>} /> + }>} /> }>} /> }>} /> } /> diff --git a/client/components/layout/Sidebar.jsx b/client/components/layout/Sidebar.jsx index 44850bd..8b27447 100644 --- a/client/components/layout/Sidebar.jsx +++ b/client/components/layout/Sidebar.jsx @@ -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 }) { diff --git a/client/components/snowball/PayoffChart.jsx b/client/components/snowball/PayoffChart.jsx new file mode 100644 index 0000000..1055503 --- /dev/null +++ b/client/components/snowball/PayoffChart.jsx @@ -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 ( +
+ + + {/* Grid + Y axis */} + {yTicks.map(tick => { + const y = PAD.top + CH - tick * CH; + return ( + + + + {money(bal * tick)} + + + ); + })} + + {/* X axis labels */} + {xLabels.map(m => { + const x = PAD.left + (m / maxMonths) * CW; + return ( + + {m}mo + + ); + })} + + {/* X axis baseline */} + + + {/* Min-only track (slate dashed) */} + {minPts.length > 1 && ( + + )} + + {/* Current snowball plan (indigo dashed) */} + {showCurrent && currentPts.length > 1 && ( + + )} + + {/* Simulation track (amber solid, prominent) */} + {simPts.length > 1 && ( + <> + + {/* Endpoint dot */} + {(() => { + const last = simPts[simPts.length - 1]; + return ; + })()} + + )} + + {/* Tooltips at 6-month intervals on sim track */} + {simPts.filter(p => p.month > 0 && p.month % 6 === 0).map(p => ( + + {`Month ${p.month}: ${fullMoney(p.balance)} remaining`} + + ))} + + {/* Legend */} + + + Min only + + {showCurrent && ( + <> + + Snowball plan + + )} + + + Simulation + + + +
+ ); +} diff --git a/client/pages/PayoffPage.jsx b/client/pages/PayoffPage.jsx new file mode 100644 index 0000000..8f4025d --- /dev/null +++ b/client/pages/PayoffPage.jsx @@ -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 ( +
+

{label}

+

{value}

+ {sub &&

{sub}

} +
+ ); +} + +// ─── Input row ──────────────────────────────────────────────────────────────── + +function InputRow({ label, hint, children }) { + return ( +
+
+ + {hint && {hint}} +
+ {children} +
+ ); +} + +// ─── Empty states ───────────────────────────────────────────────────────────── + +function EmptyDebts() { + return ( +
+ +

No debts with a balance found

+

+ Add a current balance to your bills on the{' '} + Snowball page. +

+
+ ); +} + +function NoSelection() { + return ( +
+ +

Select a loan or debt to begin

+

Choose from the dropdown above to run your simulation.

+
+ ); +} + +// ─── 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 ( +
+
+
+
+
+ {[1, 2, 3, 4].map(i => ( +
+ ))} +
+
+
+
+ ); + } + + if (loadError) { + return ( +
+ +

Failed to load data

+

{loadError}

+ +
+ ); + } + + return ( +
+ + {/* Page header */} +
+
+

Payoff Simulator

+

+ Explore how extra payments reduce interest and shorten your payoff timeline. +

+
+ {isDirty && ( + + )} +
+ + {/* Bill selector */} +
+ {bills.length === 0 ? ( + + ) : ( + + )} +
+ + {/* Main content: left panel + right panel */} + {!bill ? ( + bills.length > 0 ? : null + ) : ( +
+ + {/* ── Left panel ── */} +
+ + {/* Required minimum */} +
+ + Required Minimum + + + {minPayment > 0 ? fmt(minPayment) : Not set} + +
+ + {minPayment <= 0 && ( +

+ + Set a minimum payment on the Snowball page for best results. +

+ )} + + {/* Interest rate */} + +
+ setSimRate(e.target.value)} + className="font-mono" + placeholder="0.00" + /> + % +
+
+ + {/* Monthly payment */} + + setSimPayment(e.target.value)} + className="font-mono" + placeholder="0.00" + /> + {paymentBelowMin && ( +

+ + Below minimum payment of {fmt(minPayment)} +

+ )} + {paymentTooLow && !paymentBelowMin && ( +

+ + Payment too low to overcome interest +

+ )} + {simPaymentN > 0 && simPaymentN !== (bill?.expected_amount ?? 0) && !paymentTooLow && ( + + )} +
+ + {/* One-time extra */} + +
+ setOneTimeExtra(e.target.value)} + className="font-mono" + placeholder="0.00" + /> +
+ + +
+
+
+ + {/* Divider */} +
+ + {/* Payoff date summary */} +
+ {simPayoffLabel ? ( +
+ Payoff +
+ + {simPayoffLabel} + + {simDuration && ( +

{simDuration}

+ )} +
+
+ ) : ( +

Enter a payment to see payoff date

+ )} + + {minPayoffLabel && simPayoffLabel && minPayoffLabel !== simPayoffLabel && ( +
+ Minimum only + {minPayoffLabel} +
+ )} +
+ +
+ + {/* ── Right panel ── */} +
+ + {/* Chart */} + {simTrack.length > 0 ? ( + + ) : ( +
+ {simPaymentN <= 0 ? 'Enter a monthly payment to see the chart' : 'Payment too low to pay off this debt'} +
+ )} + + {/* Stats row */} + {simTrack.length > 0 && ( + <> +
+ 0 ? 'teal' : 'slate'} + /> + 0 ? `${timeSavings} mo` : 'β€”'} + sub={timeSavings > 0 ? 'months sooner' : 'same timeline'} + color={timeSavings > 0 ? 'amber' : 'slate'} + /> +
+ + {/* Breakdown */} +
+
+ Balance today + {fmt(bill.current_balance)} +
+
+ Total interest + {fmt(simInterest)} +
+
+ Total paid + {fmt(simTotalPaid)} +
+
+ + )} + +
+
+ )} + +
+ ); +} diff --git a/package.json b/package.json index 9d312c5..ab64dca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bill-tracker", - "version": "0.34.1", + "version": "0.34.2", "description": "Monthly bill tracking system", "main": "server.js", "scripts": {