feat: bank sync section, data sources route, subscription page updates, package updates
This commit is contained in:
parent
d9cf499dba
commit
4f5a3d0cff
|
|
@ -292,6 +292,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
|||
const [enabled, setEnabled] = useState(null);
|
||||
const [syncDays, setSyncDays] = useState(30);
|
||||
const [seedDays, setSeedDays] = useState(44);
|
||||
const [serverTz, setServerTz] = useState(null);
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [accountsBySource, setAccountsBySource] = useState({});
|
||||
const [accountsLoading, setAccountsLoading] = useState({});
|
||||
|
|
@ -382,6 +383,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
|||
setEnabled(status.enabled);
|
||||
setSyncDays(status.sync_days ?? 30);
|
||||
setSeedDays(status.seed_days ?? 44);
|
||||
setServerTz(status.timezone || null);
|
||||
const conns = Array.isArray(sources) ? sources.filter(s => s.provider === 'simplefin') : [];
|
||||
setConnections(conns);
|
||||
onConnectionChange?.(conns[0] || null);
|
||||
|
|
@ -656,7 +658,7 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })
|
|||
</div>
|
||||
|
||||
{/* 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">
|
||||
<p className="text-muted-foreground">Last sync</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="font-medium mt-0.5">{syncDays} days</p>
|
||||
</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>
|
||||
|
||||
{/* Accounts section */}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
CalendarDays,
|
||||
CheckCircle2,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
Cloud,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
|
|
@ -159,15 +160,41 @@ function subscriptionSummaryFromList(subscriptions) {
|
|||
};
|
||||
}
|
||||
|
||||
function cadenceIndex(item) {
|
||||
const index = CADENCE_ORDER.indexOf(normalizedCadence(item));
|
||||
return index >= 0 ? index : CADENCE_ORDER.length - 1;
|
||||
// Extended group order: monthly bills split by 1st/15th pay bucket
|
||||
const GROUP_ORDER = ['weekly', 'biweekly', 'monthly-1st', 'monthly-15th', 'quarterly', 'annual', 'other'];
|
||||
const GROUP_LABELS = {
|
||||
'weekly': 'Weekly',
|
||||
'biweekly': 'Biweekly',
|
||||
'monthly-1st': '1st · Due days 1–14',
|
||||
'monthly-15th': '15th · Due days 15–31',
|
||||
'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) {
|
||||
return [...items].sort((a, b) => (
|
||||
cadenceIndex(a) - cadenceIndex(b)
|
||||
|| String(a.next_due_date || '').localeCompare(String(b.next_due_date || ''))
|
||||
groupIndex(a) - groupIndex(b)
|
||||
|| (Number(a.due_day) || 0) - (Number(b.due_day) || 0)
|
||||
|| String(a.name || '').localeCompare(String(b.name || ''))
|
||||
));
|
||||
|
|
@ -349,7 +376,21 @@ function SubscriptionRow({ item, onEdit, onToggle, moveControls, dragProps, busy
|
|||
</div>
|
||||
<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>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>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
|
|
@ -970,6 +1011,9 @@ export default function SubscriptionsPage() {
|
|||
const [dropTargetId, setDropTargetId] = useState(null);
|
||||
const [movingBillId, setMovingBillId] = useState(null);
|
||||
const [searchPanelCollapsed, setSearchPanelCollapsed] = useSearchPanelPreference();
|
||||
const [collapsedGroups, setCollapsedGroups] = useState(new Set());
|
||||
const cardHeaderRef = useRef(null);
|
||||
const [cardHeaderHeight, setCardHeaderHeight] = useState(0);
|
||||
|
||||
const [txQuery, setTxQuery] = useState('');
|
||||
const [txResults, setTxResults] = useState([]);
|
||||
|
|
@ -1025,6 +1069,14 @@ export default function SubscriptionsPage() {
|
|||
localStorage.setItem(SUBSCRIPTION_SORT_KEY, subscriptionSort);
|
||||
}, [subscriptionSort]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = cardHeaderRef.current;
|
||||
if (!el) return;
|
||||
const obs = new ResizeObserver(() => setCardHeaderHeight(el.offsetHeight));
|
||||
obs.observe(el);
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
clearTimeout(txDebounce.current);
|
||||
const q = txQuery.trim();
|
||||
|
|
@ -1320,34 +1372,47 @@ export default function SubscriptionsPage() {
|
|||
));
|
||||
}
|
||||
|
||||
return CADENCE_ORDER.flatMap(cadence => {
|
||||
const cadenceItems = group.filter(item => normalizedCadence(item) === cadence);
|
||||
if (cadenceItems.length === 0) return [];
|
||||
return GROUP_ORDER.flatMap(groupKey => {
|
||||
const groupItems = group.filter(item => subscriptionGroup(item) === groupKey);
|
||||
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 [
|
||||
<div
|
||||
key={`${activeState ? 'active' : 'paused'}-${cadence}-heading`}
|
||||
className="border-b border-border/40 bg-muted/20 px-4 py-2"
|
||||
<button
|
||||
key={`${sectionKey}-heading`}
|
||||
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">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{CADENCE_LABELS[cadence]}
|
||||
{GROUP_LABELS[groupKey]}
|
||||
</p>
|
||||
<span className="text-[11px] font-medium text-muted-foreground tabular-nums">
|
||||
{cadenceItems.length}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[11px] font-medium text-muted-foreground tabular-nums">
|
||||
{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>,
|
||||
...cadenceItems.map((item, index) => (
|
||||
</button>,
|
||||
...(!isCollapsed ? groupItems.map((item, index) => (
|
||||
<SubscriptionRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onEdit={bill => setModal({ bill })}
|
||||
onToggle={toggleSubscription}
|
||||
busy={busyId === `toggle-${item.id}`}
|
||||
moveControls={moveControlsForGroup(cadenceItems, activeState)(item, index)}
|
||||
dragProps={dragPropsForGroup(cadenceItems, activeState)(item, index)}
|
||||
moveControls={moveControlsForGroup(groupItems, activeState)(item, index)}
|
||||
dragProps={dragPropsForGroup(groupItems, activeState)(item, index)}
|
||||
/>
|
||||
)),
|
||||
)) : []),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
|
@ -1384,8 +1449,8 @@ export default function SubscriptionsPage() {
|
|||
</div>
|
||||
|
||||
<div className="grid min-w-0 gap-5 2xl:grid-cols-[minmax(0,1fr)_minmax(360px,420px)]">
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader className="px-4 pb-3 sm:px-6">
|
||||
<Card>
|
||||
<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="min-w-0">
|
||||
<CardTitle className="text-base">Tracked Subscriptions</CardTitle>
|
||||
|
|
@ -1464,6 +1529,13 @@ export default function SubscriptionsPage() {
|
|||
) : (
|
||||
<>
|
||||
{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)}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
"@simplewebauthn/server": "^13.0.0",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"@tanstack/react-query-devtools": "^5.100.9",
|
||||
"@tanstack/react-virtual": "^3.14.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
|
|
@ -4122,6 +4123,33 @@
|
|||
"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": {
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -26,8 +26,11 @@
|
|||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@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-devtools": "^5.100.9",
|
||||
"@tanstack/react-virtual": "^3.14.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^12.9.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
|
|
@ -40,8 +43,6 @@
|
|||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^8.0.9",
|
||||
"openid-client": "^5.7.1",
|
||||
"@simplewebauthn/browser": "^13.0.0",
|
||||
"@simplewebauthn/server": "^13.0.0",
|
||||
"otplib": "^13.4.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
|
|
|
|||
|
|
@ -102,7 +102,8 @@ router.get('/simplefin/status', (req, res) => {
|
|||
'SELECT 1 FROM bill_merchant_rules WHERE user_id = ? LIMIT 1'
|
||||
).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 ────────────────────────────────
|
||||
|
|
|
|||
Loading…
Reference in New Issue