snowball visuals

This commit is contained in:
null 2026-05-14 19:33:23 -05:00
parent 7aff0d0283
commit eea5641126
3 changed files with 98 additions and 53 deletions

View File

@ -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}`),

View File

@ -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 (
<div
@ -479,65 +484,46 @@ export default function SnowballPage() {
data-card-index={index}
className={cn(
'surface-elevated rounded-xl border transition-all duration-100 select-none touch-none',
isAttack ? 'border-emerald-500/40' : 'border-border/40',
isAttack ? 'border-emerald-500/30 bg-emerald-950/5' : 'border-border/40',
isTarget && isDragging && 'ring-2 ring-primary/50 scale-[0.99]',
)}
>
<div className="flex items-stretch">
{/* Grip handle — pointer-capture trigger */}
{/* Grip */}
<div
data-grip
onPointerDown={e => 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"
>
<GripVertical className="h-5 w-5" />
</div>
{/* Body */}
<div className="flex-1 py-3.5 pr-4 min-w-0">
{/* Top row */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-xs font-bold text-muted-foreground tabular-nums w-5 shrink-0">
{/* Main content */}
<div className="flex-1 py-3 min-w-0">
{/* Name row */}
<div className="flex items-center gap-2 min-w-0">
{isAttack ? (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-emerald-400 shrink-0">
<Zap className="h-2.5 w-2.5" /> Now
</span>
) : (
<span className="text-xs font-semibold text-muted-foreground/50 tabular-nums shrink-0 w-5">
#{index + 1}
</span>
{isAttack && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-400 shrink-0">
<Zap className="h-2.5 w-2.5" /> Attack
</span>
)}
<span className="font-semibold truncate">{bill.name}</span>
<span className="font-semibold text-sm truncate">{bill.name}</span>
{bill.category_name && (
<span className="text-[10px] text-muted-foreground border border-border/50 rounded px-1.5 py-0.5 shrink-0">
<span className="text-[10px] text-muted-foreground/60 shrink-0 hidden sm:inline">
{bill.category_name}
</span>
)}
{bill.snowball_include === 1 && !bill.category_name && (
<span className="text-[10px] text-violet-400 border border-violet-500/30 rounded px-1.5 py-0.5 shrink-0">
manual
</span>
)}
<button
type="button"
onClick={() => setEditBill(bill)}
className="ml-auto text-xs text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
Edit
</button>
<button
type="button"
onClick={() => removeFromSnowball(bill)}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-amber-400 transition-colors shrink-0"
title="Remove from Snowball"
>
<X className="h-3.5 w-3.5" />
Remove
</button>
</div>
{/* Stats row */}
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-1.5 text-sm items-center">
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-sm items-center">
{/* Balance — inline editable */}
<div className="flex items-center gap-1.5">
@ -559,38 +545,46 @@ export default function SnowballPage() {
<button
type="button"
onClick={() => startEditBalance(bill)}
title="Click to update balance"
className={cn(
'font-semibold tabular-nums rounded px-1 -mx-1 hover:bg-muted/60 transition-colors',
isAttack && bill.current_balance != null ? 'text-emerald-400' : '',
bill.current_balance == null && 'text-muted-foreground/60 italic text-xs',
isAttack && bill.current_balance != null && 'text-emerald-400',
bill.current_balance == null && 'text-muted-foreground/50 italic text-xs',
)}
title="Click to update balance"
>
{bill.current_balance != null ? fmt(bill.current_balance) : 'enter balance'}
{bill.current_balance != null ? fmt(bill.current_balance) : 'add balance'}
</button>
)}
</div>
{bill.minimum_payment != null && (
<div>
<span className="text-xs text-muted-foreground">Min/mo </span>
<span className="font-medium tabular-nums">
{bill.minimum_payment != null ? fmt(bill.minimum_payment) : '—'}
</span>
<span className="text-xs text-muted-foreground">Min </span>
<span className="font-medium tabular-nums">{fmt(bill.minimum_payment)}</span>
</div>
)}
{isAttack && extraAmt > 0 && (
<div>
<span className="text-xs text-muted-foreground">Attack </span>
<span className="font-medium tabular-nums text-emerald-400">
<span className="text-xs text-muted-foreground">Throwing </span>
<span className="font-semibold tabular-nums text-emerald-400">
{fmt((bill.minimum_payment || 0) + extraAmt)}
</span>
<span className="text-xs text-muted-foreground"> /mo</span>
</div>
)}
{bill.interest_rate != null && (
<div>
<span className="text-xs text-muted-foreground">APR </span>
<span className="font-medium tabular-nums">{bill.interest_rate}%</span>
<span className={cn(
'font-medium tabular-nums',
bill.interest_rate >= 25 ? 'text-rose-400' :
bill.interest_rate >= 15 ? 'text-amber-400' :
'text-muted-foreground',
)}>
{bill.interest_rate}%
</span>
</div>
)}
@ -599,7 +593,38 @@ export default function SnowballPage() {
<span className="font-medium">{ordinal(bill.due_day)}</span>
</div>
</div>
{/* Attack card payoff line — from projection */}
{isAttack && attackProjection?.payoff_display && (
<div className="mt-1.5 flex items-center gap-1.5 text-xs text-emerald-400/80">
<span className="font-medium"> Clears {attackProjection.payoff_display}</span>
{attackProjection.total_interest > 0 && (
<span className="text-muted-foreground">· {fmtCompact(attackProjection.total_interest)} interest</span>
)}
</div>
)}
</div>
{/* Action icons — fixed right column */}
<div className="flex flex-col items-center justify-center gap-1 px-3 shrink-0">
<button
type="button"
onClick={() => setEditBill(bill)}
title="Edit bill"
className="p-1.5 rounded-md text-muted-foreground/40 hover:text-foreground hover:bg-muted/60 transition-colors"
>
<PenLine className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => removeFromSnowball(bill)}
title="Hide from Snowball"
className="p-1.5 rounded-md text-muted-foreground/40 hover:text-amber-400 hover:bg-amber-500/10 transition-colors"
>
<EyeOff className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
);

View File

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