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
|
||||||
tracker: (y, m, params = {}) => get(`/tracker${queryString({ year: y, month: m, ...params })}`),
|
tracker: (y, m, params = {}) => get(`/tracker${queryString({ year: y, month: m, ...params })}`),
|
||||||
upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`),
|
upcomingBills: (days = 30) => get(`/tracker/upcoming?days=${days}`),
|
||||||
|
overdueCount: () => get('/tracker/overdue-count'),
|
||||||
|
snoozeOverdue: (id, data) => put(`/bills/${id}/monthly-state`, data),
|
||||||
|
|
||||||
// Calendar
|
// Calendar
|
||||||
calendar: (y, m) => get(`/calendar?year=${y}&month=${m}`),
|
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 { NavLink } from 'react-router-dom';
|
||||||
import { cn } from '@/lib/utils';
|
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 Icon = useMemo(() => item.icon, [item.icon]);
|
||||||
const to = useMemo(() => item.to, [item.to]);
|
const to = useMemo(() => item.to, [item.to]);
|
||||||
const end = useMemo(() => item.end, [item.end]);
|
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" />
|
<Icon className="h-4 w-4" />
|
||||||
<span>{label}</span>
|
<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>
|
</NavLink>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { useOverdueCount } from '@/hooks/useQueries';
|
||||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
|
|
@ -42,7 +43,7 @@ const trackerItems = [
|
||||||
{ to: '/snowball', icon: TrendingDown, label: 'Snowball' },
|
{ to: '/snowball', icon: TrendingDown, label: 'Snowball' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function TrackerMenu({ onNavigate }) {
|
function TrackerMenu({ onNavigate, badge }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isTrackerActive = useMemo(() => trackerItems.some(item => (
|
const isTrackerActive = useMemo(() => trackerItems.some(item => (
|
||||||
|
|
@ -65,6 +66,11 @@ function TrackerMenu({ onNavigate }) {
|
||||||
>
|
>
|
||||||
<LayoutGrid className="h-4 w-4" />
|
<LayoutGrid className="h-4 w-4" />
|
||||||
Tracker
|
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" />
|
<ChevronDown className="h-3.5 w-3.5 opacity-75" />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
@ -169,6 +175,8 @@ export default function Sidebar({ adminMode = false }) {
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const items = useMemo(() => adminMode ? adminNavItems : userNavItems, [adminMode]);
|
const items = useMemo(() => adminMode ? adminNavItems : userNavItems, [adminMode]);
|
||||||
|
const { data: overdueData } = useOverdueCount();
|
||||||
|
const overdueCount = (!adminMode && overdueData?.count) ? overdueData.count : 0;
|
||||||
|
|
||||||
return (
|
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">
|
<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} />
|
<BrandBlock adminMode={adminMode} />
|
||||||
|
|
||||||
<nav className="hidden items-center gap-1 lg:flex">
|
<nav className="hidden items-center gap-1 lg:flex">
|
||||||
{!adminMode && <TrackerMenu />}
|
{!adminMode && <TrackerMenu badge={overdueCount} />}
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<NavPill key={item.to} item={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">
|
<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">
|
<nav className="mx-auto grid max-w-[1500px] gap-1">
|
||||||
{!adminMode && trackerItems.map(item => (
|
{!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 => (
|
{items.map(item => (
|
||||||
<NavPill key={item.to} item={item} onNavigate={() => setMobileOpen(false)} />
|
<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
|
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 = {
|
export const RELEASE_NOTES = {
|
||||||
version: APP_VERSION,
|
version: APP_VERSION,
|
||||||
date: '2026-05-29',
|
date: '2026-05-30',
|
||||||
version: APP_VERSION,
|
|
||||||
highlights: [
|
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: '🧠',
|
icon: '🧠',
|
||||||
title: 'Advisory non-bill transaction filters',
|
title: 'Advisory non-bill transaction filters',
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import { Label } from '@/components/ui/label';
|
||||||
import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
|
import MonthlyStateDialog from '@/components/tracker/MonthlyStateDialog';
|
||||||
import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog';
|
import StartingAmountsEditDialog from '@/components/tracker/StartingAmountsEditDialog';
|
||||||
import PaymentModal from '@/components/tracker/PaymentModal';
|
import PaymentModal from '@/components/tracker/PaymentModal';
|
||||||
|
import OverdueCommandCenter from '@/components/tracker/OverdueCommandCenter';
|
||||||
const MONTHS = [
|
const MONTHS = [
|
||||||
'January','February','March','April','May','June',
|
'January','February','March','April','May','June',
|
||||||
'July','August','September','October','November','December',
|
'July','August','September','October','November','December',
|
||||||
|
|
@ -1824,6 +1825,9 @@ export default function TrackerPage() {
|
||||||
debt: false,
|
debt: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Row to open in PaymentLedgerDialog via the overdue command center
|
||||||
|
const [commandCenterPayRow, setCommandCenterPayRow] = useState(null);
|
||||||
|
|
||||||
// Use React Query for data fetching
|
// Use React Query for data fetching
|
||||||
const { data, isLoading: loading, isError, error, refetch } = useTracker(year, month);
|
const { data, isLoading: loading, isError, error, refetch } = useTracker(year, month);
|
||||||
|
|
||||||
|
|
@ -2053,6 +2057,17 @@ export default function TrackerPage() {
|
||||||
</div>
|
</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 ── */}
|
{/* ── Fetch error state ── */}
|
||||||
{isError && (
|
{isError && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-center rounded-xl border border-destructive/20 bg-destructive/5">
|
<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(); }}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2417,6 +2417,14 @@ function runMigrations() {
|
||||||
run: function() {
|
run: function() {
|
||||||
runSubscriptionCatalogV2Migration(db);
|
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
|
actual_amount REAL, -- NULL = use bill.expected_amount for this month
|
||||||
notes TEXT, -- month-specific notes, NULL = no notes
|
notes TEXT, -- month-specific notes, NULL = no notes
|
||||||
is_skipped INTEGER NOT NULL DEFAULT 0, -- 1 = hidden/removed for this month only
|
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')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
updated_at TEXT DEFAULT (datetime('now')),
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
UNIQUE(bill_id, year, month)
|
UNIQUE(bill_id, year, month)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "bill-tracker",
|
"name": "bill-tracker",
|
||||||
"version": "0.33.8.7",
|
"version": "0.34.0",
|
||||||
"description": "Monthly bill tracking system",
|
"description": "Monthly bill tracking system",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"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))
|
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'));
|
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 y = parseInt(year, 10);
|
||||||
const m = parseInt(month, 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'));
|
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 amt = actual_amount !== undefined ? (actual_amount === null ? null : parseFloat(actual_amount)) : null;
|
||||||
const noteVal = notes !== undefined ? (notes || null) : null;
|
const noteVal = notes !== undefined ? (notes || null) : null;
|
||||||
const skipVal = is_skipped !== undefined ? (is_skipped ? 1 : 0) : 0;
|
const skipVal = is_skipped !== undefined ? (is_skipped ? 1 : 0) : 0;
|
||||||
|
const snoozeVal = snoozed_until !== undefined ? (snoozed_until || null) : null;
|
||||||
|
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO monthly_bill_state (bill_id, year, month, actual_amount, notes, is_skipped, updated_at)
|
INSERT INTO monthly_bill_state (bill_id, year, month, actual_amount, notes, is_skipped, snoozed_until, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
||||||
ON CONFLICT(bill_id, year, month) DO UPDATE SET
|
ON CONFLICT(bill_id, year, month) DO UPDATE SET
|
||||||
actual_amount = excluded.actual_amount,
|
actual_amount = excluded.actual_amount,
|
||||||
notes = excluded.notes,
|
notes = excluded.notes,
|
||||||
is_skipped = excluded.is_skipped,
|
is_skipped = excluded.is_skipped,
|
||||||
|
snoozed_until = excluded.snoozed_until,
|
||||||
updated_at = datetime('now')
|
updated_at = datetime('now')
|
||||||
`).run(billId, y, m, amt, noteVal, skipVal);
|
`).run(billId, y, m, amt, noteVal, skipVal, snoozeVal);
|
||||||
|
|
||||||
const saved = db.prepare(
|
const saved = db.prepare(
|
||||||
'SELECT * FROM monthly_bill_state WHERE bill_id=? AND year=? AND month=?'
|
'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,
|
actual_amount: saved.actual_amount,
|
||||||
notes: saved.notes,
|
notes: saved.notes,
|
||||||
is_skipped: !!saved.is_skipped,
|
is_skipped: !!saved.is_skipped,
|
||||||
|
snoozed_until: saved.snoozed_until ?? null,
|
||||||
created_at: saved.created_at,
|
created_at: saved.created_at,
|
||||||
updated_at: saved.updated_at,
|
updated_at: saved.updated_at,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
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
|
// GET /api/tracker?year=2026&month=5
|
||||||
router.get('/', (req, res) => {
|
router.get('/', (req, res) => {
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ function fetchMonthlyStates(db, billIds, year, month) {
|
||||||
if (billIds.length === 0) return {};
|
if (billIds.length === 0) return {};
|
||||||
const placeholders = billIds.map(() => '?').join(',');
|
const placeholders = billIds.map(() => '?').join(',');
|
||||||
const rows = db.prepare(`
|
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
|
FROM monthly_bill_state
|
||||||
WHERE bill_id IN (${placeholders}) AND year = ? AND month = ?
|
WHERE bill_id IN (${placeholders}) AND year = ? AND month = ?
|
||||||
`).all(...billIds, year, month);
|
`).all(...billIds, year, month);
|
||||||
|
|
@ -268,6 +268,7 @@ function getTracker(userId, query = {}, now = new Date()) {
|
||||||
row.actual_amount = mbs?.actual_amount ?? null;
|
row.actual_amount = mbs?.actual_amount ?? null;
|
||||||
row.monthly_notes = mbs?.notes ?? null;
|
row.monthly_notes = mbs?.notes ?? null;
|
||||||
row.is_skipped = !!(mbs?.is_skipped);
|
row.is_skipped = !!(mbs?.is_skipped);
|
||||||
|
row.snoozed_until = mbs?.snoozed_until ?? null;
|
||||||
if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion;
|
if (autopaySuggestion) row.autopay_suggestion = autopaySuggestion;
|
||||||
row.previous_month_paid = prevMonthPayments[bill.id] || 0;
|
row.previous_month_paid = prevMonthPayments[bill.id] || 0;
|
||||||
row.amount_suggestion = computeAmountSuggestion(db, bill.id, year, month);
|
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 };
|
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 = {
|
module.exports = {
|
||||||
getTracker,
|
getTracker,
|
||||||
getUpcomingBills,
|
getUpcomingBills,
|
||||||
validateTrackerMonth,
|
validateTrackerMonth,
|
||||||
|
getOverdueCount,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue