'use client'; import { useEffect, useState, useRef, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import api from '@/lib/api'; import { PlusIcon, PlayIcon, ClockIcon, CheckIcon } from '@/components/ui/Icons'; import Link from 'next/link'; interface Routine { id: string; name: string; description?: string; icon?: string; } interface ScheduleEntry { routine_id: string; routine_name: string; routine_icon: string; days: string[]; time: string; remind: boolean; total_duration_minutes: 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 MedicationTimelineEntry { routine_id: string; routine_name: string; routine_icon: string; days: string[]; time: string; total_duration_minutes: number; medication_id: string; scheduled_time: string; dosage: string; unit: string; status: 'taken' | 'pending' | 'overdue' | 'skipped'; } interface GroupedMedEntry { time: string; medications: MedicationTimelineEntry[]; allTaken: boolean; allSkipped: boolean; anyOverdue: boolean; } // ── Constants ──────────────────────────────────────────────────── const HOUR_HEIGHT = 80; const DEFAULT_START_HOUR = 7; const DEFAULT_END_HOUR = 22; const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; const DAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; const MEDICATION_DURATION_MINUTES = 5; // ── Utility functions ───────────────────────────────────────────── function getDayKey(date: Date): string { const day = date.getDay(); return DAY_KEYS[day === 0 ? 6 : day - 1]; } function getMedicationStatus( scheduledTime: string, takenTimes: string[], skippedTimes: string[], now: Date ): 'taken' | 'pending' | 'overdue' | 'skipped' { 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(); if (diffMs / 60000 > 15) return 'overdue'; return 'pending'; } function getWeekDays(anchor: Date): Date[] { const d = new Date(anchor); const day = d.getDay(); const diff = day === 0 ? 6 : day - 1; d.setDate(d.getDate() - diff); return Array.from({ length: 7 }, (_, i) => { const dd = new Date(d); dd.setDate(d.getDate() + i); return dd; }); } function isSameDay(a: Date, b: Date): boolean { return ( a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() ); } function timeToMinutes(t: string): number { const [h, m] = t.split(':').map(Number); return h * 60 + m; } function minutesToTop(minutes: number, startHour: number): number { return ((minutes - startHour * 60) / 60) * HOUR_HEIGHT; } function durationToHeight(minutes: number): number { return Math.max(48, (minutes / 60) * HOUR_HEIGHT); } function formatTime(t: string): string { const [h, m] = t.split(':').map(Number); const period = h >= 12 ? 'PM' : 'AM'; const dh = h > 12 ? h - 12 : h === 0 ? 12 : h; return `${dh}:${String(m).padStart(2, '0')} ${period}`; } function addMinutesToTime(t: string, mins: number): string { const [h, m] = t.split(':').map(Number); const total = h * 60 + m + mins; const nh = Math.floor(total / 60) % 24; const nm = total % 60; return `${String(nh).padStart(2, '0')}:${String(nm).padStart(2, '0')}`; } function formatMedsList(meds: { routine_name: string }[]): string { const MAX_CHARS = 25; if (meds.length === 1) return meds[0].routine_name; let result = ''; for (const med of meds) { const next = result ? result + ', ' + med.routine_name : med.routine_name; if (next.length > MAX_CHARS) { const remaining = meds.length - (result ? result.split(', ').length : 0) - 1; return result + ` +${remaining} more`; } result = next; } return result; } // ── Lane layout ─────────────────────────────────────────────────── interface LayoutItem { id: string; startMin: number; endMin: number; } function computeLanes( items: LayoutItem[] ): Map { if (items.length === 0) return new Map(); const sorted = [...items].sort((a, b) => a.startMin - b.startMin); const laneEnds: number[] = []; const assigned = new Map(); for (const item of sorted) { let lane = laneEnds.findIndex((end) => end <= item.startMin); if (lane === -1) lane = laneEnds.length; assigned.set(item.id, lane); laneEnds[lane] = item.endMin; } const result = new Map(); for (const item of sorted) { const myLane = assigned.get(item.id)!; const overlapping = sorted.filter( (o) => o.startMin < item.endMin && o.endMin > item.startMin ); const maxLane = Math.max(...overlapping.map((o) => assigned.get(o.id)!)); result.set(item.id, { lane: myLane, totalLanes: maxLane + 1 }); } return result; } function laneStyle( lane: number, totalLanes: number ): React.CSSProperties { if (totalLanes <= 1) return { left: '60px', right: '8px' }; const leftFrac = lane / totalLanes; const rightFrac = (totalLanes - lane - 1) / totalLanes; return { left: `calc(60px + ${leftFrac} * (100% - 68px))`, right: `calc(8px + ${rightFrac} * (100% - 68px))`, }; } // ── Component ───────────────────────────────────────────────────── export default function RoutinesPage() { const router = useRouter(); const timelineRef = useRef(null); const [allRoutines, setAllRoutines] = useState([]); const [allSchedules, setAllSchedules] = useState([]); const [todayMeds, setTodayMeds] = useState([]); const [isLoading, setIsLoading] = useState(true); const [selectedDate, setSelectedDate] = useState(() => new Date()); const [nowMinutes, setNowMinutes] = useState(() => { const n = new Date(); return n.getHours() * 60 + n.getMinutes(); }); const [tick, setTick] = useState(0); const [undoAction, setUndoAction] = useState<{ medicationId: string; scheduledTime: string; action: 'taken' | 'skipped'; timestamp: number; } | null>(null); const [error, setError] = useState(null); const today = new Date(); const weekDays = getWeekDays(selectedDate); const isToday = isSameDay(selectedDate, today); const dayKey = getDayKey(selectedDate); const scheduledForDay = allSchedules .filter((s) => s.days.includes(dayKey)) .sort((a, b) => timeToMinutes(a.time) - timeToMinutes(b.time)); const scheduledRoutineIds = new Set(allSchedules.map((s) => s.routine_id)); const unscheduledRoutines = allRoutines.filter( (r) => !scheduledRoutineIds.has(r.id) ); const medEntries = useMemo(() => { const now = new Date(); const entries: MedicationTimelineEntry[] = []; for (const med of todayMeds) { if (med.is_prn) continue; if (med.is_next_day || med.is_previous_day) continue; for (const time of med.scheduled_times) { entries.push({ routine_id: `med-${med.medication.id}-${time}`, routine_name: med.medication.name, routine_icon: '💊', days: [dayKey], time, total_duration_minutes: MEDICATION_DURATION_MINUTES, medication_id: med.medication.id, scheduled_time: time, dosage: med.medication.dosage, unit: med.medication.unit, status: getMedicationStatus( time, med.taken_times, med.skipped_times || [], now ), }); } } return entries.sort((a, b) => timeToMinutes(a.time) - timeToMinutes(b.time)); }, [todayMeds, dayKey, tick]); const groupedMedEntries = useMemo(() => { const groups: Map = new Map(); for (const entry of medEntries) { if (!groups.has(entry.time)) { groups.set(entry.time, { time: entry.time, medications: [], allTaken: true, allSkipped: true, anyOverdue: false, }); } const group = groups.get(entry.time)!; group.medications.push(entry); if (entry.status !== 'taken') group.allTaken = false; if (entry.status !== 'skipped') group.allSkipped = false; if (entry.status === 'overdue') group.anyOverdue = true; } return Array.from(groups.values()).sort( (a, b) => timeToMinutes(a.time) - timeToMinutes(b.time) ); }, [medEntries]); // ── Dynamic time window ─────────────────────────────────────── const allEventMins = [ ...scheduledForDay.map((e) => timeToMinutes(e.time)), ...groupedMedEntries.map((e) => timeToMinutes(e.time)), ]; const displayStartHour = allEventMins.length > 0 ? Math.max(5, Math.floor(Math.min(...allEventMins) / 60) - 1) : DEFAULT_START_HOUR; const displayEndHour = allEventMins.length > 0 ? Math.min(24, Math.ceil(Math.max(...allEventMins) / 60) + 2) : DEFAULT_END_HOUR; const nowTopPx = minutesToTop(nowMinutes, displayStartHour); // ── Overlap layout ──────────────────────────────────────────── const timelineLayout = useMemo(() => { const items: LayoutItem[] = [ ...scheduledForDay.map((e) => ({ id: `r-${e.routine_id}`, startMin: timeToMinutes(e.time), endMin: timeToMinutes(e.time) + Math.max(1, e.total_duration_minutes), })), ...groupedMedEntries.map((g) => { const heightPx = Math.max(48, g.medications.length * 24); const durationMin = (heightPx / HOUR_HEIGHT) * 60; return { id: `m-${g.time}`, startMin: timeToMinutes(g.time), endMin: timeToMinutes(g.time) + durationMin, }; }), ]; return computeLanes(items); }, [scheduledForDay, groupedMedEntries]); // ── Handlers ────────────────────────────────────────────────── const handleTakeMed = async (medicationId: string, scheduledTime: string) => { try { setError(null); await api.medications.take(medicationId, scheduledTime || undefined); setTodayMeds((prev) => prev.map((med) => { if (med.medication.id !== medicationId) return med; return { ...med, taken_times: [...med.taken_times, scheduledTime] }; }) ); setUndoAction({ medicationId, scheduledTime, action: 'taken', timestamp: Date.now(), }); setTimeout(() => setUndoAction(null), 5000); } catch (err) { console.error('Failed to take medication:', err); setError(err instanceof Error ? err.message : 'Failed to take medication'); } }; const handleSkipMed = async (medicationId: string, scheduledTime: string) => { try { setError(null); await api.medications.skip(medicationId, scheduledTime || undefined); setTodayMeds((prev) => prev.map((med) => { if (med.medication.id !== medicationId) return med; return { ...med, skipped_times: [...(med.skipped_times || []), scheduledTime], }; }) ); setUndoAction({ medicationId, scheduledTime, action: 'skipped', timestamp: Date.now(), }); setTimeout(() => setUndoAction(null), 5000); } catch (err) { console.error('Failed to skip medication:', err); setError( err instanceof Error ? err.message : 'Failed to skip medication' ); } }; const handleUndo = () => { if (!undoAction) return; if (undoAction.action === 'taken') { setTodayMeds((prev) => prev.map((med) => { if (med.medication.id !== undoAction.medicationId) return med; return { ...med, taken_times: med.taken_times.filter( (t) => t !== undoAction.scheduledTime ), }; }) ); } else { setTodayMeds((prev) => prev.map((med) => { if (med.medication.id !== undoAction.medicationId) return med; return { ...med, skipped_times: (med.skipped_times || []).filter( (t) => t !== undoAction.scheduledTime ), }; }) ); } setUndoAction(null); }; useEffect(() => { Promise.all([ api.routines.list(), api.routines.listAllSchedules(), api.medications.getToday().catch(() => []), ]) .then(([routines, schedules, meds]) => { setAllRoutines(routines); setAllSchedules(schedules); setTodayMeds(meds); }) .catch(() => {}) .finally(() => setIsLoading(false)); }, []); useEffect(() => { const timer = setInterval(() => { const n = new Date(); setNowMinutes(n.getHours() * 60 + n.getMinutes()); setTick((t) => t + 1); }, 30_000); return () => clearInterval(timer); }, []); useEffect(() => { if (!isLoading && isToday && timelineRef.current) { const scrollTarget = nowTopPx - window.innerHeight / 3; timelineRef.current.scrollTo({ top: Math.max(0, scrollTarget), behavior: 'smooth', }); } // intentionally only run after initial load // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLoading, isToday]); const handleStartRoutine = async (routineId: string) => { try { await api.sessions.start(routineId); router.push(`/dashboard/routines/${routineId}/run`); } catch (err) { const msg = (err as Error).message; if (msg.includes('already have active session')) { router.push(`/dashboard/routines/${routineId}/run`); } } }; if (isLoading) { return (
); } const timelineHeight = (displayEndHour - displayStartHour) * HOUR_HEIGHT; return ( // h-screen + overflow-hidden eliminates the outer page scrollbar; // only the inner timeline div scrolls.
{/* Header */}

Routines

{/* Undo Toast */} {undoAction && (
{undoAction.action === 'taken' ? 'Medication taken' : 'Medication skipped'}
)} {/* Error Toast */} {error && (
{error}
)} {/* Week Strip */}
{weekDays.map((day, i) => { const selected = isSameDay(day, selectedDate); const isTodayDay = isSameDay(day, today); return ( ); })}
{/* Timeline — flex-1 scrolls; scrollbar hidden via CSS */}
{allRoutines.length === 0 ? (

No routines yet

Create Routine
) : ( <> {/* Timeline grid */}
{/* Hour lines */} {Array.from( { length: displayEndHour - displayStartHour }, (_, i) => { const hour = displayStartHour + i; const label = hour === 0 ? '12 AM' : hour < 12 ? `${hour} AM` : hour === 12 ? '12 PM' : `${hour - 12} PM`; return (
{label}
); } )} {/* Now indicator */} {isToday && nowTopPx >= 0 && nowTopPx <= timelineHeight && (
)} {/* Routine cards */} {scheduledForDay.map((entry) => { const startMin = timeToMinutes(entry.time); const topPx = minutesToTop(startMin, displayStartHour); const heightPx = durationToHeight(entry.total_duration_minutes); const endMin = startMin + entry.total_duration_minutes; const isPast = isToday && nowMinutes > endMin; const isCurrent = isToday && nowMinutes >= startMin && nowMinutes < endMin; const layout = timelineLayout.get(`r-${entry.routine_id}`) ?? { lane: 0, totalLanes: 1, }; return ( ); })} {/* Medication cards — grouped by time, sharing lanes with routines */} {groupedMedEntries.map((group) => { const startMin = timeToMinutes(group.time); const topPx = minutesToTop(startMin, displayStartHour); const heightPx = Math.max(48, group.medications.length * 24); const layout = timelineLayout.get(`m-${group.time}`) ?? { lane: 0, totalLanes: 1, }; let statusColor = 'bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800'; if (group.allTaken) statusColor = 'bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-800'; else if (group.allSkipped) statusColor = 'bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700 opacity-60'; else if (group.anyOverdue) statusColor = 'bg-amber-50 dark:bg-amber-900/30 border-amber-300 dark:border-amber-700'; return (
💊

{formatMedsList(group.medications)}

{formatTime(group.time)}

{group.allTaken ? ( Taken ) : group.allSkipped ? ( Skipped ) : (
{group.anyOverdue && ( ! )}
)}
); })} {/* Empty day */} {scheduledForDay.length === 0 && medEntries.length === 0 && (

No routines or medications for this day

)}
)} {/* Unscheduled routines — inside the scroll area, below the timeline */} {unscheduledRoutines.length > 0 && (

Unscheduled

{unscheduledRoutines.map((r) => (
{r.icon || '✨'}

{r.name}

{r.description && (

{r.description}

)}
Edit
))}
)}
); }