feat(datastore): add diaper and feeding tracking, automated update_website posthook, and morning_briefing

This commit is contained in:
Moritz Graf
2026-10-11 12:16:24 +02:00
parent 80e38ab64a
commit 967909c3a7
4 changed files with 1074 additions and 3 deletions
@@ -0,0 +1,203 @@
#!/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()