feat(datastore): add diaper and feeding tracking, automated update_website posthook, and morning_briefing
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user