feat(datastore): migrate to single-writer DuckDB backend HTTP proxy and remove legacy HTML renderers
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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."""
|
||||||
|
|||||||
Reference in New Issue
Block a user