#!/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])}
Zeit Lebensmittel Menge Protein KH Fett Energie

Verlauf (Letzte Tage)

{''.join(thirty_days_rows[:7])}
Datum Intake Garmin Netto Protein KH Fett Schritte
""" 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()