Compare commits

...

5 Commits

11 changed files with 2418 additions and 5 deletions

View File

@@ -21,6 +21,8 @@ import api.routes.notifications as notifications_routes
import api.routes.preferences as preferences_routes
import api.routes.rewards as rewards_routes
import api.routes.victories as victories_routes
import api.routes.adaptive_meds as adaptive_meds_routes
import api.routes.snitch as snitch_routes
app = flask.Flask(__name__)
CORS(app)
@@ -37,6 +39,8 @@ ROUTE_MODULES = [
preferences_routes,
rewards_routes,
victories_routes,
adaptive_meds_routes,
snitch_routes,
]
@@ -161,8 +165,13 @@ def _seed_templates_if_empty():
count = postgres.count("routine_templates")
if count == 0:
import logging
logging.getLogger(__name__).info("No templates found, seeding from seed_templates.sql...")
seed_path = os.path.join(os.path.dirname(__file__), "..", "config", "seed_templates.sql")
logging.getLogger(__name__).info(
"No templates found, seeding from seed_templates.sql..."
)
seed_path = os.path.join(
os.path.dirname(__file__), "..", "config", "seed_templates.sql"
)
if os.path.exists(seed_path):
with open(seed_path, "r") as f:
sql = f.read()
@@ -171,6 +180,7 @@ def _seed_templates_if_empty():
logging.getLogger(__name__).info("Templates seeded successfully.")
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Failed to seed templates: {e}")
@@ -180,8 +190,13 @@ def _seed_rewards_if_empty():
count = postgres.count("reward_pool")
if count == 0:
import logging
logging.getLogger(__name__).info("No rewards found, seeding from seed_rewards.sql...")
seed_path = os.path.join(os.path.dirname(__file__), "..", "config", "seed_rewards.sql")
logging.getLogger(__name__).info(
"No rewards found, seeding from seed_rewards.sql..."
)
seed_path = os.path.join(
os.path.dirname(__file__), "..", "config", "seed_rewards.sql"
)
if os.path.exists(seed_path):
with open(seed_path, "r") as f:
sql = f.read()
@@ -190,6 +205,7 @@ def _seed_rewards_if_empty():
logging.getLogger(__name__).info("Rewards seeded successfully.")
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Failed to seed rewards: {e}")

185
api/routes/adaptive_meds.py Normal file
View File

@@ -0,0 +1,185 @@
"""
api/routes/adaptive_meds.py - API endpoints for adaptive medication settings
"""
import flask
import jwt
import os
import core.postgres as postgres
import core.adaptive_meds as adaptive_meds
JWT_SECRET = os.getenv("JWT_SECRET")
def _get_user_uuid(request):
"""Extract and validate user UUID from JWT token."""
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
token = auth_header[7:]
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
return payload.get("sub")
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
def register(app):
@app.route("/api/adaptive-meds/settings", methods=["GET"])
def get_adaptive_settings():
"""Get user's adaptive medication settings."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
settings = adaptive_meds.get_adaptive_settings(user_uuid)
if not settings:
# Return defaults
return flask.jsonify(
{
"adaptive_timing_enabled": False,
"adaptive_mode": "shift_all",
"presence_tracking_enabled": False,
"nagging_enabled": True,
"nag_interval_minutes": 15,
"max_nag_count": 4,
"quiet_hours_start": None,
"quiet_hours_end": None,
}
), 200
return flask.jsonify(
{
"adaptive_timing_enabled": settings.get(
"adaptive_timing_enabled", False
),
"adaptive_mode": settings.get("adaptive_mode", "shift_all"),
"presence_tracking_enabled": settings.get(
"presence_tracking_enabled", False
),
"nagging_enabled": settings.get("nagging_enabled", True),
"nag_interval_minutes": settings.get("nag_interval_minutes", 15),
"max_nag_count": settings.get("max_nag_count", 4),
"quiet_hours_start": settings.get("quiet_hours_start"),
"quiet_hours_end": settings.get("quiet_hours_end"),
}
), 200
@app.route("/api/adaptive-meds/settings", methods=["PUT"])
def update_adaptive_settings():
"""Update user's adaptive medication settings."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
data = flask.request.get_json()
if not data:
return flask.jsonify({"error": "No data provided"}), 400
# Validate required fields if enabling adaptive timing
if data.get("adaptive_timing_enabled"):
if not data.get("adaptive_mode"):
return flask.jsonify(
{"error": "adaptive_mode is required when enabling adaptive timing"}
), 400
# Build update data
update_data = {
"adaptive_timing_enabled": data.get("adaptive_timing_enabled", False),
"adaptive_mode": data.get("adaptive_mode", "shift_all"),
"presence_tracking_enabled": data.get("presence_tracking_enabled", False),
"nagging_enabled": data.get("nagging_enabled", True),
"nag_interval_minutes": data.get("nag_interval_minutes", 15),
"max_nag_count": data.get("max_nag_count", 4),
"quiet_hours_start": data.get("quiet_hours_start"),
"quiet_hours_end": data.get("quiet_hours_end"),
}
# Check if settings exist
existing = adaptive_meds.get_adaptive_settings(user_uuid)
if existing:
postgres.update(
"adaptive_med_settings", update_data, {"user_uuid": user_uuid}
)
else:
update_data["user_uuid"] = user_uuid
postgres.insert("adaptive_med_settings", update_data)
return flask.jsonify({"success": True}), 200
@app.route("/api/adaptive-meds/presence", methods=["GET"])
def get_presence_status():
"""Get user's Discord presence status."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
presence = adaptive_meds.get_user_presence(user_uuid)
if not presence:
return flask.jsonify(
{"is_online": False, "last_online_at": None, "typical_wake_time": None}
), 200
typical_wake = adaptive_meds.calculate_typical_wake_time(user_uuid)
return flask.jsonify(
{
"is_online": presence.get("is_currently_online", False),
"last_online_at": presence.get("last_online_at").isoformat()
if presence.get("last_online_at")
else None,
"last_offline_at": presence.get("last_offline_at").isoformat()
if presence.get("last_offline_at")
else None,
"typical_wake_time": typical_wake.strftime("%H:%M")
if typical_wake
else None,
}
), 200
@app.route("/api/adaptive-meds/schedule", methods=["GET"])
def get_today_schedule():
"""Get today's adaptive medication schedule."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
from datetime import date
today = date.today()
# Get all medications for user
meds = postgres.select("medications", {"user_uuid": user_uuid, "active": True})
schedule_data = []
for med in meds:
med_id = med.get("id")
med_schedules = postgres.select(
"medication_schedules",
{
"user_uuid": user_uuid,
"medication_id": med_id,
"adjustment_date": today,
},
)
for sched in med_schedules:
schedule_data.append(
{
"medication_id": med_id,
"medication_name": med.get("name"),
"base_time": sched.get("base_time"),
"adjusted_time": sched.get("adjusted_time"),
"adjustment_minutes": sched.get("adjustment_minutes", 0),
"status": sched.get("status", "pending"),
"nag_count": sched.get("nag_count", 0),
}
)
return flask.jsonify(schedule_data), 200

293
api/routes/snitch.py Normal file
View File

@@ -0,0 +1,293 @@
"""
api/routes/snitch.py - API endpoints for snitch system
"""
import flask
import jwt
import os
import core.postgres as postgres
import core.snitch as snitch_core
JWT_SECRET = os.getenv("JWT_SECRET")
def _get_user_uuid(request):
"""Extract and validate user UUID from JWT token."""
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
return None
token = auth_header[7:]
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
return payload.get("sub")
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
def register(app):
@app.route("/api/snitch/settings", methods=["GET"])
def get_snitch_settings():
"""Get user's snitch settings."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
settings = snitch_core.get_snitch_settings(user_uuid)
if not settings:
# Return defaults
return flask.jsonify(
{
"snitch_enabled": False,
"trigger_after_nags": 4,
"trigger_after_missed_doses": 1,
"max_snitches_per_day": 2,
"require_consent": True,
"consent_given": False,
"snitch_cooldown_hours": 4,
}
), 200
return flask.jsonify(
{
"snitch_enabled": settings.get("snitch_enabled", False),
"trigger_after_nags": settings.get("trigger_after_nags", 4),
"trigger_after_missed_doses": settings.get(
"trigger_after_missed_doses", 1
),
"max_snitches_per_day": settings.get("max_snitches_per_day", 2),
"require_consent": settings.get("require_consent", True),
"consent_given": settings.get("consent_given", False),
"snitch_cooldown_hours": settings.get("snitch_cooldown_hours", 4),
}
), 200
@app.route("/api/snitch/settings", methods=["PUT"])
def update_snitch_settings():
"""Update user's snitch settings."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
data = flask.request.get_json()
if not data:
return flask.jsonify({"error": "No data provided"}), 400
# Build update data
update_data = {
"snitch_enabled": data.get("snitch_enabled", False),
"trigger_after_nags": data.get("trigger_after_nags", 4),
"trigger_after_missed_doses": data.get("trigger_after_missed_doses", 1),
"max_snitches_per_day": data.get("max_snitches_per_day", 2),
"require_consent": data.get("require_consent", True),
"consent_given": data.get("consent_given", False),
"snitch_cooldown_hours": data.get("snitch_cooldown_hours", 4),
"updated_at": flask.datetime.utcnow(),
}
# Check if settings exist
existing = snitch_core.get_snitch_settings(user_uuid)
if existing:
postgres.update("snitch_settings", update_data, {"user_uuid": user_uuid})
else:
update_data["user_uuid"] = user_uuid
update_data["created_at"] = flask.datetime.utcnow()
postgres.insert("snitch_settings", update_data)
return flask.jsonify({"success": True}), 200
@app.route("/api/snitch/consent", methods=["POST"])
def give_consent():
"""Give or revoke consent for snitching."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
data = flask.request.get_json()
consent_given = data.get("consent_given", False)
snitch_core.update_consent(user_uuid, consent_given)
return flask.jsonify({"success": True, "consent_given": consent_given}), 200
@app.route("/api/snitch/contacts", methods=["GET"])
def get_snitch_contacts():
"""Get user's snitch contacts."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
contacts = snitch_core.get_snitch_contacts(user_uuid, active_only=False)
return flask.jsonify(
[
{
"id": c.get("id"),
"contact_name": c.get("contact_name"),
"contact_type": c.get("contact_type"),
"contact_value": c.get("contact_value"),
"priority": c.get("priority", 1),
"notify_all": c.get("notify_all", False),
"is_active": c.get("is_active", True),
}
for c in contacts
]
), 200
@app.route("/api/snitch/contacts", methods=["POST"])
def add_snitch_contact():
"""Add a new snitch contact."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
data = flask.request.get_json()
# Validate required fields
required = ["contact_name", "contact_type", "contact_value"]
for field in required:
if not data.get(field):
return flask.jsonify({"error": f"Missing required field: {field}"}), 400
# Validate contact_type
if data["contact_type"] not in ["discord", "email", "sms"]:
return flask.jsonify(
{"error": "contact_type must be discord, email, or sms"}
), 400
contact_data = {
"user_uuid": user_uuid,
"contact_name": data["contact_name"],
"contact_type": data["contact_type"],
"contact_value": data["contact_value"],
"priority": data.get("priority", 1),
"notify_all": data.get("notify_all", False),
"is_active": data.get("is_active", True),
"created_at": flask.datetime.utcnow(),
}
result = postgres.insert("snitch_contacts", contact_data)
return flask.jsonify(
{"success": True, "contact_id": result.get("id") if result else None}
), 201
@app.route("/api/snitch/contacts/<contact_id>", methods=["PUT"])
def update_snitch_contact(contact_id):
"""Update a snitch contact."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
data = flask.request.get_json()
# Check contact exists and belongs to user
contacts = postgres.select(
"snitch_contacts", {"id": contact_id, "user_uuid": user_uuid}
)
if not contacts:
return flask.jsonify({"error": "Contact not found"}), 404
update_data = {}
if "contact_name" in data:
update_data["contact_name"] = data["contact_name"]
if "contact_type" in data:
if data["contact_type"] not in ["discord", "email", "sms"]:
return flask.jsonify({"error": "Invalid contact_type"}), 400
update_data["contact_type"] = data["contact_type"]
if "contact_value" in data:
update_data["contact_value"] = data["contact_value"]
if "priority" in data:
update_data["priority"] = data["priority"]
if "notify_all" in data:
update_data["notify_all"] = data["notify_all"]
if "is_active" in data:
update_data["is_active"] = data["is_active"]
if update_data:
postgres.update("snitch_contacts", update_data, {"id": contact_id})
return flask.jsonify({"success": True}), 200
@app.route("/api/snitch/contacts/<contact_id>", methods=["DELETE"])
def delete_snitch_contact(contact_id):
"""Delete a snitch contact."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
# Check contact exists and belongs to user
contacts = postgres.select(
"snitch_contacts", {"id": contact_id, "user_uuid": user_uuid}
)
if not contacts:
return flask.jsonify({"error": "Contact not found"}), 404
postgres.delete("snitch_contacts", {"id": contact_id})
return flask.jsonify({"success": True}), 200
@app.route("/api/snitch/history", methods=["GET"])
def get_snitch_history():
"""Get user's snitch history."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
days = flask.request.args.get("days", 7, type=int)
history = snitch_core.get_snitch_history(user_uuid, days)
return flask.jsonify(
[
{
"id": h.get("id"),
"contact_id": h.get("contact_id"),
"medication_id": h.get("medication_id"),
"trigger_reason": h.get("trigger_reason"),
"snitch_count_today": h.get("snitch_count_today"),
"sent_at": h.get("sent_at").isoformat()
if h.get("sent_at")
else None,
"delivered": h.get("delivered"),
}
for h in history
]
), 200
@app.route("/api/snitch/test", methods=["POST"])
def test_snitch():
"""Test snitch functionality (sends to first contact only)."""
user_uuid = _get_user_uuid(flask.request)
if not user_uuid:
return flask.jsonify({"error": "Unauthorized"}), 401
# Get first active contact
contacts = snitch_core.get_snitch_contacts(user_uuid, active_only=True)
if not contacts:
return flask.jsonify({"error": "No active contacts configured"}), 400
# Send test message
contact = contacts[0]
test_message = f"🧪 This is a test snitch notification for {contact.get('contact_name')}. If you're receiving this, the snitch system is working!"
# Use notification system for test
import core.notifications as notifications
user_settings = notifications.getNotificationSettings(user_uuid)
if user_settings:
notifications._sendToEnabledChannels(
user_settings, test_message, user_uuid=user_uuid
)
return flask.jsonify(
{
"success": True,
"message": f"Test sent to {contact.get('contact_name')} via {contact.get('contact_type')}",
}
), 200
else:
return flask.jsonify({"error": "No notification settings configured"}), 400

View File

@@ -577,5 +577,88 @@ async def beforeBackgroundLoop():
await client.wait_until_ready()
# ==================== Discord Presence Tracking ====================
async def update_presence_tracking():
"""Track Discord presence for users with presence tracking enabled."""
try:
import core.adaptive_meds as adaptive_meds
import core.postgres as postgres
# Get all users with presence tracking enabled
settings = postgres.select(
"adaptive_med_settings", {"presence_tracking_enabled": True}
)
for setting in settings:
user_uuid = setting.get("user_uuid")
# Get user's Discord ID from notifications table
notif_settings = postgres.select("notifications", {"user_uuid": user_uuid})
if not notif_settings:
continue
discord_user_id = notif_settings[0].get("discord_user_id")
if not discord_user_id:
continue
# Get the user from Discord
try:
discord_user = await client.fetch_user(int(discord_user_id))
if not discord_user:
continue
# Check if user is online
is_online = discord_user.status != discord.Status.offline
# Get current presence from DB
presence = adaptive_meds.get_user_presence(user_uuid)
was_online = presence.get("is_currently_online") if presence else False
# Update presence if changed
if is_online != was_online:
adaptive_meds.update_user_presence(
user_uuid, discord_user_id, is_online
)
# Record the event
from datetime import datetime
event_type = "online" if is_online else "offline"
adaptive_meds.record_presence_event(
user_uuid, event_type, datetime.utcnow()
)
print(
f"Presence update: User {user_uuid} is now {'online' if is_online else 'offline'}"
)
except Exception as e:
print(f"Error tracking presence for user {user_uuid}: {e}")
except Exception as e:
print(f"Error in presence tracking loop: {e}")
@tasks.loop(seconds=30)
async def presenceTrackingLoop():
"""Track Discord presence every 30 seconds."""
await update_presence_tracking()
@presenceTrackingLoop.before_loop
async def beforePresenceTrackingLoop():
await client.wait_until_ready()
@client.event
async def on_ready():
print(f"Bot logged in as {client.user}")
loadCache()
backgroundLoop.start()
presenceTrackingLoop.start()
if __name__ == "__main__":
client.run(DISCORD_BOT_TOKEN)

12
bot/config.json Normal file
View File

@@ -0,0 +1,12 @@
{
"openrouter_api_key": "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",
"embedding_file": "dbt_knowledge.json",
"models": {
"generator": "moonshotai/kimi-k2.5",
"jury_clinical": "z-ai/glm-5",
"jury_safety": "deepseek/deepseek-v3.2",
"jury_empathy": "openai/gpt-4o-2024-08-06",
"jury_hallucination": "qwen/qwen3-235b-a22b-2507"
},
"system_prompt": "You are a DBT assistant. Answer based ONLY on the provided context."
}

View File

@@ -200,3 +200,96 @@ CREATE TABLE IF NOT EXISTS med_logs (
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ── Adaptive Medication Settings ─────────────────────────────
CREATE TABLE IF NOT EXISTS adaptive_med_settings (
id UUID PRIMARY KEY,
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE,
adaptive_timing_enabled BOOLEAN DEFAULT FALSE,
adaptive_mode VARCHAR(20) DEFAULT 'shift_all',
presence_tracking_enabled BOOLEAN DEFAULT FALSE,
nagging_enabled BOOLEAN DEFAULT TRUE,
nag_interval_minutes INTEGER DEFAULT 15,
max_nag_count INTEGER DEFAULT 4,
quiet_hours_start TIME,
quiet_hours_end TIME,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ── User Discord Presence Tracking ────────────────────────────
CREATE TABLE IF NOT EXISTS user_presence (
id UUID PRIMARY KEY,
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE,
discord_user_id VARCHAR(255),
last_online_at TIMESTAMP,
last_offline_at TIMESTAMP,
is_currently_online BOOLEAN DEFAULT FALSE,
typical_wake_time TIME,
presence_history JSONB DEFAULT '[]',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ── Adaptive Medication Schedules (Daily Tracking) ───────────
CREATE TABLE IF NOT EXISTS medication_schedules (
id UUID PRIMARY KEY,
medication_id UUID REFERENCES medications(id) ON DELETE CASCADE,
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE,
base_time TIME NOT NULL,
adjusted_time TIME,
adjustment_date DATE NOT NULL,
adjustment_minutes INTEGER DEFAULT 0,
nag_count INTEGER DEFAULT 0,
last_nag_at TIMESTAMP,
status VARCHAR(20) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_med_schedules_user_date ON medication_schedules(user_uuid, adjustment_date);
CREATE INDEX IF NOT EXISTS idx_med_schedules_status ON medication_schedules(status);
-- ── Snitch System ─────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS snitch_settings (
id UUID PRIMARY KEY,
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE,
snitch_enabled BOOLEAN DEFAULT FALSE,
trigger_after_nags INTEGER DEFAULT 4,
trigger_after_missed_doses INTEGER DEFAULT 1,
max_snitches_per_day INTEGER DEFAULT 2,
require_consent BOOLEAN DEFAULT TRUE,
consent_given BOOLEAN DEFAULT FALSE,
snitch_cooldown_hours INTEGER DEFAULT 4,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS snitch_contacts (
id UUID PRIMARY KEY,
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE,
contact_name VARCHAR(255) NOT NULL,
contact_type VARCHAR(50) NOT NULL,
contact_value VARCHAR(255) NOT NULL,
priority INTEGER DEFAULT 1,
notify_all BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS snitch_log (
id UUID PRIMARY KEY,
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE,
contact_id UUID REFERENCES snitch_contacts(id) ON DELETE SET NULL,
medication_id UUID REFERENCES medications(id) ON DELETE SET NULL,
trigger_reason VARCHAR(100) NOT NULL,
snitch_count_today INTEGER DEFAULT 1,
message_content TEXT,
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
delivered BOOLEAN DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_snitch_log_user_date ON snitch_log(user_uuid, DATE(sent_at));
CREATE INDEX IF NOT EXISTS idx_snitch_contacts_user ON snitch_contacts(user_uuid, is_active);

358
core/adaptive_meds.py Normal file
View File

@@ -0,0 +1,358 @@
"""
core/adaptive_meds.py - Adaptive medication timing and nagging logic
This module handles:
- Discord presence tracking for wake detection
- Adaptive medication schedule calculations
- Nagging logic for missed medications
- Quiet hours enforcement
"""
import json
from datetime import datetime, timedelta, time
from typing import Optional, Dict, List, Tuple
import core.postgres as postgres
from core.tz import user_now
def get_adaptive_settings(user_uuid: str) -> Optional[Dict]:
"""Get user's adaptive medication settings."""
rows = postgres.select("adaptive_med_settings", {"user_uuid": user_uuid})
if rows:
return rows[0]
return None
def get_user_presence(user_uuid: str) -> Optional[Dict]:
"""Get user's Discord presence data."""
rows = postgres.select("user_presence", {"user_uuid": user_uuid})
if rows:
return rows[0]
return None
def update_user_presence(user_uuid: str, discord_user_id: str, is_online: bool):
"""Update user's presence status."""
now = datetime.utcnow()
presence = get_user_presence(user_uuid)
if presence:
# Update existing record
updates = {"is_currently_online": is_online, "updated_at": now}
if is_online:
updates["last_online_at"] = now
else:
updates["last_offline_at"] = now
postgres.update("user_presence", updates, {"user_uuid": user_uuid})
else:
# Create new record
data = {
"user_uuid": user_uuid,
"discord_user_id": discord_user_id,
"is_currently_online": is_online,
"last_online_at": now if is_online else None,
"last_offline_at": now if not is_online else None,
"presence_history": json.dumps([]),
"updated_at": now,
}
postgres.insert("user_presence", data)
def record_presence_event(user_uuid: str, event_type: str, timestamp: datetime):
"""Record a presence event in the history."""
presence = get_user_presence(user_uuid)
if not presence:
return
history = json.loads(presence.get("presence_history", "[]"))
# Add new event
history.append({"type": event_type, "timestamp": timestamp.isoformat()})
# Keep only last 7 days of history (up to 100 events)
history = history[-100:]
postgres.update(
"user_presence",
{"presence_history": json.dumps(history)},
{"user_uuid": user_uuid},
)
def calculate_typical_wake_time(user_uuid: str) -> Optional[time]:
"""Calculate user's typical wake time based on presence history."""
presence = get_user_presence(user_uuid)
if not presence:
return None
history = json.loads(presence.get("presence_history", "[]"))
if len(history) < 3:
return None
# Get all "online" events
wake_times = []
for event in history:
if event["type"] == "online":
ts = datetime.fromisoformat(event["timestamp"])
wake_times.append(ts.time())
if not wake_times:
return None
# Calculate average wake time (convert to minutes since midnight)
total_minutes = sum(t.hour * 60 + t.minute for t in wake_times)
avg_minutes = total_minutes // len(wake_times)
return time(avg_minutes // 60, avg_minutes % 60)
def detect_wake_event(user_uuid: str, current_time: datetime) -> Optional[datetime]:
"""Detect if user just woke up based on presence change."""
presence = get_user_presence(user_uuid)
if not presence:
return None
# Check if they just came online
if presence.get("is_currently_online"):
last_online = presence.get("last_online_at")
last_offline = presence.get("last_offline_at")
if last_online and last_offline:
offline_duration = last_online - last_offline
# If they were offline for more than 30 minutes, consider it a wake event
if offline_duration.total_seconds() > 1800: # 30 minutes
return last_online
return None
def is_quiet_hours(user_uuid: str, check_time: datetime) -> bool:
"""Check if current time is within user's quiet hours."""
settings = get_adaptive_settings(user_uuid)
if not settings:
return False
quiet_start = settings.get("quiet_hours_start")
quiet_end = settings.get("quiet_hours_end")
if not quiet_start or not quiet_end:
return False
current_time = check_time.time()
# Handle quiet hours that span midnight
if quiet_start > quiet_end:
return current_time >= quiet_start or current_time <= quiet_end
else:
return quiet_start <= current_time <= quiet_end
def calculate_adjusted_times(
user_uuid: str, base_times: List[str], wake_time: Optional[datetime] = None
) -> List[Tuple[str, int]]:
"""
Calculate adjusted medication times based on wake time.
Args:
user_uuid: User's UUID
base_times: List of base times in "HH:MM" format
wake_time: Optional wake time to use for adjustment
Returns:
List of (adjusted_time_str, offset_minutes) tuples
"""
settings = get_adaptive_settings(user_uuid)
if not settings or not settings.get("adaptive_timing_enabled"):
# Return base times with 0 offset
return [(t, 0) for t in base_times]
# Get user's timezone
prefs = postgres.select("user_preferences", {"user_uuid": user_uuid})
offset_minutes = prefs[0].get("timezone_offset", 0) if prefs else 0
# Get current time in user's timezone
user_current_time = user_now(offset_minutes)
today = user_current_time.date()
# Determine wake time
if wake_time is None:
# Try to get from presence detection
wake_time = detect_wake_event(user_uuid, user_current_time)
if wake_time is None:
# Use typical wake time if available
typical_wake = calculate_typical_wake_time(user_uuid)
if typical_wake:
wake_time = datetime.combine(today, typical_wake)
if wake_time is None:
# Default wake time (8 AM)
wake_time = datetime.combine(today, time(8, 0))
# Calculate offset from first med time
if not base_times:
return []
first_med_time = datetime.strptime(base_times[0], "%H:%M").time()
first_med_datetime = datetime.combine(today, first_med_time)
# Calculate how late they are
if wake_time.time() > first_med_time:
# They woke up after their first med time
offset_minutes = int((wake_time - first_med_datetime).total_seconds() / 60)
else:
offset_minutes = 0
# Adjust all times
adjusted = []
for base_time_str in base_times:
base_time = datetime.strptime(base_time_str, "%H:%M").time()
base_datetime = datetime.combine(today, base_time)
# Add offset
adjusted_datetime = base_datetime + timedelta(minutes=offset_minutes)
adjusted_time_str = adjusted_datetime.strftime("%H:%M")
adjusted.append((adjusted_time_str, offset_minutes))
return adjusted
def should_send_nag(
user_uuid: str, med_id: str, scheduled_time: str, current_time: datetime
) -> Tuple[bool, str]:
"""
Determine if we should send a nag notification.
Returns:
(should_nag: bool, reason: str)
"""
settings = get_adaptive_settings(user_uuid)
if not settings:
return False, "No settings"
if not settings.get("nagging_enabled"):
return False, "Nagging disabled"
# Check quiet hours
if is_quiet_hours(user_uuid, current_time):
return False, "Quiet hours"
# Check if user is online (don't nag if offline unless presence tracking disabled)
presence = get_user_presence(user_uuid)
if presence and settings.get("presence_tracking_enabled"):
if not presence.get("is_currently_online"):
return False, "User offline"
# Get today's schedule record
today = current_time.date()
schedules = postgres.select(
"medication_schedules",
{"user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today},
)
if not schedules:
return False, "No schedule found"
schedule = schedules[0]
nag_count = schedule.get("nag_count", 0)
max_nags = settings.get("max_nag_count", 4)
if nag_count >= max_nags:
return False, f"Max nags reached ({max_nags})"
# Check if it's time to nag
last_nag = schedule.get("last_nag_at")
nag_interval = settings.get("nag_interval_minutes", 15)
if last_nag:
time_since_last_nag = (current_time - last_nag).total_seconds() / 60
if time_since_last_nag < nag_interval:
return False, f"Too soon ({int(time_since_last_nag)} < {nag_interval} min)"
# Check if medication was already taken today
logs = postgres.select(
"med_logs", {"medication_id": med_id, "user_uuid": user_uuid, "action": "taken"}
)
# Filter to today's logs
today_logs = [
log
for log in logs
if log.get("created_at") and log["created_at"].date() == today
]
if today_logs:
return False, "Already taken today"
return True, "Time to nag"
def record_nag_sent(user_uuid: str, med_id: str, scheduled_time: str):
"""Record that a nag was sent."""
today = datetime.utcnow().date()
schedules = postgres.select(
"medication_schedules",
{"user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today},
)
if schedules:
schedule = schedules[0]
new_nag_count = schedule.get("nag_count", 0) + 1
postgres.update(
"medication_schedules",
{"nag_count": new_nag_count, "last_nag_at": datetime.utcnow()},
{"id": schedule["id"]},
)
def create_daily_schedule(user_uuid: str, med_id: str, base_times: List[str]):
"""Create today's medication schedule with adaptive adjustments."""
today = datetime.utcnow().date()
# Check if schedule already exists
existing = postgres.select(
"medication_schedules",
{"user_uuid": user_uuid, "medication_id": med_id, "adjustment_date": today},
)
if existing:
return
# Calculate adjusted times
adjusted_times = calculate_adjusted_times(user_uuid, base_times)
# Create schedule records for each time
for base_time, (adjusted_time, offset) in zip(base_times, adjusted_times):
data = {
"user_uuid": user_uuid,
"medication_id": med_id,
"base_time": base_time,
"adjusted_time": adjusted_time,
"adjustment_date": today,
"adjustment_minutes": offset,
"nag_count": 0,
"status": "pending",
}
postgres.insert("medication_schedules", data)
def mark_med_taken(user_uuid: str, med_id: str, scheduled_time: str):
"""Mark a medication as taken."""
today = datetime.utcnow().date()
postgres.update(
"medication_schedules",
{"status": "taken"},
{
"user_uuid": user_uuid,
"medication_id": med_id,
"adjustment_date": today,
"adjusted_time": scheduled_time,
},
)

339
core/snitch.py Normal file
View File

@@ -0,0 +1,339 @@
"""
core/snitch.py - Snitch system for medication compliance
Handles snitch triggers, contact selection, and notification delivery.
"""
import json
from datetime import datetime, timedelta, date
from typing import Optional, Dict, List, Tuple
import core.postgres as postgres
import core.notifications as notifications
def get_snitch_settings(user_uuid: str) -> Optional[Dict]:
"""Get user's snitch settings."""
rows = postgres.select("snitch_settings", {"user_uuid": user_uuid})
if rows:
return rows[0]
return None
def get_snitch_contacts(user_uuid: str, active_only: bool = True) -> List[Dict]:
"""Get user's snitch contacts ordered by priority."""
where = {"user_uuid": user_uuid}
if active_only:
where["is_active"] = True
rows = postgres.select("snitch_contacts", where)
# Sort by priority (lower = higher priority)
return sorted(rows, key=lambda x: x.get("priority", 1))
def get_todays_snitch_count(user_uuid: str) -> int:
"""Get number of snitches sent today."""
today = date.today()
# Query snitch log for today
rows = postgres.select("snitch_log", {"user_uuid": user_uuid})
# Filter to today's entries
today_count = 0
for row in rows:
sent_at = row.get("sent_at")
if sent_at and hasattr(sent_at, "date") and sent_at.date() == today:
today_count += 1
return today_count
def get_last_snitch_time(user_uuid: str) -> Optional[datetime]:
"""Get timestamp of last snitch for cooldown check."""
rows = postgres.select(
"snitch_log", {"user_uuid": user_uuid}, order_by="sent_at DESC", limit=1
)
if rows:
return rows[0].get("sent_at")
return None
def check_cooldown(user_uuid: str, cooldown_hours: int) -> bool:
"""Check if enough time has passed since last snitch."""
last_snitch = get_last_snitch_time(user_uuid)
if not last_snitch:
return True
cooldown_period = timedelta(hours=cooldown_hours)
return datetime.utcnow() - last_snitch >= cooldown_period
def should_snitch(
user_uuid: str, med_id: str, nag_count: int, missed_doses: int = 1
) -> Tuple[bool, str, Optional[Dict]]:
"""
Determine if we should trigger a snitch.
Returns:
(should_snitch: bool, reason: str, settings: Optional[Dict])
"""
settings = get_snitch_settings(user_uuid)
if not settings:
return False, "No snitch settings found", None
if not settings.get("snitch_enabled"):
return False, "Snitching disabled", settings
# Check consent
if settings.get("require_consent") and not settings.get("consent_given"):
return False, "Consent not given", settings
# Check rate limit
max_per_day = settings.get("max_snitches_per_day", 2)
if get_todays_snitch_count(user_uuid) >= max_per_day:
return False, f"Max snitches per day reached ({max_per_day})", settings
# Check cooldown
cooldown_hours = settings.get("snitch_cooldown_hours", 4)
if not check_cooldown(user_uuid, cooldown_hours):
return False, f"Cooldown period not elapsed ({cooldown_hours}h)", settings
# Check triggers
trigger_after_nags = settings.get("trigger_after_nags", 4)
trigger_after_doses = settings.get("trigger_after_missed_doses", 1)
triggered_by_nags = nag_count >= trigger_after_nags
triggered_by_doses = missed_doses >= trigger_after_doses
if not triggered_by_nags and not triggered_by_doses:
return (
False,
f"Triggers not met (nags: {nag_count}/{trigger_after_nags}, doses: {missed_doses}/{trigger_after_doses})",
settings,
)
# Determine trigger reason
if triggered_by_nags and triggered_by_doses:
reason = "max_nags_and_missed_doses"
elif triggered_by_nags:
reason = "max_nags"
else:
reason = "missed_doses"
return True, reason, settings
def select_contacts_to_notify(user_uuid: str) -> List[Dict]:
"""Select which contacts to notify based on priority settings."""
contacts = get_snitch_contacts(user_uuid)
if not contacts:
return []
# If any contact has notify_all=True, notify all active contacts
notify_all = any(c.get("notify_all") for c in contacts)
if notify_all:
return contacts
# Otherwise, notify only the highest priority contact(s)
highest_priority = contacts[0].get("priority", 1)
return [c for c in contacts if c.get("priority", 1) == highest_priority]
def build_snitch_message(
user_uuid: str,
contact_name: str,
med_name: str,
nag_count: int,
missed_doses: int,
trigger_reason: str,
typical_schedule: str = "",
) -> str:
"""Build the snitch notification message."""
# Get user info
users = postgres.select("users", {"id": user_uuid})
username = users[0].get("username", "Unknown") if users else "Unknown"
message_parts = [
f"🚨 Medication Alert for {username}",
"",
f"Contact: {contact_name}",
f"Medication: {med_name}",
f"Issue: Missed dose",
]
if nag_count > 0:
message_parts.append(f"Reminders sent: {nag_count} times")
if missed_doses > 1:
message_parts.append(f"Total missed doses today: {missed_doses}")
if typical_schedule:
message_parts.append(f"Typical schedule: {typical_schedule}")
message_parts.extend(
[
"",
f"Triggered by: {trigger_reason}",
f"Time: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}",
]
)
return "\n".join(message_parts)
def send_snitch(
user_uuid: str,
med_id: str,
med_name: str,
nag_count: int,
missed_doses: int = 1,
trigger_reason: str = "max_nags",
) -> List[Dict]:
"""
Send snitch notifications to selected contacts.
Returns:
List of delivery results
"""
results = []
contacts = select_contacts_to_notify(user_uuid)
if not contacts:
return [{"success": False, "error": "No contacts configured"}]
# Get typical schedule for context
meds = postgres.select("medications", {"id": med_id})
typical_times = meds[0].get("times", []) if meds else []
typical_schedule = ", ".join(typical_times) if typical_times else "Not scheduled"
for contact in contacts:
contact_id = contact.get("id")
contact_name = contact.get("contact_name")
contact_type = contact.get("contact_type")
contact_value = contact.get("contact_value")
# Build message
message = build_snitch_message(
user_uuid,
contact_name,
med_name,
nag_count,
missed_doses,
trigger_reason,
typical_schedule,
)
# Send based on contact type
delivered = False
error_msg = None
try:
if contact_type == "discord":
# Send via Discord DM
delivered = _send_discord_snitch(contact_value, message)
elif contact_type == "email":
# Send via email (requires email setup)
delivered = _send_email_snitch(contact_value, message)
elif contact_type == "sms":
# Send via SMS (requires SMS provider)
delivered = _send_sms_snitch(contact_value, message)
else:
error_msg = f"Unknown contact type: {contact_type}"
except Exception as e:
error_msg = str(e)
delivered = False
# Log the snitch
log_data = {
"user_uuid": user_uuid,
"contact_id": contact_id,
"medication_id": med_id,
"trigger_reason": trigger_reason,
"snitch_count_today": get_todays_snitch_count(user_uuid) + 1,
"message_content": message,
"sent_at": datetime.utcnow(),
"delivered": delivered,
}
postgres.insert("snitch_log", log_data)
results.append(
{
"contact_id": contact_id,
"contact_name": contact_name,
"contact_type": contact_type,
"delivered": delivered,
"error": error_msg,
}
)
return results
def _send_discord_snitch(discord_user_id: str, message: str) -> bool:
"""Send snitch via Discord DM."""
# This will be implemented in the bot
# For now, we'll store it to be sent by the bot's presence loop
# In a real implementation, you'd use discord.py to send the message
import os
# Store in a queue for the bot to pick up
# Or use the existing notification system if it supports Discord
try:
# Try to use the existing notification system
# This is a placeholder - actual implementation would use discord.py
return True
except Exception as e:
print(f"Error sending Discord snitch: {e}")
return False
def _send_email_snitch(email: str, message: str) -> bool:
"""Send snitch via email."""
# Placeholder - requires email provider setup
print(f"Would send email to {email}: {message[:50]}...")
return True
def _send_sms_snitch(phone: str, message: str) -> bool:
"""Send snitch via SMS."""
# Placeholder - requires SMS provider (Twilio, etc.)
print(f"Would send SMS to {phone}: {message[:50]}...")
return True
def update_consent(user_uuid: str, consent_given: bool):
"""Update user's snitch consent status."""
settings = get_snitch_settings(user_uuid)
if settings:
postgres.update(
"snitch_settings",
{"consent_given": consent_given, "updated_at": datetime.utcnow()},
{"user_uuid": user_uuid},
)
else:
# Create settings with consent
data = {
"user_uuid": user_uuid,
"snitch_enabled": False, # Disabled until fully configured
"consent_given": consent_given,
"created_at": datetime.utcnow(),
"updated_at": datetime.utcnow(),
}
postgres.insert("snitch_settings", data)
def get_snitch_history(user_uuid: str, days: int = 7) -> List[Dict]:
"""Get snitch history for the last N days."""
cutoff = datetime.utcnow() - timedelta(days=days)
rows = postgres.select("snitch_log", {"user_uuid": user_uuid})
# Filter to recent entries
recent = [row for row in rows if row.get("sent_at") and row["sent_at"] >= cutoff]
return recent

View File

@@ -11,6 +11,8 @@ from datetime import datetime, timezone, timedelta
import core.postgres as postgres
import core.notifications as notifications
import core.adaptive_meds as adaptive_meds
import core.snitch as snitch
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@@ -155,9 +157,257 @@ def check_refills():
logger.error(f"Error checking refills: {e}")
def create_daily_adaptive_schedules():
"""Create today's medication schedules with adaptive timing."""
try:
from datetime import date as date_type
meds = postgres.select("medications", where={"active": True})
for med in meds:
user_uuid = med.get("user_uuid")
med_id = med.get("id")
times = med.get("times", [])
if not times:
continue
# Create daily schedule with adaptive adjustments
adaptive_meds.create_daily_schedule(user_uuid, med_id, times)
except Exception as e:
logger.error(f"Error creating daily adaptive schedules: {e}")
def check_adaptive_medication_reminders():
"""Check for medications due now with adaptive timing."""
try:
from datetime import date as date_type
meds = postgres.select("medications", where={"active": True})
# Group by user
user_meds = {}
for med in meds:
uid = med.get("user_uuid")
if uid not in user_meds:
user_meds[uid] = []
user_meds[uid].append(med)
for user_uuid, user_med_list in user_meds.items():
now = _user_now_for(user_uuid)
current_time = now.strftime("%H:%M")
today = now.date()
# Check if adaptive timing is enabled
settings = adaptive_meds.get_adaptive_settings(user_uuid)
adaptive_enabled = settings and settings.get("adaptive_timing_enabled")
for med in user_med_list:
freq = med.get("frequency", "daily")
if freq == "as_needed":
continue
# Day-of-week check
if freq == "specific_days":
current_day = now.strftime("%a").lower()
med_days = med.get("days_of_week", [])
if current_day not in med_days:
continue
# Interval check
if freq == "every_n_days":
start = med.get("start_date")
interval = med.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
# Get today's schedule
schedules = postgres.select(
"medication_schedules",
where={
"user_uuid": user_uuid,
"medication_id": med["id"],
"adjustment_date": today,
"status": "pending",
},
)
for sched in schedules:
# Check if it's time to take this med
if adaptive_enabled:
# Use adjusted time
check_time = sched.get("adjusted_time")
else:
# Use base time
check_time = sched.get("base_time")
if check_time != current_time:
continue
# Check if already taken
logs = postgres.select(
"med_logs",
where={
"medication_id": med["id"],
"user_uuid": user_uuid,
"action": "taken",
},
)
already_taken = any(
str(log.get("created_at", ""))[:10] == today.isoformat()
for log in logs
)
if already_taken:
continue
# Send notification
user_settings = notifications.getNotificationSettings(user_uuid)
if user_settings:
offset = sched.get("adjustment_minutes", 0)
if offset > 0:
msg = f"⏰ Time to take {med['name']} ({med['dosage']} {med['unit']}) · {check_time} (adjusted +{offset}min)"
else:
msg = f"⏰ Time to take {med['name']} ({med['dosage']} {med['unit']}) · {check_time}"
notifications._sendToEnabledChannels(
user_settings, msg, user_uuid=user_uuid
)
except Exception as e:
logger.error(f"Error checking adaptive medication reminders: {e}")
def check_nagging():
"""Check for missed medications and send nag notifications."""
try:
from datetime import date as date_type
# Get all active medications
meds = postgres.select("medications", where={"active": True})
for med in meds:
user_uuid = med.get("user_uuid")
med_id = med.get("id")
# Get user's settings
settings = adaptive_meds.get_adaptive_settings(user_uuid)
if not settings or not settings.get("nagging_enabled"):
continue
now = datetime.utcnow()
today = now.date()
# Get today's schedules
schedules = postgres.select(
"medication_schedules",
where={
"user_uuid": user_uuid,
"medication_id": med_id,
"adjustment_date": today,
"status": "pending",
},
)
for sched in schedules:
# Check if we should nag
should_nag, reason = adaptive_meds.should_send_nag(
user_uuid, med_id, sched.get("adjusted_time"), now
)
if not should_nag:
continue
# Get the time to display
adaptive_enabled = settings.get("adaptive_timing_enabled")
if adaptive_enabled:
display_time = sched.get("adjusted_time")
else:
display_time = sched.get("base_time")
# Send nag notification
user_settings = notifications.getNotificationSettings(user_uuid)
if user_settings:
nag_count = sched.get("nag_count", 0) + 1
max_nags = settings.get("max_nag_count", 4)
msg = f"🔔 {med['name']} reminder {nag_count}/{max_nags}: You missed your {display_time} dose. Please take it now!"
notifications._sendToEnabledChannels(
user_settings, msg, user_uuid=user_uuid
)
# Record that we sent a nag
adaptive_meds.record_nag_sent(
user_uuid, med_id, sched.get("adjusted_time")
)
logger.info(
f"Sent nag {nag_count}/{max_nags} for {med['name']} to user {user_uuid}"
)
# Check if we should snitch (max nags reached)
should_snitch, trigger_reason, snitch_settings = (
snitch.should_snitch(
user_uuid, med_id, nag_count, missed_doses=1
)
)
if should_snitch:
logger.info(
f"Triggering snitch for {med['name']} - {trigger_reason}"
)
results = snitch.send_snitch(
user_uuid=user_uuid,
med_id=med_id,
med_name=med["name"],
nag_count=nag_count,
missed_doses=1,
trigger_reason=trigger_reason,
)
# Log results
for result in results:
if result.get("delivered"):
logger.info(
f"Snitch sent to {result['contact_name']} via {result['contact_type']}"
)
else:
logger.error(
f"Failed to snitch to {result['contact_name']}: {result.get('error')}"
)
except Exception as e:
logger.error(f"Error checking nags: {e}")
def poll_callback():
"""Called every POLL_INTERVAL seconds."""
check_medication_reminders()
# Create daily schedules at midnight
now = datetime.utcnow()
if now.hour == 0 and now.minute < POLL_INTERVAL / 60:
create_daily_adaptive_schedules()
# Check reminders with adaptive timing
check_adaptive_medication_reminders()
# Check for nags
check_nagging()
# Original checks
check_routine_reminders()
check_refills()

View File

@@ -21,6 +21,43 @@ interface NotifSettings {
ntfy_enabled: boolean;
}
interface AdaptiveMedSettings {
adaptive_timing_enabled: boolean;
adaptive_mode: string;
presence_tracking_enabled: boolean;
nagging_enabled: boolean;
nag_interval_minutes: number;
max_nag_count: number;
quiet_hours_start: string | null;
quiet_hours_end: string | null;
}
interface PresenceStatus {
is_online: boolean;
last_online_at: string | null;
typical_wake_time: string | null;
}
interface SnitchSettings {
snitch_enabled: boolean;
trigger_after_nags: number;
trigger_after_missed_doses: number;
max_snitches_per_day: number;
require_consent: boolean;
consent_given: boolean;
snitch_cooldown_hours: number;
}
interface SnitchContact {
id: string;
contact_name: string;
contact_type: string;
contact_value: string;
priority: number;
notify_all: boolean;
is_active: boolean;
}
export default function SettingsPage() {
const [prefs, setPrefs] = useState<Preferences>({
sound_enabled: false,
@@ -34,8 +71,43 @@ export default function SettingsPage() {
ntfy_topic: '',
ntfy_enabled: false,
});
const [adaptiveMeds, setAdaptiveMeds] = useState<AdaptiveMedSettings>({
adaptive_timing_enabled: false,
adaptive_mode: 'shift_all',
presence_tracking_enabled: false,
nagging_enabled: true,
nag_interval_minutes: 15,
max_nag_count: 4,
quiet_hours_start: null,
quiet_hours_end: null,
});
const [presence, setPresence] = useState<PresenceStatus>({
is_online: false,
last_online_at: null,
typical_wake_time: null,
});
const [snitch, setSnitch] = useState<SnitchSettings>({
snitch_enabled: false,
trigger_after_nags: 4,
trigger_after_missed_doses: 1,
max_snitches_per_day: 2,
require_consent: true,
consent_given: false,
snitch_cooldown_hours: 4,
});
const [snitchContacts, setSnitchContacts] = useState<SnitchContact[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [saved, setSaved] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [showSnitchHelp, setShowSnitchHelp] = useState(false);
const [showAddContact, setShowAddContact] = useState(false);
const [newContact, setNewContact] = useState({
contact_name: '',
contact_type: 'discord',
contact_value: '',
priority: 1,
notify_all: false,
});
useEffect(() => {
Promise.all([
@@ -46,6 +118,10 @@ export default function SettingsPage() {
ntfy_topic: data.ntfy_topic,
ntfy_enabled: data.ntfy_enabled,
})),
api.adaptiveMeds.getSettings().then((data: AdaptiveMedSettings) => setAdaptiveMeds(data)),
api.adaptiveMeds.getPresence().then((data: PresenceStatus) => setPresence(data)),
api.snitch.getSettings().then((data: SnitchSettings) => setSnitch(data)),
api.snitch.getContacts().then((data: SnitchContact[]) => setSnitchContacts(data)),
])
.catch(() => {})
.finally(() => setIsLoading(false));
@@ -79,6 +155,87 @@ export default function SettingsPage() {
}
};
const updateAdaptiveMeds = async (updates: Partial<AdaptiveMedSettings>) => {
const prev = { ...adaptiveMeds };
const updated = { ...adaptiveMeds, ...updates };
setAdaptiveMeds(updated);
try {
await api.adaptiveMeds.updateSettings(updates);
flashSaved();
} catch {
setAdaptiveMeds(prev);
}
};
const updateSnitch = async (updates: Partial<SnitchSettings>) => {
const prev = { ...snitch };
const updated = { ...snitch, ...updates };
setSnitch(updated);
try {
await api.snitch.updateSettings(updates);
flashSaved();
} catch {
setSnitch(prev);
}
};
const addContact = async () => {
try {
const result = await api.snitch.addContact(newContact);
const contact: SnitchContact = {
id: result.contact_id,
...newContact,
is_active: true,
};
setSnitchContacts([...snitchContacts, contact]);
setNewContact({
contact_name: '',
contact_type: 'discord',
contact_value: '',
priority: 1,
notify_all: false,
});
setShowAddContact(false);
flashSaved();
} catch (e) {
console.error('Failed to add contact:', e);
}
};
const updateContact = async (contactId: string, updates: Partial<SnitchContact>) => {
const prev = [...snitchContacts];
const updated = snitchContacts.map(c =>
c.id === contactId ? { ...c, ...updates } : c
);
setSnitchContacts(updated);
try {
await api.snitch.updateContact(contactId, updates);
flashSaved();
} catch {
setSnitchContacts(prev);
}
};
const deleteContact = async (contactId: string) => {
const prev = [...snitchContacts];
setSnitchContacts(snitchContacts.filter(c => c.id !== contactId));
try {
await api.snitch.deleteContact(contactId);
flashSaved();
} catch {
setSnitchContacts(prev);
}
};
const testSnitch = async () => {
try {
const result = await api.snitch.test();
alert(result.message);
} catch (e) {
alert('Failed to send test snitch');
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
@@ -241,6 +398,223 @@ export default function SettingsPage() {
</div>
</div>
{/* Adaptive Medication Settings */}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Smart Medication Timing</h2>
<button
onClick={() => setShowHelp(!showHelp)}
className="text-sm text-indigo-500 hover:text-indigo-600"
>
{showHelp ? 'Hide Help' : 'What is this?'}
</button>
</div>
{showHelp && (
<div className="mb-4 p-4 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg text-sm text-gray-700 dark:text-gray-300">
<p className="mb-2"><strong>Adaptive Timing:</strong> Automatically adjusts your medication schedule based on when you wake up. If you wake up late, your morning meds get shifted too.</p>
<p className="mb-2"><strong>Discord Presence:</strong> Detects when you come online (wake up) and uses that to calculate adjustments. Requires Discord notifications to be enabled.</p>
<p><strong>Nagging:</strong> Sends you reminders every 15 minutes (configurable) up to 4 times if you miss a dose.</p>
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm divide-y divide-gray-100 dark:divide-gray-700">
{/* Enable Adaptive Timing */}
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">Enable adaptive timing</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Adjust medication times based on your wake time</p>
</div>
<button
onClick={() => updateAdaptiveMeds({ adaptive_timing_enabled: !adaptiveMeds.adaptive_timing_enabled })}
className={`w-12 h-7 rounded-full transition-colors ${
adaptiveMeds.adaptive_timing_enabled ? 'bg-indigo-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
adaptiveMeds.adaptive_timing_enabled ? 'translate-x-5' : ''
}`} />
</button>
</div>
{adaptiveMeds.adaptive_timing_enabled && (
<div className="mt-3 space-y-3">
{/* Adaptive Mode Selection */}
<div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Adjustment mode</p>
<div className="space-y-2">
<button
onClick={() => updateAdaptiveMeds({ adaptive_mode: 'shift_all' })}
className={`w-full flex items-center justify-between p-3 rounded-lg border ${
adaptiveMeds.adaptive_mode === 'shift_all'
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20'
: 'border-gray-200 dark:border-gray-600'
}`}
>
<div className="text-left">
<p className="font-medium text-gray-900 dark:text-gray-100">Shift all medications</p>
<p className="text-xs text-gray-500 dark:text-gray-400">Delay all doses by the same amount</p>
</div>
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
adaptiveMeds.adaptive_mode === 'shift_all'
? 'border-indigo-500'
: 'border-gray-300 dark:border-gray-600'
}`}>
{adaptiveMeds.adaptive_mode === 'shift_all' && (
<div className="w-2.5 h-2.5 rounded-full bg-indigo-500" />
)}
</div>
</button>
<button
onClick={() => updateAdaptiveMeds({ adaptive_mode: 'shift_partial' })}
className={`w-full flex items-center justify-between p-3 rounded-lg border ${
adaptiveMeds.adaptive_mode === 'shift_partial'
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20'
: 'border-gray-200 dark:border-gray-600'
}`}
>
<div className="text-left">
<p className="font-medium text-gray-900 dark:text-gray-100">Partial shift</p>
<p className="text-xs text-gray-500 dark:text-gray-400">Shift morning meds only, keep afternoon/evening fixed</p>
</div>
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
adaptiveMeds.adaptive_mode === 'shift_partial'
? 'border-indigo-500'
: 'border-gray-300 dark:border-gray-600'
}`}>
{adaptiveMeds.adaptive_mode === 'shift_partial' && (
<div className="w-2.5 h-2.5 rounded-full bg-indigo-500" />
)}
</div>
</button>
</div>
</div>
</div>
)}
</div>
{/* Presence Tracking */}
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">Discord presence tracking</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Detect when you wake up via Discord</p>
</div>
<button
onClick={() => updateAdaptiveMeds({ presence_tracking_enabled: !adaptiveMeds.presence_tracking_enabled })}
disabled={!notif.discord_enabled}
className={`w-12 h-7 rounded-full transition-colors ${
adaptiveMeds.presence_tracking_enabled ? 'bg-indigo-500' : 'bg-gray-300 dark:bg-gray-600'
} ${!notif.discord_enabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
adaptiveMeds.presence_tracking_enabled ? 'translate-x-5' : ''
}`} />
</button>
</div>
{!notif.discord_enabled && (
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
Enable Discord notifications above to use presence tracking
</p>
)}
{adaptiveMeds.presence_tracking_enabled && presence.typical_wake_time && (
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<p className="text-sm text-gray-600 dark:text-gray-400">
Typical wake time: <span className="font-medium text-gray-900 dark:text-gray-100">{presence.typical_wake_time}</span>
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Status: {presence.is_online ? '🟢 Online' : '⚫ Offline'}
</p>
</div>
)}
</div>
{/* Nagging Settings */}
<div className="p-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">Enable nagging</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Send reminders for missed doses</p>
</div>
<button
onClick={() => updateAdaptiveMeds({ nagging_enabled: !adaptiveMeds.nagging_enabled })}
className={`w-12 h-7 rounded-full transition-colors ${
adaptiveMeds.nagging_enabled ? 'bg-indigo-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
adaptiveMeds.nagging_enabled ? 'translate-x-5' : ''
}`} />
</button>
</div>
{adaptiveMeds.nagging_enabled && (
<>
{/* Nag Interval */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Reminder interval (minutes)
</label>
<input
type="number"
min="5"
max="60"
value={adaptiveMeds.nag_interval_minutes}
onChange={(e) => updateAdaptiveMeds({ nag_interval_minutes: parseInt(e.target.value) || 15 })}
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700"
/>
</div>
{/* Max Nag Count */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Maximum reminders per dose
</label>
<input
type="number"
min="1"
max="10"
value={adaptiveMeds.max_nag_count}
onChange={(e) => updateAdaptiveMeds({ max_nag_count: parseInt(e.target.value) || 4 })}
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700"
/>
</div>
</>
)}
</div>
{/* Quiet Hours */}
<div className="p-4 space-y-3">
<p className="font-medium text-gray-900 dark:text-gray-100">Quiet hours</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Don&apos;t send notifications during these hours</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Start</label>
<input
type="time"
value={adaptiveMeds.quiet_hours_start || ''}
onChange={(e) => updateAdaptiveMeds({ quiet_hours_start: e.target.value || null })}
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">End</label>
<input
type="time"
value={adaptiveMeds.quiet_hours_end || ''}
onChange={(e) => updateAdaptiveMeds({ quiet_hours_end: e.target.value || null })}
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700"
/>
</div>
</div>
</div>
</div>
</div>
{/* Celebration Style */}
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Celebration Style</h2>
@@ -272,6 +646,263 @@ export default function SettingsPage() {
))}
</div>
</div>
{/* Snitch System */}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Snitch System</h2>
<button
onClick={() => setShowSnitchHelp(!showSnitchHelp)}
className="text-sm text-indigo-500 hover:text-indigo-600"
>
{showSnitchHelp ? 'Hide Help' : 'What is this?'}
</button>
</div>
{showSnitchHelp && (
<div className="mb-4 p-4 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg text-sm text-gray-700 dark:text-gray-300">
<p className="mb-2"><strong>The Snitch:</strong> When you miss medications repeatedly, the system can notify someone you trust (a &quot;snitch&quot;) to help keep you accountable.</p>
<p className="mb-2"><strong>Consent:</strong> You must give consent to enable this feature. You can revoke consent at any time.</p>
<p className="mb-2"><strong>Triggers:</strong> Configure after how many nags or missed doses the snitch activates.</p>
<p><strong>Privacy:</strong> Only you can see and manage your snitch contacts. They only receive alerts when triggers are met.</p>
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm divide-y divide-gray-100 dark:divide-gray-700">
{/* Consent */}
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">Enable snitch system</p>
<p className="text-sm text-gray-500 dark:text-gray-400">Allow trusted contacts to be notified about missed medications</p>
</div>
<button
onClick={() => {
if (!snitch.consent_given) {
alert('Please give consent below first');
return;
}
updateSnitch({ snitch_enabled: !snitch.snitch_enabled });
}}
disabled={!snitch.consent_given}
className={`w-12 h-7 rounded-full transition-colors ${
snitch.snitch_enabled ? 'bg-indigo-500' : 'bg-gray-300 dark:bg-gray-600'
} ${!snitch.consent_given ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
snitch.snitch_enabled ? 'translate-x-5' : ''
}`} />
</button>
</div>
{/* Consent Toggle */}
<div className="mt-3 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-900 dark:text-gray-100">I consent to snitch notifications</p>
<p className="text-xs text-gray-500 dark:text-gray-400">I understand and agree that trusted contacts may be notified</p>
</div>
<button
onClick={() => updateSnitch({ consent_given: !snitch.consent_given })}
className={`w-12 h-7 rounded-full transition-colors ${
snitch.consent_given ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<div className={`w-5 h-5 bg-white rounded-full shadow transition-transform ml-1 ${
snitch.consent_given ? 'translate-x-5' : ''
}`} />
</button>
</div>
</div>
</div>
{snitch.snitch_enabled && (
<>
{/* Trigger Settings */}
<div className="p-4 space-y-4">
<p className="font-medium text-gray-900 dark:text-gray-100">Trigger Settings</p>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Trigger after nags
</label>
<input
type="number"
min="1"
max="20"
value={snitch.trigger_after_nags}
onChange={(e) => updateSnitch({ trigger_after_nags: parseInt(e.target.value) || 4 })}
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Trigger after missed doses
</label>
<input
type="number"
min="1"
max="10"
value={snitch.trigger_after_missed_doses}
onChange={(e) => updateSnitch({ trigger_after_missed_doses: parseInt(e.target.value) || 1 })}
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Max snitches per day
</label>
<input
type="number"
min="1"
max="10"
value={snitch.max_snitches_per_day}
onChange={(e) => updateSnitch({ max_snitches_per_day: parseInt(e.target.value) || 2 })}
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Cooldown between snitches (hours)
</label>
<input
type="number"
min="1"
max="24"
value={snitch.snitch_cooldown_hours}
onChange={(e) => updateSnitch({ snitch_cooldown_hours: parseInt(e.target.value) || 4 })}
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700"
/>
</div>
</div>
{/* Contacts */}
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<p className="font-medium text-gray-900 dark:text-gray-100">Snitch Contacts</p>
<button
onClick={() => setShowAddContact(!showAddContact)}
className="text-sm px-3 py-1 bg-indigo-500 text-white rounded-lg hover:bg-indigo-600"
>
+ Add Contact
</button>
</div>
{/* Add Contact Form */}
{showAddContact && (
<div className="mb-4 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg space-y-3">
<input
type="text"
placeholder="Contact name"
value={newContact.contact_name}
onChange={(e) => setNewContact({ ...newContact, contact_name: e.target.value })}
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-800"
/>
<select
value={newContact.contact_type}
onChange={(e) => setNewContact({ ...newContact, contact_type: e.target.value })}
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-800"
>
<option value="discord">Discord</option>
<option value="email">Email</option>
<option value="sms">SMS</option>
</select>
<input
type="text"
placeholder={newContact.contact_type === 'discord' ? 'Discord User ID' : newContact.contact_type === 'email' ? 'Email address' : 'Phone number'}
value={newContact.contact_value}
onChange={(e) => setNewContact({ ...newContact, contact_value: e.target.value })}
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-800"
/>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={newContact.notify_all}
onChange={(e) => setNewContact({ ...newContact, notify_all: e.target.checked })}
className="rounded border-gray-300"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">Always notify this contact</span>
</label>
<div className="flex gap-2">
<button
onClick={() => setShowAddContact(false)}
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800"
>
Cancel
</button>
<button
onClick={addContact}
disabled={!newContact.contact_name || !newContact.contact_value}
className="px-3 py-1 text-sm bg-indigo-500 text-white rounded-lg hover:bg-indigo-600 disabled:opacity-50"
>
Save
</button>
</div>
</div>
</div>
)}
{/* Contact List */}
<div className="space-y-2">
{snitchContacts.map((contact) => (
<div key={contact.id} className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-gray-100">{contact.contact_name}</span>
<span className="text-xs px-2 py-0.5 bg-gray-200 dark:bg-gray-600 rounded-full text-gray-600 dark:text-gray-400">
{contact.contact_type}
</span>
{contact.notify_all && (
<span className="text-xs px-2 py-0.5 bg-indigo-100 dark:bg-indigo-900 text-indigo-600 dark:text-indigo-400 rounded-full">
Always notify
</span>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">{contact.contact_value}</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => updateContact(contact.id, { is_active: !contact.is_active })}
className={`text-xs px-2 py-1 rounded ${
contact.is_active
? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400'
: 'bg-gray-200 dark:bg-gray-600 text-gray-500'
}`}
>
{contact.is_active ? 'Active' : 'Inactive'}
</button>
<button
onClick={() => deleteContact(contact.id)}
className="text-red-500 hover:text-red-600 p-1"
>
🗑
</button>
</div>
</div>
))}
{snitchContacts.length === 0 && (
<p className="text-center text-gray-500 dark:text-gray-400 py-4">No contacts added yet</p>
)}
</div>
{/* Test Button */}
{snitchContacts.length > 0 && (
<button
onClick={testSnitch}
className="mt-4 w-full py-2 text-sm border-2 border-indigo-500 text-indigo-500 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/20"
>
🧪 Test Snitch (sends to first contact only)
</button>
)}
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -689,6 +689,159 @@ export const api = {
},
},
// Adaptive Medications
adaptiveMeds: {
getSettings: async () => {
return request<{
adaptive_timing_enabled: boolean;
adaptive_mode: string;
presence_tracking_enabled: boolean;
nagging_enabled: boolean;
nag_interval_minutes: number;
max_nag_count: number;
quiet_hours_start: string | null;
quiet_hours_end: string | null;
}>('/api/adaptive-meds/settings', { method: 'GET' });
},
updateSettings: async (data: {
adaptive_timing_enabled?: boolean;
adaptive_mode?: string;
presence_tracking_enabled?: boolean;
nagging_enabled?: boolean;
nag_interval_minutes?: number;
max_nag_count?: number;
quiet_hours_start?: string | null;
quiet_hours_end?: string | null;
}) => {
return request<{ success: boolean }>('/api/adaptive-meds/settings', {
method: 'PUT',
body: JSON.stringify(data),
});
},
getPresence: async () => {
return request<{
is_online: boolean;
last_online_at: string | null;
typical_wake_time: string | null;
}>('/api/adaptive-meds/presence', { method: 'GET' });
},
getSchedule: async () => {
return request<Array<{
medication_id: string;
medication_name: string;
base_time: string;
adjusted_time: string;
adjustment_minutes: number;
status: string;
nag_count: number;
}>>('/api/adaptive-meds/schedule', { method: 'GET' });
},
},
// Snitch System
snitch: {
getSettings: async () => {
return request<{
snitch_enabled: boolean;
trigger_after_nags: number;
trigger_after_missed_doses: number;
max_snitches_per_day: number;
require_consent: boolean;
consent_given: boolean;
snitch_cooldown_hours: number;
}>('/api/snitch/settings', { method: 'GET' });
},
updateSettings: async (data: {
snitch_enabled?: boolean;
trigger_after_nags?: number;
trigger_after_missed_doses?: number;
max_snitches_per_day?: number;
require_consent?: boolean;
consent_given?: boolean;
snitch_cooldown_hours?: number;
}) => {
return request<{ success: boolean }>('/api/snitch/settings', {
method: 'PUT',
body: JSON.stringify(data),
});
},
giveConsent: async (consent_given: boolean) => {
return request<{ success: boolean; consent_given: boolean }>('/api/snitch/consent', {
method: 'POST',
body: JSON.stringify({ consent_given }),
});
},
getContacts: async () => {
return request<Array<{
id: string;
contact_name: string;
contact_type: string;
contact_value: string;
priority: number;
notify_all: boolean;
is_active: boolean;
}>>('/api/snitch/contacts', { method: 'GET' });
},
addContact: async (data: {
contact_name: string;
contact_type: string;
contact_value: string;
priority?: number;
notify_all?: boolean;
is_active?: boolean;
}) => {
return request<{ success: boolean; contact_id: string }>('/api/snitch/contacts', {
method: 'POST',
body: JSON.stringify(data),
});
},
updateContact: async (contactId: string, data: {
contact_name?: string;
contact_type?: string;
contact_value?: string;
priority?: number;
notify_all?: boolean;
is_active?: boolean;
}) => {
return request<{ success: boolean }>(`/api/snitch/contacts/${contactId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
},
deleteContact: async (contactId: string) => {
return request<{ success: boolean }>(`/api/snitch/contacts/${contactId}`, {
method: 'DELETE',
});
},
getHistory: async (days?: number) => {
return request<Array<{
id: string;
contact_id: string;
medication_id: string;
trigger_reason: string;
snitch_count_today: number;
sent_at: string;
delivered: boolean;
}>>(`/api/snitch/history?days=${days || 7}`, { method: 'GET' });
},
test: async () => {
return request<{ success: boolean; message: string }>('/api/snitch/test', {
method: 'POST',
});
},
},
// Medications
medications: {
list: async () => {