231 lines
7.2 KiB
Python
231 lines
7.2 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import date, 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
|
|
from server.slots import compute_status, get_log_for_day, get_snooze_for_day
|
|
|
|
|
|
async def get_first_intake_day(db: aiosqlite.Connection, tz: ZoneInfo) -> str:
|
|
cur = await db.execute("SELECT MIN(day) FROM intake_log WHERE status = 'taken'")
|
|
row = await cur.fetchone()
|
|
await cur.close()
|
|
if row and row[0]:
|
|
return str(row[0])
|
|
return today_str(tz)
|
|
|
|
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 _today_streak_state(
|
|
db: aiosqlite.Connection,
|
|
tz: ZoneInfo,
|
|
slot_ids: list[str],
|
|
) -> str:
|
|
"""Return 'complete', 'failed', or 'pending' for today."""
|
|
config = load_meds_config()
|
|
day = today_str(tz)
|
|
logs = await get_log_for_day(db, day)
|
|
slots_by_id = {s.id: s for s in config.slots}
|
|
|
|
for sid in slot_ids:
|
|
entry = logs.get(sid)
|
|
if entry and entry["status"] == "missed":
|
|
return "failed"
|
|
|
|
now = datetime.now(tz)
|
|
snoozes = await get_snooze_for_day(db, day)
|
|
all_taken = True
|
|
|
|
for sid in slot_ids:
|
|
entry = logs.get(sid)
|
|
if entry and entry["status"] == "taken":
|
|
continue
|
|
all_taken = False
|
|
slot = slots_by_id[sid]
|
|
status = compute_status(slot, now, tz, entry, snoozes.get(sid))
|
|
if status == "overdue":
|
|
return "failed"
|
|
|
|
if all_taken:
|
|
return "complete"
|
|
return "pending"
|
|
|
|
|
|
async def compute_streak(db: aiosqlite.Connection, tz: ZoneInfo) -> int:
|
|
config = load_meds_config()
|
|
slot_ids = [s.id for s in config.slots]
|
|
if not slot_ids:
|
|
return 0
|
|
|
|
today = datetime.now(tz).date()
|
|
today_state = await _today_streak_state(db, tz, slot_ids)
|
|
|
|
if today_state == "failed":
|
|
return 0
|
|
|
|
day = today if today_state == "complete" else today - timedelta(days=1)
|
|
streak = 0
|
|
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)
|
|
|
|
first_day = await get_first_intake_day(db, tz)
|
|
first_date = date.fromisoformat(first_day)
|
|
today = datetime.now(tz).date()
|
|
days_active = max(1, (today - first_date).days + 1)
|
|
compliance_window_days = min(90, days_active)
|
|
|
|
taken = 0
|
|
total = 0
|
|
for i in range(compliance_window_days):
|
|
d = (today - timedelta(days=i)).isoformat()
|
|
if d < first_day:
|
|
break
|
|
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,
|
|
"compliance_window_days": compliance_window_days,
|
|
"days_active": days_active,
|
|
"first_day": first_day,
|
|
"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}
|