956 lines
43 KiB
TypeScript
956 lines
43 KiB
TypeScript
'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'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 "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, 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>
|
||
);
|
||
}
|