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:
@@ -506,6 +506,34 @@ def register(app):
|
||||
|
||||
# ── Routine Scheduling ────────────────────────────────────────
|
||||
|
||||
@app.route("/api/routines/schedules", methods=["GET"])
|
||||
def api_listAllSchedules():
|
||||
"""Get all schedules for the user's routines with routine metadata."""
|
||||
user_uuid = _auth(flask.request)
|
||||
if not user_uuid:
|
||||
return flask.jsonify({"error": "unauthorized"}), 401
|
||||
routines = postgres.select("routines", where={"user_uuid": user_uuid})
|
||||
if not routines:
|
||||
return flask.jsonify([]), 200
|
||||
|
||||
result = []
|
||||
for r in routines:
|
||||
sched = postgres.select_one("routine_schedules", {"routine_id": r["id"]})
|
||||
if not sched:
|
||||
continue
|
||||
steps = postgres.select("routine_steps", where={"routine_id": r["id"]})
|
||||
total_duration = sum(s.get("duration_minutes") or 0 for s in steps)
|
||||
result.append({
|
||||
"routine_id": r["id"],
|
||||
"routine_name": r.get("name", ""),
|
||||
"routine_icon": r.get("icon", ""),
|
||||
"days": sched.get("days", []),
|
||||
"time": sched.get("time"),
|
||||
"remind": sched.get("remind", True),
|
||||
"total_duration_minutes": total_duration,
|
||||
})
|
||||
return flask.jsonify(result), 200
|
||||
|
||||
@app.route("/api/routines/<routine_id>/schedule", methods=["PUT"])
|
||||
def api_setRoutineSchedule(routine_id):
|
||||
"""Set when this routine should run. Body: {days: ["mon","tue",...], time: "08:00", remind: true}"""
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<{
|
||||
|
||||
Reference in New Issue
Block a user