fix: SubscriptionsPage polish
This commit is contained in:
parent
4f5a3d0cff
commit
3b0f267ab3
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue