From ecb79af44eaf8cbb1e86c0cf1f3778d89ead61b4 Mon Sep 17 00:00:00 2001 From: chelsea Date: Thu, 19 Feb 2026 19:04:52 -0600 Subject: [PATCH] Fix bugs, add auto-refresh, quick-complete tasks, and every-N-day routines - Fix bot auth: merge duplicate on_ready handlers so session restore runs (#13) - Fix push notifications: pass Uint8Array directly as applicationServerKey (#6) - Show specific conflict reason on schedule save instead of generic error (#17) - Add inline checkmark button to complete tasks on routines timeline (#18) - Add visibility-change + 60s polling auto-refresh to routines, meds, tasks (#15) - Add every-N-day routine scheduling: schema, API, scheduler, and UI (#16) Co-Authored-By: Claude Opus 4.6 --- api/routes/routines.py | 54 +++--- bot/bot.py | 9 +- config/schema.sql | 10 +- scheduler/daemon.py | 27 ++- .../src/app/dashboard/medications/page.tsx | 44 +++-- .../src/app/dashboard/routines/[id]/page.tsx | 159 +++++++++++++----- .../src/app/dashboard/routines/page.tsx | 58 ++++++- .../src/app/dashboard/tasks/page.tsx | 13 ++ .../notifications/PushNotificationToggle.tsx | 2 +- synculous-client/src/lib/api.ts | 8 +- 10 files changed, 288 insertions(+), 96 deletions(-) diff --git a/api/routes/routines.py b/api/routes/routines.py index e1ca42e..bbddb8a 100644 --- a/api/routes/routines.py +++ b/api/routes/routines.py @@ -661,17 +661,20 @@ def register(app): 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, - } - ) + entry = { + "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, + "frequency": sched.get("frequency", "weekly"), + } + if sched.get("frequency") == "every_n_days": + entry["interval_days"] = sched.get("interval_days") + entry["start_date"] = str(sched.get("start_date")) if sched.get("start_date") else None + result.append(entry) return flask.jsonify(result), 200 def _get_routine_duration_minutes(routine_id): @@ -745,7 +748,10 @@ def register(app): @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}""" + """Set when this routine should run. + Body: {days, time, remind, frequency?, interval_days?, start_date?} + frequency: 'weekly' (default, uses days) or 'every_n_days' (uses interval_days + start_date) + """ user_uuid = _auth(flask.request) if not user_uuid: return flask.jsonify({"error": "unauthorized"}), 401 @@ -758,15 +764,18 @@ def register(app): if not data: return flask.jsonify({"error": "missing body"}), 400 - # Check for schedule conflicts - new_days = data.get("days", []) - new_time = data.get("time") - has_conflict, conflict_msg = _check_schedule_conflicts( - user_uuid, new_days, new_time, exclude_routine_id=routine_id, - new_routine_id=routine_id, - ) - if has_conflict: - return flask.jsonify({"error": conflict_msg}), 409 + frequency = data.get("frequency", "weekly") + + # Check for schedule conflicts (only for weekly โ€” interval conflicts checked at reminder time) + if frequency == "weekly": + new_days = data.get("days", []) + new_time = data.get("time") + has_conflict, conflict_msg = _check_schedule_conflicts( + user_uuid, new_days, new_time, exclude_routine_id=routine_id, + new_routine_id=routine_id, + ) + if has_conflict: + return flask.jsonify({"error": conflict_msg}), 409 existing = postgres.select_one("routine_schedules", {"routine_id": routine_id}) schedule_data = { @@ -774,6 +783,9 @@ def register(app): "days": json.dumps(data.get("days", [])), "time": data.get("time"), "remind": data.get("remind", True), + "frequency": frequency, + "interval_days": data.get("interval_days"), + "start_date": data.get("start_date"), } if existing: result = postgres.update( diff --git a/bot/bot.py b/bot/bot.py index 7a858e2..0f231d5 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -663,14 +663,6 @@ def _restore_sessions_from_cache(): print(f"Restored {restored} user session(s) from cache") -@client.event -async def on_ready(): - print(f"Bot logged in as {client.user}") - loadCache() - _restore_sessions_from_cache() - backgroundLoop.start() - - @client.event async def on_message(message): if message.author == client.user: @@ -866,6 +858,7 @@ async def on_ready(): print(f"Bot logged in as {client.user}", flush=True) print(f"Connected to {len(client.guilds)} guilds", flush=True) loadCache() + _restore_sessions_from_cache() backgroundLoop.start() presenceTrackingLoop.start() print(f"[DEBUG] Presence tracking loop started", flush=True) diff --git a/config/schema.sql b/config/schema.sql index 8d632db..21831c7 100644 --- a/config/schema.sql +++ b/config/schema.sql @@ -74,7 +74,10 @@ CREATE TABLE IF NOT EXISTS routine_schedules ( routine_id UUID REFERENCES routines(id) ON DELETE CASCADE, days JSON DEFAULT '[]', time VARCHAR(5), - remind BOOLEAN DEFAULT FALSE + remind BOOLEAN DEFAULT FALSE, + frequency VARCHAR(20) DEFAULT 'weekly', + interval_days INTEGER, + start_date DATE ); CREATE TABLE IF NOT EXISTS routine_session_notes ( @@ -314,3 +317,8 @@ CREATE TABLE IF NOT EXISTS tasks ( ); CREATE INDEX IF NOT EXISTS idx_tasks_user_scheduled ON tasks(user_uuid, scheduled_datetime); CREATE INDEX IF NOT EXISTS idx_tasks_pending ON tasks(status) WHERE status = 'pending'; + +-- Add every-N-day scheduling to routine_schedules (run once on existing DBs) +ALTER TABLE routine_schedules ADD COLUMN IF NOT EXISTS frequency VARCHAR(20) DEFAULT 'weekly'; +ALTER TABLE routine_schedules ADD COLUMN IF NOT EXISTS interval_days INTEGER; +ALTER TABLE routine_schedules ADD COLUMN IF NOT EXISTS start_date DATE; diff --git a/scheduler/daemon.py b/scheduler/daemon.py index b87119b..7d5a57d 100644 --- a/scheduler/daemon.py +++ b/scheduler/daemon.py @@ -108,6 +108,8 @@ def check_medication_reminders(): def check_routine_reminders(): """Check for scheduled routines due now and send notifications.""" try: + from datetime import date as date_type + schedules = postgres.select("routine_schedules", where={"remind": True}) for schedule in schedules: @@ -117,13 +119,30 @@ def check_routine_reminders(): now = _user_now_for(routine["user_uuid"]) current_time = now.strftime("%H:%M") - current_day = now.strftime("%a").lower() + today = now.date() if current_time != schedule.get("time"): continue - days = schedule.get("days", []) - if current_day not in days: - continue + + frequency = schedule.get("frequency", "weekly") + if frequency == "every_n_days": + start = schedule.get("start_date") + interval = schedule.get("interval_days") + if start and interval: + start_d = ( + start + if isinstance(start, date_type) + else datetime.strptime(str(start), "%Y-%m-%d").date() + ) + if (today - start_d).days < 0 or (today - start_d).days % interval != 0: + continue + else: + continue + else: + current_day = now.strftime("%a").lower() + days = schedule.get("days", []) + if current_day not in days: + continue user_settings = notifications.getNotificationSettings(routine["user_uuid"]) if user_settings: diff --git a/synculous-client/src/app/dashboard/medications/page.tsx b/synculous-client/src/app/dashboard/medications/page.tsx index 8f76a8a..a390d5b 100644 --- a/synculous-client/src/app/dashboard/medications/page.tsx +++ b/synculous-client/src/app/dashboard/medications/page.tsx @@ -91,24 +91,36 @@ export default function MedicationsPage() { const [isLoading, setIsLoading] = useState(true); const [tick, setTick] = useState(0); + const fetchData = async () => { + try { + const [medsData, todayData, adherenceData] = await Promise.all([ + api.medications.list(), + api.medications.getToday().catch(() => []), + api.medications.getAdherence(30).catch(() => []), + ]); + setMedications(medsData); + setTodayMeds(todayData); + setAdherence(adherenceData); + } catch (err) { + console.error('Failed to fetch medications:', err); + } + }; + useEffect(() => { - const fetchData = async () => { - try { - const [medsData, todayData, adherenceData] = await Promise.all([ - api.medications.list(), - api.medications.getToday().catch(() => []), - api.medications.getAdherence(30).catch(() => []), - ]); - setMedications(medsData); - setTodayMeds(todayData); - setAdherence(adherenceData); - } catch (err) { - console.error('Failed to fetch medications:', err); - } finally { - setIsLoading(false); - } + fetchData().finally(() => setIsLoading(false)); + }, []); + + // Re-fetch when tab becomes visible or every 60s + useEffect(() => { + const onVisible = () => { + if (document.visibilityState === 'visible') fetchData(); + }; + document.addEventListener('visibilitychange', onVisible); + const poll = setInterval(fetchData, 60_000); + return () => { + document.removeEventListener('visibilitychange', onVisible); + clearInterval(poll); }; - fetchData(); }, []); // Auto-refresh grouping every 60s diff --git a/synculous-client/src/app/dashboard/routines/[id]/page.tsx b/synculous-client/src/app/dashboard/routines/[id]/page.tsx index a95fd6b..ea224d0 100644 --- a/synculous-client/src/app/dashboard/routines/[id]/page.tsx +++ b/synculous-client/src/app/dashboard/routines/[id]/page.tsx @@ -29,6 +29,9 @@ interface Schedule { days: string[]; time: string; remind: boolean; + frequency?: string; + interval_days?: number; + start_date?: string; } const ICONS = ['โœจ', '๐ŸŒ…', '๐ŸŒ™', 'โ˜€๏ธ', '๐Ÿ’ช', '๐Ÿง˜', '๐Ÿ“š', '๐Ÿณ', '๐Ÿƒ', '๐Ÿ’ผ', '๐ŸŽฏ', 'โญ', '๐Ÿ”ฅ', '๐Ÿ’ค', '๐Ÿง ', 'โ˜•', '๐ŸŽ', '๐Ÿ’ง', '๐Ÿ€', '๐ŸŽต', '๐Ÿ“', '๐Ÿšด', '๐Ÿ‹๏ธ', '๐Ÿšถ', '๐Ÿ‘€', '๐Ÿ›ก๏ธ', '๐Ÿ˜Š', '๐Ÿ˜”']; @@ -77,6 +80,9 @@ export default function RoutineDetailPage() { const [editDays, setEditDays] = useState(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']); const [editTime, setEditTime] = useState('08:00'); const [editRemind, setEditRemind] = useState(true); + const [editFrequency, setEditFrequency] = useState<'weekly' | 'every_n_days'>('weekly'); + const [editIntervalDays, setEditIntervalDays] = useState(2); + const [editStartDate, setEditStartDate] = useState(() => new Date().toISOString().split('T')[0]); const [showScheduleEditor, setShowScheduleEditor] = useState(false); useEffect(() => { @@ -99,6 +105,9 @@ export default function RoutineDetailPage() { setEditDays(scheduleData.days || []); setEditTime(scheduleData.time || '08:00'); setEditRemind(scheduleData.remind ?? true); + setEditFrequency((scheduleData.frequency as 'weekly' | 'every_n_days') || 'weekly'); + setEditIntervalDays(scheduleData.interval_days || 2); + setEditStartDate(scheduleData.start_date || new Date().toISOString().split('T')[0]); } else { setEditDays(['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']); if (isNewRoutine) { @@ -162,13 +171,27 @@ export default function RoutineDetailPage() { const handleSaveSchedule = async () => { try { - if (editDays.length > 0) { - await api.routines.setSchedule(routineId, { + const hasSchedule = editFrequency === 'every_n_days' || editDays.length > 0; + if (hasSchedule) { + const schedulePayload = { days: editDays, time: editTime || '08:00', remind: editRemind, + frequency: editFrequency, + ...(editFrequency === 'every_n_days' && { + interval_days: editIntervalDays, + start_date: editStartDate, + }), + }; + await api.routines.setSchedule(routineId, schedulePayload); + setSchedule({ + days: editDays, + time: editTime || '08:00', + remind: editRemind, + frequency: editFrequency, + interval_days: editFrequency === 'every_n_days' ? editIntervalDays : undefined, + start_date: editFrequency === 'every_n_days' ? editStartDate : undefined, }); - setSchedule({ days: editDays, time: editTime || '08:00', remind: editRemind }); } else if (schedule) { await api.routines.deleteSchedule(routineId); setSchedule(null); @@ -176,7 +199,7 @@ export default function RoutineDetailPage() { setShowScheduleEditor(false); } catch (err) { console.error('Failed to save schedule:', err); - alert('Failed to save schedule. Please try again.'); + alert((err as Error).message || 'Failed to save schedule. Please try again.'); } }; @@ -462,56 +485,108 @@ export default function RoutineDetailPage() { {showScheduleEditor ? ( <> - {/* Quick select */} + {/* Frequency selector */}
-
- -
- -
- {DAY_OPTIONS.map((day) => ( + + {editFrequency === 'every_n_days' ? ( +
+
+ +
+ setEditIntervalDays(Math.max(2, Number(e.target.value)))} + className="w-20 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none" + /> + days +
+
+
+ + setEditStartDate(e.target.value)} + className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-indigo-500 outline-none" + /> +
+
+ ) : ( + <> + {/* Quick select */} +
- ))} -
-
+ + +
+ +
+ +
+ {DAY_OPTIONS.map((day) => ( + + ))} +
+
+ + )}
- ) : schedule && schedule.days.length > 0 ? ( + ) : schedule && (schedule.days.length > 0 || schedule.frequency === 'every_n_days') ? ( <>

- {formatDays(schedule.days)} at {schedule.time} + {schedule.frequency === 'every_n_days' + ? `Every ${schedule.interval_days} days at ${schedule.time}` + : `${formatDays(schedule.days)} at ${schedule.time}`}

{schedule.remind && (

Reminders on

diff --git a/synculous-client/src/app/dashboard/routines/page.tsx b/synculous-client/src/app/dashboard/routines/page.tsx index ebedab5..4a92e0b 100644 --- a/synculous-client/src/app/dashboard/routines/page.tsx +++ b/synculous-client/src/app/dashboard/routines/page.tsx @@ -21,6 +21,9 @@ interface ScheduleEntry { time: string; remind: boolean; total_duration_minutes: number; + frequency?: string; + interval_days?: number; + start_date?: string; } interface TodaysMedication { @@ -230,7 +233,15 @@ export default function RoutinesPage() { const dayKey = getDayKey(selectedDate); const scheduledForDay = allSchedules - .filter((s) => s.days.includes(dayKey)) + .filter((s) => { + if (s.frequency === 'every_n_days') { + if (!s.interval_days || !s.start_date) return false; + const start = new Date(s.start_date + 'T00:00:00'); + const diffDays = Math.round((selectedDate.getTime() - start.getTime()) / 86400000); + return diffDays >= 0 && diffDays % s.interval_days === 0; + } + return s.days.includes(dayKey); + }) .sort((a, b) => timeToMinutes(a.time) - timeToMinutes(b.time)); const tasksForDay = allTasks.filter((t) => { @@ -433,7 +444,7 @@ export default function RoutinesPage() { setUndoAction(null); }; - useEffect(() => { + const fetchAllData = () => Promise.all([ api.routines.list(), api.routines.listAllSchedules(), @@ -446,8 +457,10 @@ export default function RoutinesPage() { setTodayMeds(meds); setAllTasks(tasks); }) - .catch(() => {}) - .finally(() => setIsLoading(false)); + .catch(() => {}); + + useEffect(() => { + fetchAllData().finally(() => setIsLoading(false)); }, []); useEffect(() => { @@ -459,6 +472,19 @@ export default function RoutinesPage() { return () => clearInterval(timer); }, []); + // Re-fetch when tab becomes visible or every 60s + useEffect(() => { + const onVisible = () => { + if (document.visibilityState === 'visible') fetchAllData(); + }; + document.addEventListener('visibilitychange', onVisible); + const poll = setInterval(fetchAllData, 60_000); + return () => { + document.removeEventListener('visibilitychange', onVisible); + clearInterval(poll); + }; + }, []); + useEffect(() => { if (!isLoading && isToday && timelineRef.current) { const scrollTarget = nowTopPx - window.innerHeight / 3; @@ -471,6 +497,18 @@ export default function RoutinesPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLoading, isToday]); + const handleCompleteTask = async (taskId: string) => { + try { + await api.tasks.update(taskId, { status: 'completed' }); + setAllTasks((prev) => + prev.map((t) => (t.id === taskId ? { ...t, status: 'completed' } : t)) + ); + } catch (err) { + console.error('Failed to complete task:', err); + setError(err instanceof Error ? err.message : 'Failed to complete task'); + } + }; + const handleStartRoutine = async (routineId: string) => { try { await api.sessions.start(routineId); @@ -838,10 +876,20 @@ export default function RoutinesPage() { {task.description && ` ยท ${task.description}`}

- {isPast && ( + {isPast ? ( + ) : ( + )} diff --git a/synculous-client/src/app/dashboard/tasks/page.tsx b/synculous-client/src/app/dashboard/tasks/page.tsx index fec2ade..b1150f0 100644 --- a/synculous-client/src/app/dashboard/tasks/page.tsx +++ b/synculous-client/src/app/dashboard/tasks/page.tsx @@ -53,6 +53,19 @@ export default function TasksPage() { loadTasks(showCompleted ? 'all' : 'pending'); }, [showCompleted]); + // Re-fetch when tab becomes visible or every 60s + useEffect(() => { + const onVisible = () => { + if (document.visibilityState === 'visible') loadTasks(showCompleted ? 'all' : 'pending'); + }; + document.addEventListener('visibilitychange', onVisible); + const poll = setInterval(() => loadTasks(showCompleted ? 'all' : 'pending'), 60_000); + return () => { + document.removeEventListener('visibilitychange', onVisible); + clearInterval(poll); + }; + }, [showCompleted]); + const handleMarkDone = async (task: Task) => { try { await api.tasks.update(task.id, { status: 'completed' }); diff --git a/synculous-client/src/components/notifications/PushNotificationToggle.tsx b/synculous-client/src/components/notifications/PushNotificationToggle.tsx index a87d286..471ce9d 100644 --- a/synculous-client/src/components/notifications/PushNotificationToggle.tsx +++ b/synculous-client/src/components/notifications/PushNotificationToggle.tsx @@ -65,7 +65,7 @@ export default function PushNotificationToggle() { const { public_key } = await api.notifications.getVapidPublicKey(); const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(public_key).buffer as ArrayBuffer, + applicationServerKey: urlBase64ToUint8Array(public_key) as BufferSource, }); const subJson = sub.toJSON(); diff --git a/synculous-client/src/lib/api.ts b/synculous-client/src/lib/api.ts index 529953e..c46edff 100644 --- a/synculous-client/src/lib/api.ts +++ b/synculous-client/src/lib/api.ts @@ -323,12 +323,15 @@ export const api = { days: string[]; time: string; remind: boolean; + frequency?: string; + interval_days?: number; + start_date?: string; }>(`/api/routines/${routineId}/schedule`, { method: 'GET' }); }, setSchedule: async ( routineId: string, - data: { days: string[]; time: string; remind?: boolean } + data: { days: string[]; time: string; remind?: boolean; frequency?: string; interval_days?: number; start_date?: string } ) => { return request<{ id: string }>(`/api/routines/${routineId}/schedule`, { method: 'PUT', @@ -352,6 +355,9 @@ export const api = { time: string; remind: boolean; total_duration_minutes: number; + frequency?: string; + interval_days?: number; + start_date?: string; }>>('/api/routines/schedules', { method: 'GET' }); },