feat(tracker): overdue command center with snooze/skip/pay + sidebar badge

- migration v0.70 adds snoozed_until TEXT to monthly_bill_state
- trackerService: snoozed_until in monthly state fetch + getOverdueCount()
- GET /api/tracker/overdue-count endpoint
- PUT /bills/:id/monthly-state validates snoozed_until
- OverdueCommandCenter component: collapsible, per-bill actions, hides snoozed
- useOverdueCount hook (2-min stale, 5-min poll, tab-only)
- Sidebar/nav uses overdue count badge on Tracker menu item
- Bump v0.33.8.7 → v0.34.0
This commit is contained in:
null 2026-05-30 13:19:09 -05:00
parent db5f765d84
commit 3978507572
13 changed files with 374 additions and 16 deletions

View File

@ -137,6 +137,8 @@ export const api = {
// Tracker
tracker: (y, m, params = {}) => get(`/tracker${queryString({ year: y, month: m, ...params })}`),
upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`),
overdueCount: () => get('/tracker/overdue-count'),
snoozeOverdue: (id, data) => put(`/bills/${id}/monthly-state`, data),
// Calendar
calendar: (y, m) => get(`/calendar?year=${y}&month=${m}`),

View File

@ -2,7 +2,7 @@ import React, { useMemo } from 'react';
import { NavLink } from 'react-router-dom';
import { cn } from '@/lib/utils';
export const NavPill = React.memo(function NavPill({ item, onNavigate }) {
export const NavPill = React.memo(function NavPill({ item, onNavigate, badge }) {
const Icon = useMemo(() => item.icon, [item.icon]);
const to = useMemo(() => item.to, [item.to]);
const end = useMemo(() => item.end, [item.end]);
@ -23,6 +23,11 @@ export const NavPill = React.memo(function NavPill({ item, onNavigate }) {
>
<Icon className="h-4 w-4" />
<span>{label}</span>
{badge > 0 && (
<span className="ml-0.5 flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-rose-500 px-1 text-[10px] font-bold leading-none text-white">
{badge > 99 ? '99+' : badge}
</span>
)}
</NavLink>
);
});

View File

@ -7,6 +7,7 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth';
import { useOverdueCount } from '@/hooks/useQueries';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { Button } from '@/components/ui/button';
import {
@ -42,7 +43,7 @@ const trackerItems = [
{ to: '/snowball', icon: TrendingDown, label: 'Snowball' },
];
function TrackerMenu({ onNavigate }) {
function TrackerMenu({ onNavigate, badge }) {
const location = useLocation();
const navigate = useNavigate();
const isTrackerActive = useMemo(() => trackerItems.some(item => (
@ -65,6 +66,11 @@ function TrackerMenu({ onNavigate }) {
>
<LayoutGrid className="h-4 w-4" />
Tracker
{badge > 0 && (
<span className="flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-rose-500 px-1 text-[10px] font-bold leading-none text-white">
{badge > 99 ? '99+' : badge}
</span>
)}
<ChevronDown className="h-3.5 w-3.5 opacity-75" />
</button>
</DropdownMenuTrigger>
@ -169,6 +175,8 @@ export default function Sidebar({ adminMode = false }) {
const [mobileOpen, setMobileOpen] = useState(false);
const { user } = useAuth();
const items = useMemo(() => adminMode ? adminNavItems : userNavItems, [adminMode]);
const { data: overdueData } = useOverdueCount();
const overdueCount = (!adminMode && overdueData?.count) ? overdueData.count : 0;
return (
<header className="sticky top-0 z-40 border-b border-border/80 bg-card/95 shadow-sm shadow-foreground/10 backdrop-blur-md supports-[backdrop-filter]:bg-card/90">
@ -176,7 +184,7 @@ export default function Sidebar({ adminMode = false }) {
<BrandBlock adminMode={adminMode} />
<nav className="hidden items-center gap-1 lg:flex">
{!adminMode && <TrackerMenu />}
{!adminMode && <TrackerMenu badge={overdueCount} />}
{items.map(item => (
<NavPill key={item.to} item={item} />
))}
@ -218,7 +226,12 @@ export default function Sidebar({ adminMode = false }) {
<div className="max-h-[70vh] overflow-y-auto border-t border-border/70 bg-card/95 px-4 py-3 shadow-lg shadow-foreground/10 lg:hidden">
<nav className="mx-auto grid max-w-[1500px] gap-1">
{!adminMode && trackerItems.map(item => (
<NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} />
<NavPill
key={item.to}
item={item}
onNavigate={() => setMobileOpen(false)}
badge={item.to === '/' ? overdueCount : undefined}
/>
))}
{items.map(item => (
<NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} />

View File

@ -0,0 +1,228 @@
import React, { useState } from 'react';
import { AlertCircle, ChevronDown, BellOff, SkipForward, CreditCard } 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';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from '@/components/ui/dropdown-menu';
function snoozeUntil(days) {
const d = new Date();
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
function daysOverdueLabel(dueDate) {
if (!dueDate) return 'overdue';
const today = new Date();
today.setHours(0, 0, 0, 0);
const due = new Date(`${dueDate}T00:00:00`);
const days = Math.floor((today - due) / 86_400_000);
if (days <= 0) return 'due today';
return `${days} ${days === 1 ? 'day' : 'days'} overdue`;
}
function OverdueRow({ row, year, month, onPayNow, onRefresh }) {
const [loading, setLoading] = useState(false);
const threshold = row.actual_amount ?? row.expected_amount;
async function handleSkip() {
setLoading(true);
try {
await api.saveBillMonthlyState(row.id, {
year,
month,
is_skipped: true,
actual_amount: row.actual_amount,
notes: row.monthly_notes,
});
onRefresh();
} catch {
toast.error('Failed to skip bill');
} finally {
setLoading(false);
}
}
async function handleSnooze(days) {
setLoading(true);
try {
await api.snoozeOverdue(row.id, {
year,
month,
snoozed_until: snoozeUntil(days),
actual_amount: row.actual_amount,
notes: row.monthly_notes,
is_skipped: false,
});
toast.success(`Snoozed for ${days} ${days === 1 ? 'day' : 'days'}`);
onRefresh();
} catch {
toast.error('Failed to snooze bill');
} finally {
setLoading(false);
}
}
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>
<span className="text-xs text-rose-400 dark:text-rose-300">
{daysOverdueLabel(row.due_date)}
</span>
</div>
{/* Amount */}
<span className="shrink-0 font-mono text-sm font-semibold text-rose-400 dark:text-rose-300">
{fmt(threshold)}
</span>
{/* Actions */}
<div className="flex shrink-0 items-center gap-1">
<Button
size="sm"
variant="outline"
className="h-7 gap-1.5 border-rose-400/40 px-2.5 text-xs text-rose-500 hover:border-rose-400/70 hover:bg-rose-500/[0.08] hover:text-rose-400"
disabled={loading}
onClick={() => onPayNow(row)}
>
<CreditCard className="h-3 w-3" />
Pay Now
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2.5 text-xs text-muted-foreground hover:text-foreground"
disabled={loading}
onClick={handleSkip}
>
<SkipForward className="h-3 w-3 mr-1" />
Skip
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-7 px-2.5 text-xs text-muted-foreground hover:text-foreground"
disabled={loading}
>
<BellOff className="h-3 w-3 mr-1" />
Snooze
<ChevronDown className="ml-0.5 h-2.5 w-2.5 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[120px]">
<DropdownMenuItem onClick={() => handleSnooze(1)}>1 day</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSnooze(3)}>3 days</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSnooze(7)}>7 days</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
export default function OverdueCommandCenter({ rows, year, month, refresh, onPayNow }) {
const todayStr = new Date().toISOString().slice(0, 10);
const [isOpen, setIsOpen] = useState(true);
const overdueRows = rows.filter(r =>
(r.status === 'late' || r.status === 'missed') &&
!r.is_skipped &&
(!r.snoozed_until || r.snoozed_until <= todayStr)
);
const snoozedRows = rows.filter(r =>
(r.status === 'late' || r.status === 'missed') &&
!r.is_skipped &&
r.snoozed_until && r.snoozed_until > todayStr
);
if (overdueRows.length === 0 && snoozedRows.length === 0) return null;
const totalOverdue = overdueRows.reduce((sum, r) => {
const threshold = r.actual_amount ?? r.expected_amount;
const paid = r.total_paid ?? 0;
return sum + Math.max(0, threshold - paid);
}, 0);
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="rounded-xl border border-rose-400/30 bg-rose-500/[0.06] shadow-sm overflow-hidden dark:bg-rose-400/[0.05]">
{/* Header */}
<CollapsibleTrigger asChild>
<button className="flex w-full items-center justify-between px-4 py-3 transition-colors hover:bg-rose-500/[0.04] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40 focus-visible:ring-inset">
<div className="flex items-center gap-2.5">
<AlertCircle className="h-4 w-4 shrink-0 text-rose-400" />
<span className="text-sm font-semibold text-foreground">
{overdueRows.length === 0
? 'No active overdue bills'
: `${overdueRows.length} overdue ${overdueRows.length === 1 ? 'bill' : 'bills'}`}
</span>
{overdueRows.length > 0 && (
<span className="font-mono text-sm text-rose-400 dark:text-rose-300">
{fmt(totalOverdue)}
</span>
)}
</div>
<div className="flex items-center gap-3">
{snoozedRows.length > 0 && (
<span className="text-xs text-muted-foreground">
({snoozedRows.length} snoozed)
</span>
)}
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform duration-200',
isOpen && 'rotate-180'
)}
/>
</div>
</button>
</CollapsibleTrigger>
{/* Bill rows */}
<CollapsibleContent>
{overdueRows.length > 0 ? (
<div className="divide-y divide-border/40 px-4 pb-2">
{overdueRows.map(row => (
<OverdueRow
key={row.id}
row={row}
year={year}
month={month}
onPayNow={onPayNow}
onRefresh={refresh}
/>
))}
</div>
) : (
<p className="px-4 pb-3 text-xs text-muted-foreground">
All overdue bills are snoozed.
</p>
)}
</CollapsibleContent>
</div>
</Collapsible>
);
}

View File

@ -30,3 +30,14 @@ export function useCategories() {
cacheTime: 1000 * 60 * 60 * 2, // 2 hours
});
}
// Lightweight overdue count for sidebar badge — polls every 5 minutes
export function useOverdueCount() {
return useQuery({
queryKey: ['overdue-count'],
queryFn: () => api.overdueCount(),
staleTime: 1000 * 60 * 2, // 2 minutes
refetchInterval: 1000 * 60 * 5, // poll every 5 minutes
refetchIntervalInBackground: false, // only when tab is active
});
}

View File

@ -6,9 +6,13 @@ export const APP_NAME = 'BillTracker';
export const RELEASE_NOTES = {
version: APP_VERSION,
date: '2026-05-29',
version: APP_VERSION,
date: '2026-05-30',
highlights: [
{
icon: '🔔',
title: 'Overdue Command Center',
desc: 'Tracker page now shows a collapsible command center for overdue bills. Per-bill Pay Now, Skip, and Snooze (1/3/7 days). Snoozed bills are hidden with a count hint. Sidebar badge shows live overdue count, polled every 5 minutes.',
},
{
icon: '🧠',
title: 'Advisory non-bill transaction filters',

View File

@ -28,6 +28,7 @@ import { Label } from '@/components/ui/label';
import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog';
import PaymentModal from '@/components/tracker/PaymentModal';
import OverdueCommandCenter from '@/components/tracker/OverdueCommandCenter';
const MONTHS = [
'January','February','March','April','May','June',
'July','August','September','October','November','December',
@ -1824,6 +1825,9 @@ export default function TrackerPage() {
debt: false,
});
// Row to open in PaymentLedgerDialog via the overdue command center
const [commandCenterPayRow, setCommandCenterPayRow] = useState(null);
// Use React Query for data fetching
const { data, isLoading: loading, isError, error, refetch } = useTracker(year, month);
@ -2053,6 +2057,17 @@ export default function TrackerPage() {
</div>
)}
{/* ── Overdue Command Center ── */}
{!isError && !loading && (summary?.count_late ?? 0) > 0 && (
<OverdueCommandCenter
rows={rows}
year={year}
month={month}
refresh={refetch}
onPayNow={(row) => setCommandCenterPayRow(row)}
/>
)}
{/* ── 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">
@ -2152,6 +2167,19 @@ export default function TrackerPage() {
onSave={() => { setEditStartingOpen(false); refetch(); }}
/>
{/* PaymentLedgerDialog opened via Overdue Command Center "Pay Now" */}
{commandCenterPayRow && (
<PaymentLedgerDialog
row={commandCenterPayRow}
year={year}
month={month}
threshold={commandCenterPayRow.actual_amount ?? commandCenterPayRow.expected_amount}
defaultPaymentDate={paymentDateForTrackerMonth(year, month, commandCenterPayRow.due_day)}
onClose={() => setCommandCenterPayRow(null)}
onSaved={() => { setCommandCenterPayRow(null); refetch(); }}
/>
)}
</div>
);
}

View File

@ -2417,6 +2417,14 @@ function runMigrations() {
run: function() {
runSubscriptionCatalogV2Migration(db);
}
},
{
version: 'v0.70',
description: 'monthly_bill_state: add snoozed_until for overdue command center',
dependsOn: ['v0.69'],
run: function() {
db.exec('ALTER TABLE monthly_bill_state ADD COLUMN snoozed_until TEXT');
}
}
];

View File

@ -219,6 +219,7 @@ CREATE TABLE IF NOT EXISTS monthly_bill_state (
actual_amount REAL, -- NULL = use bill.expected_amount for this month
notes TEXT, -- month-specific notes, NULL = no notes
is_skipped INTEGER NOT NULL DEFAULT 0, -- 1 = hidden/removed for this month only
snoozed_until TEXT, -- ISO date: hide from overdue command center until this date
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(bill_id, year, month)

View File

@ -1,6 +1,6 @@
{
"name": "bill-tracker",
"version": "0.33.8.7",
"version": "0.34.0",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {

View File

@ -178,7 +178,7 @@ router.put('/:id/monthly-state', (req, res) => {
if (!db.prepare('SELECT id FROM bills WHERE id = ? AND user_id = ? AND deleted_at IS NULL').get(billId, req.user.id))
return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
const { year, month, actual_amount, notes, is_skipped } = req.body;
const { year, month, actual_amount, notes, is_skipped, snoozed_until } = req.body;
const y = parseInt(year, 10);
const m = parseInt(month, 10);
@ -193,19 +193,26 @@ router.put('/:id/monthly-state', (req, res) => {
return res.status(400).json(standardizeError('actual_amount must be a non-negative number or null', 'VALIDATION_ERROR', 'actual_amount'));
}
const amt = actual_amount !== undefined ? (actual_amount === null ? null : parseFloat(actual_amount)) : null;
const noteVal = notes !== undefined ? (notes || null) : null;
const skipVal = is_skipped !== undefined ? (is_skipped ? 1 : 0) : 0;
if (snoozed_until !== undefined && snoozed_until !== null) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(snoozed_until))
return res.status(400).json(standardizeError('snoozed_until must be an ISO date string (YYYY-MM-DD) or null', 'VALIDATION_ERROR', 'snoozed_until'));
}
const amt = actual_amount !== undefined ? (actual_amount === null ? null : parseFloat(actual_amount)) : null;
const noteVal = notes !== undefined ? (notes || null) : null;
const skipVal = is_skipped !== undefined ? (is_skipped ? 1 : 0) : 0;
const snoozeVal = snoozed_until !== undefined ? (snoozed_until || null) : null;
db.prepare(`
INSERT INTO monthly_bill_state (bill_id, year, month, actual_amount, notes, is_skipped, updated_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
INSERT INTO monthly_bill_state (bill_id, year, month, actual_amount, notes, is_skipped, snoozed_until, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(bill_id, year, month) DO UPDATE SET
actual_amount = excluded.actual_amount,
notes = excluded.notes,
is_skipped = excluded.is_skipped,
snoozed_until = excluded.snoozed_until,
updated_at = datetime('now')
`).run(billId, y, m, amt, noteVal, skipVal);
`).run(billId, y, m, amt, noteVal, skipVal, snoozeVal);
const saved = db.prepare(
'SELECT * FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?'
@ -218,6 +225,7 @@ router.put('/:id/monthly-state', (req, res) => {
actual_amount: saved.actual_amount,
notes: saved.notes,
is_skipped: !!saved.is_skipped,
snoozed_until: saved.snoozed_until ?? null,
created_at: saved.created_at,
updated_at: saved.updated_at,
});

View File

@ -1,6 +1,11 @@
const express = require('express');
const router = express.Router();
const { getTracker, getUpcomingBills } = require('../services/trackerService');
const { getTracker, getUpcomingBills, getOverdueCount } = require('../services/trackerService');
// GET /api/tracker/overdue-count — lightweight count for sidebar badge
router.get('/overdue-count', (req, res) => {
res.json(getOverdueCount(req.user.id));
});
// GET /api/tracker?year=2026&month=5
router.get('/', (req, res) => {

View File

@ -54,7 +54,7 @@ function fetchMonthlyStates(db, billIds, year, month) {
if (billIds.length === 0) return {};
const placeholders = billIds.map(() => '?').join(',');
const rows = db.prepare(`
SELECT bill_id, actual_amount, notes, is_skipped
SELECT bill_id, actual_amount, notes, is_skipped, snoozed_until
FROM monthly_bill_state
WHERE bill_id IN (${placeholders}) AND year = ? AND month = ?
`).all(...billIds, year, month);
@ -268,6 +268,7 @@ function getTracker(userId, query = {}, now = new Date()) {
row.actual_amount = mbs?.actual_amount ?? null;
row.monthly_notes = mbs?.notes ?? null;
row.is_skipped = !!(mbs?.is_skipped);
row.snoozed_until = mbs?.snoozed_until ?? null;
if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion;
row.previous_month_paid = prevMonthPayments[bill.id] || 0;
row.amount_suggestion = computeAmountSuggestion(db, bill.id, year, month);
@ -387,8 +388,52 @@ function getUpcomingBills(userId, query = {}, now = new Date()) {
return { days, today: todayStr, upcoming };
}
function getOverdueCount(userId, now = new Date()) {
const db = getDb();
const todayStr = now.toISOString().slice(0, 10);
const year = now.getFullYear();
const month = now.getMonth() + 1;
const monthStr = String(month).padStart(2, '0');
const rangeStart = `${year}-${monthStr}-01`;
const lastDay = new Date(year, month, 0).getDate();
const rangeEnd = `${year}-${monthStr}-${String(lastDay).padStart(2, '0')}`;
const bills = db.prepare(`
SELECT b.id, b.due_day, b.override_due_date, b.expected_amount,
b.billing_cycle, b.cycle_type, b.cycle_day,
b.autopay_enabled, b.autodraft_status,
mbs.actual_amount, mbs.is_skipped, mbs.snoozed_until,
COALESCE(SUM(p.amount), 0) AS total_paid
FROM bills b
LEFT JOIN monthly_bill_state mbs
ON mbs.bill_id = b.id AND mbs.year = ? AND mbs.month = ?
LEFT JOIN payments p
ON p.bill_id = b.id AND p.paid_date BETWEEN ? AND ? AND p.deleted_at IS NULL
WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL
GROUP BY b.id
`).all(year, month, rangeStart, rangeEnd, userId);
let count = 0;
for (const bill of bills) {
if (bill.is_skipped) continue;
if (bill.snoozed_until && bill.snoozed_until > todayStr) continue;
if (bill.autopay_enabled && bill.autodraft_status === 'assumed_paid') continue;
const dueDate = resolveDueDate(bill, year, month);
if (!dueDate || dueDate > todayStr) continue;
const threshold = bill.actual_amount != null ? bill.actual_amount : bill.expected_amount;
if (threshold > 0 && bill.total_paid >= threshold) continue;
count++;
}
return { count, month, year, today: todayStr };
}
module.exports = {
getTracker,
getUpcomingBills,
validateTrackerMonth,
getOverdueCount,
};