- Fix bot auth: merge duplicate on_ready handlers so session restore runs (#13) - Fix push notifications: pass Uint8Array directly as applicationServerKey (#6) - Show specific conflict reason on schedule save instead of generic error (#17) - Add inline checkmark button to complete tasks on routines timeline (#18) - Add visibility-change + 60s polling auto-refresh to routines, meds, tasks (#15) - Add every-N-day routine scheduling: schema, API, scheduler, and UI (#16) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
441 lines
17 KiB
TypeScript
441 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState, useMemo } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import api from '@/lib/api';
|
|
import { PlusIcon, CheckIcon, PillIcon, ClockIcon, TrashIcon, EditIcon } from '@/components/ui/Icons';
|
|
import Link from 'next/link';
|
|
import PushNotificationToggle from '@/components/notifications/PushNotificationToggle';
|
|
|
|
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[];
|
|
skipped_times?: string[];
|
|
is_prn?: boolean;
|
|
is_next_day?: boolean;
|
|
is_previous_day?: boolean;
|
|
}
|
|
|
|
interface AdherenceEntry {
|
|
medication_id: string;
|
|
name: string;
|
|
adherence_percent: number | null;
|
|
is_prn?: boolean;
|
|
}
|
|
|
|
type TimeStatus = 'overdue' | 'due_now' | 'upcoming' | 'taken' | 'skipped';
|
|
|
|
function getTimeStatus(scheduledTime: string, takenTimes: string[], skippedTimes: string[], now: Date): TimeStatus {
|
|
if (takenTimes.includes(scheduledTime)) return 'taken';
|
|
if (skippedTimes.includes(scheduledTime)) return 'skipped';
|
|
|
|
const [h, m] = scheduledTime.split(':').map(Number);
|
|
const scheduled = new Date(now);
|
|
scheduled.setHours(h, m, 0, 0);
|
|
|
|
const diffMs = now.getTime() - scheduled.getTime();
|
|
const diffMin = diffMs / 60000;
|
|
|
|
if (diffMin > 15) return 'overdue';
|
|
if (diffMin >= -15) return 'due_now';
|
|
return 'upcoming';
|
|
}
|
|
|
|
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';
|
|
const timesCount = med.times?.length || 0;
|
|
if (med.frequency === 'twice_daily' || timesCount === 2) return 'Twice daily';
|
|
if (timesCount >= 3) return `${timesCount}x daily`;
|
|
return 'Daily';
|
|
};
|
|
|
|
interface DueEntry {
|
|
item: TodaysMedication;
|
|
time: string;
|
|
status: TimeStatus;
|
|
}
|
|
|
|
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);
|
|
const [tick, setTick] = useState(0);
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchData().finally(() => setIsLoading(false));
|
|
}, []);
|
|
|
|
// Re-fetch when tab becomes visible or every 60s
|
|
useEffect(() => {
|
|
const onVisible = () => {
|
|
if (document.visibilityState === 'visible') fetchData();
|
|
};
|
|
document.addEventListener('visibilitychange', onVisible);
|
|
const poll = setInterval(fetchData, 60_000);
|
|
return () => {
|
|
document.removeEventListener('visibilitychange', onVisible);
|
|
clearInterval(poll);
|
|
};
|
|
}, []);
|
|
|
|
// Auto-refresh grouping every 60s
|
|
useEffect(() => {
|
|
const interval = setInterval(() => setTick(t => t + 1), 60000);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
const { dueEntries, upcomingEntries, prnEntries } = useMemo(() => {
|
|
const now = new Date();
|
|
const due: DueEntry[] = [];
|
|
const upcoming: DueEntry[] = [];
|
|
const prn: TodaysMedication[] = [];
|
|
|
|
for (const item of todayMeds) {
|
|
if (item.is_prn) {
|
|
prn.push(item);
|
|
continue;
|
|
}
|
|
|
|
// Next-day meds are always upcoming
|
|
if (item.is_next_day) {
|
|
for (const time of item.scheduled_times) {
|
|
upcoming.push({ item, time, status: 'upcoming' });
|
|
}
|
|
continue;
|
|
}
|
|
|
|
for (const time of item.scheduled_times) {
|
|
const status = getTimeStatus(time, item.taken_times, item.skipped_times || [], now);
|
|
if (status === 'upcoming') {
|
|
upcoming.push({ item, time, status });
|
|
} else {
|
|
due.push({ item, time, status });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort due: overdue first, then due_now, then by time
|
|
const statusOrder: Record<TimeStatus, number> = { overdue: 0, due_now: 1, taken: 2, skipped: 3, upcoming: 4 };
|
|
due.sort((a, b) => statusOrder[a.status] - statusOrder[b.status] || a.time.localeCompare(b.time));
|
|
upcoming.sort((a, b) => a.time.localeCompare(b.time));
|
|
|
|
return { dueEntries: due, upcomingEntries: upcoming, prnEntries: prn };
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [todayMeds, tick]);
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const handleTake = async (medId: string, time?: string) => {
|
|
try {
|
|
setError(null);
|
|
await api.medications.take(medId, time);
|
|
window.location.reload();
|
|
} catch (err) {
|
|
console.error('Failed to log medication:', err);
|
|
setError(err instanceof Error ? err.message : 'Failed to log medication');
|
|
}
|
|
};
|
|
|
|
const handleSkip = async (medId: string, time?: string) => {
|
|
try {
|
|
setError(null);
|
|
await api.medications.skip(medId, time);
|
|
window.location.reload();
|
|
} catch (err) {
|
|
console.error('Failed to skip medication:', err);
|
|
setError(err instanceof Error ? err.message : 'Failed to skip medication');
|
|
}
|
|
};
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
const borderColor = (status: TimeStatus) => {
|
|
if (status === 'overdue') return 'border-l-4 border-l-red-500';
|
|
if (status === 'due_now') return 'border-l-4 border-l-amber-500';
|
|
if (status === 'taken') return 'border-l-4 border-l-green-500';
|
|
if (status === 'skipped') return 'border-l-4 border-l-gray-400';
|
|
return '';
|
|
};
|
|
|
|
return (
|
|
<div className="p-4 space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Medications</h1>
|
|
<Link href="/dashboard/medications/new" className="bg-indigo-600 text-white p-2 rounded-full">
|
|
<PlusIcon size={24} />
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Push Notification Toggle */}
|
|
<PushNotificationToggle />
|
|
|
|
{error && (
|
|
<div className="bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 px-4 py-3 rounded-lg">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Due Now Section */}
|
|
{dueEntries.length > 0 && (
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Due</h2>
|
|
<div className="space-y-3">
|
|
{dueEntries.map((entry) => (
|
|
<div
|
|
key={`${entry.item.medication.id}-${entry.time}`}
|
|
className={`bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm ${borderColor(entry.status)}`}
|
|
>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{entry.item.medication.name}</h3>
|
|
{entry.item.is_previous_day && (
|
|
<span className="text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 px-2 py-0.5 rounded">Yesterday</span>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">{entry.item.medication.dosage} {entry.item.medication.unit}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
|
|
<div className="flex items-center gap-2">
|
|
<ClockIcon size={16} className="text-gray-500 dark:text-gray-400" />
|
|
<span className="font-medium text-gray-900 dark:text-gray-100">{entry.time}</span>
|
|
{entry.status === 'overdue' && (
|
|
<span className="text-xs text-red-600 dark:text-red-400 font-medium">Overdue</span>
|
|
)}
|
|
</div>
|
|
{entry.status === 'taken' ? (
|
|
<span className="text-green-600 dark:text-green-400 font-medium flex items-center gap-1">
|
|
<CheckIcon size={16} /> Taken
|
|
</span>
|
|
) : entry.status === 'skipped' ? (
|
|
<span className="text-gray-400 dark:text-gray-500 font-medium">Skipped</span>
|
|
) : (
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => handleTake(entry.item.medication.id, entry.time)}
|
|
className="bg-green-600 text-white px-3 py-1 rounded-lg text-sm font-medium"
|
|
>
|
|
Take
|
|
</button>
|
|
<button
|
|
onClick={() => handleSkip(entry.item.medication.id, entry.time)}
|
|
className="text-gray-500 dark:text-gray-400 px-2 py-1"
|
|
>
|
|
Skip
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* PRN Section */}
|
|
{prnEntries.length > 0 && (
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">As Needed</h2>
|
|
<div className="space-y-3">
|
|
{prnEntries.map((item) => (
|
|
<div key={item.medication.id} className="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div>
|
|
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{item.medication.name}</h3>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">{item.medication.dosage} {item.medication.unit}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-between bg-gray-50 dark:bg-gray-700 rounded-lg p-3">
|
|
<span className="text-gray-500 dark:text-gray-400 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>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Upcoming Section */}
|
|
{upcomingEntries.length > 0 && (
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-500 dark:text-gray-400 mb-3">Upcoming</h2>
|
|
<div className="space-y-3">
|
|
{upcomingEntries.map((entry) => (
|
|
<div
|
|
key={`${entry.item.medication.id}-${entry.time}`}
|
|
className="bg-gray-50 dark:bg-gray-800 rounded-xl p-4 shadow-sm opacity-75"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-medium text-gray-700 dark:text-gray-300">{entry.item.medication.name}</h3>
|
|
{entry.item.is_next_day && (
|
|
<span className="text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 px-2 py-0.5 rounded">Tomorrow</span>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-gray-400 dark:text-gray-500">{entry.item.medication.dosage} {entry.item.medication.unit}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-gray-400 dark:text-gray-500">
|
|
<ClockIcon size={16} />
|
|
<span className="font-medium">{entry.time}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* All Medications */}
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">All Medications</h2>
|
|
|
|
{medications.length === 0 ? (
|
|
<div className="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-sm text-center">
|
|
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
<PillIcon className="text-gray-400 dark:text-gray-500" size={32} />
|
|
</div>
|
|
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">No medications yet</h3>
|
|
<p className="text-gray-500 dark:text-gray-400 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 dark:bg-gray-800 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 dark:text-gray-100">{med.name}</h3>
|
|
{!med.active && (
|
|
<span className="text-xs bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 px-2 py-0.5 rounded">Inactive</span>
|
|
)}
|
|
</div>
|
|
<p className="text-gray-500 dark:text-gray-400 text-sm">{med.dosage} {med.unit} · {formatSchedule(med)}</p>
|
|
{med.times.length > 0 && (
|
|
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">Times: {med.times.join(', ')}</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Link
|
|
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"
|
|
>
|
|
<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>
|
|
|
|
{/* Adherence */}
|
|
<div className="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700">
|
|
{isPrn || adherencePercent === null ? (
|
|
<span className="text-sm text-gray-400 dark:text-gray-500">PRN — no adherence tracking</span>
|
|
) : (
|
|
<>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="text-sm text-gray-500 dark:text-gray-400">30-day adherence</span>
|
|
<span className={`font-semibold ${adherencePercent >= 80 ? 'text-green-600 dark:text-green-400' : adherencePercent >= 50 ? 'text-yellow-600 dark:text-yellow-400' : 'text-red-600 dark:text-red-400'}`}>
|
|
{adherencePercent}%
|
|
</span>
|
|
</div>
|
|
<div className="h-2 bg-gray-100 dark:bg-gray-700 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>
|
|
);
|
|
}
|