From 967909c3a7722ca5425d108791bb64b413acc4cf Mon Sep 17 00:00:00 2001 From: Moritz Graf Date: Sun, 11 Oct 2026 12:16:24 +0200 Subject: [PATCH] feat(datastore): add diaper and feeding tracking, automated update_website posthook, and morning_briefing --- skills/datastore/scripts/datastore.py | 73 +- skills/datastore/scripts/morning_briefing.py | 203 +++++ skills/datastore/scripts/update_website.py | 739 +++++++++++++++++++ skills/datastore/tests/test_datastore.py | 62 ++ 4 files changed, 1074 insertions(+), 3 deletions(-) create mode 100644 skills/datastore/scripts/morning_briefing.py create mode 100644 skills/datastore/scripts/update_website.py diff --git a/skills/datastore/scripts/datastore.py b/skills/datastore/scripts/datastore.py index 7362255..d06023f 100755 --- a/skills/datastore/scripts/datastore.py +++ b/skills/datastore/scripts/datastore.py @@ -280,6 +280,27 @@ class DatastoreEngine: except Exception as e: logger.warning(f"Error loading garmin record from {file_path}: {e}") + def _trigger_website_update(self, subject: str): + """Triggers update_website.py automatically for Mila changes.""" + if subject != "mila": + return + try: + update_script = Path(__file__).resolve().parent / "update_website.py" + if update_script.exists(): + import subprocess, shutil + uv_bin = shutil.which("uv") or "/home/node/.openclaw/bin/uv" + if Path(uv_bin).is_file(): + cmd = [str(uv_bin), "run", str(update_script)] + else: + cmd = [sys.executable, str(update_script)] + subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception as e: + logger.warning(f"Could not trigger update_website.py: {e}") + def _load_jsonl_file(self, file_path: Path, table_name: str): """Reads lines from JSONL and inserts into target table.""" if not file_path.is_file() or file_path.stat().st_size == 0: @@ -292,6 +313,13 @@ class DatastoreEngine: continue try: record = json.loads(line) + if table_name == "events": + if "created_at" not in record or not record.get("created_at"): + record["created_at"] = record.get("start") or datetime.datetime.now(datetime.timezone.utc).isoformat() + if "estimated" not in record: + record["estimated"] = False + if "deleted_at" not in record: + record["deleted_at"] = None columns = list(record.keys()) placeholders = ", ".join(["?"] * len(columns)) col_names = ", ".join([f'"{c}"' for c in columns]) @@ -304,8 +332,7 @@ class DatastoreEngine: values, ) except Exception as e: - # Ignore comment lines or malformed lines with warning - pass + logger.warning(f"Error loading {table_name} record from {file_path}: {e}") def _matches_payload(self, kind: str, p1: Dict[str, Any], p2: Dict[str, Any]) -> bool: """Determines if two event payloads represent the same intake/session.""" @@ -554,6 +581,8 @@ class DatastoreEngine: elif "day_total_formatted" not in feedback: feedback["day_total_formatted"] = open_warning + self._trigger_website_update(subject) + return { "status": "success", "event": record, @@ -621,6 +650,8 @@ class DatastoreEngine: start_dt = datetime.datetime.fromisoformat(start_str.replace("Z", "+00:00")) duration_minutes = int((end_dt.astimezone(BERLIN_TZ) - start_dt.astimezone(BERLIN_TZ)).total_seconds() / 60) + self._trigger_website_update(subject) + return { "status": "success", "event_id": resolved_event_id, @@ -637,16 +668,19 @@ class DatastoreEngine: def delete_event(self, event_id: str) -> Dict[str, Any]: """Soft-deletes an event.""" now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat() - row = self.con.execute("SELECT kind FROM events WHERE id = ?", [event_id]).fetchone() + row = self.con.execute("SELECT kind, subject FROM events WHERE id = ?", [event_id]).fetchone() if not row: raise ValueError(f"Event with id '{event_id}' not found.") kind = row[0] + subject = row[1] domain = kind.split(".")[0] self.con.execute("UPDATE events SET deleted_at = ? WHERE id = ?", [now_iso, event_id]) self._update_jsonl_record(domain, event_id, {"deleted_at": now_iso}) + self._trigger_website_update(subject) + return {"status": "success", "deleted_event_id": event_id} # -------------------------------------------------------------------------- @@ -770,6 +804,16 @@ class DatastoreEngine: if open_sleep: res["open_sleep"] = open_sleep[0] + # 4. Diaper summary (if subject has diaper data) + dia = self.query(f"SELECT * FROM v_diaper_daily WHERE subject = '{subject}' AND log_date = '{target_date}'") + if dia: + res["diaper"] = dia[0] + + # 5. Feeding summary (if subject has feeding data) + fee = self.query(f"SELECT * FROM v_feeding_daily WHERE subject = '{subject}' AND log_date = '{target_date}'") + if fee: + res["feeding"] = fee[0] + return res def find_places_near( @@ -829,6 +873,29 @@ class DatastoreEngine: "total_hours": daily[0], "nap_count": daily[1], } + elif kind.startswith("diaper"): + daily = self.con.execute( + "SELECT total_changes, wet_count, dirty_count FROM v_diaper_daily WHERE subject = ? AND log_date = ?", + [subject, date_str], + ).fetchone() + if daily: + return { + "day_total_formatted": f"Windeln ({date_str}): {daily[0]} gewechselt ({daily[1]} Pipi, {daily[2]} Stuhl)", + "total_changes": daily[0], + "wet_count": daily[1], + "dirty_count": daily[2], + } + elif kind.startswith("feeding"): + daily = self.con.execute( + "SELECT bottle_count, total_consumed_ml FROM v_feeding_daily WHERE subject = ? AND log_date = ?", + [subject, date_str], + ).fetchone() + if daily: + return { + "day_total_formatted": f"Fläschchen ({date_str}): {daily[0]} Fläschchen ({int(daily[1])} ml getrunken)", + "bottle_count": daily[0], + "total_consumed_ml": daily[1], + } return {} # -------------------------------------------------------------------------- diff --git a/skills/datastore/scripts/morning_briefing.py b/skills/datastore/scripts/morning_briefing.py new file mode 100644 index 0000000..9d0bace --- /dev/null +++ b/skills/datastore/scripts/morning_briefing.py @@ -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: 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() diff --git a/skills/datastore/scripts/update_website.py b/skills/datastore/scripts/update_website.py new file mode 100644 index 0000000..6722673 --- /dev/null +++ b/skills/datastore/scripts/update_website.py @@ -0,0 +1,739 @@ +#!/usr/bin/env python3 +# /// script +# dependencies = [ +# "duckdb>=1.1.0", +# "boto3>=1.34.0", +# "pytz>=2024.1", +# "python-ulid>=3.0.0", +# ] +# /// +""" +update_website.py — Builds and publishes the streamlined, mobile-first, +PWA-installable Mila family dashboard to https://hosting.haumdaucher.de/family/mila.html. + +Key Features: +1. Current Day strictly ON TOP: Live sleep/wake status (active sleep or current wake window), + detailed today breakdown (night sleep segments + each individual nap with exact start/end). +2. Conditional Modules: Fläschchen (feeding) and Windeln (diapers) are rendered dynamically + and hidden gracefully when not logged. +3. 30-Day History Table with collapsible/detailed nap timelines per day. +4. Instant S3 deployment to MinIO (permanent URL: /family/mila.html) with zero-cache headers. +""" + +from __future__ import annotations + +import argparse +import datetime +import json +import os +import sys +from pathlib import Path +from zoneinfo import ZoneInfo +import duckdb + +# Add sibling scripts to path +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_DE = { + 0: "Mo", + 1: "Di", + 2: "Mi", + 3: "Do", + 4: "Fr", + 5: "Sa", + 6: "So", +} + +WEEKDAYS_LONG_DE = { + 0: "Montag", + 1: "Dienstag", + 2: "Mittwoch", + 3: "Donnerstag", + 4: "Freitag", + 5: "Samstag", + 6: "Sonntag", +} + +DEFAULT_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "http://minio.minio.svc.cluster.local:9000") +DEFAULT_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "openclaw") +DEFAULT_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "5uu23t69xC2Oigf8Bky0KTlI") +DEFAULT_BUCKET = os.environ.get("MINIO_BUCKET", "hosting") +DEFAULT_BASE_URL = os.environ.get("HOSTING_BASE_URL", "https://hosting.haumdaucher.de").rstrip("/") + + +def calculate_age_str(as_of: datetime.date) -> str: + """Calculates age in months and weeks from Mila's birthdate (14.03.2026).""" + days = (as_of - MILA_BIRTHDATE).days + if days < 0: + return "Geboren am 14.03.2026" + months = days // 30.4375 + weeks = (days % 30.4375) // 7 + if months < 1: + return f"{days // 7} Wochen alt" + if weeks > 0: + return f"{int(months)} Monate, {int(weeks)} Wochen alt" + return f"{int(months)} Monate alt" + + +def get_s3_client(): + import boto3 + from botocore.client import Config + + return boto3.client( + "s3", + endpoint_url=DEFAULT_ENDPOINT, + aws_access_key_id=DEFAULT_ACCESS_KEY, + aws_secret_access_key=DEFAULT_SECRET_KEY, + config=Config(signature_version="s3v4", connect_timeout=5, retries={"max_attempts": 2}), + region_name="us-east-1", + ) + + +def generate_dashboard_html(engine: DatastoreEngine) -> str: + con = engine.con + now_berlin = datetime.datetime.now(BERLIN_TZ) + today_date = now_berlin.date() + today_str = today_date.strftime("%Y-%m-%d") + today_pretty = f"{WEEKDAYS_LONG_DE[today_date.weekday()]}, {today_date.strftime('%d.%m.%Y')}" + age_str = calculate_age_str(today_date) + + # 1. Active open sleep check + open_sleep = con.execute( + """ + SELECT id, start, local_start, sleep_type, location, note, + date_diff('minute', start, CURRENT_TIMESTAMP) AS duration_mins + FROM v_sleep + WHERE subject = 'mila' AND is_open = true + ORDER BY start DESC LIMIT 1 + """ + ).fetchone() + + is_sleeping = open_sleep is not None + active_sleep_duration_str = "" + active_sleep_start_time = "" + wake_window_str = "" + last_wake_time_str = "" + + if is_sleeping: + dur = max(0, open_sleep[6] or 0) + h, m = divmod(dur, 60) + active_sleep_duration_str = f"{h} h {m:02d} min" if h > 0 else f"{m} min" + s_dt = datetime.datetime.fromisoformat(str(open_sleep[2])) + active_sleep_start_time = s_dt.strftime("%H:%M") + else: + # Calculate current wake window from last closed sleep session + last_closed = con.execute( + """ + SELECT "end", local_end, date_diff('minute', "end", CURRENT_TIMESTAMP) AS mins_ago + FROM v_sleep + WHERE subject = 'mila' AND is_open = false AND "end" IS NOT NULL + ORDER BY "end" DESC LIMIT 1 + """ + ).fetchone() + if last_closed: + mins_ago = max(0, last_closed[2] or 0) + h, m = divmod(mins_ago, 60) + wake_window_str = f"{h} h {m:02d} min" if h > 0 else f"{m} min" + e_dt = datetime.datetime.fromisoformat(str(last_closed[1])) + last_wake_time_str = e_dt.strftime("%H:%M") + + # 2. Today's sleep details + today_sessions = con.execute( + f""" + SELECT id, strftime(local_start, '%H:%M') AS start_fmt, + CASE WHEN is_open THEN NULL ELSE strftime(local_end, '%H:%M') END AS end_fmt, + sleep_type, location, note, duration_minutes, is_open + FROM v_sleep + WHERE subject = 'mila' AND log_date = '{today_str}' + ORDER BY start ASC + """ + ).fetchall() + + today_naps = [] + today_nights = [] + for s in today_sessions: + if s[3] == "night": + today_nights.append(s) + else: + today_naps.append(s) + + # Today daily aggregates + today_agg = con.execute( + f""" + SELECT total_sleep_hours, nap_count, night_sleep_minutes, nap_sleep_minutes + FROM v_sleep_daily + WHERE subject = 'mila' AND log_date = '{today_str}' + """ + ).fetchone() + + today_tot_hours = f"{today_agg[0]:.2f} h" if today_agg and today_agg[0] else "0 h" + today_night_min = today_agg[2] if today_agg else 0 + today_night_h, today_night_m = divmod(today_night_min, 60) + today_night_dur_str = f"{today_night_h} h {today_night_m:02d} min" if today_night_h > 0 else f"{today_night_m} min" + today_nap_count = today_agg[1] if today_agg else 0 + today_nap_min = today_agg[3] if today_agg else 0 + + # 3. Today's Feedings (conditional) + today_feedings = con.execute( + f""" + SELECT strftime(local_time, '%H:%M'), consumed_ml, milk_type, note + FROM v_feeding + WHERE subject = 'mila' AND log_date = '{today_str}' + ORDER BY start ASC + """ + ).fetchall() + + today_feeding_agg = con.execute( + f""" + SELECT bottle_count, total_consumed_ml + FROM v_feeding_daily + WHERE subject = 'mila' AND log_date = '{today_str}' + """ + ).fetchone() + + has_feeding_today = bool(today_feedings and len(today_feedings) > 0) + today_bottle_count = today_feeding_agg[0] if today_feeding_agg else 0 + today_bottle_ml = int(today_feeding_agg[1]) if today_feeding_agg else 0 + + # 4. Today's Diapers (conditional) + today_diapers = con.execute( + f""" + SELECT strftime(local_time, '%H:%M'), pee, poo, texture, note + FROM v_diaper + WHERE subject = 'mila' AND log_date = '{today_str}' + ORDER BY start ASC + """ + ).fetchall() + + today_diaper_agg = con.execute( + f""" + SELECT total_changes, wet_count, dirty_count + FROM v_diaper_daily + WHERE subject = 'mila' AND log_date = '{today_str}' + """ + ).fetchone() + + has_diapers_today = bool(today_diapers and len(today_diapers) > 0) + today_diaper_count = today_diaper_agg[0] if today_diaper_agg else 0 + today_wet_count = today_diaper_agg[1] if today_diaper_agg else 0 + today_dirty_count = today_diaper_agg[2] if today_diaper_agg else 0 + + # 5. 7-Day and 30-Day Sleep Metrics + avg_7 = con.execute( + """ + SELECT ROUND(AVG(total_sleep_hours), 2), ROUND(AVG(night_sleep_minutes)/60.0, 2), ROUND(AVG(nap_sleep_minutes)/60.0, 2), ROUND(AVG(nap_count), 1) + FROM (SELECT total_sleep_hours, night_sleep_minutes, nap_sleep_minutes, nap_count FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7) + """ + ).fetchone() + avg_7_tot = f"{avg_7[0]:.2f} h" if avg_7 and avg_7[0] is not None else "—" + avg_7_night = f"{avg_7[1]:.2f} h" if avg_7 and avg_7[1] is not None else "—" + avg_7_naps = f"{avg_7[3]} Naps ({avg_7[2]:.2f} h)" if avg_7 and avg_7[3] is not None else "—" + + 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 = f"{avg_30[0]:.2f} h" if avg_30 and avg_30[0] is not None else "—" + + # 6. Past 30 Days History Table + history_days = con.execute( + """ + SELECT + s.log_date, + s.total_sleep_hours, + s.night_sleep_minutes, + s.nap_count, + s.nap_sleep_minutes, + COALESCE(f.total_consumed_ml, 0) AS feed_ml, + COALESCE(f.bottle_count, 0) AS feed_bottles, + COALESCE(d.total_changes, 0) AS diaper_cnt, + COALESCE(d.dirty_count, 0) AS diaper_dirty + FROM v_sleep_daily s + LEFT JOIN v_feeding_daily f ON s.log_date = f.log_date AND s.subject = f.subject + LEFT JOIN v_diaper_daily d ON s.log_date = d.log_date AND s.subject = d.subject + WHERE s.subject = 'mila' + ORDER BY s.log_date DESC + LIMIT 30 + """ + ).fetchall() + + # Pre-fetch individual sessions for the last 30 days for expandable details + sessions_by_date = {} + all_recent_sessions = con.execute( + """ + SELECT log_date, strftime(local_start, '%H:%M'), + CASE WHEN is_open THEN NULL ELSE strftime(local_end, '%H:%M') END, + sleep_type, duration_minutes, is_open, note + FROM v_sleep + WHERE subject = 'mila' AND log_date >= (CURRENT_DATE - INTERVAL 30 DAY) + ORDER BY start ASC + """ + ).fetchall() + for row in all_recent_sessions: + d_key = str(row[0]) + if d_key not in sessions_by_date: + sessions_by_date[d_key] = [] + sessions_by_date[d_key].append(row) + + # Build history rows HTML + history_rows = [] + for r in history_days: + d_str = str(r[0]) + d_obj = datetime.date.fromisoformat(d_str) + wkday = WEEKDAYS_DE[d_obj.weekday()] + tot_h = f"{r[1]:.2f} h" + night_h, night_m = divmod(r[2], 60) + night_str = f"{night_h} h {night_m:02d} min" if night_h > 0 else f"{night_m} min" + nap_str = f"{r[3]} ({r[4]} min)" + feed_str = f"{int(r[5])} ml ({r[6]}x)" if r[6] > 0 else "—" + diaper_str = f"{r[7]} ({r[8]} Stuhl)" if r[7] > 0 else "—" + + # Detailed breakdown of sessions for this day + day_sess = sessions_by_date.get(d_str, []) + sess_badges = [] + for s in day_sess: + s_st = s[1] + s_et = s[2] or "läuft" + s_dur = f"{s[4]}m" if s[4] else "" + badge_color = "bg-purple-950/60 text-purple-300 border-purple-800/40" if s[3] == "night" else "bg-emerald-950/60 text-emerald-300 border-emerald-800/40" + sess_badges.append( + f'{s_st}–{s_et} ({s_dur})' + ) + sess_badges_html = " ".join(sess_badges) if sess_badges else '—' + + is_today_row = (d_str == today_str) + row_bg = "bg-purple-950/20" if is_today_row else "hover:bg-zinc-900/40" + + history_rows.append(f""" + + + {wkday}, {d_obj.strftime('%d.%m.')} + {' Heute' if is_today_row else ''} + + {tot_h} + {night_str} + {nap_str} + {feed_str} + {diaper_str} + + + + Einzelschläfchen: {sess_badges_html} + + + """) + + # Render today's naps cards HTML + today_naps_html = [] + if today_naps: + for idx, n in enumerate(today_naps, 1): + n_st = n[1] + n_et = n[2] or "läuft gerade" + n_dur = f"{n[6]} min" if n[6] is not None else "in Ausführung" + n_loc = f"({n[4]})" if n[4] else "" + n_note = f"· {n[5]}" if n[5] else "" + running_pulse = '' if n[7] else '' + + today_naps_html.append(f""" +
+
+
+ #{idx} +
+
+
+ {n_st} – {n_et} Uhr + {running_pulse} +
+
{n_loc} {n_note}
+
+
+
+ {n_dur} +
+
+ """) + else: + today_naps_html.append(""" +
+ Heute bisher noch kein Tagesschläfchen eingetragen. +
+ """) + + # Render today's nights cards HTML + today_nights_html = [] + if today_nights: + for n in today_nights: + n_st = n[1] + n_et = n[2] or "läuft" + dur_m = n[6] or 0 + dh, dm = divmod(dur_m, 60) + dur_str = f"{dh} h {dm:02d} min" if dh > 0 else f"{dm} min" + note_str = f"· {n[5]}" if n[5] else "" + today_nights_html.append(f""" +
+
+
+ 🌙 +
+
+
{n_st} – {n_et} Uhr
+
{note_str}
+
+
+
{dur_str}
+
+ """) + else: + today_nights_html.append(""" +
+ Kein Nachtschlaf für das Tagesdatum zugeordnet (Nachtstart zählt zum Vortag). +
+ """) + + # Conditional feeding card HTML + feeding_section_html = "" + if has_feeding_today: + bottles_pills = [] + for f in today_feedings: + ml_val = f"{int(f[1])} ml" if f[1] else "—" + bottles_pills.append(f'{f[0]} Uhr ({ml_val})') + feeding_section_html = f""" +
+
+
+ 🍼 +

Fläschchen heute

+
+ {today_bottle_ml} ml ({today_bottle_count}x) +
+
+ {' '.join(bottles_pills)} +
+
+ """ + + # Conditional diaper card HTML + diaper_section_html = "" + if has_diapers_today: + diaper_pills = [] + for d in today_diapers: + content = [] + if d[1]: content.append("Pipi") + if d[2]: content.append("Kacka") + d_desc = "+".join(content) or "Wechsel" + diaper_pills.append(f'{d[0]} Uhr ({d_desc})') + diaper_section_html = f""" +
+
+
+ 🧷 +

Windeln heute

+
+ {today_diaper_count} gewechselt ({today_wet_count} nass · {today_dirty_count} Stuhl) +
+
+ {' '.join(diaper_pills)} +
+
+ """ + + # PWA Manifest as inline Data URI + manifest_dict = { + "name": "Mila Tracker", + "short_name": "Mila", + "start_url": "/family/mila.html", + "display": "standalone", + "background_color": "#090A0F", + "theme_color": "#090A0F", + "icons": [ + { + "src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='50' r='48' fill='%237C3AED'/%3E%3Ctext x='50' y='65' font-size='42' font-family='sans-serif' text-anchor='middle' fill='white'%3E%F0%9F%A7%B8%3C/text%3E%3C/svg%3E", + "sizes": "192x192 512x512", + "type": "image/svg+xml" + } + ] + } + manifest_uri = "data:application/manifest+json," + json.dumps(manifest_dict) + + # Full HTML Document + html = f""" + + + + + Mila — Schlaf & Entwicklung + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ 🧸 +
+
+
+
+

+ Mila Graf +

+ + {age_str} + +
+

{today_pretty}

+
+
+ + +
+ {'
Schl\u00e4ft (' + active_sleep_duration_str + ')
' if is_sleeping else '
Wach (' + wake_window_str + ')
'} +
+
+
+ +
+ + +
+ + +
+
+
+ {'💤 Status: Schläft gerade' if is_sleeping else '☀️ Status: Wach & Munter'} +
+
+ {f'Seit {active_sleep_start_time} Uhr ({active_sleep_duration_str})' if is_sleeping else f'Wachfenster: {wake_window_str}'} +
+

+ {f'Eingeschlafen im Bett. Schlafintervall l\u00e4uft aktuell.' if is_sleeping else f'Letztes Schläfchen beendet um {last_wake_time_str} Uhr.'} +

+
+
+ + +
+
+
24h Schlaf Heute
+
{today_tot_hours}
+
Nacht + Schläfchen
+
+
+
Tagesschläfchen
+
{today_nap_count} Naps
+
{today_nap_min} min gesamt
+
+
+
Nachtschlaf
+
{today_night_dur_str}
+
Hauptschlafphase
+
+
+
7-Tage-Schnitt
+
{avg_7_tot}
+
Alterskorridor: 12–15h
+
+
+ + +
+
+
+ 📋 +

Heutiger Tagesablauf im Detail

+
+ {today_str} +
+ + +
+
+ 🌿 + Tagesschläfchen ({today_nap_count} erfasst) +
+
+ {''.join(today_naps_html)} +
+
+ + +
+
+ 🌙 + Nachtschlaf +
+
+ {''.join(today_nights_html)} +
+
+
+ + + {feeding_section_html} + {diaper_section_html} + +
+ + +
+
+ 📈 +

Entwicklungs-Benchmarks & Tendenzen

+
+
+
+
7-Tage-Ø Gesamtschlaf
+
{avg_7_tot}
+
Nacht: {avg_7_night} · Tag: {avg_7_naps}
+
+
+
30-Tage-Ø Gesamtschlaf
+
{avg_30_tot}
+
Stabile zirkadiane Konsolidierung
+
+
+
Altersreferenz (7 Monate)
+
12 – 15 Std
+
Typisch 2–3 Naps, Wachfenster ~2.25–3.25h
+
+
+
+ + +
+
+
+ 📅 +

Historie (Letzte 30 Tage)

+
+ DuckDB Aggregatdaten +
+ +
+ + + + + + + + + + + + + {''.join(history_rows)} + +
Datum24h GesamtNachtSchläfchenFläschchenWindeln
+
+
+ +
+ + +
+

Aktualisiert: {now_berlin.strftime('%d.%m.%Y um %H:%M:%S')} Uhr · Single Source of Truth via DuckDB Datastore

+

Tipp: Im Safari-Menü auf „Zum Home-Bildschirm" tippen für die native App-Nutzung.

+
+ + + + +""" + return html + + +def publish_to_s3(html_content: str) -> str: + """Uploads the rendered HTML to MinIO hosting bucket at family/mila.html.""" + s3 = get_s3_client() + key = "family/mila.html" + + s3.put_object( + Bucket=DEFAULT_BUCKET, + Key=key, + Body=html_content.encode("utf-8"), + ContentType="text/html; charset=utf-8", + CacheControl="no-cache, no-store, must-revalidate", + ) + url = f"{DEFAULT_BASE_URL}/{key}" + return url + + +def main(): + parser = argparse.ArgumentParser(description="Render and publish Mila family dashboard") + parser.add_argument("--data-dir", help="Path to knowledge-wiki/data directory") + parser.add_argument("--output-file", help="Path to save HTML locally") + parser.add_argument("--skip-upload", action="store_true", help="Skip S3 upload") + args = parser.parse_args() + + data_dir = Path(args.data_dir) if args.data_dir else find_data_dir() + engine = DatastoreEngine(data_dir) + + print("Building Mila Dashboard from DuckDB...") + html = generate_dashboard_html(engine) + + # 1. Save locally to knowledge-wiki/public/family/mila.html (or specified file) + wiki_public = data_dir.parent / "public" / "family" + wiki_public.mkdir(parents=True, exist_ok=True) + out_file = Path(args.output_file) if args.output_file else (wiki_public / "mila.html") + out_file.write_text(html, encoding="utf-8") + print(f"✅ Saved locally: {out_file}") + + # 2. Upload to S3 hosting + if not args.skip_upload: + try: + url = publish_to_s3(html) + print(f"🚀 Published successfully to: {url}") + except Exception as e: + print(f"⚠️ S3 upload failed (local testing?): {e}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/skills/datastore/tests/test_datastore.py b/skills/datastore/tests/test_datastore.py index 7103bcd..b28bfdd 100644 --- a/skills/datastore/tests/test_datastore.py +++ b/skills/datastore/tests/test_datastore.py @@ -675,5 +675,67 @@ def test_cli_rejects_invalid_kind(temp_datastore: DatastoreEngine): assert "Invalid kind 'true'" in proc.stderr +def test_diaper_logging(temp_datastore: DatastoreEngine): + """Verifies diaper change events, feedback, and DuckDB views.""" + res = temp_datastore.add_event( + kind="diaper.change", + subject="mila", + actor="agent:hebamme", + start="2026-10-11T09:45:00+02:00", + payload={"pee": True, "poo": True, "texture": "normal"}, + ) + assert res["status"] == "success" + feedback = res["feedback"] + assert feedback["total_changes"] == 1 + assert feedback["wet_count"] == 1 + assert feedback["dirty_count"] == 1 + assert "1 gewechselt" in feedback["day_total_formatted"] + + # Add second diaper (only pee) + res2 = temp_datastore.add_event( + kind="diaper.change", + subject="mila", + actor="agent:hebamme", + start="2026-10-11T12:30:00+02:00", + payload={"pee": True, "poo": False}, + ) + assert res2["status"] == "success" + feedback2 = res2["feedback"] + assert feedback2["total_changes"] == 2 + assert feedback2["wet_count"] == 2 + assert feedback2["dirty_count"] == 1 + + # Check v_diaper and v_diaper_daily views + rows = temp_datastore.query("SELECT * FROM v_diaper WHERE subject = 'mila'") + assert len(rows) == 2 + daily = temp_datastore.query("SELECT * FROM v_diaper_daily WHERE subject = 'mila' AND log_date = '2026-10-11'") + assert len(daily) == 1 + assert daily[0]["total_changes"] == 2 + assert daily[0]["wet_count"] == 2 + assert daily[0]["dirty_count"] == 1 + + +def test_feeding_logging(temp_datastore: DatastoreEngine): + """Verifies feeding bottle events, feedback, and DuckDB views.""" + res = temp_datastore.add_event( + kind="feeding.bottle", + subject="mila", + actor="agent:hebamme", + start="2026-10-11T06:30:00+02:00", + payload={"amount_ml": 180, "consumed_ml": 160, "milk_type": "pre"}, + ) + assert res["status"] == "success" + feedback = res["feedback"] + assert feedback["bottle_count"] == 1 + assert feedback["total_consumed_ml"] == 160.0 + assert "160 ml getrunken" in feedback["day_total_formatted"] + + # Check v_feeding_daily view + daily = temp_datastore.query("SELECT * FROM v_feeding_daily WHERE subject = 'mila' AND log_date = '2026-10-11'") + assert len(daily) == 1 + assert daily[0]["bottle_count"] == 1 + assert daily[0]["total_consumed_ml"] == 160.0 + +