refactor(ts): SettingsPage → TypeScript (auto-save settings + tracker layout toggles)

TS flagged a dead prop: onSaved was passed to NotificationPreferences (which only
takes settings) — removed it. Notification components imported from ProfilePage
(still .jsx) type as any until that page converts.
This commit is contained in:
null 2026-07-04 22:29:16 -05:00
parent b912fd74be
commit 18d0c0bdb9
1 changed files with 42 additions and 21 deletions

View File

@ -1,9 +1,9 @@
import React, { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, type ComponentType, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { AlertCircle, Moon, RefreshCw, Sun, Users } from 'lucide-react'; import { AlertCircle, Moon, RefreshCw, Sun, Users } from 'lucide-react';
import { api } from '@/api'; import { api } from '@/api';
import { cn } from '@/lib/utils'; import { cn, errMessage } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { CalendarFeedManager } from '@/components/CalendarFeedManager'; import { CalendarFeedManager } from '@/components/CalendarFeedManager';
@ -18,7 +18,7 @@ import { SaveStatus } from '@/components/ui/save-status';
import { NotificationPreferences, PushNotifications, asSettings } from '@/pages/ProfilePage'; import { NotificationPreferences, PushNotifications, asSettings } from '@/pages/ProfilePage';
export const LINK_IMPORT_PREF_KEY = 'link_import_ask'; export const LINK_IMPORT_PREF_KEY = 'link_import_ask';
export function getLinkImportPref() { export function getLinkImportPref(): boolean {
return localStorage.getItem(LINK_IMPORT_PREF_KEY) !== 'false'; return localStorage.getItem(LINK_IMPORT_PREF_KEY) !== 'false';
} }
@ -26,12 +26,12 @@ export function getLinkImportPref() {
// preferences; Profile keeps identity, security, and privacy) ────────────── // preferences; Profile keeps identity, security, and privacy) ──────────────
function NotificationsSection() { function NotificationsSection() {
const [settings, setSettings] = useState(null); const [settings, setSettings] = useState<Record<string, unknown> | null>(null);
const load = useCallback(() => { const load = useCallback(() => {
api.profileSettings() api.profileSettings()
.then((data) => setSettings(asSettings(data))) .then((data) => setSettings(asSettings(data)))
.catch((err) => toast.error(err.message || 'Failed to load notification settings.')); .catch((err) => toast.error(errMessage(err, 'Failed to load notification settings.')));
}, []); }, []);
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
@ -39,7 +39,7 @@ function NotificationsSection() {
if (!settings) return null; if (!settings) return null;
return ( return (
<div className="space-y-4 mb-4"> <div className="space-y-4 mb-4">
<NotificationPreferences settings={settings} onSaved={setSettings} /> <NotificationPreferences settings={settings} />
<PushNotifications settings={settings} onSaved={load} /> <PushNotifications settings={settings} onSaved={load} />
</div> </div>
); );
@ -47,7 +47,7 @@ function NotificationsSection() {
// ─── Card wrapper ───────────────────────────────────────────────────────────── // ─── Card wrapper ─────────────────────────────────────────────────────────────
function SectionCard({ title, children }) { function SectionCard({ title, children }: { title: ReactNode; children: ReactNode }) {
return ( return (
<div className="table-surface mb-4"> <div className="table-surface mb-4">
<div className="px-6 py-4 border-b border-border/50"> <div className="px-6 py-4 border-b border-border/50">
@ -62,7 +62,7 @@ function SectionCard({ title, children }) {
// ─── Setting Row ────────────────────────────────────────────────────────────── // ─── Setting Row ──────────────────────────────────────────────────────────────
function SettingRow({ label, description, children }) { function SettingRow({ label, description, children }: { label: ReactNode; description?: ReactNode; children: ReactNode }) {
return ( return (
<div className="px-4 py-4 flex flex-col gap-3 sm:px-6 sm:flex-row sm:items-center sm:justify-between"> <div className="px-4 py-4 flex flex-col gap-3 sm:px-6 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1 min-w-0 sm:mr-8"> <div className="flex-1 min-w-0 sm:mr-8">
@ -80,7 +80,13 @@ function SettingRow({ label, description, children }) {
// ─── Theme Card ─────────────────────────────────────────────────────────────── // ─── Theme Card ───────────────────────────────────────────────────────────────
function ThemeCard({ value, label, icon: Icon, currentTheme, onSelect }) { 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; const selected = currentTheme === value;
return ( return (
<button <button
@ -132,7 +138,7 @@ function LoginModeRecoverySection() {
refresh(); refresh();
navigate('/login', { replace: true }); navigate('/login', { replace: true });
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to restore multi-user mode.'); toast.error(errMessage(err, 'Failed to restore multi-user mode.'));
} finally { } finally {
setRestoring(false); setRestoring(false);
} }
@ -246,7 +252,7 @@ function SettingsSkeleton() {
function LinkImportToggle() { function LinkImportToggle() {
const [enabled, setEnabled] = useState(getLinkImportPref); const [enabled, setEnabled] = useState(getLinkImportPref);
function toggle(next) { function toggle(next: boolean) {
localStorage.setItem(LINK_IMPORT_PREF_KEY, String(next)); localStorage.setItem(LINK_IMPORT_PREF_KEY, String(next));
setEnabled(next); setEnabled(next);
toast.success(next toast.success(next
@ -264,15 +270,30 @@ function LinkImportToggle() {
); );
} }
function settingsBool(value, fallback = true) { function settingsBool(value: unknown, fallback = true): boolean {
if (value === undefined || value === null || value === '') return fallback; if (value === undefined || value === null || value === '') return fallback;
return value === true || value === 'true'; return value === true || value === 'true';
} }
interface SettingsState {
currency: string;
date_format: string;
grace_period_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 ───────────────────────────────────────────────────────────── // ─── SettingsPage ─────────────────────────────────────────────────────────────
export default function SettingsPage() { export default function SettingsPage() {
const DEFAULTS = { const DEFAULTS: SettingsState = {
currency: 'USD', currency: 'USD',
date_format: 'MM/DD/YYYY', date_format: 'MM/DD/YYYY',
grace_period_days: 3, grace_period_days: 3,
@ -286,22 +307,22 @@ export default function SettingsPage() {
tracker_show_drift_insights: 'true', tracker_show_drift_insights: 'true',
}; };
const [settings, setSettings] = useState(DEFAULTS); const [settings, setSettings] = useState<SettingsState>(DEFAULTS);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(null); const [loadError, setLoadError] = useState<string | null>(null);
const loadSettings = useCallback(() => { const loadSettings = useCallback(() => {
setLoading(true); setLoading(true);
setLoadError(null); setLoadError(null);
api.settings() api.settings()
.then((d) => setSettings({ ...DEFAULTS, ...d })) .then((d) => setSettings({ ...DEFAULTS, ...(d as Partial<SettingsState>) }))
.catch((err) => setLoadError(err.message || 'Failed to load settings')) .catch((err) => setLoadError(errMessage(err, 'Failed to load settings')))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { loadSettings(); }, [loadSettings]); useEffect(() => { loadSettings(); }, [loadSettings]);
const buildPayload = (s) => ({ const buildPayload = (s: SettingsState) => ({
currency: s.currency, currency: s.currency,
date_format: s.date_format, date_format: s.date_format,
grace_period_days: s.grace_period_days, grace_period_days: s.grace_period_days,
@ -320,17 +341,17 @@ export default function SettingsPage() {
// don't save half-typed numbers. // don't save half-typed numbers.
const { status: saveStatus, schedule } = useAutoSave( const { status: saveStatus, schedule } = useAutoSave(
(payload) => api.saveSettings(payload).catch((err) => { (payload) => api.saveSettings(payload).catch((err) => {
toast.error(err.message || 'Failed to save settings.'); toast.error(errMessage(err, 'Failed to save settings.'));
throw err; throw err;
}), }),
); );
const set = (k, v, delay) => setSettings((p) => { const set = (k: string, v: unknown, delay?: number) => setSettings((p) => {
const next = { ...p, [k]: v }; const next = { ...p, [k]: v };
schedule(buildPayload(next), delay); schedule(buildPayload(next), delay);
return next; return next;
}); });
const setTyped = (k, v) => set(k, v, 900); // for keystroke-driven inputs const setTyped = (k: string, v: unknown) => set(k, v, 900); // for keystroke-driven inputs
if (loading) { if (loading) {
return ( return (