#!/usr/bin/env python3 # /// script # dependencies = [ # "duckdb>=1.1.0", # "pytz>=2024.1", # "python-ulid>=3.0.0", # ] # /// """ morning_briefing.py — Generates the daily morning Hebamme sleep roundup for Mila, computes 7d vs 30d trends, renders a visual trend chart via chart-to-chat, and returns the message with MEDIA: and the permanent website URL. """ from __future__ import annotations import argparse import datetime import json import os import subprocess import sys from pathlib import Path from zoneinfo import ZoneInfo SCRIPT_DIR = Path(__file__).resolve().parent if str(SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(SCRIPT_DIR)) from datastore import DatastoreEngine, find_data_dir BERLIN_TZ = ZoneInfo("Europe/Berlin") MILA_BIRTHDATE = datetime.date(2026, 3, 14) WEEKDAYS_SHORT = { 0: "Mo", 1: "Di", 2: "Mi", 3: "Do", 4: "Fr", 5: "Sa", 6: "So", } def find_chart_script() -> Optional[Path]: candidates = [ SCRIPT_DIR.parent.parent / "chart-to-chat" / "scripts" / "chart.py", Path("/home/node/mop-skills/mop-skills/skills/chart-to-chat/scripts/chart.py"), Path("/Users/moritz/src/mop-skills/skills/chart-to-chat/scripts/chart.py"), ] for c in candidates: if c.is_file(): return c return None def generate_morning_briefing(force: bool = False) -> Dict[str, Any]: data_dir = find_data_dir() state_file = data_dir / ".mila_morning_state.json" now_berlin = datetime.datetime.now(BERLIN_TZ) today = now_berlin.date() today_str = today.strftime("%Y-%m-%d") # State check: only brief once per day unless forced if not force and state_file.is_file(): try: state = json.loads(state_file.read_text(encoding="utf-8")) if state.get("last_briefed_date") == today_str: return { "already_briefed": True, "date": today_str, "message": None, } except Exception: pass engine = DatastoreEngine(data_dir) con = engine.con yesterday = today - datetime.timedelta(days=1) yesterday_str = yesterday.strftime("%Y-%m-%d") # 1. Yesterday's sleep y_agg = con.execute( f""" SELECT total_sleep_hours, night_sleep_minutes, nap_count, nap_sleep_minutes FROM v_sleep_daily WHERE subject = 'mila' AND log_date = '{yesterday_str}' """ ).fetchone() y_tot = y_agg[0] if y_agg and y_agg[0] is not None else 0.0 y_night_min = y_agg[1] if y_agg and y_agg[1] is not None else 0 y_night_h, y_night_m = divmod(y_night_min, 60) y_naps = y_agg[2] if y_agg and y_agg[2] is not None else 0 y_naps_min = y_agg[3] if y_agg and y_agg[3] is not None else 0 y_naps_h, y_naps_m = divmod(y_naps_min, 60) # 2. 7-Day & 30-Day Averages avg_7 = con.execute( """ SELECT ROUND(AVG(total_sleep_hours), 2), ROUND(AVG(night_sleep_minutes)/60.0, 2), ROUND(AVG(nap_count), 1) FROM (SELECT total_sleep_hours, night_sleep_minutes, nap_count FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7) """ ).fetchone() avg_7_tot = avg_7[0] if avg_7 and avg_7[0] is not None else y_tot avg_7_night = avg_7[1] if avg_7 and avg_7[1] is not None else 0.0 avg_7_naps = avg_7[2] if avg_7 and avg_7[2] is not None else 0.0 avg_30 = con.execute( """ SELECT ROUND(AVG(total_sleep_hours), 2) FROM (SELECT total_sleep_hours FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 30) """ ).fetchone() avg_30_tot = avg_30[0] if avg_30 and avg_30[0] is not None else avg_7_tot # 3. 7-Day History Data for Chart recent_7_rows = con.execute( """ SELECT log_date, ROUND(total_sleep_hours, 1) FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date ASC LIMIT 7 """ ).fetchall() chart_media_tag = "" chart_script = find_chart_script() if chart_script and len(recent_7_rows) >= 3: chart_data = [] for r in recent_7_rows: d_obj = datetime.date.fromisoformat(str(r[0])) label = f"{WEEKDAYS_SHORT[d_obj.weekday()]} {d_obj.strftime('%d.%m.')}" chart_data.append({"day": label, "hours": float(r[1])}) try: chart_cmd = [ sys.executable, str(chart_script), "--title", "Mila Schlaf letzte 7 Tage (Stunden)", "--type", "bar", "--data", json.dumps(chart_data), ] res = subprocess.run(chart_cmd, capture_output=True, text=True, timeout=15) for line in res.stdout.splitlines(): if line.startswith("MEDIA:"): chart_media_tag = "\n\n" + line.strip() break except Exception: pass # 4. Mila's exact age & pediatric reflection days_old = (today - MILA_BIRTHDATE).days months_old = days_old // 30.4375 trend_reflection = ( f"Mila schläft im 7-Tage-Schnitt **{avg_7_tot:.1f} h** (30-Tage-Basis: **{avg_30_tot:.1f} h**). " f"Mit rund {int(months_old)} Monaten liegt das wunderbar im altersgerechten Entwicklungsfenster (12–15 Std/24h). " f"Die Hauptschlafphase nachts konsolidiert sich stabil (Ø {avg_7_night:.1f} h), während tagsüber typischerweise 2–3 Schläfchen den Rhythmus tragen." ) briefing_text = ( f"Guten Morgen! ☀️ Hier ist euer morgendlicher Schlafrückblick für Mila:\n\n" f"**Gestern ({yesterday.strftime('%d.%m.')}):**\n" f"• 24h Gesamtschlaf: **{y_tot:.1f} h**\n" f"• Nachtschlaf: **{y_night_h} h {y_night_m:02d} min**\n" f"• Tagesschläfchen: **{y_naps} Naps** ({y_naps_h} h {y_naps_m:02d} min)\n\n" f"**Entwicklung & Trend:**\n" f"• {trend_reflection}\n\n" f"🔗 **Live-Dashboard:** https://hosting.haumdaucher.de/family/mila.html" f"{chart_media_tag}" ) # Save state try: state_file.write_text(json.dumps({"last_briefed_date": today_str, "updated_at": now_berlin.isoformat()}), encoding="utf-8") except Exception: pass return { "already_briefed": False, "date": today_str, "message": briefing_text, } def main(): parser = argparse.ArgumentParser(description="Mila Morning Sleep Briefing") parser.add_argument("--force", action="store_true", help="Force briefing even if already generated today") args = parser.parse_args() result = generate_morning_briefing(force=args.force) if result.get("already_briefed"): print(f"Already briefed for today ({result.get('date')}). Use --force to override.") else: print(result.get("message")) if __name__ == "__main__": main()