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 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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue