Compare commits
No commits in common. "799189059b23b510e1e69c0915ff344d87bdb2e1" and "d0b318c9d20769f5a15ac09245ee921ce7854bda" have entirely different histories.
799189059b
...
d0b318c9d2
|
|
@ -2,6 +2,7 @@
|
|||
DEVELOPMENT_LOG.md
|
||||
PROJECT.md
|
||||
STRUCTURE.md
|
||||
FUTURE.md
|
||||
BUILD_SUMMARY.md
|
||||
SCRIPTS.md
|
||||
project-requirements.md
|
||||
|
|
|
|||
208
FUTURE.md
208
FUTURE.md
|
|
@ -1,208 +0,0 @@
|
|||
# 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
|
||||
33
HISTORY.md
33
HISTORY.md
|
|
@ -1,38 +1,5 @@
|
|||
# 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
|
||||
|
||||
- **Price Change Insights panel** — Tracker page now shows a collapsible amber panel when recurring bills have been paid at a different amount than expected for 2+ consecutive months. Per-bill "Update to $X.XX" action (with undo toast) and "Dismiss" (hidden for 30 days). TrendingUp/TrendingDown icons and teal palette for decreases.
|
||||
- **Drift detection service** — `driftService.getDriftReport()` computes a rolling median of the last 3 months of payments per bill and compares it against `expected_amount`. Flags when `|delta| ≥ $1 AND |drift%| ≥ threshold`.
|
||||
- **Price-change email digest** — Daily worker now calls `runDriftNotifications()`, sending a single amber-styled digest email per user listing all bills with changed amounts (old → new, Δ%).
|
||||
- **Drift snooze persistence** — `drift_snoozed_until` column on `bills` (migration v0.71). `POST /api/bills/:id/snooze-drift` sets a 30-day snooze server-side.
|
||||
- **"Notify on price changes" toggle** — New notification preference in ProfilePage, backed by `notify_amount_change` column on `users` (migration v0.71).
|
||||
- **Price change sensitivity setting** — "Price change sensitivity" `%` input in SettingsPage Billing Behavior section. Stored as `drift_threshold_pct` in per-user settings (default 5%, range 1–25%).
|
||||
|
||||
### 🔧 Changed
|
||||
|
||||
- **Bump** — `0.34.0` → `0.34.1`
|
||||
|
||||
---
|
||||
|
||||
## v0.34.0
|
||||
|
||||
### 🚀 Features
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ 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();
|
||||
|
|
@ -210,7 +209,6 @@ 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 />} />
|
||||
|
|
|
|||
|
|
@ -178,8 +178,6 @@ export const api = {
|
|||
createBillHistoryRange: (id, data) => post(`/bills/${id}/history-ranges`, data),
|
||||
updateBillHistoryRange: (id, rangeId, data) => put(`/bills/${id}/history-ranges/${rangeId}`, data),
|
||||
deleteBillHistoryRange: (id, rangeId) => del(`/bills/${id}/history-ranges/${rangeId}`),
|
||||
driftReport: () => get('/bills/drift-report'),
|
||||
snoozeBillDrift: (id) => post(`/bills/${id}/snooze-drift`, {}),
|
||||
billTemplates: () => get('/bills/templates'),
|
||||
saveBillTemplate: (data) => post('/bills/templates', data),
|
||||
deleteBillTemplate: (id) => del(`/bills/templates/${id}`),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Activity, BarChart3, Calculator, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
|
||||
Activity, BarChart3, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
|
||||
Search, Settings, ShieldCheck, Tag, TrendingDown, User, X,
|
||||
Repeat,
|
||||
} from 'lucide-react';
|
||||
|
|
@ -41,7 +41,6 @@ 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 }) {
|
||||
|
|
|
|||
|
|
@ -1,146 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { TrendingUp, TrendingDown, ChevronDown } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { cn, fmt } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
||||
|
||||
function DriftRow({ row, refresh }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleUpdate() {
|
||||
setLoading(true);
|
||||
const oldAmount = row.expected_amount;
|
||||
try {
|
||||
await api.updateBill(row.id, { expected_amount: row.recent_amount });
|
||||
toast.success(`"${row.name}" updated to ${fmt(row.recent_amount)}`, {
|
||||
action: {
|
||||
label: 'Undo',
|
||||
onClick: async () => {
|
||||
try {
|
||||
await api.updateBill(row.id, { expected_amount: oldAmount });
|
||||
toast.success('Reverted');
|
||||
refresh();
|
||||
} catch {
|
||||
toast.error('Failed to revert');
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
refresh();
|
||||
} catch {
|
||||
toast.error('Failed to update bill');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDismiss() {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.snoozeBillDrift(row.id);
|
||||
toast.success('Hidden for 30 days');
|
||||
refresh();
|
||||
} catch {
|
||||
toast.error('Failed to dismiss');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isUp = row.direction === 'up';
|
||||
const sign = isUp ? '+' : '';
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 py-2.5 sm:flex-nowrap">
|
||||
{/* Bill info */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium text-foreground">{row.name}</span>
|
||||
{row.category_name && (
|
||||
<Badge variant="secondary" className="shrink-0 px-1.5 py-0 text-[10px]">
|
||||
{row.category_name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isUp
|
||||
? <TrendingUp className="h-3 w-3 text-amber-500 dark:text-amber-400" />
|
||||
: <TrendingDown className="h-3 w-3 text-teal-500 dark:text-teal-400" />
|
||||
}
|
||||
<span className={cn(
|
||||
'text-xs font-medium',
|
||||
isUp ? 'text-amber-600 dark:text-amber-400' : 'text-teal-600 dark:text-teal-400'
|
||||
)}>
|
||||
{sign}{row.drift_pct}% over {row.months_sampled} months
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount change */}
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<span className="font-mono text-xs text-muted-foreground line-through">
|
||||
{fmt(row.expected_amount)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className={cn(
|
||||
'font-mono text-sm font-semibold',
|
||||
isUp ? 'text-amber-500 dark:text-amber-400' : 'text-teal-500 dark:text-teal-400'
|
||||
)}>
|
||||
{fmt(row.recent_amount)}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'rounded-full px-1.5 py-0.5 text-[10px] font-bold leading-none',
|
||||
isUp
|
||||
? 'bg-amber-500/15 text-amber-700 dark:text-amber-300'
|
||||
: 'bg-teal-500/15 text-teal-700 dark:text-teal-300'
|
||||
)}>
|
||||
{sign}{row.drift_pct}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-7 gap-1.5 px-2.5 text-xs',
|
||||
isUp
|
||||
? 'border-amber-400/40 text-amber-600 hover:border-amber-400/70 hover:bg-amber-500/[0.08] hover:text-amber-500 dark:text-amber-400'
|
||||
: 'border-teal-400/40 text-teal-600 hover:border-teal-400/70 hover:bg-teal-500/[0.08] hover:text-teal-500 dark:text-teal-400'
|
||||
)}
|
||||
disabled={loading}
|
||||
onClick={handleUpdate}
|
||||
>
|
||||
Update to {fmt(row.recent_amount)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
disabled={loading}
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DriftInsightPanel({ driftBills, refresh }) {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
if (!driftBills?.length) return null;
|
||||
|
||||
const totalNetDelta = driftBills.reduce((sum, b) => sum + (b.recent_amount - b.expected_amount), 0);
|
||||
const sign = totalNetDelta >= 0 ? '+' : '';
|
||||
const netColor = totalNetDelta >= 0 ? 'text-amber-500 dark:text-amber-400' : 'text-teal-500 dark:text-teal-400';
|
||||
const hasIncrease = driftBills.some(b => b.direction === 'up');
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||
<div className="rounded-xl border border-amber-400/30 bg-amber-500/[0.06] shadow-sm overflow-hidden dark:bg-amber-400/[0.05]">
|
||||
|
||||
{/* Header */}
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="flex w-full items-center justify-between px-4 py-3 transition-colors hover:bg-amber-500/[0.04] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/40 focus-visible:ring-inset">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{hasIncrease
|
||||
? <TrendingUp className="h-4 w-4 shrink-0 text-amber-400" />
|
||||
: <TrendingDown className="h-4 w-4 shrink-0 text-teal-400" />
|
||||
}
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{driftBills.length === 1
|
||||
? '1 bill changed price'
|
||||
: `${driftBills.length} bills changed price`}
|
||||
</span>
|
||||
<span className={cn('font-mono text-sm', netColor)}>
|
||||
{sign}{fmt(Math.abs(totalNetDelta))}/mo net
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform duration-200',
|
||||
isOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{/* Bill rows */}
|
||||
<CollapsibleContent>
|
||||
<div className="divide-y divide-border/40 px-4 pb-2">
|
||||
{driftBills.map(row => (
|
||||
<DriftRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
refresh={refresh}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
|
@ -40,13 +40,4 @@ export function useOverdueCount() {
|
|||
refetchInterval: 1000 * 60 * 5, // poll every 5 minutes
|
||||
refetchIntervalInBackground: false, // only when tab is active
|
||||
});
|
||||
}
|
||||
// Drift / price-change report — refreshed on demand, not auto-polled
|
||||
export function useDriftReport() {
|
||||
return useQuery({
|
||||
queryKey: ['drift-report'],
|
||||
queryFn: () => api.driftReport(),
|
||||
staleTime: 1000 * 60 * 10,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,492 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -339,7 +339,6 @@ function NotificationPreferences({ settings, onSaved }) {
|
|||
notify_1_day: !!(form.notify_1_day ?? form.notify_1d),
|
||||
notify_due: !!(form.notify_due ?? form.notify_day_of),
|
||||
notify_overdue: !!(form.notify_overdue ?? form.notify_daily_overdue),
|
||||
notify_amount_change: !!(form.notify_amount_change ?? true),
|
||||
};
|
||||
payload.enabled = payload.notifications_enabled;
|
||||
payload.notify_3d = payload.notify_3_day;
|
||||
|
|
@ -373,7 +372,6 @@ function NotificationPreferences({ settings, onSaved }) {
|
|||
<CheckRow id="n-1" label="Notify 1 day before" checked={payload.notify_1_day} onChange={v => set('notify_1_day', v)} disabled={!payload.notifications_enabled} />
|
||||
<CheckRow id="n-due" label="Notify due date" checked={payload.notify_due} onChange={v => set('notify_due', v)} disabled={!payload.notifications_enabled} />
|
||||
<CheckRow id="n-overdue" label="Notify overdue" checked={payload.notify_overdue} onChange={v => set('notify_overdue', v)} disabled={!payload.notifications_enabled} />
|
||||
<CheckRow id="n-amount" label="Notify on price changes" checked={payload.notify_amount_change} onChange={v => set('notify_amount_change', v)} disabled={!payload.notifications_enabled} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-border/50 flex justify-end">
|
||||
|
|
|
|||
|
|
@ -215,7 +215,6 @@ export default function SettingsPage() {
|
|||
currency: 'USD',
|
||||
date_format: 'MM/DD/YYYY',
|
||||
grace_period_days: 3,
|
||||
drift_threshold_pct: '5',
|
||||
};
|
||||
|
||||
const [settings, setSettings] = useState(DEFAULTS);
|
||||
|
|
@ -243,7 +242,6 @@ export default function SettingsPage() {
|
|||
currency: settings.currency,
|
||||
date_format: settings.date_format,
|
||||
grace_period_days: settings.grace_period_days,
|
||||
drift_threshold_pct: settings.drift_threshold_pct,
|
||||
});
|
||||
toast.success('Settings saved.');
|
||||
} catch (err) {
|
||||
|
|
@ -339,23 +337,6 @@ export default function SettingsPage() {
|
|||
<span className="text-sm text-muted-foreground">days</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Price change sensitivity"
|
||||
description="Flag a bill when recent payments differ from the expected amount by at least this percentage."
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={25}
|
||||
step={1}
|
||||
value={settings.drift_threshold_pct ?? '5'}
|
||||
onChange={(e) => set('drift_threshold_pct', e.target.value)}
|
||||
className="w-20 font-mono"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">%</span>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useSearchParams } from 'react-router-dom';
|
|||
import { ChevronLeft, ChevronRight, Pencil, TrendingUp, AlertCircle, Clock, CheckCircle2, Settings2, Loader2, Plus, Search, X, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api.js';
|
||||
import { useTracker, useDriftReport } from '@/hooks/useQueries';
|
||||
import { useTracker } from '@/hooks/useQueries';
|
||||
import BillModal from '@/components/BillModal';
|
||||
import { makeBillDraft } from '@/lib/billDrafts';
|
||||
import { cn, fmt, fmtDate, todayStr } from '@/lib/utils';
|
||||
|
|
@ -29,7 +29,6 @@ import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
|
|||
import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog';
|
||||
import PaymentModal from '@/components/tracker/PaymentModal';
|
||||
import OverdueCommandCenter from '@/components/tracker/OverdueCommandCenter';
|
||||
import DriftInsightPanel from '@/components/tracker/DriftInsightPanel';
|
||||
const MONTHS = [
|
||||
'January','February','March','April','May','June',
|
||||
'July','August','September','October','November','December',
|
||||
|
|
@ -1831,7 +1830,6 @@ export default function TrackerPage() {
|
|||
|
||||
// Use React Query for data fetching
|
||||
const { data, isLoading: loading, isError, error, refetch } = useTracker(year, month);
|
||||
const { data: driftData, refetch: refetchDrift } = useDriftReport();
|
||||
|
||||
useEffect(() => {
|
||||
const querySearch = searchParams.get('search') || '';
|
||||
|
|
@ -2070,14 +2068,6 @@ export default function TrackerPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* ── Drift / Price-Change Insights ── */}
|
||||
{!isError && !loading && (driftData?.bills?.length ?? 0) > 0 && (
|
||||
<DriftInsightPanel
|
||||
driftBills={driftData.bills}
|
||||
refresh={() => { refetch(); refetchDrift(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Fetch error state ── */}
|
||||
{isError && (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center rounded-xl border border-destructive/20 bg-destructive/5">
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ const DEFAULT_CATEGORIES = [
|
|||
const COLUMN_WHITELIST = new Set([
|
||||
// users table columns
|
||||
'active', 'is_default_admin', 'notification_email', 'notifications_enabled',
|
||||
'notify_3d', 'notify_1d', 'notify_due', 'notify_overdue', 'notify_amount_change',
|
||||
'notify_3d', 'notify_1d', 'notify_due', 'notify_overdue',
|
||||
'display_name', 'last_password_change_at', 'auth_provider', 'external_subject',
|
||||
'email', 'last_login_at',
|
||||
// payments table columns
|
||||
|
|
@ -49,7 +49,7 @@ const COLUMN_WHITELIST = new Set([
|
|||
'history_visibility', 'interest_rate', 'user_id',
|
||||
'current_balance', 'minimum_payment', 'snowball_order', 'snowball_include',
|
||||
'snowball_exempt', 'is_subscription', 'subscription_type', 'reminder_days_before',
|
||||
'subscription_source', 'subscription_detected_at', 'deleted_at', 'drift_snoozed_until',
|
||||
'subscription_source', 'subscription_detected_at', 'deleted_at',
|
||||
// sessions table columns
|
||||
'created_at',
|
||||
// financial_accounts table columns
|
||||
|
|
@ -2425,15 +2425,6 @@ function runMigrations() {
|
|||
run: function() {
|
||||
db.exec('ALTER TABLE monthly_bill_state ADD COLUMN snoozed_until TEXT');
|
||||
}
|
||||
},
|
||||
{
|
||||
version: 'v0.71',
|
||||
description: 'bills: add drift_snoozed_until; users: add notify_amount_change',
|
||||
dependsOn: ['v0.70'],
|
||||
run: function() {
|
||||
db.exec('ALTER TABLE bills ADD COLUMN drift_snoozed_until TEXT');
|
||||
db.exec('ALTER TABLE users ADD COLUMN notify_amount_change INTEGER NOT NULL DEFAULT 1');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ CREATE TABLE IF NOT EXISTS bills (
|
|||
subscription_detected_at TEXT,
|
||||
deleted_at TEXT,
|
||||
notes TEXT,
|
||||
drift_snoozed_until TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
|
@ -77,7 +76,6 @@ CREATE TABLE IF NOT EXISTS users (
|
|||
must_change_password INTEGER NOT NULL DEFAULT 0,
|
||||
first_login INTEGER NOT NULL DEFAULT 1,
|
||||
snowball_extra_payment REAL NOT NULL DEFAULT 0,
|
||||
notify_amount_change INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.34.2",
|
||||
"version": "0.34.0",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -44,31 +44,6 @@ router.get('/audit', (req, res) => {
|
|||
res.json(auditBillsForUser(db, req.user.id, includeInactive));
|
||||
});
|
||||
|
||||
// ── GET /api/bills/drift-report ──────────────────────────────────────────────
|
||||
router.get('/drift-report', (req, res) => {
|
||||
const { getDriftReport } = require('../services/driftService');
|
||||
try {
|
||||
res.json(getDriftReport(req.user.id));
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to compute drift report' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/bills/:id/snooze-drift ─────────────────────────────────────────
|
||||
// Registered early (before /:id) but path has suffix so no conflict
|
||||
router.post('/:id/snooze-drift', (req, res) => {
|
||||
const db = getDb();
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' });
|
||||
const bill = db.prepare('SELECT id, user_id FROM bills WHERE id = ? AND deleted_at IS NULL').get(id);
|
||||
if (!bill || bill.user_id !== req.user.id) return res.status(404).json({ error: 'Not found' });
|
||||
const until = new Date();
|
||||
until.setDate(until.getDate() + 30);
|
||||
const untilStr = until.toISOString().slice(0, 10);
|
||||
db.prepare('UPDATE bills SET drift_snoozed_until = ? WHERE id = ?').run(untilStr, id);
|
||||
res.json({ ok: true, drift_snoozed_until: untilStr });
|
||||
});
|
||||
|
||||
// ── GET /api/bills/templates ─────────────────────────────────────────────────
|
||||
router.get('/templates', (req, res) => {
|
||||
const db = getDb();
|
||||
|
|
|
|||
|
|
@ -58,20 +58,19 @@ router.get('/me', requireAuth, requireUser, (req, res) => {
|
|||
const db = getDb();
|
||||
const user = db.prepare(`
|
||||
SELECT notification_email, notifications_enabled,
|
||||
notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change
|
||||
notify_3d, notify_1d, notify_due, notify_overdue
|
||||
FROM users WHERE id = ?
|
||||
`).get(req.user.id);
|
||||
|
||||
res.json({
|
||||
smtp_enabled: getSetting('notify_smtp_enabled') === 'true',
|
||||
allow_user_config: getSetting('notify_allow_user_config') === 'true',
|
||||
notification_email: user.notification_email || '',
|
||||
smtp_enabled: getSetting('notify_smtp_enabled') === 'true',
|
||||
allow_user_config: getSetting('notify_allow_user_config') === 'true',
|
||||
notification_email: user.notification_email || '',
|
||||
notifications_enabled: !!user.notifications_enabled,
|
||||
notify_3d: !!user.notify_3d,
|
||||
notify_1d: !!user.notify_1d,
|
||||
notify_due: !!user.notify_due,
|
||||
notify_overdue: !!user.notify_overdue,
|
||||
notify_amount_change: user.notify_amount_change !== 0,
|
||||
notify_3d: !!user.notify_3d,
|
||||
notify_1d: !!user.notify_1d,
|
||||
notify_due: !!user.notify_due,
|
||||
notify_overdue: !!user.notify_overdue,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -80,7 +79,7 @@ router.put('/me', requireAuth, requireUser, (req, res) => {
|
|||
const db = getDb();
|
||||
const {
|
||||
notification_email, notifications_enabled,
|
||||
notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change,
|
||||
notify_3d, notify_1d, notify_due, notify_overdue,
|
||||
} = req.body;
|
||||
|
||||
db.prepare(`
|
||||
|
|
@ -91,17 +90,15 @@ router.put('/me', requireAuth, requireUser, (req, res) => {
|
|||
notify_1d = ?,
|
||||
notify_due = ?,
|
||||
notify_overdue = ?,
|
||||
notify_amount_change = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
notification_email || null,
|
||||
notifications_enabled ? 1 : 0,
|
||||
notify_3d !== false ? 1 : 0,
|
||||
notify_1d !== false ? 1 : 0,
|
||||
notify_due !== false ? 1 : 0,
|
||||
notification_email || null,
|
||||
notifications_enabled ? 1 : 0,
|
||||
notify_3d !== false ? 1 : 0,
|
||||
notify_1d !== false ? 1 : 0,
|
||||
notify_due !== false ? 1 : 0,
|
||||
notify_overdue !== false ? 1 : 0,
|
||||
notify_amount_change !== false ? 1 : 0,
|
||||
req.user.id,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ router.get('/settings', (req, res) => {
|
|||
const db = getDb();
|
||||
const user = db.prepare(`
|
||||
SELECT notification_email, notifications_enabled,
|
||||
notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change
|
||||
notify_3d, notify_1d, notify_due, notify_overdue
|
||||
FROM users WHERE id = ?
|
||||
`).get(req.user.id);
|
||||
|
||||
|
|
@ -144,7 +144,6 @@ router.get('/settings', (req, res) => {
|
|||
notify_1d: !!user.notify_1d,
|
||||
notify_due: !!user.notify_due,
|
||||
notify_overdue: !!user.notify_overdue,
|
||||
notify_amount_change: user.notify_amount_change !== 0,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -155,7 +154,7 @@ router.patch('/settings', (req, res) => {
|
|||
const db = getDb();
|
||||
const {
|
||||
notification_email, email, notifications_enabled,
|
||||
notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change,
|
||||
notify_3d, notify_1d, notify_due, notify_overdue,
|
||||
} = req.body;
|
||||
|
||||
const nextEmail = notification_email !== undefined ? notification_email : email;
|
||||
|
|
@ -171,7 +170,7 @@ router.patch('/settings', (req, res) => {
|
|||
|
||||
const current = db.prepare(`
|
||||
SELECT notification_email, notifications_enabled,
|
||||
notify_3d, notify_1d, notify_due, notify_overdue, notify_amount_change
|
||||
notify_3d, notify_1d, notify_due, notify_overdue
|
||||
FROM users WHERE id = ?
|
||||
`).get(req.user.id);
|
||||
|
||||
|
|
@ -190,17 +189,15 @@ router.patch('/settings', (req, res) => {
|
|||
notify_1d = ?,
|
||||
notify_due = ?,
|
||||
notify_overdue = ?,
|
||||
notify_amount_change = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
emailVal,
|
||||
boolVal(notifications_enabled, current.notifications_enabled),
|
||||
boolVal(notify_3d, current.notify_3d),
|
||||
boolVal(notify_1d, current.notify_1d),
|
||||
boolVal(notify_due, current.notify_due),
|
||||
boolVal(notify_overdue, current.notify_overdue),
|
||||
boolVal(notify_amount_change, current.notify_amount_change),
|
||||
boolVal(notifications_enabled, current.notifications_enabled),
|
||||
boolVal(notify_3d, current.notify_3d),
|
||||
boolVal(notify_1d, current.notify_1d),
|
||||
boolVal(notify_due, current.notify_due),
|
||||
boolVal(notify_overdue, current.notify_overdue),
|
||||
req.user.id,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,106 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
const { getDb } = require('../db/database');
|
||||
const { getCycleRange } = require('./statusService');
|
||||
const { getUserSettings } = require('./userSettings');
|
||||
|
||||
const MONTHS_BACK = 3;
|
||||
const MIN_PAID_MONTHS = 2;
|
||||
const MIN_ABS_DELTA = 1.00;
|
||||
|
||||
function median(arr) {
|
||||
if (!arr.length) return 0;
|
||||
const sorted = [...arr].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 !== 0
|
||||
? sorted[mid]
|
||||
: (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
function monthEnd(year, month) {
|
||||
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
}
|
||||
|
||||
function getDriftReport(userId, now = new Date()) {
|
||||
try {
|
||||
const db = getDb();
|
||||
const settings = getUserSettings(userId);
|
||||
const thresholdPct = Math.max(1, Math.min(25,
|
||||
parseFloat(settings.drift_threshold_pct ?? '5') || 5
|
||||
));
|
||||
|
||||
const bills = db.prepare(`
|
||||
SELECT b.*, c.name AS category_name
|
||||
FROM bills b
|
||||
LEFT JOIN categories c ON c.id = b.category_id
|
||||
WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL
|
||||
`).all(userId);
|
||||
|
||||
const todayStr = now.toISOString().slice(0, 10);
|
||||
const drifted = [];
|
||||
|
||||
const mbsStmt = db.prepare(
|
||||
'SELECT is_skipped FROM monthly_bill_state WHERE bill_id = ? AND year = ? AND month = ?'
|
||||
);
|
||||
const payStmt = db.prepare(`
|
||||
SELECT COALESCE(SUM(amount), 0) AS total
|
||||
FROM payments
|
||||
WHERE bill_id = ? AND paid_date BETWEEN ? AND ? AND deleted_at IS NULL
|
||||
`);
|
||||
|
||||
for (const bill of bills) {
|
||||
if (!bill.expected_amount || bill.expected_amount <= 0) continue;
|
||||
if (bill.drift_snoozed_until && bill.drift_snoozed_until > todayStr) continue;
|
||||
|
||||
const monthTotals = [];
|
||||
|
||||
for (let i = 1; i <= MONTHS_BACK; i++) {
|
||||
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - i, 1));
|
||||
const yr = d.getUTCFullYear();
|
||||
const mo = d.getUTCMonth() + 1;
|
||||
|
||||
// Skip if bill was created after this month ended
|
||||
const monthEndStr = `${yr}-${String(mo).padStart(2,'0')}-${String(monthEnd(yr, mo)).padStart(2,'0')}`;
|
||||
if (bill.created_at && bill.created_at.slice(0, 10) > monthEndStr) continue;
|
||||
|
||||
const mbs = mbsStmt.get(bill.id, yr, mo);
|
||||
if (mbs?.is_skipped) continue;
|
||||
|
||||
const range = getCycleRange(yr, mo, bill);
|
||||
if (!range) continue;
|
||||
|
||||
const { total } = payStmt.get(bill.id, range.start, range.end);
|
||||
if (total > 0) monthTotals.push(total);
|
||||
}
|
||||
|
||||
if (monthTotals.length < MIN_PAID_MONTHS) continue;
|
||||
|
||||
const recentAmount = median(monthTotals);
|
||||
const delta = recentAmount - bill.expected_amount;
|
||||
const absDelta = Math.abs(delta);
|
||||
const driftPct = (delta / bill.expected_amount) * 100;
|
||||
|
||||
if (absDelta < MIN_ABS_DELTA) continue;
|
||||
if (Math.abs(driftPct) < thresholdPct) continue;
|
||||
|
||||
drifted.push({
|
||||
id: bill.id,
|
||||
name: bill.name,
|
||||
category_name: bill.category_name ?? null,
|
||||
expected_amount: bill.expected_amount,
|
||||
recent_amount: Math.round(recentAmount * 100) / 100,
|
||||
drift_pct: Math.round(driftPct * 10) / 10,
|
||||
direction: delta > 0 ? 'up' : 'down',
|
||||
months_sampled: monthTotals.length,
|
||||
drift_snoozed_until: bill.drift_snoozed_until ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return { bills: drifted, threshold_pct: thresholdPct };
|
||||
} catch (err) {
|
||||
console.error('[driftService] getDriftReport error:', err.message);
|
||||
return { bills: [], threshold_pct: 5, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getDriftReport };
|
||||
|
|
@ -272,123 +272,4 @@ async function runNotifications() {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Drift / price-change digest email ────────────────────────────────────────
|
||||
|
||||
function fmtAmt(n) {
|
||||
return '$' + Number(n || 0).toFixed(2);
|
||||
}
|
||||
|
||||
function buildDriftDigestHtml(bills) {
|
||||
const color = '#d97706'; // amber-600
|
||||
|
||||
const rows = bills.map(b => {
|
||||
const sign = b.direction === 'up' ? '+' : '';
|
||||
const arrow = b.direction === 'up' ? '▲' : '▼';
|
||||
const arrowColor = b.direction === 'up' ? '#d97706' : '#0d9488';
|
||||
return `
|
||||
<tr>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #f3f4f6;font-size:14px;">${esc(b.name)}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #f3f4f6;font-size:14px;font-family:monospace;text-decoration:line-through;color:#9ca3af;">${fmtAmt(b.expected_amount)}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #f3f4f6;font-size:14px;font-family:monospace;font-weight:600;">${fmtAmt(b.recent_amount)}</td>
|
||||
<td style="padding:10px 12px;border-bottom:1px solid #f3f4f6;font-size:13px;color:${arrowColor};font-weight:700;">${arrow} ${sign}${b.drift_pct}%</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;background:#f5f6fa;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="padding:32px 16px;">
|
||||
<tr><td align="center">
|
||||
<table width="540" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:8px;border:1px solid #e2e4eb;overflow:hidden;">
|
||||
<tr>
|
||||
<td style="background:${color};padding:16px 24px;">
|
||||
<p style="margin:0;color:#fff;font-size:11px;text-transform:uppercase;letter-spacing:1px;font-weight:600;">Bill Tracker</p>
|
||||
<h1 style="margin:4px 0 0;color:#fff;font-size:20px;font-weight:700;">Price Changes Detected</h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:20px 24px 8px;">
|
||||
<p style="margin:0;color:#374151;font-size:14px;">
|
||||
The following bills have been paid at amounts different from their expected amounts for the past ${bills[0]?.months_sampled ?? 2}+ months.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 24px 20px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e5e7eb;border-radius:6px;overflow:hidden;">
|
||||
<thead>
|
||||
<tr style="background:#fef9c3;">
|
||||
<th style="padding:8px 12px;text-align:left;font-size:12px;color:#92400e;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;">Bill</th>
|
||||
<th style="padding:8px 12px;text-align:left;font-size:12px;color:#92400e;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;">Was</th>
|
||||
<th style="padding:8px 12px;text-align:left;font-size:12px;color:#92400e;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;">Now ~</th>
|
||||
<th style="padding:8px 12px;text-align:left;font-size:12px;color:#92400e;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;">Change</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:0 24px 24px;">
|
||||
<p style="margin:0;color:#6b7280;font-size:13px;">
|
||||
You can update the expected amounts or dismiss these alerts in Bill Tracker.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async function runDriftNotifications() {
|
||||
if (getSetting('notify_smtp_enabled') !== 'true') return;
|
||||
if (!getSetting('notify_smtp_host')) return;
|
||||
if (!getSetting('notify_sender_address')) return;
|
||||
|
||||
const db = getDb();
|
||||
const { getDriftReport } = require('./driftService');
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
const today = now.toISOString().slice(0, 10);
|
||||
|
||||
const allowUserConfig = getSetting('notify_allow_user_config') === 'true';
|
||||
const globalRecipient = getSetting('notify_global_recipient');
|
||||
|
||||
const recipients = [];
|
||||
|
||||
if (allowUserConfig) {
|
||||
const users = db.prepare(
|
||||
"SELECT * FROM users WHERE active=1 AND role='user' AND notifications_enabled=1 AND notify_amount_change=1 AND notification_email IS NOT NULL AND notification_email != ''"
|
||||
).all();
|
||||
recipients.push(...users);
|
||||
} else if (globalRecipient) {
|
||||
recipients.push({ id: 0, notification_email: globalRecipient, notify_amount_change: 1 });
|
||||
}
|
||||
|
||||
for (const recipient of recipients) {
|
||||
try {
|
||||
const report = getDriftReport(recipient.id, now);
|
||||
const newBills = (report.bills || []).filter(b =>
|
||||
!hasNotification(db, b.id, recipient.id, year, month, 'amount_change', today)
|
||||
);
|
||||
if (!newBills.length) continue;
|
||||
|
||||
await sendEmail(
|
||||
recipient.notification_email,
|
||||
'Price Change Alert: Your bill amounts have changed',
|
||||
buildDriftDigestHtml(newBills)
|
||||
);
|
||||
|
||||
for (const b of newBills) {
|
||||
recordNotification(db, b.id, recipient.id, year, month, 'amount_change', today);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[drift notifications] Error for recipient', recipient.notification_email, ':', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { runNotifications, runDriftNotifications, sendTestEmail, createTransport };
|
||||
module.exports = { runNotifications, sendTestEmail, createTransport };
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ const USER_SETTING_KEYS = [
|
|||
'date_format',
|
||||
'grace_period_days',
|
||||
'notify_days_before',
|
||||
'drift_threshold_pct',
|
||||
];
|
||||
|
||||
function defaultUserSettings() {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ const cron = require('node-cron');
|
|||
const { getDb, getSetting } = require('../db/database');
|
||||
const { buildTrackerRow, getCycleRange } = require('../services/statusService');
|
||||
const { pruneExpiredSessions } = require('../services/authService');
|
||||
const { runNotifications, runDriftNotifications } = require('../services/notificationService');
|
||||
const { runNotifications } = require('../services/notificationService');
|
||||
const { runAllCleanup } = require('../services/cleanupService');
|
||||
const {
|
||||
markWorkerError,
|
||||
|
|
@ -49,9 +49,6 @@ async function runDailyTasks() {
|
|||
|
||||
pruneExpiredSessions();
|
||||
await runNotifications();
|
||||
await runDriftNotifications().catch(err => {
|
||||
console.error('[worker] Drift notification error (non-fatal):', err.message);
|
||||
});
|
||||
|
||||
// Run scheduled cleanup tasks (expired import sessions, stale temp files, etc.)
|
||||
await runAllCleanup().catch(err => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue