import { useState, useEffect, useCallback, type ComponentType, type ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { AlertCircle, Moon, Monitor, RefreshCw, Sun, Users, HelpCircle } from 'lucide-react'; import { api } from '@/api'; import { cn, errMessage, settingEnabled, setDateFormat } from '@/lib/utils'; import { setMoneyFormat } from '@/lib/money'; 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'; import { Switch } from '@/components/ui/switch'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useTheme } from '@/contexts/ThemeContext'; import { useMotionPreference } from '@/contexts/MotionPreferenceContext'; import { useAuth } from '@/hooks/useAuth'; import { useAutoSave } from '@/hooks/useAutoSave'; import { SaveStatus } from '@/components/ui/save-status'; import { NotificationPreferences, PushNotifications, asSettings } from '@/pages/ProfilePage'; export const LINK_IMPORT_PREF_KEY = 'link_import_ask'; export function getLinkImportPref(): boolean { return localStorage.getItem(LINK_IMPORT_PREF_KEY) !== 'false'; } // ─── Notifications (moved here from Profile — these are app-behavior // preferences; Profile keeps identity, security, and privacy) ────────────── function NotificationsSection() { const [settings, setSettings] = useState | null>(null); const load = useCallback(() => { api .profileSettings() .then((data) => setSettings(asSettings(data))) .catch((err) => toast.error(errMessage(err, 'Failed to load notification settings.'))); }, []); useEffect(() => { load(); }, [load]); if (!settings) return null; return (
); } // ─── Card wrapper ───────────────────────────────────────────────────────────── function SectionCard({ title, children }: { title: ReactNode; children: ReactNode }) { return (

{title}

{children}
); } // ─── Setting Row ────────────────────────────────────────────────────────────── function SettingRow({ label, description, children, }: { label: ReactNode; description?: ReactNode; children: ReactNode; }) { return (

{label}

{description &&

{description}

}
{children}
); } // ─── Inline help tooltip ────────────────────────────────────────────────────── // A focusable help icon that explains a non-obvious setting. Sits next to a // SettingRow label. Keyboard + screen-reader accessible (button + aria-label). function SettingHelp({ text, label }: { text: string; label: string }) { return ( {text} ); } // ─── Theme Card ─────────────────────────────────────────────────────────────── function ThemeCard({ value, label, icon: Icon, currentTheme, onSelect, }: { value: string; label: string; icon: ComponentType<{ className?: string }>; currentTheme: string; onSelect: (v: string) => void; }) { const selected = currentTheme === value; return ( ); } // ─── Appearance Section ─────────────────────────────────────────────────────── function AppearanceSection() { const { theme, setTheme } = useTheme(); const { reduceMotion, setReduceMotion } = useMotionPreference(); return ( Theme } description="Choose your preferred color scheme." >
Reduce motion } description="Minimize animations and transitions." >
); } function LoginModeRecoverySection() { const navigate = useNavigate(); const { singleUserMode, refresh } = useAuth(); const [restoring, setRestoring] = useState(false); if (!singleUserMode) return null; const handleRestore = async () => { setRestoring(true); try { await api.restoreMultiUserMode(); toast.success('Multi-user login restored.'); refresh(); navigate('/login', { replace: true }); } catch (err) { toast.error(errMessage(err, 'Failed to restore multi-user mode.')); } finally { setRestoring(false); } }; return ( ); } // ─── Settings Skeleton ──────────────────────────────────────────────────────── function SettingsSkeleton() { return (
{/* Page header */}

{/* Appearance */}

{/* Login mode */}

{/* General */}

{/* Billing */}

); } // ─── Link-import preference toggle (localStorage-backed) ───────────────────── function LinkImportToggle() { const [enabled, setEnabled] = useState(getLinkImportPref); function toggle(next: boolean) { localStorage.setItem(LINK_IMPORT_PREF_KEY, String(next)); setEnabled(next); toast.success( next ? 'Past payments will be offered for import when linking a bill.' : 'Past payment import prompt is disabled.', ); } return ( ); } interface SettingsState { currency: string; date_format: string; week_start: string; default_landing_page: string; grace_period_days: number | string; due_soon_days: number | string; drift_threshold_pct: string; tracker_show_bank_projection_banner: string; tracker_bank_projection_banner_snoozed_until: string; tracker_show_search_sort: string; tracker_show_summary_cards: string; tracker_show_safe_to_spend: string; tracker_show_overdue_command_center: string; tracker_show_drift_insights: string; [key: string]: unknown; } // ─── SettingsPage ───────────────────────────────────────────────────────────── export default function SettingsPage() { const DEFAULTS: SettingsState = { currency: 'USD', date_format: 'MM/DD/YYYY', week_start: '0', default_landing_page: 'tracker', grace_period_days: 5, due_soon_days: 3, drift_threshold_pct: '5', tracker_show_bank_projection_banner: 'true', tracker_bank_projection_banner_snoozed_until: '', tracker_show_search_sort: 'true', tracker_show_summary_cards: 'true', tracker_show_safe_to_spend: 'true', tracker_show_overdue_command_center: 'true', tracker_show_drift_insights: 'true', }; const [settings, setSettings] = useState(DEFAULTS); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); const loadSettings = useCallback(() => { setLoading(true); setLoadError(null); api .settings() .then((d) => setSettings({ ...DEFAULTS, ...(d as Partial) })) .catch((err) => setLoadError(errMessage(err, 'Failed to load settings'))) .finally(() => setLoading(false)); }, []); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { loadSettings(); }, [loadSettings]); const queryClient = useQueryClient(); const buildPayload = (s: SettingsState) => ({ currency: s.currency, date_format: s.date_format, week_start: s.week_start, default_landing_page: s.default_landing_page, grace_period_days: s.grace_period_days, due_soon_days: s.due_soon_days, drift_threshold_pct: s.drift_threshold_pct, tracker_show_bank_projection_banner: s.tracker_show_bank_projection_banner, tracker_bank_projection_banner_snoozed_until: s.tracker_bank_projection_banner_snoozed_until || '', tracker_show_search_sort: s.tracker_show_search_sort, tracker_show_summary_cards: s.tracker_show_summary_cards, tracker_show_safe_to_spend: s.tracker_show_safe_to_spend, tracker_show_overdue_command_center: s.tracker_show_overdue_command_center, tracker_show_drift_insights: s.tracker_show_drift_insights, }); // Auto-save: every change persists on its own — no Save button. Toggles and // selects feel instant (short debounce); typed inputs get a longer one so we // don't save half-typed numbers. After a save, invalidate the ['settings'] // query so the tracker (which reads its display toggles from that cache) picks // up the change instead of showing a stale value for up to its staleTime. const { status: saveStatus, schedule } = useAutoSave((payload) => api .saveSettings(payload) .then((res) => { queryClient.invalidateQueries({ queryKey: ['settings'] }); return res; }) .catch((err) => { toast.error(errMessage(err, 'Failed to save settings.')); throw err; }), ); const set = (k: string, v: unknown, delay?: number) => setSettings((p) => { const next = { ...p, [k]: v }; schedule(buildPayload(next), delay); return next; }); const setTyped = (k: string, v: unknown) => set(k, v, 900); // for keystroke-driven inputs if (loading) { return (
); } if (loadError) { return (

Failed to load settings

{loadError}

); } return (
{/* Page header — flat on background, live save status on the right */}

Settings

Manage your display, billing, and notification preferences · changes save automatically

{/* Appearance */} {/* Login mode recovery */} {/* General */} Currency } description="How money amounts are shown across the app." > {/* Tracker Layout */} set('tracker_show_bank_projection_banner', String(checked)) } aria-label="Show Bank Projection Banner" /> set('tracker_show_search_sort', String(checked))} aria-label="Show Search and sort" /> set('tracker_show_summary_cards', String(checked))} aria-label="Show Summary cards" /> set('tracker_show_safe_to_spend', String(checked))} aria-label="Show Safe to Spend" /> set('tracker_show_overdue_command_center', String(checked)) } aria-label="Show Overdue Command Center" /> set('tracker_show_drift_insights', String(checked))} aria-label="Show Drift insights" /> {/* Billing Behavior */} Grace period } description="Days after the due date before a bill is marked overdue." >
setTyped('grace_period_days', parseInt(e.target.value, 10) || 0)} className="w-20" /> days
Due soon window } description="How many days ahead a bill is flagged as due soon." >
setTyped( 'due_soon_days', Math.min(Math.max(parseInt(e.target.value, 10) || 0, 0), 31), ) } className="w-20" /> days
Price change sensitivity } description="Flag a bill when recent payments differ from the expected amount by at least this percentage." >
setTyped('drift_threshold_pct', e.target.value)} className="w-20 font-mono" /> %
{/* Notifications — email + push reminder preferences (auto-save too) */}
); }