diff --git a/client/api.js b/client/api.js index 54be6c9..358ffa9 100644 --- a/client/api.js +++ b/client/api.js @@ -144,6 +144,7 @@ export const api = { updateBill: (id, data) => put(`/bills/${id}`, data), updateBillBalance: (id, bal) => _fetch('PATCH', `/bills/${id}/balance`, { current_balance: bal }), updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data), + updateBillSnowball: (id, data) => _fetch('PATCH', `/bills/${id}/snowball`, data), deleteBill: (id) => del(`/bills/${id}`), togglePaid: (id, data) => post(`/bills/${id}/toggle-paid`, data), billPayments: (id, p, l) => get(`/bills/${id}/payments?page=${p||1}&limit=${l||20}`), diff --git a/client/pages/SnowballPage.jsx b/client/pages/SnowballPage.jsx index 659759a..c63e6f6 100644 --- a/client/pages/SnowballPage.jsx +++ b/client/pages/SnowballPage.jsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, X } from 'lucide-react'; +import { GripVertical, TrendingDown, Zap, Save, CalendarCheck, AlertTriangle, PenLine, EyeOff } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@/api'; import { Button } from '@/components/ui/button'; @@ -470,7 +470,12 @@ export default function SnowballPage() { const isAttack = index === 0; const isEditingBal = editingBalance.billId === bill.id; const isDragging = draggingIdx !== null; - const isTarget = draggingIdx === index; // where it will land + const isTarget = draggingIdx === index; + + // Pull this debt's payoff info from the snowball projection (attack card only) + const attackProjection = isAttack + ? projection?.snowball?.debts?.[0] + : null; return (
- {/* Grip handle — pointer-capture trigger */} + {/* Grip */}
onPointerDown(e, index)} - className="flex items-center px-3 text-muted-foreground/30 hover:text-muted-foreground/70 cursor-grab active:cursor-grabbing transition-colors touch-none" + className="flex items-center px-3 text-muted-foreground/20 hover:text-muted-foreground/60 cursor-grab active:cursor-grabbing transition-colors touch-none shrink-0" aria-label="Drag to reorder" >
- {/* Body */} -
- {/* Top row */} -
- - #{index + 1} - - {isAttack && ( - - Attack + {/* Main content */} +
+ + {/* Name row */} +
+ {isAttack ? ( + + Now + + ) : ( + + #{index + 1} )} - {bill.name} + {bill.name} {bill.category_name && ( - + {bill.category_name} )} - {bill.snowball_include === 1 && !bill.category_name && ( - - manual - - )} - -
{/* Stats row */} -
+
{/* Balance — inline editable */}
@@ -559,38 +545,46 @@ export default function SnowballPage() { )}
-
- Min/mo - - {bill.minimum_payment != null ? fmt(bill.minimum_payment) : '—'} - -
+ {bill.minimum_payment != null && ( +
+ Min + {fmt(bill.minimum_payment)} +
+ )} {isAttack && extraAmt > 0 && (
- Attack - + Throwing + {fmt((bill.minimum_payment || 0) + extraAmt)} + /mo
)} {bill.interest_rate != null && (
APR - {bill.interest_rate}% + = 25 ? 'text-rose-400' : + bill.interest_rate >= 15 ? 'text-amber-400' : + 'text-muted-foreground', + )}> + {bill.interest_rate}% +
)} @@ -599,7 +593,38 @@ export default function SnowballPage() { {ordinal(bill.due_day)}
+ + {/* Attack card payoff line — from projection */} + {isAttack && attackProjection?.payoff_display && ( +
+ ↳ Clears {attackProjection.payoff_display} + {attackProjection.total_interest > 0 && ( + · {fmtCompact(attackProjection.total_interest)} interest + )} +
+ )}
+ + {/* Action icons — fixed right column */} +
+ + +
+
); diff --git a/routes/bills.js b/routes/bills.js index fab3a7c..1ce1878 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -499,6 +499,25 @@ router.delete('/:id/history-ranges/:rangeId', (req, res) => { res.json({ success: true }); }); +// ── PATCH /api/bills/:id/snowball — update only snowball_include / snowball_exempt ── +router.patch('/:id/snowball', (req, res) => { + const db = getDb(); + const billId = parseInt(req.params.id, 10); + if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id)) { + return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); + } + const include = req.body.snowball_include !== undefined ? (req.body.snowball_include ? 1 : 0) : undefined; + const exempt = req.body.snowball_exempt !== undefined ? (req.body.snowball_exempt ? 1 : 0) : undefined; + const parts = []; + const vals = []; + if (include !== undefined) { parts.push('snowball_include = ?'); vals.push(include); } + if (exempt !== undefined) { parts.push('snowball_exempt = ?'); vals.push(exempt); } + if (parts.length === 0) return res.status(400).json(standardizeError('Nothing to update', 'VALIDATION_ERROR')); + parts.push("updated_at = datetime('now')"); + db.prepare(`UPDATE bills SET ${parts.join(', ')} WHERE id = ? AND user_id = ?`).run(...vals, billId, req.user.id); + res.json({ id: billId, snowball_include: include, snowball_exempt: exempt }); +}); + // ── PATCH /api/bills/:id/balance — lightweight balance-only update ──────────── router.patch('/:id/balance', (req, res) => { const db = getDb();