This commit is contained in:
2026-02-15 22:19:48 -06:00
parent 749f734aff
commit 782b1d2931
9 changed files with 1400 additions and 269 deletions

View File

@@ -156,21 +156,27 @@ export default function MedicationsPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [todayMeds, tick]);
const [error, setError] = useState<string | null>(null);
const handleTake = async (medId: string, time?: string) => {
try {
setError(null);
await api.medications.take(medId, time);
window.location.reload();
} catch (err) {
console.error('Failed to log medication:', err);
setError(err instanceof Error ? err.message : 'Failed to log medication');
}
};
const handleSkip = async (medId: string, time?: string) => {
try {
setError(null);
await api.medications.skip(medId, time);
window.location.reload();
} catch (err) {
console.error('Failed to skip medication:', err);
setError(err instanceof Error ? err.message : 'Failed to skip medication');
}
};
@@ -217,6 +223,12 @@ export default function MedicationsPage() {
{/* Push Notification Toggle */}
<PushNotificationToggle />
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
)}
{/* Due Now Section */}
{dueEntries.length > 0 && (
<div>

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { useRouter, useParams, useSearchParams } from 'next/navigation';
import api from '@/lib/api';
import { ArrowLeftIcon, PlayIcon, PlusIcon, TrashIcon, GripVerticalIcon, ClockIcon } from '@/components/ui/Icons';
import Link from 'next/link';
@@ -31,7 +31,7 @@ interface Schedule {
remind: boolean;
}
const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠'];
const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠', '☕', '🍎', '💧', '🍀', '🎵', '📝', '🚴', '🏋️', '🚶', '👀', '🛡️', '😊', '😔'];
const DAY_OPTIONS = [
{ value: 'mon', label: 'Mon' },
@@ -53,12 +53,15 @@ function formatDays(days: string[]): string {
export default function RoutineDetailPage() {
const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
const routineId = params.id as string;
const isNewRoutine = searchParams.get('new') === '1';
const [routine, setRoutine] = useState<Routine | null>(null);
const [steps, setSteps] = useState<Step[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [editName, setEditName] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editIcon, setEditIcon] = useState('✨');
@@ -71,9 +74,10 @@ export default function RoutineDetailPage() {
// Schedule state
const [schedule, setSchedule] = useState<Schedule | null>(null);
const [editDays, setEditDays] = useState<string[]>([]);
const [editDays, setEditDays] = useState<string[]>(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
const [editTime, setEditTime] = useState('08:00');
const [editRemind, setEditRemind] = useState(true);
const [showScheduleEditor, setShowScheduleEditor] = useState(false);
useEffect(() => {
const fetchRoutine = async () => {
@@ -95,6 +99,11 @@ export default function RoutineDetailPage() {
setEditDays(scheduleData.days || []);
setEditTime(scheduleData.time || '08:00');
setEditRemind(scheduleData.remind ?? true);
} else {
setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
if (isNewRoutine) {
setShowScheduleEditor(true);
}
}
} catch (err) {
console.error('Failed to fetch routine:', err);
@@ -104,7 +113,22 @@ export default function RoutineDetailPage() {
}
};
fetchRoutine();
}, [routineId, router]);
}, [routineId, router, isNewRoutine]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (showScheduleEditor) {
setShowScheduleEditor(false);
} else if (isEditing) {
setIsEditing(false);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isEditing, showScheduleEditor]);
const handleStart = () => {
router.push(`/dashboard/routines/${routineId}/launch`);
@@ -121,19 +145,6 @@ export default function RoutineDetailPage() {
environment_prompts: editEnvPrompts,
});
// Save or delete schedule
if (editDays.length > 0) {
await api.routines.setSchedule(routineId, {
days: editDays,
time: editTime,
remind: editRemind,
});
setSchedule({ days: editDays, time: editTime, remind: editRemind });
} else if (schedule) {
await api.routines.deleteSchedule(routineId);
setSchedule(null);
}
setRoutine({
...routine!,
name: editName,
@@ -149,6 +160,26 @@ export default function RoutineDetailPage() {
}
};
const handleSaveSchedule = async () => {
try {
if (editDays.length > 0) {
await api.routines.setSchedule(routineId, {
days: editDays,
time: editTime || '08:00',
remind: editRemind,
});
setSchedule({ days: editDays, time: editTime || '08:00', remind: editRemind });
} else if (schedule) {
await api.routines.deleteSchedule(routineId);
setSchedule(null);
}
setShowScheduleEditor(false);
} catch (err) {
console.error('Failed to save schedule:', err);
alert('Failed to save schedule. Please try again.');
}
};
const handleAddStep = async () => {
if (!newStepName.trim()) return;
try {
@@ -172,6 +203,25 @@ export default function RoutineDetailPage() {
}
};
const handleMoveStep = async (stepId: string, direction: 'up' | 'down') => {
const currentIndex = steps.findIndex(s => s.id === stepId);
if (direction === 'up' && currentIndex === 0) return;
if (direction === 'down' && currentIndex === steps.length - 1) return;
const newSteps = [...steps];
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
[newSteps[currentIndex], newSteps[targetIndex]] = [newSteps[targetIndex], newSteps[currentIndex]];
newSteps.forEach((s, i) => s.position = i + 1);
setSteps(newSteps);
try {
const stepIds = newSteps.map(s => s.id);
await api.routines.reorderSteps(routineId, stepIds);
} catch (err) {
console.error('Failed to reorder steps:', err);
}
};
const toggleDay = (day: string) => {
setEditDays(prev =>
prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day]
@@ -203,12 +253,32 @@ export default function RoutineDetailPage() {
</h1>
</div>
{!isEditing && (
<button
onClick={() => setIsEditing(true)}
className="text-indigo-600 font-medium"
>
Edit
</button>
<div className="flex items-center gap-2">
<button
onClick={() => {
if (confirm('Are you sure you want to delete this routine?')) {
setIsDeleting(true);
api.routines.delete(routineId).then(() => {
router.push('/dashboard/routines');
}).catch(err => {
console.error('Failed to delete routine:', err);
alert('Failed to delete routine');
setIsDeleting(false);
});
}
}}
disabled={isDeleting}
className="text-red-500 font-medium disabled:opacity-50"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
<button
onClick={() => setIsEditing(true)}
className="text-indigo-600 font-medium"
>
Edit
</button>
</div>
)}
</div>
</header>
@@ -319,86 +389,17 @@ export default function RoutineDetailPage() {
</div>
</div>
{/* Schedule Editor */}
<div className="bg-white rounded-xl p-4 shadow-sm space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Schedule</h2>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Days</label>
<div className="flex gap-2 flex-wrap">
{DAY_OPTIONS.map((day) => (
<button
key={day.value}
type="button"
onClick={() => toggleDay(day.value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
editDays.includes(day.value)
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white text-gray-700 border-gray-300'
}`}
>
{day.label}
</button>
))}
</div>
</div>
{editDays.length > 0 && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Time</label>
<input
type="time"
value={editTime}
onChange={(e) => setEditTime(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Send reminder</p>
<p className="text-sm text-gray-500">Get notified when it's time</p>
</div>
<button
onClick={() => setEditRemind(!editRemind)}
className={`w-12 h-7 rounded-full transition-colors ${
editRemind ? 'bg-indigo-500' : 'bg-gray-300'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
editRemind ? 'translate-x-5' : ''
}`} />
</button>
</div>
</>
)}
{schedule && (
<button
onClick={() => {
setEditDays([]);
setEditTime('08:00');
setEditRemind(true);
}}
className="text-red-500 text-sm font-medium"
>
Remove schedule
</button>
)}
</div>
{/* Save/Cancel */}
<div className="flex gap-3">
<button
onClick={() => {
setIsEditing(false);
// Reset schedule edits
if (schedule) {
setEditDays(schedule.days);
setEditTime(schedule.time);
setEditRemind(schedule.remind);
} else {
setEditDays([]);
setEditTime('08:00');
setEditRemind(true);
}
setEditName(routine.name);
setEditDescription(routine.description || '');
setEditIcon(routine.icon || '✨');
setEditLocation(routine.location || '');
setEditHabitStack(routine.habit_stack_after || '');
setEditEnvPrompts(routine.environment_prompts || []);
}}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg font-medium"
>
@@ -443,20 +444,138 @@ export default function RoutineDetailPage() {
</div>
{/* Schedule display (view mode) */}
{schedule && schedule.days.length > 0 && (
<div className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex items-center gap-2 mb-2">
<div className="bg-white rounded-xl p-4 shadow-sm">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<ClockIcon size={16} className="text-indigo-500" />
<h3 className="font-semibold text-gray-900">Schedule</h3>
</div>
<p className="text-gray-700">
{formatDays(schedule.days)} at {schedule.time}
</p>
{schedule.remind && (
<p className="text-sm text-gray-500 mt-1">Reminders on</p>
{!showScheduleEditor && (
<button
onClick={() => setShowScheduleEditor(true)}
className="text-indigo-600 text-sm font-medium"
>
{schedule ? 'Edit' : 'Add schedule'}
</button>
)}
</div>
)}
{showScheduleEditor ? (
<>
{/* Quick select */}
<div className="flex gap-2 mb-3">
<button
type="button"
onClick={() => setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
editDays.length === 7 ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-700 border-gray-300'
}`}
>
Every day
</button>
<button
type="button"
onClick={() => setEditDays(['mon', 'tue', 'wed', 'thu', 'fri'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
editDays.length === 5 && !editDays.includes('sat') && !editDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-700 border-gray-300'
}`}
>
Weekdays
</button>
<button
type="button"
onClick={() => setEditDays(['sat', 'sun'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
editDays.length === 2 && editDays.includes('sat') && editDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-700 border-gray-300'
}`}
>
Weekends
</button>
</div>
<div className="mb-3">
<label className="block text-sm font-medium text-gray-700 mb-2">Days</label>
<div className="flex gap-2 flex-wrap">
{DAY_OPTIONS.map((day) => (
<button
key={day.value}
type="button"
onClick={() => toggleDay(day.value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
editDays.includes(day.value)
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white text-gray-700 border-gray-300'
}`}
>
{day.label}
</button>
))}
</div>
</div>
<div className="mb-3">
<label className="block text-sm font-medium text-gray-700 mb-1">Time</label>
<input
type="time"
value={editTime}
onChange={(e) => setEditTime(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div className="flex items-center justify-between mb-3">
<div>
<p className="font-medium text-gray-900">Send reminder</p>
<p className="text-sm text-gray-500">Get notified when it's time</p>
</div>
<button
onClick={() => setEditRemind(!editRemind)}
className={`w-12 h-7 rounded-full transition-colors ${
editRemind ? 'bg-indigo-500' : 'bg-gray-300'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
editRemind ? 'translate-x-5' : ''
}`} />
</button>
</div>
<div className="flex gap-2">
<button
onClick={() => {
setShowScheduleEditor(false);
if (schedule) {
setEditDays(schedule.days);
setEditTime(schedule.time);
setEditRemind(schedule.remind);
} else {
setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
setEditTime('08:00');
setEditRemind(true);
}
}}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm font-medium"
>
Cancel
</button>
<button
onClick={handleSaveSchedule}
className="flex-1 px-3 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium"
>
Save Schedule
</button>
</div>
</>
) : schedule && schedule.days.length > 0 ? (
<>
<p className="text-gray-700">
{formatDays(schedule.days)} at {schedule.time}
</p>
{schedule.remind && (
<p className="text-sm text-gray-500 mt-1">Reminders on</p>
)}
</>
) : (
<p className="text-gray-500 text-sm">Not scheduled. Click "Add schedule" to set a time.</p>
)}
</div>
</>
)}
@@ -523,6 +642,22 @@ export default function RoutineDetailPage() {
<p className="text-sm text-gray-500">{step.duration_minutes} min</p>
)}
</div>
<div className="flex flex-col">
<button
onClick={() => handleMoveStep(step.id, 'up')}
disabled={index === 0}
className="text-gray-400 p-1 disabled:opacity-30 hover:text-gray-600"
>
</button>
<button
onClick={() => handleMoveStep(step.id, 'down')}
disabled={index === steps.length - 1}
className="text-gray-400 p-1 disabled:opacity-30 hover:text-gray-600"
>
</button>
</div>
<button
onClick={() => handleDeleteStep(step.id)}
className="text-red-500 p-2"
@@ -534,6 +669,18 @@ export default function RoutineDetailPage() {
</div>
)}
</div>
{/* Bottom Save Button - shows when schedule editor is open */}
{showScheduleEditor && !isEditing && (
<div className="fixed bottom-4 left-4 right-4">
<button
onClick={handleSaveSchedule}
className="w-full bg-indigo-600 text-white font-semibold py-4 rounded-xl shadow-lg shadow-indigo-500/25"
>
Save Schedule
</button>
</div>
)}
</div>
</div>
);

View File

@@ -2,8 +2,9 @@
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import api from '@/lib/api';
import { ArrowLeftIcon, PlusIcon, TrashIcon, GripVerticalIcon } from '@/components/ui/Icons';
import { ArrowLeftIcon, PlusIcon, TrashIcon, GripVerticalIcon, CopyIcon } from '@/components/ui/Icons';
interface Step {
id: string;
@@ -12,7 +13,7 @@ interface Step {
position: number;
}
const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠'];
const ICONS = ['✨', '🌅', '🌙', '☀️', '💪', '🧘', '📚', '🍳', '🏃', '💼', '🎯', '⭐', '🔥', '💤', '🧠', '☕', '🍎', '💧', '🍀', '🎵', '📝', '🚴', '🏋️', '🚶', '👀', '🛡️', '😊', '😔'];
const STEP_TYPES = [
{ value: 'generic', label: 'Generic' },
@@ -22,6 +23,16 @@ const STEP_TYPES = [
{ value: 'exercise', label: 'Exercise' },
];
const DAY_OPTIONS = [
{ value: 'mon', label: 'Mon' },
{ value: 'tue', label: 'Tue' },
{ value: 'wed', label: 'Wed' },
{ value: 'thu', label: 'Thu' },
{ value: 'fri', label: 'Fri' },
{ value: 'sat', label: 'Sat' },
{ value: 'sun', label: 'Sun' },
];
export default function NewRoutinePage() {
const router = useRouter();
const [name, setName] = useState('');
@@ -31,6 +42,17 @@ export default function NewRoutinePage() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
// Schedule
const [scheduleDays, setScheduleDays] = useState<string[]>(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']);
const [scheduleTime, setScheduleTime] = useState('08:00');
const [scheduleRemind, setScheduleRemind] = useState(true);
const toggleDay = (day: string) => {
setScheduleDays(prev =>
prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day]
);
};
const handleAddStep = () => {
const newStep: Step = {
id: `temp-${Date.now()}`,
@@ -69,7 +91,7 @@ export default function NewRoutinePage() {
try {
const routine = await api.routines.create({ name, description, icon });
for (const step of validSteps) {
await api.routines.addStep(routine.id, {
name: step.name,
@@ -77,7 +99,15 @@ export default function NewRoutinePage() {
});
}
router.push('/dashboard/routines');
if (scheduleDays.length > 0) {
await api.routines.setSchedule(routine.id, {
days: scheduleDays,
time: scheduleTime,
remind: scheduleRemind,
});
}
router.push(`/dashboard/routines/${routine.id}?new=1`);
} catch (err) {
setError((err as Error).message || 'Failed to create routine');
} finally {
@@ -96,6 +126,22 @@ export default function NewRoutinePage() {
</div>
</header>
<Link
href="/dashboard/templates"
className="mx-4 mt-4 flex items-center gap-3 bg-gradient-to-r from-indigo-50 to-purple-50 border-2 border-indigo-200 rounded-xl p-4 hover:border-indigo-400 transition-colors"
>
<div className="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center flex-shrink-0">
<CopyIcon size={24} className="text-indigo-600" />
</div>
<div className="flex-1">
<p className="font-semibold text-gray-900">Start from a template</p>
<p className="text-sm text-gray-500">Browse pre-made routines</p>
</div>
<div className="bg-indigo-600 text-white text-xs font-medium px-2 py-1 rounded-full">
Recommended
</div>
</Link>
<form onSubmit={handleSubmit} className="p-4 space-y-6">
{error && (
<div className="bg-red-50 text-red-600 px-4 py-3 rounded-lg text-sm">
@@ -114,8 +160,8 @@ export default function NewRoutinePage() {
type="button"
onClick={() => setIcon(i)}
className={`w-10 h-10 rounded-lg text-xl flex items-center justify-center transition ${
icon === i
? 'bg-indigo-100 ring-2 ring-indigo-600'
icon === i
? 'bg-indigo-100 ring-2 ring-indigo-600'
: 'bg-gray-100 hover:bg-gray-200'
}`}
>
@@ -148,6 +194,90 @@ export default function NewRoutinePage() {
</div>
</div>
{/* Schedule */}
<div className="bg-white rounded-xl p-4 shadow-sm space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Schedule <span className="text-sm font-normal text-gray-400">(optional)</span></h2>
{/* Quick select buttons */}
<div className="flex gap-2">
<button
type="button"
onClick={() => setScheduleDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.length === 7 ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-700 border-gray-300'
}`}
>
Every day
</button>
<button
type="button"
onClick={() => setScheduleDays(['mon', 'tue', 'wed', 'thu', 'fri'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.length === 5 && !scheduleDays.includes('sat') && !scheduleDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-700 border-gray-300'
}`}
>
Weekdays
</button>
<button
type="button"
onClick={() => setScheduleDays(['sat', 'sun'])}
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.length === 2 && scheduleDays.includes('sat') && scheduleDays.includes('sun') ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-700 border-gray-300'
}`}
>
Weekends
</button>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Days</label>
<div className="flex gap-2 flex-wrap">
{DAY_OPTIONS.map((day) => (
<button
key={day.value}
type="button"
onClick={() => toggleDay(day.value)}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
scheduleDays.includes(day.value)
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white text-gray-700 border-gray-300'
}`}
>
{day.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Time</label>
<input
type="time"
value={scheduleTime}
onChange={(e) => setScheduleTime(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 outline-none"
/>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">Send reminder</p>
<p className="text-sm text-gray-500">Get notified when it's time</p>
</div>
<button
type="button"
onClick={() => setScheduleRemind(!scheduleRemind)}
className={`w-12 h-7 rounded-full transition-colors ${
scheduleRemind ? 'bg-indigo-500' : 'bg-gray-300'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
scheduleRemind ? 'translate-x-5' : ''
}`} />
</button>
</div>
</div>
{/* Steps */}
<div>
<div className="flex items-center justify-between mb-3">

View File

@@ -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>
);
}

View File

@@ -37,7 +37,7 @@ export default function TemplatesPage() {
setCloningId(templateId);
try {
const routine = await api.templates.clone(templateId);
router.push(`/dashboard/routines/${routine.id}`);
router.push(`/dashboard/routines/${routine.id}?new=1`);
} catch (err) {
console.error('Failed to clone template:', err);
setCloningId(null);