Compare commits

..

2 Commits

Author SHA1 Message Date
null ee7026872c feat(banking): bank transactions ledger page with route, sidebar link, and API endpoint 2026-06-12 03:59:42 -05:00
null d9a441dff6 feat(settings): auto-save preferences with live save status (batch 0.39.0)
Replace all Save buttons on the Settings page with debounced auto-save:

- useAutoSave hook: debounce with latest-payload-wins, flush() for blur,
  pending-edit flush on unmount, status machine (idle/saving/saved/error)
  with saved fading back to idle. Covered by 6 Vitest tests (fake timers).
- SaveStatus pill (framer-motion) in the page header and notification card
  headers — Saving…/Saved/Save failed.
- Timing per control: toggles/selects/channel ~150-400ms; typed inputs
  (email, URLs, grace period, drift pct) 900ms + flush on blur.
- Push token never auto-saves mid-type: saves on blur only, so a partial
  token can never overwrite a working one.
- Notification cards no longer refetch parent settings on save (would
  clobber in-flight edits under auto-save).
- Decision: no undo toast — settings are non-destructive and instantly
  re-editable; undo would add noise without safety.
- vitest include now picks up .jsx tests; jsdom + @testing-library/react
  added as devDependencies.
2026-06-12 02:08:42 -05:00
13 changed files with 1780 additions and 101 deletions

View File

@ -48,6 +48,7 @@ const SnowballPage = lazy(() => import('@/pages/SnowballPage'));
const HealthPage = lazy(() => import('@/pages/HealthPage'));
const PayoffPage = lazy(() => import('@/pages/PayoffPage'));
const SpendingPage = lazy(() => import('@/pages/SpendingPage'));
const BankTransactionsPage = lazy(() => import('@/pages/BankTransactionsPage'));
function RequireAuth({ children, role }) {
const { user, singleUserMode } = useAuth();
@ -221,6 +222,7 @@ export default function App() {
<Route path="snowball" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SnowballPage /></Suspense></ErrorBoundary>} />
<Route path="payoff" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><PayoffPage /></Suspense></ErrorBoundary>} />
<Route path="spending" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><SpendingPage /></Suspense></ErrorBoundary>} />
<Route path="bank-transactions" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><BankTransactionsPage /></Suspense></ErrorBoundary>} />
<Route path="data" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><DataPage /></Suspense></ErrorBoundary>} />
<Route path="profile" element={<ErrorBoundary><Suspense fallback={<PageLoader />}><ProfilePage /></Suspense></ErrorBoundary>} />
<Route path="*" element={<NotFoundPage />} />

View File

@ -403,6 +403,7 @@ export const api = {
// Transactions
transactions: (params = {}) => get(`/transactions${queryString(params)}`),
bankTransactionsLedger: (params = {}) => get(`/transactions/bank-ledger${queryString(params)}`),
createManualTransaction: (data) => post('/transactions/manual', data),
updateTransaction: (id, data) => put(`/transactions/${id}`, data),
deleteTransaction: (id) => del(`/transactions/${id}`),

View File

@ -1,10 +1,11 @@
import { useState, useMemo } from 'react';
import { useEffect, useState, useMemo } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import {
Activity, BarChart3, Calculator, CalendarDays, ChevronDown, ClipboardCheck, ClipboardList, Database, Info, LayoutGrid, LogOut, Map, Menu, Receipt,
Search, Settings, ShieldCheck, Tag, TrendingDown, User, X,
Repeat, ShoppingCart,
Landmark, Repeat, ShoppingCart,
} from 'lucide-react';
import { api } from '@/api';
import { cn } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth';
import { useOverdueCount } from '@/hooks/useQueries';
@ -42,16 +43,17 @@ const trackerItems = [
{ to: '/categories', icon: Tag, label: 'Categories' },
{ to: '/health', icon: ClipboardCheck, label: 'Health' },
{ to: '/spending', icon: ShoppingCart, label: 'Spending' },
{ to: '/bank-transactions', icon: Landmark, label: 'Banking', simplefinOnly: true },
{ to: '/snowball', icon: TrendingDown, label: 'Snowball' },
{ to: '/payoff', icon: Calculator, label: 'Payoff' },
];
function TrackerMenu({ onNavigate, badge, badgeNames = [] }) {
function TrackerMenu({ onNavigate, badge, badgeNames = [], items = trackerItems }) {
const location = useLocation();
const navigate = useNavigate();
const isTrackerActive = useMemo(() => trackerItems.some(item => (
const isTrackerActive = useMemo(() => items.some(item => (
item.end ? location.pathname === item.to : location.pathname.startsWith(item.to)
)), [location.pathname]);
)), [items, location.pathname]);
return (
<DropdownMenu>
@ -93,7 +95,7 @@ function TrackerMenu({ onNavigate, badge, badgeNames = [] }) {
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
{trackerItems.map(item => {
{items.map(item => {
const Icon = item.icon;
return (
<DropdownMenuItem key={item.to} onSelect={() => { navigate(item.to); onNavigate?.(); }}>
@ -193,9 +195,27 @@ export default function Sidebar({ adminMode = false }) {
const [mobileOpen, setMobileOpen] = useState(false);
const { user } = useAuth();
const items = useMemo(() => adminMode ? adminNavItems : userNavItems, [adminMode]);
const [simplefinReady, setSimplefinReady] = useState(false);
const { data: overdueData } = useOverdueCount();
const overdueCount = (!adminMode && overdueData?.count) ? overdueData.count : 0;
const overdueNames = (!adminMode && overdueData?.names) ? overdueData.names : [];
const trackerMenuItems = useMemo(
() => trackerItems.filter(item => !item.simplefinOnly || simplefinReady),
[simplefinReady],
);
useEffect(() => {
if (adminMode) return undefined;
let cancelled = false;
api.simplefinStatus()
.then(status => {
if (!cancelled) setSimplefinReady(Boolean(status?.enabled && status?.has_connections));
})
.catch(() => {
if (!cancelled) setSimplefinReady(false);
});
return () => { cancelled = true; };
}, [adminMode]);
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">
@ -203,7 +223,7 @@ export default function Sidebar({ adminMode = false }) {
<BrandBlock adminMode={adminMode} />
<nav className="hidden items-center gap-1 lg:flex">
{!adminMode && <TrackerMenu badge={overdueCount} badgeNames={overdueNames} />}
{!adminMode && <TrackerMenu badge={overdueCount} badgeNames={overdueNames} items={trackerMenuItems} />}
{items.map(item => (
<NavPill key={item.to} item={item} />
))}
@ -244,7 +264,7 @@ export default function Sidebar({ adminMode = false }) {
{mobileOpen && (
<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 => (
{!adminMode && trackerMenuItems.map(item => (
<NavPill
key={item.to}
item={item}

View File

@ -0,0 +1,38 @@
import { AnimatePresence, motion } from 'framer-motion';
import { Check, CloudUpload, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
const STATES = {
saving: { icon: CloudUpload, text: 'Saving…', cls: 'text-muted-foreground border-border/70 bg-muted/40' },
saved: { icon: Check, text: 'Saved', cls: 'text-emerald-600 dark:text-emerald-300 border-emerald-500/30 bg-emerald-500/10' },
error: { icon: AlertCircle, text: 'Save failed', cls: 'text-rose-500 dark:text-rose-300 border-rose-500/35 bg-rose-500/10' },
};
/**
* Tiny animated pill that reflects auto-save state. Renders nothing while idle
* the page communicates "changes save automatically" once, statically.
*/
export function SaveStatus({ status, className }) {
const state = STATES[status];
return (
<AnimatePresence mode="wait">
{state && (
<motion.span
key={status}
initial={{ opacity: 0, y: -2 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 2 }}
transition={{ duration: 0.15 }}
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium',
state.cls,
className,
)}
>
<state.icon className={cn('h-3 w-3', status === 'saving' && 'animate-pulse')} />
{state.text}
</motion.span>
)}
</AnimatePresence>
);
}

View File

@ -0,0 +1,60 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/**
* Debounced auto-save with a status the UI can render.
*
* const { status, schedule, flush } = useAutoSave(payload => api.save(payload));
* - schedule(payload[, delayMs]) (re)arms the debounce with the latest payload
* - flush() saves any pending payload immediately (e.g. on blur)
* - status 'idle' | 'saving' | 'saved' | 'error' ('saved' fades to 'idle')
*
* Pending changes are flushed on unmount so navigating away never loses edits.
*/
export function useAutoSave(saveFn, defaultDelay = 400) {
const [status, setStatus] = useState('idle');
const timer = useRef(null);
const pending = useRef(null);
const saveRef = useRef(saveFn);
saveRef.current = saveFn;
const run = useCallback(async (payload) => {
pending.current = null;
setStatus('saving');
try {
await saveRef.current(payload);
setStatus('saved');
} catch {
setStatus('error'); // saveFn is responsible for surfacing the error (toast)
}
}, []);
const schedule = useCallback((payload, delay = defaultDelay) => {
pending.current = payload;
clearTimeout(timer.current);
timer.current = setTimeout(() => run(payload), delay);
}, [run, defaultDelay]);
const flush = useCallback(() => {
if (pending.current != null) {
clearTimeout(timer.current);
run(pending.current);
}
}, [run]);
// Fade the "Saved" confirmation back to idle.
useEffect(() => {
if (status !== 'saved') return undefined;
const t = setTimeout(() => setStatus('idle'), 2000);
return () => clearTimeout(t);
}, [status]);
// Never lose a pending edit on unmount.
useEffect(() => () => {
clearTimeout(timer.current);
if (pending.current != null) {
Promise.resolve(saveRef.current(pending.current)).catch(() => {});
}
}, []);
return { status, schedule, flush };
}

View File

@ -0,0 +1,93 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useAutoSave } from './useAutoSave';
describe('useAutoSave', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('debounces: only the latest payload is saved, once', async () => {
const save = vi.fn().mockResolvedValue();
const { result } = renderHook(() => useAutoSave(save, 400));
act(() => {
result.current.schedule({ a: 1 });
result.current.schedule({ a: 2 });
result.current.schedule({ a: 3 });
});
expect(save).not.toHaveBeenCalled();
await act(async () => { await vi.advanceTimersByTimeAsync(400); });
expect(save).toHaveBeenCalledTimes(1);
expect(save).toHaveBeenCalledWith({ a: 3 });
expect(result.current.status).toBe('saved');
});
it('per-call delay overrides the default', async () => {
const save = vi.fn().mockResolvedValue();
const { result } = renderHook(() => useAutoSave(save, 400));
act(() => { result.current.schedule({ slow: true }, 900); });
await act(async () => { await vi.advanceTimersByTimeAsync(400); });
expect(save).not.toHaveBeenCalled();
await act(async () => { await vi.advanceTimersByTimeAsync(500); });
expect(save).toHaveBeenCalledWith({ slow: true });
});
it('flush() saves a pending payload immediately and is a no-op when idle', async () => {
const save = vi.fn().mockResolvedValue();
const { result } = renderHook(() => useAutoSave(save, 400));
await act(async () => { result.current.flush(); });
expect(save).not.toHaveBeenCalled();
act(() => { result.current.schedule({ b: 1 }, 900); });
await act(async () => { result.current.flush(); });
expect(save).toHaveBeenCalledTimes(1);
expect(save).toHaveBeenCalledWith({ b: 1 });
// payload no longer pending flushing again must not double-save
await act(async () => { result.current.flush(); });
expect(save).toHaveBeenCalledTimes(1);
});
it('reports error status when the save rejects, then recovers on next save', async () => {
const save = vi.fn()
.mockRejectedValueOnce(new Error('boom'))
.mockResolvedValueOnce();
const { result } = renderHook(() => useAutoSave(save, 100));
act(() => { result.current.schedule({ x: 1 }); });
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
expect(result.current.status).toBe('error');
act(() => { result.current.schedule({ x: 2 }); });
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
expect(result.current.status).toBe('saved');
});
it('"saved" fades back to idle after 2 seconds', async () => {
const save = vi.fn().mockResolvedValue();
const { result } = renderHook(() => useAutoSave(save, 100));
act(() => { result.current.schedule({ y: 1 }); });
await act(async () => { await vi.advanceTimersByTimeAsync(100); });
expect(result.current.status).toBe('saved');
await act(async () => { await vi.advanceTimersByTimeAsync(2000); });
expect(result.current.status).toBe('idle');
});
it('flushes a pending payload on unmount so edits are never lost', async () => {
const save = vi.fn().mockResolvedValue();
const { result, unmount } = renderHook(() => useAutoSave(save, 900));
act(() => { result.current.schedule({ unsaved: true }); });
expect(save).not.toHaveBeenCalled();
unmount();
expect(save).toHaveBeenCalledTimes(1);
expect(save).toHaveBeenCalledWith({ unsaved: true });
});
});

View File

@ -0,0 +1,567 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import {
ArrowDown,
ArrowDownUp,
ArrowUp,
Building2,
CheckCircle2,
ChevronLeft,
ChevronRight,
Clock3,
Landmark,
Link2,
RefreshCw,
Search,
TrendingDown,
TrendingUp,
WalletCards,
} from 'lucide-react';
import { api } from '@/api';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { cn, fmt, fmtDate } from '@/lib/utils';
const PAGE_SIZE = 50;
const flowOptions = [
{ value: 'all', label: 'All' },
{ value: 'in', label: 'Money in' },
{ value: 'out', label: 'Money out' },
{ value: 'pending', label: 'Pending' },
{ value: 'unmatched', label: 'Needs review' },
{ value: 'matched', label: 'Matched' },
{ value: 'ignored', label: 'Ignored' },
];
const sortOptions = [
{ value: 'date', label: 'Date' },
{ value: 'amount', label: 'Amount' },
{ value: 'merchant', label: 'Merchant' },
{ value: 'account', label: 'Account' },
{ value: 'status', label: 'Status' },
];
function formatCents(value, { signed = false } = {}) {
const cents = Number(value || 0);
const amount = fmt(Math.abs(cents) / 100);
if (!signed || cents === 0) return amount;
return `${cents > 0 ? '+' : '-'}${amount}`;
}
function formatSyncTime(value) {
if (!value) return 'Not synced yet';
const normalized = String(value).includes('T') ? String(value) : String(value).replace(' ', 'T');
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) return String(value);
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(date);
}
function transactionTitle(tx) {
return tx.payee || tx.description || tx.memo || 'Transaction';
}
function transactionDate(tx) {
return tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : null);
}
function accountLabel(account) {
if (!account) return 'Unknown account';
return [account.org_name, account.name].filter(Boolean).join(' - ') || account.name || 'Account';
}
function StatusBadge({ tx }) {
if (tx.pending) {
return (
<Badge className="border-amber-300/50 bg-amber-400/15 text-amber-700 dark:text-amber-200">
<Clock3 className="mr-1 h-3 w-3" />
Pending
</Badge>
);
}
if (tx.ignored || tx.match_status === 'ignored') {
return <Badge variant="outline" className="border-muted-foreground/30 text-muted-foreground">Ignored</Badge>;
}
if (tx.match_status === 'matched') {
return (
<Badge className="border-emerald-300/50 bg-emerald-400/15 text-emerald-700 dark:text-emerald-200">
<CheckCircle2 className="mr-1 h-3 w-3" />
Matched
</Badge>
);
}
return <Badge className="border-sky-300/50 bg-sky-400/15 text-sky-700 dark:text-sky-200">Review</Badge>;
}
function SummaryTile({ icon: Icon, label, value, tone, detail }) {
const tones = {
emerald: 'border-emerald-300/40 bg-emerald-400/10 text-emerald-700 dark:text-emerald-200',
rose: 'border-rose-300/40 bg-rose-400/10 text-rose-700 dark:text-rose-200',
sky: 'border-sky-300/40 bg-sky-400/10 text-sky-700 dark:text-sky-200',
amber: 'border-amber-300/40 bg-amber-400/10 text-amber-700 dark:text-amber-200',
};
return (
<div className="rounded-lg border border-border/70 bg-card/80 p-4 shadow-sm">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium text-muted-foreground">{label}</p>
<span className={cn('flex h-9 w-9 items-center justify-center rounded-full border', tones[tone])}>
<Icon className="h-4 w-4" />
</span>
</div>
<p className="tracker-number mt-3 text-2xl font-bold text-foreground">{value}</p>
{detail && <p className="mt-1 text-xs font-medium text-muted-foreground">{detail}</p>}
</div>
);
}
function TransactionMobileCard({ tx }) {
const cents = Number(tx.amount || 0);
const isCredit = cents > 0;
return (
<div className="rounded-lg border border-border/70 bg-card/85 p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">{transactionTitle(tx)}</p>
<p className="mt-1 truncate text-xs font-medium text-muted-foreground">
{[tx.account_org_name, tx.account_name].filter(Boolean).join(' - ') || 'Account'}
</p>
</div>
<p className={cn('tracker-number shrink-0 text-sm font-bold', isCredit ? 'text-emerald-600 dark:text-emerald-300' : 'text-rose-600 dark:text-rose-300')}>
{formatCents(cents, { signed: true })}
</p>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Badge variant="outline" className="text-muted-foreground">{fmtDate(transactionDate(tx))}</Badge>
<StatusBadge tx={tx} />
{tx.matched_bill_name && (
<Badge className="border-indigo-300/50 bg-indigo-400/15 text-indigo-700 dark:text-indigo-200">
<Link2 className="mr-1 h-3 w-3" />
{tx.matched_bill_name}
</Badge>
)}
</div>
</div>
);
}
export default function BankTransactionsPage() {
const [ledger, setLedger] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
const [query, setQuery] = useState('');
const [accountId, setAccountId] = useState('all');
const [flow, setFlow] = useState('all');
const [sortBy, setSortBy] = useState('date');
const [sortDir, setSortDir] = useState('desc');
const [page, setPage] = useState(1);
// Monotonic request id: a response only lands if it belongs to the newest
// request, so a slow Refresh can never overwrite fresher filter results.
const requestSeq = useRef(0);
useEffect(() => {
const id = window.setTimeout(() => {
setQuery(search.trim());
setPage(1);
}, 250);
return () => window.clearTimeout(id);
}, [search]);
useEffect(() => {
setPage(1);
}, [accountId, flow, sortBy, sortDir]);
// Single fetch path used by both the load effect and the Refresh button.
const loadLedger = useCallback(async () => {
const seq = ++requestSeq.current;
setLoading(true);
setError('');
try {
const data = await api.bankTransactionsLedger({
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
q: query,
account_id: accountId === 'all' ? undefined : accountId,
flow,
sort_by: sortBy,
sort_dir: sortDir,
});
if (seq === requestSeq.current) setLedger(data);
} catch (err) {
if (seq === requestSeq.current) setError(err.message || 'Unable to load bank transactions');
} finally {
if (seq === requestSeq.current) setLoading(false);
}
}, [accountId, flow, page, query, sortBy, sortDir]);
useEffect(() => { loadLedger(); }, [loadLedger]);
const accounts = ledger?.accounts || [];
const transactions = ledger?.transactions || [];
const summary = ledger?.summary || {};
const total = Number(ledger?.total || 0);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
// If a filter shrinks the result set below the current page, snap back to
// the last real page instead of stranding the user on an empty one.
useEffect(() => {
if (!loading && page > totalPages) setPage(totalPages);
}, [loading, page, totalPages]);
const latestSync = useMemo(() => {
const times = (ledger?.sources || []).map(source => source.last_sync_at).filter(Boolean).sort();
return times[times.length - 1] || null;
}, [ledger?.sources]);
const connected = Boolean(ledger?.enabled && ledger?.has_connections);
// A failed request must never masquerade as "not connected" without this,
// a transient API error told users with working bank sync to go connect it.
if (!loading && error && !ledger) {
return (
<div className="space-y-6">
<section className="rounded-lg border border-rose-300/40 bg-rose-400/10 p-6 shadow-sm">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-xl font-bold text-foreground">Bank Transactions</h1>
<p className="mt-1 text-sm font-medium text-rose-700 dark:text-rose-200">{error}</p>
</div>
<Button type="button" variant="outline" onClick={loadLedger}>
<RefreshCw className="h-4 w-4" />
Try again
</Button>
</div>
</section>
</div>
);
}
if (!loading && !connected) {
return (
<div className="space-y-6">
<section className="rounded-lg border border-border/70 bg-card/85 p-6 shadow-sm">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-sky-300/40 bg-sky-400/10 text-sky-700 dark:text-sky-200">
<Landmark className="h-5 w-5" />
</span>
<div>
<h1 className="text-2xl font-bold tracking-normal text-foreground">Bank Transactions</h1>
<p className="text-sm font-medium text-muted-foreground">SimpleFIN bridge required</p>
</div>
</div>
</div>
<Button asChild>
<Link to="/data">
<WalletCards className="h-4 w-4" />
Open Data
</Link>
</Button>
</div>
</section>
</div>
);
}
return (
<div className="space-y-6">
<section className="rounded-lg border border-border/70 bg-[linear-gradient(135deg,oklch(var(--card))_0%,oklch(var(--muted)/0.72)_58%,oklch(var(--primary)/0.10)_100%)] p-5 shadow-sm">
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-3">
<span className="flex h-11 w-11 items-center justify-center rounded-full border border-sky-300/40 bg-sky-400/10 text-sky-700 dark:text-sky-200">
<Landmark className="h-5 w-5" />
</span>
<div>
<h1 className="text-2xl font-bold tracking-normal text-foreground sm:text-3xl">Bank Transactions</h1>
<p className="mt-1 text-sm font-medium text-muted-foreground">
{accounts.length} monitored {accounts.length === 1 ? 'account' : 'accounts'} synced through SimpleFIN
</p>
</div>
<Badge className={cn(
connected
? 'border-emerald-300/50 bg-emerald-400/15 text-emerald-700 dark:text-emerald-200'
: 'border-sky-300/50 bg-sky-400/15 text-sky-700 dark:text-sky-200',
)}>
{connected ? 'Connected' : 'Loading'}
</Badge>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="rounded-lg border border-border/70 bg-card/75 px-3 py-2 text-sm shadow-sm">
<span className="font-medium text-muted-foreground">Last sync </span>
<span className="font-semibold text-foreground">{formatSyncTime(latestSync)}</span>
</div>
<Button type="button" variant="outline" onClick={loadLedger} disabled={loading}>
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
Refresh
</Button>
</div>
</div>
</section>
{error && (
<div className="rounded-lg border border-rose-300/40 bg-rose-400/10 px-4 py-3 text-sm font-medium text-rose-700 dark:text-rose-200">
{error}
</div>
)}
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<SummaryTile
icon={TrendingUp}
label="Money in"
value={formatCents(summary.inflow)}
tone="emerald"
detail={`${Number(summary.matched || 0)} matched`}
/>
<SummaryTile
icon={TrendingDown}
label="Money out"
value={formatCents(summary.outflow)}
tone="rose"
detail={`${Number(summary.unmatched || 0)} need review`}
/>
<SummaryTile
icon={ArrowDownUp}
label="Net flow"
value={formatCents(summary.net, { signed: true })}
tone="sky"
detail={summary.latest_date ? `Latest ${fmtDate(summary.latest_date)}` : 'No posted activity'}
/>
<SummaryTile
icon={Clock3}
label="Pending"
value={String(Number(summary.pending || 0))}
tone="amber"
detail={`${Number(summary.total || 0)} total transactions`}
/>
</section>
{accounts.length > 0 && (
<section className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{accounts.map(account => (
<div key={account.id} className="rounded-lg border border-border/70 bg-card/85 p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">{accountLabel(account)}</p>
<p className="mt-1 truncate text-xs font-medium text-muted-foreground">
{[account.account_type, account.currency].filter(Boolean).join(' - ') || 'Bank account'}
</p>
</div>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-indigo-300/40 bg-indigo-400/10 text-indigo-700 dark:text-indigo-200">
<Building2 className="h-4 w-4" />
</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<div>
<p className="text-xs font-medium text-muted-foreground">Balance</p>
<p className="tracker-number mt-1 text-lg font-bold text-foreground">{formatCents(account.balance)}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Available</p>
<p className="tracker-number mt-1 text-lg font-bold text-emerald-600 dark:text-emerald-300">
{account.available_balance == null ? '—' : formatCents(account.available_balance)}
</p>
</div>
</div>
<div className="mt-3 flex items-center justify-between text-xs font-medium text-muted-foreground">
<span>{Number(account.transaction_count || 0)} transactions</span>
<span>{account.last_transaction_date ? fmtDate(account.last_transaction_date) : 'No activity'}</span>
</div>
</div>
))}
</section>
)}
<section className="space-y-4">
<div className="grid gap-3 rounded-lg border border-border/70 bg-card/85 p-3 shadow-sm lg:grid-cols-[minmax(18rem,1fr)_12rem_12rem_10rem_auto]">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={event => setSearch(event.target.value)}
placeholder="Search merchant, memo, category"
className="pl-9"
/>
</div>
<Select value={accountId} onValueChange={setAccountId}>
<SelectTrigger>
<SelectValue placeholder="Account" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All accounts</SelectItem>
{accounts.map(account => (
<SelectItem key={account.id} value={String(account.id)}>{accountLabel(account)}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={flow} onValueChange={setFlow}>
<SelectTrigger>
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
{flowOptions.map(option => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger>
<SelectValue placeholder="Sort" />
</SelectTrigger>
<SelectContent>
{sortOptions.map(option => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
onClick={() => setSortDir(value => (value === 'asc' ? 'desc' : 'asc'))}
title={`Sort ${sortDir === 'asc' ? 'ascending' : 'descending'}`}
>
{sortDir === 'asc' ? <ArrowUp className="h-4 w-4" /> : <ArrowDown className="h-4 w-4" />}
{sortDir === 'asc' ? 'Ascending' : 'Descending'}
</Button>
</div>
<div className="hidden overflow-hidden rounded-lg border border-border/70 bg-card/85 shadow-sm lg:block">
<Table>
<TableHeader>
<TableRow className="bg-muted/35 hover:bg-muted/35">
<TableHead className="w-32">Date</TableHead>
<TableHead>Merchant</TableHead>
<TableHead>Account</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && transactions.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">Loading transactions...</TableCell>
</TableRow>
)}
{!loading && transactions.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">No transactions found.</TableCell>
</TableRow>
)}
{transactions.map(tx => {
const cents = Number(tx.amount || 0);
const isCredit = cents > 0;
return (
<TableRow key={tx.id}>
<TableCell className="text-muted-foreground">{fmtDate(transactionDate(tx))}</TableCell>
<TableCell>
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{transactionTitle(tx)}</p>
<p className="truncate text-xs font-medium text-muted-foreground">
{[tx.memo, tx.category].filter(Boolean).join(' - ') || tx.description || 'SimpleFIN'}
</p>
</div>
</TableCell>
<TableCell>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">
{[tx.account_org_name, tx.account_name].filter(Boolean).join(' - ') || 'Account'}
</p>
<p className="truncate text-xs font-medium text-muted-foreground">{tx.account_type || tx.currency || 'Bank'}</p>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<StatusBadge tx={tx} />
{tx.matched_bill_name && (
<Badge className="border-indigo-300/50 bg-indigo-400/15 text-indigo-700 dark:text-indigo-200">
<Link2 className="mr-1 h-3 w-3" />
{tx.matched_bill_name}
</Badge>
)}
</div>
</TableCell>
<TableCell className={cn('tracker-number text-right font-bold', isCredit ? 'text-emerald-600 dark:text-emerald-300' : 'text-rose-600 dark:text-rose-300')}>
{formatCents(cents, { signed: true })}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
<div className="grid gap-3 lg:hidden">
{loading && transactions.length === 0 && (
<div className="rounded-lg border border-border/70 bg-card/85 p-8 text-center text-sm font-medium text-muted-foreground">
Loading transactions...
</div>
)}
{!loading && transactions.length === 0 && (
<div className="rounded-lg border border-border/70 bg-card/85 p-8 text-center text-sm font-medium text-muted-foreground">
No transactions found.
</div>
)}
{transactions.map(tx => <TransactionMobileCard key={tx.id} tx={tx} />)}
</div>
<div className="flex flex-col gap-3 rounded-lg border border-border/70 bg-card/85 px-4 py-3 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm font-medium text-muted-foreground">
{total === 0
? '0 transactions'
: `${(page - 1) * PAGE_SIZE + 1}-${Math.min(page * PAGE_SIZE, total)} of ${total} transactions`}
</p>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
disabled={page <= 1 || loading}
onClick={() => setPage(value => Math.max(1, value - 1))}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<span className="min-w-20 text-center text-sm font-semibold text-foreground">
{page} / {totalPages}
</span>
<Button
type="button"
variant="outline"
disabled={page >= totalPages || loading}
onClick={() => setPage(value => Math.min(totalPages, value + 1))}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</section>
</div>
);
}

View File

@ -6,6 +6,8 @@ import {
} from 'lucide-react';
import { api } from '@/api';
import { useAuth } from '@/hooks/useAuth';
import { useAutoSave } from '@/hooks/useAutoSave';
import { SaveStatus } from '@/components/ui/save-status';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
@ -34,7 +36,7 @@ function formatDateTime(value) {
+ d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function SectionCard({ title, icon: Icon, subtitle, children }) {
function SectionCard({ title, icon: Icon, subtitle, action, children }) {
return (
<section className="overflow-hidden rounded-xl border border-border/70 bg-card/90 shadow-sm">
<div className="px-6 py-4 border-b border-border/50 flex items-center gap-3">
@ -45,6 +47,7 @@ function SectionCard({ title, icon: Icon, subtitle, children }) {
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">{title}</h2>
{subtitle && <p className="text-sm text-muted-foreground mt-0.5">{subtitle}</p>}
</div>
{action && <div className="ml-auto shrink-0">{action}</div>}
</div>
<div>{children}</div>
</section>
@ -362,13 +365,7 @@ function EditProfile({ profile, onSaved }) {
// Exported: rendered on the Settings page ("Notifications" section). Lives here
// because it shares asSettings/CheckRow/SectionCard with the rest of this file.
export function NotificationPreferences({ settings, onSaved }) {
const [form, setForm] = useState(settings);
const [saving, setSaving] = useState(false);
useEffect(() => setForm(settings), [settings]);
const set = (k, v) => setForm(prev => ({ ...prev, [k]: v }));
function buildNotificationPayload(form) {
const payload = {
email: form.email || form.notification_email || '',
notifications_enabled: !!(form.notifications_enabled ?? form.enabled),
@ -383,26 +380,48 @@ export function NotificationPreferences({ settings, onSaved }) {
payload.notify_1d = payload.notify_1_day;
payload.notify_day_of = payload.notify_due;
payload.notify_daily_overdue = payload.notify_overdue;
return payload;
}
const save = async () => {
setSaving(true);
try {
const data = await api.updateProfileSettings(payload);
toast.success('Notification preferences saved.');
onSaved(asSettings(data));
} catch (err) {
export function NotificationPreferences({ settings }) {
const [form, setForm] = useState(settings);
// Auto-save: toggles persist almost instantly, the email field debounces so
// we never save a half-typed address. Local form stays the source of truth
// no parent refresh that could clobber in-flight edits.
const { status, schedule, flush } = useAutoSave(
(payload) => api.updateProfileSettings(payload).catch((err) => {
toast.error(err.message || 'Failed to save notification preferences.');
} finally {
setSaving(false);
}
};
throw err;
}),
);
const set = (k, v, delay) => setForm(prev => {
const next = { ...prev, [k]: v };
schedule(buildNotificationPayload(next), delay);
return next;
});
const payload = buildNotificationPayload(form);
return (
<SectionCard title="Notification Preferences" icon={Mail} subtitle="Manage email reminders for your bills.">
<SectionCard
title="Notification Preferences"
icon={Mail}
subtitle="Manage email reminders for your bills. Changes save automatically."
action={<SaveStatus status={status} />}
>
<div className="px-6 py-5 space-y-4">
<div className="space-y-1.5 max-w-md">
<label htmlFor="profile-email" className="text-xs font-medium text-muted-foreground">Email</label>
<Input id="profile-email" type="email" value={payload.email} onChange={e => set('email', e.target.value)} placeholder="you@example.com" />
<Input
id="profile-email"
type="email"
value={payload.email}
onChange={e => set('email', e.target.value, 900)}
onBlur={flush}
placeholder="you@example.com"
/>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<CheckRow id="n-enabled" label="Notifications enabled" checked={payload.notifications_enabled} onChange={v => set('notifications_enabled', v)} />
@ -413,11 +432,6 @@ export function NotificationPreferences({ settings, onSaved }) {
<CheckRow id="n-amount" label="Notify on price changes" checked={payload.notify_amount_change} onChange={v => set('notify_amount_change', v)} disabled={!payload.notifications_enabled} />
</div>
</div>
<div className="px-6 py-4 border-t border-border/50 flex justify-end">
<Button onClick={save} disabled={saving}>
{saving ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving</> : 'Save Preferences'}
</Button>
</div>
</SectionCard>
);
}
@ -442,23 +456,37 @@ export function PushNotifications({ settings, onSaved }) {
const ch = PUSH_CHANNELS.find(c => c.value === channel) || PUSH_CHANNELS[0];
const save = async () => {
// Auto-save. Toggle/channel persist immediately; URL and chat ID debounce.
// The token is deliberately NOT auto-saved while typing a half-typed token
// must never overwrite a working one. It saves on blur, when complete.
const buildPatch = (over = {}) => {
const s = { enabled, channel, url, chatId, ...over };
return {
notify_push_enabled: s.enabled,
push_channel: s.channel,
push_url: (s.url || '').trim() || null,
push_chat_id: (s.chatId || '').trim() || null,
};
};
const { status, schedule, flush } = useAutoSave(
(patch) => api.updateProfileSettings(patch).catch((err) => {
toast.error(err.message || 'Failed to save push settings.');
throw err;
}),
);
const saveToken = async () => {
const t = token.trim();
if (!t) return;
setSaving(true);
try {
const patch = {
notify_push_enabled: enabled,
push_channel: channel,
push_url: url.trim() || null,
push_chat_id: chatId.trim() || null,
};
if (token.trim()) patch.push_token = token.trim();
await api.updateProfileSettings(patch);
setTokenSet(!!token.trim() || tokenSet);
await api.updateProfileSettings({ ...buildPatch(), push_token: t });
setTokenSet(true);
setToken('');
toast.success('Push notification settings saved.');
onSaved?.();
toast.success('Token saved.');
} catch (err) {
toast.error(err.message || 'Failed to save push settings.');
toast.error(err.message || 'Failed to save token.');
} finally {
setSaving(false);
}
@ -477,7 +505,12 @@ export function PushNotifications({ settings, onSaved }) {
};
return (
<SectionCard title="Push Notifications" icon={Bell} subtitle="Get bill reminders on your phone via ntfy, Gotify, Discord, or Telegram.">
<SectionCard
title="Push Notifications"
icon={Bell}
subtitle="Get bill reminders on your phone via ntfy, Gotify, Discord, or Telegram. Changes save automatically."
action={<SaveStatus status={status} />}
>
<div className="px-6 py-5 space-y-5">
{/* Master toggle — same CheckRow pattern as the email section */}
@ -485,7 +518,7 @@ export function PushNotifications({ settings, onSaved }) {
id="push-enabled"
label="Enable push notifications"
checked={enabled}
onChange={setEnabled}
onChange={(v) => { setEnabled(v); schedule(buildPatch({ enabled: v }), 150); }}
/>
{enabled && (
<p className="text-xs text-muted-foreground -mt-3 pl-1">
@ -503,7 +536,7 @@ export function PushNotifications({ settings, onSaved }) {
<button
key={c.value}
type="button"
onClick={() => setChannel(c.value)}
onClick={() => { setChannel(c.value); schedule(buildPatch({ channel: c.value }), 150); }}
className={`rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors ${
channel === c.value
? 'border-primary bg-primary text-primary-foreground'
@ -522,7 +555,8 @@ export function PushNotifications({ settings, onSaved }) {
<label className="text-xs font-medium text-muted-foreground">{ch.urlLabel}</label>
<Input
value={url}
onChange={e => setUrl(e.target.value)}
onChange={e => { setUrl(e.target.value); schedule(buildPatch({ url: e.target.value }), 900); }}
onBlur={flush}
placeholder={ch.urlHint}
autoComplete="off"
/>
@ -539,7 +573,8 @@ export function PushNotifications({ settings, onSaved }) {
<Input
value={token}
onChange={e => setToken(e.target.value)}
placeholder={tokenSet ? '(leave blank to keep saved token)' : 'Enter token…'}
onBlur={saveToken}
placeholder={tokenSet ? '(leave blank to keep saved token)' : 'Enter token — saves when you click away'}
type="password"
autoComplete="off"
/>
@ -551,7 +586,8 @@ export function PushNotifications({ settings, onSaved }) {
<label className="text-xs font-medium text-muted-foreground">{ch.chatIdLabel}</label>
<Input
value={chatId}
onChange={e => setChatId(e.target.value)}
onChange={e => { setChatId(e.target.value); schedule(buildPatch({ chatId: e.target.value }), 900); }}
onBlur={flush}
placeholder="e.g. 123456789"
autoComplete="off"
/>
@ -584,22 +620,20 @@ export function PushNotifications({ settings, onSaved }) {
)}
</div>
<div className="px-6 py-4 border-t border-border/50 flex items-center justify-between gap-3">
<div className="px-6 py-4 border-t border-border/50 flex items-center gap-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={test}
disabled={testing || !enabled || !url.trim()}
disabled={testing || saving || !enabled || !url.trim()}
className="gap-1.5"
>
{testing
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Sending</>
: <><SendHorizontal className="h-3.5 w-3.5" />Send test</>}
</Button>
<Button onClick={save} disabled={saving}>
{saving ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving</> : 'Save'}
</Button>
<p className="text-[11px] text-muted-foreground">Settings save as you change them.</p>
</div>
</SectionCard>
);

View File

@ -13,6 +13,8 @@ import {
import { Switch } from '@/components/ui/switch';
import { useTheme } from '@/contexts/ThemeContext';
import { useAuth } from '@/hooks/useAuth';
import { useAutoSave } from '@/hooks/useAutoSave';
import { SaveStatus } from '@/components/ui/save-status';
import { NotificationPreferences, PushNotifications, asSettings } from '@/pages/ProfilePage';
export const LINK_IMPORT_PREF_KEY = 'link_import_ask';
@ -287,7 +289,6 @@ export default function SettingsPage() {
const [settings, setSettings] = useState(DEFAULTS);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
const [saving, setSaving] = useState(false);
const loadSettings = useCallback(() => {
setLoading(true);
@ -300,31 +301,36 @@ export default function SettingsPage() {
useEffect(() => { loadSettings(); }, [loadSettings]);
const set = (k, v) => setSettings((p) => ({ ...p, [k]: v }));
const buildPayload = (s) => ({
currency: s.currency,
date_format: s.date_format,
grace_period_days: s.grace_period_days,
drift_threshold_pct: s.drift_threshold_pct,
tracker_show_bank_projection_banner: s.tracker_show_bank_projection_banner,
tracker_bank_projection_banner_snoozed_until: s.tracker_bank_projection_banner_snoozed_until || '',
tracker_show_search_sort: s.tracker_show_search_sort,
tracker_show_summary_cards: s.tracker_show_summary_cards,
tracker_show_safe_to_spend: s.tracker_show_safe_to_spend,
tracker_show_overdue_command_center: s.tracker_show_overdue_command_center,
tracker_show_drift_insights: s.tracker_show_drift_insights,
});
const handleSave = async () => {
setSaving(true);
try {
await api.saveSettings({
currency: settings.currency,
date_format: settings.date_format,
grace_period_days: settings.grace_period_days,
drift_threshold_pct: settings.drift_threshold_pct,
tracker_show_bank_projection_banner: settings.tracker_show_bank_projection_banner,
tracker_bank_projection_banner_snoozed_until: settings.tracker_bank_projection_banner_snoozed_until || '',
tracker_show_search_sort: settings.tracker_show_search_sort,
tracker_show_summary_cards: settings.tracker_show_summary_cards,
tracker_show_safe_to_spend: settings.tracker_show_safe_to_spend,
tracker_show_overdue_command_center: settings.tracker_show_overdue_command_center,
tracker_show_drift_insights: settings.tracker_show_drift_insights,
});
toast.success('Settings saved.');
} catch (err) {
// Auto-save: every change persists on its own no Save button. Toggles and
// selects feel instant (short debounce); typed inputs get a longer one so we
// don't save half-typed numbers.
const { status: saveStatus, schedule } = useAutoSave(
(payload) => api.saveSettings(payload).catch((err) => {
toast.error(err.message || 'Failed to save settings.');
} finally {
setSaving(false);
}
};
throw err;
}),
);
const set = (k, v, delay) => setSettings((p) => {
const next = { ...p, [k]: v };
schedule(buildPayload(next), delay);
return next;
});
const setTyped = (k, v) => set(k, v, 900); // for keystroke-driven inputs
if (loading) {
return (
@ -352,10 +358,17 @@ export default function SettingsPage() {
return (
<div>
{/* Page header — flat on background */}
<div className="mb-8">
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<p className="text-sm text-muted-foreground mt-0.5">Manage your display, billing, and notification preferences</p>
{/* Page header — flat on background, live save status on the right */}
<div className="mb-8 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Manage your display, billing, and notification preferences · changes save automatically
</p>
</div>
<div className="pt-1.5">
<SaveStatus status={saveStatus} />
</div>
</div>
{/* Appearance */}
@ -471,7 +484,7 @@ export default function SettingsPage() {
min={0}
max={30}
value={settings.grace_period_days}
onChange={(e) => set('grace_period_days', parseInt(e.target.value, 10) || 0)}
onChange={(e) => setTyped('grace_period_days', parseInt(e.target.value, 10) || 0)}
className="w-20"
/>
<span className="text-sm text-muted-foreground">days</span>
@ -488,7 +501,7 @@ export default function SettingsPage() {
max={25}
step={1}
value={settings.drift_threshold_pct ?? '5'}
onChange={(e) => set('drift_threshold_pct', e.target.value)}
onChange={(e) => setTyped('drift_threshold_pct', e.target.value)}
className="w-20 font-mono"
/>
<span className="text-sm text-muted-foreground">%</span>
@ -496,14 +509,7 @@ export default function SettingsPage() {
</SettingRow>
</SectionCard>
{/* Save button — right-aligned below all cards */}
<div className="flex justify-end mt-6">
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save Settings'}
</Button>
</div>
{/* Notifications — email + push reminder preferences (save independently) */}
{/* Notifications — email + push reminder preferences (auto-save too) */}
<div id="notifications" className="mt-6 scroll-mt-24">
<NotificationsSection />
</div>

681
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "bill-tracker",
"version": "0.38.4",
"version": "0.39.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bill-tracker",
"version": "0.38.4",
"version": "0.39.0",
"license": "ISC",
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.2",
@ -53,9 +53,11 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"concurrently": "^9.1.0",
"jsdom": "^29.1.1",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"vite": "^5.4.10",
@ -92,6 +94,57 @@
"ajv": ">=8"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/code-frame": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@ -1666,6 +1719,159 @@
"node": ">=6.9.0"
}
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
"integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.3.tgz",
"integrity": "sha512-DOgvIPkikIOixQRlD4YF31VN6fLLUTdrzhfRbis8vm0kMTgIbEPX0Ip/YX9fOeV9iywAS4sUUbTclpan7yYP8Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.0.2",
"@csstools/css-calc": "^3.2.1"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz",
"integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@ -2091,6 +2297,24 @@
"node": ">=12"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.1",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
"integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
@ -5285,6 +5509,55 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/react": {
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@testing-library/dom": "^10.0.0",
"@types/react": "^18.0.0 || ^19.0.0",
"@types/react-dom": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
"version": "3.0.0-pre1",
"resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz",
@ -5312,6 +5585,14 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -5679,6 +5960,17 @@
"node": ">=10"
}
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/array-buffer-byte-length": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz",
@ -5943,6 +6235,16 @@
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -6586,6 +6888,20 @@
"node": ">=8"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -6605,6 +6921,58 @@
"license": "MIT",
"peer": true
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/data-urls/node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/data-urls/node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/data-urls/node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@ -6685,6 +7053,13 @@
"node": ">=0.10.0"
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/decode-named-character-reference": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
@ -6842,6 +7217,14 @@
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"license": "MIT"
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -6909,6 +7292,19 @@
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-abstract": {
"version": "1.24.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
@ -7944,6 +8340,19 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-url-attributes": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@ -8401,6 +8810,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@ -8642,6 +9058,95 @@
"dev": true,
"license": "MIT"
},
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsdom/node_modules/lru-cache": {
"version": "11.5.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz",
"integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/jsdom/node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/jsdom/node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/jsdom/node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@ -9054,6 +9559,17 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@ -9353,6 +9869,13 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@ -10412,6 +10935,19 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -10737,6 +11273,36 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/property-information": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@ -10976,6 +11542,14 @@
"react": "^19.2.7"
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@ -11593,6 +12167,19 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@ -12268,6 +12855,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwind-merge": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
@ -12509,6 +13103,26 @@
"node": ">=14.0.0"
}
},
"node_modules/tldts": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz",
"integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.4.2"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.4.2",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz",
"integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==",
"dev": true,
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -12530,6 +13144,19 @@
"node": ">=0.6"
}
},
"node_modules/tough-cookie": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
@ -12735,6 +13362,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/undici": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz",
"integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
@ -13344,6 +13981,19 @@
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/webidl-conversions": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
@ -13351,6 +14001,16 @@
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
@ -13774,6 +14434,23 @@
"node": ">=0.8"
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -1,6 +1,6 @@
{
"name": "bill-tracker",
"version": "0.38.4",
"version": "0.39.0",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {
@ -61,9 +61,11 @@
"xlsx": "^0.18.5"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"concurrently": "^9.1.0",
"jsdom": "^29.1.1",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"vite": "^5.4.10",

View File

@ -7,6 +7,7 @@ const {
getTransactionForUser,
} = require('../services/transactionService');
const { checkTransaction: advisoryCheck } = require('../services/advisoryFilterService');
const { getBankSyncConfig } = require('../services/bankSyncConfigService');
const {
ignoreTransaction,
matchTransactionToBill,
@ -272,6 +273,81 @@ function sendTransactionServiceError(res, err, fallbackMessage = 'Transaction op
return res.status(500).json(standardizeError(fallbackMessage, 'TRANSACTION_ERROR'));
}
function emptyBankLedger(enabled, hasConnections = false) {
return {
enabled: Boolean(enabled),
has_connections: Boolean(hasConnections),
sources: [],
accounts: [],
summary: {
total: 0,
inflow: 0,
outflow: 0,
net: 0,
pending: 0,
matched: 0,
unmatched: 0,
latest_date: null,
},
transactions: [],
total: 0,
limit: 50,
offset: 0,
};
}
function bankLedgerWhere(query, userId) {
const where = [
't.user_id = ?',
"ds.type = 'provider_sync'",
"ds.provider = 'simplefin'",
'(t.account_id IS NULL OR fa.id IS NULL OR fa.monitored = 1)',
];
const params = [userId];
if (query.account_id && query.account_id !== 'all') {
const parsed = parseInteger(query.account_id, 'account_id');
if (parsed.error) return { error: parsed.error };
where.push('t.account_id = ?');
params.push(parsed.value);
}
const flow = query.flow ? String(query.flow).trim() : 'all';
if (!['all', 'in', 'out', 'pending', 'matched', 'unmatched', 'ignored'].includes(flow)) {
return { error: standardizeError('flow must be all, in, out, pending, matched, unmatched, or ignored', 'VALIDATION_ERROR', 'flow') };
}
if (flow === 'in') where.push('t.amount > 0');
if (flow === 'out') where.push('t.amount < 0');
if (flow === 'pending') where.push('t.pending = 1');
if (flow === 'matched') where.push("t.match_status = 'matched'");
if (flow === 'unmatched') where.push("t.match_status = 'unmatched' AND t.ignored = 0");
if (flow === 'ignored') where.push('t.ignored = 1');
if (query.start_date) {
const parsed = parseDate(query.start_date, 'start_date');
if (parsed.error) return { error: parsed.error };
where.push("COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) >= ?");
params.push(parsed.value);
}
if (query.end_date) {
const parsed = parseDate(query.end_date, 'end_date');
if (parsed.error) return { error: parsed.error };
where.push("COALESCE(t.posted_date, substr(t.transacted_at, 1, 10)) <= ?");
params.push(parsed.value);
}
if (query.q) {
const q = `%${String(query.q).trim()}%`;
where.push(`(
t.description LIKE ? OR t.payee LIKE ? OR t.memo LIKE ? OR t.category LIKE ?
OR fa.name LIKE ? OR fa.org_name LIKE ? OR b.name LIKE ?
)`);
params.push(q, q, q, q, q, q, q);
}
return { whereClause: where.join(' AND '), params };
}
// GET /api/transactions
router.get('/', (req, res) => {
const db = getDb();
@ -400,6 +476,109 @@ router.get('/', (req, res) => {
});
});
// GET /api/transactions/bank-ledger
router.get('/bank-ledger', (req, res) => {
const config = getBankSyncConfig();
const page = parseLimitOffset(req.query);
if (page.error) return res.status(400).json(page.error);
const db = getDb();
const sources = db.prepare(`
SELECT id, user_id, type, provider, name, status, last_sync_at, created_at, updated_at
FROM data_sources
WHERE user_id = ? AND type = 'provider_sync' AND provider = 'simplefin'
ORDER BY updated_at DESC, id DESC
`).all(req.user.id);
if (!config.enabled || sources.length === 0) {
return res.json({ ...emptyBankLedger(config.enabled, sources.length > 0), sources, limit: page.limit, offset: page.offset });
}
const accounts = db.prepare(`
SELECT
fa.id, fa.data_source_id, fa.name, fa.org_name, fa.account_type, fa.currency,
fa.balance, fa.available_balance, fa.monitored, fa.updated_at,
ds.name AS source_name, ds.status AS source_status, ds.last_sync_at,
COUNT(t.id) AS transaction_count,
MAX(COALESCE(t.posted_date, substr(t.transacted_at, 1, 10))) AS last_transaction_date
FROM financial_accounts fa
JOIN data_sources ds ON ds.id = fa.data_source_id AND ds.user_id = fa.user_id
LEFT JOIN transactions t ON t.account_id = fa.id AND t.user_id = fa.user_id
WHERE fa.user_id = ?
AND fa.monitored = 1
AND ds.type = 'provider_sync'
AND ds.provider = 'simplefin'
GROUP BY fa.id
ORDER BY COALESCE(fa.org_name, ''), fa.name
`).all(req.user.id);
const filtered = bankLedgerWhere(req.query, req.user.id);
if (filtered.error) return res.status(400).json(filtered.error);
const SORT_COLUMNS = {
date: "COALESCE(t.posted_date, substr(t.transacted_at, 1, 10), t.created_at)",
amount: 't.amount',
merchant: "LOWER(COALESCE(t.payee, t.description, t.memo, ''))",
account: "LOWER(COALESCE(fa.org_name, fa.name, ''))",
status: 't.match_status',
};
const sortBy = SORT_COLUMNS[req.query.sort_by] ? req.query.sort_by : 'date';
const sortDir = req.query.sort_dir === 'asc' ? 'ASC' : 'DESC';
const orderBy = `${SORT_COLUMNS[sortBy]} ${sortDir}, t.id ${sortDir}`;
const joins = `
FROM transactions t
JOIN data_sources ds ON ds.id = t.data_source_id AND ds.user_id = t.user_id
LEFT JOIN financial_accounts fa ON fa.id = t.account_id AND fa.user_id = t.user_id
LEFT JOIN bills b ON b.id = t.matched_bill_id AND b.user_id = t.user_id AND b.deleted_at IS NULL
`;
const summary = db.prepare(`
SELECT
COUNT(*) AS total,
COALESCE(SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE 0 END), 0) AS inflow,
COALESCE(SUM(CASE WHEN t.amount < 0 THEN ABS(t.amount) ELSE 0 END), 0) AS outflow,
COALESCE(SUM(t.amount), 0) AS net,
COALESCE(SUM(CASE WHEN t.pending = 1 THEN 1 ELSE 0 END), 0) AS pending,
COALESCE(SUM(CASE WHEN t.match_status = 'matched' THEN 1 ELSE 0 END), 0) AS matched,
COALESCE(SUM(CASE WHEN t.match_status = 'unmatched' AND t.ignored = 0 THEN 1 ELSE 0 END), 0) AS unmatched,
MAX(COALESCE(t.posted_date, substr(t.transacted_at, 1, 10))) AS latest_date
${joins}
WHERE ${filtered.whereClause}
`).get(...filtered.params);
const total = summary.total;
const rows = db.prepare(`
SELECT
t.id, t.user_id, t.data_source_id, t.account_id, t.provider_transaction_id,
t.source_type, t.transaction_type, t.posted_date, t.transacted_at, t.amount,
t.currency, t.description, t.payee, t.memo, t.category, t.matched_bill_id,
t.match_status, t.ignored, t.pending, t.created_at, t.updated_at,
ds.type AS data_source_type, ds.provider AS data_source_provider,
ds.name AS data_source_name, ds.status AS data_source_status,
fa.name AS account_name, fa.org_name AS account_org_name,
fa.account_type AS account_type, fa.balance AS account_balance,
fa.available_balance AS account_available_balance,
b.name AS matched_bill_name
${joins}
WHERE ${filtered.whereClause}
ORDER BY ${orderBy}
LIMIT ? OFFSET ?
`).all(...filtered.params, page.limit, page.offset);
res.json({
enabled: true,
has_connections: true,
sources,
accounts,
summary,
transactions: rows.map(row => decorateTransaction(row)),
total,
limit: page.limit,
offset: page.offset,
});
});
// POST /api/transactions/manual
router.post('/manual', (req, res) => {
const db = getDb();

View File

@ -68,7 +68,7 @@ export default defineConfig({
// Server tests stay on node:test (`npm run test`); client tests run with
// `npm run test:client`; `npm run test:all` runs both.
test: {
environment: 'node',
include: ['client/**/*.test.js'],
environment: 'node', // hook/component tests opt into jsdom via @vitest-environment
include: ['client/**/*.test.{js,jsx}'],
},
});