diff --git a/skills/datastore/scripts/datastore.py b/skills/datastore/scripts/datastore.py index 59e332b..4064acc 100755 --- a/skills/datastore/scripts/datastore.py +++ b/skills/datastore/scripts/datastore.py @@ -29,6 +29,8 @@ from typing import Any, Dict, List, Optional, Tuple, Union from zoneinfo import ZoneInfo import duckdb +import urllib.request +import urllib.error SCRIPT_DIR = Path(__file__).resolve().parent if str(SCRIPT_DIR) not in sys.path: @@ -110,12 +112,74 @@ def find_data_dir() -> Path: class DatastoreEngine: - def __init__(self, data_dir: Optional[Path] = None): + def __init__(self, data_dir: Optional[Path] = None, backend_url: Optional[str] = None): self.data_dir = data_dir or find_data_dir() - self.con = duckdb.connect(":memory:") - self.has_spatial_extension = False - self._init_schemas() - self._load_all_data() + self.backend_url = (backend_url or os.environ.get( + "DUCKDB_BACKEND_URL", "http://duckdb-backend.family.svc.cluster.local:8000/duckdb-api/v1" + )).rstrip("/") + + self.use_backend = False + if os.environ.get("DUCKDB_FORCE_LOCAL") != "1": + try: + base = self.backend_url.replace("/duckdb-api/v1", "") + test_url = f"{base}/healthz" + req = urllib.request.Request(test_url, method="GET") + with urllib.request.urlopen(req, timeout=1.0) as r: + if r.status == 200: + self.use_backend = True + logger.info(f"DatastoreEngine using single-writer backend at {self.backend_url}") + except Exception: + self.use_backend = False + + if not self.use_backend: + self.con = duckdb.connect(":memory:") + self.has_spatial_extension = False + self._init_schemas() + self._load_all_data() + + def _http_post(self, url: str, data: Dict[str, Any]) -> Any: + req = urllib.request.Request( + url, + data=json.dumps(data).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + err_body = e.read().decode("utf-8", errors="replace") + try: + detail = json.loads(err_body).get("detail", err_body) + except Exception: + detail = err_body + raise RuntimeError(f"Backend HTTP {e.code}: {detail}") + + def _http_get(self, url: str) -> Any: + req = urllib.request.Request(url, method="GET") + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + err_body = e.read().decode("utf-8", errors="replace") + try: + detail = json.loads(err_body).get("detail", err_body) + except Exception: + detail = err_body + raise RuntimeError(f"Backend HTTP {e.code}: {detail}") + + def _http_delete(self, url: str) -> Any: + req = urllib.request.Request(url, method="DELETE") + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + err_body = e.read().decode("utf-8", errors="replace") + try: + detail = json.loads(err_body).get("detail", err_body) + except Exception: + detail = err_body + raise RuntimeError(f"Backend HTTP {e.code}: {detail}") def _init_schemas(self): """Initializes tables, views, and spatial support in DuckDB.""" @@ -429,30 +493,8 @@ class DatastoreEngine: logger.warning(f"Error loading garmin record from {file_path}: {e}") def _trigger_website_update(self, subject: str): - """Triggers website updates automatically (Mila -> update_website.py, Moritz -> update_moritz_website.py).""" - if subject == "mila": - script_name = "update_website.py" - elif subject == "moritz": - script_name = "update_moritz_website.py" - else: - return - - try: - update_script = Path(__file__).resolve().parent / script_name - 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), "--data-dir", str(self.data_dir)] - else: - cmd = [sys.executable, str(update_script), "--data-dir", str(self.data_dir)] - subprocess.Popen( - cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except Exception as e: - logger.warning(f"Could not trigger {script_name}: {e}") + """No-op: legacy HTML generator retired in favor of Vue 3 apps.""" + pass def _git_sync(self, commit_msg: str): """Safely commits and pushes data changes if data_dir is inside a git repository.""" @@ -653,6 +695,25 @@ class DatastoreEngine: allow_duplicate: bool = False, ) -> Dict[str, Any]: """Adds a time-series event into the appropriate database (sleep, nutrition, or mila).""" + if self.use_backend: + body = { + "kind": kind, + "subject": subject, + "actor": actor, + "payload": payload, + "start": start, + "end": end, + "since": since, + "estimated": estimated, + "meal": meal, + "note": note, + "source_ref": source_ref, + "allow_duplicate": allow_duplicate, + } + if isinstance(payload, dict) and "date" in payload and payload["date"]: + body["date"] = payload["date"] + return self._http_post(f"{self.backend_url}/events", body) + event_id = generate_ulid() now_dt = datetime.datetime.now(datetime.timezone.utc) now_iso = now_dt.isoformat() @@ -905,6 +966,18 @@ class DatastoreEngine: actor: Optional[str] = None, ) -> Dict[str, Any]: """Finds the open event (end IS NULL) for kind and subject, and closes it.""" + if self.use_backend: + body = { + "kind": kind, + "subject": subject, + "end_time": end_time, + "since": since, + "event_id": event_id, + } + if actor: + body["actor"] = actor + return self._http_post(f"{self.backend_url}/events/stop", body) + if since and end_time: raise ValueError("Cannot specify both 'since' and 'end_time'. Use 'since' for relative times or 'end_time' for absolute times.") @@ -1030,6 +1103,9 @@ class DatastoreEngine: def delete_event(self, event_id: str) -> Dict[str, Any]: """Soft-deletes an event across sleep, nutrition, or mila.""" + if self.use_backend: + return self._http_delete(f"{self.backend_url}/events/{event_id}") + now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat() row = self.con.execute("SELECT kind, subject FROM events WHERE id = ?", [event_id]).fetchone() if not row: @@ -1052,6 +1128,9 @@ class DatastoreEngine: # -------------------------------------------------------------------------- def put_entity(self, collection: str, record: Dict[str, Any]) -> Dict[str, Any]: """Upserts an entity into entities/.jsonl.""" + if self.use_backend: + return self._http_post(f"{self.backend_url}/entities", {"collection": collection, "record": record}) + if collection not in ["foods", "recipes", "places", "people"]: raise ValueError(f"Unknown entity collection: {collection}") @@ -1114,6 +1193,10 @@ class DatastoreEngine: # -------------------------------------------------------------------------- def query(self, sql: str) -> List[Dict[str, Any]]: """Executes read-only SQL query and returns list of dictionaries.""" + if self.use_backend: + res = self._http_post(f"{self.backend_url}/query", {"query": sql}) + return res.get("rows", []) + clean_sql = sql.strip() # Basic safeguard against destructive statements in read query disallowed = ["DROP ", "ALTER ", "TRUNCATE ", "DELETE FROM", "UPDATE "] @@ -1138,6 +1221,12 @@ class DatastoreEngine: def get_summary(self, subject: str, date_str: Optional[str] = None) -> Dict[str, Any]: """Returns daily summary for subject (nutrition, sleep, deficit).""" + if self.use_backend: + url = f"{self.backend_url}/summary/{subject}" + if date_str: + url += f"?date={date_str}" + return self._http_get(url) + target_date = date_str or datetime.datetime.now(BERLIN_TZ).strftime("%Y-%m-%d") res: Dict[str, Any] = {"subject": subject, "date": target_date} @@ -1195,6 +1284,12 @@ class DatastoreEngine: category: Optional[str] = None, ) -> List[Dict[str, Any]]: """Radius search for places using spatial extension or Haversine fallback.""" + if self.use_backend: + url = f"{self.backend_url}/places/near?lat={lat}&lon={lon}&radius_m={radius_m}" + if category: + url += f"&category={category}" + return self._http_get(url) + cat_filter = f"AND category = '{category}'" if category else "" if self.has_spatial_extension: diff --git a/skills/datastore/scripts/garmin_sync.py b/skills/datastore/scripts/garmin_sync.py index b3d2cd4..14dd094 100755 --- a/skills/datastore/scripts/garmin_sync.py +++ b/skills/datastore/scripts/garmin_sync.py @@ -25,6 +25,8 @@ import json import logging import os import sys +import urllib.error +import urllib.request from pathlib import Path from typing import Any, Dict, List, Optional @@ -377,30 +379,81 @@ class GarminMirrorWriter: logger.info(f"Upserted {len(act_list)} activities for month {month} into {target_file.name}") -def sync_date_range(client: GarminSyncClient, writer: GarminMirrorWriter, dates: List[str]): +def sync_date_range( + client: GarminSyncClient, + writer: Optional[GarminMirrorWriter], + dates: List[str], + backend_url: Optional[str] = None, +): + target_backend = None + if backend_url is not None: + target_backend = backend_url + elif os.environ.get("DUCKDB_FORCE_LOCAL") != "1": + env_backend = os.environ.get( + "DUCKDB_BACKEND_URL", "http://duckdb-backend.family.svc.cluster.local:8000/duckdb-api/v1" + ).rstrip("/") + try: + base = env_backend.replace("/duckdb-api/v1", "") + test_url = f"{base}/healthz" + req = urllib.request.Request(test_url, method="GET") + with urllib.request.urlopen(req, timeout=1.0) as r: + if r.status == 200: + target_backend = env_backend + logger.info(f"GarminSync using single-writer backend at {target_backend}") + except Exception: + target_backend = None + for d in dates: logger.info(f"--- Syncing Garmin data for {d} ---") + summary = None + sleep = None + activities = None try: summary = client.get_daily_summary(d) - if summary: - writer.upsert_daily_summary(summary) except Exception as e: logger.warning(f"Could not fetch daily summary for {d}: {e}") try: sleep = client.get_sleep_data(d) - if sleep: - writer.upsert_sleep_data(sleep) except Exception as e: logger.warning(f"Could not fetch sleep data for {d}: {e}") try: activities = client.get_activities(d) - if activities: - writer.upsert_activities(activities) except Exception as e: logger.warning(f"Could not fetch activities for {d}: {e}") + synced_via_backend = False + if target_backend: + try: + sync_payload = { + "date": d, + "daily_summary": summary, + "sleep_data": sleep, + "activities": activities, + } + sync_url = f"{target_backend}/garmin/sync" + req = urllib.request.Request( + sync_url, + data=json.dumps(sync_payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=30) as resp: + if resp.status == 200: + logger.info(f"Successfully posted Garmin sync for {d} to backend") + synced_via_backend = True + except Exception as e: + logger.warning(f"Failed to sync via backend {target_backend} for {d}: {e}. Falling back to local writer if available.") + + if not synced_via_backend and writer: + if summary: + writer.upsert_daily_summary(summary) + if sleep: + writer.upsert_sleep_data(sleep) + if activities: + writer.upsert_activities(activities) + def generate_date_list(start_str: str, end_str: str) -> List[str]: d_start = datetime.datetime.strptime(start_str, "%Y-%m-%d").date() diff --git a/skills/datastore/scripts/render_views.py b/skills/datastore/scripts/render_views.py deleted file mode 100644 index 4dd94ca..0000000 --- a/skills/datastore/scripts/render_views.py +++ /dev/null @@ -1,598 +0,0 @@ -#!/usr/bin/env python3 -# /// script -# dependencies = [ -# "duckdb>=1.1.0", -# "python-ulid>=3.0.0", -# "pytz>=2024.1", -# ] -# /// -""" -render_views.py — Generates clean Markdown view pages and HTML dashboards from DuckDB JSONL. -Uses DatastoreEngine for exact schema validation, typed loading, and Berlin timezone attribution. -""" - -from __future__ import annotations - -import argparse -import datetime -import os -from pathlib import Path -from zoneinfo import ZoneInfo - -from datastore import DatastoreEngine - -BERLIN_TZ = ZoneInfo("Europe/Berlin") - -WEEKDAYS_DE = { - 0: "Montag", - 1: "Dienstag", - 2: "Mittwoch", - 3: "Donnerstag", - 4: "Freitag", - 5: "Samstag", - 6: "Sonntag", -} - - -def find_default_dirs(): - candidates = [ - Path.cwd() / "knowledge-wiki", - Path("/Users/moritz/src/infrapuzzle/knowledge-wiki"), - Path("/home/node/.openclaw/wiki/main"), - ] - for c in candidates: - if (c / "data").is_dir(): - return c, c / "data" - return Path.cwd(), Path.cwd() / "data" - - -def render_nutrition_views(engine: DatastoreEngine, wiki_dir: Path): - con = engine.con - months = ["2026-09", "2026-10"] - - for m in months: - out_file = wiki_dir / "entities" / "nutrition-log" / "moritz" / f"{m}.md" - out_file.parent.mkdir(parents=True, exist_ok=True) - month_name = "September" if m.endswith("09") else "Oktober" - - # Query days in month descending - days = con.execute( - f""" - SELECT DISTINCT log_date - FROM v_nutrition - WHERE strftime(log_date, '%Y-%m') = '{m}' - ORDER BY log_date DESC - """ - ).fetchall() - - md = [] - md.append("---") - md.append(f"id: nutrition-log-moritz-{m}") - md.append("pageType: source") - md.append(f'title: "Ernährungsprotokoll Moritz — {month_name} 2026"') - md.append(f"month: {m}") - md.append("tags:") - md.append(" - nutrition-log") - md.append(" - personal") - md.append("lifecycle:") - md.append(" state: active") - md.append("---") - md.append("") - md.append(f"# Ernährungsprotokoll Moritz — {month_name} 2026") - md.append("") - md.append("") - md.append("") - md.append("") - - for (d,) in days: - d_str = str(d) - d_date = datetime.date.fromisoformat(d_str) - weekday_str = WEEKDAYS_DE[d_date.weekday()] - - # Fetch daily aggregate - daily_agg = con.execute( - f""" - SELECT total_protein_g, total_carbs_g, total_sugar_g, total_fat_g, total_fiber_g, total_salt_g, total_kcal - FROM v_nutrition_daily - WHERE log_date = '{d_str}' AND subject = 'moritz' - """ - ).fetchone() - - # Check if daily balance against Garmin is available - balance_row = con.execute( - f""" - SELECT intake_kcal, expenditure_kcal, active_kcal, rmr_kcal, steps, balance_kcal, balance_status - FROM v_daily_balance - WHERE log_date = '{d_str}' AND subject = 'moritz' - """ - ).fetchone() - - md.append(f"## {d_str} {weekday_str}") - md.append("") - md.append("| Zeit | Input (original) | Menge | Eiweiß | KH | Zucker | Fett | Ballaststoffe | Salz | kcal |") - md.append("|---|---|---|---|---|---|---|---|---|---|") - - # Fetch rows for this day - rows = con.execute( - f""" - SELECT strftime(local_time, '%H:%M'), item, qty, unit, protein_g, carbs_g, sugar_g, fat_g, fiber_g, salt_g, kcal, estimated - FROM v_nutrition - WHERE log_date = '{d_str}' AND subject = 'moritz' - ORDER BY start ASC - """ - ).fetchall() - - for r in rows: - t_str = r[0] - item = r[1] - qty = f"{int(r[2]) if r[2].is_integer() else r[2]} {r[3]}" if r[2] is not None and r[3] else "" - est = "~" if r[11] else "" - p = f"{est}{r[4]:.1f} g" - c = f"{est}{r[5]:.1f} g" - s = f"{est}{r[6]:.1f} g" - f_val = f"{est}{r[7]:.1f} g" - fib = f"{est}{r[8]:.1f} g" - salt = f"{est}{r[9]:.2f} g" - kc = f"{est}{int(round(r[10]))}" - md.append(f"| {t_str} | {item} | {qty} | {p} | {c} | {s} | {f_val} | {fib} | {salt} | {kc} |") - - if daily_agg: - tot_p = f"{daily_agg[0]:.1f} g" - tot_c = f"{daily_agg[1]:.1f} g" - tot_s = f"{daily_agg[2]:.1f} g" - tot_f = f"{daily_agg[3]:.1f} g" - tot_fib = f"{daily_agg[4]:.1f} g" - tot_salt = f"{daily_agg[5]:.2f} g" - tot_kc = f"**{int(round(daily_agg[6]))}**" - md.append(f"| **Tages-Summe** | | | **{tot_p}** | **{tot_c}** | **{tot_s}** | **{tot_f}** | **{tot_fib}** | **{tot_salt}** | {tot_kc} |") - - md.append("") - - # Render Garmin balance block if available - if balance_row and balance_row[1] is not None: - intake = int(round(balance_row[0])) - expend = int(round(balance_row[1])) - diff = int(round(balance_row[5])) - steps = balance_row[4] or 0 - active = balance_row[2] or 0 - sign = "+" if diff > 0 else "" - md.append(f"- **Vortags-Bilanz (Garmin)**: {intake} kcal Verzehr - {expend} kcal Verbrauch = **{sign}{diff} kcal {balance_row[6]}** ({steps} Schritte, {active} Aktiv-kcal)") - md.append("") - - out_file.write_text("\n".join(md), encoding="utf-8") - print(f"Rendered {out_file}") - - -def render_sleep_view(engine: DatastoreEngine, wiki_dir: Path): - con = engine.con - out_file = wiki_dir / "entities" / "people" / "mila-schlafprotokoll.md" - out_file.parent.mkdir(parents=True, exist_ok=True) - - days = con.execute( - """ - SELECT DISTINCT log_date - FROM v_sleep - WHERE subject = 'mila' - ORDER BY log_date DESC - """ - ).fetchall() - - md = [] - md.append("---") - md.append("id: mila-schlafprotokoll") - md.append("pageType: log") - md.append('title: "Schlafprotokoll Mila"') - md.append("subject: mila") - md.append("tags:") - md.append(" - sleep") - md.append(" - baby") - md.append(" - mila") - md.append("lifecycle:") - md.append(" state: active") - md.append("---") - md.append("") - md.append("# Schlafprotokoll Mila") - md.append("") - md.append("") - md.append("") - md.append("") - - current_month_label = "" - - for (d,) in days: - d_str = str(d) - d_date = datetime.date.fromisoformat(d_str) - month_label = f"{'September' if d_date.month == 9 else 'Oktober'} {d_date.year}" - if month_label != current_month_label: - current_month_label = month_label - md.append(f"# {month_label}") - md.append("") - - weekday_str = WEEKDAYS_DE[d_date.weekday()] - - daily_agg = con.execute( - f""" - SELECT total_sleep_hours, nap_count, night_sleep_minutes, nap_sleep_minutes - FROM v_sleep_daily - WHERE log_date = '{d_str}' AND subject = 'mila' - """ - ).fetchone() - - day_sum_str = "" - if daily_agg: - tot_h = daily_agg[0] - naps = daily_agg[1] - day_sum_str = f" ({tot_h:.2f} h gesamt, {naps} Schläfchen)" - - md.append(f"## {weekday_str}, {d_date.strftime('%d.%m.%Y')}{day_sum_str}") - - sessions = con.execute( - f""" - SELECT strftime(local_start, '%H:%M'), - CASE WHEN is_open THEN NULL ELSE strftime(local_end, '%H:%M') END, - sleep_type, location, note, duration_minutes, is_open - FROM v_sleep - WHERE log_date = '{d_str}' AND subject = 'mila' - ORDER BY start ASC - """ - ).fetchall() - - for s in sessions: - s_time = s[0] - e_time = s[1] - s_type = s[2] - loc = s[3] - note = s[4] or "" - dur = s[5] - is_open = s[6] - - dur_str = f"{dur // 60} h {dur % 60:02d} min" if dur >= 60 else f"{dur} min" - - if is_open: - md.append(f"- {s_time} – | {note or 'Nacht'} (läuft)") - else: - label = note if note else ("Nacht" if s_type == "night" else "Schläfchen") - md.append(f"- {s_time} – {e_time} | {label} ({dur_str})") - - md.append("") - - # Preserve narrative analysis block - md.append("""--- - -# Monatsanalyse September 2026 (nachgetragen 03.10.) - -*Bewertet aus Sicht einer Kinderarzt-Praxis mit Schlafspezialisierung – Hinweise, keine Diagnose.* - -**Zahlen (19.–30.09., 11 protokollierte Tage):** -- Nacht: Ø ~11 h 18 min (10 h 10 – 11 h 55) -- Tag: Ø ~2 h 29 min in 4–5 Schläfchen -- 24-h-Summe: Ø ~13 h 47 min - -**Einordnung:** Das liegt am oberen, aber völlig normalen Rand des altersüblichen Bereichs (Säugling ~6 Mon.: 12–16 h/24 h, vgl. [Wikipedia: Schlaf](https://de.wikipedia.org/wiki/Schlaf) und [Wikipedia: Säugling](https://de.wikipedia.org/wiki/S%C3%A4ugling)). - -**Trends im Monat:** -1. **Konsolidierung:** Von 5 kurzen Mikro-Schläfchen (Sep Anfang) hin zu 3–4 Schläfchen, erste lange Blöcke (85–140 min) am 20./21./27.09. und 02.10. -2. **Spätere Bettzeit:** 18:34–19:55 (Mitte Sep) → 20:10–21:00 (Ende Sep/Okt) – stabile Morgenwache ~06:00–06:50 bleibt. -3. **Durchschlafen:** Durchgehend lange Nachtblöcke, Wachphasen nachts selten und kurz. - -**Erwartbar ab ~7–8 Monaten:** Übergang zum 3→2-Naps-Schema, Morgenschläfchen fällt zuerst weg. Zirkadiane Reifung unterstützt das (vgl. [Wikipedia: Zirkadiane Rhythmik](https://de.wikipedia.org/wiki/Zirkadiane_Rhythmik)). - ---- -""") - - out_file.write_text("\n".join(md), encoding="utf-8") - print(f"Rendered {out_file}") - - -def render_html_dashboards(engine: DatastoreEngine, html_dir: Path): - html_dir.mkdir(parents=True, exist_ok=True) - con = engine.con - - # 1. Nutrition HTML Dashboard - nutrition_days = con.execute( - """ - SELECT - d.log_date, - d.total_kcal, - d.total_protein_g, - d.total_carbs_g, - d.total_fat_g, - d.item_count, - b.expenditure_kcal, - b.balance_kcal, - b.balance_status, - b.steps - FROM v_nutrition_daily d - LEFT JOIN v_daily_balance b ON d.log_date = b.log_date AND d.subject = b.subject - WHERE d.subject = 'moritz' - ORDER BY d.log_date DESC - LIMIT 30 - """ - ).fetchall() - - latest_n = nutrition_days[0] if nutrition_days else None - latest_kcal = int(round(latest_n[1])) if latest_n else 0 - latest_protein = f"{latest_n[2]:.1f}" if latest_n else "0" - latest_garmin = f"{int(round(latest_n[6]))} kcal" if latest_n and latest_n[6] is not None else "Ausstehend" - latest_balance = f"{int(round(latest_n[7]))} kcal ({latest_n[8]})" if latest_n and latest_n[7] is not None else "—" - - # Compute 7-day average - avg_7 = con.execute( - """ - SELECT ROUND(AVG(total_kcal), 0), ROUND(AVG(total_protein_g), 1) - FROM (SELECT total_kcal, total_protein_g FROM v_nutrition_daily WHERE subject = 'moritz' ORDER BY log_date DESC LIMIT 7) - """ - ).fetchone() - avg_7_kcal = int(avg_7[0]) if avg_7 and avg_7[0] is not None else 0 - avg_7_prot = avg_7[1] if avg_7 and avg_7[1] is not None else 0 - - nutrition_rows_html = [] - for r in nutrition_days: - d_str = str(r[0]) - kc = int(round(r[1])) - prot = f"{r[2]:.1f}" - carbs = f"{r[3]:.1f}" - fat = f"{r[4]:.1f}" - exp = int(round(r[6])) if r[6] is not None else "—" - diff = int(round(r[7])) if r[7] is not None else "—" - diff_class = "text-emerald-400" if (r[7] is not None and r[7] < 0) else ("text-amber-400" if (r[7] is not None and r[7] > 0) else "text-slate-400") - steps = f"{r[9]:,}" if r[9] is not None else "—" - - nutrition_rows_html.append(f""" - - {d_str} - {kc} - {prot} g - {carbs} g - {fat} g - {exp} - {diff} - {steps} - - """) - - nutrition_html = f""" - - - - - Moritz — Ernährung & Energiebilanz - - - -
- -
-
-

- Moritz — Ernährung & Energiebilanz -

-

Strikte DuckDB-Aggregatdaten · Single Source of Truth

-
-
- - Letztes Update: {datetime.date.today().strftime('%d.%m.%Y')} -
-
- - -
-
-
Letzter Tag (Kcal)
-
{latest_kcal}
-
7-Tage-Schnitt: {avg_7_kcal} kcal
-
-
-
Eiweiß Intake
-
{latest_protein} g
-
7-Tage-Schnitt: {avg_7_prot} g
-
-
-
Garmin Verbrauch
-
{latest_garmin}
-
RMR + Aktivität
-
-
-
Tages-Bilanz
-
{latest_balance}
-
Netto-Differenz
-
-
- - -
-

Verlauf (Letzte 30 Tage)

-
- - - - - - - - - - - - - - - {''.join(nutrition_rows_html)} - -
DatumAufnahmeProteinKHFettGarminBilanzSchritte
-
-
-
- - -""" - - out_nutrition_html = html_dir / "nutrition.html" - out_nutrition_html.write_text(nutrition_html, encoding="utf-8") - print(f"Rendered HTML Dashboard: {out_nutrition_html}") - - # 2. Sleep HTML Dashboard - sleep_days = con.execute( - """ - SELECT - log_date, - total_sleep_hours, - nap_count, - night_sleep_minutes, - nap_sleep_minutes, - total_sessions - FROM v_sleep_daily - WHERE subject = 'mila' - ORDER BY log_date DESC - LIMIT 30 - """ - ).fetchall() - - latest_s = sleep_days[0] if sleep_days else None - latest_tot_h = f"{latest_s[1]:.2f} h" if latest_s else "0 h" - latest_night_str = f"{latest_s[3] // 60} h {latest_s[3] % 60:02d} min" if latest_s else "—" - latest_nap_str = f"{latest_s[2]} Naps ({latest_s[4]} min)" if latest_s else "—" - - # Compute 7-day average total sleep hours - avg_sleep = 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 7) - """ - ).fetchone() - avg_sleep_h = f"{avg_sleep[0]:.2f} h" if avg_sleep and avg_sleep[0] is not None else "—" - - sleep_rows_html = [] - for r in sleep_days: - d_str = str(r[0]) - tot = f"{r[1]:.2f} h" - night_h = f"{r[3] // 60} h {r[3] % 60:02d} min" - nap_info = f"{r[2]} ({r[4]} min)" - - sleep_rows_html.append(f""" - - {d_str} - {tot} - {night_h} - {nap_info} - - """) - - sleep_html = f""" - - - - - Mila — Schlafprotokoll - - - -
- -
-
-

- Mila — Schlafprotokoll -

-

Deterministische Schlafdauer & Wachfenster via DuckDB

-
-
- - Letztes Update: {datetime.date.today().strftime('%d.%m.%Y')} -
-
- - -
-
-
24h Schlaf
-
{latest_tot_h}
-
Letzter Tag
-
-
-
Nachtschlaf
-
{latest_night_str}
-
Hauptschlafphase
-
-
-
Tagesschläfchen
-
{latest_nap_str}
-
Tagsüber
-
-
-
7-Tage-Schnitt
-
{avg_sleep_h}
-
Altersbereich: 12-16 h
-
-
- - -
-

Schlafhistorie (Letzte 30 Tage)

-
- - - - - - - - - - - {''.join(sleep_rows_html)} - -
Datum24h GesamtschlafNachtschlafTagesschläfchen (Dauer)
-
-
-
- - -""" - - out_sleep_html = html_dir / "sleep.html" - out_sleep_html.write_text(sleep_html, encoding="utf-8") - print(f"Rendered HTML Dashboard: {out_sleep_html}") - - -def main(): - parser = argparse.ArgumentParser(description="Render Markdown and HTML views from DuckDB Datastore") - parser.add_argument("--wiki-dir", help="Path to knowledge-wiki repository") - parser.add_argument("--data-dir", help="Path to knowledge-wiki/data directory") - parser.add_argument("--html-dir", help="Path to output HTML dashboards") - parser.add_argument("--no-md", action="store_true", help="Skip Markdown rendering") - parser.add_argument("--no-html", action="store_true", help="Skip HTML dashboard rendering") - - args = parser.parse_args() - - default_wiki, default_data = find_default_dirs() - wiki_dir = Path(args.wiki_dir) if args.wiki_dir else default_wiki - data_dir = Path(args.data_dir) if args.data_dir else default_data - html_dir = Path(args.html_dir) if args.html_dir else (wiki_dir / "public" / "family") - - engine = DatastoreEngine(data_dir) - - if not args.no_md: - render_nutrition_views(engine, wiki_dir) - render_sleep_view(engine, wiki_dir) - - if not args.no_html: - render_html_dashboards(engine, html_dir) - - # Automatically trigger update_website.py for live publishing to hosting - 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.run(cmd, check=False) - except Exception as e: - print(f"Warning: could not trigger update_website.py from render_views: {e}") - - -if __name__ == "__main__": - main() diff --git a/skills/datastore/scripts/update_moritz_website.py b/skills/datastore/scripts/update_moritz_website.py deleted file mode 100644 index 852be6b..0000000 --- a/skills/datastore/scripts/update_moritz_website.py +++ /dev/null @@ -1,1463 +0,0 @@ -#!/usr/bin/env python3 -# /// script -# dependencies = [ -# "duckdb>=1.1.0", -# "boto3>=1.34.0", -# "pytz>=2024.1", -# "python-ulid>=3.0.0", -# ] -# /// -""" -update_moritz_website.py — Builds and publishes the streamlined, mobile-first, -light-mode PWA dashboard for Moritz to https://hosting.haumdaucher.de/moritz.html. - -Key Requirements: -1. Light Mode only (clean Apple Health style, crisp white & slate-50). -2. Installable PWA with Web App Manifest, theme color, mobile tags. -3. Hero Section: Netto-Kalorienbilanz (Intake vs. Garmin-Verbrauch = Defizit/Überschuss). -4. Strict separation: "Heute" shows current intake so far; Netto-Bilanz starts with "Gestern" (last full 24h). -5. Aggregations: Gestern, Letzte 7 Tage, Letzte 30 Tage. -6. Click-to-Deep-Dive: Modale Detail-Overlays/Drawers für Mahlzeiten, 7d-Balken, 30d-Tabelle & Garmin-Health. -7. Instant S3 deployment to MinIO (permanent URL: /moritz.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") - -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 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 publish_to_s3(html_content: str, key: str = "family/moritz.html", data_dir: Optional[Path] = None) -> str: - """Uploads the rendered HTML and webmanifest to MinIO hosting bucket.""" - s3 = get_s3_client() - 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", - ) - # Also upload moritz.webmanifest if present - assets_dir = SCRIPT_DIR.parent / "assets" - manifest_file = assets_dir / "moritz.webmanifest" - if not manifest_file.is_file() and data_dir: - manifest_file = data_dir.parent / "public" / "family" / "moritz.webmanifest" - if manifest_file.is_file(): - try: - s3.put_object( - Bucket=DEFAULT_BUCKET, - Key="family/moritz.webmanifest", - Body=manifest_file.read_bytes(), - ContentType="application/manifest+json; charset=utf-8", - CacheControl="no-cache, no-store, must-revalidate", - ) - except Exception as ex: - print(f"Warning: could not upload family/moritz.webmanifest: {ex}", file=sys.stderr) - return f"{DEFAULT_BASE_URL}/{key}" - - -def generate_dashboard_html(engine: DatastoreEngine) -> str: - con = engine.con - now_berlin = datetime.datetime.now(BERLIN_TZ) - today_date = now_berlin.date() - yesterday_date = today_date - datetime.timedelta(days=1) - today_str = today_date.strftime("%Y-%m-%d") - yesterday_str = yesterday_date.strftime("%Y-%m-%d") - - today_pretty = f"{WEEKDAYS_LONG_DE[today_date.weekday()]}, {today_date.strftime('%d.%m.%Y')}" - yesterday_pretty = f"{WEEKDAYS_LONG_DE[yesterday_date.weekday()]}, {yesterday_date.strftime('%d.%m.%Y')}" - - # Auto-sync Garmin if yesterday's summary is missing in DuckDB - try: - g_check = con.execute("SELECT total_expenditure_kcal FROM v_garmin_daily WHERE log_date = ?", [yesterday_str]).fetchone() - if not g_check or g_check[0] is None: - from garmin_sync import GarminSyncClient, GarminMirrorWriter, get_token_dir, sync_date_range - token_dir = get_token_dir() - if (token_dir / "garmin_tokens.json").is_file(): - g_client = GarminSyncClient(token_dir) - g_writer = GarminMirrorWriter(engine.data_dir) - sync_date_range(g_client, g_writer, [yesterday_str]) - engine._load_garmin_daily_file(engine.data_dir / "garmin" / "daily" / f"{yesterday_str[:7]}.jsonl") - except Exception: - pass - - # ------------------------------------------------------------------------- - # 1. HEUTE: Intake & Mahlzeiten (Moritz) - # ------------------------------------------------------------------------- - today_summary = con.execute( - """ - SELECT total_protein_g, total_carbs_g, total_sugar_g, total_fat_g, total_fiber_g, total_salt_g, total_kcal, item_count - FROM v_nutrition_daily - WHERE subject = 'moritz' AND log_date = ? - """, - [today_str] - ).fetchone() - - today_kcal = round(today_summary[6]) if today_summary and today_summary[6] is not None else 0 - today_protein = round(today_summary[0], 1) if today_summary and today_summary[0] is not None else 0.0 - today_carbs = round(today_summary[1], 1) if today_summary and today_summary[1] is not None else 0.0 - today_fat = round(today_summary[3], 1) if today_summary and today_summary[3] is not None else 0.0 - today_count = int(today_summary[7]) if today_summary and today_summary[7] is not None else 0 - - today_meals = con.execute( - """ - SELECT - strftime(local_time, '%H:%M') AS time_str, - item, - qty, - unit, - protein_g, - carbs_g, - fat_g, - fiber_g, - kcal, - estimated - FROM v_nutrition - WHERE subject = 'moritz' AND log_date = ? - ORDER BY start ASC - """, - [today_str] - ).fetchall() - - # ------------------------------------------------------------------------- - # 2. GESTERN: Vollständige 24h-Bilanz (Intake vs. Garmin) - # ------------------------------------------------------------------------- - yesterday_balance = con.execute( - """ - SELECT - intake_kcal, - expenditure_kcal, - active_kcal, - rmr_kcal, - steps, - balance_kcal, - balance_status, - protein_g, - carbs_g, - fat_g - FROM v_daily_balance - WHERE subject = 'moritz' AND log_date = ? - """, - [yesterday_str] - ).fetchone() - - # Fallback to direct garmin table if balance row incomplete - if not yesterday_balance or yesterday_balance[1] is None: - g_row = con.execute( - """ - SELECT total_expenditure_kcal, active_kcal, rmr_kcal, steps, resting_hr - FROM v_garmin_daily - WHERE log_date = ? - """, - [yesterday_str] - ).fetchone() - n_row = con.execute( - """ - SELECT total_kcal, total_protein_g, total_carbs_g, total_fat_g - FROM v_nutrition_daily - WHERE subject = 'moritz' AND log_date = ? - """, - [yesterday_str] - ).fetchone() - - intake_y = round(n_row[0]) if n_row and n_row[0] is not None else 0 - exp_y = round(g_row[0]) if g_row and g_row[0] is not None else 0 - act_y = round(g_row[1]) if g_row and g_row[1] is not None else 0 - rmr_y = round(g_row[2]) if g_row and g_row[2] is not None else 0 - steps_y = int(g_row[3]) if g_row and g_row[3] is not None else 0 - if exp_y > 0: - bal_y = intake_y - exp_y - status_y = "Defizit" if bal_y < 0 else ("Überschuss" if bal_y > 0 else "Ausgeglichen") - else: - bal_y = None - status_y = "Garmin ausstehend" - prot_y = round(n_row[1], 1) if n_row and n_row[1] is not None else 0.0 - carbs_y = round(n_row[2], 1) if n_row and n_row[2] is not None else 0.0 - fat_y = round(n_row[3], 1) if n_row and n_row[3] is not None else 0.0 - rhr_y = int(g_row[4]) if g_row and g_row[4] is not None else None - else: - intake_y = round(yesterday_balance[0]) if yesterday_balance[0] is not None else 0 - exp_y = round(yesterday_balance[1]) if yesterday_balance[1] is not None else 0 - act_y = round(yesterday_balance[2]) if yesterday_balance[2] is not None else 0 - rmr_y = round(yesterday_balance[3]) if yesterday_balance[3] is not None else 0 - steps_y = int(yesterday_balance[4]) if yesterday_balance[4] is not None else 0 - bal_y = round(yesterday_balance[5]) if yesterday_balance[5] is not None else (intake_y - exp_y if exp_y > 0 else None) - status_y = yesterday_balance[6] or ("Garmin ausstehend" if not exp_y else "Ausgeglichen") - prot_y = round(yesterday_balance[7], 1) if yesterday_balance[7] is not None else 0.0 - carbs_y = round(yesterday_balance[8], 1) if yesterday_balance[8] is not None else 0.0 - fat_y = round(yesterday_balance[9], 1) if yesterday_balance[9] is not None else 0.0 - - rhr_check = con.execute( - "SELECT resting_hr FROM v_garmin_daily WHERE log_date = ?", [yesterday_str] - ).fetchone() - rhr_y = int(rhr_check[0]) if rhr_check and rhr_check[0] is not None else None - - yesterday_meals = con.execute( - """ - SELECT - strftime(local_time, '%H:%M') AS time_str, - item, - qty, - unit, - protein_g, - carbs_g, - fat_g, - fiber_g, - kcal - FROM v_nutrition - WHERE subject = 'moritz' AND log_date = ? - ORDER BY start ASC - """, - [yesterday_str] - ).fetchall() - - # Query v_sleep_daily for Moritz (Garmin Sleep & Recovery) - sleep_y_row = con.execute( - """ - SELECT - total_sleep_hours, - total_sleep_minutes, - sleep_score, - sleep_score_qualifier, - avg_overnight_hrv, - hrv_status, - body_battery_change, - resting_heart_rate, - deep_sleep_minutes, - light_sleep_minutes, - rem_sleep_minutes, - awake_sleep_minutes - FROM v_sleep_daily - WHERE subject = 'moritz' AND log_date = ? - """, - [yesterday_str] - ).fetchone() - - # Fallback to latest available sleep entry if yesterday has not synced yet - if not sleep_y_row or sleep_y_row[1] is None or sleep_y_row[1] == 0: - latest_sleep = con.execute( - """ - SELECT - total_sleep_hours, - total_sleep_minutes, - sleep_score, - sleep_score_qualifier, - avg_overnight_hrv, - hrv_status, - body_battery_change, - resting_heart_rate, - deep_sleep_minutes, - light_sleep_minutes, - rem_sleep_minutes, - awake_sleep_minutes, - log_date - FROM v_sleep_daily - WHERE subject = 'moritz' AND total_sleep_minutes > 0 - ORDER BY log_date DESC - LIMIT 1 - """ - ).fetchone() - if latest_sleep: - sleep_y_row = latest_sleep[:12] - sleep_log_date_str = str(latest_sleep[12]) - else: - sleep_log_date_str = yesterday_str - else: - sleep_log_date_str = yesterday_str - - if sleep_y_row and sleep_y_row[1]: - sleep_dur_tot_m = int(sleep_y_row[1]) - s_dh, s_dm = divmod(sleep_dur_tot_m, 60) - sleep_dur_str = f"{s_dh}h {s_dm:02d}m" - sleep_score = sleep_y_row[2] - sleep_qualifier = sleep_y_row[3] or "" - hrv_val = round(sleep_y_row[4]) if sleep_y_row[4] is not None else None - hrv_status = sleep_y_row[5] or "" - bb_change = sleep_y_row[6] - rhr_val = sleep_y_row[7] or rhr_y - deep_m = int(sleep_y_row[8] or 0) - light_m = int(sleep_y_row[9] or 0) - rem_m = int(sleep_y_row[10] or 0) - awake_m = int(sleep_y_row[11] or 0) - tot_stages = (deep_m + light_m + rem_m + awake_m) or 1 - deep_pct = round((deep_m / tot_stages) * 100, 1) - light_pct = round((light_m / tot_stages) * 100, 1) - rem_pct = round((rem_m / tot_stages) * 100, 1) - awake_pct = round((awake_m / tot_stages) * 100, 1) - else: - sleep_dur_str = "—" - sleep_score = None - sleep_qualifier = "" - hrv_val = None - hrv_status = "" - bb_change = None - rhr_val = rhr_y - deep_m = 0 - light_m = 0 - rem_m = 0 - awake_m = 0 - deep_pct = 0 - light_pct = 0 - rem_pct = 0 - awake_pct = 0 - - if sleep_log_date_str == yesterday_str: - sleep_date_label = yesterday_pretty - else: - try: - d_obj = datetime.date.fromisoformat(sleep_log_date_str) - sleep_date_label = f"{WEEKDAYS_LONG_DE[d_obj.weekday()]}, {d_obj.strftime('%d.%m.%Y')}" - except Exception: - sleep_date_label = sleep_log_date_str - - # ------------------------------------------------------------------------- - # 3. LETZTE 7 TAGE (Vollendete Tage bis gestern) - # ------------------------------------------------------------------------- - seven_days = con.execute( - """ - SELECT - b.log_date, - b.intake_kcal, - b.expenditure_kcal, - b.active_kcal, - b.steps, - b.balance_kcal, - b.protein_g, - b.carbs_g, - b.fat_g, - g.resting_hr - FROM v_daily_balance b - LEFT JOIN v_garmin_daily g ON b.log_date = g.log_date - WHERE b.subject = 'moritz' AND b.log_date < ? - ORDER BY b.log_date DESC - LIMIT 7 - """, - [today_str] - ).fetchall() - - # Calculate 7d averages - if seven_days: - valid_intake_7 = [r[1] for r in seven_days if r[1] is not None] - valid_exp_7 = [r[2] for r in seven_days if r[2] is not None and r[2] > 0] - valid_bal_7 = [r[5] for r in seven_days if r[5] is not None and r[2] is not None and r[2] > 0] - valid_steps_7 = [r[4] for r in seven_days if r[4] is not None] - valid_prot_7 = [r[6] for r in seven_days if r[6] is not None] - - avg_intake_7 = round(sum(valid_intake_7) / len(valid_intake_7)) if valid_intake_7 else 0 - avg_exp_7 = round(sum(valid_exp_7) / len(valid_exp_7)) if valid_exp_7 else 0 - avg_bal_7 = round(sum(valid_bal_7) / len(valid_bal_7)) if valid_bal_7 else (avg_intake_7 - avg_exp_7) - avg_steps_7 = round(sum(valid_steps_7) / len(valid_steps_7)) if valid_steps_7 else 0 - avg_prot_7 = round(sum(valid_prot_7) / len(valid_prot_7), 1) if valid_prot_7 else 0.0 - else: - avg_intake_7 = 0 - avg_exp_7 = 0 - avg_bal_7 = 0 - avg_steps_7 = 0 - avg_prot_7 = 0.0 - - # ------------------------------------------------------------------------- - # 4. LETZTE 30 TAGE - # ------------------------------------------------------------------------- - thirty_days = con.execute( - """ - SELECT - b.log_date, - b.intake_kcal, - b.expenditure_kcal, - b.active_kcal, - b.rmr_kcal, - b.steps, - b.balance_kcal, - b.balance_status, - b.protein_g, - b.carbs_g, - b.fat_g, - g.resting_hr - FROM v_daily_balance b - LEFT JOIN v_garmin_daily g ON b.log_date = g.log_date - WHERE b.subject = 'moritz' AND b.log_date <= ? - ORDER BY b.log_date DESC - LIMIT 30 - """, - [today_str] - ).fetchall() - - valid_exp_30 = [r[2] for r in thirty_days if r[2] is not None and r[2] > 0] - valid_intake_30 = [r[1] for r in thirty_days if r[1] is not None] - valid_bal_30 = [r[6] for r in thirty_days if r[6] is not None and r[2] is not None and r[2] > 0] - valid_steps_30 = [r[5] for r in thirty_days if r[5] is not None] - - avg_intake_30 = round(sum(valid_intake_30) / len(valid_intake_30)) if valid_intake_30 else 0 - avg_exp_30 = round(sum(valid_exp_30) / len(valid_exp_30)) if valid_exp_30 else 0 - avg_bal_30 = round(sum(valid_bal_30) / len(valid_bal_30)) if valid_bal_30 else 0 - total_bal_30 = round(sum(valid_bal_30)) if valid_bal_30 else 0 - avg_steps_30 = round(sum(valid_steps_30) / len(valid_steps_30)) if valid_steps_30 else 0 - - # ------------------------------------------------------------------------- - # HTML Render Helpers - # ------------------------------------------------------------------------- - def format_balance(val: int | None) -> str: - if val is None: - return "ausstehend" - if val < 0: - return f"-{abs(val):,} kcal".replace(",", ".") - elif val > 0: - return f"+{val:,} kcal".replace(",", ".") - return "0 kcal" - - def balance_badge(val: int | None, status: str) -> str: - if val is None or status == "Garmin ausstehend": - return 'Garmin ausstehend' - if val == 0: - return 'Ausgeglichen' - if val < 0: - return f' {abs(val):,} kcal Defizit'.replace(",", ".") - return f' +{val:,} kcal Überschuss'.replace(",", ".") - - # Render Today Meals HTML rows - today_meals_html = [] - if today_meals: - for m in today_meals: - est_pill = 'geschätzt' if m[9] else '' - today_meals_html.append(f""" - - {m[0]} Uhr - {m[1]} {est_pill} - {m[2]} {m[3]} - {m[4]:.1f} g - {m[5]:.1f} g - {m[6]:.1f} g - {round(m[8])} kcal - - """) - else: - today_meals_html.append(""" - - - Heute wurden noch keine Mahlzeiten im Topic #Kochen geloggt. - - - """) - - # Render Yesterday Meals HTML rows - yesterday_meals_html = [] - if yesterday_meals: - for m in yesterday_meals: - yesterday_meals_html.append(f""" - - {m[0]} Uhr - {m[1]} - {m[2]} {m[3]} - {m[4]:.1f} g - {m[5]:.1f} g - {m[6]:.1f} g - {round(m[8])} kcal - - """) - else: - yesterday_meals_html.append(""" - - - Keine Mahlzeiten für gestern hinterlegt. - - - """) - - # Render 7-Days Chart / Table rows - seven_days_html = [] - for r in seven_days: - dt = datetime.date.fromisoformat(str(r[0])) - d_label = f"{WEEKDAYS_DE[dt.weekday()]}, {dt.strftime('%d.%m.')}" - ik = round(r[1]) if r[1] is not None else 0 - ek = round(r[2]) if r[2] is not None and r[2] > 0 else 0 - bk = (ik - ek) if ek > 0 else None - st = f"{r[4]:,}".replace(",", ".") if r[4] is not None else "—" - prot = f"{r[6]:.1f} g" if r[6] is not None else "—" - - b_badge = balance_badge(bk, "Defizit" if (bk and bk < 0) else "Überschuss") if bk is not None else 'Ausstehend' - - # Progress bar visualization (intake vs expenditure ratio) - max_bar = max(ik, ek, 3000) - ik_pct = min(100, int((ik / max_bar) * 100)) - ek_pct = min(100, int((ek / max_bar) * 100)) - - seven_days_html.append(f""" -
-
- {d_label} - {b_badge} -
-
-
-
- Intake: - {ik:,} kcal -
-
-
-
-
-
-
- Garmin: - {ek:,} kcal -
-
-
-
-
-
-
- Eiweiß: {prot} - Schritte: {st} -
-
- """.replace(",", ".")) - - # Render 30-Days Table rows - thirty_days_rows = [] - for r in thirty_days: - dt = datetime.date.fromisoformat(str(r[0])) - is_today = (dt == today_date) - is_yesterday = (dt == yesterday_date) - d_badge = 'Heute' if is_today else ('Gestern' if is_yesterday else '') - - ik = round(r[1]) if r[1] is not None else 0 - ek = round(r[2]) if r[2] is not None and r[2] > 0 else 0 - bk = (ik - ek) if ek > 0 else None - st = f"{r[5]:,}".replace(",", ".") if r[5] is not None else "—" - prot = f"{r[8]:.1f} g" if r[8] is not None else "—" - carbs = f"{r[9]:.1f} g" if r[9] is not None else "—" - fat = f"{r[10]:.1f} g" if r[10] is not None else "—" - - if bk is not None: - bal_str = f"-{abs(bk):,} kcal" if bk < 0 else f"+{bk:,} kcal" - bal_class = "text-emerald-600 font-bold" if bk < 0 else "text-amber-600 font-bold" - else: - bal_str = "—" - bal_class = "text-slate-400" - - thirty_days_rows.append(f""" - - - {WEEKDAYS_DE[dt.weekday()]}, {dt.strftime('%d.%m.')} {d_badge} - - {ik:,} - {ek if ek > 0 else '—'} - {bal_str} - {prot} - {carbs} - {fat} - {st} - - """.replace(",", ".")) - - # PWA Web App Manifest JSON (Inline Data URI) - manifest_data = { - "name": "Moritz Health", - "short_name": "Health", - "description": "Persönliches Dashboard für Kalorienbilanz, Ernährung und Garmin-Fitness von Moritz.", - "start_url": "/family/moritz.html", - "scope": "/family/", - "display": "standalone", - "background_color": "#f8fafc", - "theme_color": "#ffffff", - "icons": [ - { - "src": "data:image/svg+xml;utf8,", - "sizes": "192x192 512x512", - "type": "image/svg+xml" - } - ] - } - manifest_json = json.dumps(manifest_data) - import base64 - manifest_data_uri = f"data:application/manifest+json;base64,{base64.b64encode(manifest_json.encode('utf-8')).decode('utf-8')}" - - # Hero Balance Styling - if bal_y is None or exp_y == 0: - hero_bal_class = "text-slate-700 bg-slate-100 border-slate-200" - hero_bal_sign = f"{intake_y:,} kcal geloggt" - status_y = "Garmin ausstehend" - elif bal_y < 0: - hero_bal_class = "text-emerald-700 bg-emerald-50 border-emerald-200" - hero_bal_sign = f"-{abs(bal_y):,} kcal Defizit" - status_y = "Defizit" - elif bal_y > 0: - hero_bal_class = "text-amber-700 bg-amber-50 border-amber-200" - hero_bal_sign = f"+{bal_y:,} kcal Überschuss" - status_y = "Überschuss" - else: - hero_bal_class = "text-slate-700 bg-slate-50 border-slate-200" - hero_bal_sign = "0 kcal Ausgeglichen" - status_y = "Ausgeglichen" - - html = f""" - - - - - - - Moritz Health — Energiebilanz & Sport - - - - - - - - - - - - - - - - - - -
-
-
-
- -
-
-

Moritz Health

-

Energiebilanz & Garmin · Grabengasse 7

-
-
-
- - -
-
-
- -
- - -
- - -
-
-
- - Gestern (24h Netto-Bilanz) -
- - Deep Dive - -
- -
-
-
- {hero_bal_sign} -
- - {status_y} - -
- - -
-
- - {intake_y:,} Intake -
- − -
- - {f'{exp_y:,}' if exp_y else 'ausstehend'} Garmin -
- = -
- {format_balance(bal_y)} -
-
- - -
-
- Schritte - {steps_y:,} -
-
- Aktiv-Kcal - {act_y:,} -
-
- Ruhepuls - {rhr_y if rhr_y else '—'} bpm -
-
-
-
- - -
-
-
-
- - Heute Intake -
- - {today_count} Logs - -
- -
-
- {today_kcal:,} kcal -
-
- - {today_protein}g Protein - - - {today_carbs}g KH - - - {today_fat}g Fett - -
-
-
- -
- Strikte Trennung: 24h-Bilanz folgt nachts - Details anzeigen → -
-
- -
- - -
-
-
- - Schlaf & Erholung (Garmin) -
- - {sleep_date_label} · Deep Dive - -
- -
- -
- Sleep Score -
- {sleep_score if sleep_score is not None else '—'} - {f'({sleep_qualifier})' if sleep_qualifier else ''} -
-
- - -
- Schlafdauer -
{sleep_dur_str}
-
Tief {deep_m}m · Leicht {light_m}m · REM {rem_m}m
-
- - -
- Overnight HRV -
- {f'{hrv_val} ms' if hrv_val is not None else '—'} - {hrv_status} -
-
- - -
- Body Battery & Puls -
{f'+{bb_change}' if bb_change is not None else '—'}
-
Ruhepuls: {rhr_val if rhr_val is not None else '—'} bpm
-
-
- - -
-
- Phasen: Tief ({deep_m}m) · Leicht ({light_m}m) · REM ({rem_m}m) · Wach ({awake_m}m) - Details anzeigen → -
-
-
-
-
-
-
-
-
- - -
-
-

Historische Aggregationen

- Tippen für Deep Dive -
- -
- - -
-
- Gestern - Deep Dive → -
-
- {format_balance(bal_y)} -
-
-
- Intake: - {intake_y:,} kcal -
-
- Garmin: - {f'{exp_y:,} kcal' if exp_y else 'ausstehend'} -
-
- Schritte: - {steps_y:,} -
-
-
- - -
-
- Letzte 7 Tage - Verlauf → -
-
- {format_balance(avg_bal_7)} / Tag -
-
-
- Ø Intake: - {avg_intake_7:,} kcal -
-
- Ø Garmin: - {avg_exp_7:,} kcal -
-
- Ø Schritte: - {avg_steps_7:,} -
-
-
- - -
-
- Letzte 30 Tage - Tabelle → -
-
- {format_balance(total_bal_30)} Gesamt -
-
-
- Ø Intake: - {avg_intake_30:,} kcal -
-
- Ø Garmin: - {avg_exp_30:,} kcal -
-
- Ø Schritte: - {avg_steps_30:,} -
-
-
- -
-
- - -
-
-
- -

Heute geloggte Mahlzeiten

-
- -
- -
- - - - - - - - - - - - - - {''.join(today_meals_html[:6])} - -
ZeitLebensmittelMengeProteinKHFettEnergie
-
-
- - -
-
-
- -

Verlauf (Letzte Tage)

-
- -
- -
- - - - - - - - - - - - - - - {''.join(thirty_days_rows[:7])} - -
DatumIntakeGarminNettoProteinKHFettSchritte
-
-
- -
- - - - - - - - - - - - - - - - - - - - - - - -""" - return html - - -def main(): - parser = argparse.ArgumentParser(description="Render and publish Moritz Health 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 Moritz Health Dashboard from DuckDB...") - html = generate_dashboard_html(engine) - - # 1. Save locally strictly to knowledge-wiki/public/family/moritz.html (and nutrition.html) - wiki_public_family = data_dir.parent / "public" / "family" - wiki_public_family.mkdir(parents=True, exist_ok=True) - out_file = Path(args.output_file) if args.output_file else (wiki_public_family / "moritz.html") - out_file.write_text(html, encoding="utf-8") - print(f"✅ Saved locally: {out_file}") - - # Also keep public/family/nutrition.html in sync - family_nutrition = wiki_public_family / "nutrition.html" - family_nutrition.write_text(html, encoding="utf-8") - print(f"✅ Synced locally: {family_nutrition}") - - # 2. Upload strictly to protected S3 hosting under /family/ - if not args.skip_upload: - try: - url = publish_to_s3(html, key="family/moritz.html", data_dir=data_dir) - print(f"🚀 Published successfully to: {url}") - # Also publish to /family/nutrition.html for backwards compatibility - publish_to_s3(html, key="family/nutrition.html", data_dir=data_dir) - except Exception as e: - print(f"⚠️ S3 upload failed (local testing?): {e}", file=sys.stderr) - - -if __name__ == "__main__": - main() diff --git a/skills/datastore/scripts/update_website.py b/skills/datastore/scripts/update_website.py deleted file mode 100644 index 4d46deb..0000000 --- a/skills/datastore/scripts/update_website.py +++ /dev/null @@ -1,1564 +0,0 @@ -#!/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 -from who_growth import WHO_GIRLS_WEIGHT, WHO_GIRLS_HEIGHT, calculate_percentile - -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 = "" - active_sleep_iso = "" - wake_window_str = "" - last_wake_time_str = "" - last_wake_iso = "" - - 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") - active_sleep_iso = s_dt.isoformat() - 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") - last_wake_iso = e_dt.isoformat() - - # 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}' - OR (sleep_type = 'night' AND CAST(local_end AS 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() - - # Prefer the completed night sleep that ended this morning/today for the top card - morning_night = next((s for s in today_nights if not s[7] and s[6] is not None), None) - if morning_night and morning_night[6]: - today_night_min = morning_night[6] - else: - today_night_min = today_agg[2] if today_agg else 0 - - today_nap_count = today_agg[1] if today_agg else len(today_naps) - today_nap_min = sum(n[6] or 0 for n in today_naps) - - today_tot_min = today_night_min + today_nap_min - today_tot_hours = f"{today_tot_min / 60.0:.2f} h" if today_tot_min > 0 else "0 h" - - 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" - - # 3. Today's Feedings & Nutrition (Bottles + Brei/Beikost) - today_feedings = con.execute( - f""" - SELECT - strftime(local_time, '%H:%M') AS time_str, - consumed_ml, - milk_type, - item, - solid_g, - kcal, - kind, - 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, - solid_count, - total_solid_g, - total_kcal - 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 - today_solid_count = today_feeding_agg[2] if today_feeding_agg else 0 - today_solid_g = int(today_feeding_agg[3]) if today_feeding_agg else 0 - today_feeding_kcal = int(today_feeding_agg[4]) 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(f.total_solid_g, 0) AS feed_solid_g, - COALESCE(f.solid_count, 0) AS feed_solid_count, - 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_parts = [] - if r[6] > 0: # feed_bottles - feed_parts.append(f"{int(r[5])} ml ({r[6]}x)") - if r[8] > 0: # feed_solid_count - feed_parts.append(f"{int(r[7])} g Brei ({r[8]}x)") - feed_str = " + ".join(feed_parts) if feed_parts else "—" - - diaper_str = f"{r[9]} ({r[10]} Stuhl)" if r[9] > 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" - dur_display = f'{dur_str}' if n[7] else f'{dur_str}' - note_str = f"· {n[5]}" if n[5] else "" - today_nights_html.append(f""" -
-
-
- 🌙 -
-
-
{n_st} – {n_et} Uhr
-
{note_str}
-
-
-
{dur_display}
-
- """) - 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: - feeding_pills = [] - for f in today_feedings: - time_str = f[0] - ml_val = f[1] - milk_type = f[2] - item_name = f[3] - solid_g = f[4] - kcal_val = f[5] - kind = f[6] - if kind == "bottle" or (ml_val is not None and ml_val > 0): - ml_str = f"{int(ml_val)} ml" - milk_desc = f" · {milk_type}" if milk_type else "" - feeding_pills.append( - f'' - f'🍼 {time_str} Uhr: {ml_str}{milk_desc}' - ) - else: - food_title = item_name or "Brei" - details = [] - if solid_g: - details.append(f"{int(solid_g)} g") - if kcal_val: - details.append(f"~{int(kcal_val)} kcal") - detail_str = f" ({', '.join(details)})" if details else "" - feeding_pills.append( - f'' - f'🥣 {time_str} Uhr: {food_title}{detail_str}' - ) - - feed_summary_parts = [] - if today_bottle_count > 0: - feed_summary_parts.append( - f'{today_bottle_ml} ml ({today_bottle_count}x Flasche)' - ) - if today_solid_count > 0: - feed_summary_parts.append( - f'{today_solid_g} g ({today_solid_count}x Brei)' - ) - feed_summary_html = " · ".join(feed_summary_parts) if feed_summary_parts else '—' - - feeding_section_html = f""" -
-
-
- 🍼🥣 -

Ernährung heute

-
-
{feed_summary_html}
-
-
- {' '.join(feeding_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)} -
-
- """ - - # Growth Measurements & WHO Percentiles - growth_records = con.execute( - """ - SELECT - id, - log_date, - local_time, - weight_kg, - height_cm, - head_circ_cm, - note - FROM v_growth - WHERE subject = 'mila' - ORDER BY start ASC - """ - ).fetchall() - - growth_measurements = [] - mila_weight_points = [] - mila_height_points = [] - growth_table_rows = [] - - for row in growth_records: - r_id, r_date, r_time, r_weight, r_height, r_hc, r_note = row - d_obj = datetime.date.fromisoformat(str(r_date)) - age_days = (d_obj - MILA_BIRTHDATE).days - age_months = max(0.0, age_days / 30.4375) - - pw = calculate_percentile("weight", r_weight, age_months) if r_weight is not None else None - ph = calculate_percentile("height", r_height, age_months) if r_height is not None else None - - meas_dict = { - "id": r_id, - "date_str": str(r_date), - "date_pretty": d_obj.strftime("%d.%m.%Y"), - "age_str": calculate_age_str(d_obj), - "age_months": round(age_months, 1), - "weight_kg": r_weight, - "weight_pct": pw["percentile_str"] if pw else "—", - "height_cm": r_height, - "height_pct": ph["percentile_str"] if ph else "—", - "note": r_note or "", - } - growth_measurements.append(meas_dict) - - if r_weight is not None: - mila_weight_points.append({ - "x": round(age_months, 2), - "y": round(r_weight, 2), - "date": d_obj.strftime("%d.%m.%Y"), - "pct": pw["percentile_str"] if pw else "", - }) - if r_height is not None: - mila_height_points.append({ - "x": round(age_months, 2), - "y": round(r_height, 1), - "date": d_obj.strftime("%d.%m.%Y"), - "pct": ph["percentile_str"] if ph else "", - }) - - for m in reversed(growth_measurements): - w_val = f"{m['weight_kg']:.2f} kg" if m['weight_kg'] is not None else "—" - w_badge = f'{m["weight_pct"]}' if m['weight_kg'] is not None else "" - h_val = f"{m['height_cm']:.1f} cm" if m['height_cm'] is not None else "—" - h_badge = f'{m["height_pct"]}' if m['height_cm'] is not None else "" - note_display = f'{m["note"]}' if m["note"] else '—' - - growth_table_rows.append(f""" - - {m['date_pretty']} - {m['age_str']} - {w_val} {w_badge} - {h_val} {h_badge} - {note_display} - - """) - - growth_table_html = "".join(growth_table_rows) if growth_table_rows else """ - - - Bisher noch keine manuellen Messwerte eingetragen. Messungen können per Telegram oder CLI erfasst werden. - - - """ - - if growth_measurements: - latest_meas = growth_measurements[-1] - w_part = f"{latest_meas['weight_kg']:.2f} kg ({latest_meas['weight_pct']})" if latest_meas["weight_kg"] is not None else "" - h_part = f"{latest_meas['height_cm']:.1f} cm ({latest_meas['height_pct']})" if latest_meas["height_cm"] is not None else "" - both_part = " · ".join(filter(None, [w_part, h_part])) - growth_summary_pill = f"Letzte Messung: {both_part} ({latest_meas['date_pretty']})" - else: - growth_summary_pill = "Noch keine Messungen erfasst" - - who_w_p3 = [{"x": r["month"], "y": r["p3"]} for r in WHO_GIRLS_WEIGHT] - who_w_p50 = [{"x": r["month"], "y": r["p50"]} for r in WHO_GIRLS_WEIGHT] - who_w_p97 = [{"x": r["month"], "y": r["p97"]} for r in WHO_GIRLS_WEIGHT] - - who_h_p3 = [{"x": r["month"], "y": r["p3"]} for r in WHO_GIRLS_HEIGHT] - who_h_p50 = [{"x": r["month"], "y": r["p50"]} for r in WHO_GIRLS_HEIGHT] - who_h_p97 = [{"x": r["month"], "y": r["p97"]} for r in WHO_GIRLS_HEIGHT] - - chart_payload_json = json.dumps({ - "who_weight": {"p3": who_w_p3, "p50": who_w_p50, "p97": who_w_p97}, - "who_height": {"p3": who_h_p3, "p50": who_h_p50, "p97": who_h_p97}, - "mila_weight": mila_weight_points, - "mila_height": mila_height_points, - }) - - growth_section_html = f""" - -
- - - - -
- -
- - {growth_summary_pill} - -
- - -
- -
- - -
- - -
- - Mila - - - WHO Median (P50) - - - P3–P97 Normkorridor (94%) - -
-
- - -
- -
- - -
-
-

- 📋 - Erfasste Messungen -

- {len(growth_measurements)} Messung(en) -
- -
- - - - - - - - - - - - {growth_table_html} - -
DatumAlterGewichtKörpergrößeNotiz / Anlass
-
-
- -
-
- """ - - # PWA Manifest - manifest_dict = { - "id": "/family/mila.html", - "name": "Mila — Schlaf & Entwicklung", - "short_name": "Mila", - "description": "Schlaf-, Trink- und Windeltagebuch für Mila Graf", - "start_url": "/family/mila.html", - "scope": "/family/", - "display": "standalone", - "orientation": "portrait-primary", - "background_color": "#090A0F", - "theme_color": "#090A0F", - "icons": [ - { - "src": "/icons/mila-icon-192-v3.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/icons/mila-icon-192-v3.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/icons/mila-icon-512-v3.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/icons/mila-icon-512-v3.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "/icons/mila-apple-touch-icon-v3.png", - "sizes": "180x180", - "type": "image/png" - }, - { - "src": "/icons/mila-icon.svg", - "sizes": "any", - "type": "image/svg+xml" - } - ] - } - - # Full HTML Document - html = f""" - - - - - - - Mila — Schlaf & Entwicklung - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
- Mila -
-
-
-

- 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äfchenErnährung (Milch & Brei)Windeln
-
-
- - {growth_section_html} - -
- - - - - - - -""" - return html - - -def publish_to_s3(html_content: str, data_dir: Optional[Path] = None) -> str: - """Uploads the rendered HTML, webmanifest, service worker and icons to MinIO hosting bucket.""" - s3 = get_s3_client() - assets_dir = SCRIPT_DIR.parent / "assets" - - # HTML files (strictly under protected family/ path) - 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", - ) - - # Static PWA Assets to upload (strictly under family/) - static_assets = [ - ("mila.webmanifest", "application/manifest+json; charset=utf-8", "no-cache, no-store, must-revalidate"), - ("sw.js", "application/javascript; charset=utf-8", "no-cache, no-store, must-revalidate"), - ("mila-apple-touch-icon-v3.png", "image/png", "no-cache, must-revalidate, max-age=0"), - ("mila-icon-192-v3.png", "image/png", "no-cache, must-revalidate, max-age=0"), - ("mila-icon-512-v3.png", "image/png", "no-cache, must-revalidate, max-age=0"), - ("mila-icon-32-v3.png", "image/png", "no-cache, must-revalidate, max-age=0"), - ("mila-icon-16-v3.png", "image/png", "no-cache, must-revalidate, max-age=0"), - ("mila-apple-touch-icon.png", "image/png", "no-cache, must-revalidate, max-age=0"), - ("mila-icon-192.png", "image/png", "no-cache, must-revalidate, max-age=0"), - ("mila-icon-512.png", "image/png", "no-cache, must-revalidate, max-age=0"), - ("mila-icon-32.png", "image/png", "no-cache, must-revalidate, max-age=0"), - ("mila-icon-16.png", "image/png", "no-cache, must-revalidate, max-age=0"), - ("favicon.ico", "image/x-icon", "no-cache, must-revalidate, max-age=0"), - ("mila-icon.svg", "image/svg+xml", "no-cache, must-revalidate, max-age=0"), - ] - - for fname, ctype, cache_ctrl in static_assets: - candidates = [assets_dir / fname] - if data_dir: - candidates.append(data_dir.parent / "public" / "family" / fname) - candidates.append(data_dir.parent / "public" / "icons" / fname) - body_bytes = None - for candidate in candidates: - if candidate.is_file(): - body_bytes = candidate.read_bytes() - break - if body_bytes: - # 1. Upload to family/ (for backward compatibility / scoped access) - try: - s3.put_object( - Bucket=DEFAULT_BUCKET, - Key=f"family/{fname}", - Body=body_bytes, - ContentType=ctype, - CacheControl=cache_ctrl, - ) - except Exception as ex: - print(f"Warning: could not upload family/{fname}: {ex}", file=sys.stderr) - - # 2. Upload to public icons/ prefix (unauthenticated access for iOS SpringBoard and PWA manifests) - if fname.startswith("mila-") or fname in ("favicon.ico", "mila.webmanifest"): - try: - s3.put_object( - Bucket=DEFAULT_BUCKET, - Key=f"icons/{fname}", - Body=body_bytes, - ContentType=ctype, - CacheControl=cache_ctrl, - ) - except Exception as ex: - print(f"Warning: could not upload icons/{fname}: {ex}", file=sys.stderr) - - url = f"{DEFAULT_BASE_URL}/family/mila.html" - 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 strictly to knowledge-wiki/public/family/mila.html - wiki_public_family = data_dir.parent / "public" / "family" - wiki_public_family.mkdir(parents=True, exist_ok=True) - out_file = Path(args.output_file) if args.output_file else (wiki_public_family / "mila.html") - out_file.write_text(html, encoding="utf-8") - print(f"✅ Saved locally: {out_file}") - - # Ensure public/icons directory exists and is populated locally too - wiki_public_icons = data_dir.parent / "public" / "icons" - wiki_public_icons.mkdir(parents=True, exist_ok=True) - assets_dir = SCRIPT_DIR.parent / "assets" - for asset in assets_dir.glob("mila-*"): - dest = wiki_public_icons / asset.name - if not dest.exists() or dest.stat().st_size != asset.stat().st_size: - dest.write_bytes(asset.read_bytes()) - for extra in ["favicon.ico", "mila.webmanifest"]: - src = assets_dir / extra - if src.is_file(): - dest = wiki_public_icons / extra - if not dest.exists() or dest.stat().st_size != src.stat().st_size: - dest.write_bytes(src.read_bytes()) - - # 2. Upload to S3 hosting - if not args.skip_upload: - try: - url = publish_to_s3(html, data_dir) - 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 9c87f08..037116a 100644 --- a/skills/datastore/tests/test_datastore.py +++ b/skills/datastore/tests/test_datastore.py @@ -782,33 +782,6 @@ def test_growth_logging(temp_datastore: DatastoreEngine): assert summary["growth_latest"]["height_cm"] == 67.5 -def test_website_growth_html_rendering(temp_datastore: DatastoreEngine): - """Verifies that update_website generates valid HTML containing the growth section and Chart.js code.""" - from update_website import generate_dashboard_html - - temp_datastore.put_entity( - collection="people", - record={"id": "mila", "name": "Mila Graf", "birthdate": "2026-03-14", "role": "child"}, - ) - temp_datastore.add_event( - kind="growth.measurement", - subject="mila", - actor="agent:hebamme", - start="2026-10-12T10:00:00+02:00", - payload={"weight_kg": 7.45, "height_cm": 68.0}, - note="U5 Untersuchung", - ) - - html = generate_dashboard_html(temp_datastore) - assert "Körpergröße & Gewicht (WHO-Perzentilen)" in html - assert "7.45 kg" in html - assert "68.0 cm" in html - assert "U5 Untersuchung" in html - assert "chart.umd.min.js" in html - assert "growthChart" in html - assert "who_weight" in html - assert "who_height" in html - def test_feeding_solid_brei_logging(temp_datastore: DatastoreEngine): """Verifies feeding solid (Brei/Beikost) events, feedback formatting, and DuckDB views."""