lots of changes leave me alone its better now
This commit is contained in:
@@ -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_templates as routine_templates_routes
|
||||||
import api.routes.routine_stats as routine_stats_routes
|
import api.routes.routine_stats as routine_stats_routes
|
||||||
import api.routes.routine_tags as routine_tags_routes
|
import api.routes.routine_tags as routine_tags_routes
|
||||||
|
import api.routes.notifications as notifications_routes
|
||||||
|
|
||||||
app = flask.Flask(__name__)
|
app = flask.Flask(__name__)
|
||||||
CORS(app)
|
CORS(app)
|
||||||
@@ -29,6 +30,7 @@ ROUTE_MODULES = [
|
|||||||
routine_templates_routes,
|
routine_templates_routes,
|
||||||
routine_stats_routes,
|
routine_stats_routes,
|
||||||
routine_tags_routes,
|
routine_tags_routes,
|
||||||
|
notifications_routes,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()}
|
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):
|
def _is_med_due_today(med, today, current_day):
|
||||||
"""Check if a medication is scheduled for today based on its frequency."""
|
"""Check if a medication is scheduled for today based on its frequency."""
|
||||||
freq = med.get("frequency", "daily")
|
freq = med.get("frequency", "daily")
|
||||||
@@ -306,7 +311,12 @@ def register(app):
|
|||||||
|
|
||||||
@app.route("/api/medications/today", methods=["GET"])
|
@app.route("/api/medications/today", methods=["GET"])
|
||||||
def api_todaysMeds():
|
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)
|
user_uuid = _auth(flask.request)
|
||||||
if not user_uuid:
|
if not user_uuid:
|
||||||
return flask.jsonify({"error": "unauthorized"}), 401
|
return flask.jsonify({"error": "unauthorized"}), 401
|
||||||
@@ -316,8 +326,12 @@ def register(app):
|
|||||||
today = date.today()
|
today = date.today()
|
||||||
today_str = today.isoformat()
|
today_str = today.isoformat()
|
||||||
current_day = now.strftime("%a").lower() # "mon","tue", etc.
|
current_day = now.strftime("%a").lower() # "mon","tue", etc.
|
||||||
|
current_hour = now.hour
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
|
seen_med_ids = set()
|
||||||
|
|
||||||
|
# Main pass: today's meds
|
||||||
for med in meds:
|
for med in meds:
|
||||||
if not _is_med_due_today(med, today, current_day):
|
if not _is_med_due_today(med, today, current_day):
|
||||||
continue
|
continue
|
||||||
@@ -325,7 +339,6 @@ def register(app):
|
|||||||
freq = med.get("frequency", "daily")
|
freq = med.get("frequency", "daily")
|
||||||
is_prn = freq == "as_needed"
|
is_prn = freq == "as_needed"
|
||||||
|
|
||||||
# Get today's taken times by filtering on created_at date
|
|
||||||
all_logs = postgres.select(
|
all_logs = postgres.select(
|
||||||
"med_logs",
|
"med_logs",
|
||||||
where={"medication_id": med["id"], "action": "taken"},
|
where={"medication_id": med["id"], "action": "taken"},
|
||||||
@@ -342,6 +355,82 @@ def register(app):
|
|||||||
"taken_times": today_taken,
|
"taken_times": today_taken,
|
||||||
"is_prn": is_prn,
|
"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
|
return flask.jsonify(result), 200
|
||||||
|
|
||||||
# ── Adherence Stats ───────────────────────────────────────────
|
# ── Adherence Stats ───────────────────────────────────────────
|
||||||
|
|||||||
@@ -9,3 +9,8 @@ JWT_SECRET=bf773b4562221bef4d304ae5752a68931382ea3e98fe38394a098f73e0c776e1
|
|||||||
OPENROUTER_API_KEY=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
|
OPENROUTER_API_KEY=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
|
||||||
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||||
AI_CONFIG_PATH=/app/ai/ai_config.json
|
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
|
||||||
|
|||||||
@@ -18,3 +18,8 @@ JWT_SECRET=your_jwt_secret_here
|
|||||||
OPENROUTER_API_KEY=your_openrouter_api_key_here
|
OPENROUTER_API_KEY=your_openrouter_api_key_here
|
||||||
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||||
AI_CONFIG_PATH=/app/ai/ai_config.json
|
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
|
||||||
|
|||||||
@@ -14,12 +14,23 @@ CREATE TABLE IF NOT EXISTS notifications (
|
|||||||
discord_enabled BOOLEAN DEFAULT FALSE,
|
discord_enabled BOOLEAN DEFAULT FALSE,
|
||||||
ntfy_topic VARCHAR(255),
|
ntfy_topic VARCHAR(255),
|
||||||
ntfy_enabled BOOLEAN DEFAULT FALSE,
|
ntfy_enabled BOOLEAN DEFAULT FALSE,
|
||||||
|
web_push_enabled BOOLEAN DEFAULT FALSE,
|
||||||
last_message_sent TIMESTAMP,
|
last_message_sent TIMESTAMP,
|
||||||
current_notification_status VARCHAR(50) DEFAULT 'inactive',
|
current_notification_status VARCHAR(50) DEFAULT 'inactive',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_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 ────────────────────────────────────────────────
|
-- ── Routines ────────────────────────────────────────────────
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS routines (
|
CREATE TABLE IF NOT EXISTS routines (
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
"""
|
"""
|
||||||
notifications.py - Multi-channel notification routing
|
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 core.postgres as postgres
|
||||||
import uuid
|
import uuid
|
||||||
import requests
|
import requests
|
||||||
import time
|
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."""
|
"""Send message to all enabled channels. Returns True if at least one succeeded."""
|
||||||
sent = False
|
sent = False
|
||||||
|
|
||||||
@@ -22,6 +27,10 @@ def _sendToEnabledChannels(notif_settings, message):
|
|||||||
if ntfy.send(notif_settings["ntfy_topic"], message):
|
if ntfy.send(notif_settings["ntfy_topic"], message):
|
||||||
sent = True
|
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
|
return sent
|
||||||
|
|
||||||
|
|
||||||
@@ -39,6 +48,7 @@ def setNotificationSettings(userUUID, data_dict):
|
|||||||
"discord_enabled",
|
"discord_enabled",
|
||||||
"ntfy_topic",
|
"ntfy_topic",
|
||||||
"ntfy_enabled",
|
"ntfy_enabled",
|
||||||
|
"web_push_enabled",
|
||||||
]
|
]
|
||||||
updates = {k: v for k, v in data_dict.items() if k in allowed}
|
updates = {k: v for k, v in data_dict.items() if k in allowed}
|
||||||
if not updates:
|
if not updates:
|
||||||
@@ -72,3 +82,56 @@ class ntfy:
|
|||||||
return response.status_code == 200
|
return response.status_code == 200
|
||||||
except:
|
except:
|
||||||
return False
|
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
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
- ./config/schema.sql:/docker-entrypoint-initdb.d/schema.sql
|
- ./config/schema.sql:/docker-entrypoint-initdb.d/schema.sql
|
||||||
|
- ./config/seed_templates.sql:/docker-entrypoint-initdb.d/seed_templates.sql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U app"]
|
test: ["CMD-SHELL", "pg_isready -U app"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ openai>=1.0.0
|
|||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
pytest>=7.0.0
|
pytest>=7.0.0
|
||||||
pytest-asyncio>=0.21.0
|
pytest-asyncio>=0.21.0
|
||||||
|
pywebpush>=1.14.0
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ def check_medication_reminders():
|
|||||||
user_settings = notifications.getNotificationSettings(med["user_uuid"])
|
user_settings = notifications.getNotificationSettings(med["user_uuid"])
|
||||||
if user_settings:
|
if user_settings:
|
||||||
msg = f"Time to take {med['name']} ({med['dosage']} {med['unit']})"
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error checking medication reminders: {e}")
|
logger.error(f"Error checking medication reminders: {e}")
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ def check_routine_reminders():
|
|||||||
user_settings = notifications.getNotificationSettings(routine["user_uuid"])
|
user_settings = notifications.getNotificationSettings(routine["user_uuid"])
|
||||||
if user_settings:
|
if user_settings:
|
||||||
msg = f"Time to start your routine: {routine['name']}"
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error checking routine reminders: {e}")
|
logger.error(f"Error checking routine reminders: {e}")
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ def check_refills():
|
|||||||
user_settings = notifications.getNotificationSettings(med["user_uuid"])
|
user_settings = notifications.getNotificationSettings(med["user_uuid"])
|
||||||
if user_settings:
|
if user_settings:
|
||||||
msg = f"Low on {med['name']}: only {qty} doses remaining. Time to refill!"
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error checking refills: {e}")
|
logger.error(f"Error checking refills: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -28,3 +28,48 @@ self.addEventListener('fetch', (event) => {
|
|||||||
.catch(() => caches.match(event.request))
|
.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);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { PlusIcon, CheckIcon, PillIcon, ClockIcon, TrashIcon } from '@/components/ui/Icons';
|
import { PlusIcon, CheckIcon, PillIcon, ClockIcon, TrashIcon } from '@/components/ui/Icons';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import PushNotificationToggle from '@/components/notifications/PushNotificationToggle';
|
||||||
|
|
||||||
interface Medication {
|
interface Medication {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -32,6 +33,8 @@ interface TodaysMedication {
|
|||||||
scheduled_times: string[];
|
scheduled_times: string[];
|
||||||
taken_times: string[];
|
taken_times: string[];
|
||||||
is_prn?: boolean;
|
is_prn?: boolean;
|
||||||
|
is_next_day?: boolean;
|
||||||
|
is_previous_day?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AdherenceEntry {
|
interface AdherenceEntry {
|
||||||
@@ -41,6 +44,23 @@ interface AdherenceEntry {
|
|||||||
is_prn?: boolean;
|
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 => {
|
const formatSchedule = (med: Medication): string => {
|
||||||
if (med.frequency === 'specific_days' && med.days_of_week?.length) {
|
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(', ');
|
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';
|
return 'Daily';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface DueEntry {
|
||||||
|
item: TodaysMedication;
|
||||||
|
time: string;
|
||||||
|
status: TimeStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export default function MedicationsPage() {
|
export default function MedicationsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [medications, setMedications] = useState<Medication[]>([]);
|
const [medications, setMedications] = useState<Medication[]>([]);
|
||||||
const [todayMeds, setTodayMeds] = useState<TodaysMedication[]>([]);
|
const [todayMeds, setTodayMeds] = useState<TodaysMedication[]>([]);
|
||||||
const [adherence, setAdherence] = useState<AdherenceEntry[]>([]);
|
const [adherence, setAdherence] = useState<AdherenceEntry[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [tick, setTick] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@@ -80,6 +107,51 @@ export default function MedicationsPage() {
|
|||||||
fetchData();
|
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<TimeStatus, number> = { 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) => {
|
const handleTake = async (medId: string, time?: string) => {
|
||||||
try {
|
try {
|
||||||
await api.medications.take(medId, time);
|
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 (
|
return (
|
||||||
<div className="p-4 space-y-6">
|
<div className="p-4 space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -130,21 +209,78 @@ export default function MedicationsPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Today's Schedule */}
|
{/* Push Notification Toggle */}
|
||||||
{todayMeds.length > 0 && (
|
<PushNotificationToggle />
|
||||||
|
|
||||||
|
{/* Due Now Section */}
|
||||||
|
{dueEntries.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-3">Today</h2>
|
<h2 className="text-lg font-semibold text-gray-900 mb-3">Due</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{todayMeds.map((item) => (
|
{dueEntries.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={`${entry.item.medication.id}-${entry.time}`}
|
||||||
|
className={`bg-white rounded-xl p-4 shadow-sm ${borderColor(entry.status)}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-gray-900">{entry.item.medication.name}</h3>
|
||||||
|
{entry.item.is_previous_day && (
|
||||||
|
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded">Yesterday</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">{entry.item.medication.dosage} {entry.item.medication.unit}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between bg-gray-50 rounded-lg p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClockIcon size={16} className="text-gray-500" />
|
||||||
|
<span className="font-medium">{entry.time}</span>
|
||||||
|
{entry.status === 'overdue' && (
|
||||||
|
<span className="text-xs text-red-600 font-medium">Overdue</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{entry.status === 'taken' ? (
|
||||||
|
<span className="text-green-600 font-medium flex items-center gap-1">
|
||||||
|
<CheckIcon size={16} /> Taken
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleTake(entry.item.medication.id, entry.time)}
|
||||||
|
className="bg-green-600 text-white px-3 py-1 rounded-lg text-sm font-medium"
|
||||||
|
>
|
||||||
|
Take
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSkip(entry.item.medication.id, entry.time)}
|
||||||
|
className="text-gray-500 px-2 py-1"
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PRN Section */}
|
||||||
|
{prnEntries.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 mb-3">As Needed</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{prnEntries.map((item) => (
|
||||||
<div key={item.medication.id} className="bg-white rounded-xl p-4 shadow-sm">
|
<div key={item.medication.id} className="bg-white rounded-xl p-4 shadow-sm">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-gray-900">{item.medication.name}</h3>
|
<h3 className="font-semibold text-gray-900">{item.medication.name}</h3>
|
||||||
<p className="text-sm text-gray-500">{item.medication.dosage} {item.medication.unit}</p>
|
<p className="text-sm text-gray-500">{item.medication.dosage} {item.medication.unit}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
{item.is_prn ? (
|
|
||||||
<div className="flex items-center justify-between bg-gray-50 rounded-lg p-3">
|
<div className="flex items-center justify-between bg-gray-50 rounded-lg p-3">
|
||||||
<span className="text-gray-500 text-sm">As needed</span>
|
<span className="text-gray-500 text-sm">As needed</span>
|
||||||
<button
|
<button
|
||||||
@@ -154,39 +290,36 @@ export default function MedicationsPage() {
|
|||||||
Log Dose
|
Log Dose
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
item.scheduled_times.map((time) => {
|
))}
|
||||||
const isTaken = item.taken_times.includes(time);
|
</div>
|
||||||
return (
|
</div>
|
||||||
<div key={time} className="flex items-center justify-between bg-gray-50 rounded-lg p-3">
|
)}
|
||||||
|
|
||||||
|
{/* Upcoming Section */}
|
||||||
|
{upcomingEntries.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-500 mb-3">Upcoming</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{upcomingEntries.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={`${entry.item.medication.id}-${entry.time}`}
|
||||||
|
className="bg-gray-50 rounded-xl p-4 shadow-sm opacity-75"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ClockIcon size={16} className="text-gray-500" />
|
<h3 className="font-medium text-gray-700">{entry.item.medication.name}</h3>
|
||||||
<span className="font-medium">{time}</span>
|
{entry.item.is_next_day && (
|
||||||
</div>
|
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded">Tomorrow</span>
|
||||||
{isTaken ? (
|
|
||||||
<span className="text-green-600 font-medium flex items-center gap-1">
|
|
||||||
<CheckIcon size={16} /> Taken
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleTake(item.medication.id, time)}
|
|
||||||
className="bg-green-600 text-white px-3 py-1 rounded-lg text-sm font-medium"
|
|
||||||
>
|
|
||||||
Take
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSkip(item.medication.id, time)}
|
|
||||||
className="text-gray-500 px-2 py-1"
|
|
||||||
>
|
|
||||||
Skip
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
<p className="text-sm text-gray-400">{entry.item.medication.dosage} {entry.item.medication.unit}</p>
|
||||||
})
|
</div>
|
||||||
)}
|
<div className="flex items-center gap-2 text-gray-400">
|
||||||
|
<ClockIcon size={16} />
|
||||||
|
<span className="font-medium">{entry.time}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function RoutineDetailPage() {
|
|||||||
name: newStepName,
|
name: newStepName,
|
||||||
duration_minutes: newStepDuration,
|
duration_minutes: newStepDuration,
|
||||||
});
|
});
|
||||||
setSteps([...steps, { ...step, position: steps.length + 1 }]);
|
setSteps([...steps, { ...step, name: newStepName, step_type: 'generic', position: steps.length + 1 }]);
|
||||||
setNewStepName('');
|
setNewStepName('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to add step:', err);
|
console.error('Failed to add step:', err);
|
||||||
|
|||||||
@@ -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
|
||||||
medications: {
|
medications: {
|
||||||
list: async () => {
|
list: async () => {
|
||||||
@@ -653,6 +676,9 @@ export const api = {
|
|||||||
};
|
};
|
||||||
scheduled_times: string[];
|
scheduled_times: string[];
|
||||||
taken_times: string[];
|
taken_times: string[];
|
||||||
|
is_prn?: boolean;
|
||||||
|
is_next_day?: boolean;
|
||||||
|
is_previous_day?: boolean;
|
||||||
}>>('/api/medications/today', { method: 'GET' });
|
}>>('/api/medications/today', { method: 'GET' });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user