Add complete UI for adaptive medication settings with presence tracking and nagging configuration
This commit is contained in:
@@ -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'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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user