UI fixes
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import api from '@/lib/api';
|
||||
import { PlusIcon, PlayIcon, ClockIcon } from '@/components/ui/Icons';
|
||||
import { PlusIcon, PlayIcon, ClockIcon, CheckIcon } from '@/components/ui/Icons';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Routine {
|
||||
@@ -23,11 +23,69 @@ interface ScheduleEntry {
|
||||
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;
|
||||
}
|
||||
|
||||
const HOUR_HEIGHT = 80;
|
||||
const START_HOUR = 5;
|
||||
const END_HOUR = 23;
|
||||
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;
|
||||
|
||||
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();
|
||||
const diffMin = diffMs / 60000;
|
||||
|
||||
if (diffMin > 15) return 'overdue';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
function getWeekDays(anchor: Date): Date[] {
|
||||
const d = new Date(anchor);
|
||||
@@ -73,9 +131,20 @@ function addMinutesToTime(t: string, mins: number): string {
|
||||
return `${String(nh).padStart(2, '0')}:${String(nm).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function getDayKey(date: Date): string {
|
||||
const day = date.getDay();
|
||||
return DAY_KEYS[day === 0 ? 6 : day - 1];
|
||||
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;
|
||||
}
|
||||
|
||||
export default function RoutinesPage() {
|
||||
@@ -84,12 +153,21 @@ export default function RoutinesPage() {
|
||||
|
||||
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);
|
||||
@@ -105,14 +183,130 @@ export default function RoutinesPage() {
|
||||
|
||||
const nowTopPx = minutesToTop(nowMinutes);
|
||||
|
||||
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]);
|
||||
|
||||
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 = () => {
|
||||
// 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),
|
||||
};
|
||||
}));
|
||||
}
|
||||
setUndoAction(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.routines.list(),
|
||||
api.routines.listAllSchedules(),
|
||||
api.medications.getToday().catch(() => []),
|
||||
])
|
||||
.then(([routines, schedules]) => {
|
||||
.then(([routines, schedules, todayMeds]) => {
|
||||
setAllRoutines(routines);
|
||||
setAllSchedules(schedules);
|
||||
setTodayMeds(todayMeds);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoading(false));
|
||||
@@ -122,6 +316,7 @@ export default function RoutinesPage() {
|
||||
const timer = setInterval(() => {
|
||||
const n = new Date();
|
||||
setNowMinutes(n.getHours() * 60 + n.getMinutes());
|
||||
setTick(t => t + 1);
|
||||
}, 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
@@ -166,6 +361,32 @@ export default function RoutinesPage() {
|
||||
</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 px-2 pb-3 pt-2 gap-1 border-b border-gray-100">
|
||||
{weekDays.map((day, i) => {
|
||||
@@ -293,53 +514,131 @@ export default function RoutinesPage() {
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Medication cards - grouped by time */}
|
||||
{groupedMedEntries.map((group) => {
|
||||
const startMin = timeToMinutes(group.time) || 0;
|
||||
const topPx = minutesToTop(startMin);
|
||||
const heightPx = Math.max(48, group.medications.length * 24);
|
||||
|
||||
let statusColor = 'bg-blue-50 border-blue-200';
|
||||
if (group.allTaken) statusColor = 'bg-green-50 border-green-200';
|
||||
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 (
|
||||
<div
|
||||
key={group.time}
|
||||
style={{
|
||||
top: `${topPx}px`,
|
||||
height: `${heightPx}px`,
|
||||
left: '60px',
|
||||
right: '8px',
|
||||
}}
|
||||
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 text-sm truncate">
|
||||
{formatMedsList(group.medications)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 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 px-1 py-1 text-xs"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Empty day */}
|
||||
{scheduledForDay.length === 0 && (
|
||||
{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 scheduled for this day</p>
|
||||
<p className="text-gray-400 text-sm">No routines or medications for this day</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unscheduled routines */}
|
||||
{unscheduledRoutines.length > 0 && (
|
||||
<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">
|
||||
Unscheduled
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{unscheduledRoutines.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="flex items-center gap-3 bg-gray-50 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 text-sm truncate">{r.name}</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>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unscheduled routines - outside scrollable area */}
|
||||
{unscheduledRoutines.length > 0 && !isLoading && (
|
||||
<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">
|
||||
Unscheduled
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{unscheduledRoutines.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="flex items-center gap-3 bg-gray-50 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 text-sm truncate">{r.name}</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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user