#!/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 = "family/moritz.html", data_dir: Optional[Path] = None) -> str:
"""Uploads the rendered HTML and webmanifest 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",
)
# Also upload moritz.webmanifest if present
assets_dir = SCRIPT_DIR.parent / "assets"
manifest_file = assets_dir / "moritz.webmanifest"
if not manifest_file.is_file() and data_dir:
manifest_file = data_dir.parent / "public" / "family" / "moritz.webmanifest"
if manifest_file.is_file():
try:
s3.put_object(
Bucket=DEFAULT_BUCKET,
Key="family/moritz.webmanifest",
Body=manifest_file.read_bytes(),
ContentType="application/manifest+json; charset=utf-8",
CacheControl="no-cache, no-store, must-revalidate",
)
except Exception as ex:
print(f"Warning: could not upload family/moritz.webmanifest: {ex}", file=sys.stderr)
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')}"
# Auto-sync Garmin if yesterday's summary is missing in DuckDB
try:
g_check = con.execute("SELECT total_expenditure_kcal FROM v_garmin_daily WHERE log_date = ?", [yesterday_str]).fetchone()
if not g_check or g_check[0] is None:
from garmin_sync import GarminSyncClient, GarminMirrorWriter, get_token_dir, sync_date_range
token_dir = get_token_dir()
if (token_dir / "garmin_tokens.json").is_file():
g_client = GarminSyncClient(token_dir)
g_writer = GarminMirrorWriter(engine.data_dir)
sync_date_range(g_client, g_writer, [yesterday_str])
engine._load_garmin_daily_file(engine.data_dir / "garmin" / "daily" / f"{yesterday_str[:7]}.jsonl")
except Exception:
pass
# -------------------------------------------------------------------------
# 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, item_count
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
if exp_y > 0:
bal_y = intake_y - exp_y
status_y = "Defizit" if bal_y < 0 else ("Überschuss" if bal_y > 0 else "Ausgeglichen")
else:
bal_y = None
status_y = "Garmin ausstehend"
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 (intake_y - exp_y if exp_y > 0 else None)
status_y = yesterday_balance[6] or ("Garmin ausstehend" if not exp_y else "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 | None) -> str:
if val is None:
return "ausstehend"
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 status == "Garmin ausstehend":
return 'Garmin ausstehend'
if 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"""