fix: SubscriptionsPage polish

This commit is contained in:
null 2026-06-07 02:06:31 -05:00
parent 4f5a3d0cff
commit 3b0f267ab3
1 changed files with 161 additions and 84 deletions

View File

@ -48,6 +48,8 @@ import BillModal from '@/components/BillModal';
import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog'; import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog';
import { getLinkImportPref } from '@/pages/SettingsPage'; import { getLinkImportPref } from '@/pages/SettingsPage';
import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference'; import { useSearchPanelPreference } from '@/hooks/useSearchPanelPreference';
import { useOptimistic } from '@/hooks/useOptimistic';
import { useVirtualizer } from '@tanstack/react-virtual';
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder'; import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
const TYPE_LABELS = { const TYPE_LABELS = {
@ -1100,15 +1102,14 @@ export default function SubscriptionsPage() {
} }
async function toggleSubscription(item) { async function toggleSubscription(item) {
setBusyId(`toggle-${item.id}`); const newActive = !item.active;
addOptimisticSub({ id: item.id, active: newActive }); // instant no spinner
try { try {
await api.updateSubscription(item.id, { active: !item.active }); await api.updateSubscription(item.id, { active: newActive });
toast.success(item.active ? 'Subscription paused' : 'Subscription resumed'); await load(); // reconciles optimistic state
await load();
} catch (err) { } catch (err) {
toast.error(err.message || 'Subscription could not be updated.'); toast.error(err.message || 'Subscription could not be updated.');
} finally { await load(); // revert via useOptimistic reconciliation
setBusyId(null);
} }
} }
@ -1245,15 +1246,23 @@ export default function SubscriptionsPage() {
const summary = data.summary || {}; const summary = data.summary || {};
const subscriptions = data.subscriptions || []; 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 filteredSubscriptions = useMemo(() => {
const q = subSearch.trim().toLowerCase(); const q = subSearch.trim().toLowerCase();
if (!q) return subscriptions; const base = q
return subscriptions.filter(item => ? optimisticSubs.filter(item =>
String(item.name || '').toLowerCase().includes(q) || String(item.name || '').toLowerCase().includes(q) ||
String(item.subscription_type || '').toLowerCase().includes(q) || String(item.subscription_type || '').toLowerCase().includes(q) ||
String(item.category_name || '').toLowerCase().includes(q) String(item.category_name || '').toLowerCase().includes(q)
); )
}, [subscriptions, subSearch]); : optimisticSubs;
return base;
}, [optimisticSubs, subSearch]);
const active = filteredSubscriptions.filter(item => item.active); const active = filteredSubscriptions.filter(item => item.active);
const paused = filteredSubscriptions.filter(item => !item.active); const paused = filteredSubscriptions.filter(item => !item.active);
const sortedActive = useMemo( const sortedActive = useMemo(
@ -1266,18 +1275,59 @@ export default function SubscriptionsPage() {
); );
const reorderEnabled = !loading && bills.length > 0 && subscriptionSort === 'custom'; 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) { async function persistSubscriptionOrder(nextSubscriptions, nextBills, movedId) {
const prevData = data;
const prevBills = bills;
setData(prev => ({ ...prev, subscriptions: nextSubscriptions })); setData(prev => ({ ...prev, subscriptions: nextSubscriptions }));
setBills(nextBills); setBills(nextBills);
setMovingBillId(movedId); setMovingBillId(movedId);
try { try {
await api.reorderBills(reorderPayload(nextBills)); await api.reorderBills(reorderPayload(nextBills));
toast.success('Subscription order saved');
await load(); 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) { } catch (err) {
toast.error(err.message || 'Failed to save subscription order'); toast.error(err.message || 'Failed to save subscription order');
await load(); setData(prevData);
setBills(prevBills);
} finally { } finally {
setMovingBillId(null); setMovingBillId(null);
} }
@ -1357,64 +1407,75 @@ export default function SubscriptionsPage() {
return q ? highConfidenceRecs.filter(r => r.name?.toLowerCase().includes(q)) : highConfidenceRecs; return q ? highConfidenceRecs.filter(r => r.name?.toLowerCase().includes(q)) : highConfidenceRecs;
}, [highConfidenceRecs, recSearch]); }, [highConfidenceRecs, recSearch]);
function renderSubscriptionRows(group, activeState) { function renderGroupHeader(sectionKey, groupKey, groupItems, monthlySum) {
if (subscriptionSort !== 'cadence') { const isCollapsed = collapsedGroups.has(sectionKey);
return group.map((item, index) => ( return (
<SubscriptionRow <button
key={item.id} type="button"
item={item} onClick={() => setCollapsedGroups(prev => {
onEdit={bill => setModal({ bill })} const next = new Set(prev);
onToggle={toggleSubscription} next.has(sectionKey) ? next.delete(sectionKey) : next.add(sectionKey);
busy={busyId === `toggle-${item.id}`} return next;
moveControls={moveControlsForGroup(group, activeState)(item, index)} })}
dragProps={dragPropsForGroup(group, activeState)(item, index)} 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">
return GROUP_ORDER.flatMap(groupKey => { {GROUP_LABELS[groupKey]}
const groupItems = group.filter(item => subscriptionGroup(item) === groupKey); </p>
if (groupItems.length === 0) return []; <div className="flex items-center gap-3">
const sectionKey = `${activeState ? 'active' : 'paused'}-${groupKey}`; <span className="text-[11px] font-medium text-muted-foreground tabular-nums">
const isCollapsed = collapsedGroups.has(sectionKey); {fmt(monthlySum)}/mo · {groupItems.length}
const monthlySum = groupItems.reduce((s, i) => s + (Number(i.monthly_equivalent) || 0), 0); </span>
return [ <ChevronDown className={cn('h-3.5 w-3.5 text-muted-foreground/60 transition-transform duration-150', isCollapsed && '-rotate-90')} />
<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">
{GROUP_LABELS[groupKey]}
</p>
<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>
</button>, </div>
...(!isCollapsed ? groupItems.map((item, index) => ( </button>
<SubscriptionRow );
key={item.id} }
item={item}
onEdit={bill => setModal({ bill })} // Custom sort: normal DOM rendering (drag-reorder needs DOM order)
onToggle={toggleSubscription} function renderCustomRows(group, activeState) {
busy={busyId === `toggle-${item.id}`} return group.map((item, index) => (
moveControls={moveControlsForGroup(groupItems, activeState)(item, index)} <SubscriptionRow
dragProps={dragPropsForGroup(groupItems, activeState)(item, index)} key={item.id}
/> item={item}
)) : []), onEdit={bill => 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 (
<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 · {it.count}
</p>
</div>
);
}
if (it.type === 'group-header') {
return renderGroupHeader(it.sectionKey, it.groupKey, it.groupItems, it.monthlySum);
}
return (
<SubscriptionRow
item={it.item}
onEdit={bill => 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 ( return (
@ -1513,7 +1574,7 @@ export default function SubscriptionsPage() {
</div> </div>
</SearchFilterPanel> </SearchFilterPanel>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className={cn('p-0', subscriptionSort === 'cadence' && 'max-h-[70vh] overflow-y-auto')}>
{loading ? ( {loading ? (
<div className="p-4"> <div className="p-4">
{Array.from({ length: 4 }).map((_, index) => ( {Array.from({ length: 4 }).map((_, index) => (
@ -1526,17 +1587,33 @@ export default function SubscriptionsPage() {
<p className="mt-3 text-sm font-medium">No subscriptions tracked yet.</p> <p className="mt-3 text-sm font-medium">No subscriptions tracked yet.</p>
<p className="mt-1 text-sm text-muted-foreground">Add one manually or accept a SimpleFIN recommendation.</p> <p className="mt-1 text-sm text-muted-foreground">Add one manually or accept a SimpleFIN recommendation.</p>
</div> </div>
) : ( ) : subscriptionSort === 'cadence' ? (
<> // Cadence mode: virtualised from flatVirtualItems
{renderSubscriptionRows(sortedActive, true)} <div
{sortedPaused.length > 0 && subscriptionSort === 'cadence' && ( ref={listRef}
<div className="border-t-2 border-border/60 bg-muted/20 px-4 py-1.5"> style={{ height: virtualizer.getTotalSize(), position: 'relative' }}
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/60"> >
Paused · {sortedPaused.length} {virtualizer.getVirtualItems().map(vRow => (
</p> <div
key={vRow.key}
data-index={vRow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
width: '100%',
transform: `translateY(${vRow.start}px)`,
}}
>
{renderVirtualItem(vRow)}
</div> </div>
)} ))}
{renderSubscriptionRows(sortedPaused, false)} </div>
) : (
// Custom mode: normal DOM rendering so drag-reorder works
<>
{renderCustomRows(sortedActive, true)}
{renderCustomRows(sortedPaused, false)}
</> </>
)} )}
</CardContent> </CardContent>