Fix medication system and rename to Synculous.

- Add all 14 missing database tables (medications, med_logs, routines, etc.)
- Rewrite medication scheduling: support specific days, every N days, as-needed (PRN)
- Fix taken_times matching: match by created_at date, not scheduled_time string
- Fix adherence calculation: taken / expected doses, not taken / (taken + skipped)
- Add formatSchedule() helper for readable display
- Update client types and API layer
- Rename brilli-ins-client → synculous-client
- Make client PWA: add manifest, service worker, icons
- Bind dev server to 0.0.0.0 for network access
- Fix SVG icon bugs in Icons.tsx
- Add .dockerignore for client npm caching

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 03:23:38 -06:00
parent 3e1134575b
commit 97a166f5aa
47 changed files with 5231 additions and 61 deletions

View File

@@ -0,0 +1,253 @@
'use client';
import { useState } from 'react';
import { useRouter } 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 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<string[]>(['08:00']);
const [daysOfWeek, setDaysOfWeek] = useState<string[]>([]);
const [intervalDays, setIntervalDays] = useState(7);
const [startDate, setStartDate] = useState(new Date().toISOString().slice(0, 10));
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const handleAddTime = () => {
setTimes([...times, '12:00']);
};
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 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;
}
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 }),
});
router.push('/dashboard/medications');
} catch (err) {
setError((err as Error).message || 'Failed to add medication');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="flex items-center gap-3 px-4 py-3">
<button onClick={() => router.back()} className="p-1">
<ArrowLeftIcon size={24} />
</button>
<h1 className="text-xl font-bold text-gray-900">Add Medication</h1>
</div>
</header>
<form onSubmit={handleSubmit} className="p-4 space-y-6">
{error && (
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div className="bg-white rounded-xl p-4 shadow-sm space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Medication Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Vitamin D"
className="w-full px-4 py-3 border border-gray-300 rounded-lg 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 mb-1">Dosage</label>
<input
type="text"
value={dosage}
onChange={(e) => setDosage(e.target.value)}
placeholder="e.g., 1000"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Unit</label>
<select
value={unit}
onChange={(e) => setUnit(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg 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 mb-1">Frequency</label>
<select
value={frequency}
onChange={(e) => setFrequency(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
>
<option value="daily">Daily</option>
<option value="twice_daily">Twice 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>
{/* Day-of-week picker for specific_days */}
{frequency === 'specific_days' && (
<div>
<label className="block text-sm font-medium text-gray-700 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 text-gray-700 border-gray-300'
}`}
>
{label}
</button>
))}
</div>
</div>
)}
{/* Interval settings for every_n_days */}
{frequency === 'every_n_days' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 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 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 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 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
</div>
)}
{/* Times picker — hidden for as_needed */}
{frequency !== 'as_needed' && (
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-700">Times</label>
<button
type="button"
onClick={handleAddTime}
className="text-indigo-600 text-sm font-medium"
>
+ Add Time
</button>
</div>
<div className="space-y-2">
{times.map((time, index) => (
<div key={index} className="flex gap-2">
<input
type="time"
value={time}
onChange={(e) => handleTimeChange(index, e.target.value)}
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
{times.length > 1 && (
<button
type="button"
onClick={() => handleRemoveTime(index)}
className="text-red-500 px-3"
>
Remove
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
<button
type="submit"
disabled={isLoading}
className="w-full bg-indigo-600 text-white font-semibold py-4 rounded-xl hover:bg-indigo-700 disabled:opacity-50"
>
{isLoading ? 'Adding...' : 'Add Medication'}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,268 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import api from '@/lib/api';
import { PlusIcon, CheckIcon, PillIcon, ClockIcon, TrashIcon } from '@/components/ui/Icons';
import Link from 'next/link';
interface Medication {
id: string;
name: string;
dosage: string;
unit: string;
frequency: string;
times: string[];
days_of_week?: string[];
interval_days?: number;
start_date?: string;
next_dose_date?: string;
notes?: string;
active: boolean;
quantity_remaining?: number;
}
interface TodaysMedication {
medication: {
id: string;
name: string;
dosage: string;
unit: string;
};
scheduled_times: string[];
taken_times: string[];
is_prn?: boolean;
}
interface AdherenceEntry {
medication_id: string;
name: string;
adherence_percent: number | null;
is_prn?: boolean;
}
const formatSchedule = (med: Medication): string => {
if (med.frequency === 'specific_days' && med.days_of_week?.length) {
return med.days_of_week.map(d => d.charAt(0).toUpperCase() + d.slice(1)).join(', ');
}
if (med.frequency === 'every_n_days' && med.interval_days) {
return `Every ${med.interval_days} days`;
}
if (med.frequency === 'as_needed') return 'As needed';
if (med.frequency === 'twice_daily') return 'Twice daily';
return 'Daily';
};
export default function MedicationsPage() {
const router = useRouter();
const [medications, setMedications] = useState<Medication[]>([]);
const [todayMeds, setTodayMeds] = useState<TodaysMedication[]>([]);
const [adherence, setAdherence] = useState<AdherenceEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [medsData, todayData, adherenceData] = await Promise.all([
api.medications.list(),
api.medications.getToday().catch(() => []),
api.medications.getAdherence(30).catch(() => []),
]);
setMedications(medsData);
setTodayMeds(todayData);
setAdherence(adherenceData);
} catch (err) {
console.error('Failed to fetch medications:', err);
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
const handleTake = async (medId: string, time?: string) => {
try {
await api.medications.take(medId, time);
window.location.reload();
} catch (err) {
console.error('Failed to log medication:', err);
}
};
const handleSkip = async (medId: string, time?: string) => {
try {
await api.medications.skip(medId, time);
window.location.reload();
} catch (err) {
console.error('Failed to skip medication:', err);
}
};
const handleDelete = async (medId: string) => {
try {
await api.medications.delete(medId);
setMedications(medications.filter(m => m.id !== medId));
} catch (err) {
console.error('Failed to delete medication:', err);
}
};
const getAdherenceForMed = (medId: string) => {
const entry = adherence.find(a => a.medication_id === medId);
if (!entry) return { percent: 0, isPrn: false };
return { percent: entry.adherence_percent, isPrn: entry.is_prn || false };
};
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">Medications</h1>
<Link href="/dashboard/medications/new" className="bg-indigo-600 text-white p-2 rounded-full">
<PlusIcon size={24} />
</Link>
</div>
{/* Today's Schedule */}
{todayMeds.length > 0 && (
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">Today</h2>
<div className="space-y-3">
{todayMeds.map((item) => (
<div key={item.medication.id} className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="font-semibold text-gray-900">{item.medication.name}</h3>
<p className="text-sm text-gray-500">{item.medication.dosage} {item.medication.unit}</p>
</div>
</div>
<div className="space-y-2">
{item.is_prn ? (
<div className="flex items-center justify-between bg-gray-50 rounded-lg p-3">
<span className="text-gray-500 text-sm">As needed</span>
<button
onClick={() => handleTake(item.medication.id)}
className="bg-green-600 text-white px-3 py-1 rounded-lg text-sm font-medium"
>
Log Dose
</button>
</div>
) : (
item.scheduled_times.map((time) => {
const isTaken = item.taken_times.includes(time);
return (
<div key={time} className="flex items-center justify-between bg-gray-50 rounded-lg p-3">
<div className="flex items-center gap-2">
<ClockIcon size={16} className="text-gray-500" />
<span className="font-medium">{time}</span>
</div>
{isTaken ? (
<span className="text-green-600 font-medium flex items-center gap-1">
<CheckIcon size={16} /> Taken
</span>
) : (
<div className="flex gap-2">
<button
onClick={() => handleTake(item.medication.id, time)}
className="bg-green-600 text-white px-3 py-1 rounded-lg text-sm font-medium"
>
Take
</button>
<button
onClick={() => handleSkip(item.medication.id, time)}
className="text-gray-500 px-2 py-1"
>
Skip
</button>
</div>
)}
</div>
);
})
)}
</div>
</div>
))}
</div>
</div>
)}
{/* All Medications */}
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-3">All Medications</h2>
{medications.length === 0 ? (
<div className="bg-white rounded-xl p-8 shadow-sm text-center">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<PillIcon className="text-gray-400" size={32} />
</div>
<h3 className="font-semibold text-gray-900 mb-1">No medications yet</h3>
<p className="text-gray-500 text-sm mb-4">Add your medications to track them</p>
<Link href="/dashboard/medications/new" className="inline-block bg-indigo-600 text-white px-4 py-2 rounded-lg font-medium">
Add Medication
</Link>
</div>
) : (
<div className="space-y-3">
{medications.map((med) => {
const { percent: adherencePercent, isPrn } = getAdherenceForMed(med.id);
return (
<div key={med.id} className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900">{med.name}</h3>
{!med.active && (
<span className="text-xs bg-gray-100 text-gray-500 px-2 py-0.5 rounded">Inactive</span>
)}
</div>
<p className="text-gray-500 text-sm">{med.dosage} {med.unit} &middot; {formatSchedule(med)}</p>
{med.times.length > 0 && (
<p className="text-gray-400 text-sm mt-1">Times: {med.times.join(', ')}</p>
)}
</div>
<button
onClick={() => handleDelete(med.id)}
className="text-red-500 p-2"
>
<TrashIcon size={18} />
</button>
</div>
{/* Adherence */}
<div className="mt-3 pt-3 border-t border-gray-100">
{isPrn || adherencePercent === null ? (
<span className="text-sm text-gray-400">PRN &mdash; no adherence tracking</span>
) : (
<>
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-500">30-day adherence</span>
<span className={`font-semibold ${adherencePercent >= 80 ? 'text-green-600' : adherencePercent >= 50 ? 'text-yellow-600' : 'text-red-600'}`}>
{adherencePercent}%
</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full ${adherencePercent >= 80 ? 'bg-green-500' : adherencePercent >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${adherencePercent}%` }}
/>
</div>
</>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}