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
+70 -3
View File
@@ -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 {}
# --------------------------------------------------------------------------
@@ -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()
+739
View File
@@ -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'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border {badge_color}">{s_st}–{s_et} ({s_dur})</span>'
)
sess_badges_html = " ".join(sess_badges) if sess_badges else '<span class="text-zinc-500 text-xs">—</span>'
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"""
<tr class="border-b border-zinc-800/60 {row_bg} transition">
<td class="py-3.5 px-4 font-mono font-medium text-zinc-200">
{wkday}, {d_obj.strftime('%d.%m.')}
{' <span class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-300 border border-purple-500/30">Heute</span>' if is_today_row else ''}
</td>
<td class="py-3.5 px-4 font-bold text-purple-300">{tot_h}</td>
<td class="py-3.5 px-4 text-sky-400 font-semibold">{night_str}</td>
<td class="py-3.5 px-4 text-emerald-400">{nap_str}</td>
<td class="py-3.5 px-4 text-zinc-400">{feed_str}</td>
<td class="py-3.5 px-4 text-zinc-400">{diaper_str}</td>
</tr>
<tr class="border-b border-zinc-800/30 bg-zinc-950/30 text-xs">
<td colspan="6" class="py-1.5 px-4 text-zinc-400 space-x-1">
<span class="text-zinc-500 font-mono text-[10px] uppercase">Einzelschläfchen:</span> {sess_badges_html}
</td>
</tr>
""")
# 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 = '<span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>' if n[7] else ''
today_naps_html.append(f"""
<div class="flex items-center justify-between p-3 rounded-xl bg-zinc-900/60 border border-zinc-800/60 hover:border-emerald-500/30 transition">
<div class="flex items-center gap-3">
<div class="w-7 h-7 rounded-lg bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400 font-mono text-xs font-bold">
#{idx}
</div>
<div>
<div class="text-sm font-semibold text-zinc-200 flex items-center gap-2">
<span>{n_st} – {n_et} Uhr</span>
{running_pulse}
</div>
<div class="text-xs text-zinc-400">{n_loc} {n_note}</div>
</div>
</div>
<div class="text-right">
<span class="text-sm font-bold text-emerald-400 font-mono">{n_dur}</span>
</div>
</div>
""")
else:
today_naps_html.append("""
<div class="p-4 rounded-xl bg-zinc-900/40 border border-zinc-800/40 text-center text-zinc-500 text-sm">
Heute bisher noch kein Tagesschläfchen eingetragen.
</div>
""")
# 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"""
<div class="flex items-center justify-between p-3 rounded-xl bg-zinc-900/60 border border-zinc-800/60">
<div class="flex items-center gap-3">
<div class="w-7 h-7 rounded-lg bg-sky-500/10 border border-sky-500/20 flex items-center justify-center text-sky-400 text-xs">
🌙
</div>
<div>
<div class="text-sm font-semibold text-zinc-200">{n_st} – {n_et} Uhr</div>
<div class="text-xs text-zinc-400">{note_str}</div>
</div>
</div>
<div class="text-right font-mono font-bold text-sky-400 text-sm">{dur_str}</div>
</div>
""")
else:
today_nights_html.append("""
<div class="p-3 rounded-xl bg-zinc-900/40 border border-zinc-800/40 text-center text-zinc-500 text-xs">
Kein Nachtschlaf für das Tagesdatum zugeordnet (Nachtstart zählt zum Vortag).
</div>
""")
# 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'<span class="px-2 py-1 rounded bg-amber-500/10 border border-amber-500/20 text-amber-300 text-xs">{f[0]} Uhr ({ml_val})</span>')
feeding_section_html = f"""
<div class="bg-zinc-900/80 border border-amber-500/20 rounded-2xl p-5 shadow-lg space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xl">🍼</span>
<h3 class="font-semibold text-zinc-200">Fläschchen heute</h3>
</div>
<span class="font-mono font-bold text-amber-400 text-lg">{today_bottle_ml} ml <span class="text-xs text-zinc-400 font-normal">({today_bottle_count}x)</span></span>
</div>
<div class="flex flex-wrap gap-2 pt-1">
{' '.join(bottles_pills)}
</div>
</div>
"""
# 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'<span class="px-2 py-1 rounded bg-teal-500/10 border border-teal-500/20 text-teal-300 text-xs">{d[0]} Uhr ({d_desc})</span>')
diaper_section_html = f"""
<div class="bg-zinc-900/80 border border-teal-500/20 rounded-2xl p-5 shadow-lg space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xl">🧷</span>
<h3 class="font-semibold text-zinc-200">Windeln heute</h3>
</div>
<span class="font-mono font-bold text-teal-400 text-lg">{today_diaper_count} gewechselt <span class="text-xs text-zinc-400 font-normal">({today_wet_count} nass · {today_dirty_count} Stuhl)</span></span>
</div>
<div class="flex flex-wrap gap-2 pt-1">
{' '.join(diaper_pills)}
</div>
</div>
"""
# 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"""<!DOCTYPE html>
<html lang="de" class="dark" x-data="{{ activeTab: 'today' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Mila — Schlaf & Entwicklung</title>
<!-- PWA & Mobile Web Capabilities -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Mila">
<meta name="theme-color" content="#090A0F">
<link rel="manifest" href="{manifest_uri}">
<link rel="apple-touch-icon" href="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">
<!-- Typography & Styling -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Syne:wght@700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {{
darkMode: 'class',
theme: {{
extend: {{
fontFamily: {{
heading: ['Syne', 'sans-serif'],
sans: ['Plus Jakarta Sans', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
}}
}}
}}
}}
</script>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
</head>
<body class="bg-[#090A0F] text-zinc-100 min-h-screen font-sans antialiased selection:bg-purple-500 selection:text-white pb-16">
<!-- Header & Live Status (Always sticky on mobile) -->
<header class="border-b border-zinc-800/80 bg-zinc-950/80 backdrop-blur-xl sticky top-0 z-40 px-4 py-3 sm:px-8">
<div class="max-w-4xl mx-auto flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-2xl bg-gradient-to-tr from-purple-600 via-pink-500 to-amber-400 p-0.5 shadow-lg shadow-purple-500/20">
<div class="w-full h-full bg-zinc-950 rounded-[14px] flex items-center justify-center text-lg">
🧸
</div>
</div>
<div>
<div class="flex items-center gap-2">
<h1 class="text-lg sm:text-xl font-heading font-extrabold tracking-tight bg-gradient-to-r from-purple-300 via-pink-300 to-indigo-200 bg-clip-text text-transparent">
Mila Graf
</h1>
<span class="text-[11px] px-2 py-0.5 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-300 font-medium">
{age_str}
</span>
</div>
<p class="text-xs text-zinc-400">{today_pretty}</p>
</div>
</div>
<!-- Live State Badge -->
<div>
{'<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-purple-500/15 border border-purple-500/30 text-purple-300 text-xs font-semibold shadow-sm shadow-purple-500/10"><span class="w-2 h-2 rounded-full bg-purple-400 animate-pulse"></span><span>Schl\u00e4ft (' + active_sleep_duration_str + ')</span></div>' if is_sleeping else '<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 text-xs font-semibold shadow-sm shadow-emerald-500/10"><span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span><span>Wach (' + wake_window_str + ')</span></div>'}
</div>
</div>
</header>
<main class="max-w-4xl mx-auto p-4 sm:p-6 space-y-6">
<!-- HERO SECTION: CURRENT DAY STRICTLY ON TOP -->
<section class="space-y-4">
<!-- Hero Banner / Current State Card -->
<div class="p-6 rounded-3xl {'bg-gradient-to-br from-purple-900/30 via-zinc-900/70 to-zinc-950 border border-purple-500/30' if is_sleeping else 'bg-gradient-to-br from-emerald-900/20 via-zinc-900/70 to-zinc-950 border border-emerald-500/30'} shadow-2xl relative overflow-hidden">
<div class="relative z-10 space-y-2">
<div class="flex items-center gap-2 text-xs font-mono tracking-wider uppercase {'text-purple-400 font-bold' if is_sleeping else 'text-emerald-400 font-bold'}">
<span>{'💤 Status: Schläft gerade' if is_sleeping else '☀️ Status: Wach & Munter'}</span>
</div>
<div class="text-3xl sm:text-4xl font-extrabold tracking-tight text-white font-heading">
{f'Seit {active_sleep_start_time} Uhr ({active_sleep_duration_str})' if is_sleeping else f'Wachfenster: {wake_window_str}'}
</div>
<p class="text-sm text-zinc-400">
{f'Eingeschlafen im Bett. Schlafintervall l\u00e4uft aktuell.' if is_sleeping else f'Letztes Schläfchen beendet um {last_wake_time_str} Uhr.'}
</p>
</div>
</div>
<!-- Key Metrics Strip for Today -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div class="bg-zinc-900/80 border border-zinc-800 rounded-2xl p-4 shadow-md">
<div class="text-[11px] font-semibold uppercase tracking-wider text-zinc-400">24h Schlaf Heute</div>
<div class="text-2xl font-extrabold text-purple-300 font-mono mt-1">{today_tot_hours}</div>
<div class="text-[11px] text-zinc-500 mt-0.5">Nacht + Schläfchen</div>
</div>
<div class="bg-zinc-900/80 border border-zinc-800 rounded-2xl p-4 shadow-md">
<div class="text-[11px] font-semibold uppercase tracking-wider text-zinc-400">Tagesschläfchen</div>
<div class="text-2xl font-extrabold text-emerald-400 font-mono mt-1">{today_nap_count} Naps</div>
<div class="text-[11px] text-zinc-500 mt-0.5">{today_nap_min} min gesamt</div>
</div>
<div class="bg-zinc-900/80 border border-zinc-800 rounded-2xl p-4 shadow-md">
<div class="text-[11px] font-semibold uppercase tracking-wider text-zinc-400">Nachtschlaf</div>
<div class="text-2xl font-extrabold text-sky-400 font-mono mt-1">{today_night_dur_str}</div>
<div class="text-[11px] text-zinc-500 mt-0.5">Hauptschlafphase</div>
</div>
<div class="bg-zinc-900/80 border border-zinc-800 rounded-2xl p-4 shadow-md">
<div class="text-[11px] font-semibold uppercase tracking-wider text-zinc-400">7-Tage-Schnitt</div>
<div class="text-2xl font-extrabold text-pink-400 font-mono mt-1">{avg_7_tot}</div>
<div class="text-[11px] text-zinc-500 mt-0.5">Alterskorridor: 12–15h</div>
</div>
</div>
<!-- TODAY'S DETAILED TIMELINE: NAPS & NIGHT -->
<div class="bg-zinc-900/80 border border-zinc-800/80 rounded-2xl p-5 shadow-xl space-y-4">
<div class="flex items-center justify-between border-b border-zinc-800/80 pb-3">
<div class="flex items-center gap-2">
<span class="text-lg">📋</span>
<h2 class="text-base font-bold text-zinc-100">Heutiger Tagesablauf im Detail</h2>
</div>
<span class="text-xs font-mono text-zinc-400">{today_str}</span>
</div>
<!-- Tagesschläfchen Breakdown -->
<div class="space-y-2">
<div class="text-xs font-bold text-emerald-400 uppercase tracking-wider flex items-center gap-1.5">
<span>🌿</span>
<span>Tagesschläfchen ({today_nap_count} erfasst)</span>
</div>
<div class="space-y-2">
{''.join(today_naps_html)}
</div>
</div>
<!-- Nachtschlaf Breakdown -->
<div class="space-y-2 pt-2 border-t border-zinc-800/50">
<div class="text-xs font-bold text-sky-400 uppercase tracking-wider flex items-center gap-1.5">
<span>🌙</span>
<span>Nachtschlaf</span>
</div>
<div class="space-y-2">
{''.join(today_nights_html)}
</div>
</div>
</div>
<!-- CONDITIONAL MODULES: BOTTLES & DIAPERS -->
{feeding_section_html}
{diaper_section_html}
</section>
<!-- DEVELOPMENT & TREND BENCHMARKS -->
<section class="bg-gradient-to-r from-purple-950/30 via-zinc-900/60 to-zinc-900/30 border border-zinc-800 rounded-2xl p-5 shadow-md">
<div class="flex items-center gap-2 mb-3">
<span class="text-lg">📈</span>
<h2 class="text-sm font-bold uppercase tracking-wider text-zinc-300">Entwicklungs-Benchmarks & Tendenzen</h2>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs text-zinc-400">
<div class="p-3 rounded-xl bg-zinc-950/50 border border-zinc-800/60 space-y-1">
<div class="text-zinc-500 font-medium">7-Tage-Ø Gesamtschlaf</div>
<div class="text-lg font-bold text-purple-300 font-mono">{avg_7_tot}</div>
<div>Nacht: <span class="text-sky-300 font-mono">{avg_7_night}</span> · Tag: <span class="text-emerald-300 font-mono">{avg_7_naps}</span></div>
</div>
<div class="p-3 rounded-xl bg-zinc-950/50 border border-zinc-800/60 space-y-1">
<div class="text-zinc-500 font-medium">30-Tage-Ø Gesamtschlaf</div>
<div class="text-lg font-bold text-pink-300 font-mono">{avg_30_tot}</div>
<div>Stabile zirkadiane Konsolidierung</div>
</div>
<div class="p-3 rounded-xl bg-zinc-950/50 border border-zinc-800/60 space-y-1">
<div class="text-zinc-500 font-medium">Altersreferenz (7 Monate)</div>
<div class="text-lg font-bold text-amber-300 font-mono">12 – 15 Std</div>
<div>Typisch 2–3 Naps, Wachfenster ~2.25–3.25h</div>
</div>
</div>
</section>
<!-- 30-DAY HISTORY TABLE -->
<section class="bg-zinc-900/80 border border-zinc-800/80 rounded-2xl p-5 shadow-xl space-y-4">
<div class="flex items-center justify-between pb-2 border-b border-zinc-800/60">
<div class="flex items-center gap-2">
<span class="text-lg">📅</span>
<h2 class="text-base font-bold text-zinc-100">Historie (Letzte 30 Tage)</h2>
</div>
<span class="text-xs text-zinc-400">DuckDB Aggregatdaten</span>
</div>
<div class="overflow-x-auto -mx-5 px-5">
<table class="w-full text-left text-sm whitespace-nowrap">
<thead class="text-[11px] uppercase bg-zinc-800/50 text-zinc-400">
<tr>
<th class="py-2.5 px-4 rounded-l-lg">Datum</th>
<th class="py-2.5 px-4">24h Gesamt</th>
<th class="py-2.5 px-4">Nacht</th>
<th class="py-2.5 px-4">Schläfchen</th>
<th class="py-2.5 px-4">Fläschchen</th>
<th class="py-2.5 px-4 rounded-r-lg">Windeln</th>
</tr>
</thead>
<tbody>
{''.join(history_rows)}
</tbody>
</table>
</div>
</section>
</main>
<!-- Floating Refresh Footer -->
<footer class="max-w-4xl mx-auto px-4 text-center text-xs text-zinc-500 space-y-1">
<p>Aktualisiert: {now_berlin.strftime('%d.%m.%Y um %H:%M:%S')} Uhr · Single Source of Truth via DuckDB Datastore</p>
<p class="text-[11px]">Tipp: Im Safari-Menü auf „Zum Home-Bildschirm" tippen für die native App-Nutzung.</p>
</footer>
<script>
document.addEventListener('DOMContentLoaded', () => {{
lucide.createIcons();
}});
</script>
</body>
</html>
"""
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()
+62
View File
@@ -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