From 4f5a3d0cff487b152c6929c1ec392ab9565e8fd4 Mon Sep 17 00:00:00 2001 From: null Date: Sun, 7 Jun 2026 02:03:00 -0500 Subject: [PATCH] feat: bank sync section, data sources route, subscription page updates, package updates --- client/components/data/BankSyncSection.jsx | 8 +- client/hooks/useOptimistic.js | 21 ++++ client/pages/SubscriptionsPage.jsx | 118 +++++++++++++++++---- package-lock.json | 28 +++++ package.json | 5 +- routes/dataSources.js | 3 +- 6 files changed, 156 insertions(+), 27 deletions(-) create mode 100644 client/hooks/useOptimistic.js diff --git a/client/components/data/BankSyncSection.jsx b/client/components/data/BankSyncSection.jsx index 79b161a..19af440 100644 --- a/client/components/data/BankSyncSection.jsx +++ b/client/components/data/BankSyncSection.jsx @@ -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 = {} }) {/* Sync status grid */} -
+

Last sync

{fmtDate(conn.last_sync_at)}

@@ -674,6 +676,10 @@ export default function BankSyncSection({ onConnectionChange, cardProps = {} })

History window

{syncDays} days

+
+

Server timezone

+

{serverTz || '—'}

+
{/* Accounts section */} diff --git a/client/hooks/useOptimistic.js b/client/hooks/useOptimistic.js new file mode 100644 index 0000000..abd8915 --- /dev/null +++ b/client/hooks/useOptimistic.js @@ -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]; +} diff --git a/client/pages/SubscriptionsPage.jsx b/client/pages/SubscriptionsPage.jsx index caeb2e2..d38cf6a 100644 --- a/client/pages/SubscriptionsPage.jsx +++ b/client/pages/SubscriptionsPage.jsx @@ -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
{item.category_name || 'Uncategorized'} - Due {fmtDate(item.next_due_date)} + {(() => { + 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 ? ( + {label} · {fmtDate(item.next_due_date)} + ) : null; + })()} {cycleLabel(item)} @@ -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 [ -
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" >

- {CADENCE_LABELS[cadence]} + {GROUP_LABELS[groupKey]}

- - {cadenceItems.length} - +
+ + {fmt(monthlySum)}/mo · {groupItems.length} + + +
-
, - ...cadenceItems.map((item, index) => ( + , + ...(!isCollapsed ? groupItems.map((item, index) => ( 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() {
- - + +
Tracked Subscriptions @@ -1464,6 +1529,13 @@ export default function SubscriptionsPage() { ) : ( <> {renderSubscriptionRows(sortedActive, true)} + {sortedPaused.length > 0 && subscriptionSort === 'cadence' && ( +
+

+ Paused · {sortedPaused.length} +

+
+ )} {renderSubscriptionRows(sortedPaused, false)} )} diff --git a/package-lock.json b/package-lock.json index 3e12b65..0202b7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 72f8dde..a588626 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/routes/dataSources.js b/routes/dataSources.js index bdacaff..9cdb02c 100644 --- a/routes/dataSources.js +++ b/routes/dataSources.js @@ -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 ────────────────────────────────