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:
parent
db5f765d84
commit
3978507572
|
|
@ -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}`),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue