Add complete snitch system UI to settings page with contact management and consent flow
This commit is contained in:
@@ -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 "snitch") 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user