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}