feat(settings): safe-to-spend toggle, move notifications from Profile to Settings, fix dark-mode readability

This commit is contained in:
null 2026-06-12 01:52:48 -05:00
parent dc49eb9633
commit 8ef794a94a
5 changed files with 63 additions and 16 deletions

View File

@ -80,7 +80,9 @@
--secondary: 0.275 0.018 245; --secondary: 0.275 0.018 245;
--secondary-foreground: 0.94 0.007 245; --secondary-foreground: 0.94 0.007 245;
--muted: 0.275 0.016 245; --muted: 0.275 0.016 245;
--muted-foreground: 0.76 0.012 245; /* Secondary text. Raised 0.76 0.85 at 0.76 labels and hints sat too
close to the card background (L 0.235) and strained the eyes. */
--muted-foreground: 0.85 0.012 245;
--accent: 0.32 0.045 158; --accent: 0.32 0.045 158;
--accent-foreground: 0.965 0.006 245; --accent-foreground: 0.965 0.006 245;
--destructive: 0.66 0.18 26; --destructive: 0.66 0.18 26;
@ -172,20 +174,22 @@
} }
/* Faded muted-text variants: keep them readable in dark mode Tailwind's
raw /40/70 alphas dissolve into the background. */
.dark .text-muted-foreground\/40 { .dark .text-muted-foreground\/40 {
color: oklch(var(--muted-foreground) / 0.72); color: oklch(var(--muted-foreground) / 0.8);
} }
.dark .text-muted-foreground\/50 { .dark .text-muted-foreground\/50 {
color: oklch(var(--muted-foreground) / 0.78); color: oklch(var(--muted-foreground) / 0.85);
} }
.dark .text-muted-foreground\/60 { .dark .text-muted-foreground\/60 {
color: oklch(var(--muted-foreground) / 0.84); color: oklch(var(--muted-foreground) / 0.9);
} }
.dark .text-muted-foreground\/70 { .dark .text-muted-foreground\/70 {
color: oklch(var(--muted-foreground) / 0.9); color: oklch(var(--muted-foreground) / 0.95);
} }
/* Custom Scrollbar */ /* Custom Scrollbar */

View File

@ -21,7 +21,7 @@ function displayNameOf(profile) {
return profile.display_name || profile.displayName || profile.name || ''; return profile.display_name || profile.displayName || profile.name || '';
} }
function asSettings(data) { export function asSettings(data) {
return data?.settings || data?.notifications || data || {}; return data?.settings || data?.notifications || data || {};
} }
@ -360,7 +360,9 @@ function EditProfile({ profile, onSaved }) {
); );
} }
function NotificationPreferences({ settings, onSaved }) { // Exported: rendered on the Settings page ("Notifications" section). Lives here
// because it shares asSettings/CheckRow/SectionCard with the rest of this file.
export function NotificationPreferences({ settings, onSaved }) {
const [form, setForm] = useState(settings); const [form, setForm] = useState(settings);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -427,7 +429,8 @@ const PUSH_CHANNELS = [
{ value: 'telegram', label: 'Telegram', urlLabel: 'Server URL / n/a', urlHint: 'Leave blank for api.telegram.org', tokenLabel: 'Bot token', chatIdLabel: 'Chat ID' }, { value: 'telegram', label: 'Telegram', urlLabel: 'Server URL / n/a', urlHint: 'Leave blank for api.telegram.org', tokenLabel: 'Bot token', chatIdLabel: 'Chat ID' },
]; ];
function PushNotifications({ settings, onSaved }) { // Exported: rendered on the Settings page ("Notifications" section).
export function PushNotifications({ settings, onSaved }) {
const [enabled, setEnabled] = useState(!!settings.notify_push_enabled); const [enabled, setEnabled] = useState(!!settings.notify_push_enabled);
const [channel, setChannel] = useState(settings.push_channel || 'ntfy'); const [channel, setChannel] = useState(settings.push_channel || 'ntfy');
const [url, setUrl] = useState(settings.push_url || ''); const [url, setUrl] = useState(settings.push_url || '');
@ -898,7 +901,6 @@ function ProfileNav() {
const items = [ const items = [
['#account', 'Account'], ['#account', 'Account'],
['#security', 'Security'], ['#security', 'Security'],
['#notifications', 'Notifications'],
['#privacy', 'Privacy'], ['#privacy', 'Privacy'],
]; ];
return ( return (
@ -950,7 +952,7 @@ export default function ProfilePage() {
<div className="mb-6 flex items-start justify-between gap-4"> <div className="mb-6 flex items-start justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight">Profile</h1> <h1 className="text-2xl font-bold tracking-tight">Profile</h1>
<p className="text-sm text-muted-foreground mt-0.5">Manage your account, notification preferences, and password.</p> <p className="text-sm text-muted-foreground mt-0.5">Manage your account, security, and privacy. Notification preferences live in Settings.</p>
</div> </div>
<div className="hidden sm:flex items-center gap-2 rounded-full border border-border bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground"> <div className="hidden sm:flex items-center gap-2 rounded-full border border-border bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground">
<ShieldCheck className="h-3.5 w-3.5 text-emerald-500" /> <ShieldCheck className="h-3.5 w-3.5 text-emerald-500" />
@ -968,10 +970,7 @@ export default function ProfilePage() {
<div id="security" className="scroll-mt-6"> <div id="security" className="scroll-mt-6">
<ChangePassword /> <ChangePassword />
</div> </div>
<div id="notifications" className="scroll-mt-6 space-y-4"> {/* Notification preferences moved to Settings → Notifications */}
{!loading && <NotificationPreferences settings={settings} onSaved={setSettings} />}
{!loading && <PushNotifications settings={settings} onSaved={() => api.profileSettings().then(setSettings).catch(() => {})} />}
</div>
<div id="privacy" className="scroll-mt-6"> <div id="privacy" className="scroll-mt-6">
{!loading && <PrivacySettings settings={settings} onSaved={setSettings} />} {!loading && <PrivacySettings settings={settings} onSaved={setSettings} />}
</div> </div>

View File

@ -13,12 +13,36 @@ import {
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { useTheme } from '@/contexts/ThemeContext'; import { useTheme } from '@/contexts/ThemeContext';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
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() {
return localStorage.getItem(LINK_IMPORT_PREF_KEY) !== 'false'; 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);
const load = useCallback(() => {
api.profileSettings()
.then((data) => setSettings(asSettings(data)))
.catch((err) => toast.error(err.message || 'Failed to load notification settings.'));
}, []);
useEffect(() => { load(); }, [load]);
if (!settings) return null;
return (
<div className="space-y-4 mb-4">
<NotificationPreferences settings={settings} onSaved={setSettings} />
<PushNotifications settings={settings} onSaved={load} />
</div>
);
}
// Card wrapper // Card wrapper
function SectionCard({ title, children }) { function SectionCard({ title, children }) {
@ -255,6 +279,7 @@ export default function SettingsPage() {
tracker_bank_projection_banner_snoozed_until: '', tracker_bank_projection_banner_snoozed_until: '',
tracker_show_search_sort: 'true', tracker_show_search_sort: 'true',
tracker_show_summary_cards: 'true', tracker_show_summary_cards: 'true',
tracker_show_safe_to_spend: 'true',
tracker_show_overdue_command_center: 'true', tracker_show_overdue_command_center: 'true',
tracker_show_drift_insights: 'true', tracker_show_drift_insights: 'true',
}; };
@ -289,6 +314,7 @@ export default function SettingsPage() {
tracker_bank_projection_banner_snoozed_until: settings.tracker_bank_projection_banner_snoozed_until || '', tracker_bank_projection_banner_snoozed_until: settings.tracker_bank_projection_banner_snoozed_until || '',
tracker_show_search_sort: settings.tracker_show_search_sort, tracker_show_search_sort: settings.tracker_show_search_sort,
tracker_show_summary_cards: settings.tracker_show_summary_cards, tracker_show_summary_cards: settings.tracker_show_summary_cards,
tracker_show_safe_to_spend: settings.tracker_show_safe_to_spend,
tracker_show_overdue_command_center: settings.tracker_show_overdue_command_center, tracker_show_overdue_command_center: settings.tracker_show_overdue_command_center,
tracker_show_drift_insights: settings.tracker_show_drift_insights, tracker_show_drift_insights: settings.tracker_show_drift_insights,
}); });
@ -329,7 +355,7 @@ export default function SettingsPage() {
{/* Page header — flat on background */} {/* Page header — flat on background */}
<div className="mb-8"> <div className="mb-8">
<h1 className="text-2xl font-bold tracking-tight">Settings</h1> <h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<p className="text-sm text-muted-foreground mt-0.5">Manage your display and billing preferences</p> <p className="text-sm text-muted-foreground mt-0.5">Manage your display, billing, and notification preferences</p>
</div> </div>
{/* Appearance */} {/* Appearance */}
@ -400,6 +426,16 @@ export default function SettingsPage() {
aria-label="Show Summary cards" aria-label="Show Summary cards"
/> />
</SettingRow> </SettingRow>
<SettingRow
label="Safe to Spend"
description="Show the safe-to-spend projection card with the payday countdown and upcoming bills."
>
<Switch
checked={settingsBool(settings.tracker_show_safe_to_spend)}
onCheckedChange={(checked) => set('tracker_show_safe_to_spend', String(checked))}
aria-label="Show Safe to Spend"
/>
</SettingRow>
<SettingRow <SettingRow
label="Overdue Command Center" label="Overdue Command Center"
description="Show the quick-action panel for overdue bills." description="Show the quick-action panel for overdue bills."
@ -467,6 +503,11 @@ export default function SettingsPage() {
</Button> </Button>
</div> </div>
{/* Notifications — email + push reminder preferences (save independently) */}
<div id="notifications" className="mt-6 scroll-mt-24">
<NotificationsSection />
</div>
<div id="calendar-feed" className="mt-6 scroll-mt-24"> <div id="calendar-feed" className="mt-6 scroll-mt-24">
<SectionCard title="Calendar Feed"> <SectionCard title="Calendar Feed">
<CalendarFeedManager /> <CalendarFeedManager />

View File

@ -387,6 +387,7 @@ export default function TrackerPage() {
const showSearchSort = settingEnabled(trackerSettings.tracker_show_search_sort); const showSearchSort = settingEnabled(trackerSettings.tracker_show_search_sort);
const showSummaryCards = settingEnabled(trackerSettings.tracker_show_summary_cards); const showSummaryCards = settingEnabled(trackerSettings.tracker_show_summary_cards);
const showOverdueCommandCenter = settingEnabled(trackerSettings.tracker_show_overdue_command_center); const showOverdueCommandCenter = settingEnabled(trackerSettings.tracker_show_overdue_command_center);
const showSafeToSpend = settingEnabled(trackerSettings.tracker_show_safe_to_spend);
const showDriftInsights = settingEnabled(trackerSettings.tracker_show_drift_insights); const showDriftInsights = settingEnabled(trackerSettings.tracker_show_drift_insights);
const showBankProjectionBanner = settingEnabled(trackerSettings.tracker_show_bank_projection_banner) && const showBankProjectionBanner = settingEnabled(trackerSettings.tracker_show_bank_projection_banner) &&
(!bannerSnoozedUntil || bannerSnoozedUntil <= today); (!bannerSnoozedUntil || bannerSnoozedUntil <= today);
@ -848,7 +849,7 @@ export default function TrackerPage() {
) : null} ) : null}
{/* ── Safe to Spend ── */} {/* ── Safe to Spend ── */}
{!isError && !loading && isCurrentMonth && cashflow && ( {!isError && !loading && showSafeToSpend && isCurrentMonth && cashflow && (
<CashFlowCard <CashFlowCard
cashflow={cashflow} cashflow={cashflow}
onSetStartingAmounts={() => setEditStartingOpen(true)} onSetStartingAmounts={() => setEditStartingOpen(true)}

View File

@ -17,6 +17,7 @@ const USER_SETTING_KEYS = [
'tracker_bank_projection_banner_snoozed_until', 'tracker_bank_projection_banner_snoozed_until',
'tracker_show_search_sort', 'tracker_show_search_sort',
'tracker_show_summary_cards', 'tracker_show_summary_cards',
'tracker_show_safe_to_spend',
'tracker_show_overdue_command_center', 'tracker_show_overdue_command_center',
'tracker_show_drift_insights', 'tracker_show_drift_insights',
'tracker_table_columns', 'tracker_table_columns',
@ -28,6 +29,7 @@ const USER_SETTING_DEFAULTS = {
tracker_bank_projection_banner_snoozed_until: '', tracker_bank_projection_banner_snoozed_until: '',
tracker_show_search_sort: 'true', tracker_show_search_sort: 'true',
tracker_show_summary_cards: 'true', tracker_show_summary_cards: 'true',
tracker_show_safe_to_spend: 'true',
tracker_show_overdue_command_center: 'true', tracker_show_overdue_command_center: 'true',
tracker_show_drift_insights: 'true', tracker_show_drift_insights: 'true',
tracker_table_columns: '["due","expected","previous","paid","paid_date","status","action","notes"]', tracker_table_columns: '["due","expected","previous","paid","paid_date","status","action","notes"]',