From 35f51e6d27c91342603cf5516c3a87501b7420e6 Mon Sep 17 00:00:00 2001 From: chelsea Date: Mon, 16 Feb 2026 20:16:29 -0600 Subject: [PATCH] Add complete snitch system UI to settings page with contact management and consent flow --- .../src/app/dashboard/settings/page.tsx | 367 ++++++++++++++++++ 1 file changed, 367 insertions(+) diff --git a/synculous-client/src/app/dashboard/settings/page.tsx b/synculous-client/src/app/dashboard/settings/page.tsx index da98ff1..50b37dd 100644 --- a/synculous-client/src/app/dashboard/settings/page.tsx +++ b/synculous-client/src/app/dashboard/settings/page.tsx @@ -38,6 +38,26 @@ interface PresenceStatus { 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, @@ -66,9 +86,28 @@ export default function SettingsPage() { 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([ @@ -81,6 +120,8 @@ export default function SettingsPage() { })), 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)); @@ -126,6 +167,75 @@ export default function SettingsPage() { } }; + 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 (
@@ -536,6 +646,263 @@ export default function SettingsPage() { ))}
+ + {/* 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" + /> + + setNewContact({ ...newContact, contact_value: 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" + /> +
+ +
+ + +
+
+
+ )} + + {/* 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 && ( + + )} +
+ + )} +
+
); }