lots of changes leave me alone its better now

This commit is contained in:
2026-02-14 04:35:40 -06:00
parent 97a166f5aa
commit 4d3a9fbd54
13 changed files with 438 additions and 57 deletions

View File

@@ -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,
] ]

View File

@@ -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 ───────────────────────────────────────────

View File

@@ -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

View File

@@ -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

View File

@@ -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 (

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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}")

View File

@@ -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);
})
);
});

View File

@@ -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,63 +209,117 @@ 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"> <div className="flex items-center justify-between bg-gray-50 rounded-lg p-3">
{item.is_prn ? ( <span className="text-gray-500 text-sm">As needed</span>
<div className="flex items-center justify-between bg-gray-50 rounded-lg p-3"> <button
<span className="text-gray-500 text-sm">As needed</span> onClick={() => handleTake(item.medication.id)}
<button className="bg-green-600 text-white px-3 py-1 rounded-lg text-sm font-medium"
onClick={() => handleTake(item.medication.id)} >
className="bg-green-600 text-white px-3 py-1 rounded-lg text-sm font-medium" Log Dose
> </button>
Log Dose </div>
</button> </div>
))}
</div>
</div>
)}
{/* 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">
<h3 className="font-medium text-gray-700">{entry.item.medication.name}</h3>
{entry.item.is_next_day && (
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded">Tomorrow</span>
)}
</div> </div>
) : ( <p className="text-sm text-gray-400">{entry.item.medication.dosage} {entry.item.medication.unit}</p>
item.scheduled_times.map((time) => { </div>
const isTaken = item.taken_times.includes(time); <div className="flex items-center gap-2 text-gray-400">
return ( <ClockIcon size={16} />
<div key={time} className="flex items-center justify-between bg-gray-50 rounded-lg p-3"> <span className="font-medium">{entry.time}</span>
<div className="flex items-center gap-2"> </div>
<ClockIcon size={16} className="text-gray-500" />
<span className="font-medium">{time}</span>
</div>
{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> </div>
</div> </div>
))} ))}

View File

@@ -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);

View File

@@ -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' });
}, },