From 4d3a9fbd546253a20a2c3346e30c1da5ef3d2ad1 Mon Sep 17 00:00:00 2001 From: chelsea Date: Sat, 14 Feb 2026 04:35:40 -0600 Subject: [PATCH] lots of changes leave me alone its better now --- api/main.py | 2 + api/routes/medications.py | 93 ++++++- config/.env | 5 + config/.env.example | 5 + config/schema.sql | 11 + core/notifications.py | 67 ++++- docker-compose.yml | 1 + requirements.txt | 1 + scheduler/daemon.py | 6 +- synculous-client/public/sw.js | 45 ++++ .../src/app/dashboard/medications/page.tsx | 231 ++++++++++++++---- .../src/app/dashboard/routines/[id]/page.tsx | 2 +- synculous-client/src/lib/api.ts | 26 ++ 13 files changed, 438 insertions(+), 57 deletions(-) diff --git a/api/main.py b/api/main.py index 714b71b..63af706 100644 --- a/api/main.py +++ b/api/main.py @@ -17,6 +17,7 @@ import api.routes.routine_sessions_extended as routine_sessions_extended_routes import api.routes.routine_templates as routine_templates_routes import api.routes.routine_stats as routine_stats_routes import api.routes.routine_tags as routine_tags_routes +import api.routes.notifications as notifications_routes app = flask.Flask(__name__) CORS(app) @@ -29,6 +30,7 @@ ROUTE_MODULES = [ routine_templates_routes, routine_stats_routes, routine_tags_routes, + notifications_routes, ] diff --git a/api/routes/medications.py b/api/routes/medications.py index ce38406..d367b8c 100644 --- a/api/routes/medications.py +++ b/api/routes/medications.py @@ -41,6 +41,11 @@ def _wrap_json(data): return {k: Json(v) if k in _JSON_COLS and isinstance(v, (list, dict)) else v for k, v in data.items()} +def _filter_times_in_range(times, start, end): + """Return times (HH:MM strings) that fall within [start, end] inclusive.""" + return [t for t in times if start <= t <= end] + + def _is_med_due_today(med, today, current_day): """Check if a medication is scheduled for today based on its frequency.""" freq = med.get("frequency", "daily") @@ -306,7 +311,12 @@ def register(app): @app.route("/api/medications/today", methods=["GET"]) def api_todaysMeds(): - """Get today's medication schedule with taken/pending status.""" + """Get today's medication schedule with taken/pending status. + + Includes cross-midnight lookahead: + - After 22:00: includes next-day meds scheduled 00:00-02:00 (is_next_day) + - Before 02:00: includes previous-day meds scheduled 22:00-23:59 (is_previous_day) + """ user_uuid = _auth(flask.request) if not user_uuid: return flask.jsonify({"error": "unauthorized"}), 401 @@ -316,8 +326,12 @@ def register(app): today = date.today() today_str = today.isoformat() current_day = now.strftime("%a").lower() # "mon","tue", etc. + current_hour = now.hour result = [] + seen_med_ids = set() + + # Main pass: today's meds for med in meds: if not _is_med_due_today(med, today, current_day): continue @@ -325,7 +339,6 @@ def register(app): freq = med.get("frequency", "daily") is_prn = freq == "as_needed" - # Get today's taken times by filtering on created_at date all_logs = postgres.select( "med_logs", where={"medication_id": med["id"], "action": "taken"}, @@ -342,6 +355,82 @@ def register(app): "taken_times": today_taken, "is_prn": is_prn, }) + seen_med_ids.add(med["id"]) + + # Late night pass (22:00+): include next-day meds scheduled 00:00-02:00 + if current_hour >= 22: + tomorrow = today + timedelta(days=1) + tomorrow_str = tomorrow.isoformat() + tomorrow_day = (now + timedelta(days=1)).strftime("%a").lower() + for med in meds: + if med["id"] in seen_med_ids: + continue + if not _is_med_due_today(med, tomorrow, tomorrow_day): + continue + freq = med.get("frequency", "daily") + if freq == "as_needed": + continue + times = med.get("times", []) + early_times = _filter_times_in_range(times, "00:00", "02:00") + if not early_times: + continue + + all_logs = postgres.select( + "med_logs", + where={"medication_id": med["id"], "action": "taken"}, + ) + tomorrow_taken = [ + log.get("scheduled_time", "") + for log in all_logs + if str(log.get("created_at", ""))[:10] == tomorrow_str + ] + + result.append({ + "medication": med, + "scheduled_times": early_times, + "taken_times": tomorrow_taken, + "is_prn": False, + "is_next_day": True, + }) + seen_med_ids.add(med["id"]) + + # Early morning pass (<02:00): include previous-day meds scheduled 22:00-23:59 + if current_hour < 2: + yesterday = today - timedelta(days=1) + yesterday_str = yesterday.isoformat() + yesterday_day = (now - timedelta(days=1)).strftime("%a").lower() + for med in meds: + if med["id"] in seen_med_ids: + continue + if not _is_med_due_today(med, yesterday, yesterday_day): + continue + freq = med.get("frequency", "daily") + if freq == "as_needed": + continue + times = med.get("times", []) + late_times = _filter_times_in_range(times, "22:00", "23:59") + if not late_times: + continue + + all_logs = postgres.select( + "med_logs", + where={"medication_id": med["id"], "action": "taken"}, + ) + yesterday_taken = [ + log.get("scheduled_time", "") + for log in all_logs + if str(log.get("created_at", ""))[:10] == yesterday_str + ] + + result.append({ + "medication": med, + "scheduled_times": late_times, + "taken_times": yesterday_taken, + "is_prn": False, + "is_previous_day": True, + }) + seen_med_ids.add(med["id"]) + return flask.jsonify(result), 200 # ── Adherence Stats ─────────────────────────────────────────── diff --git a/config/.env b/config/.env index c203d3d..eaf254d 100644 --- a/config/.env +++ b/config/.env @@ -9,3 +9,8 @@ JWT_SECRET=bf773b4562221bef4d304ae5752a68931382ea3e98fe38394a098f73e0c776e1 OPENROUTER_API_KEY=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 OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 AI_CONFIG_PATH=/app/ai/ai_config.json + +# VAPID (Web Push) +VAPID_PUBLIC_KEY=BPFIPQFiOpJ4DUONdYCAGfbDcOxulVqYaf5ygDA5PxD41Mnp1Ctejg5mfusqHzmwYW8DSXUzzZU8f3lpRQOVQaA +VAPID_PRIVATE_KEY=cI32R20UjSVd8agIHI2Ci4seoUfLqplWLm909QmWDWo +VAPID_CLAIMS_EMAIL=mailto:admin@synculous.app diff --git a/config/.env.example b/config/.env.example index 7db5999..e48e53f 100644 --- a/config/.env.example +++ b/config/.env.example @@ -18,3 +18,8 @@ JWT_SECRET=your_jwt_secret_here OPENROUTER_API_KEY=your_openrouter_api_key_here OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 AI_CONFIG_PATH=/app/ai/ai_config.json + +# VAPID (Web Push) +VAPID_PUBLIC_KEY=your_vapid_public_key_here +VAPID_PRIVATE_KEY=your_vapid_private_key_here +VAPID_CLAIMS_EMAIL=mailto:admin@synculous.app diff --git a/config/schema.sql b/config/schema.sql index 7283ec0..b9ae59a 100644 --- a/config/schema.sql +++ b/config/schema.sql @@ -14,12 +14,23 @@ CREATE TABLE IF NOT EXISTS notifications ( discord_enabled BOOLEAN DEFAULT FALSE, ntfy_topic VARCHAR(255), ntfy_enabled BOOLEAN DEFAULT FALSE, + web_push_enabled BOOLEAN DEFAULT FALSE, last_message_sent TIMESTAMP, current_notification_status VARCHAR(50) DEFAULT 'inactive', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); +-- Push subscriptions for web push notifications +CREATE TABLE IF NOT EXISTS push_subscriptions ( + id UUID PRIMARY KEY, + user_uuid UUID REFERENCES users(id) ON DELETE CASCADE, + endpoint TEXT NOT NULL, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + -- ── Routines ──────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS routines ( diff --git a/core/notifications.py b/core/notifications.py index 91d0ede..3940128 100644 --- a/core/notifications.py +++ b/core/notifications.py @@ -1,16 +1,21 @@ """ notifications.py - Multi-channel notification routing -Supported channels: Discord webhook, ntfy +Supported channels: Discord webhook, ntfy, Web Push """ +import os import core.postgres as postgres import uuid import requests import time +import json +import logging + +logger = logging.getLogger(__name__) -def _sendToEnabledChannels(notif_settings, message): +def _sendToEnabledChannels(notif_settings, message, user_uuid=None): """Send message to all enabled channels. Returns True if at least one succeeded.""" sent = False @@ -22,6 +27,10 @@ def _sendToEnabledChannels(notif_settings, message): if ntfy.send(notif_settings["ntfy_topic"], message): sent = True + if notif_settings.get("web_push_enabled") and user_uuid: + if web_push.send_to_user(user_uuid, message): + sent = True + return sent @@ -39,6 +48,7 @@ def setNotificationSettings(userUUID, data_dict): "discord_enabled", "ntfy_topic", "ntfy_enabled", + "web_push_enabled", ] updates = {k: v for k, v in data_dict.items() if k in allowed} if not updates: @@ -72,3 +82,56 @@ class ntfy: return response.status_code == 200 except: return False + + +class web_push: + @staticmethod + def send_to_user(user_uuid, message): + """Send web push notification to all subscriptions for a user.""" + try: + from pywebpush import webpush, WebPushException + + vapid_private_key = os.environ.get("VAPID_PRIVATE_KEY") + vapid_claims_email = os.environ.get("VAPID_CLAIMS_EMAIL", "mailto:admin@synculous.app") + if not vapid_private_key: + logger.warning("VAPID_PRIVATE_KEY not set, skipping web push") + return False + + subscriptions = postgres.select("push_subscriptions", where={"user_uuid": user_uuid}) + if not subscriptions: + return False + + payload = json.dumps({"title": "Synculous", "body": message}) + sent_any = False + + for sub in subscriptions: + try: + webpush( + subscription_info={ + "endpoint": sub["endpoint"], + "keys": { + "p256dh": sub["p256dh"], + "auth": sub["auth"], + }, + }, + data=payload, + vapid_private_key=vapid_private_key, + vapid_claims={"sub": vapid_claims_email}, + ) + sent_any = True + except WebPushException as e: + # 410 Gone or 404 means subscription expired - clean up + if hasattr(e, "response") and e.response and e.response.status_code in (404, 410): + postgres.delete("push_subscriptions", {"id": sub["id"]}) + else: + logger.error(f"Web push failed for subscription {sub['id']}: {e}") + except Exception as e: + logger.error(f"Web push error: {e}") + + return sent_any + except ImportError: + logger.warning("pywebpush not installed, skipping web push") + return False + except Exception as e: + logger.error(f"Web push send_to_user error: {e}") + return False diff --git a/docker-compose.yml b/docker-compose.yml index 5e9654a..d3a70f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ services: volumes: - pgdata:/var/lib/postgresql/data - ./config/schema.sql:/docker-entrypoint-initdb.d/schema.sql + - ./config/seed_templates.sql:/docker-entrypoint-initdb.d/seed_templates.sql healthcheck: test: ["CMD-SHELL", "pg_isready -U app"] interval: 5s diff --git a/requirements.txt b/requirements.txt index 286d50b..a41e2c9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ openai>=1.0.0 requests>=2.31.0 pytest>=7.0.0 pytest-asyncio>=0.21.0 +pywebpush>=1.14.0 diff --git a/scheduler/daemon.py b/scheduler/daemon.py index e2118fd..5f99bf1 100644 --- a/scheduler/daemon.py +++ b/scheduler/daemon.py @@ -71,7 +71,7 @@ def check_medication_reminders(): user_settings = notifications.getNotificationSettings(med["user_uuid"]) if user_settings: msg = f"Time to take {med['name']} ({med['dosage']} {med['unit']})" - notifications._sendToEnabledChannels(user_settings, msg) + notifications._sendToEnabledChannels(user_settings, msg, user_uuid=med["user_uuid"]) except Exception as e: logger.error(f"Error checking medication reminders: {e}") @@ -98,7 +98,7 @@ def check_routine_reminders(): user_settings = notifications.getNotificationSettings(routine["user_uuid"]) if user_settings: msg = f"Time to start your routine: {routine['name']}" - notifications._sendToEnabledChannels(user_settings, msg) + notifications._sendToEnabledChannels(user_settings, msg, user_uuid=routine["user_uuid"]) except Exception as e: logger.error(f"Error checking routine reminders: {e}") @@ -113,7 +113,7 @@ def check_refills(): user_settings = notifications.getNotificationSettings(med["user_uuid"]) if user_settings: msg = f"Low on {med['name']}: only {qty} doses remaining. Time to refill!" - notifications._sendToEnabledChannels(user_settings, msg) + notifications._sendToEnabledChannels(user_settings, msg, user_uuid=med["user_uuid"]) except Exception as e: logger.error(f"Error checking refills: {e}") diff --git a/synculous-client/public/sw.js b/synculous-client/public/sw.js index ca8926f..56cb1b7 100644 --- a/synculous-client/public/sw.js +++ b/synculous-client/public/sw.js @@ -28,3 +28,48 @@ self.addEventListener('fetch', (event) => { .catch(() => caches.match(event.request)) ); }); + +// ── Web Push Notifications ────────────────────────────────── + +self.addEventListener('push', (event) => { + let data = { title: 'Synculous', body: 'You have a notification' }; + if (event.data) { + try { + data = event.data.json(); + } catch { + data.body = event.data.text(); + } + } + + event.waitUntil( + self.registration.showNotification(data.title || 'Synculous', { + body: data.body, + icon: '/icon-192.png', + badge: '/icon-192.png', + data: { url: '/dashboard/medications' }, + actions: [ + { action: 'open', title: 'Open' }, + { action: 'dismiss', title: 'Dismiss' }, + ], + }) + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + if (event.action === 'dismiss') return; + + const url = event.notification.data?.url || '/dashboard/medications'; + + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => { + for (const client of clients) { + if (client.url.includes('/dashboard') && 'focus' in client) { + return client.focus(); + } + } + return self.clients.openWindow(url); + }) + ); +}); diff --git a/synculous-client/src/app/dashboard/medications/page.tsx b/synculous-client/src/app/dashboard/medications/page.tsx index a6f16a1..cf901d1 100644 --- a/synculous-client/src/app/dashboard/medications/page.tsx +++ b/synculous-client/src/app/dashboard/medications/page.tsx @@ -1,10 +1,11 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useMemo } from 'react'; import { useRouter } from 'next/navigation'; import api from '@/lib/api'; import { PlusIcon, CheckIcon, PillIcon, ClockIcon, TrashIcon } from '@/components/ui/Icons'; import Link from 'next/link'; +import PushNotificationToggle from '@/components/notifications/PushNotificationToggle'; interface Medication { id: string; @@ -32,6 +33,8 @@ interface TodaysMedication { scheduled_times: string[]; taken_times: string[]; is_prn?: boolean; + is_next_day?: boolean; + is_previous_day?: boolean; } interface AdherenceEntry { @@ -41,6 +44,23 @@ interface AdherenceEntry { is_prn?: boolean; } +type TimeStatus = 'overdue' | 'due_now' | 'upcoming' | 'taken'; + +function getTimeStatus(scheduledTime: string, takenTimes: string[], now: Date): TimeStatus { + if (takenTimes.includes(scheduledTime)) return 'taken'; + + const [h, m] = scheduledTime.split(':').map(Number); + const scheduled = new Date(now); + scheduled.setHours(h, m, 0, 0); + + const diffMs = now.getTime() - scheduled.getTime(); + const diffMin = diffMs / 60000; + + if (diffMin > 15) return 'overdue'; + if (diffMin >= -15) return 'due_now'; + return 'upcoming'; +} + const formatSchedule = (med: Medication): string => { if (med.frequency === 'specific_days' && med.days_of_week?.length) { return med.days_of_week.map(d => d.charAt(0).toUpperCase() + d.slice(1)).join(', '); @@ -53,12 +73,19 @@ const formatSchedule = (med: Medication): string => { return 'Daily'; }; +interface DueEntry { + item: TodaysMedication; + time: string; + status: TimeStatus; +} + export default function MedicationsPage() { const router = useRouter(); const [medications, setMedications] = useState([]); const [todayMeds, setTodayMeds] = useState([]); const [adherence, setAdherence] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [tick, setTick] = useState(0); useEffect(() => { const fetchData = async () => { @@ -80,6 +107,51 @@ export default function MedicationsPage() { fetchData(); }, []); + // Auto-refresh grouping every 60s + useEffect(() => { + const interval = setInterval(() => setTick(t => t + 1), 60000); + return () => clearInterval(interval); + }, []); + + const { dueEntries, upcomingEntries, prnEntries } = useMemo(() => { + const now = new Date(); + const due: DueEntry[] = []; + const upcoming: DueEntry[] = []; + const prn: TodaysMedication[] = []; + + for (const item of todayMeds) { + if (item.is_prn) { + prn.push(item); + continue; + } + + // Next-day meds are always upcoming + if (item.is_next_day) { + for (const time of item.scheduled_times) { + upcoming.push({ item, time, status: 'upcoming' }); + } + continue; + } + + for (const time of item.scheduled_times) { + const status = getTimeStatus(time, item.taken_times, now); + if (status === 'upcoming') { + upcoming.push({ item, time, status }); + } else { + due.push({ item, time, status }); + } + } + } + + // Sort due: overdue first, then due_now, then by time + const statusOrder: Record = { overdue: 0, due_now: 1, taken: 2, upcoming: 3 }; + due.sort((a, b) => statusOrder[a.status] - statusOrder[b.status] || a.time.localeCompare(b.time)); + upcoming.sort((a, b) => a.time.localeCompare(b.time)); + + return { dueEntries: due, upcomingEntries: upcoming, prnEntries: prn }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [todayMeds, tick]); + const handleTake = async (medId: string, time?: string) => { try { await api.medications.take(medId, time); @@ -121,6 +193,13 @@ export default function MedicationsPage() { ); } + const borderColor = (status: TimeStatus) => { + if (status === 'overdue') return 'border-l-4 border-l-red-500'; + if (status === 'due_now') return 'border-l-4 border-l-amber-500'; + if (status === 'taken') return 'border-l-4 border-l-green-500'; + return ''; + }; + return (
@@ -130,63 +209,117 @@ export default function MedicationsPage() {
- {/* Today's Schedule */} - {todayMeds.length > 0 && ( + {/* Push Notification Toggle */} + + + {/* Due Now Section */} + {dueEntries.length > 0 && (
-

Today

+

Due

- {todayMeds.map((item) => ( + {dueEntries.map((entry) => ( +
+
+
+
+

{entry.item.medication.name}

+ {entry.item.is_previous_day && ( + Yesterday + )} +
+

{entry.item.medication.dosage} {entry.item.medication.unit}

+
+
+
+
+ + {entry.time} + {entry.status === 'overdue' && ( + Overdue + )} +
+ {entry.status === 'taken' ? ( + + Taken + + ) : ( +
+ + +
+ )} +
+
+ ))} +
+
+ )} + + {/* PRN Section */} + {prnEntries.length > 0 && ( +
+

As Needed

+
+ {prnEntries.map((item) => (
-
+

{item.medication.name}

{item.medication.dosage} {item.medication.unit}

-
- {item.is_prn ? ( -
- As needed - +
+ As needed + +
+
+ ))} +
+
+ )} + + {/* Upcoming Section */} + {upcomingEntries.length > 0 && ( +
+

Upcoming

+
+ {upcomingEntries.map((entry) => ( +
+
+
+
+

{entry.item.medication.name}

+ {entry.item.is_next_day && ( + Tomorrow + )}
- ) : ( - item.scheduled_times.map((time) => { - const isTaken = item.taken_times.includes(time); - return ( -
-
- - {time} -
- {isTaken ? ( - - Taken - - ) : ( -
- - -
- )} -
- ); - }) - )} +

{entry.item.medication.dosage} {entry.item.medication.unit}

+
+
+ + {entry.time} +
))} diff --git a/synculous-client/src/app/dashboard/routines/[id]/page.tsx b/synculous-client/src/app/dashboard/routines/[id]/page.tsx index a3c31a4..33c2fe3 100644 --- a/synculous-client/src/app/dashboard/routines/[id]/page.tsx +++ b/synculous-client/src/app/dashboard/routines/[id]/page.tsx @@ -88,7 +88,7 @@ export default function RoutineDetailPage() { name: newStepName, duration_minutes: newStepDuration, }); - setSteps([...steps, { ...step, position: steps.length + 1 }]); + setSteps([...steps, { ...step, name: newStepName, step_type: 'generic', position: steps.length + 1 }]); setNewStepName(''); } catch (err) { console.error('Failed to add step:', err); diff --git a/synculous-client/src/lib/api.ts b/synculous-client/src/lib/api.ts index 4cef7fa..e994b06 100644 --- a/synculous-client/src/lib/api.ts +++ b/synculous-client/src/lib/api.ts @@ -546,6 +546,29 @@ export const api = { }, }, + // Notifications + notifications: { + getVapidPublicKey: async () => { + return request<{ public_key: string }>('/api/notifications/vapid-public-key', { + method: 'GET', + }); + }, + + subscribe: async (subscription: PushSubscriptionJSON) => { + return request<{ subscribed: boolean }>('/api/notifications/subscribe', { + method: 'POST', + body: JSON.stringify(subscription), + }); + }, + + unsubscribe: async (endpoint: string) => { + return request<{ unsubscribed: boolean }>('/api/notifications/subscribe', { + method: 'DELETE', + body: JSON.stringify({ endpoint }), + }); + }, + }, + // Medications medications: { list: async () => { @@ -653,6 +676,9 @@ export const api = { }; scheduled_times: string[]; taken_times: string[]; + is_prn?: boolean; + is_next_day?: boolean; + is_previous_day?: boolean; }>>('/api/medications/today', { method: 'GET' }); },