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();