Fix off-day med reminders and add medication editing
Scheduler: check_nagging() now calls _is_med_due_today() before creating on-demand schedules or processing existing ones — prevents nagging for specific_days / every_n_days meds on days they are not scheduled. Web client: add Edit button (pencil icon) on each medication card linking to /dashboard/medications/[id]/edit — new page pre-populates the full form (name, dosage, unit, frequency, times, days, interval, notes) and submits PUT /api/medications/:id on save. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -317,6 +317,10 @@ def check_nagging():
|
|||||||
now = _user_now_for(user_uuid)
|
now = _user_now_for(user_uuid)
|
||||||
today = now.date()
|
today = now.date()
|
||||||
|
|
||||||
|
# Skip nagging if medication is not due today
|
||||||
|
if not _is_med_due_today(med, today):
|
||||||
|
continue
|
||||||
|
|
||||||
# Get today's schedules
|
# Get today's schedules
|
||||||
try:
|
try:
|
||||||
schedules = postgres.select(
|
schedules = postgres.select(
|
||||||
@@ -333,8 +337,10 @@ def check_nagging():
|
|||||||
# Table may not exist yet
|
# Table may not exist yet
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# If no schedules exist, try to create them
|
# If no schedules exist, try to create them — but only if med is due today
|
||||||
if not schedules:
|
if not schedules:
|
||||||
|
if not _is_med_due_today(med, today):
|
||||||
|
continue
|
||||||
logger.info(f"No schedules found for medication {med_id}, attempting to create")
|
logger.info(f"No schedules found for medication {med_id}, attempting to create")
|
||||||
times = med.get("times", [])
|
times = med.get("times", [])
|
||||||
if times:
|
if times:
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import { ArrowLeftIcon } from '@/components/ui/Icons';
|
||||||
|
|
||||||
|
const DAY_OPTIONS = [
|
||||||
|
{ value: 'mon', label: 'Mon' },
|
||||||
|
{ value: 'tue', label: 'Tue' },
|
||||||
|
{ value: 'wed', label: 'Wed' },
|
||||||
|
{ value: 'thu', label: 'Thu' },
|
||||||
|
{ value: 'fri', label: 'Fri' },
|
||||||
|
{ value: 'sat', label: 'Sat' },
|
||||||
|
{ value: 'sun', label: 'Sun' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function EditMedicationPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const medId = params.id as string;
|
||||||
|
|
||||||
|
const [isLoadingMed, setIsLoadingMed] = useState(true);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [dosage, setDosage] = useState('');
|
||||||
|
const [unit, setUnit] = useState('mg');
|
||||||
|
const [frequency, setFrequency] = useState('daily');
|
||||||
|
const [times, setTimes] = useState<string[]>(['08:00']);
|
||||||
|
const [daysOfWeek, setDaysOfWeek] = useState<string[]>([]);
|
||||||
|
const [intervalDays, setIntervalDays] = useState(7);
|
||||||
|
const [startDate, setStartDate] = useState(new Date().toISOString().slice(0, 10));
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.medications.get(medId)
|
||||||
|
.then(med => {
|
||||||
|
setName(med.name);
|
||||||
|
setDosage(String(med.dosage));
|
||||||
|
setUnit(med.unit);
|
||||||
|
setFrequency((med as any).frequency || 'daily');
|
||||||
|
setTimes((med as any).times?.length ? (med as any).times : ['08:00']);
|
||||||
|
setDaysOfWeek((med as any).days_of_week || []);
|
||||||
|
setIntervalDays((med as any).interval_days || 7);
|
||||||
|
setStartDate((med as any).start_date?.slice(0, 10) || new Date().toISOString().slice(0, 10));
|
||||||
|
setNotes(med.notes || '');
|
||||||
|
})
|
||||||
|
.catch(() => setError('Failed to load medication.'))
|
||||||
|
.finally(() => setIsLoadingMed(false));
|
||||||
|
}, [medId]);
|
||||||
|
|
||||||
|
const handleAddTime = () => setTimes([...times, '12:00']);
|
||||||
|
const handleRemoveTime = (i: number) => setTimes(times.filter((_, idx) => idx !== i));
|
||||||
|
const handleTimeChange = (i: number, val: string) => {
|
||||||
|
const t = [...times]; t[i] = val; setTimes(t);
|
||||||
|
};
|
||||||
|
const toggleDay = (day: string) =>
|
||||||
|
setDaysOfWeek(prev => prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim() || !dosage.trim()) { setError('Name and dosage are required.'); return; }
|
||||||
|
if (frequency === 'specific_days' && daysOfWeek.length === 0) { setError('Select at least one day.'); return; }
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await api.medications.update(medId, {
|
||||||
|
name: name.trim(),
|
||||||
|
dosage: dosage.trim(),
|
||||||
|
unit,
|
||||||
|
frequency,
|
||||||
|
times: frequency === 'as_needed' ? [] : times,
|
||||||
|
...(frequency === 'specific_days' && { days_of_week: daysOfWeek }),
|
||||||
|
...(frequency === 'every_n_days' && { interval_days: intervalDays, start_date: startDate }),
|
||||||
|
...(notes.trim() && { notes: notes.trim() }),
|
||||||
|
});
|
||||||
|
router.push('/dashboard/medications');
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message || 'Failed to save changes.');
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingMed) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 sticky top-0 z-10">
|
||||||
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
|
<button onClick={() => router.back()} className="p-1 text-gray-600 dark:text-gray-400">
|
||||||
|
<ArrowLeftIcon size={24} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 dark:text-gray-100">Edit Medication</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-4 space-y-6">
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 px-4 py-3 rounded-lg text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Medication Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Dosage</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={dosage}
|
||||||
|
onChange={e => setDosage(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Unit</label>
|
||||||
|
<select
|
||||||
|
value={unit}
|
||||||
|
onChange={e => setUnit(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||||
|
>
|
||||||
|
<option value="mg">mg</option>
|
||||||
|
<option value="mcg">mcg</option>
|
||||||
|
<option value="g">g</option>
|
||||||
|
<option value="ml">ml</option>
|
||||||
|
<option value="IU">IU</option>
|
||||||
|
<option value="tablets">tablets</option>
|
||||||
|
<option value="capsules">capsules</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Frequency</label>
|
||||||
|
<select
|
||||||
|
value={frequency}
|
||||||
|
onChange={e => setFrequency(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||||
|
>
|
||||||
|
<option value="daily">Daily</option>
|
||||||
|
<option value="specific_days">Specific Days of Week</option>
|
||||||
|
<option value="every_n_days">Every N Days</option>
|
||||||
|
<option value="as_needed">As Needed (PRN)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{frequency === 'specific_days' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Days</label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{DAY_OPTIONS.map(({ value, label }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleDay(value)}
|
||||||
|
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
|
daysOfWeek.includes(value)
|
||||||
|
? 'bg-indigo-600 text-white border-indigo-600'
|
||||||
|
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{frequency === 'every_n_days' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Every N Days</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={intervalDays}
|
||||||
|
onChange={e => setIntervalDays(parseInt(e.target.value) || 1)}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Starting From</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={e => setStartDate(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{frequency !== 'as_needed' && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Times</label>
|
||||||
|
<button type="button" onClick={handleAddTime} className="text-indigo-600 dark:text-indigo-400 text-sm font-medium">
|
||||||
|
+ Add Time
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{times.map((time, i) => (
|
||||||
|
<div key={i} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={time}
|
||||||
|
onChange={e => handleTimeChange(i, e.target.value)}
|
||||||
|
className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none"
|
||||||
|
/>
|
||||||
|
{times.length > 1 && (
|
||||||
|
<button type="button" onClick={() => handleRemoveTime(i)} className="text-red-500 dark:text-red-400 px-3">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Notes <span className="text-gray-400 font-normal">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={e => setNotes(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full bg-indigo-600 text-white font-semibold py-4 rounded-xl hover:bg-indigo-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Saving…' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { PlusIcon, CheckIcon, PillIcon, ClockIcon, TrashIcon } from '@/components/ui/Icons';
|
import { PlusIcon, CheckIcon, PillIcon, ClockIcon, TrashIcon, EditIcon } from '@/components/ui/Icons';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import PushNotificationToggle from '@/components/notifications/PushNotificationToggle';
|
import PushNotificationToggle from '@/components/notifications/PushNotificationToggle';
|
||||||
|
|
||||||
@@ -380,12 +380,20 @@ export default function MedicationsPage() {
|
|||||||
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">Times: {med.times.join(', ')}</p>
|
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">Times: {med.times.join(', ')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-1">
|
||||||
onClick={() => handleDelete(med.id)}
|
<Link
|
||||||
className="text-red-500 dark:text-red-400 p-2"
|
href={`/dashboard/medications/${med.id}/edit`}
|
||||||
>
|
className="text-gray-400 dark:text-gray-500 hover:text-indigo-600 dark:hover:text-indigo-400 p-2"
|
||||||
<TrashIcon size={18} />
|
>
|
||||||
</button>
|
<EditIcon size={18} />
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(med.id)}
|
||||||
|
className="text-red-500 dark:text-red-400 p-2"
|
||||||
|
>
|
||||||
|
<TrashIcon size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Adherence */}
|
{/* Adherence */}
|
||||||
|
|||||||
Reference in New Issue
Block a user