diff --git a/synculous-client/src/app/dashboard/routines/page.tsx b/synculous-client/src/app/dashboard/routines/page.tsx index 0edc2ff..6e2e868 100644 --- a/synculous-client/src/app/dashboard/routines/page.tsx +++ b/synculous-client/src/app/dashboard/routines/page.tsx @@ -55,35 +55,35 @@ interface GroupedMedEntry { anyOverdue: boolean; } +// ── Constants ──────────────────────────────────────────────────── const HOUR_HEIGHT = 80; -const START_HOUR = 5; -const END_HOUR = 23; +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[], + 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(); - const diffMin = diffMs / 60000; - - if (diffMin > 15) return 'overdue'; + if (diffMs / 60000 > 15) return 'overdue'; return 'pending'; } @@ -100,7 +100,11 @@ function getWeekDays(anchor: Date): Date[] { } function isSameDay(a: Date, b: Date): boolean { - return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); } function timeToMinutes(t: string): number { @@ -108,8 +112,8 @@ function timeToMinutes(t: string): number { return h * 60 + m; } -function minutesToTop(minutes: number): number { - return ((minutes - START_HOUR * 60) / 60) * HOUR_HEIGHT; +function minutesToTop(minutes: number, startHour: number): number { + return ((minutes - startHour * 60) / 60) * HOUR_HEIGHT; } function durationToHeight(minutes: number): number { @@ -134,7 +138,7 @@ function addMinutesToTime(t: string, mins: number): string { 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; @@ -147,6 +151,56 @@ function formatMedsList(meds: { routine_name: string }[]): string { 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); @@ -175,22 +229,22 @@ export default function RoutinesPage() { const dayKey = getDayKey(selectedDate); const scheduledForDay = allSchedules - .filter(s => s.days.includes(dayKey)) + .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 nowTopPx = minutesToTop(nowMinutes); + 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}`, @@ -203,17 +257,22 @@ export default function RoutinesPage() { scheduled_time: time, dosage: med.medication.dosage, unit: med.medication.unit, - status: getMedicationStatus(time, med.taken_times, med.skipped_times || [], now), + 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, { @@ -230,22 +289,66 @@ export default function RoutinesPage() { 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)); + + 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() }); + 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); @@ -257,42 +360,56 @@ export default function RoutinesPage() { 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() }); + 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'); + setError( + err instanceof Error ? err.message : 'Failed to skip medication' + ); } }; const handleUndo = () => { - // Undo works by reverting the local state immediately - // On next refresh, data will sync from server 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 if (undoAction.action === 'skipped') { - 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), - }; - })); + 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); }; @@ -303,10 +420,10 @@ export default function RoutinesPage() { api.routines.listAllSchedules(), api.medications.getToday().catch(() => []), ]) - .then(([routines, schedules, todayMeds]) => { + .then(([routines, schedules, meds]) => { setAllRoutines(routines); setAllSchedules(schedules); - setTodayMeds(todayMeds); + setTodayMeds(meds); }) .catch(() => {}) .finally(() => setIsLoading(false)); @@ -316,7 +433,7 @@ export default function RoutinesPage() { const timer = setInterval(() => { const n = new Date(); setNowMinutes(n.getHours() * 60 + n.getMinutes()); - setTick(t => t + 1); + setTick((t) => t + 1); }, 30_000); return () => clearInterval(timer); }, []); @@ -324,8 +441,13 @@ export default function RoutinesPage() { useEffect(() => { if (!isLoading && isToday && timelineRef.current) { const scrollTarget = nowTopPx - window.innerHeight / 3; - timelineRef.current.scrollTo({ top: Math.max(0, scrollTarget), behavior: 'smooth' }); + 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) => { @@ -343,15 +465,19 @@ export default function RoutinesPage() { 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

- {undoAction.action === 'taken' ? 'Medication taken' : 'Medication skipped'} + {undoAction.action === 'taken' + ? 'Medication taken' + : 'Medication skipped'}
)} -
- {/* Unscheduled routines - outside scrollable area */} - {unscheduledRoutines.length > 0 && !isLoading && ( -
-

- Unscheduled -

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

{r.name}

- {r.description && ( -

{r.description}

- )} + {/* 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 +
- - - Edit - -
- ))} + ))} +
-
- )} + )} +
); }