204 lines
6.8 KiB
Python
204 lines
6.8 KiB
Python
#!/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:<path> 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()
|