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:
2026-02-13 03:23:38 -06:00
parent 3e1134575b
commit 97a166f5aa
47 changed files with 5231 additions and 61 deletions

View File

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