diff --git a/HISTORY.md b/HISTORY.md index c626b18..92084d7 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -21,7 +21,7 @@ - **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. +- **Private calendar subscription feed** — Added a token-protected `feed.ics` calendar subscription flow for Apple Calendar, Google Calendar/Android, Outlook, and generic ICS clients. The Calendar page now opens an in-place "Subscribe Calendar" dialog that explains what will happen, previews upcoming feed events, and lets users create/copy the feed without losing context. Settings keeps the Calendar Feed management card with regenerate, revoke, preview, platform guidance, and bearer-link privacy copy. 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. diff --git a/client/components/CalendarFeedManager.jsx b/client/components/CalendarFeedManager.jsx new file mode 100644 index 0000000..36cc361 --- /dev/null +++ b/client/components/CalendarFeedManager.jsx @@ -0,0 +1,226 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { CalendarDays, Copy, Eye, KeyRound, RefreshCw, ShieldOff, Settings2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { api } from '@/api'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +function PlatformNote({ title, children }) { + return ( +
+

{title}

+

{children}

+
+ ); +} + +export function CalendarFeedManager({ compact = false, showManageLink = false }) { + 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, Outlook, or any ICS calendar.

+
+

+ This creates a private calendar feed URL. Nothing is added automatically; copy the URL into your calendar app to subscribe. +

+
+ + {!loading && !active && ( + + )} +
+ + {loading && ( +
+ )} + + {!loading && !active && ( +
+

What happens next?

+
+
+

1. Create

+

Generate a private feed URL for your bill calendar.

+
+
+

2. Copy

+

Paste it into Apple, Google, Outlook, or Android calendar setup.

+
+
+

3. Subscribe

+

Your calendar app refreshes bill due dates when it checks the feed.

+
+
+
+ )} + + {!loading && active && ( + <> +
+ Anyone with this URL can see the bill events in this feed. Regenerate or revoke it if it was shared somewhere it should not be. +
+ +
+ + + +
+ +
+ + Add a calendar subscription with the copied URL. The feed uses all-day dates to avoid timezone shifts. + + + In Google Calendar on the web, use Other calendars, From URL. Android follows Google Calendar sync. + + + Subscribe from Outlook on the web with this URL. Imported copies will not update; subscriptions will. + + + Bill Tracker emits stable event IDs per bill cycle so subscribed calendars can update without double-adding events. + +
+ +
+
+

Next events that will appear

+ + Last fetched: {feed.last_used_at ? new Date(feed.last_used_at).toLocaleString() : 'Not yet'} + +
+
+ {preview.length === 0 && ( +

No upcoming bill events in the preview window.

+ )} + {preview.map(event => ( +
+
+

{event.name}

+

{event.due_date} · {event.cycle_type}

+
+ ${Number(event.amount || 0).toFixed(2)} +
+ ))} +
+
+ +
+ {showManageLink && ( + + )} + + +
+ + )} +
+ ); +} diff --git a/client/pages/CalendarPage.jsx b/client/pages/CalendarPage.jsx index 2565f24..df91651 100644 --- a/client/pages/CalendarPage.jsx +++ b/client/pages/CalendarPage.jsx @@ -19,7 +19,8 @@ import { cn, fmt, fmtDate, todayStr } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { CalendarFeedManager } from '@/components/CalendarFeedManager'; const MONTHS = [ 'January', 'February', 'March', 'April', 'May', 'June', @@ -728,6 +729,30 @@ function DayDetailDialog({ day, open, onOpenChange, moneyMarker }) { ); } +function CalendarSubscribeDialog({ open, onOpenChange }) { + return ( + + + + Subscribe to Bill Calendar + + Create a private calendar feed URL, preview what will appear, then copy it into your calendar app. Bill Tracker does not add anything to Apple, Google, Android, or Outlook until you subscribe with that URL. + + + +
+

Best for keeping calendars updated

+

+ A subscription stays linked to Bill Tracker, so future due-date or amount changes can appear when your calendar app refreshes the feed. Calendar apps control their own refresh timing. +

+
+ + +
+
+ ); +} + export default function CalendarPage() { const initial = currentMonth(); const [year, setYear] = useState(initial.year); @@ -739,6 +764,7 @@ export default function CalendarPage() { const [error, setError] = useState(''); const [selectedDay, setSelectedDay] = useState(null); const [detailOpen, setDetailOpen] = useState(false); + const [calendarFeedOpen, setCalendarFeedOpen] = useState(false); const load = useCallback(async () => { setLoading(true); @@ -836,11 +862,9 @@ export default function CalendarPage() {
- - )} - - - {loading && ( -
- )} - - {!loading && active && ( -
-
- Anyone with this URL can see the bill events in this feed. Regenerate or revoke it if it was shared somewhere it should not be. -
- -
- - - -
- -
-
-

Apple Calendar

-

Add a calendar subscription using the copied URL. The feed uses all-day DATE events to avoid timezone shifts.

-
-
-

Google Calendar

-

Use Google Calendar on the web: Other calendars, From URL. Android sync follows your Google Calendar settings.

-
-
-

Outlook

-

Subscribe from Outlook on the web with this URL. Imported copies will not update; subscribed calendars will.

-
-
-

Duplicate Safety

-

Bill Tracker emits stable event IDs per bill cycle, so subscribed calendars can update without double-adding events.

-
-
- -
-
-

Next events

- - Last fetched: {feed.last_used_at ? new Date(feed.last_used_at).toLocaleString() : 'Not yet'} - -
-
- {preview.length === 0 && ( -

No upcoming bill events in the preview window.

- )} - {preview.map(event => ( -
-
-

{event.name}

-

{event.due_date} · {event.cycle_type}

-
- ${Number(event.amount || 0).toFixed(2)} -
- ))} -
-
- -
- - -
-
- )} -
- - ); -} - // ─── SettingsPage ───────────────────────────────────────────────────────────── export default function SettingsPage() { @@ -583,7 +397,9 @@ export default function SettingsPage() {
- + + +