Compare commits
5 Commits
1ed187b0dd
...
35f51e6d27
| Author | SHA1 | Date | |
|---|---|---|---|
| 35f51e6d27 | |||
| a6ae4e13fd | |||
| 69163a37d1 | |||
| 84c6032dc9 | |||
| d4fb41ae6b |
24
api/main.py
24
api/main.py
@@ -21,6 +21,8 @@ import api.routes.notifications as notifications_routes
|
|||||||
import api.routes.preferences as preferences_routes
|
import api.routes.preferences as preferences_routes
|
||||||
import api.routes.rewards as rewards_routes
|
import api.routes.rewards as rewards_routes
|
||||||
import api.routes.victories as victories_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__)
|
app = flask.Flask(__name__)
|
||||||
CORS(app)
|
CORS(app)
|
||||||
@@ -37,6 +39,8 @@ ROUTE_MODULES = [
|
|||||||
preferences_routes,
|
preferences_routes,
|
||||||
rewards_routes,
|
rewards_routes,
|
||||||
victories_routes,
|
victories_routes,
|
||||||
|
adaptive_meds_routes,
|
||||||
|
snitch_routes,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -161,8 +165,13 @@ def _seed_templates_if_empty():
|
|||||||
count = postgres.count("routine_templates")
|
count = postgres.count("routine_templates")
|
||||||
if count == 0:
|
if count == 0:
|
||||||
import logging
|
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):
|
if os.path.exists(seed_path):
|
||||||
with open(seed_path, "r") as f:
|
with open(seed_path, "r") as f:
|
||||||
sql = f.read()
|
sql = f.read()
|
||||||
@@ -171,6 +180,7 @@ def _seed_templates_if_empty():
|
|||||||
logging.getLogger(__name__).info("Templates seeded successfully.")
|
logging.getLogger(__name__).info("Templates seeded successfully.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logging.getLogger(__name__).warning(f"Failed to seed templates: {e}")
|
logging.getLogger(__name__).warning(f"Failed to seed templates: {e}")
|
||||||
|
|
||||||
|
|
||||||
@@ -180,8 +190,13 @@ def _seed_rewards_if_empty():
|
|||||||
count = postgres.count("reward_pool")
|
count = postgres.count("reward_pool")
|
||||||
if count == 0:
|
if count == 0:
|
||||||
import logging
|
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):
|
if os.path.exists(seed_path):
|
||||||
with open(seed_path, "r") as f:
|
with open(seed_path, "r") as f:
|
||||||
sql = f.read()
|
sql = f.read()
|
||||||
@@ -190,6 +205,7 @@ def _seed_rewards_if_empty():
|
|||||||
logging.getLogger(__name__).info("Rewards seeded successfully.")
|
logging.getLogger(__name__).info("Rewards seeded successfully.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logging.getLogger(__name__).warning(f"Failed to seed rewards: {e}")
|
logging.getLogger(__name__).warning(f"Failed to seed rewards: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
185
api/routes/adaptive_meds.py
Normal file
185
api/routes/adaptive_meds.py
Normal 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
293
api/routes/snitch.py
Normal 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
|
||||||
83
bot/bot.py
83
bot/bot.py
@@ -577,5 +577,88 @@ async def beforeBackgroundLoop():
|
|||||||
await client.wait_until_ready()
|
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__":
|
if __name__ == "__main__":
|
||||||
client.run(DISCORD_BOT_TOKEN)
|
client.run(DISCORD_BOT_TOKEN)
|
||||||
|
|||||||
12
bot/config.json
Normal file
12
bot/config.json
Normal 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."
|
||||||
|
}
|
||||||
@@ -200,3 +200,96 @@ CREATE TABLE IF NOT EXISTS med_logs (
|
|||||||
notes TEXT,
|
notes TEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
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
358
core/adaptive_meds.py
Normal 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
339
core/snitch.py
Normal 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
|
||||||
@@ -11,6 +11,8 @@ from datetime import datetime, timezone, timedelta
|
|||||||
|
|
||||||
import core.postgres as postgres
|
import core.postgres as postgres
|
||||||
import core.notifications as notifications
|
import core.notifications as notifications
|
||||||
|
import core.adaptive_meds as adaptive_meds
|
||||||
|
import core.snitch as snitch
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -155,9 +157,257 @@ def check_refills():
|
|||||||
logger.error(f"Error checking refills: {e}")
|
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():
|
def poll_callback():
|
||||||
"""Called every POLL_INTERVAL seconds."""
|
"""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_routine_reminders()
|
||||||
check_refills()
|
check_refills()
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,43 @@ interface NotifSettings {
|
|||||||
ntfy_enabled: boolean;
|
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() {
|
export default function SettingsPage() {
|
||||||
const [prefs, setPrefs] = useState<Preferences>({
|
const [prefs, setPrefs] = useState<Preferences>({
|
||||||
sound_enabled: false,
|
sound_enabled: false,
|
||||||
@@ -34,8 +71,43 @@ export default function SettingsPage() {
|
|||||||
ntfy_topic: '',
|
ntfy_topic: '',
|
||||||
ntfy_enabled: false,
|
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 [isLoading, setIsLoading] = useState(true);
|
||||||
const [saved, setSaved] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
@@ -46,6 +118,10 @@ export default function SettingsPage() {
|
|||||||
ntfy_topic: data.ntfy_topic,
|
ntfy_topic: data.ntfy_topic,
|
||||||
ntfy_enabled: data.ntfy_enabled,
|
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(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setIsLoading(false));
|
.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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[50vh]">
|
<div className="flex items-center justify-center min-h-[50vh]">
|
||||||
@@ -241,6 +398,223 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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'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 */}
|
{/* Celebration Style */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Celebration Style</h2>
|
<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>
|
||||||
</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 "snitch") 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
medications: {
|
medications: {
|
||||||
list: async () => {
|
list: async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user