diff --git a/client/pages/SubscriptionsPage.jsx b/client/pages/SubscriptionsPage.jsx index d38cf6a..f8322a0 100644 --- a/client/pages/SubscriptionsPage.jsx +++ b/client/pages/SubscriptionsPage.jsx @@ -48,6 +48,8 @@ import BillModal from '@/components/BillModal'; import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog'; import { getLinkImportPref } from '@/pages/SettingsPage'; import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference'; +import { useOptimistic } from '@/hooks/useOptimistic'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder'; const TYPE_LABELS = { @@ -1100,15 +1102,14 @@ export default function SubscriptionsPage() { } async function toggleSubscription(item) { - setBusyId(`toggle-${item.id}`); + const newActive = !item.active; + addOptimisticSub({ id: item.id, active: newActive }); // instant — no spinner try { - await api.updateSubscription(item.id, { active: !item.active }); - toast.success(item.active ? 'Subscription paused' : 'Subscription resumed'); - await load(); + await api.updateSubscription(item.id, { active: newActive }); + await load(); // reconciles optimistic state } catch (err) { toast.error(err.message || 'Subscription could not be updated.'); - } finally { - setBusyId(null); + await load(); // revert via useOptimistic reconciliation } } @@ -1245,15 +1246,23 @@ export default function SubscriptionsPage() { const summary = data.summary || {}; const subscriptions = data.subscriptions || []; + + const [optimisticSubs, addOptimisticSub] = useOptimistic( + subscriptions, + useCallback((state, { id, active }) => state.map(s => s.id === id ? { ...s, active } : s), []), + ); + const filteredSubscriptions = useMemo(() => { const q = subSearch.trim().toLowerCase(); - if (!q) return subscriptions; - return subscriptions.filter(item => - String(item.name || '').toLowerCase().includes(q) || - String(item.subscription_type || '').toLowerCase().includes(q) || - String(item.category_name || '').toLowerCase().includes(q) - ); - }, [subscriptions, subSearch]); + const base = q + ? optimisticSubs.filter(item => + String(item.name || '').toLowerCase().includes(q) || + String(item.subscription_type || '').toLowerCase().includes(q) || + String(item.category_name || '').toLowerCase().includes(q) + ) + : optimisticSubs; + return base; + }, [optimisticSubs, subSearch]); const active = filteredSubscriptions.filter(item => item.active); const paused = filteredSubscriptions.filter(item => !item.active); const sortedActive = useMemo( @@ -1266,18 +1275,59 @@ export default function SubscriptionsPage() { ); const reorderEnabled = !loading && bills.length > 0 && subscriptionSort === 'custom'; + // Flat item list for virtualizer — cadence mode only (custom mode needs DOM order for drag-reorder) + const flatVirtualItems = useMemo(() => { + if (subscriptionSort !== 'cadence') return null; + const items = []; + const buildGroup = (group, activeState) => { + GROUP_ORDER.forEach(groupKey => { + const groupItems = group.filter(item => subscriptionGroup(item) === groupKey); + if (!groupItems.length) return; + const sectionKey = `${activeState ? 'active' : 'paused'}-${groupKey}`; + const monthlySum = groupItems.reduce((s, i) => s + (Number(i.monthly_equivalent) || 0), 0); + items.push({ type: 'group-header', sectionKey, groupKey, activeState, groupItems, monthlySum }); + if (!collapsedGroups.has(sectionKey)) { + groupItems.forEach((item, i) => items.push({ type: 'row', item, groupItems, activeState, i })); + } + }); + }; + buildGroup(sortedActive, true); + if (sortedPaused.length > 0) { + items.push({ type: 'paused-divider', count: sortedPaused.length }); + buildGroup(sortedPaused, false); + } + return items; + }, [sortedActive, sortedPaused, subscriptionSort, collapsedGroups]); + + const listRef = useRef(null); + const virtualizer = useVirtualizer({ + count: flatVirtualItems?.length ?? 0, + getScrollElement: () => listRef.current, + estimateSize: i => { + if (!flatVirtualItems) return 96; + const it = flatVirtualItems[i]; + if (it?.type === 'group-header') return 40; + if (it?.type === 'paused-divider') return 32; + return 96; + }, + overscan: 5, + enabled: !!flatVirtualItems, + }); + async function persistSubscriptionOrder(nextSubscriptions, nextBills, movedId) { + const prevData = data; + const prevBills = bills; setData(prev => ({ ...prev, subscriptions: nextSubscriptions })); setBills(nextBills); setMovingBillId(movedId); try { await api.reorderBills(reorderPayload(nextBills)); - toast.success('Subscription order saved'); await load(); - api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(err => console.error('[SubscriptionsPage] failed to refresh bills after reorder', err)); + api.allBills().then(b => setBills(Array.isArray(b) ? b : [])).catch(() => {}); } catch (err) { toast.error(err.message || 'Failed to save subscription order'); - await load(); + setData(prevData); + setBills(prevBills); } finally { setMovingBillId(null); } @@ -1357,64 +1407,75 @@ export default function SubscriptionsPage() { return q ? highConfidenceRecs.filter(r => r.name?.toLowerCase().includes(q)) : highConfidenceRecs; }, [highConfidenceRecs, recSearch]); - function renderSubscriptionRows(group, activeState) { - if (subscriptionSort !== 'cadence') { - return group.map((item, index) => ( - setModal({ bill })} - onToggle={toggleSubscription} - busy={busyId === `toggle-${item.id}`} - moveControls={moveControlsForGroup(group, activeState)(item, index)} - dragProps={dragPropsForGroup(group, activeState)(item, index)} - /> - )); - } - - 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 [ - , - ...(!isCollapsed ? groupItems.map((item, index) => ( - setModal({ bill })} - onToggle={toggleSubscription} - busy={busyId === `toggle-${item.id}`} - moveControls={moveControlsForGroup(groupItems, activeState)(item, index)} - dragProps={dragPropsForGroup(groupItems, activeState)(item, index)} - /> - )) : []), - ]; - }); + + + ); + } + + // Custom sort: normal DOM rendering (drag-reorder needs DOM order) + function renderCustomRows(group, activeState) { + return group.map((item, index) => ( + setModal({ bill })} + onToggle={toggleSubscription} + busy={false} + moveControls={moveControlsForGroup(group, activeState)(item, index)} + dragProps={dragPropsForGroup(group, activeState)(item, index)} + /> + )); + } + + // Cadence sort: virtualizer renders from flatVirtualItems + function renderVirtualItem(vRow) { + const it = flatVirtualItems[vRow.index]; + if (!it) return null; + if (it.type === 'paused-divider') { + return ( +
+

+ Paused · {it.count} +

+
+ ); + } + if (it.type === 'group-header') { + return renderGroupHeader(it.sectionKey, it.groupKey, it.groupItems, it.monthlySum); + } + return ( + setModal({ bill })} + onToggle={toggleSubscription} + busy={false} + moveControls={moveControlsForGroup(it.groupItems, it.activeState)(it.item, it.i)} + dragProps={dragPropsForGroup(it.groupItems, it.activeState)(it.item, it.i)} + /> + ); } return ( @@ -1513,7 +1574,7 @@ export default function SubscriptionsPage() { - + {loading ? (
{Array.from({ length: 4 }).map((_, index) => ( @@ -1526,17 +1587,33 @@ export default function SubscriptionsPage() {

No subscriptions tracked yet.

Add one manually or accept a SimpleFIN recommendation.

- ) : ( - <> - {renderSubscriptionRows(sortedActive, true)} - {sortedPaused.length > 0 && subscriptionSort === 'cadence' && ( -
-

- Paused · {sortedPaused.length} -

+ ) : subscriptionSort === 'cadence' ? ( + // Cadence mode: virtualised from flatVirtualItems +
+ {virtualizer.getVirtualItems().map(vRow => ( +
+ {renderVirtualItem(vRow)}
- )} - {renderSubscriptionRows(sortedPaused, false)} + ))} +
+ ) : ( + // Custom mode: normal DOM rendering so drag-reorder works + <> + {renderCustomRows(sortedActive, true)} + {renderCustomRows(sortedPaused, false)} )}