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""" + + {m[0]} Uhr + {m[1]} + {m[2]} {m[3]} + {m[4]:.1f} g + {m[5]:.1f} g + {m[6]:.1f} g + {round(m[8])} kcal + + """) + else: + yesterday_meals_html.append(""" + + + Keine Mahlzeiten für gestern hinterlegt. + + + """) + + # Render 7-Days Chart / Table rows + seven_days_html = [] + for r in seven_days: + dt = datetime.date.fromisoformat(str(r[0])) + d_label = f"{WEEKDAYS_DE[dt.weekday()]}, {dt.strftime('%d.%m.')}" + ik = round(r[1]) if r[1] is not None else 0 + ek = round(r[2]) if r[2] is not None and r[2] > 0 else 0 + bk = (ik - ek) if ek > 0 else None + st = f"{r[4]:,}".replace(",", ".") if r[4] is not None else "—" + prot = f"{r[6]:.1f} g" if r[6] is not None else "—" + + b_badge = balance_badge(bk, "Defizit" if (bk and bk < 0) else "Überschuss") if bk is not None else 'Ausstehend' + + # Progress bar visualization (intake vs expenditure ratio) + max_bar = max(ik, ek, 3000) + ik_pct = min(100, int((ik / max_bar) * 100)) + ek_pct = min(100, int((ek / max_bar) * 100)) + + seven_days_html.append(f""" +
+
+ {d_label} + {b_badge} +
+
+
+
+ Intake: + {ik:,} kcal +
+
+
+
+
+
+
+ Garmin: + {ek:,} kcal +
+
+
+
+
+
+
+ Eiweiß: {prot} + Schritte: {st} +
+
+ """.replace(",", ".")) + + # Render 30-Days Table rows + thirty_days_rows = [] + for r in thirty_days: + dt = datetime.date.fromisoformat(str(r[0])) + is_today = (dt == today_date) + is_yesterday = (dt == yesterday_date) + d_badge = 'Heute' if is_today else ('Gestern' if is_yesterday else '') + + ik = round(r[1]) if r[1] is not None else 0 + ek = round(r[2]) if r[2] is not None and r[2] > 0 else 0 + bk = (ik - ek) if ek > 0 else None + st = f"{r[5]:,}".replace(",", ".") if r[5] is not None else "—" + prot = f"{r[8]:.1f} g" if r[8] is not None else "—" + carbs = f"{r[9]:.1f} g" if r[9] is not None else "—" + fat = f"{r[10]:.1f} g" if r[10] is not None else "—" + + if bk is not None: + bal_str = f"-{abs(bk):,} kcal" if bk < 0 else f"+{bk:,} kcal" + bal_class = "text-emerald-600 font-bold" if bk < 0 else "text-amber-600 font-bold" + else: + bal_str = "—" + bal_class = "text-slate-400" + + thirty_days_rows.append(f""" + + + {WEEKDAYS_DE[dt.weekday()]}, {dt.strftime('%d.%m.')} {d_badge} + + {ik:,} + {ek if ek > 0 else '—'} + {bal_str} + {prot} + {carbs} + {fat} + {st} + + """.replace(",", ".")) + + # PWA Web App Manifest JSON (Inline Data URI) + manifest_data = { + "name": "Moritz Health", + "short_name": "Health", + "description": "Persönliches Dashboard für Kalorienbilanz, Ernährung und Garmin-Fitness von Moritz.", + "start_url": "/moritz.html", + "display": "standalone", + "background_color": "#f8fafc", + "theme_color": "#ffffff", + "icons": [ + { + "src": "data:image/svg+xml;utf8,", + "sizes": "192x192 512x512", + "type": "image/svg+xml" + } + ] + } + manifest_json = json.dumps(manifest_data) + import base64 + manifest_data_uri = f"data:application/manifest+json;base64,{base64.b64encode(manifest_json.encode('utf-8')).decode('utf-8')}" + + # Hero Balance Styling + hero_bal_class = "text-emerald-700 bg-emerald-50 border-emerald-200" if bal_y < 0 else "text-amber-700 bg-amber-50 border-amber-200" + hero_bal_sign = f"-{abs(bal_y):,} kcal Defizit" if bal_y < 0 else (f"+{bal_y:,} kcal Überschuss" if bal_y > 0 else "0 kcal Ausgeglichen") + + html = f""" + + + + + Moritz Health — Energiebilanz & Sport + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+

Moritz Health

+

Energiebilanz & Garmin · Grabengasse 7

+
+
+
+ +
+
+
+ +
+ + +
+ + +
+
+
+ + Gestern (24h Netto-Bilanz) +
+ + Deep Dive + +
+ +
+
+
+ {hero_bal_sign} +
+ + {status_y} + +
+ + +
+
+ + {intake_y:,} Intake +
+ − +
+ + {exp_y:,} Garmin +
+ = +
+ {format_balance(bal_y)} +
+
+ + +
+
+ Schritte + {steps_y:,} +
+
+ Aktiv-Kcal + {act_y:,} +
+
+ Ruhepuls + {rhr_y if rhr_y else '—'} bpm +
+
+
+
+ + +
+
+
+
+ + Heute Intake +
+ + {today_count} Logs + +
+ +
+
+ {today_kcal:,} kcal +
+
+ + {today_protein}g Protein + + + {today_carbs}g KH + + + {today_fat}g Fett + +
+
+
+ +
+ Strikte Trennung: 24h-Bilanz folgt nachts + Details anzeigen → +
+
+ +
+ + +
+
+

Historische Aggregationen

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

Heute geloggte Mahlzeiten

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

Verlauf (Letzte Tage)

+
+ +
+ +
+ + + + + + + + + + + + + + + {''.join(thirty_days_rows[:7])} + +
DatumIntakeGarminNettoProteinKHFettSchritte
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +""" + return html + + +def main(): + parser = argparse.ArgumentParser(description="Render and publish Moritz Health dashboard") + parser.add_argument("--data-dir", help="Path to knowledge-wiki/data directory") + parser.add_argument("--output-file", help="Path to save HTML locally") + parser.add_argument("--skip-upload", action="store_true", help="Skip S3 upload") + args = parser.parse_args() + + data_dir = Path(args.data_dir) if args.data_dir else find_data_dir() + engine = DatastoreEngine(data_dir) + + print("Building Moritz Health Dashboard from DuckDB...") + html = generate_dashboard_html(engine) + + # 1. Save locally to knowledge-wiki/public/moritz.html (and also nutrition.html for legacy compatibility) + wiki_public = data_dir.parent / "public" + wiki_public.mkdir(parents=True, exist_ok=True) + out_file = Path(args.output_file) if args.output_file else (wiki_public / "moritz.html") + out_file.write_text(html, encoding="utf-8") + print(f"✅ Saved locally: {out_file}") + + # Also keep public/family/nutrition.html in sync + family_nutrition = wiki_public / "family" / "nutrition.html" + if family_nutrition.parent.is_dir(): + family_nutrition.write_text(html, encoding="utf-8") + print(f"✅ Synced locally: {family_nutrition}") + + # 2. Upload to S3 hosting + if not args.skip_upload: + try: + url = publish_to_s3(html, key="moritz.html") + print(f"🚀 Published successfully to: {url}") + # Also publish to /family/nutrition.html for backwards compatibility + publish_to_s3(html, key="family/nutrition.html") + except Exception as e: + print(f"⚠️ S3 upload failed (local testing?): {e}", file=sys.stderr) + + +if __name__ == "__main__": + main()