Add complete snitch system UI to settings page with contact management and consent flow

This commit is contained in:
2026-02-16 20:16:29 -06:00
parent a6ae4e13fd
commit 35f51e6d27

View File

@@ -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<Preferences>({
sound_enabled: false,
@@ -66,9 +86,28 @@ export default function SettingsPage() {
last_online_at: null,
typical_wake_time: null,
});
const [snitch, setSnitch] = useState<SnitchSettings>({
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<SnitchContact[]>([]);
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<SnitchSettings>) => {
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<SnitchContact>) => {
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 (
<div className="flex items-center justify-center min-h-[50vh]">
@@ -536,6 +646,263 @@ export default function SettingsPage() {
))}
</div>
</div>
{/* Snitch System */}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Snitch System</h2>
<button
onClick={() => setShowSnitchHelp(!showSnitchHelp)}
className="text-sm text-indigo-500 hover:text-indigo-600"
>
{showSnitchHelp ? 'Hide Help' : 'What is this?'}
</button>
</div>
{showSnitchHelp && (
<div className="mb-4 p-4 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg text-sm text-gray-700 dark:text-gray-300">
<p className="mb-2"><strong>The Snitch:</strong> When you miss medications repeatedly, the system can notify someone you trust (a &quot;snitch&quot;) to help keep you accountable.</p>
<p className="mb-2"><strong>Consent:</strong> You must give consent to enable this feature. You can revoke consent at any time.</p>
<p className="mb-2"><strong>Triggers:</strong> Configure after how many nags or missed doses the snitch activates.</p>
<p><strong>Privacy:</strong> Only you can see and manage your snitch contacts. They only receive alerts when triggers are met.</p>
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm divide-y divide-gray-100 dark:divide-gray-700">
{/* Consent */}
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">Enable snitch system</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Allow trusted contacts to be notified about missed medications</p>
</div>
<button
onClick={() => {
if (!snitch.consent_given) {
alert('Please give consent below first');
return;
}
updateSnitch({ snitch_enabled: !snitch.snitch_enabled });
}}
disabled={!snitch.consent_given}
className={`w-12 h-7 rounded-full transition-colors ${
snitch.snitch_enabled ? 'bg-indigo-500' : 'bg-gray-300 dark:bg-gray-600'
} ${!snitch.consent_given ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
snitch.snitch_enabled ? 'translate-x-5' : ''
}`} />
</button>
</div>
{/* Consent Toggle */}
<div className="mt-3 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">I consent to snitch notifications</p>
<p className="text-xs text-gray-500 dark:text-gray-400">I understand and agree that trusted contacts may be notified</p>
</div>
<button
onClick={() => updateSnitch({ consent_given: !snitch.consent_given })}
className={`w-12 h-7 rounded-full transition-colors ${
snitch.consent_given ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
snitch.consent_given ? 'translate-x-5' : ''
}`} />
</button>
</div>
</div>
</div>
{snitch.snitch_enabled && (
<>
{/* Trigger Settings */}
<div className="p-4 space-y-4">
<p className="font-medium text-gray-900 dark:text-gray-100">Trigger Settings</p>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Trigger after nags
</label>
<input
type="number"
min="1"
max="20"
value={snitch.trigger_after_nags}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Trigger after missed doses
</label>
<input
type="number"
min="1"
max="10"
value={snitch.trigger_after_missed_doses}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max snitches per day
</label>
<input
type="number"
min="1"
max="10"
value={snitch.max_snitches_per_day}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Cooldown between snitches (hours)
</label>
<input
type="number"
min="1"
max="24"
value={snitch.snitch_cooldown_hours}
onChange={(e) => 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"
/>
</div>
</div>
{/* Contacts */}
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<p className="font-medium text-gray-900 dark:text-gray-100">Snitch Contacts</p>
<button
onClick={() => setShowAddContact(!showAddContact)}
className="text-sm px-3 py-1 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600"
>
+ Add Contact
</button>
</div>
{/* Add Contact Form */}
{showAddContact && (
<div className="mb-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg space-y-3">
<input
type="text"
placeholder="Contact name"
value={newContact.contact_name}
onChange={(e) => 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"
/>
<select
value={newContact.contact_type}
onChange={(e) => setNewContact({ ...newContact, contact_type: 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"
>
<option value="discord">Discord</option>
<option value="email">Email</option>
<option value="sms">SMS</option>
</select>
<input
type="text"
placeholder={newContact.contact_type === 'discord' ? 'Discord User ID' : newContact.contact_type === 'email' ? 'Email address' : 'Phone number'}
value={newContact.contact_value}
onChange={(e) => 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"
/>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={newContact.notify_all}
onChange={(e) => setNewContact({ ...newContact, notify_all: e.target.checked })}
className="rounded border-gray-300"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Always notify this contact</span>
</label>
<div className="flex gap-2">
<button
onClick={() => setShowAddContact(false)}
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800"
>
Cancel
</button>
<button
onClick={addContact}
disabled={!newContact.contact_name || !newContact.contact_value}
className="px-3 py-1 text-sm bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 disabled:opacity-50"
>
Save
</button>
</div>
</div>
</div>
)}
{/* Contact List */}
<div className="space-y-2">
{snitchContacts.map((contact) => (
<div key={contact.id} className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-gray-100">{contact.contact_name}</span>
<span className="text-xs px-2 py-0.5 bg-gray-200 dark:bg-gray-600 rounded-full text-gray-600 dark:text-gray-400">
{contact.contact_type}
</span>
{contact.notify_all && (
<span className="text-xs px-2 py-0.5 bg-indigo-100 dark:bg-indigo-900 text-indigo-600 dark:text-indigo-400 rounded-full">
Always notify
</span>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">{contact.contact_value}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => updateContact(contact.id, { is_active: !contact.is_active })}
className={`text-xs px-2 py-1 rounded ${
contact.is_active
? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400'
: 'bg-gray-200 dark:bg-gray-600 text-gray-500'
}`}
>
{contact.is_active ? 'Active' : 'Inactive'}
</button>
<button
onClick={() => deleteContact(contact.id)}
className="text-red-500 hover:text-red-600 p-1"
>
🗑
</button>
</div>
</div>
))}
{snitchContacts.length === 0 && (
<p className="text-center text-gray-500 dark:text-gray-400 py-4">No contacts added yet</p>
)}
</div>
{/* Test Button */}
{snitchContacts.length > 0 && (
<button
onClick={testSnitch}
className="mt-4 w-full py-2 text-sm border-2 border-indigo-500 text-indigo-500 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/20"
>
🧪 Test Snitch (sends to first contact only)
</button>
)}
</div>
</>
)}
</div>
</div>
</div>
);
}