Fix timeline layout: shorter window, overlap lanes, single scrollbar

- Dynamic start/end hours computed from actual events (+1h padding each
  side) instead of hard-coded 5 AM–11 PM; falls back to 7 AM–10 PM
  when no events are scheduled
- Lane algorithm (greedy interval scheduling) prevents overlapping events
  from hiding each other; routines and med groups share the same lane
  pool so conflicts split the column width side by side
- Outer container locked to h-screen overflow-hidden to eliminate the
  page-level scrollbar; timeline inner scrollbar hidden via
  [&::-webkit-scrollbar]:hidden + scrollbarWidth:none

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 22:37:31 -06:00
parent 782b1d2931
commit e97347ff65

View File

@@ -55,35 +55,35 @@ interface GroupedMedEntry {
anyOverdue: boolean; anyOverdue: boolean;
} }
// ── Constants ────────────────────────────────────────────────────
const HOUR_HEIGHT = 80; const HOUR_HEIGHT = 80;
const START_HOUR = 5; const DEFAULT_START_HOUR = 7;
const END_HOUR = 23; const DEFAULT_END_HOUR = 22;
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const DAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; const DAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
const MEDICATION_DURATION_MINUTES = 5; const MEDICATION_DURATION_MINUTES = 5;
// ── Utility functions ─────────────────────────────────────────────
function getDayKey(date: Date): string { function getDayKey(date: Date): string {
const day = date.getDay(); const day = date.getDay();
return DAY_KEYS[day === 0 ? 6 : day - 1]; return DAY_KEYS[day === 0 ? 6 : day - 1];
} }
function getMedicationStatus( function getMedicationStatus(
scheduledTime: string, scheduledTime: string,
takenTimes: string[], takenTimes: string[],
skippedTimes: string[], skippedTimes: string[],
now: Date now: Date
): 'taken' | 'pending' | 'overdue' | 'skipped' { ): 'taken' | 'pending' | 'overdue' | 'skipped' {
if (takenTimes.includes(scheduledTime)) return 'taken'; if (takenTimes.includes(scheduledTime)) return 'taken';
if (skippedTimes?.includes(scheduledTime)) return 'skipped'; if (skippedTimes?.includes(scheduledTime)) return 'skipped';
const [h, m] = scheduledTime.split(':').map(Number); const [h, m] = scheduledTime.split(':').map(Number);
const scheduled = new Date(now); const scheduled = new Date(now);
scheduled.setHours(h, m, 0, 0); scheduled.setHours(h, m, 0, 0);
const diffMs = now.getTime() - scheduled.getTime(); const diffMs = now.getTime() - scheduled.getTime();
const diffMin = diffMs / 60000; if (diffMs / 60000 > 15) return 'overdue';
if (diffMin > 15) return 'overdue';
return 'pending'; return 'pending';
} }
@@ -100,7 +100,11 @@ function getWeekDays(anchor: Date): Date[] {
} }
function isSameDay(a: Date, b: Date): boolean { 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 { function timeToMinutes(t: string): number {
@@ -108,8 +112,8 @@ function timeToMinutes(t: string): number {
return h * 60 + m; return h * 60 + m;
} }
function minutesToTop(minutes: number): number { function minutesToTop(minutes: number, startHour: number): number {
return ((minutes - START_HOUR * 60) / 60) * HOUR_HEIGHT; return ((minutes - startHour * 60) / 60) * HOUR_HEIGHT;
} }
function durationToHeight(minutes: number): number { function durationToHeight(minutes: number): number {
@@ -134,7 +138,7 @@ function addMinutesToTime(t: string, mins: number): string {
function formatMedsList(meds: { routine_name: string }[]): string { function formatMedsList(meds: { routine_name: string }[]): string {
const MAX_CHARS = 25; const MAX_CHARS = 25;
if (meds.length === 1) return meds[0].routine_name; if (meds.length === 1) return meds[0].routine_name;
let result = ''; let result = '';
for (const med of meds) { for (const med of meds) {
const next = result ? result + ', ' + med.routine_name : med.routine_name; const next = result ? result + ', ' + med.routine_name : med.routine_name;
@@ -147,6 +151,56 @@ function formatMedsList(meds: { routine_name: string }[]): string {
return result; return result;
} }
// ── Lane layout ───────────────────────────────────────────────────
interface LayoutItem {
id: string;
startMin: number;
endMin: number;
}
function computeLanes(
items: LayoutItem[]
): Map<string, { lane: number; totalLanes: number }> {
if (items.length === 0) return new Map();
const sorted = [...items].sort((a, b) => a.startMin - b.startMin);
const laneEnds: number[] = [];
const assigned = new Map<string, number>();
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<string, { lane: number; totalLanes: number }>();
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() { export default function RoutinesPage() {
const router = useRouter(); const router = useRouter();
const timelineRef = useRef<HTMLDivElement>(null); const timelineRef = useRef<HTMLDivElement>(null);
@@ -175,22 +229,22 @@ export default function RoutinesPage() {
const dayKey = getDayKey(selectedDate); const dayKey = getDayKey(selectedDate);
const scheduledForDay = allSchedules const scheduledForDay = allSchedules
.filter(s => s.days.includes(dayKey)) .filter((s) => s.days.includes(dayKey))
.sort((a, b) => timeToMinutes(a.time) - timeToMinutes(b.time)); .sort((a, b) => timeToMinutes(a.time) - timeToMinutes(b.time));
const scheduledRoutineIds = new Set(allSchedules.map(s => s.routine_id)); const scheduledRoutineIds = new Set(allSchedules.map((s) => s.routine_id));
const unscheduledRoutines = allRoutines.filter(r => !scheduledRoutineIds.has(r.id)); const unscheduledRoutines = allRoutines.filter(
(r) => !scheduledRoutineIds.has(r.id)
const nowTopPx = minutesToTop(nowMinutes); );
const medEntries = useMemo(() => { const medEntries = useMemo(() => {
const now = new Date(); const now = new Date();
const entries: MedicationTimelineEntry[] = []; const entries: MedicationTimelineEntry[] = [];
for (const med of todayMeds) { for (const med of todayMeds) {
if (med.is_prn) continue; if (med.is_prn) continue;
if (med.is_next_day || med.is_previous_day) continue; if (med.is_next_day || med.is_previous_day) continue;
for (const time of med.scheduled_times) { for (const time of med.scheduled_times) {
entries.push({ entries.push({
routine_id: `med-${med.medication.id}-${time}`, routine_id: `med-${med.medication.id}-${time}`,
@@ -203,17 +257,22 @@ export default function RoutinesPage() {
scheduled_time: time, scheduled_time: time,
dosage: med.medication.dosage, dosage: med.medication.dosage,
unit: med.medication.unit, 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)); return entries.sort((a, b) => timeToMinutes(a.time) - timeToMinutes(b.time));
}, [todayMeds, dayKey, tick]); }, [todayMeds, dayKey, tick]);
const groupedMedEntries = useMemo(() => { const groupedMedEntries = useMemo(() => {
const groups: Map<string, GroupedMedEntry> = new Map(); const groups: Map<string, GroupedMedEntry> = new Map();
for (const entry of medEntries) { for (const entry of medEntries) {
if (!groups.has(entry.time)) { if (!groups.has(entry.time)) {
groups.set(entry.time, { groups.set(entry.time, {
@@ -230,22 +289,66 @@ export default function RoutinesPage() {
if (entry.status !== 'skipped') group.allSkipped = false; if (entry.status !== 'skipped') group.allSkipped = false;
if (entry.status === 'overdue') group.anyOverdue = true; 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]); }, [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) => { const handleTakeMed = async (medicationId: string, scheduledTime: string) => {
try { try {
setError(null); setError(null);
await api.medications.take(medicationId, scheduledTime || undefined); await api.medications.take(medicationId, scheduledTime || undefined);
setTodayMeds(prev => prev.map(med => { setTodayMeds((prev) =>
if (med.medication.id !== medicationId) return med; prev.map((med) => {
return { if (med.medication.id !== medicationId) return med;
...med, return { ...med, taken_times: [...med.taken_times, scheduledTime] };
taken_times: [...med.taken_times, scheduledTime], })
}; );
})); setUndoAction({
setUndoAction({ medicationId, scheduledTime, action: 'taken', timestamp: Date.now() }); medicationId,
scheduledTime,
action: 'taken',
timestamp: Date.now(),
});
setTimeout(() => setUndoAction(null), 5000); setTimeout(() => setUndoAction(null), 5000);
} catch (err) { } catch (err) {
console.error('Failed to take medication:', err); console.error('Failed to take medication:', err);
@@ -257,42 +360,56 @@ export default function RoutinesPage() {
try { try {
setError(null); setError(null);
await api.medications.skip(medicationId, scheduledTime || undefined); await api.medications.skip(medicationId, scheduledTime || undefined);
setTodayMeds(prev => prev.map(med => { setTodayMeds((prev) =>
if (med.medication.id !== medicationId) return med; prev.map((med) => {
return { if (med.medication.id !== medicationId) return med;
...med, return {
skipped_times: [...(med.skipped_times || []), scheduledTime], ...med,
}; skipped_times: [...(med.skipped_times || []), scheduledTime],
})); };
setUndoAction({ medicationId, scheduledTime, action: 'skipped', timestamp: Date.now() }); })
);
setUndoAction({
medicationId,
scheduledTime,
action: 'skipped',
timestamp: Date.now(),
});
setTimeout(() => setUndoAction(null), 5000); setTimeout(() => setUndoAction(null), 5000);
} catch (err) { } catch (err) {
console.error('Failed to skip medication:', 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 = () => { const handleUndo = () => {
// Undo works by reverting the local state immediately
// On next refresh, data will sync from server
if (!undoAction) return; if (!undoAction) return;
if (undoAction.action === 'taken') { if (undoAction.action === 'taken') {
setTodayMeds(prev => prev.map(med => { setTodayMeds((prev) =>
if (med.medication.id !== undoAction.medicationId) return med; prev.map((med) => {
return { if (med.medication.id !== undoAction.medicationId) return med;
...med, return {
taken_times: med.taken_times.filter(t => t !== undoAction.scheduledTime), ...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, } else {
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,
skipped_times: (med.skipped_times || []).filter(
(t) => t !== undoAction.scheduledTime
),
};
})
);
} }
setUndoAction(null); setUndoAction(null);
}; };
@@ -303,10 +420,10 @@ export default function RoutinesPage() {
api.routines.listAllSchedules(), api.routines.listAllSchedules(),
api.medications.getToday().catch(() => []), api.medications.getToday().catch(() => []),
]) ])
.then(([routines, schedules, todayMeds]) => { .then(([routines, schedules, meds]) => {
setAllRoutines(routines); setAllRoutines(routines);
setAllSchedules(schedules); setAllSchedules(schedules);
setTodayMeds(todayMeds); setTodayMeds(meds);
}) })
.catch(() => {}) .catch(() => {})
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
@@ -316,7 +433,7 @@ export default function RoutinesPage() {
const timer = setInterval(() => { const timer = setInterval(() => {
const n = new Date(); const n = new Date();
setNowMinutes(n.getHours() * 60 + n.getMinutes()); setNowMinutes(n.getHours() * 60 + n.getMinutes());
setTick(t => t + 1); setTick((t) => t + 1);
}, 30_000); }, 30_000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, []);
@@ -324,8 +441,13 @@ export default function RoutinesPage() {
useEffect(() => { useEffect(() => {
if (!isLoading && isToday && timelineRef.current) { if (!isLoading && isToday && timelineRef.current) {
const scrollTarget = nowTopPx - window.innerHeight / 3; 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]); }, [isLoading, isToday]);
const handleStartRoutine = async (routineId: string) => { const handleStartRoutine = async (routineId: string) => {
@@ -343,15 +465,19 @@ export default function RoutinesPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center min-h-[50vh]"> <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 className="w-8 h-8 border-4 border-indigo-500 border-t-transparent rounded-full animate-spin" />
</div> </div>
); );
} }
const timelineHeight = (displayEndHour - displayStartHour) * HOUR_HEIGHT;
return ( return (
<div className="flex flex-col h-full min-h-screen bg-gray-50"> // h-screen + overflow-hidden eliminates the outer page scrollbar;
// only the inner timeline div scrolls.
<div className="flex flex-col h-screen overflow-hidden bg-gray-50">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 pt-4 pb-2 bg-white border-b border-gray-100"> <div className="flex items-center justify-between px-4 pt-4 pb-2 bg-white border-b border-gray-100 flex-shrink-0">
<h1 className="text-2xl font-bold text-gray-900">Routines</h1> <h1 className="text-2xl font-bold text-gray-900">Routines</h1>
<Link <Link
href="/dashboard/routines/new" href="/dashboard/routines/new"
@@ -366,7 +492,9 @@ export default function RoutinesPage() {
<div className="fixed bottom-20 left-4 right-4 z-50 animate-fade-in-up"> <div className="fixed bottom-20 left-4 right-4 z-50 animate-fade-in-up">
<div className="bg-gray-900 text-white px-4 py-3 rounded-xl flex items-center justify-between shadow-lg"> <div className="bg-gray-900 text-white px-4 py-3 rounded-xl flex items-center justify-between shadow-lg">
<span className="text-sm"> <span className="text-sm">
{undoAction.action === 'taken' ? 'Medication taken' : 'Medication skipped'} {undoAction.action === 'taken'
? 'Medication taken'
: 'Medication skipped'}
</span> </span>
<button <button
onClick={handleUndo} onClick={handleUndo}
@@ -388,7 +516,7 @@ export default function RoutinesPage() {
)} )}
{/* Week Strip */} {/* Week Strip */}
<div className="flex bg-white px-2 pb-3 pt-2 gap-1 border-b border-gray-100"> <div className="flex bg-white px-2 pb-3 pt-2 gap-1 border-b border-gray-100 flex-shrink-0">
{weekDays.map((day, i) => { {weekDays.map((day, i) => {
const selected = isSameDay(day, selectedDate); const selected = isSameDay(day, selectedDate);
const isTodayDay = isSameDay(day, today); const isTodayDay = isSameDay(day, today);
@@ -398,7 +526,9 @@ export default function RoutinesPage() {
onClick={() => setSelectedDate(day)} onClick={() => setSelectedDate(day)}
className="flex-1 flex flex-col items-center py-1 rounded-xl" className="flex-1 flex flex-col items-center py-1 rounded-xl"
> >
<span className="text-xs text-gray-500 mb-1">{DAY_LABELS[i]}</span> <span className="text-xs text-gray-500 mb-1">
{DAY_LABELS[i]}
</span>
<span <span
className={`w-8 h-8 flex items-center justify-center rounded-full text-sm font-semibold transition-colors ${ className={`w-8 h-8 flex items-center justify-center rounded-full text-sm font-semibold transition-colors ${
isTodayDay && selected isTodayDay && selected
@@ -417,8 +547,13 @@ export default function RoutinesPage() {
})} })}
</div> </div>
{/* Timeline */} {/* Timeline — flex-1 scrolls; scrollbar hidden via CSS */}
<div ref={timelineRef} className="flex-1 overflow-y-auto"> <div
ref={timelineRef}
className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:hidden"
style={{ scrollbarWidth: 'none' } as React.CSSProperties}
>
{allRoutines.length === 0 ? ( {allRoutines.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 px-4"> <div className="flex flex-col items-center justify-center py-20 px-4">
<div className="w-16 h-16 bg-indigo-100 rounded-full flex items-center justify-center mb-4"> <div className="w-16 h-16 bg-indigo-100 rounded-full flex items-center justify-center mb-4">
@@ -435,61 +570,75 @@ export default function RoutinesPage() {
) : ( ) : (
<> <>
{/* Timeline grid */} {/* Timeline grid */}
<div <div className="relative" style={{ height: `${timelineHeight}px` }}>
className="relative"
style={{ height: `${(END_HOUR - START_HOUR) * HOUR_HEIGHT}px` }}
>
{/* Hour lines */} {/* Hour lines */}
{Array.from({ length: END_HOUR - START_HOUR }, (_, i) => { {Array.from(
const hour = START_HOUR + i; { length: displayEndHour - displayStartHour },
const label = (_, i) => {
hour === 0 ? '12 AM' : const hour = displayStartHour + i;
hour < 12 ? `${hour} AM` : const label =
hour === 12 ? '12 PM' : hour === 0
`${hour - 12} PM`; ? '12 AM'
return ( : hour < 12
<div ? `${hour} AM`
key={hour} : hour === 12
className="absolute w-full flex items-start" ? '12 PM'
style={{ top: `${i * HOUR_HEIGHT}px`, height: `${HOUR_HEIGHT}px` }} : `${hour - 12} PM`;
> return (
<span className="w-14 text-xs text-gray-400 pr-2 text-right flex-shrink-0 -mt-2"> <div
{label} key={hour}
</span> className="absolute w-full flex items-start"
<div className="flex-1 border-t border-gray-200 h-full" /> style={{
</div> top: `${i * HOUR_HEIGHT}px`,
); height: `${HOUR_HEIGHT}px`,
})} }}
>
<span className="w-14 text-xs text-gray-400 pr-2 text-right flex-shrink-0 -mt-2">
{label}
</span>
<div className="flex-1 border-t border-gray-200 h-full" />
</div>
);
}
)}
{/* Now indicator */} {/* Now indicator */}
{isToday && nowTopPx >= 0 && nowTopPx <= (END_HOUR - START_HOUR) * HOUR_HEIGHT && ( {isToday &&
<div nowTopPx >= 0 &&
className="absolute flex items-center pointer-events-none z-10" nowTopPx <= timelineHeight && (
style={{ top: `${nowTopPx}px`, left: '48px', right: '0' }} <div
> className="absolute flex items-center pointer-events-none z-10"
<div className="w-3 h-3 rounded-full bg-red-500 -ml-1.5 flex-shrink-0" /> style={{ top: `${nowTopPx}px`, left: '48px', right: '0' }}
<div className="flex-1 h-0.5 bg-red-500" /> >
</div> <div className="w-3 h-3 rounded-full bg-red-500 -ml-1.5 flex-shrink-0" />
)} <div className="flex-1 h-0.5 bg-red-500" />
</div>
)}
{/* Routine cards */} {/* Routine cards */}
{scheduledForDay.map((entry) => { {scheduledForDay.map((entry) => {
const startMin = timeToMinutes(entry.time); const startMin = timeToMinutes(entry.time);
const topPx = minutesToTop(startMin); const topPx = minutesToTop(startMin, displayStartHour);
const heightPx = durationToHeight(entry.total_duration_minutes); const heightPx = durationToHeight(entry.total_duration_minutes);
const endMin = startMin + entry.total_duration_minutes; const endMin = startMin + entry.total_duration_minutes;
const isPast = isToday && nowMinutes > endMin; const isPast = isToday && nowMinutes > endMin;
const isCurrent = isToday && nowMinutes >= startMin && nowMinutes < endMin; const isCurrent =
isToday && nowMinutes >= startMin && nowMinutes < endMin;
const layout = timelineLayout.get(`r-${entry.routine_id}`) ?? {
lane: 0,
totalLanes: 1,
};
return ( return (
<button <button
key={entry.routine_id} key={entry.routine_id}
onClick={() => router.push(`/dashboard/routines/${entry.routine_id}`)} onClick={() =>
router.push(`/dashboard/routines/${entry.routine_id}`)
}
style={{ style={{
top: `${topPx}px`, top: `${topPx}px`,
height: `${heightPx}px`, height: `${heightPx}px`,
left: '60px', ...laneStyle(layout.lane, layout.totalLanes),
right: '8px',
}} }}
className={`absolute rounded-xl px-3 py-2 text-left shadow-sm border transition-all overflow-hidden ${ className={`absolute rounded-xl px-3 py-2 text-left shadow-sm border transition-all overflow-hidden ${
isPast isPast
@@ -498,15 +647,29 @@ export default function RoutinesPage() {
} ${isCurrent ? 'ring-2 ring-indigo-500 opacity-100' : ''}`} } ${isCurrent ? 'ring-2 ring-indigo-500 opacity-100' : ''}`}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-lg leading-none flex-shrink-0">{entry.routine_icon || '✨'}</span> <span className="text-lg leading-none flex-shrink-0">
{entry.routine_icon || '✨'}
</span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-semibold text-gray-900 text-sm truncate">{entry.routine_name}</p> <p className="font-semibold text-gray-900 text-sm truncate">
{entry.routine_name}
</p>
<p className="text-xs text-gray-500 truncate"> <p className="text-xs text-gray-500 truncate">
{formatTime(entry.time)} {formatTime(entry.time)}
{entry.total_duration_minutes > 0 && ( {entry.total_duration_minutes > 0 && (
<> - {formatTime(addMinutesToTime(entry.time, entry.total_duration_minutes))}</> <>
{' '}
-{' '}
{formatTime(
addMinutesToTime(
entry.time,
entry.total_duration_minutes
)
)}
</>
)} )}
{entry.total_duration_minutes > 0 && ` · ${entry.total_duration_minutes}m`} {entry.total_duration_minutes > 0 &&
` · ${entry.total_duration_minutes}m`}
</p> </p>
</div> </div>
</div> </div>
@@ -514,31 +677,39 @@ export default function RoutinesPage() {
); );
})} })}
{/* Medication cards - grouped by time */} {/* Medication cards grouped by time, sharing lanes with routines */}
{groupedMedEntries.map((group) => { {groupedMedEntries.map((group) => {
const startMin = timeToMinutes(group.time) || 0; const startMin = timeToMinutes(group.time);
const topPx = minutesToTop(startMin); const topPx = minutesToTop(startMin, displayStartHour);
const heightPx = Math.max(48, group.medications.length * 24); 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 border-blue-200'; let statusColor = 'bg-blue-50 border-blue-200';
if (group.allTaken) statusColor = 'bg-green-50 border-green-200'; if (group.allTaken)
else if (group.allSkipped) statusColor = 'bg-gray-50 border-gray-200 opacity-60'; statusColor = 'bg-green-50 border-green-200';
else if (group.anyOverdue) statusColor = 'bg-amber-50 border-amber-300'; else if (group.allSkipped)
statusColor = 'bg-gray-50 border-gray-200 opacity-60';
else if (group.anyOverdue)
statusColor = 'bg-amber-50 border-amber-300';
return ( return (
<div <div
key={group.time} key={group.time}
style={{ style={{
top: `${topPx}px`, top: `${topPx}px`,
height: `${heightPx}px`, height: `${heightPx}px`,
left: '60px', ...laneStyle(layout.lane, layout.totalLanes),
right: '8px',
}} }}
className={`absolute rounded-xl px-3 py-2 text-left shadow-sm border transition-all overflow-hidden ${statusColor}`} className={`absolute rounded-xl px-3 py-2 text-left shadow-sm border transition-all overflow-hidden ${statusColor}`}
> >
<div className="flex items-center justify-between gap-2 h-full"> <div className="flex items-center justify-between gap-2 h-full">
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center gap-2 min-w-0 flex-1">
<span className="text-lg leading-none flex-shrink-0">💊</span> <span className="text-lg leading-none flex-shrink-0">
💊
</span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-semibold text-gray-900 text-sm truncate"> <p className="font-semibold text-gray-900 text-sm truncate">
{formatMedsList(group.medications)} {formatMedsList(group.medications)}
@@ -553,18 +724,28 @@ export default function RoutinesPage() {
<CheckIcon size={16} /> Taken <CheckIcon size={16} /> Taken
</span> </span>
) : group.allSkipped ? ( ) : group.allSkipped ? (
<span className="text-gray-400 font-medium flex-shrink-0">Skipped</span> <span className="text-gray-400 font-medium flex-shrink-0">
Skipped
</span>
) : ( ) : (
<div className="flex gap-1 flex-shrink-0 items-center"> <div className="flex gap-1 flex-shrink-0 items-center">
{group.anyOverdue && ( {group.anyOverdue && (
<span className="text-amber-600 font-medium text-xs mr-1">!</span> <span className="text-amber-600 font-medium text-xs mr-1">
!
</span>
)} )}
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
group.medications.forEach(med => { group.medications.forEach((med) => {
if (med.status !== 'taken' && med.status !== 'skipped') { if (
handleTakeMed(med.medication_id, med.scheduled_time); med.status !== 'taken' &&
med.status !== 'skipped'
) {
handleTakeMed(
med.medication_id,
med.scheduled_time
);
} }
}); });
}} }}
@@ -575,9 +756,15 @@ export default function RoutinesPage() {
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
group.medications.forEach(med => { group.medications.forEach((med) => {
if (med.status !== 'taken' && med.status !== 'skipped') { if (
handleSkipMed(med.medication_id, med.scheduled_time); med.status !== 'taken' &&
med.status !== 'skipped'
) {
handleSkipMed(
med.medication_id,
med.scheduled_time
);
} }
}); });
}} }}
@@ -595,50 +782,56 @@ export default function RoutinesPage() {
{/* Empty day */} {/* Empty day */}
{scheduledForDay.length === 0 && medEntries.length === 0 && ( {scheduledForDay.length === 0 && medEntries.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<p className="text-gray-400 text-sm">No routines or medications for this day</p> <p className="text-gray-400 text-sm">
No routines or medications for this day
</p>
</div> </div>
)} )}
</div> </div>
</> </>
)} )}
</div>
{/* Unscheduled routines - outside scrollable area */} {/* Unscheduled routines — inside the scroll area, below the timeline */}
{unscheduledRoutines.length > 0 && !isLoading && ( {unscheduledRoutines.length > 0 && (
<div className="border-t border-gray-200 bg-white px-4 pt-3 pb-4"> <div className="border-t border-gray-200 bg-white px-4 pt-3 pb-4">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2"> <h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
Unscheduled Unscheduled
</h2> </h2>
<div className="space-y-2"> <div className="space-y-2">
{unscheduledRoutines.map((r) => ( {unscheduledRoutines.map((r) => (
<div <div
key={r.id} key={r.id}
className="flex items-center gap-3 bg-gray-50 rounded-xl p-3" className="flex items-center gap-3 bg-gray-50 rounded-xl p-3"
> >
<span className="text-xl flex-shrink-0">{r.icon || '✨'}</span> <span className="text-xl flex-shrink-0">{r.icon || '✨'}</span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 text-sm truncate">{r.name}</p> <p className="font-medium text-gray-900 text-sm truncate">
{r.description && ( {r.name}
<p className="text-xs text-gray-500 truncate">{r.description}</p> </p>
)} {r.description && (
<p className="text-xs text-gray-500 truncate">
{r.description}
</p>
)}
</div>
<button
onClick={() => handleStartRoutine(r.id)}
className="bg-indigo-600 text-white p-2 rounded-lg flex-shrink-0"
>
<PlayIcon size={16} />
</button>
<Link
href={`/dashboard/routines/${r.id}`}
className="text-indigo-600 text-sm font-medium flex-shrink-0"
>
Edit
</Link>
</div> </div>
<button ))}
onClick={() => handleStartRoutine(r.id)} </div>
className="bg-indigo-600 text-white p-2 rounded-lg flex-shrink-0"
>
<PlayIcon size={16} />
</button>
<Link
href={`/dashboard/routines/${r.id}`}
className="text-indigo-600 text-sm font-medium flex-shrink-0"
>
Edit
</Link>
</div>
))}
</div> </div>
</div> )}
)} </div>
</div> </div>
); );
} }