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 { 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) => (
<SubscriptionRow
key={item.id}
item={item}
onEdit={bill => 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 [
<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>
function renderGroupHeader(sectionKey, groupKey, groupItems, monthlySum) {
const isCollapsed = collapsedGroups.has(sectionKey);
return (
<button
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>
</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(groupItems, activeState)(item, index)}
dragProps={dragPropsForGroup(groupItems, activeState)(item, index)}
/>
)) : []),
];
});
</div>
</button>
);
}
// Custom sort: normal DOM rendering (drag-reorder needs DOM order)
function renderCustomRows(group, activeState) {
return group.map((item, index) => (
<SubscriptionRow
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 (
@ -1513,7 +1574,7 @@ export default function SubscriptionsPage() {
</div>
</SearchFilterPanel>
</CardHeader>
<CardContent className="p-0">
<CardContent className={cn('p-0', subscriptionSort === 'cadence' && 'max-h-[70vh] overflow-y-auto')}>
{loading ? (
<div className="p-4">
{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-1 text-sm text-muted-foreground">Add one manually or accept a SimpleFIN recommendation.</p>
</div>
) : (
<>
{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>
) : subscriptionSort === 'cadence' ? (
// Cadence mode: virtualised from flatVirtualItems
<div
ref={listRef}
style={{ height: virtualizer.getTotalSize(), position: 'relative' }}
>
{virtualizer.getVirtualItems().map(vRow => (
<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>
)}
{renderSubscriptionRows(sortedPaused, false)}
))}
</div>
) : (
// Custom mode: normal DOM rendering so drag-reorder works
<>
{renderCustomRows(sortedActive, true)}
{renderCustomRows(sortedPaused, false)}
</>
)}
</CardContent>