838 lines
30 KiB
TypeScript
838 lines
30 KiB
TypeScript
'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<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() {
|
|
const router = useRouter();
|
|
const timelineRef = useRef<HTMLDivElement>(null);
|
|
|
|
const [allRoutines, setAllRoutines] = useState<Routine[]>([]);
|
|
const [allSchedules, setAllSchedules] = useState<ScheduleEntry[]>([]);
|
|
const [todayMeds, setTodayMeds] = useState<TodaysMedication[]>([]);
|
|
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<string | null>(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<string, GroupedMedEntry> = 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 (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
const timelineHeight = (displayEndHour - displayStartHour) * HOUR_HEIGHT;
|
|
|
|
return (
|
|
// 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 dark:bg-gray-950">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 pt-4 pb-2 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Routines</h1>
|
|
<Link
|
|
href="/dashboard/routines/new"
|
|
className="bg-indigo-600 text-white p-2 rounded-full"
|
|
>
|
|
<PlusIcon size={20} />
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Undo Toast */}
|
|
{undoAction && (
|
|
<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">
|
|
<span className="text-sm">
|
|
{undoAction.action === 'taken'
|
|
? 'Medication taken'
|
|
: 'Medication skipped'}
|
|
</span>
|
|
<button
|
|
onClick={handleUndo}
|
|
className="text-indigo-400 font-medium text-sm hover:text-indigo-300"
|
|
>
|
|
Undo
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error Toast */}
|
|
{error && (
|
|
<div className="fixed bottom-20 left-4 right-4 z-50 animate-fade-in-up">
|
|
<div className="bg-red-600 text-white px-4 py-3 rounded-xl shadow-lg">
|
|
{error}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Week Strip */}
|
|
<div className="flex bg-white dark:bg-gray-900 px-2 pb-3 pt-2 gap-1 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
|
|
{weekDays.map((day, i) => {
|
|
const selected = isSameDay(day, selectedDate);
|
|
const isTodayDay = isSameDay(day, today);
|
|
return (
|
|
<button
|
|
key={i}
|
|
onClick={() => setSelectedDate(day)}
|
|
className="flex-1 flex flex-col items-center py-1 rounded-xl"
|
|
>
|
|
<span className="text-xs text-gray-500 dark:text-gray-400 mb-1">
|
|
{DAY_LABELS[i]}
|
|
</span>
|
|
<span
|
|
className={`w-8 h-8 flex items-center justify-center rounded-full text-sm font-semibold transition-colors ${
|
|
isTodayDay && selected
|
|
? 'bg-indigo-600 text-white'
|
|
: isTodayDay
|
|
? 'text-indigo-600 font-bold'
|
|
: selected
|
|
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100'
|
|
: 'text-gray-700 dark:text-gray-300'
|
|
}`}
|
|
>
|
|
{day.getDate()}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Timeline — flex-1 scrolls; scrollbar hidden via CSS */}
|
|
<div
|
|
ref={timelineRef}
|
|
className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:hidden"
|
|
style={{ scrollbarWidth: 'none' } as React.CSSProperties}
|
|
>
|
|
|
|
{allRoutines.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-20 px-4">
|
|
<div className="w-16 h-16 bg-indigo-100 dark:bg-indigo-900/50 rounded-full flex items-center justify-center mb-4">
|
|
<ClockIcon size={32} className="text-indigo-400" />
|
|
</div>
|
|
<p className="text-gray-500 dark:text-gray-400 text-center mb-4">No routines yet</p>
|
|
<Link
|
|
href="/dashboard/routines/new"
|
|
className="bg-indigo-600 text-white px-6 py-3 rounded-xl font-medium"
|
|
>
|
|
Create Routine
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Timeline grid */}
|
|
<div className="relative" style={{ height: `${timelineHeight}px` }}>
|
|
{/* 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 (
|
|
<div
|
|
key={hour}
|
|
className="absolute w-full flex items-start"
|
|
style={{
|
|
top: `${i * HOUR_HEIGHT}px`,
|
|
height: `${HOUR_HEIGHT}px`,
|
|
}}
|
|
>
|
|
<span className="w-14 text-xs text-gray-400 dark:text-gray-500 pr-2 text-right flex-shrink-0 -mt-2">
|
|
{label}
|
|
</span>
|
|
<div className="flex-1 border-t border-gray-200 dark:border-gray-700 h-full" />
|
|
</div>
|
|
);
|
|
}
|
|
)}
|
|
|
|
{/* Now indicator */}
|
|
{isToday &&
|
|
nowTopPx >= 0 &&
|
|
nowTopPx <= timelineHeight && (
|
|
<div
|
|
className="absolute flex items-center pointer-events-none z-10"
|
|
style={{ top: `${nowTopPx}px`, left: '48px', right: '0' }}
|
|
>
|
|
<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 */}
|
|
{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 (
|
|
<button
|
|
key={entry.routine_id}
|
|
onClick={() =>
|
|
router.push(`/dashboard/routines/${entry.routine_id}`)
|
|
}
|
|
style={{
|
|
top: `${topPx}px`,
|
|
height: `${heightPx}px`,
|
|
...laneStyle(layout.lane, layout.totalLanes),
|
|
}}
|
|
className={`absolute rounded-xl px-3 py-2 text-left shadow-sm border transition-all overflow-hidden ${
|
|
isPast
|
|
? 'bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-800 opacity-75'
|
|
: 'bg-indigo-50 dark:bg-indigo-900/30 border-indigo-200 dark:border-indigo-800'
|
|
} ${isCurrent ? 'ring-2 ring-indigo-500 opacity-100' : ''}`}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-lg leading-none flex-shrink-0">
|
|
{entry.routine_icon || '✨'}
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-semibold text-gray-900 dark:text-gray-100 text-sm truncate">
|
|
{entry.routine_name}
|
|
</p>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
|
{formatTime(entry.time)}
|
|
{entry.total_duration_minutes > 0 && (
|
|
<>
|
|
{' '}
|
|
-{' '}
|
|
{formatTime(
|
|
addMinutesToTime(
|
|
entry.time,
|
|
entry.total_duration_minutes
|
|
)
|
|
)}
|
|
</>
|
|
)}
|
|
{entry.total_duration_minutes > 0 &&
|
|
` · ${entry.total_duration_minutes}m`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
|
|
{/* 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 (
|
|
<div
|
|
key={group.time}
|
|
style={{
|
|
top: `${topPx}px`,
|
|
height: `${heightPx}px`,
|
|
...laneStyle(layout.lane, layout.totalLanes),
|
|
}}
|
|
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 gap-2 min-w-0 flex-1">
|
|
<span className="text-lg leading-none flex-shrink-0">
|
|
💊
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-semibold text-gray-900 dark:text-gray-100 text-sm truncate">
|
|
{formatMedsList(group.medications)}
|
|
</p>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
|
{formatTime(group.time)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{group.allTaken ? (
|
|
<span className="text-green-600 font-medium flex items-center gap-1 flex-shrink-0">
|
|
<CheckIcon size={16} /> Taken
|
|
</span>
|
|
) : group.allSkipped ? (
|
|
<span className="text-gray-400 font-medium flex-shrink-0">
|
|
Skipped
|
|
</span>
|
|
) : (
|
|
<div className="flex gap-1 flex-shrink-0 items-center">
|
|
{group.anyOverdue && (
|
|
<span className="text-amber-600 font-medium text-xs mr-1">
|
|
!
|
|
</span>
|
|
)}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
group.medications.forEach((med) => {
|
|
if (
|
|
med.status !== 'taken' &&
|
|
med.status !== 'skipped'
|
|
) {
|
|
handleTakeMed(
|
|
med.medication_id,
|
|
med.scheduled_time
|
|
);
|
|
}
|
|
});
|
|
}}
|
|
className="bg-green-600 text-white px-2 py-1 rounded-lg text-xs font-medium"
|
|
>
|
|
Take All
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
group.medications.forEach((med) => {
|
|
if (
|
|
med.status !== 'taken' &&
|
|
med.status !== 'skipped'
|
|
) {
|
|
handleSkipMed(
|
|
med.medication_id,
|
|
med.scheduled_time
|
|
);
|
|
}
|
|
});
|
|
}}
|
|
className="text-gray-500 dark:text-gray-400 px-1 py-1 text-xs"
|
|
>
|
|
Skip
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Empty day */}
|
|
{scheduledForDay.length === 0 && medEntries.length === 0 && (
|
|
<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>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Unscheduled routines — inside the scroll area, below the timeline */}
|
|
{unscheduledRoutines.length > 0 && (
|
|
<div className="border-t border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 px-4 pt-3 pb-4">
|
|
<h2 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
|
|
Unscheduled
|
|
</h2>
|
|
<div className="space-y-2">
|
|
{unscheduledRoutines.map((r) => (
|
|
<div
|
|
key={r.id}
|
|
className="flex items-center gap-3 bg-gray-50 dark:bg-gray-800 rounded-xl p-3"
|
|
>
|
|
<span className="text-xl flex-shrink-0">{r.icon || '✨'}</span>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-gray-900 dark:text-gray-100 text-sm truncate">
|
|
{r.name}
|
|
</p>
|
|
{r.description && (
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 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>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|