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

@@ -506,6 +506,34 @@ def register(app):
# ── Routine Scheduling ──────────────────────────────────────── # ── 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"]) @app.route("/api/routines/<routine_id>/schedule", methods=["PUT"])
def api_setRoutineSchedule(routine_id): def api_setRoutineSchedule(routine_id):
"""Set when this routine should run. Body: {days: ["mon","tue",...], time: "08:00", remind: true}""" """Set when this routine should run. Body: {days: ["mon","tue",...], time: "08:00", remind: true}"""

View File

@@ -1,9 +1,9 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState, useRef } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import api from '@/lib/api'; 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'; import Link from 'next/link';
interface Routine { interface Routine {
@@ -13,42 +13,135 @@ interface Routine {
icon?: string; 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() { export default function RoutinesPage() {
const router = useRouter(); 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 [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(() => { useEffect(() => {
const fetchRoutines = async () => { Promise.all([
try { api.routines.list(),
const data = await api.routines.list(); api.routines.listAllSchedules(),
setRoutines(data); ])
} catch (err) { .then(([routines, schedules]) => {
console.error('Failed to fetch routines:', err); setAllRoutines(routines);
} finally { setAllSchedules(schedules);
setIsLoading(false); })
} .catch(() => {})
}; .finally(() => setIsLoading(false));
fetchRoutines();
}, []); }, []);
const handleDelete = async (routineId: string) => { useEffect(() => {
try { const timer = setInterval(() => {
await api.routines.delete(routineId); const n = new Date();
setRoutines(routines.filter(r => r.id !== routineId)); setNowMinutes(n.getHours() * 60 + n.getMinutes());
setDeleteModal(null); }, 30_000);
} catch (err) { return () => clearInterval(timer);
console.error('Failed to delete routine:', err); }, []);
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) => { const handleStartRoutine = async (routineId: string) => {
try { try {
await api.sessions.start(routineId); await api.sessions.start(routineId);
router.push(`/dashboard/routines/${routineId}/run`); router.push(`/dashboard/routines/${routineId}/run`);
} catch (err) { } 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 ( return (
<div className="p-4 space-y-4"> <div className="flex flex-col h-full min-h-screen bg-gray-50">
<div className="flex items-center justify-between"> {/* 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> <h1 className="text-2xl font-bold text-gray-900">Routines</h1>
<Link <Link
href="/dashboard/routines/new" href="/dashboard/routines/new"
className="bg-indigo-600 text-white p-2 rounded-full" className="bg-indigo-600 text-white p-2 rounded-full"
> >
<PlusIcon size={24} /> <PlusIcon size={20} />
</Link> </Link>
</div> </div>
{routines.length === 0 ? ( {/* Week Strip */}
<div className="bg-white rounded-xl p-8 shadow-sm text-center mt-4"> <div className="flex bg-white px-2 pb-3 pt-2 gap-1 border-b border-gray-100">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4"> {weekDays.map((day, i) => {
<FlameIcon className="text-gray-400" size={32} /> 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 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> </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> {/* 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 <Link
href="/dashboard/routines/new" href="/dashboard/routines/new"
className="inline-block bg-indigo-600 text-white px-4 py-2 rounded-lg font-medium" className="bg-indigo-600 text-white px-6 py-3 rounded-xl font-medium"
> >
Create Routine Create Routine
</Link> </Link>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <>
{routines.map((routine) => ( {/* Timeline grid */}
<div <div
key={routine.id} className="relative"
className="bg-white rounded-xl p-4 shadow-sm" style={{ height: `${(END_HOUR - START_HOUR) * HOUR_HEIGHT}px` }}
> >
<div className="flex items-center gap-3"> {/* Hour lines */}
<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"> {Array.from({ length: END_HOUR - START_HOUR }, (_, i) => {
<span className="text-2xl">{routine.icon || '✨'}</span> const hour = START_HOUR + i;
</div> const label =
<div className="flex-1 min-w-0"> hour === 0 ? '12 AM' :
<h3 className="font-semibold text-gray-900 truncate">{routine.name}</h3> hour < 12 ? `${hour} AM` :
{routine.description && ( hour === 12 ? '12 PM' :
<p className="text-gray-500 text-sm truncate">{routine.description}</p> `${hour - 12} PM`;
)} return (
</div> <div
<div className="flex items-center gap-2"> key={hour}
<button className="absolute w-full flex items-start"
onClick={() => handleStartRoutine(routine.id)} style={{ top: `${i * HOUR_HEIGHT}px`, height: `${HOUR_HEIGHT}px` }}
className="bg-indigo-600 text-white p-2 rounded-full"
> >
<PlayIcon size={18} /> <span className="w-14 text-xs text-gray-400 pr-2 text-right flex-shrink-0 -mt-2">
</button> {label}
<Link </span>
href={`/dashboard/routines/${routine.id}`} <div className="flex-1 border-t border-gray-200 h-full" />
className="text-gray-500 p-2" </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' }}
> >
<EditIcon size={18} /> <div className="w-3 h-3 rounded-full bg-red-500 -ml-1.5 flex-shrink-0" />
</Link> <div className="flex-1 h-0.5 bg-red-500" />
<button
onClick={() => setDeleteModal(routine.id)}
className="text-red-500 p-2"
>
<TrashIcon size={18} />
</button>
</div>
</div>
</div>
))}
</div> </div>
)} )}
{/* Delete Modal */} {/* Routine cards */}
{deleteModal && ( {scheduledForDay.map((entry) => {
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> const startMin = timeToMinutes(entry.time);
<div className="bg-white rounded-2xl p-6 max-w-sm w-full"> const topPx = minutesToTop(startMin);
<h3 className="text-lg font-semibold text-gray-900 mb-2">Delete Routine?</h3> const heightPx = durationToHeight(entry.total_duration_minutes);
<p className="text-gray-500 mb-4">This action cannot be undone.</p> const endMin = startMin + entry.total_duration_minutes;
<div className="flex gap-3"> const isPast = isToday && nowMinutes > endMin;
const isCurrent = isToday && nowMinutes >= startMin && nowMinutes < endMin;
return (
<button <button
onClick={() => setDeleteModal(null)} key={entry.routine_id}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg font-medium" 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' : ''}`}
> >
Cancel <div className="flex items-center gap-2">
</button> <span className="text-lg leading-none flex-shrink-0">{entry.routine_icon || '✨'}</span>
<button <div className="min-w-0 flex-1">
onClick={() => handleDelete(deleteModal)} <p className="font-semibold text-gray-900 text-sm truncate">{entry.routine_name}</p>
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg font-medium" <p className="text-xs text-gray-500 truncate">
> {formatTime(entry.time)}
Delete {entry.total_duration_minutes > 0 && (
</button> <> - {formatTime(addMinutesToTime(entry.time, entry.total_duration_minutes))}</>
)}
{entry.total_duration_minutes > 0 && ` · ${entry.total_duration_minutes}m`}
</p>
</div> </div>
</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>
)} )}
</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>
</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 // History
getHistory: async (routineId: string, days = 7) => { getHistory: async (routineId: string, days = 7) => {
return request<Array<{ return request<Array<{