feat(datastore): migrate to single-writer DuckDB backend HTTP proxy and remove legacy HTML renderers

This commit is contained in:
Moritz Graf
2026-10-16 23:33:02 +02:00
parent 8bdd06e598
commit 6b3fbfdbb5
6 changed files with 184 additions and 3688 deletions
+120 -25
View File
@@ -29,6 +29,8 @@ from typing import Any, Dict, List, Optional, Tuple, Union
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import duckdb import duckdb
import urllib.request
import urllib.error
SCRIPT_DIR = Path(__file__).resolve().parent SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path: if str(SCRIPT_DIR) not in sys.path:
@@ -110,13 +112,75 @@ def find_data_dir() -> Path:
class DatastoreEngine: 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.data_dir = data_dir or find_data_dir()
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.con = duckdb.connect(":memory:")
self.has_spatial_extension = False self.has_spatial_extension = False
self._init_schemas() self._init_schemas()
self._load_all_data() 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): def _init_schemas(self):
"""Initializes tables, views, and spatial support in DuckDB.""" """Initializes tables, views, and spatial support in DuckDB."""
# Timezone support via icu extension # Timezone support via icu extension
@@ -429,30 +493,8 @@ class DatastoreEngine:
logger.warning(f"Error loading garmin record from {file_path}: {e}") logger.warning(f"Error loading garmin record from {file_path}: {e}")
def _trigger_website_update(self, subject: str): def _trigger_website_update(self, subject: str):
"""Triggers website updates automatically (Mila -> update_website.py, Moritz -> update_moritz_website.py).""" """No-op: legacy HTML generator retired in favor of Vue 3 apps."""
if subject == "mila": pass
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}")
def _git_sync(self, commit_msg: str): def _git_sync(self, commit_msg: str):
"""Safely commits and pushes data changes if data_dir is inside a git repository.""" """Safely commits and pushes data changes if data_dir is inside a git repository."""
@@ -653,6 +695,25 @@ class DatastoreEngine:
allow_duplicate: bool = False, allow_duplicate: bool = False,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Adds a time-series event into the appropriate database (sleep, nutrition, or mila).""" """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() event_id = generate_ulid()
now_dt = datetime.datetime.now(datetime.timezone.utc) now_dt = datetime.datetime.now(datetime.timezone.utc)
now_iso = now_dt.isoformat() now_iso = now_dt.isoformat()
@@ -905,6 +966,18 @@ class DatastoreEngine:
actor: Optional[str] = None, actor: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Finds the open event (end IS NULL) for kind and subject, and closes it.""" """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: 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.") 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]: def delete_event(self, event_id: str) -> Dict[str, Any]:
"""Soft-deletes an event across sleep, nutrition, or mila.""" """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() now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
row = self.con.execute("SELECT kind, subject FROM events WHERE id = ?", [event_id]).fetchone() row = self.con.execute("SELECT kind, subject FROM events WHERE id = ?", [event_id]).fetchone()
if not row: if not row:
@@ -1052,6 +1128,9 @@ class DatastoreEngine:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def put_entity(self, collection: str, record: Dict[str, Any]) -> Dict[str, Any]: def put_entity(self, collection: str, record: Dict[str, Any]) -> Dict[str, Any]:
"""Upserts an entity into entities/<collection>.jsonl.""" """Upserts an entity into entities/<collection>.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"]: if collection not in ["foods", "recipes", "places", "people"]:
raise ValueError(f"Unknown entity collection: {collection}") raise ValueError(f"Unknown entity collection: {collection}")
@@ -1114,6 +1193,10 @@ class DatastoreEngine:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def query(self, sql: str) -> List[Dict[str, Any]]: def query(self, sql: str) -> List[Dict[str, Any]]:
"""Executes read-only SQL query and returns list of dictionaries.""" """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() clean_sql = sql.strip()
# Basic safeguard against destructive statements in read query # Basic safeguard against destructive statements in read query
disallowed = ["DROP ", "ALTER ", "TRUNCATE ", "DELETE FROM", "UPDATE "] 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]: def get_summary(self, subject: str, date_str: Optional[str] = None) -> Dict[str, Any]:
"""Returns daily summary for subject (nutrition, sleep, deficit).""" """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") target_date = date_str or datetime.datetime.now(BERLIN_TZ).strftime("%Y-%m-%d")
res: Dict[str, Any] = {"subject": subject, "date": target_date} res: Dict[str, Any] = {"subject": subject, "date": target_date}
@@ -1195,6 +1284,12 @@ class DatastoreEngine:
category: Optional[str] = None, category: Optional[str] = None,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Radius search for places using spatial extension or Haversine fallback.""" """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 "" cat_filter = f"AND category = '{category}'" if category else ""
if self.has_spatial_extension: if self.has_spatial_extension:
+60 -7
View File
@@ -25,6 +25,8 @@ import json
import logging import logging
import os import os
import sys import sys
import urllib.error
import urllib.request
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional 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}") 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: for d in dates:
logger.info(f"--- Syncing Garmin data for {d} ---") logger.info(f"--- Syncing Garmin data for {d} ---")
summary = None
sleep = None
activities = None
try: try:
summary = client.get_daily_summary(d) summary = client.get_daily_summary(d)
if summary:
writer.upsert_daily_summary(summary)
except Exception as e: except Exception as e:
logger.warning(f"Could not fetch daily summary for {d}: {e}") logger.warning(f"Could not fetch daily summary for {d}: {e}")
try: try:
sleep = client.get_sleep_data(d) sleep = client.get_sleep_data(d)
if sleep:
writer.upsert_sleep_data(sleep)
except Exception as e: except Exception as e:
logger.warning(f"Could not fetch sleep data for {d}: {e}") logger.warning(f"Could not fetch sleep data for {d}: {e}")
try: try:
activities = client.get_activities(d) activities = client.get_activities(d)
if activities:
writer.upsert_activities(activities)
except Exception as e: except Exception as e:
logger.warning(f"Could not fetch activities for {d}: {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]: def generate_date_list(start_str: str, end_str: str) -> List[str]:
d_start = datetime.datetime.strptime(start_str, "%Y-%m-%d").date() d_start = datetime.datetime.strptime(start_str, "%Y-%m-%d").date()
-598
View File
@@ -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("<!-- DO NOT EDIT: GENERATED BY DATASTORE -->")
md.append("<!-- Source of Truth: data/events/nutrition/{YYYY-MM}.jsonl -->")
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("<!-- DO NOT EDIT: GENERATED BY DATASTORE -->")
md.append("<!-- Source of Truth: data/events/sleep/{YYYY-MM}.jsonl -->")
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"""
<tr class="border-b border-slate-700/50 hover:bg-slate-800/40 transition">
<td class="py-3 px-4 font-mono font-medium text-slate-200">{d_str}</td>
<td class="py-3 px-4 font-bold text-sky-400">{kc}</td>
<td class="py-3 px-4 font-semibold text-emerald-400">{prot} g</td>
<td class="py-3 px-4 text-slate-300">{carbs} g</td>
<td class="py-3 px-4 text-slate-300">{fat} g</td>
<td class="py-3 px-4 text-slate-400">{exp}</td>
<td class="py-3 px-4 font-bold {diff_class}">{diff}</td>
<td class="py-3 px-4 text-slate-400">{steps}</td>
</tr>
""")
nutrition_html = f"""<!DOCTYPE html>
<html lang="de" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Moritz — Ernährung & Energiebilanz</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen p-4 sm:p-8 font-sans antialiased selection:bg-emerald-500 selection:text-black">
<div class="max-w-6xl mx-auto space-y-6">
<!-- Header -->
<header class="flex flex-col sm:flex-row sm:items-center justify-between pb-6 border-b border-slate-800 gap-4">
<div>
<h1 class="text-2xl sm:text-3xl font-bold tracking-tight bg-gradient-to-r from-sky-400 via-emerald-400 to-teal-300 bg-clip-text text-transparent">
Moritz — Ernährung & Energiebilanz
</h1>
<p class="text-sm text-slate-400 mt-1">Strikte DuckDB-Aggregatdaten · Single Source of Truth</p>
</div>
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-900 border border-slate-800 text-xs text-slate-400">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
<span>Letztes Update: {datetime.date.today().strftime('%d.%m.%Y')}</span>
</div>
</header>
<!-- Key Metrics Grid -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-lg">
<div class="text-xs font-semibold uppercase tracking-wider text-slate-400">Letzter Tag (Kcal)</div>
<div class="text-3xl font-extrabold text-sky-400 mt-2">{latest_kcal}</div>
<div class="text-xs text-slate-400 mt-1">7-Tage-Schnitt: {avg_7_kcal} kcal</div>
</div>
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-lg">
<div class="text-xs font-semibold uppercase tracking-wider text-slate-400">Eiweiß Intake</div>
<div class="text-3xl font-extrabold text-emerald-400 mt-2">{latest_protein} g</div>
<div class="text-xs text-slate-400 mt-1">7-Tage-Schnitt: {avg_7_prot} g</div>
</div>
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-lg">
<div class="text-xs font-semibold uppercase tracking-wider text-slate-400">Garmin Verbrauch</div>
<div class="text-3xl font-extrabold text-amber-400 mt-2">{latest_garmin}</div>
<div class="text-xs text-slate-400 mt-1">RMR + Aktivität</div>
</div>
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-lg">
<div class="text-xs font-semibold uppercase tracking-wider text-slate-400">Tages-Bilanz</div>
<div class="text-3xl font-extrabold text-indigo-400 mt-2">{latest_balance}</div>
<div class="text-xs text-slate-400 mt-1">Netto-Differenz</div>
</div>
</div>
<!-- Data Table Card -->
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-xl overflow-hidden">
<h2 class="text-lg font-semibold text-slate-200 mb-4">Verlauf (Letzte 30 Tage)</h2>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm whitespace-nowrap">
<thead class="text-xs uppercase bg-slate-800/60 text-slate-400">
<tr>
<th class="py-3 px-4 rounded-l-lg">Datum</th>
<th class="py-3 px-4">Aufnahme</th>
<th class="py-3 px-4">Protein</th>
<th class="py-3 px-4">KH</th>
<th class="py-3 px-4">Fett</th>
<th class="py-3 px-4">Garmin</th>
<th class="py-3 px-4">Bilanz</th>
<th class="py-3 px-4 rounded-r-lg">Schritte</th>
</tr>
</thead>
<tbody>
{''.join(nutrition_rows_html)}
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>
"""
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"""
<tr class="border-b border-slate-700/50 hover:bg-slate-800/40 transition">
<td class="py-3 px-4 font-mono font-medium text-slate-200">{d_str}</td>
<td class="py-3 px-4 font-bold text-purple-400">{tot}</td>
<td class="py-3 px-4 text-sky-400 font-semibold">{night_h}</td>
<td class="py-3 px-4 text-emerald-400">{nap_info}</td>
</tr>
""")
sleep_html = f"""<!DOCTYPE html>
<html lang="de" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mila — Schlafprotokoll</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen p-4 sm:p-8 font-sans antialiased selection:bg-purple-500 selection:text-white">
<div class="max-w-5xl mx-auto space-y-6">
<!-- Header -->
<header class="flex flex-col sm:flex-row sm:items-center justify-between pb-6 border-b border-slate-800 gap-4">
<div>
<h1 class="text-2xl sm:text-3xl font-bold tracking-tight bg-gradient-to-r from-purple-400 via-pink-400 to-indigo-300 bg-clip-text text-transparent">
Mila — Schlafprotokoll
</h1>
<p class="text-sm text-slate-400 mt-1">Deterministische Schlafdauer & Wachfenster via DuckDB</p>
</div>
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-900 border border-slate-800 text-xs text-slate-400">
<span class="w-2 h-2 rounded-full bg-purple-500 animate-pulse"></span>
<span>Letztes Update: {datetime.date.today().strftime('%d.%m.%Y')}</span>
</div>
</header>
<!-- Key Metrics Grid -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-lg">
<div class="text-xs font-semibold uppercase tracking-wider text-slate-400">24h Schlaf</div>
<div class="text-3xl font-extrabold text-purple-400 mt-2">{latest_tot_h}</div>
<div class="text-xs text-slate-400 mt-1">Letzter Tag</div>
</div>
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-lg">
<div class="text-xs font-semibold uppercase tracking-wider text-slate-400">Nachtschlaf</div>
<div class="text-3xl font-extrabold text-sky-400 mt-2">{latest_night_str}</div>
<div class="text-xs text-slate-400 mt-1">Hauptschlafphase</div>
</div>
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-lg">
<div class="text-xs font-semibold uppercase tracking-wider text-slate-400">Tagesschläfchen</div>
<div class="text-2xl font-extrabold text-emerald-400 mt-2">{latest_nap_str}</div>
<div class="text-xs text-slate-400 mt-1">Tagsüber</div>
</div>
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-lg">
<div class="text-xs font-semibold uppercase tracking-wider text-slate-400">7-Tage-Schnitt</div>
<div class="text-3xl font-extrabold text-pink-400 mt-2">{avg_sleep_h}</div>
<div class="text-xs text-slate-400 mt-1">Altersbereich: 12-16 h</div>
</div>
</div>
<!-- Data Table Card -->
<div class="bg-slate-900/80 border border-slate-800 rounded-2xl p-5 shadow-xl overflow-hidden">
<h2 class="text-lg font-semibold text-slate-200 mb-4">Schlafhistorie (Letzte 30 Tage)</h2>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm whitespace-nowrap">
<thead class="text-xs uppercase bg-slate-800/60 text-slate-400">
<tr>
<th class="py-3 px-4 rounded-l-lg">Datum</th>
<th class="py-3 px-4">24h Gesamtschlaf</th>
<th class="py-3 px-4">Nachtschlaf</th>
<th class="py-3 px-4 rounded-r-lg">Tagesschläfchen (Dauer)</th>
</tr>
</thead>
<tbody>
{''.join(sleep_rows_html)}
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>
"""
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()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-27
View File
@@ -782,33 +782,6 @@ def test_growth_logging(temp_datastore: DatastoreEngine):
assert summary["growth_latest"]["height_cm"] == 67.5 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): def test_feeding_solid_brei_logging(temp_datastore: DatastoreEngine):
"""Verifies feeding solid (Brei/Beikost) events, feedback formatting, and DuckDB views.""" """Verifies feeding solid (Brei/Beikost) events, feedback formatting, and DuckDB views."""