Files
Synculous-2/synculous-client/src/app/dashboard/settings/page.tsx

956 lines
43 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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<Preferences>({
sound_enabled: false,
haptic_enabled: true,
show_launch_screen: true,
celebration_style: 'standard',
});
const [notif, setNotif] = useState<NotifSettings>({
discord_user_id: '',
discord_enabled: false,
ntfy_topic: '',
ntfy_enabled: false,
});
const [adaptiveMeds, setAdaptiveMeds] = useState<AdaptiveMedSettings>({
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<PresenceStatus>({
is_online: false,
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([
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<NotifSettings>) => {
const prev = { ...notif };
const updated = { ...notif, ...updates };
setNotif(updated);
try {
await api.notifications.updateSettings(updates);
flashSaved();
} catch {
setNotif(prev);
}
};
const updateAdaptiveMeds = async (updates: Partial<AdaptiveMedSettings>) => {
const prev = { ...adaptiveMeds };
const updated = { ...adaptiveMeds, ...updates };
setAdaptiveMeds(updated);
try {
await api.adaptiveMeds.updateSettings(updates);
flashSaved();
} catch {
setAdaptiveMeds(prev);
}
};
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]">
<div className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
return (
<div className="p-4 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Settings</h1>
{saved && (
<span className="text-sm text-green-600 dark:text-green-400 animate-fade-in-up">Saved</span>
)}
</div>
{/* Session Experience */}
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Session Experience</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm divide-y divide-gray-100 dark:divide-gray-700">
{/* Sound */}
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
{prefs.sound_enabled ? (
<VolumeIcon size={20} className="text-indigo-500" />
) : (
<VolumeOffIcon size={20} className="text-gray-400 dark:text-gray-500" />
)}
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">Sound effects</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Subtle audio cues on step completion</p>
</div>
</div>
<button
onClick={() => {
updatePref('sound_enabled', !prefs.sound_enabled);
if (!prefs.sound_enabled) playStepComplete();
}}
className={`w-12 h-7 rounded-full transition-colors ${
prefs.sound_enabled ? 'bg-indigo-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
prefs.sound_enabled ? 'translate-x-5' : ''
}`} />
</button>
</div>
{/* Haptics */}
<div className="flex items-center justify-between p-4">
<div className="flex items-center gap-3">
<SparklesIcon size={20} className={prefs.haptic_enabled ? 'text-indigo-500' : 'text-gray-400 dark:text-gray-500'} />
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">Haptic feedback</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Gentle vibration on actions</p>
</div>
</div>
<button
onClick={() => {
updatePref('haptic_enabled', !prefs.haptic_enabled);
if (!prefs.haptic_enabled) hapticTap();
}}
className={`w-12 h-7 rounded-full transition-colors ${
prefs.haptic_enabled ? 'bg-indigo-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
prefs.haptic_enabled ? 'translate-x-5' : ''
}`} />
</button>
</div>
{/* Launch Screen */}
<div className="flex items-center justify-between p-4">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">Pre-routine launch screen</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Environment check and emotion bridge</p>
</div>
<button
onClick={() => updatePref('show_launch_screen', !prefs.show_launch_screen)}
className={`w-12 h-7 rounded-full transition-colors ${
prefs.show_launch_screen ? 'bg-indigo-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
prefs.show_launch_screen ? 'translate-x-5' : ''
}`} />
</button>
</div>
</div>
</div>
{/* Notifications */}
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Notifications</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm divide-y divide-gray-100 dark:divide-gray-700">
{/* Push Notifications */}
<PushNotificationToggle />
{/* ntfy */}
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">ntfy</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Push notifications via ntfy.sh</p>
</div>
<button
onClick={() => updateNotif({ ntfy_enabled: !notif.ntfy_enabled })}
className={`w-12 h-7 rounded-full transition-colors ${
notif.ntfy_enabled ? 'bg-indigo-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
notif.ntfy_enabled ? 'translate-x-5' : ''
}`} />
</button>
</div>
{notif.ntfy_enabled && (
<input
type="text"
placeholder="Your ntfy topic ID"
value={notif.ntfy_topic}
onChange={(e) => 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"
/>
)}
</div>
{/* Discord */}
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">Discord</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Get DMs from the Synculous bot</p>
</div>
<button
onClick={() => updateNotif({ discord_enabled: !notif.discord_enabled })}
className={`w-12 h-7 rounded-full transition-colors ${
notif.discord_enabled ? 'bg-indigo-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
notif.discord_enabled ? 'translate-x-5' : ''
}`} />
</button>
</div>
{notif.discord_enabled && (
<div className="space-y-1">
<input
type="text"
placeholder="Your Discord user ID (numbers only)"
value={notif.discord_user_id}
onChange={(e) => {
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"
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
Enable Developer Mode in Discord, right-click your profile, and copy User ID
</p>
</div>
)}
</div>
</div>
</div>
{/* Adaptive Medication Settings */}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Smart Medication Timing</h2>
<button
onClick={() => setShowHelp(!showHelp)}
className="text-sm text-indigo-500 hover:text-indigo-600"
>
{showHelp ? 'Hide Help' : 'What is this?'}
</button>
</div>
{showHelp && (
<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>Adaptive Timing:</strong> Automatically adjusts your medication schedule based on when you wake up. If you wake up late, your morning meds get shifted too.</p>
<p className="mb-2"><strong>Discord Presence:</strong> Detects when you come online (wake up) and uses that to calculate adjustments. Requires Discord notifications to be enabled.</p>
<p><strong>Nagging:</strong> Sends you reminders every 15 minutes (configurable) up to 4 times if you miss a dose.</p>
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm divide-y divide-gray-100 dark:divide-gray-700">
{/* Enable Adaptive Timing */}
<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 adaptive timing</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Adjust medication times based on your wake time</p>
</div>
<button
onClick={() => {
const newEnabled = !adaptiveMeds.adaptive_timing_enabled;
const updates: Partial<AdaptiveMedSettings> = { adaptive_timing_enabled: newEnabled };
if (newEnabled) {
updates.adaptive_mode = adaptiveMeds.adaptive_mode;
}
updateAdaptiveMeds(updates);
}}
className={`w-12 h-7 rounded-full transition-colors ${
adaptiveMeds.adaptive_timing_enabled ? 'bg-indigo-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
adaptiveMeds.adaptive_timing_enabled ? 'translate-x-5' : ''
}`} />
</button>
</div>
{adaptiveMeds.adaptive_timing_enabled && (
<div className="mt-3 space-y-3">
{/* Adaptive Mode Selection */}
<div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Adjustment mode</p>
<div className="space-y-2">
<button
onClick={() => updateAdaptiveMeds({ adaptive_mode: 'shift_all' })}
className={`w-full flex items-center justify-between p-3 rounded-lg border ${
adaptiveMeds.adaptive_mode === 'shift_all'
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20'
: 'border-gray-200 dark:border-gray-600'
}`}
>
<div className="text-left">
<p className="font-medium text-gray-900 dark:text-gray-100">Shift all medications</p>
<p className="text-xs text-gray-500 dark:text-gray-400">Delay all doses by the same amount</p>
</div>
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
adaptiveMeds.adaptive_mode === 'shift_all'
? 'border-indigo-500'
: 'border-gray-300 dark:border-gray-600'
}`}>
{adaptiveMeds.adaptive_mode === 'shift_all' && (
<div className="w-2.5 h-2.5 rounded-full bg-indigo-500" />
)}
</div>
</button>
<button
onClick={() => updateAdaptiveMeds({ adaptive_mode: 'shift_partial' })}
className={`w-full flex items-center justify-between p-3 rounded-lg border ${
adaptiveMeds.adaptive_mode === 'shift_partial'
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20'
: 'border-gray-200 dark:border-gray-600'
}`}
>
<div className="text-left">
<p className="font-medium text-gray-900 dark:text-gray-100">Partial shift</p>
<p className="text-xs text-gray-500 dark:text-gray-400">Shift morning meds only, keep afternoon/evening fixed</p>
</div>
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
adaptiveMeds.adaptive_mode === 'shift_partial'
? 'border-indigo-500'
: 'border-gray-300 dark:border-gray-600'
}`}>
{adaptiveMeds.adaptive_mode === 'shift_partial' && (
<div className="w-2.5 h-2.5 rounded-full bg-indigo-500" />
)}
</div>
</button>
</div>
</div>
</div>
)}
</div>
{/* Presence Tracking */}
<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">Discord presence tracking</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Detect when you wake up via Discord</p>
</div>
<button
onClick={() => updateAdaptiveMeds({ presence_tracking_enabled: !adaptiveMeds.presence_tracking_enabled })}
disabled={!notif.discord_enabled}
className={`w-12 h-7 rounded-full transition-colors ${
adaptiveMeds.presence_tracking_enabled ? 'bg-indigo-500' : 'bg-gray-300 dark:bg-gray-600'
} ${!notif.discord_enabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
adaptiveMeds.presence_tracking_enabled ? 'translate-x-5' : ''
}`} />
</button>
</div>
{!notif.discord_enabled && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
Enable Discord notifications above to use presence tracking
</p>
)}
{notif.discord_enabled && (
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${presence.is_online ? 'bg-green-500' : 'bg-gray-400'}`} />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{presence.is_online ? 'Online' : 'Offline'}
</span>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">
{presence.last_online_at ? `Last seen: ${new Date(presence.last_online_at).toLocaleString()}` : 'Never seen online'}
</span>
</div>
{presence.typical_wake_time && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Typical wake time: <span className="font-medium text-gray-700 dark:text-gray-300">{presence.typical_wake_time}</span>
</p>
)}
</div>
)}
</div>
{/* Nagging Settings */}
<div className="p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">Enable nagging</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Send reminders for missed doses</p>
</div>
<button
onClick={() => updateAdaptiveMeds({ nagging_enabled: !adaptiveMeds.nagging_enabled })}
className={`w-12 h-7 rounded-full transition-colors ${
adaptiveMeds.nagging_enabled ? 'bg-indigo-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
adaptiveMeds.nagging_enabled ? 'translate-x-5' : ''
}`} />
</button>
</div>
{adaptiveMeds.nagging_enabled && (
<>
{/* Nag Interval */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Reminder interval (minutes)
</label>
<input
type="number"
min="5"
max="60"
value={adaptiveMeds.nag_interval_minutes}
onChange={(e) => 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"
/>
</div>
{/* Max Nag Count */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Maximum reminders per dose
</label>
<input
type="number"
min="1"
max="10"
value={adaptiveMeds.max_nag_count}
onChange={(e) => 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"
/>
</div>
</>
)}
</div>
{/* Quiet Hours */}
<div className="p-4 space-y-3">
<p className="font-medium text-gray-900 dark:text-gray-100">Quiet hours</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Don&apos;t send notifications during these hours</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Start</label>
<input
type="time"
value={adaptiveMeds.quiet_hours_start || ''}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">End</label>
<input
type="time"
value={adaptiveMeds.quiet_hours_end || ''}
onChange={(e) => 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"
/>
</div>
</div>
</div>
</div>
</div>
{/* Celebration Style */}
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Celebration Style</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm divide-y divide-gray-100 dark:divide-gray-700">
{[
{ 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 => (
<button
key={option.value}
onClick={() => updatePref('celebration_style', option.value)}
className="w-full flex items-center justify-between p-4 text-left"
>
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">{option.label}</p>
<p className="text-sm text-gray-500 dark:text-gray-400">{option.desc}</p>
</div>
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
prefs.celebration_style === option.value
? 'border-indigo-500'
: 'border-gray-300 dark:border-gray-600'
}`}>
{prefs.celebration_style === option.value && (
<div className="w-2.5 h-2.5 rounded-full bg-indigo-500" />
)}
</div>
</button>
))}
</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, contact_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 (numbers only)' : newContact.contact_type === 'email' ? 'Email address' : 'Phone number'}
value={newContact.contact_value}
onChange={(e) => {
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"
/>
<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>
);
}