Add Brili-style scheduled routines timeline view

Replaces the flat routine card list with a day-oriented timeline showing
scheduled routines at their time slots, with week strip navigation and
a live "now" indicator. Adds bulk schedules API endpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 18:34:03 -06:00
parent bb01f0213f
commit 749f734aff
3 changed files with 330 additions and 102 deletions

View File

@@ -1,9 +1,9 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
import api from '@/lib/api';
import { PlusIcon, PlayIcon, EditIcon, TrashIcon, FlameIcon } from '@/components/ui/Icons';
import { PlusIcon, PlayIcon, ClockIcon } from '@/components/ui/Icons';
import Link from 'next/link';
interface Routine {
@@ -13,42 +13,135 @@ interface Routine {
icon?: string;
}
interface ScheduleEntry {
routine_id: string;
routine_name: string;
routine_icon: string;
days: string[];
time: string;
remind: boolean;
total_duration_minutes: number;
}
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'];
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): number {
return ((minutes - START_HOUR * 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 getDayKey(date: Date): string {
const day = date.getDay();
return DAY_KEYS[day === 0 ? 6 : day - 1];
}
export default function RoutinesPage() {
const router = useRouter();
const [routines, setRoutines] = useState<Routine[]>([]);
const timelineRef = useRef<HTMLDivElement>(null);
const [allRoutines, setAllRoutines] = useState<Routine[]>([]);
const [allSchedules, setAllSchedules] = useState<ScheduleEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [deleteModal, setDeleteModal] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState(() => new Date());
const [nowMinutes, setNowMinutes] = useState(() => {
const n = new Date();
return n.getHours() * 60 + n.getMinutes();
});
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 nowTopPx = minutesToTop(nowMinutes);
useEffect(() => {
const fetchRoutines = async () => {
try {
const data = await api.routines.list();
setRoutines(data);
} catch (err) {
console.error('Failed to fetch routines:', err);
} finally {
setIsLoading(false);
}
};
fetchRoutines();
Promise.all([
api.routines.list(),
api.routines.listAllSchedules(),
])
.then(([routines, schedules]) => {
setAllRoutines(routines);
setAllSchedules(schedules);
})
.catch(() => {})
.finally(() => setIsLoading(false));
}, []);
const handleDelete = async (routineId: string) => {
try {
await api.routines.delete(routineId);
setRoutines(routines.filter(r => r.id !== routineId));
setDeleteModal(null);
} catch (err) {
console.error('Failed to delete routine:', err);
useEffect(() => {
const timer = setInterval(() => {
const n = new Date();
setNowMinutes(n.getHours() * 60 + n.getMinutes());
}, 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' });
}
};
}, [isLoading, isToday]);
const handleStartRoutine = async (routineId: string) => {
try {
await api.sessions.start(routineId);
router.push(`/dashboard/routines/${routineId}/run`);
} catch (err) {
console.error('Failed to start routine:', err);
const msg = (err as Error).message;
if (msg.includes('already have active session')) {
router.push(`/dashboard/routines/${routineId}/run`);
}
}
};
@@ -61,97 +154,192 @@ export default function RoutinesPage() {
}
return (
<div className="p-4 space-y-4">
<div className="flex items-center justify-between">
<div className="flex flex-col h-full min-h-screen bg-gray-50">
{/* Header */}
<div className="flex items-center justify-between px-4 pt-4 pb-2 bg-white border-b border-gray-100">
<h1 className="text-2xl font-bold text-gray-900">Routines</h1>
<Link
href="/dashboard/routines/new"
className="bg-indigo-600 text-white p-2 rounded-full"
>
<PlusIcon size={24} />
<PlusIcon size={20} />
</Link>
</div>
{routines.length === 0 ? (
<div className="bg-white rounded-xl p-8 shadow-sm text-center mt-4">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<FlameIcon className="text-gray-400" size={32} />
</div>
<h3 className="font-semibold text-gray-900 mb-1">No routines yet</h3>
<p className="text-gray-500 text-sm mb-4">Create your first routine to get started</p>
<Link
href="/dashboard/routines/new"
className="inline-block bg-indigo-600 text-white px-4 py-2 rounded-lg font-medium"
>
Create Routine
</Link>
</div>
) : (
<div className="space-y-3">
{routines.map((routine) => (
<div
key={routine.id}
className="bg-white rounded-xl p-4 shadow-sm"
{/* Week Strip */}
<div className="flex bg-white px-2 pb-3 pt-2 gap-1 border-b border-gray-100">
{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"
>
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-indigo-100 to-pink-100 rounded-xl flex items-center justify-center flex-shrink-0">
<span className="text-2xl">{routine.icon || '✨'}</span>
<span className="text-xs text-gray-500 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 text-gray-900'
: 'text-gray-700'
}`}
>
{day.getDate()}
</span>
</button>
);
})}
</div>
{/* Timeline */}
<div ref={timelineRef} className="flex-1 overflow-y-auto">
{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 rounded-full flex items-center justify-center mb-4">
<ClockIcon size={32} className="text-indigo-400" />
</div>
<p className="text-gray-500 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: `${(END_HOUR - START_HOUR) * HOUR_HEIGHT}px` }}
>
{/* Hour lines */}
{Array.from({ length: END_HOUR - START_HOUR }, (_, i) => {
const hour = START_HOUR + 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 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 */}
{isToday && nowTopPx >= 0 && nowTopPx <= (END_HOUR - START_HOUR) * HOUR_HEIGHT && (
<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>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{routine.name}</h3>
{routine.description && (
<p className="text-gray-500 text-sm truncate">{routine.description}</p>
)}
)}
{/* Routine cards */}
{scheduledForDay.map((entry) => {
const startMin = timeToMinutes(entry.time);
const topPx = minutesToTop(startMin);
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;
return (
<button
key={entry.routine_id}
onClick={() => router.push(`/dashboard/routines/${entry.routine_id}`)}
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 ${
isPast
? 'bg-green-50 border-green-200 opacity-75'
: 'bg-indigo-50 border-indigo-200'
} ${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 text-sm truncate">{entry.routine_name}</p>
<p className="text-xs text-gray-500 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>
);
})}
{/* Empty day */}
{scheduledForDay.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>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleStartRoutine(routine.id)}
className="bg-indigo-600 text-white p-2 rounded-full"
>
<PlayIcon size={18} />
</button>
<Link
href={`/dashboard/routines/${routine.id}`}
className="text-gray-500 p-2"
>
<EditIcon size={18} />
</Link>
<button
onClick={() => setDeleteModal(routine.id)}
className="text-red-500 p-2"
>
<TrashIcon size={18} />
</button>
)}
</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>
))}
</div>
)}
{/* Delete Modal */}
{deleteModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 max-w-sm w-full">
<h3 className="text-lg font-semibold text-gray-900 mb-2">Delete Routine?</h3>
<p className="text-gray-500 mb-4">This action cannot be undone.</p>
<div className="flex gap-3">
<button
onClick={() => setDeleteModal(null)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg font-medium"
>
Cancel
</button>
<button
onClick={() => handleDelete(deleteModal)}
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium"
>
Delete
</button>
</div>
</div>
</div>
)}
)}
</>
)}
</div>
</div>
);
}

View File

@@ -265,6 +265,18 @@ export const api = {
);
},
listAllSchedules: async () => {
return request<Array<{
routine_id: string;
routine_name: string;
routine_icon: string;
days: string[];
time: string;
remind: boolean;
total_duration_minutes: number;
}>>('/api/routines/schedules', { method: 'GET' });
},
// History
getHistory: async (routineId: string, days = 7) => {
return request<Array<{