feat: bank sync section, data sources route, subscription page updates, package updates

This commit is contained in:
null 2026-06-07 02:03:00 -05:00
parent d9cf499dba
commit 4f5a3d0cff
6 changed files with 156 additions and 27 deletions

View File

@ -292,6 +292,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
const [enabled, setEnabled] = useState(null); const [enabled, setEnabled] = useState(null);
const [syncDays, setSyncDays] = useState(30); const [syncDays, setSyncDays] = useState(30);
const [seedDays, setSeedDays] = useState(44); const [seedDays, setSeedDays] = useState(44);
const [serverTz, setServerTz] = useState(null);
const [connections, setConnections] = useState([]); const [connections, setConnections] = useState([]);
const [accountsBySource, setAccountsBySource] = useState({}); const [accountsBySource, setAccountsBySource] = useState({});
const [accountsLoading, setAccountsLoading] = useState({}); const [accountsLoading, setAccountsLoading] = useState({});
@ -382,6 +383,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
setEnabled(status.enabled); setEnabled(status.enabled);
setSyncDays(status.sync_days ?? 30); setSyncDays(status.sync_days ?? 30);
setSeedDays(status.seed_days ?? 44); setSeedDays(status.seed_days ?? 44);
setServerTz(status.timezone || null);
const conns = Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : []; const conns = Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : [];
setConnections(conns); setConnections(conns);
onConnectionChange?.(conns[0] || null); onConnectionChange?.(conns[0] || null);
@ -656,7 +658,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
</div> </div>
{/* Sync status grid */} {/* Sync status grid */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 text-xs"> <div className="grid grid-cols-2 gap-2 sm:grid-cols-4 text-xs">
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2"> <div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
<p className="text-muted-foreground">Last sync</p> <p className="text-muted-foreground">Last sync</p>
<p className="font-medium mt-0.5">{fmtDate(conn.last_sync_at)}</p> <p className="font-medium mt-0.5">{fmtDate(conn.last_sync_at)}</p>
@ -674,6 +676,10 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
<p className="text-muted-foreground">History window</p> <p className="text-muted-foreground">History window</p>
<p className="font-medium mt-0.5">{syncDays} days</p> <p className="font-medium mt-0.5">{syncDays} days</p>
</div> </div>
<div className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2">
<p className="text-muted-foreground">Server timezone</p>
<p className="font-medium mt-0.5 truncate" title={serverTz || ''}>{serverTz || '—'}</p>
</div>
</div> </div>
{/* Accounts section */} {/* Accounts section */}

View File

@ -0,0 +1,21 @@
import { useCallback, useEffect, useState } from 'react';
/**
* Polyfill for React 19's useOptimistic.
* Shows optimistic state immediately; reconciles when passthrough changes.
*/
export function useOptimistic(passthrough, reducer) {
const [optimistic, setOptimistic] = useState(passthrough);
// Whenever the server-confirmed state lands, sync it in.
useEffect(() => {
setOptimistic(passthrough);
}, [passthrough]);
const dispatch = useCallback(
action => setOptimistic(current => reducer(current, action)),
[reducer],
);
return [optimistic, dispatch];
}

View File

@ -6,6 +6,7 @@ import {
CalendarDays, CalendarDays,
CheckCircle2, CheckCircle2,
CheckCircle, CheckCircle,
ChevronDown,
Cloud, Cloud,
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
@ -159,15 +160,41 @@ function subscriptionSummaryFromList(subscriptions) {
}; };
} }
function cadenceIndex(item) { // Extended group order: monthly bills split by 1st/15th pay bucket
const index = CADENCE_ORDER.indexOf(normalizedCadence(item)); const GROUP_ORDER = ['weekly', 'biweekly', 'monthly-1st', 'monthly-15th', 'quarterly', 'annual', 'other'];
return index >= 0 ? index : CADENCE_ORDER.length - 1; const GROUP_LABELS = {
'weekly': 'Weekly',
'biweekly': 'Biweekly',
'monthly-1st': '1st · Due days 114',
'monthly-15th': '15th · Due days 1531',
'quarterly': 'Quarterly',
'annual': 'Annual',
'other': 'Other',
};
function subscriptionGroup(item) {
const cadence = normalizedCadence(item);
if (cadence === 'monthly') {
return (Number(item.due_day) || 1) <= 14 ? 'monthly-1st' : 'monthly-15th';
}
return cadence;
}
function groupIndex(item) {
const idx = GROUP_ORDER.indexOf(subscriptionGroup(item));
return idx >= 0 ? idx : GROUP_ORDER.length - 1;
}
function daysUntil(dateStr) {
if (!dateStr) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
return Math.round((new Date(dateStr + 'T00:00:00') - today) / 86400000);
} }
function sortSubscriptionsByCadence(items) { function sortSubscriptionsByCadence(items) {
return [...items].sort((a, b) => ( return [...items].sort((a, b) => (
cadenceIndex(a) - cadenceIndex(b) groupIndex(a) - groupIndex(b)
|| String(a.next_due_date || '').localeCompare(String(b.next_due_date || ''))
|| (Number(a.due_day) || 0) - (Number(b.due_day) || 0) || (Number(a.due_day) || 0) - (Number(b.due_day) || 0)
|| String(a.name || '').localeCompare(String(b.name || '')) || String(a.name || '').localeCompare(String(b.name || ''))
)); ));
@ -349,7 +376,21 @@ function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps, busy
</div> </div>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-medium text-muted-foreground"> <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-medium text-muted-foreground">
<span>{item.category_name || 'Uncategorized'}</span> <span>{item.category_name || 'Uncategorized'}</span>
<span>Due {fmtDate(item.next_due_date)}</span> {(() => {
const days = daysUntil(item.next_due_date);
const label = days === null ? null
: days < 0 ? `${Math.abs(days)}d overdue`
: days === 0 ? 'Due today'
: days === 1 ? 'Due tomorrow'
: `Due in ${days}d`;
const cls = days === null ? 'text-muted-foreground'
: days <= 1 ? 'text-rose-500'
: days <= 7 ? 'text-amber-500'
: 'text-emerald-600 dark:text-emerald-400';
return label ? (
<span className={cls}>{label} · {fmtDate(item.next_due_date)}</span>
) : null;
})()}
<span className="capitalize">{cycleLabel(item)}</span> <span className="capitalize">{cycleLabel(item)}</span>
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
<Tooltip> <Tooltip>
@ -970,6 +1011,9 @@ export default function SubscriptionsPage() {
const [dropTargetId, setDropTargetId] = useState(null); const [dropTargetId, setDropTargetId] = useState(null);
const [movingBillId, setMovingBillId] = useState(null); const [movingBillId, setMovingBillId] = useState(null);
const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference(); const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();
const [collapsedGroups, setCollapsedGroups] = useState(new Set());
const cardHeaderRef = useRef(null);
const [cardHeaderHeight, setCardHeaderHeight] = useState(0);
const [txQuery, setTxQuery] = useState(''); const [txQuery, setTxQuery] = useState('');
const [txResults, setTxResults] = useState([]); const [txResults, setTxResults] = useState([]);
@ -1025,6 +1069,14 @@ export default function SubscriptionsPage() {
localStorage.setItem(SUBSCRIPTION_SORT_KEY, subscriptionSort); localStorage.setItem(SUBSCRIPTION_SORT_KEY, subscriptionSort);
}, [subscriptionSort]); }, [subscriptionSort]);
useEffect(() => {
const el = cardHeaderRef.current;
if (!el) return;
const obs = new ResizeObserver(() => setCardHeaderHeight(el.offsetHeight));
obs.observe(el);
return () => obs.disconnect();
}, []);
useEffect(() => { useEffect(() => {
clearTimeout(txDebounce.current); clearTimeout(txDebounce.current);
const q = txQuery.trim(); const q = txQuery.trim();
@ -1320,34 +1372,47 @@ export default function SubscriptionsPage() {
)); ));
} }
return CADENCE_ORDER.flatMap(cadence => { return GROUP_ORDER.flatMap(groupKey => {
const cadenceItems = group.filter(item => normalizedCadence(item) === cadence); const groupItems = group.filter(item => subscriptionGroup(item) === groupKey);
if (cadenceItems.length === 0) return []; if (groupItems.length === 0) return [];
const sectionKey = `${activeState ? 'active' : 'paused'}-${groupKey}`;
const isCollapsed = collapsedGroups.has(sectionKey);
const monthlySum = groupItems.reduce((s, i) => s + (Number(i.monthly_equivalent) || 0), 0);
return [ return [
<div <button
key={`${activeState ? 'active' : 'paused'}-${cadence}-heading`} key={`${sectionKey}-heading`}
className="border-b border-border/40 bg-muted/20 px-4 py-2" type="button"
onClick={() => setCollapsedGroups(prev => {
const next = new Set(prev);
next.has(sectionKey) ? next.delete(sectionKey) : next.add(sectionKey);
return next;
})}
style={{ top: cardHeaderHeight }}
className="sticky z-10 w-full border-b border-t border-border/40 bg-card/95 backdrop-blur-sm px-4 py-2 text-left transition-colors hover:bg-muted/30"
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"> <p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{CADENCE_LABELS[cadence]} {GROUP_LABELS[groupKey]}
</p> </p>
<span className="text-[11px] font-medium text-muted-foreground tabular-nums"> <div className="flex items-center gap-3">
{cadenceItems.length} <span className="text-[11px] font-medium text-muted-foreground tabular-nums">
</span> {fmt(monthlySum)}/mo · {groupItems.length}
</span>
<ChevronDown className={cn('h-3.5 w-3.5 text-muted-foreground/60 transition-transform duration-150', isCollapsed && '-rotate-90')} />
</div>
</div> </div>
</div>, </button>,
...cadenceItems.map((item, index) => ( ...(!isCollapsed ? groupItems.map((item, index) => (
<SubscriptionRow <SubscriptionRow
key={item.id} key={item.id}
item={item} item={item}
onEdit={bill => setModal({ bill })} onEdit={bill => setModal({ bill })}
onToggle={toggleSubscription} onToggle={toggleSubscription}
busy={busyId === `toggle-${item.id}`} busy={busyId === `toggle-${item.id}`}
moveControls={moveControlsForGroup(cadenceItems, activeState)(item, index)} moveControls={moveControlsForGroup(groupItems, activeState)(item, index)}
dragProps={dragPropsForGroup(cadenceItems, activeState)(item, index)} dragProps={dragPropsForGroup(groupItems, activeState)(item, index)}
/> />
)), )) : []),
]; ];
}); });
} }
@ -1384,8 +1449,8 @@ export default function SubscriptionsPage() {
</div> </div>
<div className="grid min-w-0 gap-5 2xl:grid-cols-[minmax(0,1fr)_minmax(360px,420px)]"> <div className="grid min-w-0 gap-5 2xl:grid-cols-[minmax(0,1fr)_minmax(360px,420px)]">
<Card className="overflow-hidden"> <Card>
<CardHeader className="px-4 pb-3 sm:px-6"> <CardHeader ref={cardHeaderRef} className="sticky top-0 z-20 rounded-t-xl border-b border-border/50 bg-card/95 px-4 pb-3 backdrop-blur-sm sm:px-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0"> <div className="min-w-0">
<CardTitle className="text-base">Tracked Subscriptions</CardTitle> <CardTitle className="text-base">Tracked Subscriptions</CardTitle>
@ -1464,6 +1529,13 @@ export default function SubscriptionsPage() {
) : ( ) : (
<> <>
{renderSubscriptionRows(sortedActive, true)} {renderSubscriptionRows(sortedActive, true)}
{sortedPaused.length > 0 && subscriptionSort === 'cadence' && (
<div className="border-t-2 border-border/60 bg-muted/20 px-4 py-1.5">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/60">
Paused · {sortedPaused.length}
</p>
</div>
)}
{renderSubscriptionRows(sortedPaused, false)} {renderSubscriptionRows(sortedPaused, false)}
</> </>
)} )}

28
package-lock.json generated
View File

@ -25,6 +25,7 @@
"@simplewebauthn/server": "^13.0.0", "@simplewebauthn/server": "^13.0.0",
"@tanstack/react-query": "^5.100.9", "@tanstack/react-query": "^5.100.9",
"@tanstack/react-query-devtools": "^5.100.9", "@tanstack/react-query-devtools": "^5.100.9",
"@tanstack/react-virtual": "^3.14.2",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^12.9.0", "better-sqlite3": "^12.9.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
@ -4122,6 +4123,33 @@
"react": "^18 || ^19" "react": "^18 || ^19"
} }
}, },
"node_modules/@tanstack/react-virtual": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz",
"integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.17.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.17.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz",
"integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@trickfilm400/rollup-plugin-off-main-thread": { "node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
"version": "3.0.0-pre1", "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", "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz",

View File

@ -26,8 +26,11 @@
"@radix-ui/react-switch": "^1.1.1", "@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.3",
"@simplewebauthn/browser": "^13.0.0",
"@simplewebauthn/server": "^13.0.0",
"@tanstack/react-query": "^5.100.9", "@tanstack/react-query": "^5.100.9",
"@tanstack/react-query-devtools": "^5.100.9", "@tanstack/react-query-devtools": "^5.100.9",
"@tanstack/react-virtual": "^3.14.2",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^12.9.0", "better-sqlite3": "^12.9.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
@ -40,8 +43,6 @@
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"nodemailer": "^8.0.9", "nodemailer": "^8.0.9",
"openid-client": "^5.7.1", "openid-client": "^5.7.1",
"@simplewebauthn/browser": "^13.0.0",
"@simplewebauthn/server": "^13.0.0",
"otplib": "^13.4.1", "otplib": "^13.4.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^18.3.1", "react": "^18.3.1",

View File

@ -102,7 +102,8 @@ router.get('/simplefin/status', (req, res) => {
'SELECT 1 FROM bill_merchant_rules WHERE user_id = ? LIMIT 1' 'SELECT 1 FROM bill_merchant_rules WHERE user_id = ? LIMIT 1'
).get(req.user.id); ).get(req.user.id);
res.json({ enabled, sync_days, seed_days, has_connections: hasConnections, has_merchant_rules: hasMerchantRules }); const timezone = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || null;
res.json({ enabled, sync_days, seed_days, has_connections: hasConnections, has_merchant_rules: hasMerchantRules, timezone });
}); });
// ─── POST /api/data-sources/simplefin/connect ──────────────────────────────── // ─── POST /api/data-sources/simplefin/connect ────────────────────────────────