debug line test

This commit is contained in:
Frank Schwenk
2026-06-09 21:21:52 +02:00
commit eaa019087e
43 changed files with 2666 additions and 0 deletions
View File
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from zoneinfo import ZoneInfo
import aiosqlite
import httpx
from server.db import utc_now_iso
from server.messages import pick
from server.settings import settings
from server.stats import compute_stats
async def _get_cache(db: aiosqlite.Connection, key: str) -> str | None:
cur = await db.execute("SELECT content FROM ai_cache WHERE key = ?", (key,))
row = await cur.fetchone()
await cur.close()
return row[0] if row else None
async def _set_cache(db: aiosqlite.Connection, key: str, content: str) -> None:
await db.execute(
"INSERT INTO ai_cache (key, content, created_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET content=excluded.content, created_at=excluded.created_at",
(key, content, utc_now_iso()),
)
await db.commit()
async def _call_openrouter(prompt: str) -> str | None:
if not settings.openrouter_api_key:
return None
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {settings.openrouter_api_key}",
"HTTP-Referer": "https://medis.schwenk.online",
"X-Title": "TakeYourMeds",
},
json={
"model": settings.openrouter_model,
"messages": [
{
"role": "system",
"content": "Du bist ein sarkastischer, dark-humor Medikamenten-Coach für jemanden mit ADHS. Kurz (max 2 Sätze), deutsch, witzig aber nicht gemein. Keine medizinischen Ratschläge.",
},
{"role": "user", "content": prompt},
],
"max_tokens": 120,
},
)
resp.raise_for_status()
data = resp.json()
return data["choices"][0]["message"]["content"].strip()
except Exception:
return None
async def get_roast_of_the_day(db: aiosqlite.Connection, tz: ZoneInfo) -> dict[str, Any]:
day = datetime.now(tz).strftime("%Y-%m-%d")
key = f"roast:{day}"
cached = await _get_cache(db, key)
if cached:
return {"text": cached, "source": "cache", "day": day}
stats = await compute_stats(db)
prompt = f"Roast-of-the-Day für jemanden mit ADHS. Streak: {stats['streak']} Tage, Compliance 90d: {stats['compliance_percent']}%. Ein sarkastischer Spruch."
text = await _call_openrouter(prompt)
if not text:
text = pick("roast_fallback")
source = "fallback"
else:
source = "ai"
await _set_cache(db, key, text)
return {"text": text, "source": source, "day": day}
async def get_oracle(db: aiosqlite.Connection, tz: ZoneInfo) -> dict[str, Any]:
week = datetime.now(tz).strftime("%Y-W%W")
key = f"oracle:{week}"
cached = await _get_cache(db, key)
if cached:
return {"text": cached, "source": "cache", "week": week}
stats = await compute_stats(db)
prompt = (
f"Wöchentlicher KI-Orakel-Report (passiv-aggressiv, max 4 Sätze). "
f"Streak: {stats['streak']}, Compliance 90d: {stats['compliance_percent']}%, "
f"genommen gesamt: {stats['total_taken']}."
)
text = await _call_openrouter(prompt)
if not text:
text = pick("streak", streak=stats["streak"]) + f" Compliance: {stats['compliance_percent']}%."
source = "fallback"
else:
source = "ai"
await _set_cache(db, key, text)
return {"text": text, "source": source, "week": week}
+195
View File
@@ -0,0 +1,195 @@
from __future__ import annotations
import pathlib
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Any
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from server.ai import get_oracle, get_roast_of_the_day
from server.auth import create_token, require_auth, verify_pin
from server.config_loader import load_meds_config, slot_to_dict, today_str
from server.db import ensure_schema, get_db, new_id, utc_now_iso
from server.messages import pick
from server.push import save_subscription, send_slot_reminder
from server.scheduler import schedule_snooze, start_scheduler
from server.settings import settings
from server.slots import build_today
from server.stats import build_history, check_milestones, compute_stats, MILESTONE_DEFS
NO_STORE = {"Cache-Control": "no-store"}
@asynccontextmanager
async def lifespan(app: FastAPI):
db = await get_db()
await ensure_schema(db)
await db.close()
start_scheduler()
yield
app = FastAPI(title="TakeYourMeds", version=settings.app_version, lifespan=lifespan)
@app.post("/api/auth/pin")
async def auth_pin(payload: dict[str, Any]) -> JSONResponse:
pin = str(payload.get("pin", ""))
if not verify_pin(pin):
raise HTTPException(status_code=401, detail="Falscher PIN")
return JSONResponse({"token": create_token()}, headers=NO_STORE)
@app.get("/api/vapid-public-key")
async def vapid_public_key() -> JSONResponse:
return JSONResponse({"key": settings.vapid_public_key}, headers=NO_STORE)
@app.get("/api/config")
async def get_config(_: dict = Depends(require_auth)) -> JSONResponse:
config = load_meds_config()
return JSONResponse(
{
"timezone": str(config.timezone),
"slots": [slot_to_dict(s) for s in config.slots],
"version": settings.app_version,
},
headers=NO_STORE,
)
@app.get("/api/today")
async def get_today(_: dict = Depends(require_auth)) -> JSONResponse:
db = await get_db()
try:
data = await build_today(db)
finally:
await db.close()
return JSONResponse(data, headers=NO_STORE)
@app.post("/api/log")
async def post_log(payload: dict[str, Any], _: dict = Depends(require_auth)) -> JSONResponse:
slot_id = str(payload.get("slot_id", ""))
status = str(payload.get("status", "taken"))
source = str(payload.get("source", "app"))
if status not in ("taken", "missed", "snoozed"):
raise HTTPException(status_code=400, detail="Invalid status")
config = load_meds_config()
if slot_id not in {s.id for s in config.slots}:
raise HTTPException(status_code=400, detail="Unknown slot")
day = payload.get("day") or today_str(config.timezone)
logged_at = payload.get("logged_at") or utc_now_iso()
db = await get_db()
try:
await db.execute("DELETE FROM intake_log WHERE slot_id = ? AND day = ?", (slot_id, day))
await db.execute(
"INSERT INTO intake_log (id, slot_id, day, status, logged_at, source) VALUES (?, ?, ?, ?, ?, ?)",
(new_id(), slot_id, day, status, logged_at, source),
)
if status == "taken":
await db.execute("DELETE FROM snooze WHERE slot_id = ? AND day = ?", (slot_id, day))
await db.commit()
stats = await compute_stats(db)
new_milestones = await check_milestones(db, stats["streak"])
finally:
await db.close()
message = pick("success") if status == "taken" else pick("missed")
return JSONResponse(
{
"ok": True,
"message": message,
"stats": stats,
"new_milestones": [
{"id": m, "title": MILESTONE_DEFS.get(m, m)} for m in new_milestones
],
},
headers=NO_STORE,
)
@app.get("/api/history")
async def get_history(days: int = 90, _: dict = Depends(require_auth)) -> JSONResponse:
db = await get_db()
try:
data = await build_history(db, days=min(days, 365))
finally:
await db.close()
return JSONResponse(data, headers=NO_STORE)
@app.get("/api/stats")
async def get_stats(_: dict = Depends(require_auth)) -> JSONResponse:
db = await get_db()
try:
stats = await compute_stats(db)
finally:
await db.close()
return JSONResponse(stats, headers=NO_STORE)
@app.post("/api/push/subscribe")
async def push_subscribe(payload: dict[str, Any], _: dict = Depends(require_auth)) -> JSONResponse:
sub = payload.get("subscription")
if not sub or not sub.get("endpoint"):
raise HTTPException(status_code=400, detail="Invalid subscription")
db = await get_db()
try:
await save_subscription(db, sub)
finally:
await db.close()
return JSONResponse({"ok": True}, headers=NO_STORE)
@app.post("/api/snooze")
async def post_snooze(payload: dict[str, Any], _: dict = Depends(require_auth)) -> JSONResponse:
slot_id = str(payload.get("slot_id", ""))
minutes = int(payload.get("minutes", 15))
if minutes not in (15, 30):
raise HTTPException(status_code=400, detail="minutes must be 15 or 30")
config = load_meds_config()
if slot_id not in {s.id for s in config.slots}:
raise HTTPException(status_code=400, detail="Unknown slot")
until = await schedule_snooze(slot_id, minutes)
return JSONResponse(
{"ok": True, "snooze_until": until, "message": pick("snooze")},
headers=NO_STORE,
)
@app.get("/api/roast")
async def get_roast(_: dict = Depends(require_auth)) -> JSONResponse:
config = load_meds_config()
db = await get_db()
try:
data = await get_roast_of_the_day(db, config.timezone)
finally:
await db.close()
return JSONResponse(data, headers=NO_STORE)
@app.get("/api/oracle")
async def get_oracle_route(_: dict = Depends(require_auth)) -> JSONResponse:
config = load_meds_config()
db = await get_db()
try:
data = await get_oracle(db, config.timezone)
finally:
await db.close()
return JSONResponse(data, headers=NO_STORE)
# Static files — must be after API routes
PUBLIC = pathlib.Path(settings.public_dir)
if PUBLIC.exists():
app.mount("/", StaticFiles(directory=str(PUBLIC), html=True), name="static")
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
import hmac
from datetime import datetime, timedelta, timezone
from typing import Any
import jwt
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from server.settings import settings
_bearer = HTTPBearer(auto_error=False)
ALGORITHM = "HS256"
def verify_pin(pin: str) -> bool:
return hmac.compare_digest(pin.strip(), settings.app_pin.strip())
def create_token() -> str:
expire = datetime.now(timezone.utc) + timedelta(days=settings.jwt_expire_days)
payload = {"sub": "user", "exp": expire}
return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM)
def decode_token(token: str) -> dict[str, Any]:
try:
return jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
except jwt.PyJWTError as exc:
raise HTTPException(status_code=401, detail="Invalid token") from exc
async def require_auth(
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> dict[str, Any]:
if creds is None or creds.scheme.lower() != "bearer":
raise HTTPException(status_code=401, detail="Missing token")
return decode_token(creds.credentials)
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
import pathlib
from dataclasses import dataclass, field
from datetime import datetime, time
from typing import Any
from zoneinfo import ZoneInfo
import yaml
from server.settings import settings
@dataclass
class Med:
name: str
dose: str
@dataclass
class Slot:
id: str
time: time
label: str
meds: list[Med]
reminder_window_minutes: int = 90
@dataclass
class MedsConfig:
timezone: ZoneInfo
slots: list[Slot] = field(default_factory=list)
_config: MedsConfig | None = None
def load_meds_config(path: str | None = None) -> MedsConfig:
global _config
if _config is not None:
return _config
p = pathlib.Path(path or settings.meds_path)
raw = yaml.safe_load(p.read_text(encoding="utf-8"))
tz = ZoneInfo(raw.get("timezone", "Europe/Berlin"))
slots: list[Slot] = []
for item in raw.get("slots", []):
h, m = str(item["time"]).split(":")
slots.append(
Slot(
id=item["id"],
time=time(int(h), int(m)),
label=item.get("label", item["id"]),
meds=[Med(**m) for m in item.get("meds", [])],
reminder_window_minutes=int(item.get("reminder_window_minutes", 90)),
)
)
_config = MedsConfig(timezone=tz, slots=slots)
return _config
def reload_meds_config() -> MedsConfig:
global _config
_config = None
return load_meds_config()
def slot_datetime(day: datetime, slot: Slot, tz: ZoneInfo) -> datetime:
local = day.astimezone(tz) if day.tzinfo else day.replace(tzinfo=tz)
return local.replace(hour=slot.time.hour, minute=slot.time.minute, second=0, microsecond=0)
def today_str(tz: ZoneInfo) -> str:
return datetime.now(tz).strftime("%Y-%m-%d")
def slot_to_dict(slot: Slot) -> dict[str, Any]:
return {
"id": slot.id,
"time": slot.time.strftime("%H:%M"),
"label": slot.label,
"meds": [{"name": m.name, "dose": m.dose} for m in slot.meds],
"reminder_window_minutes": slot.reminder_window_minutes,
}
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
import pathlib
import uuid
from datetime import datetime, timezone
import aiosqlite
from server.settings import settings
SCHEMA_PATH = str(pathlib.Path(__file__).resolve().parents[1] / "tools" / "schema.sql")
def new_id() -> str:
return str(uuid.uuid4())
def utc_now_iso() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
async def get_db() -> aiosqlite.Connection:
pathlib.Path(settings.db_path).parent.mkdir(parents=True, exist_ok=True)
db = await aiosqlite.connect(settings.db_path)
await db.execute("PRAGMA foreign_keys = ON;")
return db
async def ensure_schema(db: aiosqlite.Connection) -> None:
schema = pathlib.Path(SCHEMA_PATH).read_text(encoding="utf-8")
await db.executescript(schema)
await db.commit()
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
import pathlib
import random
from typing import Any
import yaml
from server.settings import settings
_pool: dict[str, list[str]] | None = None
DEFAULT_MESSAGES: dict[str, list[str]] = {
"reminder": ["Zeit für Medis. Dein Gehirn wartet.", "Med-Time. Nicht vergessen."],
"success": ["Genommen. Held des Tages.", "Dopamin incoming."],
"missed": ["Verpasst. Das Gehirn ist enttäuscht.", "Nächstes Mal vielleicht."],
"streak": ["Streak läuft!", "Weiter so, Ausnahme vom ADHS-Gesetz."],
"snooze": ["Okay, noch 15 Min. Aber wirklich.", "Snooze aktiviert. Prokrastination approved."],
"easter_egg": ["Achievement unlocked!", "Du hast was Seltenes freigeschaltet."],
"roast_fallback": [
"Dein Gehirn hat heute schon aufgegeben, bevor du die PIN eingegeben hast.",
"Elvanse wartet. Du offenbar auch, aber im falschen Sinne.",
],
}
def load_messages(path: str | None = None) -> dict[str, list[str]]:
global _pool
if _pool is not None:
return _pool
p = pathlib.Path(path or settings.messages_path)
if p.exists():
raw = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
_pool = {**DEFAULT_MESSAGES, **{k: v for k, v in raw.items() if isinstance(v, list)}}
else:
_pool = DEFAULT_MESSAGES
return _pool
def pick(category: str, **fmt: Any) -> str:
pool = load_messages()
choices = pool.get(category) or pool.get("reminder", ["Medis."])
text = random.choice(choices)
if fmt:
try:
text = text.format(**fmt)
except (KeyError, IndexError):
pass
return text
+88
View File
@@ -0,0 +1,88 @@
from __future__ import annotations
import json
from typing import Any
import aiosqlite
from pywebpush import WebPushException, webpush
from server.db import utc_now_iso
from server.settings import settings
async def save_subscription(db: aiosqlite.Connection, sub: dict[str, Any]) -> None:
keys = sub.get("keys", {})
await db.execute(
"""
INSERT INTO push_subscription (endpoint, p256dh, auth, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET p256dh=excluded.p256dh, auth=excluded.auth
""",
(sub["endpoint"], keys.get("p256dh", ""), keys.get("auth", ""), utc_now_iso()),
)
await db.commit()
async def get_subscriptions(db: aiosqlite.Connection) -> list[dict[str, str]]:
cur = await db.execute("SELECT endpoint, p256dh, auth FROM push_subscription")
rows = await cur.fetchall()
await cur.close()
return [{"endpoint": r[0], "p256dh": r[1], "auth": r[2]} for r in rows]
def _vapid_claims() -> dict[str, str]:
return {"sub": settings.vapid_claims_email}
async def send_push(
db: aiosqlite.Connection,
payload: dict[str, Any],
) -> int:
if not settings.vapid_private_key or not settings.vapid_public_key:
return 0
subs = await get_subscriptions(db)
sent = 0
dead: list[str] = []
for sub in subs:
subscription = {
"endpoint": sub["endpoint"],
"keys": {"p256dh": sub["p256dh"], "auth": sub["auth"]},
}
try:
webpush(
subscription_info=subscription,
data=json.dumps(payload),
vapid_private_key=settings.vapid_private_key,
vapid_claims=_vapid_claims(),
)
sent += 1
except WebPushException as exc:
if exc.response and exc.response.status_code in (404, 410):
dead.append(sub["endpoint"])
for endpoint in dead:
await db.execute("DELETE FROM push_subscription WHERE endpoint = ?", (endpoint,))
if dead:
await db.commit()
return sent
async def send_slot_reminder(
db: aiosqlite.Connection,
slot_id: str,
title: str,
body: str,
*,
silent: bool = True,
) -> int:
payload = {
"title": title,
"body": body,
"slot_id": slot_id,
"silent": silent,
"tag": f"med-{slot_id}",
}
return await send_push(db, payload)
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timedelta
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from server.config_loader import load_meds_config, slot_to_dict, today_str
from server.db import get_db, utc_now_iso
from server.messages import pick
from server.push import send_slot_reminder
from server.slots import build_today, get_log_for_day, mark_missed_for_overdue
logger = logging.getLogger(__name__)
scheduler = AsyncIOScheduler()
async def _remind_slot(slot_id: str) -> None:
config = load_meds_config()
tz = config.timezone
day = today_str(tz)
db = await get_db()
try:
logs = await get_log_for_day(db, day)
if slot_id in logs and logs[slot_id]["status"] == "taken":
return
slot = next((s for s in config.slots if s.id == slot_id), None)
if not slot:
return
title = f"{slot.label} — Med-Time!"
body = pick("reminder", label=slot.label, med=slot.meds[0].name if slot.meds else "Medis")
await send_slot_reminder(db, slot_id, title, body)
finally:
await db.close()
async def _evening_check() -> None:
db = await get_db()
try:
marked = await mark_missed_for_overdue(db)
for slot_id in marked:
body = pick("missed")
await send_slot_reminder(db, slot_id, "Verpasst?", body)
finally:
await db.close()
async def schedule_snooze(slot_id: str, minutes: int) -> str:
config = load_meds_config()
tz = config.timezone
now = datetime.now(tz)
until = now + timedelta(minutes=minutes)
day = today_str(tz)
db = await get_db()
try:
await db.execute(
"""
INSERT INTO snooze (slot_id, day, snooze_until) VALUES (?, ?, ?)
ON CONFLICT(slot_id, day) DO UPDATE SET snooze_until=excluded.snooze_until
""",
(slot_id, day, until.isoformat()),
)
await db.commit()
finally:
await db.close()
run_at = until.astimezone(tz).replace(tzinfo=None)
scheduler.add_job(
_remind_slot,
trigger=DateTrigger(run_date=run_at),
args=[slot_id],
id=f"snooze-{slot_id}-{until.timestamp()}",
replace_existing=False,
)
return until.isoformat()
def start_scheduler() -> None:
config = load_meds_config()
tz = config.timezone
for slot in config.slots:
h, m = slot.time.hour, slot.time.minute
scheduler.add_job(
_remind_slot,
trigger=CronTrigger(hour=h, minute=m, timezone=tz),
args=[slot.id],
id=f"remind-{slot.id}",
replace_existing=True,
)
scheduler.add_job(
_evening_check,
trigger=CronTrigger(hour=21, minute=0, timezone=tz),
id="evening-check",
replace_existing=True,
)
if not scheduler.running:
scheduler.start()
logger.info("Scheduler started for timezone %s", tz)
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
app_pin: str = "1234"
jwt_secret: str = "dev-secret-change-me"
jwt_expire_days: int = 90
vapid_private_key: str = ""
vapid_public_key: str = ""
vapid_claims_email: str = "mailto:admin@schwenk.online"
openrouter_api_key: str = ""
openrouter_model: str = "google/gemini-2.0-flash-001"
db_path: str = "data/medis.sqlite"
meds_path: str = "meds.yaml"
messages_path: str = "messages.yaml"
public_dir: str = "public"
app_version: str = "1.0.0"
settings = Settings() # type: ignore[call-arg]
+118
View File
@@ -0,0 +1,118 @@
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
from zoneinfo import ZoneInfo
import aiosqlite
from server.config_loader import MedsConfig, Slot, load_meds_config, slot_datetime, today_str
from server.db import utc_now_iso
async def get_log_for_day(db: aiosqlite.Connection, day: str) -> dict[str, dict[str, Any]]:
cur = await db.execute(
"SELECT slot_id, status, logged_at, source FROM intake_log WHERE day = ? ORDER BY logged_at DESC",
(day,),
)
rows = await cur.fetchall()
await cur.close()
result: dict[str, dict[str, Any]] = {}
for slot_id, status, logged_at, source in rows:
if slot_id not in result:
result[slot_id] = {"status": status, "logged_at": logged_at, "source": source}
return result
async def get_snooze_for_day(db: aiosqlite.Connection, day: str) -> dict[str, str]:
cur = await db.execute("SELECT slot_id, snooze_until FROM snooze WHERE day = ?", (day,))
rows = await cur.fetchall()
await cur.close()
return {slot_id: snooze_until for slot_id, snooze_until in rows}
def compute_status(
slot: Slot,
now: datetime,
tz: ZoneInfo,
log_entry: dict[str, Any] | None,
snooze_until: str | None,
) -> str:
if log_entry:
return log_entry["status"]
slot_dt = slot_datetime(now, slot, tz)
window_end = slot_dt + timedelta(minutes=slot.reminder_window_minutes)
if snooze_until:
try:
snooze_dt = datetime.fromisoformat(snooze_until)
if now < snooze_dt:
return "snoozed"
except ValueError:
pass
if now < slot_dt:
return "upcoming"
if now <= window_end:
return "pending"
return "overdue"
async def build_today(db: aiosqlite.Connection, config: MedsConfig | None = None) -> dict[str, Any]:
config = config or load_meds_config()
tz = config.timezone
now = datetime.now(tz)
day = today_str(tz)
logs = await get_log_for_day(db, day)
snoozes = await get_snooze_for_day(db, day)
slots_out: list[dict[str, Any]] = []
for slot in config.slots:
log_entry = logs.get(slot.id)
snooze_until = snoozes.get(slot.id)
status = compute_status(slot, now, tz, log_entry, snooze_until)
slot_dt = slot_datetime(now, slot, tz)
window_end = slot_dt + timedelta(minutes=slot.reminder_window_minutes)
slots_out.append(
{
"id": slot.id,
"time": slot.time.strftime("%H:%M"),
"label": slot.label,
"meds": [{"name": m.name, "dose": m.dose} for m in slot.meds],
"status": status,
"logged_at": log_entry["logged_at"] if log_entry else None,
"window_end": window_end.isoformat(),
"snooze_until": snooze_until,
}
)
return {"day": day, "timezone": str(tz), "now": now.isoformat(), "slots": slots_out}
async def mark_missed_for_overdue(db: aiosqlite.Connection, config: MedsConfig | None = None) -> list[str]:
from server.db import new_id
config = config or load_meds_config()
tz = config.timezone
now = datetime.now(tz)
day = today_str(tz)
logs = await get_log_for_day(db, day)
marked: list[str] = []
for slot in config.slots:
if slot.id in logs:
continue
status = compute_status(slot, now, tz, None, None)
if status == "overdue":
await db.execute(
"INSERT INTO intake_log (id, slot_id, day, status, logged_at, source) VALUES (?, ?, ?, ?, ?, ?)",
(new_id(), slot.id, day, "missed", utc_now_iso(), "system"),
)
marked.append(slot.id)
if marked:
await db.commit()
return marked
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
from zoneinfo import ZoneInfo
import aiosqlite
from server.config_loader import load_meds_config, today_str
from server.db import new_id, utc_now_iso
MILESTONE_DEFS: dict[str, str] = {
"log_50": "Pharma-Intern: 50 Logs. Dein Arzt wäre stolz. Vielleicht.",
"log_100": "Century Club: 100 Logs. Du bist offiziell zuverlässiger als dein WLAN.",
"streak_7": "Wochenkrieger: 7 Tage Streak. ADHS.exe has stopped crashing.",
"streak_30": "Monatslegende: 30 Tage. Das ist fast schon verdächtig diszipliniert.",
"streak_100": "Unmöglichkeitsgrad: 100 Tage Streak. Cheater oder Heilung?",
}
async def count_logs(db: aiosqlite.Connection) -> int:
cur = await db.execute("SELECT COUNT(*) FROM intake_log WHERE status = 'taken'")
row = await cur.fetchone()
await cur.close()
return int(row[0]) if row else 0
async def get_milestones(db: aiosqlite.Connection) -> list[dict[str, str]]:
cur = await db.execute("SELECT id, unlocked_at FROM milestones ORDER BY unlocked_at")
rows = await cur.fetchall()
await cur.close()
return [{"id": r[0], "title": MILESTONE_DEFS.get(r[0], r[0]), "unlocked_at": r[1]} for r in rows]
async def unlock_milestone(db: aiosqlite.Connection, milestone_id: str) -> bool:
cur = await db.execute("SELECT 1 FROM milestones WHERE id = ?", (milestone_id,))
exists = await cur.fetchone()
await cur.close()
if exists:
return False
await db.execute(
"INSERT INTO milestones (id, unlocked_at) VALUES (?, ?)",
(milestone_id, utc_now_iso()),
)
await db.commit()
return True
async def check_milestones(db: aiosqlite.Connection, streak: int) -> list[str]:
new: list[str] = []
total = await count_logs(db)
checks = []
if total >= 50:
checks.append("log_50")
if total >= 100:
checks.append("log_100")
if streak >= 7:
checks.append("streak_7")
if streak >= 30:
checks.append("streak_30")
if streak >= 100:
checks.append("streak_100")
for mid in checks:
if await unlock_milestone(db, mid):
new.append(mid)
return new
async def day_compliance(db: aiosqlite.Connection, day: str, slot_ids: list[str]) -> bool:
if not slot_ids:
return True
placeholders = ",".join("?" * len(slot_ids))
cur = await db.execute(
f"SELECT COUNT(DISTINCT slot_id) FROM intake_log WHERE day = ? AND status = 'taken' AND slot_id IN ({placeholders})",
(day, *slot_ids),
)
row = await cur.fetchone()
await cur.close()
return int(row[0]) >= len(slot_ids) if row else False
async def compute_streak(db: aiosqlite.Connection, tz: ZoneInfo) -> int:
config = load_meds_config()
slot_ids = [s.id for s in config.slots]
streak = 0
day = datetime.now(tz).date()
while True:
day_str = day.isoformat()
ok = await day_compliance(db, day_str, slot_ids)
if not ok:
break
streak += 1
day -= timedelta(days=1)
return streak
async def compute_stats(db: aiosqlite.Connection) -> dict[str, Any]:
config = load_meds_config()
tz = config.timezone
slot_ids = [s.id for s in config.slots]
streak = await compute_streak(db, tz)
days = 90
taken = 0
total = 0
today = datetime.now(tz).date()
for i in range(days):
d = (today - timedelta(days=i)).isoformat()
for sid in slot_ids:
total += 1
cur = await db.execute(
"SELECT 1 FROM intake_log WHERE day = ? AND slot_id = ? AND status = 'taken' LIMIT 1",
(d, sid),
)
if await cur.fetchone():
taken += 1
await cur.close()
compliance = round((taken / total) * 100, 1) if total else 0.0
milestones = await get_milestones(db)
return {
"streak": streak,
"compliance_percent": compliance,
"total_taken": await count_logs(db),
"milestones": milestones,
}
async def build_history(db: aiosqlite.Connection, days: int = 90) -> dict[str, Any]:
config = load_meds_config()
tz = config.timezone
slot_ids = [s.id for s in config.slots]
today = datetime.now(tz).date()
days_out: list[dict[str, Any]] = []
for i in range(days - 1, -1, -1):
d = (today - timedelta(days=i)).isoformat()
cur = await db.execute(
"SELECT slot_id, status FROM intake_log WHERE day = ?",
(d,),
)
rows = await cur.fetchall()
await cur.close()
by_slot = {sid: st for sid, st in rows}
slots_status = {}
all_taken = True
any_missed = False
for sid in slot_ids:
st = by_slot.get(sid)
slots_status[sid] = st or "none"
if st != "taken":
all_taken = False
if st == "missed":
any_missed = True
if all_taken and slot_ids:
level = "good"
elif any_missed:
level = "bad"
elif any(st != "none" for st in slots_status.values()):
level = "partial"
else:
level = "none"
days_out.append({"day": d, "level": level, "slots": slots_status})
stats = await compute_stats(db)
return {"days": days_out, "stats": stats}