diff --git a/skills/datastore/scripts/datastore.py b/skills/datastore/scripts/datastore.py
index 119c5e5..8e18f33 100755
--- a/skills/datastore/scripts/datastore.py
+++ b/skills/datastore/scripts/datastore.py
@@ -281,11 +281,16 @@ class DatastoreEngine:
logger.warning(f"Error loading garmin record from {file_path}: {e}")
def _trigger_website_update(self, subject: str):
- """Triggers update_website.py automatically for Mila changes."""
- if subject != "mila":
+ """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 / "update_website.py"
+ 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"
@@ -299,7 +304,7 @@ class DatastoreEngine:
stderr=subprocess.DEVNULL,
)
except Exception as e:
- logger.warning(f"Could not trigger update_website.py: {e}")
+ logger.warning(f"Could not trigger {script_name}: {e}")
def _load_jsonl_file(self, file_path: Path, table_name: str):
"""Reads lines from JSONL and inserts into target table."""
diff --git a/skills/datastore/scripts/update_moritz_website.py b/skills/datastore/scripts/update_moritz_website.py
new file mode 100644
index 0000000..42f8db9
--- /dev/null
+++ b/skills/datastore/scripts/update_moritz_website.py
@@ -0,0 +1,1117 @@
+#!/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 = "moritz.html") -> str:
+ """Uploads the rendered HTML 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",
+ )
+ 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')}"
+
+ # -------------------------------------------------------------------------
+ # 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, total_items
+ 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
+ bal_y = (intake_y - exp_y) if exp_y > 0 else 0
+ status_y = "Defizit" if bal_y < 0 else ("Überschuss" if bal_y > 0 else "Ausgeglichen")
+ 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 0
+ status_y = yesterday_balance[6] or "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()
+
+ # -------------------------------------------------------------------------
+ # 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) -> str:
+ 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 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"""
+
+ """)
+
+ # 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"""
+