diff --git a/api/routes/routines.py b/api/routes/routines.py index 1a1fe97..07c2564 100644 --- a/api/routes/routines.py +++ b/api/routes/routines.py @@ -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//schedule", methods=["PUT"]) def api_setRoutineSchedule(routine_id): """Set when this routine should run. Body: {days: ["mon","tue",...], time: "08:00", remind: true}""" diff --git a/synculous-client/src/app/dashboard/routines/page.tsx b/synculous-client/src/app/dashboard/routines/page.tsx index 8a64f32..598c3d6 100644 --- a/synculous-client/src/app/dashboard/routines/page.tsx +++ b/synculous-client/src/app/dashboard/routines/page.tsx @@ -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([]); + const timelineRef = useRef(null); + + const [allRoutines, setAllRoutines] = useState([]); + const [allSchedules, setAllSchedules] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [deleteModal, setDeleteModal] = useState(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 ( -
-
+
+ {/* Header */} +

Routines

- +
- {routines.length === 0 ? ( -
-
- -
-

No routines yet

-

Create your first routine to get started

- - Create Routine - -
- ) : ( -
- {routines.map((routine) => ( -
+ {weekDays.map((day, i) => { + const selected = isSameDay(day, selectedDate); + const isTodayDay = isSameDay(day, today); + return ( + + ); + })} +
+ + {/* Timeline */} +
+ {allRoutines.length === 0 ? ( +
+
+ +
+

No routines yet

+ + Create Routine + +
+ ) : ( + <> + {/* Timeline grid */} +
+ {/* 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 ( +
+ + {label} + +
+
+ ); + })} + + {/* Now indicator */} + {isToday && nowTopPx >= 0 && nowTopPx <= (END_HOUR - START_HOUR) * HOUR_HEIGHT && ( +
+
+
-
-

{routine.name}

- {routine.description && ( -

{routine.description}

- )} + )} + + {/* 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 ( + + ); + })} + + {/* Empty day */} + {scheduledForDay.length === 0 && ( +
+

No routines scheduled for this day

-
- - - - - + )} +
+ + {/* Unscheduled routines */} + {unscheduledRoutines.length > 0 && ( +
+

+ Unscheduled +

+
+ {unscheduledRoutines.map((r) => ( +
+ {r.icon || '✨'} +
+

{r.name}

+ {r.description && ( +

{r.description}

+ )} +
+ + + Edit + +
+ ))}
-
- ))} -
- )} - - {/* Delete Modal */} - {deleteModal && ( -
-
-

Delete Routine?

-

This action cannot be undone.

-
- - -
-
-
- )} + )} + + )} +
); } diff --git a/synculous-client/src/lib/api.ts b/synculous-client/src/lib/api.ts index bd189d8..a81cb78 100644 --- a/synculous-client/src/lib/api.ts +++ b/synculous-client/src/lib/api.ts @@ -265,6 +265,18 @@ export const api = { ); }, + listAllSchedules: async () => { + return request>('/api/routines/schedules', { method: 'GET' }); + }, + // History getHistory: async (routineId: string, days = 7) => { return request