diff --git a/api/routes/medications.py b/api/routes/medications.py index 94689f2..a12c33f 100644 --- a/api/routes/medications.py +++ b/api/routes/medications.py @@ -145,56 +145,6 @@ def register(app): meds = postgres.select("medications", where={"user_uuid": user_uuid}, order_by="name") return flask.jsonify(meds), 200 - def _time_str_to_minutes(time_str): - """Convert 'HH:MM' to minutes since midnight.""" - parts = time_str.split(":") - return int(parts[0]) * 60 + int(parts[1]) - - def _get_routine_duration_minutes(routine_id): - """Get total duration of a routine from its steps.""" - steps = postgres.select("routine_steps", where={"routine_id": routine_id}) - total = sum(s.get("duration_minutes", 0) or 0 for s in steps) - return max(total, 1) - - def _check_med_schedule_conflicts(user_uuid, new_times, new_days=None, exclude_med_id=None): - """Check if the proposed medication schedule conflicts with existing routines or medications. - Returns (has_conflict, conflict_message) tuple. - """ - if not new_times: - return False, None - - # Check conflicts with routines (duration-aware) - user_routines = postgres.select("routines", {"user_uuid": user_uuid}) - for r in user_routines: - sched = postgres.select_one("routine_schedules", {"routine_id": r["id"]}) - if not sched or not sched.get("time"): - continue - routine_days = sched.get("days", []) - if isinstance(routine_days, str): - routine_days = json.loads(routine_days) - if new_days and not any(d in routine_days for d in new_days): - continue - routine_start = _time_str_to_minutes(sched["time"]) - routine_dur = _get_routine_duration_minutes(r["id"]) - for t in new_times: - med_start = _time_str_to_minutes(t) - # Med falls within routine time range - if routine_start <= med_start < routine_start + routine_dur: - return True, f"Time conflicts with routine: {r.get('name', 'Unnamed routine')}" - - # Check conflicts with other medications - user_meds = postgres.select("medications", {"user_uuid": user_uuid, "active": True}) - for med in user_meds: - if med["id"] == exclude_med_id: - continue - med_times = med.get("times", []) - if isinstance(med_times, str): - med_times = json.loads(med_times) - if any(t in med_times for t in new_times): - return True, f"Time conflicts with medication: {med.get('name', 'Unnamed medication')}" - - return False, None - @app.route("/api/medications", methods=["POST"]) def api_addMedication(): """Add a medication. Body: {name, dosage, unit, frequency, times?, days_of_week?, interval_days?, start_date?, notes?}""" @@ -214,15 +164,6 @@ def register(app): if not data.get("start_date") or not data.get("interval_days"): return flask.jsonify({"error": "every_n_days frequency requires both start_date and interval_days"}), 400 - # Check for schedule conflicts - new_times = data.get("times", []) - new_days = data.get("days_of_week", []) - has_conflict, conflict_msg = _check_med_schedule_conflicts( - user_uuid, new_times, new_days - ) - if has_conflict: - return flask.jsonify({"error": conflict_msg}), 409 - row = { "id": str(uuid.uuid4()), "user_uuid": user_uuid, @@ -283,16 +224,6 @@ def register(app): "days_of_week", "interval_days", "start_date", "next_dose_date", ] - # Check for schedule conflicts if times are being updated - if "times" in data: - new_times = data.get("times", []) - new_days = data.get("days_of_week") or existing.get("days_of_week", []) - has_conflict, conflict_msg = _check_med_schedule_conflicts( - user_uuid, new_times, new_days, exclude_med_id=med_id - ) - if has_conflict: - return flask.jsonify({"error": conflict_msg}), 409 - updates = {k: v for k, v in data.items() if k in allowed} if not updates: return flask.jsonify({"error": "no valid fields to update"}), 400 diff --git a/synculous-client/src/app/dashboard/medications/new/page.tsx b/synculous-client/src/app/dashboard/medications/new/page.tsx index 31cacfd..7ad0a99 100644 --- a/synculous-client/src/app/dashboard/medications/new/page.tsx +++ b/synculous-client/src/app/dashboard/medications/new/page.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import api from '@/lib/api'; -import { ArrowLeftIcon } from '@/components/ui/Icons'; +import { ArrowLeftIcon, PlusIcon, TrashIcon } from '@/components/ui/Icons'; const DAY_OPTIONS = [ { value: 'mon', label: 'Mon' }, @@ -15,63 +15,258 @@ const DAY_OPTIONS = [ { value: 'sun', label: 'Sun' }, ]; +interface MedEntry { + id: string; + name: string; + dosage: string; + unit: string; + frequency: string; + times: string[]; + daysOfWeek: string[]; + intervalDays: number; + startDate: string; +} + +function blankEntry(): MedEntry { + return { + id: `med-${Date.now()}-${Math.random()}`, + name: '', + dosage: '', + unit: 'mg', + frequency: 'daily', + times: ['08:00'], + daysOfWeek: [], + intervalDays: 7, + startDate: new Date().toISOString().slice(0, 10), + }; +} + +function MedCard({ + entry, + index, + total, + onChange, + onRemove, +}: { + entry: MedEntry; + index: number; + total: number; + onChange: (updates: Partial) => void; + onRemove: () => void; +}) { + const handleAddTime = () => onChange({ times: [...entry.times, '12:00'] }); + const handleRemoveTime = (i: number) => onChange({ times: entry.times.filter((_, idx) => idx !== i) }); + const handleTimeChange = (i: number, val: string) => { + const t = [...entry.times]; + t[i] = val; + onChange({ times: t }); + }; + const toggleDay = (day: string) => + onChange({ + daysOfWeek: entry.daysOfWeek.includes(day) + ? entry.daysOfWeek.filter(d => d !== day) + : [...entry.daysOfWeek, day], + }); + + return ( +
+
+ + Medication {index + 1} + + {total > 1 && ( + + )} +
+ +
+ + onChange({ name: e.target.value })} + placeholder="e.g., Vitamin D" + 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 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none" + /> +
+ +
+
+ + onChange({ dosage: e.target.value })} + placeholder="e.g., 1000" + 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 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none" + /> +
+
+ + +
+
+ +
+ + +
+ + {entry.frequency === 'specific_days' && ( +
+ +
+ {DAY_OPTIONS.map(({ value, label }) => ( + + ))} +
+
+ )} + + {entry.frequency === 'every_n_days' && ( +
+
+ + onChange({ intervalDays: 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" + /> +
+
+ + onChange({ startDate: 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" + /> +
+
+ )} + + {entry.frequency !== 'as_needed' && ( +
+
+ + +
+
+ {entry.times.map((time, i) => ( +
+ 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" + /> + {entry.times.length > 1 && ( + + )} +
+ ))} +
+
+ )} +
+ ); +} + export default function NewMedicationPage() { const router = useRouter(); - const [name, setName] = useState(''); - const [dosage, setDosage] = useState(''); - const [unit, setUnit] = useState('mg'); - const [frequency, setFrequency] = useState('daily'); - const [times, setTimes] = useState(['08:00']); - const [daysOfWeek, setDaysOfWeek] = useState([]); - const [intervalDays, setIntervalDays] = useState(7); - const [startDate, setStartDate] = useState(new Date().toISOString().slice(0, 10)); + const [entries, setEntries] = useState([blankEntry()]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); - const handleAddTime = () => { - setTimes([...times, '12:00']); + const updateEntry = (index: number, updates: Partial) => { + setEntries(prev => prev.map((e, i) => (i === index ? { ...e, ...updates } : e))); }; - const handleRemoveTime = (index: number) => { - setTimes(times.filter((_, i) => i !== index)); - }; - - const handleTimeChange = (index: number, value: string) => { - const newTimes = [...times]; - newTimes[index] = value; - setTimes(newTimes); - }; - - const toggleDay = (day: string) => { - setDaysOfWeek(prev => - prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day] - ); + const removeEntry = (index: number) => { + setEntries(prev => prev.filter((_, i) => i !== index)); }; 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 of the week'); - return; + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + if (!entry.name.trim() || !entry.dosage.trim()) { + setError(`Medication ${i + 1}: name and dosage are required`); + return; + } + if (entry.frequency === 'specific_days' && entry.daysOfWeek.length === 0) { + setError(`Medication ${i + 1}: select at least one day of the week`); + return; + } } setIsLoading(true); setError(''); try { - await api.medications.create({ - name, - dosage, - unit, - frequency, - times: frequency === 'as_needed' ? [] : times, - ...(frequency === 'specific_days' && { days_of_week: daysOfWeek }), - ...(frequency === 'every_n_days' && { interval_days: intervalDays, start_date: startDate }), - }); + for (const entry of entries) { + await api.medications.create({ + name: entry.name, + dosage: entry.dosage, + unit: entry.unit, + frequency: entry.frequency, + times: entry.frequency === 'as_needed' ? [] : entry.times, + ...(entry.frequency === 'specific_days' && { days_of_week: entry.daysOfWeek }), + ...(entry.frequency === 'every_n_days' && { + interval_days: entry.intervalDays, + start_date: entry.startDate, + }), + }); + } router.push('/dashboard/medications'); } catch (err) { setError((err as Error).message || 'Failed to add medication'); @@ -80,6 +275,8 @@ export default function NewMedicationPage() { } }; + const count = entries.length; + return (
@@ -87,167 +284,47 @@ export default function NewMedicationPage() { -

Add Medication

+

Add Medications

-
+ {error && (
{error}
)} -
-
- - setName(e.target.value)} - placeholder="e.g., Vitamin D" - 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 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none" - /> -
+ {entries.map((entry, index) => ( + updateEntry(index, updates)} + onRemove={() => removeEntry(index)} + /> + ))} -
-
- - setDosage(e.target.value)} - placeholder="e.g., 1000" - 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 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none" - /> -
-
- - -
-
- -
- - -
- - {/* Day-of-week picker for specific_days */} - {frequency === 'specific_days' && ( -
- -
- {DAY_OPTIONS.map(({ value, label }) => ( - - ))} -
-
- )} - - {/* Interval settings for every_n_days */} - {frequency === 'every_n_days' && ( -
-
- - 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 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 outline-none" - /> -
-
- - 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" - /> -
-
- )} - - {/* Times picker — hidden for as_needed */} - {frequency !== 'as_needed' && ( -
-
- - -
- {frequency === 'daily' && ( -

Add multiple times for 2x, 3x, or more doses per day

- )} -
- {times.map((time, index) => ( -
- handleTimeChange(index, 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 && ( - - )} -
- ))} -
-
- )} -
+