From 12d869d4004ab8801190ea54ba8790f0d83cb8a1 Mon Sep 17 00:00:00 2001 From: null Date: Sat, 6 Jun 2026 22:25:58 -0500 Subject: [PATCH] feat(subscriptions): cadence sort toggle, in-place bill edits after save --- HISTORY.md | 4 + client/pages/SubscriptionsPage.jsx | 274 ++++++++++++++++++++++++++--- 2 files changed, 249 insertions(+), 29 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index f5f0f42..1685d5a 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -36,6 +36,10 @@ - **Subscription page actions simplified** — Recommendation cards now show one clear primary action (`Track Subscription` or `Link Existing Bill`), a Details icon, and a compact More menu for secondary actions such as choosing another bill, tracking as new, or dismissing. Tracked subscription rows now use a cleaner Edit + More menu pattern for pause/resume, and dialog/header/search/catalog action buttons use more consistent sizing and icon spacing. The noisy full reason-chip list was removed from the recommendation card surface and remains available in the Details dialog, making the page easier to scan. +- **Subscription cadence sort** — The Tracked Subscriptions panel now has a compact Custom/Cadence segmented control. Custom keeps the saved manual order and drag controls; Cadence groups subscriptions by Weekly, Biweekly, Monthly, Quarterly, Yearly, and Other, sorted by next due date within each group. Reordering is automatically disabled while Cadence sort is active so grouped views are never accidentally saved as manual order. + +- **Subscription edits update in place** — Saving a bill from the Subscriptions page no longer reloads the full subscriptions/recommendations payload. `BillModal` already returns the saved bill, so the page now updates the edited row, local bill cache, next due date, monthly/yearly totals, paused count, and top subscription type in memory. Modal-internal payment or unmatch actions no longer accidentally close the modal and refresh the page through the same callback. + - **Subscriptions page simplified** — Removed the full known-service catalog from the main Subscriptions page to reduce overload. The page now focuses on tracked subscriptions, strict known-service recommendations from bank data, transaction search, and a small "Improve Matching" link to the dedicated Service Catalog. Supporting failures that were previously quiet now surface toasts: loading bills for the link-to-bill dialog, transaction search errors, and catalog load errors. - **Vite API proxy respects alternate API ports** — `vite.config.mjs` now reads `API_PORT` or `PORT` when configuring the `/api` proxy instead of hardcoding port `3000`. This lets local development run the Bill Tracker API on another port when `3000` is already occupied. diff --git a/client/pages/SubscriptionsPage.jsx b/client/pages/SubscriptionsPage.jsx index 6c658bf..b124ff8 100644 --- a/client/pages/SubscriptionsPage.jsx +++ b/client/pages/SubscriptionsPage.jsx @@ -62,6 +62,131 @@ const TYPE_LABELS = { other: 'Other', }; +const SUBSCRIPTION_SORT_KEY = 'subscriptions_sort_mode'; +const CADENCE_ORDER = ['weekly', 'biweekly', 'monthly', 'quarterly', 'annual', 'other']; +const CADENCE_LABELS = { + weekly: 'Weekly', + biweekly: 'Biweekly', + monthly: 'Monthly', + quarterly: 'Quarterly', + annual: 'Yearly', + other: 'Other', +}; + +const SUBSCRIPTION_MONTHLY_FACTORS = { + weekly: 52 / 12, + biweekly: 26 / 12, + monthly: 1, + quarterly: 1 / 3, + annual: 1 / 12, + annually: 1 / 12, +}; + +function normalizedCadence(item) { + const raw = String(item?.cycle_type || item?.billing_cycle || '').toLowerCase(); + if (raw.includes('week') && raw.includes('bi')) return 'biweekly'; + if (raw === 'biweekly') return 'biweekly'; + if (raw.includes('week')) return 'weekly'; + if (raw.includes('quarter')) return 'quarterly'; + if (raw.includes('annual') || raw.includes('year')) return 'annual'; + if (raw.includes('month') || !raw) return 'monthly'; + return 'other'; +} + +function subscriptionMonthlyEquivalent(item) { + const key = String(item?.cycle_type || item?.billing_cycle || 'monthly').toLowerCase(); + const fallback = String(item?.billing_cycle || '').toLowerCase() === 'quarterly' + ? 'quarterly' + : String(item?.billing_cycle || '').toLowerCase() === 'annually' + ? 'annual' + : key; + const factor = SUBSCRIPTION_MONTHLY_FACTORS[key] ?? SUBSCRIPTION_MONTHLY_FACTORS[fallback] ?? 1; + return Math.round(Number(item?.expected_amount || 0) * factor * 100) / 100; +} + +function subscriptionNextDueDate(item, now = new Date()) { + const dueDay = Math.min(Math.max(Number(item?.due_day) || 1, 1), 31); + const cycle = String(item?.cycle_type || item?.billing_cycle || 'monthly').toLowerCase(); + let date = new Date(now.getFullYear(), now.getMonth(), dueDay); + if (date < new Date(now.getFullYear(), now.getMonth(), now.getDate())) { + date = new Date(now.getFullYear(), now.getMonth() + 1, dueDay); + } + if (cycle === 'quarterly' || cycle === 'annual' || cycle === 'annually') { + const startMonth = Math.min(Math.max(Number(item?.cycle_day) || 1, 1), 12) - 1; + const step = cycle === 'quarterly' ? 3 : 12; + date = new Date(now.getFullYear(), startMonth, dueDay); + while (date < new Date(now.getFullYear(), now.getMonth(), now.getDate())) { + date = new Date(date.getFullYear(), date.getMonth() + step, dueDay); + } + } + return date.toISOString().slice(0, 10); +} + +function decorateSavedSubscriptionBill(bill, categories) { + const monthly = subscriptionMonthlyEquivalent(bill); + const category = categories.find(item => Number(item.id) === Number(bill.category_id)); + return { + ...bill, + active: !!bill.active, + is_subscription: !!bill.is_subscription, + category_name: bill.category_name || category?.name || null, + monthly_equivalent: monthly, + yearly_equivalent: Math.round(monthly * 12 * 100) / 100, + next_due_date: subscriptionNextDueDate(bill), + subscription_type: bill.subscription_type || 'other', + }; +} + +function subscriptionSummaryFromList(subscriptions) { + const active = subscriptions.filter(item => item.active); + const monthlyTotal = active.reduce((sum, item) => sum + Number(item.monthly_equivalent || 0), 0); + const typeTotals = new Map(); + for (const item of active) { + const type = item.subscription_type || 'other'; + typeTotals.set(type, (typeTotals.get(type) || 0) + Number(item.monthly_equivalent || 0)); + } + const topType = [...typeTotals.entries()].sort((a, b) => b[1] - a[1])[0] || null; + return { + active_count: active.length, + paused_count: subscriptions.length - active.length, + monthly_total: Math.round(monthlyTotal * 100) / 100, + yearly_total: Math.round(monthlyTotal * 12 * 100) / 100, + top_type: topType ? { type: topType[0], monthly_total: Math.round(topType[1] * 100) / 100 } : null, + }; +} + +function cadenceIndex(item) { + const index = CADENCE_ORDER.indexOf(normalizedCadence(item)); + return index >= 0 ? index : CADENCE_ORDER.length - 1; +} + +function sortSubscriptionsByCadence(items) { + return [...items].sort((a, b) => ( + cadenceIndex(a) - cadenceIndex(b) + || String(a.next_due_date || '').localeCompare(String(b.next_due_date || '')) + || (Number(a.due_day) || 0) - (Number(b.due_day) || 0) + || String(a.name || '').localeCompare(String(b.name || '')) + )); +} + +function SortModeButton({ active, children, onClick }) { + return ( + + ); +} + function cycleLabel(item) { return scheduleLabel(item); } @@ -715,6 +840,9 @@ export default function SubscriptionsPage() { const [matchTarget, setMatchTarget] = useState(null); const [detailsTarget, setDetailsTarget] = useState(null); const [recSearch, setRecSearch] = useState(''); + const [subscriptionSort, setSubscriptionSort] = useState(() => ( + localStorage.getItem(SUBSCRIPTION_SORT_KEY) === 'cadence' ? 'cadence' : 'custom' + )); const [draggingId, setDraggingId] = useState(null); const [dropTargetId, setDropTargetId] = useState(null); const [movingBillId, setMovingBillId] = useState(null); @@ -768,6 +896,10 @@ export default function SubscriptionsPage() { }); }, [load, loadRecommendations]); + useEffect(() => { + localStorage.setItem(SUBSCRIPTION_SORT_KEY, subscriptionSort); + }, [subscriptionSort]); + useEffect(() => { clearTimeout(txDebounce.current); const q = txQuery.trim(); @@ -854,6 +986,40 @@ export default function SubscriptionsPage() { await linkRecommendationToBill(matchTarget, billId); } + function applySavedBillToPage(savedBill) { + if (!savedBill?.id) return; + const decorated = decorateSavedSubscriptionBill(savedBill, categories); + + setBills(prev => { + const current = Array.isArray(prev) ? prev : []; + const exists = current.some(item => Number(item.id) === Number(savedBill.id)); + return exists + ? current.map(item => Number(item.id) === Number(savedBill.id) ? { ...item, ...savedBill } : item) + : [...current, savedBill]; + }); + + setData(prev => { + const current = prev.subscriptions || []; + const exists = current.some(item => Number(item.id) === Number(savedBill.id)); + const nextSubscriptions = decorated.is_subscription + ? exists + ? current.map(item => Number(item.id) === Number(savedBill.id) ? { ...item, ...decorated } : item) + : [...current, decorated] + : current.filter(item => Number(item.id) !== Number(savedBill.id)); + + return { + ...prev, + subscriptions: nextSubscriptions, + summary: subscriptionSummaryFromList(nextSubscriptions), + }; + }); + } + + function handleBillModalSave(savedBill) { + if (!savedBill?.id) return; + applySavedBillToPage(savedBill); + } + function openManualSubscription() { setModal({ bill: null, @@ -901,7 +1067,15 @@ export default function SubscriptionsPage() { const subscriptions = data.subscriptions || []; const active = subscriptions.filter(item => item.active); const paused = subscriptions.filter(item => !item.active); - const reorderEnabled = !loading && bills.length > 0; + const sortedActive = useMemo( + () => subscriptionSort === 'cadence' ? sortSubscriptionsByCadence(active) : active, + [active, subscriptionSort], + ); + const sortedPaused = useMemo( + () => subscriptionSort === 'cadence' ? sortSubscriptionsByCadence(paused) : paused, + [paused, subscriptionSort], + ); + const reorderEnabled = !loading && bills.length > 0 && subscriptionSort === 'custom'; async function persistSubscriptionOrder(nextSubscriptions, nextBills, movedId) { setData(prev => ({ ...prev, subscriptions: nextSubscriptions })); @@ -994,6 +1168,53 @@ 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 CADENCE_ORDER.flatMap(cadence => { + const cadenceItems = group.filter(item => normalizedCadence(item) === cadence); + if (cadenceItems.length === 0) return []; + return [ +
+
+

+ {CADENCE_LABELS[cadence]} +

+ + {cadenceItems.length} + +
+
, + ...cadenceItems.map((item, index) => ( + setModal({ bill })} + onToggle={toggleSubscription} + busy={busyId === `toggle-${item.id}`} + moveControls={moveControlsForGroup(cadenceItems, activeState)(item, index)} + dragProps={dragPropsForGroup(cadenceItems, activeState)(item, index)} + /> + )), + ]; + }); + } + return (
@@ -1028,8 +1249,26 @@ export default function SubscriptionsPage() {
- Tracked Subscriptions - Subscriptions are bills with recurring-service metadata. +
+
+ Tracked Subscriptions + Subscriptions are bills with recurring-service metadata. +
+
+ setSubscriptionSort('custom')} + > + Custom + + setSubscriptionSort('cadence')} + > + Cadence + +
+
{loading ? ( @@ -1046,28 +1285,8 @@ export default function SubscriptionsPage() {
) : ( <> - {active.map((item, index) => ( - setModal({ bill })} - onToggle={toggleSubscription} - busy={busyId === `toggle-${item.id}`} - moveControls={moveControlsForGroup(active, true)(item, index)} - dragProps={dragPropsForGroup(active, true)(item, index)} - /> - ))} - {paused.map((item, index) => ( - setModal({ bill })} - onToggle={toggleSubscription} - busy={busyId === `toggle-${item.id}`} - moveControls={moveControlsForGroup(paused, false)(item, index)} - dragProps={dragPropsForGroup(paused, false)(item, index)} - /> - ))} + {renderSubscriptionRows(sortedActive, true)} + {renderSubscriptionRows(sortedPaused, false)} )} @@ -1227,10 +1446,7 @@ export default function SubscriptionsPage() { initialBill={modal.initialBill} categories={categories} onClose={() => setModal(null)} - onSave={async () => { - setModal(null); - await refreshAll(); - }} + onSave={handleBillModalSave} /> )}