'use client'; import { useEffect, useState } from 'react'; import api from '@/lib/api'; import { VolumeIcon, VolumeOffIcon, SparklesIcon } from '@/components/ui/Icons'; import { playStepComplete } from '@/lib/sounds'; import { hapticTap } from '@/lib/haptics'; import PushNotificationToggle from '@/components/notifications/PushNotificationToggle'; interface Preferences { sound_enabled: boolean; haptic_enabled: boolean; show_launch_screen: boolean; celebration_style: string; } interface NotifSettings { discord_user_id: string; discord_enabled: boolean; ntfy_topic: string; ntfy_enabled: boolean; } interface AdaptiveMedSettings { adaptive_timing_enabled: boolean; adaptive_mode: string; presence_tracking_enabled: boolean; nagging_enabled: boolean; nag_interval_minutes: number; max_nag_count: number; quiet_hours_start: string | null; quiet_hours_end: string | null; } interface PresenceStatus { is_online: boolean; last_online_at: string | null; typical_wake_time: string | null; } interface SnitchSettings { snitch_enabled: boolean; trigger_after_nags: number; trigger_after_missed_doses: number; max_snitches_per_day: number; require_consent: boolean; consent_given: boolean; snitch_cooldown_hours: number; } interface SnitchContact { id: string; contact_name: string; contact_type: string; contact_value: string; priority: number; notify_all: boolean; is_active: boolean; } export default function SettingsPage() { const [prefs, setPrefs] = useState({ sound_enabled: false, haptic_enabled: true, show_launch_screen: true, celebration_style: 'standard', }); const [notif, setNotif] = useState({ discord_user_id: '', discord_enabled: false, ntfy_topic: '', ntfy_enabled: false, }); const [adaptiveMeds, setAdaptiveMeds] = useState({ adaptive_timing_enabled: false, adaptive_mode: 'shift_all', presence_tracking_enabled: false, nagging_enabled: true, nag_interval_minutes: 15, max_nag_count: 4, quiet_hours_start: null, quiet_hours_end: null, }); const [presence, setPresence] = useState({ is_online: false, last_online_at: null, typical_wake_time: null, }); const [snitch, setSnitch] = useState({ snitch_enabled: false, trigger_after_nags: 4, trigger_after_missed_doses: 1, max_snitches_per_day: 2, require_consent: true, consent_given: false, snitch_cooldown_hours: 4, }); const [snitchContacts, setSnitchContacts] = useState([]); const [isLoading, setIsLoading] = useState(true); const [saved, setSaved] = useState(false); const [showHelp, setShowHelp] = useState(false); const [showSnitchHelp, setShowSnitchHelp] = useState(false); const [showAddContact, setShowAddContact] = useState(false); const [newContact, setNewContact] = useState({ contact_name: '', contact_type: 'discord', contact_value: '', priority: 1, notify_all: false, }); useEffect(() => { Promise.all([ api.preferences.get().then((data: Preferences) => setPrefs(data)), api.notifications.getSettings().then((data) => setNotif({ discord_user_id: data.discord_user_id, discord_enabled: data.discord_enabled, ntfy_topic: data.ntfy_topic, ntfy_enabled: data.ntfy_enabled, })), api.adaptiveMeds.getSettings().then((data: AdaptiveMedSettings) => setAdaptiveMeds(data)), api.adaptiveMeds.getPresence().then((data: PresenceStatus) => setPresence(data)), api.snitch.getSettings().then((data: SnitchSettings) => setSnitch(data)), api.snitch.getContacts().then((data: SnitchContact[]) => setSnitchContacts(data)), ]) .catch(() => {}) .finally(() => setIsLoading(false)); }, []); // Poll for presence updates every 10 seconds useEffect(() => { if (!notif.discord_enabled || !adaptiveMeds.presence_tracking_enabled) return; const interval = setInterval(() => { api.adaptiveMeds.getPresence().then((data: PresenceStatus) => setPresence(data)); }, 10000); return () => clearInterval(interval); }, [notif.discord_enabled, adaptiveMeds.presence_tracking_enabled]); const flashSaved = () => { setSaved(true); setTimeout(() => setSaved(false), 1500); }; const updatePref = async (key: keyof Preferences, value: boolean | string) => { const updated = { ...prefs, [key]: value }; setPrefs(updated); try { await api.preferences.update({ [key]: value }); flashSaved(); } catch { setPrefs(prefs); } }; const updateNotif = async (updates: Partial) => { const prev = { ...notif }; const updated = { ...notif, ...updates }; setNotif(updated); try { await api.notifications.updateSettings(updates); flashSaved(); } catch { setNotif(prev); } }; const updateAdaptiveMeds = async (updates: Partial) => { const prev = { ...adaptiveMeds }; const updated = { ...adaptiveMeds, ...updates }; setAdaptiveMeds(updated); try { await api.adaptiveMeds.updateSettings(updates); flashSaved(); } catch { setAdaptiveMeds(prev); } }; const updateSnitch = async (updates: Partial) => { const prev = { ...snitch }; const updated = { ...snitch, ...updates }; setSnitch(updated); try { await api.snitch.updateSettings(updates); flashSaved(); } catch { setSnitch(prev); } }; const addContact = async () => { try { const result = await api.snitch.addContact(newContact); const contact: SnitchContact = { id: result.contact_id, ...newContact, is_active: true, }; setSnitchContacts([...snitchContacts, contact]); setNewContact({ contact_name: '', contact_type: 'discord', contact_value: '', priority: 1, notify_all: false, }); setShowAddContact(false); flashSaved(); } catch (e) { console.error('Failed to add contact:', e); } }; const updateContact = async (contactId: string, updates: Partial) => { const prev = [...snitchContacts]; const updated = snitchContacts.map(c => c.id === contactId ? { ...c, ...updates } : c ); setSnitchContacts(updated); try { await api.snitch.updateContact(contactId, updates); flashSaved(); } catch { setSnitchContacts(prev); } }; const deleteContact = async (contactId: string) => { const prev = [...snitchContacts]; setSnitchContacts(snitchContacts.filter(c => c.id !== contactId)); try { await api.snitch.deleteContact(contactId); flashSaved(); } catch { setSnitchContacts(prev); } }; const testSnitch = async () => { try { const result = await api.snitch.test(); alert(result.message); } catch (e) { alert('Failed to send test snitch'); } }; if (isLoading) { return (
); } return (

Settings

{saved && ( Saved )}
{/* Session Experience */}

Session Experience

{/* Sound */}
{prefs.sound_enabled ? ( ) : ( )}

Sound effects

Subtle audio cues on step completion

{/* Haptics */}

Haptic feedback

Gentle vibration on actions

{/* Launch Screen */}

Pre-routine launch screen

Environment check and emotion bridge

{/* Notifications */}

Notifications

{/* Push Notifications */} {/* ntfy */}

ntfy

Push notifications via ntfy.sh

{notif.ntfy_enabled && ( setNotif({ ...notif, ntfy_topic: e.target.value })} onBlur={() => updateNotif({ ntfy_topic: notif.ntfy_topic })} className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 bg-white dark:bg-gray-700" /> )}
{/* Discord */}

Discord

Get DMs from the Synculous bot

{notif.discord_enabled && (
{ const val = e.target.value; if (val === '' || /^\d+$/.test(val)) { setNotif({ ...notif, discord_user_id: val }); } }} onBlur={() => updateNotif({ discord_user_id: notif.discord_user_id })} className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 bg-white dark:bg-gray-700" />

Enable Developer Mode in Discord, right-click your profile, and copy User ID

)}
{/* Adaptive Medication Settings */}

Smart Medication Timing

{showHelp && (

Adaptive Timing: Automatically adjusts your medication schedule based on when you wake up. If you wake up late, your morning meds get shifted too.

Discord Presence: Detects when you come online (wake up) and uses that to calculate adjustments. Requires Discord notifications to be enabled.

Nagging: Sends you reminders every 15 minutes (configurable) up to 4 times if you miss a dose.

)}
{/* Enable Adaptive Timing */}

Enable adaptive timing

Adjust medication times based on your wake time

{adaptiveMeds.adaptive_timing_enabled && (
{/* Adaptive Mode Selection */}

Adjustment mode

)}
{/* Presence Tracking */}

Discord presence tracking

Detect when you wake up via Discord

{!notif.discord_enabled && (

Enable Discord notifications above to use presence tracking

)} {notif.discord_enabled && (
{presence.is_online ? 'Online' : 'Offline'}
{presence.last_online_at ? `Last seen: ${new Date(presence.last_online_at).toLocaleString()}` : 'Never seen online'}
{presence.typical_wake_time && (

Typical wake time: {presence.typical_wake_time}

)}
)}
{/* Nagging Settings */}

Enable nagging

Send reminders for missed doses

{adaptiveMeds.nagging_enabled && ( <> {/* Nag Interval */}
updateAdaptiveMeds({ nag_interval_minutes: parseInt(e.target.value) || 15 })} className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700" />
{/* Max Nag Count */}
updateAdaptiveMeds({ max_nag_count: parseInt(e.target.value) || 4 })} className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700" />
)}
{/* Quiet Hours */}

Quiet hours

Don't send notifications during these hours

updateAdaptiveMeds({ quiet_hours_start: e.target.value || null })} className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700" />
updateAdaptiveMeds({ quiet_hours_end: e.target.value || null })} className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700" />
{/* Celebration Style */}

Celebration Style

{[ { value: 'standard', label: 'Standard', desc: 'Full animated celebration with stats and rewards' }, { value: 'quick', label: 'Quick', desc: 'Brief confirmation, then back to dashboard' }, { value: 'none', label: 'None', desc: 'No celebration screen, return immediately' }, ].map(option => ( ))}
{/* Snitch System */}

Snitch System

{showSnitchHelp && (

The Snitch: When you miss medications repeatedly, the system can notify someone you trust (a "snitch") to help keep you accountable.

Consent: You must give consent to enable this feature. You can revoke consent at any time.

Triggers: Configure after how many nags or missed doses the snitch activates.

Privacy: Only you can see and manage your snitch contacts. They only receive alerts when triggers are met.

)}
{/* Consent */}

Enable snitch system

Allow trusted contacts to be notified about missed medications

{/* Consent Toggle */}

I consent to snitch notifications

I understand and agree that trusted contacts may be notified

{snitch.snitch_enabled && ( <> {/* Trigger Settings */}

Trigger Settings

updateSnitch({ trigger_after_nags: parseInt(e.target.value) || 4 })} className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700" />
updateSnitch({ trigger_after_missed_doses: parseInt(e.target.value) || 1 })} className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700" />
updateSnitch({ max_snitches_per_day: parseInt(e.target.value) || 2 })} className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700" />
updateSnitch({ snitch_cooldown_hours: parseInt(e.target.value) || 4 })} className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700" />
{/* Contacts */}

Snitch Contacts

{/* Add Contact Form */} {showAddContact && (
setNewContact({ ...newContact, contact_name: e.target.value })} className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-800" /> { const val = e.target.value; if (newContact.contact_type === 'discord') { if (val === '' || /^\d+$/.test(val)) { setNewContact({ ...newContact, contact_value: val }); } } else { setNewContact({ ...newContact, contact_value: val }); } }} className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-800" />
)} {/* Contact List */}
{snitchContacts.map((contact) => (
{contact.contact_name} {contact.contact_type} {contact.notify_all && ( Always notify )}

{contact.contact_value}

))} {snitchContacts.length === 0 && (

No contacts added yet

)}
{/* Test Button */} {snitchContacts.length > 0 && ( )}
)}
); }