fix(ui): calendar settings improvements
This commit is contained in:
parent
34fcbb0d92
commit
71e783a799
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="rounded-md bg-muted/35 p-3">
|
||||
<p className="font-medium text-foreground">{title}</p>
|
||||
<p className="mt-1">{children}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={cn('space-y-4', compact ? '' : 'px-4 py-4 sm:px-6')}>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarDays className="h-4 w-4 text-primary" />
|
||||
<p className="text-sm font-medium">Subscribe from Apple Calendar, Google Calendar, Android, Outlook, or any ICS calendar.</p>
|
||||
</div>
|
||||
<p className="mt-1 max-w-2xl text-xs leading-5 text-muted-foreground">
|
||||
This creates a private calendar feed URL. Nothing is added automatically; copy the URL into your calendar app to subscribe.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!loading && !active && (
|
||||
<Button size="sm" onClick={createFeed} disabled={!!busy} className="w-full gap-2 sm:w-auto">
|
||||
<KeyRound className="h-3.5 w-3.5" />
|
||||
{busy === 'create' ? 'Creating...' : 'Create Feed'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="h-24 animate-pulse rounded-lg bg-muted/50" />
|
||||
)}
|
||||
|
||||
{!loading && !active && (
|
||||
<div className="rounded-lg border border-border/70 bg-muted/20 p-4">
|
||||
<p className="text-sm font-medium">What happens next?</p>
|
||||
<div className="mt-3 grid gap-2 text-xs text-muted-foreground sm:grid-cols-3">
|
||||
<div className="rounded-md bg-background/60 p-3">
|
||||
<p className="font-medium text-foreground">1. Create</p>
|
||||
<p className="mt-1">Generate a private feed URL for your bill calendar.</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-background/60 p-3">
|
||||
<p className="font-medium text-foreground">2. Copy</p>
|
||||
<p className="mt-1">Paste it into Apple, Google, Outlook, or Android calendar setup.</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-background/60 p-3">
|
||||
<p className="font-medium text-foreground">3. Subscribe</p>
|
||||
<p className="mt-1">Your calendar app refreshes bill due dates when it checks the feed.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && active && (
|
||||
<>
|
||||
<div className="rounded-lg border border-amber-500/25 bg-amber-500/[0.08] px-3 py-2 text-xs leading-5 text-amber-800 dark:text-amber-200">
|
||||
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.
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input value={feed.feed_url} readOnly className="min-w-0 flex-1 font-mono text-xs" aria-label="Calendar feed URL" />
|
||||
<Button size="sm" variant="outline" onClick={copyFeedUrl} className="gap-2">
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Copy URL
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="outline" className="gap-2">
|
||||
<a href={feed.feed_url} target="_blank" rel="noreferrer">
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
Preview ICS
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 text-xs text-muted-foreground lg:grid-cols-4">
|
||||
<PlatformNote title="Apple Calendar">
|
||||
Add a calendar subscription with the copied URL. The feed uses all-day dates to avoid timezone shifts.
|
||||
</PlatformNote>
|
||||
<PlatformNote title="Google / Android">
|
||||
In Google Calendar on the web, use Other calendars, From URL. Android follows Google Calendar sync.
|
||||
</PlatformNote>
|
||||
<PlatformNote title="Outlook">
|
||||
Subscribe from Outlook on the web with this URL. Imported copies will not update; subscriptions will.
|
||||
</PlatformNote>
|
||||
<PlatformNote title="Duplicate Safety">
|
||||
Bill Tracker emits stable event IDs per bill cycle so subscribed calendars can update without double-adding events.
|
||||
</PlatformNote>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/70">
|
||||
<div className="flex flex-col gap-1 border-b border-border/60 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Next events that will appear</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Last fetched: {feed.last_used_at ? new Date(feed.last_used_at).toLocaleString() : 'Not yet'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border/50">
|
||||
{preview.length === 0 && (
|
||||
<p className="px-3 py-4 text-sm text-muted-foreground">No upcoming bill events in the preview window.</p>
|
||||
)}
|
||||
{preview.map(event => (
|
||||
<div key={event.uid} className="flex items-center justify-between gap-3 px-3 py-2 text-sm">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{event.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{event.due_date} · {event.cycle_type}</p>
|
||||
</div>
|
||||
<span className="tracker-number shrink-0 text-xs font-semibold">${Number(event.amount || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
{showManageLink && (
|
||||
<Button asChild size="sm" variant="ghost" className="gap-2">
|
||||
<Link to="/settings#calendar-feed">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
Manage in Settings
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={regenerateFeed} disabled={!!busy} className="gap-2">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
{busy === 'regenerate' ? 'Regenerating...' : 'Regenerate URL'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={revokeFeed} disabled={!!busy} className="gap-2 border-destructive/30 text-destructive hover:bg-destructive/10">
|
||||
<ShieldOff className="h-3.5 w-3.5" />
|
||||
{busy === 'revoke' ? 'Revoking...' : 'Revoke Feed'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="border-border/60 bg-card/95 backdrop-blur-xl sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-semibold tracking-tight">Subscribe to Bill Calendar</DialogTitle>
|
||||
<DialogDescription>
|
||||
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.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="rounded-lg border border-primary/20 bg-primary/[0.06] p-3">
|
||||
<p className="text-sm font-medium text-foreground">Best for keeping calendars updated</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CalendarFeedManager compact showManageLink />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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() {
|
|||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={goToday}>Today</Button>
|
||||
<Button asChild variant="outline" size="sm" className="gap-2">
|
||||
<Link to="/settings#calendar-feed">
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
Add All
|
||||
</Link>
|
||||
<Button variant="outline" size="sm" className="gap-2" onClick={() => setCalendarFeedOpen(true)}>
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
Subscribe Calendar
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-9 w-9 rounded-full" onClick={load} aria-label="Refresh calendar">
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} />
|
||||
|
|
@ -951,6 +975,10 @@ export default function CalendarPage() {
|
|||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
/>
|
||||
<CalendarSubscribeDialog
|
||||
open={calendarFeedOpen}
|
||||
onOpenChange={setCalendarFeedOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { AlertCircle, CalendarDays, Copy, Eye, KeyRound, Moon, RefreshCw, ShieldOff, Sun, Users } from 'lucide-react';
|
||||
import { AlertCircle, Moon, RefreshCw, Sun, Users } from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { CalendarFeedManager } from '@/components/CalendarFeedManager';
|
||||
import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
|
|
@ -237,193 +238,6 @@ 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 (
|
||||
<SectionCard title="Calendar Feed">
|
||||
<div className="px-4 py-4 sm:px-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarDays className="h-4 w-4 text-primary" />
|
||||
<p className="text-sm font-medium">Subscribe from Apple Calendar, Google Calendar, Android, or Outlook</p>
|
||||
</div>
|
||||
<p className="mt-1 max-w-2xl text-xs leading-5 text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!loading && !active && (
|
||||
<Button size="sm" onClick={createFeed} disabled={!!busy} className="w-full gap-2 sm:w-auto">
|
||||
<KeyRound className="h-3.5 w-3.5" />
|
||||
{busy === 'create' ? 'Creating...' : 'Create Feed'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="mt-4 h-24 animate-pulse rounded-lg bg-muted/50" />
|
||||
)}
|
||||
|
||||
{!loading && active && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="rounded-lg border border-amber-500/25 bg-amber-500/[0.08] px-3 py-2 text-xs leading-5 text-amber-800 dark:text-amber-200">
|
||||
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.
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input value={feed.feed_url} readOnly className="min-w-0 flex-1 font-mono text-xs" aria-label="Calendar feed URL" />
|
||||
<Button size="sm" variant="outline" onClick={copyFeedUrl} className="gap-2">
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Copy
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="outline" className="gap-2">
|
||||
<a href={feed.feed_url} target="_blank" rel="noreferrer">
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
Preview ICS
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 text-xs text-muted-foreground lg:grid-cols-4">
|
||||
<div className="rounded-md bg-muted/35 p-3">
|
||||
<p className="font-medium text-foreground">Apple Calendar</p>
|
||||
<p className="mt-1">Add a calendar subscription using the copied URL. The feed uses all-day DATE events to avoid timezone shifts.</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/35 p-3">
|
||||
<p className="font-medium text-foreground">Google Calendar</p>
|
||||
<p className="mt-1">Use Google Calendar on the web: Other calendars, From URL. Android sync follows your Google Calendar settings.</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/35 p-3">
|
||||
<p className="font-medium text-foreground">Outlook</p>
|
||||
<p className="mt-1">Subscribe from Outlook on the web with this URL. Imported copies will not update; subscribed calendars will.</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-muted/35 p-3">
|
||||
<p className="font-medium text-foreground">Duplicate Safety</p>
|
||||
<p className="mt-1">Bill Tracker emits stable event IDs per bill cycle, so subscribed calendars can update without double-adding events.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border/70">
|
||||
<div className="flex items-center justify-between border-b border-border/60 px-3 py-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Next events</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Last fetched: {feed.last_used_at ? new Date(feed.last_used_at).toLocaleString() : 'Not yet'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border/50">
|
||||
{preview.length === 0 && (
|
||||
<p className="px-3 py-4 text-sm text-muted-foreground">No upcoming bill events in the preview window.</p>
|
||||
)}
|
||||
{preview.map(event => (
|
||||
<div key={event.uid} className="flex items-center justify-between gap-3 px-3 py-2 text-sm">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{event.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{event.due_date} · {event.cycle_type}</p>
|
||||
</div>
|
||||
<span className="tracker-number shrink-0 text-xs font-semibold">${Number(event.amount || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<Button size="sm" variant="outline" onClick={regenerateFeed} disabled={!!busy} className="gap-2">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
{busy === 'regenerate' ? 'Regenerating...' : 'Regenerate URL'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={revokeFeed} disabled={!!busy} className="gap-2 border-destructive/30 text-destructive hover:bg-destructive/10">
|
||||
<ShieldOff className="h-3.5 w-3.5" />
|
||||
{busy === 'revoke' ? 'Revoking...' : 'Revoke Feed'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── SettingsPage ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsPage() {
|
||||
|
|
@ -583,7 +397,9 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
|
||||
<div id="calendar-feed" className="mt-6 scroll-mt-24">
|
||||
<CalendarFeedSection />
|
||||
<SectionCard title="Calendar Feed">
|
||||
<CalendarFeedManager />
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue