Add complete UI for adaptive medication settings with presence tracking and nagging configuration

This commit is contained in:
2026-02-16 20:04:58 -06:00
parent 84c6032dc9
commit 69163a37d1
2 changed files with 316 additions and 0 deletions

View File

@@ -21,6 +21,23 @@ interface NotifSettings {
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;
}
export default function SettingsPage() {
const [prefs, setPrefs] = useState<Preferences>({
sound_enabled: false,
@@ -34,8 +51,24 @@ export default function SettingsPage() {
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 [isLoading, setIsLoading] = useState(true);
const [saved, setSaved] = useState(false);
const [showHelp, setShowHelp] = useState(false);
useEffect(() => {
Promise.all([
@@ -46,6 +79,8 @@ export default function SettingsPage() {
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)),
])
.catch(() => {})
.finally(() => setIsLoading(false));
@@ -79,6 +114,18 @@ export default function SettingsPage() {
}
};
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);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
@@ -241,6 +288,223 @@ export default function SettingsPage() {
</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={() => updateAdaptiveMeds({ adaptive_timing_enabled: !adaptiveMeds.adaptive_timing_enabled })}
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>
)}
{adaptiveMeds.presence_tracking_enabled && presence.typical_wake_time && (
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-400">
Typical wake time: <span className="font-medium text-gray-900 dark:text-gray-100">{presence.typical_wake_time}</span>
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Status: {presence.is_online ? '🟢 Online' : '⚫ Offline'}
</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>

View File

@@ -689,6 +689,58 @@ export const api = {
},
},
// Adaptive Medications
adaptiveMeds: {
getSettings: async () => {
return request<{
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;
}>('/api/adaptive-meds/settings', { method: 'GET' });
},
updateSettings: async (data: {
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;
}) => {
return request<{ success: boolean }>('/api/adaptive-meds/settings', {
method: 'PUT',
body: JSON.stringify(data),
});
},
getPresence: async () => {
return request<{
is_online: boolean;
last_online_at: string | null;
typical_wake_time: string | null;
}>('/api/adaptive-meds/presence', { method: 'GET' });
},
getSchedule: async () => {
return request<Array<{
medication_id: string;
medication_name: string;
base_time: string;
adjusted_time: string;
adjustment_minutes: number;
status: string;
nag_count: number;
}>>('/api/adaptive-meds/schedule', { method: 'GET' });
},
},
// Medications
medications: {
list: async () => {