diff --git a/HISTORY.md b/HISTORY.md
index c7d9a64..c626b18 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -15,6 +15,14 @@
- **Bank payments override provisional manual tracker payments** — Manual payments entered from the Tracker count immediately while waiting for bank sync. When a matching bank-backed payment clears for the same bill cycle, the bank payment becomes the accounting source of truth and the manual payment is preserved as history only with override metadata and a BillModal badge/note. Overridden manual payments are excluded consistently from Tracker, Summary, Calendar, Analytics, Categories, starting amount summaries, drift checks, notifications, status counts, bank pending deductions, trends, overdue checks, and debt balance deltas. If the bank match is undone, the provisional manual payment is reactivated.
+- **Animated page, modal, and tracker reorder transitions** — Added `framer-motion` and a shared `PageTransition` wrapper for user/admin route content. Dialog and AlertDialog content now use Framer entry motion, while Tracker bucket rows/cards use layout animation so sorted and reordered bill groups move smoothly instead of snapping.
+
+- **Remembered collapsible search/filter panels** — Added a shared `SearchFilterPanel` and `useSearchPanelPreference` hook backed by the per-user `search_bars_collapsed` setting. Tracker, Bills, and Subscriptions search/filter areas can now be collapsed, default open, and the user's expanded/collapsed preference is remembered across pages and sessions.
+
+- **Profile display-name persistence** — Profile name updates now save to `users.display_name` in the database and are returned consistently as `display_name`, `displayName`, and `name` aliases from auth/profile responses. The Profile page and Sidebar now display the saved name reliably after reloads and new sessions, with profile route coverage added.
+
+- **Private calendar subscription feed** — Added a token-protected `feed.ics` calendar subscription flow for Apple Calendar, Google Calendar/Android, Outlook, and generic ICS clients. Settings now has a Calendar Feed card with create, copy, regenerate, revoke, and preview actions, plus platform guidance and bearer-link privacy copy. The Calendar page includes an Add All entry point that routes users to the subscription setup. The public feed endpoint does not require session cookies, uses stable per-bill-cycle event UIDs to avoid duplicate subscribed events, emits all-day DATE events with CRLF/no-BOM/no-VTIMEZONE ICS output, and is backed by the new idempotent `calendar_tokens` migration.
+
- **Improved unmatch flow — choice dialog + bulk deselect** — Clicking Unmatch on a linked transaction in the Bill modal now opens a two-option choice dialog instead of immediately removing the match. Option 1 ("Unmatch this payment only") confirms via a single AlertDialog and removes only that transaction. Option 2 ("Review all similar matches") fetches all linked transactions for the bill whose payee normalizes to the same prefix and opens a checklist dialog where each similar match is pre-checked. Users can deselect individual transactions to keep them matched, use All/None quick-selects, and optionally check a "Remove merchant rule" checkbox (shown only when a merchant rule matching the payee pattern exists on the bill). The confirm button shows the count of selected transactions and is disabled when nothing is selected. New backend endpoint `POST /api/transactions/unmatch-bulk` handles mixed `provider_sync` (restores balance + soft-deletes payment) and `transaction_match` (standard unmatch service) entries in a single database transaction.
- **Service Catalog page for subscription matching** — The full known-service catalog moved out of the main Subscriptions page and into its own dedicated route at `/subscriptions/catalog`. The catalog now acts as an advanced matching tool instead of a second subscriptions list: tracked entries appear under a "Tracking" header with price drift indicators, each linked entry can be edited in BillModal, Re-link opens a searchable dialog to swap or remove the catalog link, and Custom bank descriptors let users add exact payee strings from their bank statements to improve future matching. Untracked catalog entries remain searchable/filterable and can still be tracked individually or in bulk. The Subscriptions page now shows a compact "Improve Matching" card that links to the Service Catalog when users need to tune descriptors, fix a wrong service link, or connect an existing bill to a known service. Catalog load failures now show both inline error state and toast feedback. New migration v0.96 adds `bills.catalog_id FK` (backfilled for existing subscriptions via name matching) and the `user_catalog_descriptors` table for per-user custom payee strings; user descriptors are merged into `loadCatalog` so they improve auto-matching for only that user's account.
@@ -40,6 +48,10 @@
### 🔧 Changed
+- **Mobile tracker view polish** — The mobile bill tracker now mirrors desktop drift context with the "Changed" badge and compact amount sparkline, prevents duplicate quick-pay taps while a save is in progress, and lets bucket headers wrap cleanly on narrow screens. The old unused mobile monthly-state dialog path was removed so the row actions stay focused on the supported tracker flows.
+
+- **Empty state polish** — Every dashed-border centered text empty state across the app has been replaced. The `border border-dashed border-border/...` treatment read as a broken UI rather than an intentional empty state. All instances now use a `bg-muted/20` filled card with no border. Empty states that were purely informational also received a CTA or action hint: the BillModal payment history empty state now reads "No payments yet — use the form below to record the first payment"; the PaymentLedgerDialog shows "No payments yet — add one below"; the Summary page "no bills" state links to `/bills` with an "Add a bill →" arrow. Filter-state empty copy in TrackerBucket was reworded from the passive "No bills match this bucket and filter set" to the directive "No bills match — try adjusting your filters." The Categories page empty-category row already had an "Open Bills" button — its label was updated to "Add a bill →" for consistency. Dashed borders were stripped (content preserved) on SnowballPage, PayoffPage (both `EmptyDebts` and the chart placeholder), CalendarPage day panel, and RoadmapPage issue/activity-log empties.
+
- **React 19 upgrade** — Upgraded from React 18.3.1 to React 19. The `useOptimistic` polyfill in `client/hooks/useOptimistic.js` was deleted in favour of the native React 19 hook. `BillModal` was refactored from a manual `handleSubmit` + `useState(busy)` pattern to `useActionState` with a form `action` prop — the async action handles validation, bill creation/update, template save, and error toasts without a separate busy flag. All 15 shadcn/ui component files (`button`, `badge`, `card`, `checkbox`, `collapsible`, `dialog`, `input`, `label`, `select`, `separator`, `Skeleton`, `table`, `tabs`, `theme-toggle`, `tooltip`) had `React.forwardRef(...)` replaced with plain function components accepting `ref` as a named prop, removing the deprecated pattern.
- **Subscription recommendations narrowed to bank-backed known services** — The Recommendations panel now only shows high-confidence (`90%+`) bank transaction matches that resolve to a known subscription catalog entry. Unknown recurring merchant patterns are no longer mixed into primary recommendations; those can be reviewed separately later without diluting the "known service" signal. Recommendation confidence now separates identity, amount, and cadence evidence instead of relying on a single recurrence score: user-added descriptors and researched bank descriptors score strongest, service name/domain/slang matches score lower, catalog starting monthly/annual pricing is used to judge amount plausibility, and recurring cadence/amount stability add confidence when multiple charges exist. A single exact known bank descriptor with a plausible amount can still appear as a 90%+ recommendation, but weak one-off name/domain matches no longer do. Recommendation cards now expose the evidence to the user with badges and reason chips for descriptor type, price check, recurring cadence, amount range, catalog starting price, account, last seen date, and average amount.
diff --git a/client/api.js b/client/api.js
index 781cf17..378405b 100644
--- a/client/api.js
+++ b/client/api.js
@@ -174,6 +174,11 @@ export const api = {
// Calendar
calendar: (y, m) => get(`/calendar?year=${y}&month=${m}`),
+ calendarFeed: () => get('/calendar/feed'),
+ createCalendarFeed: () => post('/calendar/feed', {}),
+ regenerateCalendarFeed: () => post('/calendar/feed/regenerate', {}),
+ revokeCalendarFeed: () => del('/calendar/feed'),
+ calendarFeedPreview:(limit = 10) => get('/calendar/feed/preview', { limit }),
// Summary
summary: (y, m) => get(`/summary?year=${y}&month=${m}`),
diff --git a/client/pages/CalendarPage.jsx b/client/pages/CalendarPage.jsx
index b86c9a0..2565f24 100644
--- a/client/pages/CalendarPage.jsx
+++ b/client/pages/CalendarPage.jsx
@@ -836,6 +836,12 @@ export default function CalendarPage() {
+
diff --git a/client/pages/SettingsPage.jsx b/client/pages/SettingsPage.jsx
index 819b4a7..ea18db5 100644
--- a/client/pages/SettingsPage.jsx
+++ b/client/pages/SettingsPage.jsx
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
-import { AlertCircle, Moon, RefreshCw, Sun, Users } from 'lucide-react';
+import { AlertCircle, CalendarDays, Copy, Eye, KeyRound, Moon, RefreshCw, ShieldOff, Sun, Users } from 'lucide-react';
import { api } from '@/api';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
@@ -237,6 +237,193 @@ function LinkImportToggle() {
);
}
+function CalendarFeedSection() {
+ const [feed, setFeed] = useState(null);
+ const [preview, setPreview] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [busy, setBusy] = useState(null);
+
+ const loadFeed = useCallback(async () => {
+ setLoading(true);
+ try {
+ const data = await api.calendarFeed();
+ setFeed(data);
+ if (data?.active) {
+ const nextPreview = await api.calendarFeedPreview(10);
+ setPreview(nextPreview.events || []);
+ } else {
+ setPreview([]);
+ }
+ } catch (err) {
+ toast.error(err.message || 'Failed to load calendar feed.');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => { loadFeed(); }, [loadFeed]);
+
+ const active = !!feed?.active && !!feed?.feed_url;
+
+ async function createFeed() {
+ setBusy('create');
+ try {
+ const data = await api.createCalendarFeed();
+ setFeed(data);
+ const nextPreview = await api.calendarFeedPreview(10);
+ setPreview(nextPreview.events || []);
+ toast.success('Calendar feed created.');
+ } catch (err) {
+ toast.error(err.message || 'Failed to create calendar feed.');
+ } finally {
+ setBusy(null);
+ }
+ }
+
+ async function copyFeedUrl() {
+ if (!feed?.feed_url) return;
+ try {
+ await navigator.clipboard.writeText(feed.feed_url);
+ toast.success('Calendar feed URL copied.');
+ } catch {
+ toast.error('Copy failed. Select the URL and copy it manually.');
+ }
+ }
+
+ async function regenerateFeed() {
+ setBusy('regenerate');
+ try {
+ const data = await api.regenerateCalendarFeed();
+ setFeed(data);
+ const nextPreview = await api.calendarFeedPreview(10);
+ setPreview(nextPreview.events || []);
+ toast.success('Calendar feed regenerated. Update any subscribed calendars with the new URL.');
+ } catch (err) {
+ toast.error(err.message || 'Failed to regenerate calendar feed.');
+ } finally {
+ setBusy(null);
+ }
+ }
+
+ async function revokeFeed() {
+ setBusy('revoke');
+ try {
+ const data = await api.revokeCalendarFeed();
+ setFeed(data);
+ setPreview([]);
+ toast.success('Calendar feed revoked.');
+ } catch (err) {
+ toast.error(err.message || 'Failed to revoke calendar feed.');
+ } finally {
+ setBusy(null);
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
Subscribe from Apple Calendar, Google Calendar, Android, or Outlook
+
+
+ This creates a private ICS feed URL. Calendar apps refresh subscribed feeds on their own schedule, so Google and Apple may take time to show updates.
+