Fix medication system and rename to Synculous.
- Add all 14 missing database tables (medications, med_logs, routines, etc.) - Rewrite medication scheduling: support specific days, every N days, as-needed (PRN) - Fix taken_times matching: match by created_at date, not scheduled_time string - Fix adherence calculation: taken / expected doses, not taken / (taken + skipped) - Add formatSchedule() helper for readable display - Update client types and API layer - Rename brilli-ins-client → synculous-client - Make client PWA: add manifest, service worker, icons - Bind dev server to 0.0.0.0 for network access - Fix SVG icon bugs in Icons.tsx - Add .dockerignore for client npm caching Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ Domain routes are registered via the routes registry.
|
||||
|
||||
import os
|
||||
import flask
|
||||
from flask_cors import CORS
|
||||
import core.auth as auth
|
||||
import core.users as users
|
||||
import core.postgres as postgres
|
||||
@@ -18,6 +19,7 @@ import api.routes.routine_stats as routine_stats_routes
|
||||
import api.routes.routine_tags as routine_tags_routes
|
||||
|
||||
app = flask.Flask(__name__)
|
||||
CORS(app)
|
||||
|
||||
ROUTE_MODULES = [
|
||||
routines_routes,
|
||||
|
||||
@@ -4,8 +4,11 @@ Medications API - medication scheduling, logging, and adherence tracking
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, date, timedelta
|
||||
|
||||
import flask
|
||||
import jwt
|
||||
from psycopg2.extras import Json
|
||||
import core.auth as auth
|
||||
import core.postgres as postgres
|
||||
|
||||
@@ -30,6 +33,85 @@ def _auth(request):
|
||||
return user_uuid
|
||||
|
||||
|
||||
_JSON_COLS = {"times", "days_of_week"}
|
||||
|
||||
|
||||
def _wrap_json(data):
|
||||
"""Wrap list/dict values in Json() for psycopg2 when targeting JSON columns."""
|
||||
return {k: Json(v) if k in _JSON_COLS and isinstance(v, (list, dict)) else v for k, v in data.items()}
|
||||
|
||||
|
||||
def _is_med_due_today(med, today, current_day):
|
||||
"""Check if a medication is scheduled for today based on its frequency."""
|
||||
freq = med.get("frequency", "daily")
|
||||
if freq in ("daily", "twice_daily"):
|
||||
return True
|
||||
if freq == "as_needed":
|
||||
return True # always show PRN, but with no scheduled times
|
||||
if freq == "specific_days":
|
||||
days = med.get("days_of_week", [])
|
||||
return current_day in days
|
||||
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) else datetime.strptime(str(start), "%Y-%m-%d").date()
|
||||
days_since = (today - start_d).days
|
||||
return days_since >= 0 and days_since % interval == 0
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _compute_next_dose_date(med):
|
||||
"""Compute the next dose date for every_n_days medications."""
|
||||
interval = med.get("interval_days")
|
||||
if not interval:
|
||||
return None
|
||||
return (date.today() + timedelta(days=interval)).isoformat()
|
||||
|
||||
|
||||
def _count_expected_doses(med, period_start, days):
|
||||
"""Count expected doses in a period based on frequency and schedule."""
|
||||
freq = med.get("frequency", "daily")
|
||||
times_per_day = len(med.get("times", [])) or 1
|
||||
|
||||
if freq == "as_needed":
|
||||
return 0 # PRN has no expected doses
|
||||
if freq in ("daily", "twice_daily"):
|
||||
return days * times_per_day
|
||||
if freq == "specific_days":
|
||||
dow = med.get("days_of_week", [])
|
||||
count = 0
|
||||
for d in range(days):
|
||||
check_date = period_start + timedelta(days=d)
|
||||
if check_date.strftime("%a").lower() in dow:
|
||||
count += times_per_day
|
||||
return count
|
||||
if freq == "every_n_days":
|
||||
interval = med.get("interval_days", 1)
|
||||
start = med.get("start_date")
|
||||
if not start:
|
||||
return 0
|
||||
start_d = start if isinstance(start, date) else datetime.strptime(str(start), "%Y-%m-%d").date()
|
||||
count = 0
|
||||
for d in range(days):
|
||||
check_date = period_start + timedelta(days=d)
|
||||
diff = (check_date - start_d).days
|
||||
if diff >= 0 and diff % interval == 0:
|
||||
count += times_per_day
|
||||
return count
|
||||
return days * times_per_day
|
||||
|
||||
|
||||
def _count_logs_in_period(logs, period_start_str, action):
|
||||
"""Count logs of a given action where created_at >= period_start."""
|
||||
return sum(
|
||||
1 for log in logs
|
||||
if log.get("action") == action
|
||||
and str(log.get("created_at", ""))[:10] >= period_start_str
|
||||
)
|
||||
|
||||
|
||||
def register(app):
|
||||
|
||||
# ── Medications CRUD ──────────────────────────────────────────
|
||||
@@ -45,7 +127,7 @@ def register(app):
|
||||
|
||||
@app.route("/api/medications", methods=["POST"])
|
||||
def api_addMedication():
|
||||
"""Add a medication. Body: {name, dosage, unit, frequency, times: ["08:00","20:00"], notes?}"""
|
||||
"""Add a medication. Body: {name, dosage, unit, frequency, times?, days_of_week?, interval_days?, start_date?, notes?}"""
|
||||
user_uuid = _auth(flask.request)
|
||||
if not user_uuid:
|
||||
return flask.jsonify({"error": "unauthorized"}), 401
|
||||
@@ -56,11 +138,37 @@ def register(app):
|
||||
missing = [f for f in required if not data.get(f)]
|
||||
if missing:
|
||||
return flask.jsonify({"error": f"missing required fields: {', '.join(missing)}"}), 400
|
||||
data["id"] = str(uuid.uuid4())
|
||||
data["user_uuid"] = user_uuid
|
||||
data["times"] = data.get("times", [])
|
||||
data["active"] = True
|
||||
med = postgres.insert("medications", data)
|
||||
|
||||
row = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"user_uuid": user_uuid,
|
||||
"name": data["name"],
|
||||
"dosage": data["dosage"],
|
||||
"unit": data["unit"],
|
||||
"frequency": data["frequency"],
|
||||
"times": data.get("times", []),
|
||||
"days_of_week": data.get("days_of_week", []),
|
||||
"interval_days": data.get("interval_days"),
|
||||
"start_date": data.get("start_date"),
|
||||
"notes": data.get("notes"),
|
||||
"active": True,
|
||||
}
|
||||
|
||||
# Compute next_dose_date for interval meds
|
||||
if data.get("frequency") == "every_n_days" and data.get("start_date") and data.get("interval_days"):
|
||||
start = datetime.strptime(data["start_date"], "%Y-%m-%d").date()
|
||||
today = date.today()
|
||||
if start > today:
|
||||
row["next_dose_date"] = data["start_date"]
|
||||
else:
|
||||
days_since = (today - start).days
|
||||
remainder = days_since % data["interval_days"]
|
||||
if remainder == 0:
|
||||
row["next_dose_date"] = today.isoformat()
|
||||
else:
|
||||
row["next_dose_date"] = (today + timedelta(days=data["interval_days"] - remainder)).isoformat()
|
||||
|
||||
med = postgres.insert("medications", _wrap_json(row))
|
||||
return flask.jsonify(med), 201
|
||||
|
||||
@app.route("/api/medications/<med_id>", methods=["GET"])
|
||||
@@ -76,7 +184,7 @@ def register(app):
|
||||
|
||||
@app.route("/api/medications/<med_id>", methods=["PUT"])
|
||||
def api_updateMedication(med_id):
|
||||
"""Update medication details. Body: {name?, dosage?, unit?, frequency?, times?, notes?, active?}"""
|
||||
"""Update medication details."""
|
||||
user_uuid = _auth(flask.request)
|
||||
if not user_uuid:
|
||||
return flask.jsonify({"error": "unauthorized"}), 401
|
||||
@@ -86,11 +194,14 @@ def register(app):
|
||||
existing = postgres.select_one("medications", {"id": med_id, "user_uuid": user_uuid})
|
||||
if not existing:
|
||||
return flask.jsonify({"error": "not found"}), 404
|
||||
allowed = ["name", "dosage", "unit", "frequency", "times", "notes", "active"]
|
||||
allowed = [
|
||||
"name", "dosage", "unit", "frequency", "times", "notes", "active",
|
||||
"days_of_week", "interval_days", "start_date", "next_dose_date",
|
||||
]
|
||||
updates = {k: v for k, v in data.items() if k in allowed}
|
||||
if not updates:
|
||||
return flask.jsonify({"error": "no valid fields to update"}), 400
|
||||
result = postgres.update("medications", updates, {"id": med_id, "user_uuid": user_uuid})
|
||||
result = postgres.update("medications", _wrap_json(updates), {"id": med_id, "user_uuid": user_uuid})
|
||||
return flask.jsonify(result[0] if result else {}), 200
|
||||
|
||||
@app.route("/api/medications/<med_id>", methods=["DELETE"])
|
||||
@@ -127,6 +238,11 @@ def register(app):
|
||||
"notes": data.get("notes"),
|
||||
}
|
||||
log = postgres.insert("med_logs", log_entry)
|
||||
# Advance next_dose_date for interval meds
|
||||
if med.get("frequency") == "every_n_days" and med.get("interval_days"):
|
||||
next_date = _compute_next_dose_date(med)
|
||||
if next_date:
|
||||
postgres.update("medications", {"next_dose_date": next_date}, {"id": med_id})
|
||||
return flask.jsonify(log), 201
|
||||
|
||||
@app.route("/api/medications/<med_id>/skip", methods=["POST"])
|
||||
@@ -148,6 +264,11 @@ def register(app):
|
||||
"notes": data.get("reason"),
|
||||
}
|
||||
log = postgres.insert("med_logs", log_entry)
|
||||
# Advance next_dose_date for interval meds
|
||||
if med.get("frequency") == "every_n_days" and med.get("interval_days"):
|
||||
next_date = _compute_next_dose_date(med)
|
||||
if next_date:
|
||||
postgres.update("medications", {"next_dose_date": next_date}, {"id": med_id})
|
||||
return flask.jsonify(log), 201
|
||||
|
||||
@app.route("/api/medications/<med_id>/snooze", methods=["POST"])
|
||||
@@ -189,24 +310,37 @@ def register(app):
|
||||
user_uuid = _auth(flask.request)
|
||||
if not user_uuid:
|
||||
return flask.jsonify({"error": "unauthorized"}), 401
|
||||
|
||||
meds = postgres.select("medications", where={"user_uuid": user_uuid, "active": True})
|
||||
from datetime import datetime
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
now = datetime.now()
|
||||
today = date.today()
|
||||
today_str = today.isoformat()
|
||||
current_day = now.strftime("%a").lower() # "mon","tue", etc.
|
||||
|
||||
result = []
|
||||
for med in meds:
|
||||
times = med.get("times", [])
|
||||
taken_times = [
|
||||
log["scheduled_time"]
|
||||
for log in postgres.select(
|
||||
"med_logs",
|
||||
where={"medication_id": med["id"], "action": "taken"},
|
||||
)
|
||||
if log.get("scheduled_time", "").startswith(today)
|
||||
if not _is_med_due_today(med, today, current_day):
|
||||
continue
|
||||
|
||||
freq = med.get("frequency", "daily")
|
||||
is_prn = freq == "as_needed"
|
||||
|
||||
# Get today's taken times by filtering on created_at date
|
||||
all_logs = postgres.select(
|
||||
"med_logs",
|
||||
where={"medication_id": med["id"], "action": "taken"},
|
||||
)
|
||||
today_taken = [
|
||||
log.get("scheduled_time", "")
|
||||
for log in all_logs
|
||||
if str(log.get("created_at", ""))[:10] == today_str
|
||||
]
|
||||
|
||||
result.append({
|
||||
"medication": med,
|
||||
"scheduled_times": times,
|
||||
"taken_times": taken_times,
|
||||
"scheduled_times": [] if is_prn else med.get("times", []),
|
||||
"taken_times": today_taken,
|
||||
"is_prn": is_prn,
|
||||
})
|
||||
return flask.jsonify(result), 200
|
||||
|
||||
@@ -218,25 +352,37 @@ def register(app):
|
||||
user_uuid = _auth(flask.request)
|
||||
if not user_uuid:
|
||||
return flask.jsonify({"error": "unauthorized"}), 401
|
||||
days = flask.request.args.get("days", 30, type=int)
|
||||
num_days = flask.request.args.get("days", 30, type=int)
|
||||
meds = postgres.select("medications", where={"user_uuid": user_uuid, "active": True})
|
||||
today = date.today()
|
||||
period_start = today - timedelta(days=num_days)
|
||||
period_start_str = period_start.isoformat()
|
||||
|
||||
result = []
|
||||
for med in meds:
|
||||
logs = postgres.select(
|
||||
"med_logs",
|
||||
where={"medication_id": med["id"]},
|
||||
limit=days * 10,
|
||||
)
|
||||
taken = sum(1 for log in logs if log.get("action") == "taken")
|
||||
skipped = sum(1 for log in logs if log.get("action") == "skipped")
|
||||
total = taken + skipped
|
||||
adherence = (taken / total * 100) if total > 0 else 0
|
||||
freq = med.get("frequency", "daily")
|
||||
is_prn = freq == "as_needed"
|
||||
expected = _count_expected_doses(med, period_start, num_days)
|
||||
|
||||
logs = postgres.select("med_logs", where={"medication_id": med["id"]})
|
||||
taken = _count_logs_in_period(logs, period_start_str, "taken")
|
||||
skipped = _count_logs_in_period(logs, period_start_str, "skipped")
|
||||
|
||||
if is_prn:
|
||||
adherence_pct = None
|
||||
elif expected > 0:
|
||||
adherence_pct = round(min(taken / expected * 100, 100), 1)
|
||||
else:
|
||||
adherence_pct = 0
|
||||
|
||||
result.append({
|
||||
"medication_id": med["id"],
|
||||
"name": med["name"],
|
||||
"taken": taken,
|
||||
"skipped": skipped,
|
||||
"adherence_percent": round(adherence, 1),
|
||||
"expected": expected,
|
||||
"adherence_percent": adherence_pct,
|
||||
"is_prn": is_prn,
|
||||
})
|
||||
return flask.jsonify(result), 200
|
||||
|
||||
@@ -249,22 +395,34 @@ def register(app):
|
||||
med = postgres.select_one("medications", {"id": med_id, "user_uuid": user_uuid})
|
||||
if not med:
|
||||
return flask.jsonify({"error": "not found"}), 404
|
||||
days = flask.request.args.get("days", 30, type=int)
|
||||
logs = postgres.select(
|
||||
"med_logs",
|
||||
where={"medication_id": med_id},
|
||||
limit=days * 10,
|
||||
)
|
||||
taken = sum(1 for log in logs if log.get("action") == "taken")
|
||||
skipped = sum(1 for log in logs if log.get("action") == "skipped")
|
||||
total = taken + skipped
|
||||
adherence = (taken / total * 100) if total > 0 else 0
|
||||
num_days = flask.request.args.get("days", 30, type=int)
|
||||
today = date.today()
|
||||
period_start = today - timedelta(days=num_days)
|
||||
period_start_str = period_start.isoformat()
|
||||
|
||||
freq = med.get("frequency", "daily")
|
||||
is_prn = freq == "as_needed"
|
||||
expected = _count_expected_doses(med, period_start, num_days)
|
||||
|
||||
logs = postgres.select("med_logs", where={"medication_id": med_id})
|
||||
taken = _count_logs_in_period(logs, period_start_str, "taken")
|
||||
skipped = _count_logs_in_period(logs, period_start_str, "skipped")
|
||||
|
||||
if is_prn:
|
||||
adherence_pct = None
|
||||
elif expected > 0:
|
||||
adherence_pct = round(min(taken / expected * 100, 100), 1)
|
||||
else:
|
||||
adherence_pct = 0
|
||||
|
||||
return flask.jsonify({
|
||||
"medication_id": med_id,
|
||||
"name": med["name"],
|
||||
"taken": taken,
|
||||
"skipped": skipped,
|
||||
"adherence_percent": round(adherence, 1),
|
||||
"expected": expected,
|
||||
"adherence_percent": adherence_pct,
|
||||
"is_prn": is_prn,
|
||||
}), 200
|
||||
|
||||
# ── Refills ───────────────────────────────────────────────────
|
||||
@@ -295,7 +453,6 @@ def register(app):
|
||||
if not user_uuid:
|
||||
return flask.jsonify({"error": "unauthorized"}), 401
|
||||
days_ahead = flask.request.args.get("days_ahead", 7, type=int)
|
||||
from datetime import datetime, timedelta
|
||||
cutoff = (datetime.now() + timedelta(days=days_ahead)).strftime("%Y-%m-%d")
|
||||
meds = postgres.select(
|
||||
"medications",
|
||||
@@ -307,6 +464,6 @@ def register(app):
|
||||
refill_date = med.get("refill_date")
|
||||
if qty is not None and qty <= 7:
|
||||
due.append(med)
|
||||
elif refill_date and refill_date <= cutoff:
|
||||
elif refill_date and str(refill_date) <= cutoff:
|
||||
due.append(med)
|
||||
return flask.jsonify(due), 200
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Routines API - Brilli-style routine management
|
||||
Routines API - routine management
|
||||
|
||||
Routines have ordered steps. Users start sessions to walk through them.
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user