Files
mop-skills/skills/datastore/scripts/update_moritz_website.py
T

1464 lines
67 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()
# Query v_sleep_daily for Moritz (Garmin Sleep & Recovery)
sleep_y_row = con.execute(
"""
SELECT
total_sleep_hours,
total_sleep_minutes,
sleep_score,
sleep_score_qualifier,
avg_overnight_hrv,
hrv_status,
body_battery_change,
resting_heart_rate,
deep_sleep_minutes,
light_sleep_minutes,
rem_sleep_minutes,
awake_sleep_minutes
FROM v_sleep_daily
WHERE subject = 'moritz' AND log_date = ?
""",
[yesterday_str]
).fetchone()
# Fallback to latest available sleep entry if yesterday has not synced yet
if not sleep_y_row or sleep_y_row[1] is None or sleep_y_row[1] == 0:
latest_sleep = con.execute(
"""
SELECT
total_sleep_hours,
total_sleep_minutes,
sleep_score,
sleep_score_qualifier,
avg_overnight_hrv,
hrv_status,
body_battery_change,
resting_heart_rate,
deep_sleep_minutes,
light_sleep_minutes,
rem_sleep_minutes,
awake_sleep_minutes,
log_date
FROM v_sleep_daily
WHERE subject = 'moritz' AND total_sleep_minutes > 0
ORDER BY log_date DESC
LIMIT 1
"""
).fetchone()
if latest_sleep:
sleep_y_row = latest_sleep[:12]
sleep_log_date_str = str(latest_sleep[12])
else:
sleep_log_date_str = yesterday_str
else:
sleep_log_date_str = yesterday_str
if sleep_y_row and sleep_y_row[1]:
sleep_dur_tot_m = int(sleep_y_row[1])
s_dh, s_dm = divmod(sleep_dur_tot_m, 60)
sleep_dur_str = f"{s_dh}h {s_dm:02d}m"
sleep_score = sleep_y_row[2]
sleep_qualifier = sleep_y_row[3] or ""
hrv_val = round(sleep_y_row[4]) if sleep_y_row[4] is not None else None
hrv_status = sleep_y_row[5] or ""
bb_change = sleep_y_row[6]
rhr_val = sleep_y_row[7] or rhr_y
deep_m = int(sleep_y_row[8] or 0)
light_m = int(sleep_y_row[9] or 0)
rem_m = int(sleep_y_row[10] or 0)
awake_m = int(sleep_y_row[11] or 0)
tot_stages = (deep_m + light_m + rem_m + awake_m) or 1
deep_pct = round((deep_m / tot_stages) * 100, 1)
light_pct = round((light_m / tot_stages) * 100, 1)
rem_pct = round((rem_m / tot_stages) * 100, 1)
awake_pct = round((awake_m / tot_stages) * 100, 1)
else:
sleep_dur_str = "—"
sleep_score = None
sleep_qualifier = ""
hrv_val = None
hrv_status = ""
bb_change = None
rhr_val = rhr_y
deep_m = 0
light_m = 0
rem_m = 0
awake_m = 0
deep_pct = 0
light_pct = 0
rem_pct = 0
awake_pct = 0
if sleep_log_date_str == yesterday_str:
sleep_date_label = yesterday_pretty
else:
try:
d_obj = datetime.date.fromisoformat(sleep_log_date_str)
sleep_date_label = f"{WEEKDAYS_LONG_DE[d_obj.weekday()]}, {d_obj.strftime('%d.%m.%Y')}"
except Exception:
sleep_date_label = sleep_log_date_str
# -------------------------------------------------------------------------
# 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 '<span class="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-700">Garmin ausstehend</span>'
if val == 0:
return '<span class="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-700">Ausgeglichen</span>'
if val < 0:
return f'<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-100 text-emerald-800 border border-emerald-200"><i data-lucide="trending-down" class="w-3 h-3"></i> {abs(val):,} kcal Defizit</span>'.replace(",", ".")
return f'<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-800 border border-amber-200"><i data-lucide="trending-up" class="w-3 h-3"></i> +{val:,} kcal Überschuss</span>'.replace(",", ".")
# Render Today Meals HTML rows
today_meals_html = []
if today_meals:
for m in today_meals:
est_pill = '<span class="text-[10px] text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">geschätzt</span>' if m[9] else ''
today_meals_html.append(f"""
<tr class="border-b border-slate-100 hover:bg-slate-50/70 transition">
<td class="py-2.5 px-3 font-mono text-xs text-slate-500 font-medium">{m[0]} Uhr</td>
<td class="py-2.5 px-3 font-medium text-slate-900">{m[1]} {est_pill}</td>
<td class="py-2.5 px-3 text-right font-mono text-xs text-slate-600">{m[2]} {m[3]}</td>
<td class="py-2.5 px-3 text-right font-mono text-xs font-semibold text-emerald-700">{m[4]:.1f} g</td>
<td class="py-2.5 px-3 text-right font-mono text-xs text-slate-600">{m[5]:.1f} g</td>
<td class="py-2.5 px-3 text-right font-mono text-xs text-slate-600">{m[6]:.1f} g</td>
<td class="py-2.5 px-3 text-right font-mono text-xs font-bold text-sky-700">{round(m[8])} kcal</td>
</tr>
""")
else:
today_meals_html.append("""
<tr>
<td colspan="7" class="py-8 text-center text-slate-400 text-sm">
Heute wurden noch keine Mahlzeiten im Topic #Kochen geloggt.
</td>
</tr>
""")
# Render Yesterday Meals HTML rows
yesterday_meals_html = []
if yesterday_meals:
for m in yesterday_meals:
yesterday_meals_html.append(f"""
<tr class="border-b border-slate-100 hover:bg-slate-50/70 transition">
<td class="py-2.5 px-3 font-mono text-xs text-slate-500 font-medium">{m[0]} Uhr</td>
<td class="py-2.5 px-3 font-medium text-slate-900">{m[1]}</td>
<td class="py-2.5 px-3 text-right font-mono text-xs text-slate-600">{m[2]} {m[3]}</td>
<td class="py-2.5 px-3 text-right font-mono text-xs font-semibold text-emerald-700">{m[4]:.1f} g</td>
<td class="py-2.5 px-3 text-right font-mono text-xs text-slate-600">{m[5]:.1f} g</td>
<td class="py-2.5 px-3 text-right font-mono text-xs text-slate-600">{m[6]:.1f} g</td>
<td class="py-2.5 px-3 text-right font-mono text-xs font-bold text-sky-700">{round(m[8])} kcal</td>
</tr>
""")
else:
yesterday_meals_html.append("""
<tr>
<td colspan="7" class="py-6 text-center text-slate-400 text-sm">
Keine Mahlzeiten für gestern hinterlegt.
</td>
</tr>
""")
# 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 '<span class="text-slate-400 text-xs">Ausstehend</span>'
# 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"""
<div class="p-3.5 rounded-xl border border-slate-200/90 bg-white hover:border-slate-300 transition space-y-2">
<div class="flex items-center justify-between">
<span class="font-bold text-sm text-slate-800">{d_label}</span>
{b_badge}
</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div class="space-y-1">
<div class="flex justify-between text-slate-500">
<span>Intake:</span>
<span class="font-mono font-bold text-sky-700">{ik:,} kcal</span>
</div>
<div class="w-full bg-slate-100 rounded-full h-1.5 overflow-hidden">
<div class="bg-sky-500 h-1.5 rounded-full" style="width: {ik_pct}%"></div>
</div>
</div>
<div class="space-y-1">
<div class="flex justify-between text-slate-500">
<span>Garmin:</span>
<span class="font-mono font-bold text-indigo-700">{ek:,} kcal</span>
</div>
<div class="w-full bg-slate-100 rounded-full h-1.5 overflow-hidden">
<div class="bg-indigo-500 h-1.5 rounded-full" style="width: {ek_pct}%"></div>
</div>
</div>
</div>
<div class="flex items-center justify-between pt-1 text-[11px] text-slate-500 border-t border-slate-100">
<span>Eiweiß: <strong class="text-emerald-700 font-semibold">{prot}</strong></span>
<span>Schritte: <strong class="font-mono text-slate-700">{st}</strong></span>
</div>
</div>
""".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 = '<span class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-sky-100 text-sky-800 font-semibold">Heute</span>' if is_today else ('<span class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-slate-200 text-slate-700 font-medium">Gestern</span>' 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"""
<tr class="border-b border-slate-100 hover:bg-slate-50/80 transition {'bg-sky-50/30' if is_today else ''}">
<td class="py-3 px-3.5 font-mono text-xs font-semibold text-slate-800 whitespace-nowrap">
{WEEKDAYS_DE[dt.weekday()]}, {dt.strftime('%d.%m.')} {d_badge}
</td>
<td class="py-3 px-3 text-right font-mono text-xs font-bold text-sky-700">{ik:,}</td>
<td class="py-3 px-3 text-right font-mono text-xs text-indigo-700">{ek if ek > 0 else '—'}</td>
<td class="py-3 px-3 text-right font-mono text-xs {bal_class}">{bal_str}</td>
<td class="py-3 px-3 text-right font-mono text-xs font-semibold text-emerald-700">{prot}</td>
<td class="py-3 px-3 text-right font-mono text-xs text-slate-600">{carbs}</td>
<td class="py-3 px-3 text-right font-mono text-xs text-slate-600">{fat}</td>
<td class="py-3 px-3 text-right font-mono text-xs text-slate-700">{st}</td>
</tr>
""".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": "/family/moritz.html",
"scope": "/family/",
"display": "standalone",
"background_color": "#f8fafc",
"theme_color": "#ffffff",
"icons": [
{
"src": "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'><rect width='512' height='512' rx='128' fill='%2310b981'/><path d='M128 256h64l32-96 64 192 32-96h64' fill='none' stroke='white' stroke-width='40' stroke-linecap='round' stroke-linejoin='round'/></svg>",
"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
if bal_y is None or exp_y == 0:
hero_bal_class = "text-slate-700 bg-slate-100 border-slate-200"
hero_bal_sign = f"{intake_y:,} kcal geloggt"
status_y = "Garmin ausstehend"
elif bal_y < 0:
hero_bal_class = "text-emerald-700 bg-emerald-50 border-emerald-200"
hero_bal_sign = f"-{abs(bal_y):,} kcal Defizit"
status_y = "Defizit"
elif bal_y > 0:
hero_bal_class = "text-amber-700 bg-amber-50 border-amber-200"
hero_bal_sign = f"+{bal_y:,} kcal Überschuss"
status_y = "Überschuss"
else:
hero_bal_class = "text-slate-700 bg-slate-50 border-slate-200"
hero_bal_sign = "0 kcal Ausgeglichen"
status_y = "Ausgeglichen"
html = f"""<!DOCTYPE html>
<html lang="de" class="h-full bg-slate-50">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="build-time" content="{now_berlin.isoformat()}">
<meta name="build-epoch" content="{int(now_berlin.timestamp())}">
<title>Moritz Health — Energiebilanz & Sport</title>
<!-- PWA & Mobile Web App Meta Tags -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Moritz Health">
<meta name="theme-color" content="#ffffff">
<link rel="manifest" href="/family/moritz.webmanifest" crossorigin="use-credentials">
<link rel="apple-touch-icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'><rect width='512' height='512' rx='128' fill='%2310b981'/><path d='M128 256h64l32-96 64 192 32-96h64' fill='none' stroke='white' stroke-width='40' stroke-linecap='round' stroke-linejoin='round'/></svg>">
<!-- Tailwind CSS & Lucide Icons -->
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
body {{
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-tap-highlight-color: transparent;
}}
.font-mono {{
font-family: 'JetBrains Mono', monospace;
}}
/* Custom Modal animations */
.modal-backdrop {{
transition: opacity 0.25s ease-out;
}}
.modal-panel {{
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.25s ease-out;
}}
.modal.hidden {{
display: none;
}}
</style>
</head>
<body class="min-h-full text-slate-800 antialiased selection:bg-emerald-500 selection:text-white pb-12">
<!-- Top App Navigation Bar -->
<header class="sticky top-0 z-30 bg-white/90 backdrop-blur-md border-b border-slate-200/80 shadow-xs">
<div class="max-w-4xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-tr from-emerald-500 to-teal-400 flex items-center justify-center text-white shadow-md shadow-emerald-500/20">
<i data-lucide="activity" class="w-5 h-5"></i>
</div>
<div>
<h1 class="text-base sm:text-lg font-extrabold tracking-tight text-slate-900 leading-none">Moritz Health</h1>
<p class="text-[11px] font-medium text-slate-500 mt-1">Energiebilanz & Garmin · Grabengasse 7</p>
</div>
</div>
<div class="flex items-center gap-2">
<button id="manual-refresh-btn" onclick="triggerManualRefresh()" title="Aktualisieren" class="inline-flex items-center justify-center p-2 rounded-lg bg-slate-100 hover:bg-slate-200 text-slate-600 hover:text-slate-900 border border-slate-200/80 transition active:scale-95 shadow-xs">
<i data-lucide="refresh-cw" class="w-3.5 h-3.5"></i>
</button>
<button onclick="openModal('modal-today-meals')" class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-sky-50 text-sky-700 hover:bg-sky-100 border border-sky-200/70 text-xs font-semibold shadow-xs transition">
<i data-lucide="utensils" class="w-3.5 h-3.5"></i>
<span>Heute ({today_count})</span>
</button>
</div>
</div>
</header>
<main class="max-w-4xl mx-auto px-4 sm:px-6 pt-6 space-y-6">
<!-- HERO SECTION: Kalorien-Netto-Bilanz (Gestern als letzter 24h-Abschluss & Heute Intake) -->
<section class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Primary Hero Card: GESTERN 24h Netto-Bilanz (Groß & Klickbar) -->
<div onclick="openModal('modal-yesterday-detail')" class="md:col-span-2 cursor-pointer group bg-white rounded-3xl p-6 border border-slate-200/90 shadow-sm hover:shadow-md hover:border-slate-300 transition relative overflow-hidden">
<div class="flex items-center justify-between pb-3 border-b border-slate-100">
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full bg-emerald-500 animate-pulse"></span>
<span class="text-xs font-bold uppercase tracking-wider text-slate-500">Gestern (24h Netto-Bilanz)</span>
</div>
<span class="text-xs font-semibold text-slate-400 group-hover:text-emerald-600 flex items-center gap-1 transition">
Deep Dive <i data-lucide="chevron-right" class="w-3.5 h-3.5"></i>
</span>
</div>
<div class="pt-4 space-y-3">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<div class="text-3xl sm:text-4xl font-extrabold font-mono tracking-tight text-slate-900">
{hero_bal_sign}
</div>
<span class="px-3 py-1 rounded-full text-xs font-bold border {hero_bal_class}">
{status_y}
</span>
</div>
<!-- Formula Breakdown -->
<div class="p-3 rounded-2xl bg-slate-50 border border-slate-100 flex items-center justify-between text-xs sm:text-sm font-mono">
<div class="flex items-center gap-1.5 text-sky-700">
<i data-lucide="utensils" class="w-4 h-4 text-sky-500"></i>
<span class="font-bold">{intake_y:,}</span> <span class="text-slate-500 font-sans text-xs">Intake</span>
</div>
<span class="text-slate-400 font-bold">−</span>
<div class="flex items-center gap-1.5 text-indigo-700">
<i data-lucide="zap" class="w-4 h-4 text-indigo-500"></i>
<span class="font-bold">{f'{exp_y:,}' if exp_y else 'ausstehend'}</span> <span class="text-slate-500 font-sans text-xs">Garmin</span>
</div>
<span class="text-slate-400 font-bold">=</span>
<div class="font-bold {hero_bal_class.split()[0]}">
{format_balance(bal_y)}
</div>
</div>
<!-- Yesterday Garmin Highlights -->
<div class="grid grid-cols-3 gap-2 pt-1 text-center">
<div class="p-2 rounded-xl bg-slate-50 border border-slate-100">
<span class="block text-[10px] text-slate-500 font-medium">Schritte</span>
<span class="font-mono font-bold text-slate-800 text-sm sm:text-base">{steps_y:,}</span>
</div>
<div class="p-2 rounded-xl bg-slate-50 border border-slate-100">
<span class="block text-[10px] text-slate-500 font-medium">Aktiv-Kcal</span>
<span class="font-mono font-bold text-slate-800 text-sm sm:text-base">{act_y:,}</span>
</div>
<div class="p-2 rounded-xl bg-slate-50 border border-slate-100">
<span class="block text-[10px] text-slate-500 font-medium">Ruhepuls</span>
<span class="font-mono font-bold text-slate-800 text-sm sm:text-base">{rhr_y if rhr_y else '—'} bpm</span>
</div>
</div>
</div>
</div>
<!-- HEUTE CARD: Aktueller Intake-Stand (Klickbar für Mahlzeiten) -->
<div onclick="openModal('modal-today-meals')" class="cursor-pointer group bg-white rounded-3xl p-6 border border-slate-200/90 shadow-sm hover:shadow-md hover:border-slate-300 transition flex flex-col justify-between">
<div>
<div class="flex items-center justify-between pb-3 border-b border-slate-100">
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full bg-sky-500"></span>
<span class="text-xs font-bold uppercase tracking-wider text-slate-500">Heute Intake</span>
</div>
<span class="text-xs font-semibold text-slate-400 group-hover:text-sky-600 flex items-center gap-1 transition">
{today_count} Logs <i data-lucide="chevron-right" class="w-3.5 h-3.5"></i>
</span>
</div>
<div class="pt-4 space-y-2">
<div class="text-3xl font-extrabold font-mono text-sky-800">
{today_kcal:,} <span class="text-sm font-sans font-medium text-slate-500">kcal</span>
</div>
<div class="flex flex-wrap gap-1.5 pt-1">
<span class="px-2 py-0.5 rounded-md bg-emerald-50 text-emerald-800 border border-emerald-200/60 text-xs font-semibold">
{today_protein}g Protein
</span>
<span class="px-2 py-0.5 rounded-md bg-slate-100 text-slate-700 text-xs">
{today_carbs}g KH
</span>
<span class="px-2 py-0.5 rounded-md bg-slate-100 text-slate-700 text-xs">
{today_fat}g Fett
</span>
</div>
</div>
</div>
<div class="pt-4 mt-2 border-t border-slate-100 text-[11px] text-slate-500 flex items-center justify-between">
<span>Strikte Trennung: 24h-Bilanz folgt nachts</span>
<span class="font-semibold text-sky-600">Details anzeigen →</span>
</div>
</div>
</section>
<!-- SCHLAF & ERHOLUNG (GARMIN) TILE -->
<section class="bg-white rounded-3xl p-5 sm:p-6 border border-slate-200/90 shadow-sm hover:shadow-md hover:border-slate-300 transition cursor-pointer group" onclick="openModal('modal-yesterday-detail')">
<div class="flex items-center justify-between pb-3 border-b border-slate-100">
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full bg-indigo-500"></span>
<span class="text-xs font-bold uppercase tracking-wider text-slate-500">Schlaf & Erholung (Garmin)</span>
</div>
<span class="text-xs font-semibold text-slate-400 group-hover:text-indigo-600 flex items-center gap-1 transition">
{sleep_date_label} · Deep Dive <i data-lucide="chevron-right" class="w-3.5 h-3.5"></i>
</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 pt-4">
<!-- Sleep Score -->
<div class="p-3 rounded-2xl bg-slate-50 border border-slate-100">
<span class="block text-[11px] text-slate-500 font-medium">Sleep Score</span>
<div class="flex items-baseline gap-1 mt-0.5">
<span class="text-2xl font-black font-mono text-indigo-700">{sleep_score if sleep_score is not None else '—'}</span>
<span class="text-xs font-semibold text-slate-500">{f'({sleep_qualifier})' if sleep_qualifier else ''}</span>
</div>
</div>
<!-- Duration -->
<div class="p-3 rounded-2xl bg-slate-50 border border-slate-100">
<span class="block text-[11px] text-slate-500 font-medium">Schlafdauer</span>
<div class="text-2xl font-black font-mono text-slate-800 mt-0.5">{sleep_dur_str}</div>
<div class="text-[10px] text-slate-400 mt-0.5">Tief {deep_m}m · Leicht {light_m}m · REM {rem_m}m</div>
</div>
<!-- Overnight HRV -->
<div class="p-3 rounded-2xl bg-slate-50 border border-slate-100">
<span class="block text-[11px] text-slate-500 font-medium">Overnight HRV</span>
<div class="flex items-baseline gap-1 mt-0.5">
<span class="text-2xl font-black font-mono text-emerald-700">{f'{hrv_val} ms' if hrv_val is not None else '—'}</span>
<span class="text-[10px] text-slate-400">{hrv_status}</span>
</div>
</div>
<!-- Body Battery & Resting HR -->
<div class="p-3 rounded-2xl bg-slate-50 border border-slate-100">
<span class="block text-[11px] text-slate-500 font-medium">Body Battery & Puls</span>
<div class="text-2xl font-black font-mono text-sky-700 mt-0.5">{f'+{bb_change}' if bb_change is not None else '—'}</div>
<div class="text-[10px] text-slate-400 mt-0.5">Ruhepuls: {rhr_val if rhr_val is not None else '—'} bpm</div>
</div>
</div>
<!-- Sleep Phase Breakdown Bar -->
<div class="mt-3 pt-3 border-t border-slate-100">
<div class="flex justify-between text-[11px] text-slate-500 mb-1">
<span>Phasen: Tief ({deep_m}m) · Leicht ({light_m}m) · REM ({rem_m}m) · Wach ({awake_m}m)</span>
<span class="font-semibold text-indigo-600">Details anzeigen →</span>
</div>
<div class="w-full bg-slate-100 rounded-full h-2.5 flex overflow-hidden">
<div class="bg-indigo-600 h-2.5" style="width: {deep_pct}%" title="Tiefschlaf"></div>
<div class="bg-sky-400 h-2.5" style="width: {light_pct}%" title="Leichtschlaf"></div>
<div class="bg-purple-400 h-2.5" style="width: {rem_pct}%" title="REM-Schlaf"></div>
<div class="bg-amber-300 h-2.5" style="width: {awake_pct}%" title="Wach"></div>
</div>
</div>
</section>
<!-- AGGREGATIONS-STRIP: Gestern, Letzte 7 Tage, Letzte 30 Tage -->
<section class="space-y-3">
<div class="flex items-center justify-between px-1">
<h2 class="text-sm font-bold uppercase tracking-wider text-slate-500">Historische Aggregationen</h2>
<span class="text-xs text-slate-400">Tippen für Deep Dive</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<!-- CARD: GESTERN -->
<div onclick="openModal('modal-yesterday-detail')" class="cursor-pointer group bg-white rounded-2xl p-4 border border-slate-200/80 shadow-xs hover:shadow-md hover:border-slate-300 transition">
<div class="flex items-center justify-between text-xs text-slate-500 font-semibold mb-2">
<span>Gestern</span>
<span class="text-slate-400 group-hover:text-slate-600">Deep Dive →</span>
</div>
<div class="text-xl font-extrabold font-mono text-slate-900 mb-1">
{format_balance(bal_y)}
</div>
<div class="text-xs text-slate-500 space-y-0.5">
<div class="flex justify-between">
<span>Intake:</span>
<span class="font-mono font-medium text-slate-700">{intake_y:,} kcal</span>
</div>
<div class="flex justify-between">
<span>Garmin:</span>
<span class="font-mono font-medium text-slate-700">{f'{exp_y:,} kcal' if exp_y else 'ausstehend'}</span>
</div>
<div class="flex justify-between pt-1 border-t border-slate-100">
<span>Schritte:</span>
<span class="font-mono font-semibold text-slate-800">{steps_y:,}</span>
</div>
</div>
</div>
<!-- CARD: LETZTE 7 TAGE -->
<div onclick="openModal('modal-7d-trend')" class="cursor-pointer group bg-white rounded-2xl p-4 border border-slate-200/80 shadow-xs hover:shadow-md hover:border-slate-300 transition">
<div class="flex items-center justify-between text-xs text-slate-500 font-semibold mb-2">
<span>Letzte 7 Tage</span>
<span class="text-slate-400 group-hover:text-slate-600">Verlauf →</span>
</div>
<div class="text-xl font-extrabold font-mono text-slate-900 mb-1">
{format_balance(avg_bal_7)} <span class="text-xs font-sans text-slate-500 font-normal">/ Tag</span>
</div>
<div class="text-xs text-slate-500 space-y-0.5">
<div class="flex justify-between">
<span>Ø Intake:</span>
<span class="font-mono font-medium text-slate-700">{avg_intake_7:,} kcal</span>
</div>
<div class="flex justify-between">
<span>Ø Garmin:</span>
<span class="font-mono font-medium text-slate-700">{avg_exp_7:,} kcal</span>
</div>
<div class="flex justify-between pt-1 border-t border-slate-100">
<span>Ø Schritte:</span>
<span class="font-mono font-semibold text-slate-800">{avg_steps_7:,}</span>
</div>
</div>
</div>
<!-- CARD: LETZTE 30 TAGE -->
<div onclick="openModal('modal-30d-history')" class="cursor-pointer group bg-white rounded-2xl p-4 border border-slate-200/80 shadow-xs hover:shadow-md hover:border-slate-300 transition">
<div class="flex items-center justify-between text-xs text-slate-500 font-semibold mb-2">
<span>Letzte 30 Tage</span>
<span class="text-slate-400 group-hover:text-slate-600">Tabelle →</span>
</div>
<div class="text-xl font-extrabold font-mono text-slate-900 mb-1">
{format_balance(total_bal_30)} <span class="text-xs font-sans text-slate-500 font-normal">Gesamt</span>
</div>
<div class="text-xs text-slate-500 space-y-0.5">
<div class="flex justify-between">
<span>Ø Intake:</span>
<span class="font-mono font-medium text-slate-700">{avg_intake_30:,} kcal</span>
</div>
<div class="flex justify-between">
<span>Ø Garmin:</span>
<span class="font-mono font-medium text-slate-700">{avg_exp_30:,} kcal</span>
</div>
<div class="flex justify-between pt-1 border-t border-slate-100">
<span>Ø Schritte:</span>
<span class="font-mono font-semibold text-slate-800">{avg_steps_30:,}</span>
</div>
</div>
</div>
</div>
</section>
<!-- HEUTIGE MAHLZEITEN VORSCHAU CARD -->
<section class="bg-white rounded-3xl p-5 sm:p-6 border border-slate-200/90 shadow-xs space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<i data-lucide="clock" class="w-4 h-4 text-slate-500"></i>
<h2 class="text-sm font-bold uppercase tracking-wider text-slate-700">Heute geloggte Mahlzeiten</h2>
</div>
<button onclick="openModal('modal-today-meals')" class="text-xs font-semibold text-sky-700 hover:text-sky-800 transition">
Alle Details ({today_count}) →
</button>
</div>
<div class="overflow-x-auto -mx-5 px-5">
<table class="w-full text-left text-xs whitespace-nowrap">
<thead class="bg-slate-50 text-slate-500 font-bold uppercase border-y border-slate-100">
<tr>
<th class="py-2.5 px-3 rounded-l-lg">Zeit</th>
<th class="py-2.5 px-3">Lebensmittel</th>
<th class="py-2.5 px-3 text-right">Menge</th>
<th class="py-2.5 px-3 text-right">Protein</th>
<th class="py-2.5 px-3 text-right">KH</th>
<th class="py-2.5 px-3 text-right">Fett</th>
<th class="py-2.5 px-3 text-right rounded-r-lg">Energie</th>
</tr>
</thead>
<tbody>
{''.join(today_meals_html[:6])}
</tbody>
</table>
</div>
</section>
<!-- 30-TAGE VERLAUFS-VORSCHAU TABELLE -->
<section class="bg-white rounded-3xl p-5 sm:p-6 border border-slate-200/90 shadow-xs space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<i data-lucide="calendar" class="w-4 h-4 text-slate-500"></i>
<h2 class="text-sm font-bold uppercase tracking-wider text-slate-700">Verlauf (Letzte Tage)</h2>
</div>
<button onclick="openModal('modal-30d-history')" class="text-xs font-semibold text-indigo-700 hover:text-indigo-800 transition">
Vollständige Tabelle (30 Tage) →
</button>
</div>
<div class="overflow-x-auto -mx-5 px-5">
<table class="w-full text-left text-xs whitespace-nowrap">
<thead class="bg-slate-50 text-slate-500 font-bold uppercase border-y border-slate-100">
<tr>
<th class="py-2.5 px-3.5 rounded-l-lg">Datum</th>
<th class="py-2.5 px-3 text-right">Intake</th>
<th class="py-2.5 px-3 text-right">Garmin</th>
<th class="py-2.5 px-3 text-right">Netto</th>
<th class="py-2.5 px-3 text-right">Protein</th>
<th class="py-2.5 px-3 text-right">KH</th>
<th class="py-2.5 px-3 text-right">Fett</th>
<th class="py-2.5 px-3 text-right rounded-r-lg">Schritte</th>
</tr>
</thead>
<tbody>
{''.join(thirty_days_rows[:7])}
</tbody>
</table>
</div>
</section>
</main>
<!-- FOOTER -->
<footer class="max-w-4xl mx-auto px-4 sm:px-6 pt-8 text-center text-xs text-slate-500 space-y-1">
<p>Aktualisiert: {now_berlin.strftime('%d.%m.%Y um %H:%M:%S')} Uhr · Single Source of Truth via DuckDB</p>
<p class="text-[11px] text-slate-400">Moritz Health PWA · Installierbar auf Home-Bildschirm (Safari & Chrome)</p>
</footer>
<!-- ========================================================================= -->
<!-- MODAL OVERLAYS (DEEP DIVE PANELS) -->
<!-- ========================================================================= -->
<!-- MODAL 1: HEUTE MAHLZEITEN DEEP DIVE -->
<div id="modal-today-meals" class="modal hidden fixed inset-0 z-50 overflow-y-auto">
<div class="min-h-screen px-4 text-center flex items-center justify-center">
<div class="fixed inset-0 bg-slate-900/40 backdrop-blur-xs modal-backdrop" onclick="closeModal('modal-today-meals')"></div>
<div class="inline-block w-full max-w-2xl bg-white rounded-3xl p-6 text-left shadow-2xl modal-panel relative z-10 my-8">
<div class="flex items-center justify-between pb-4 border-b border-slate-100">
<div>
<h3 class="text-lg font-extrabold text-slate-900">Heute: Geloggte Mahlzeiten</h3>
<p class="text-xs text-slate-500">{today_pretty} · {today_count} Einträge</p>
</div>
<button onclick="closeModal('modal-today-meals')" class="p-2 rounded-xl text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="py-4 space-y-4">
<!-- Summary Strip in Modal -->
<div class="grid grid-cols-4 gap-2 text-center p-3 rounded-2xl bg-slate-50 border border-slate-100 text-xs font-mono">
<div>
<span class="block text-[10px] text-slate-500 font-sans">Kcal</span>
<strong class="text-sky-700 text-sm font-bold">{today_kcal:,}</strong>
</div>
<div>
<span class="block text-[10px] text-slate-500 font-sans">Protein</span>
<strong class="text-emerald-700 text-sm font-bold">{today_protein}g</strong>
</div>
<div>
<span class="block text-[10px] text-slate-500 font-sans">KH</span>
<strong class="text-slate-700 text-sm font-bold">{today_carbs}g</strong>
</div>
<div>
<span class="block text-[10px] text-slate-500 font-sans">Fett</span>
<strong class="text-slate-700 text-sm font-bold">{today_fat}g</strong>
</div>
</div>
<!-- Full Table -->
<div class="overflow-x-auto max-h-96">
<table class="w-full text-left text-xs whitespace-nowrap">
<thead class="bg-slate-50 text-slate-500 font-bold uppercase sticky top-0">
<tr>
<th class="py-2 px-3">Zeit</th>
<th class="py-2 px-3">Lebensmittel</th>
<th class="py-2 px-3 text-right">Menge</th>
<th class="py-2 px-3 text-right">Protein</th>
<th class="py-2 px-3 text-right">KH</th>
<th class="py-2 px-3 text-right">Fett</th>
<th class="py-2 px-3 text-right">Kcal</th>
</tr>
</thead>
<tbody>
{''.join(today_meals_html)}
</tbody>
</table>
</div>
</div>
<div class="pt-3 border-t border-slate-100 flex justify-end">
<button onclick="closeModal('modal-today-meals')" class="px-4 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-bold transition">
Schließen
</button>
</div>
</div>
</div>
</div>
<!-- MODAL 2: GESTERN DEEP DIVE -->
<div id="modal-yesterday-detail" class="modal hidden fixed inset-0 z-50 overflow-y-auto">
<div class="min-h-screen px-4 text-center flex items-center justify-center">
<div class="fixed inset-0 bg-slate-900/40 backdrop-blur-xs modal-backdrop" onclick="closeModal('modal-yesterday-detail')"></div>
<div class="inline-block w-full max-w-2xl bg-white rounded-3xl p-6 text-left shadow-2xl modal-panel relative z-10 my-8">
<div class="flex items-center justify-between pb-4 border-b border-slate-100">
<div>
<h3 class="text-lg font-extrabold text-slate-900">Gestern: Netto-Bilanz & Garmin Deep Dive</h3>
<p class="text-xs text-slate-500">{yesterday_pretty}</p>
</div>
<button onclick="closeModal('modal-yesterday-detail')" class="p-2 rounded-xl text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="py-4 space-y-4">
<!-- Big Balance Stat Box -->
<div class="p-4 rounded-2xl border {hero_bal_class} space-y-2">
<div class="text-xs font-semibold uppercase tracking-wider">Ergebnis der 24h-Bilanz</div>
<div class="text-3xl font-extrabold font-mono">{hero_bal_sign}</div>
<p class="text-xs opacity-90">
Moritz hat gestern <strong>{intake_y:,} kcal</strong> gegessen und über Garmin einen Gesamtverbrauch von <strong>{exp_y:,} kcal</strong> erzielt.
</p>
</div>
<!-- Garmin Metrics Detailed Grid -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 text-center text-xs">
<div class="p-3 rounded-xl bg-slate-50 border border-slate-100">
<span class="block text-[10px] text-slate-500">Grundumsatz (RMR)</span>
<strong class="text-sm font-mono text-slate-800">{rmr_y:,} kcal</strong>
</div>
<div class="p-3 rounded-xl bg-slate-50 border border-slate-100">
<span class="block text-[10px] text-slate-500">Aktiv-Verbrauch</span>
<strong class="text-sm font-mono text-slate-800">{act_y:,} kcal</strong>
</div>
<div class="p-3 rounded-xl bg-slate-50 border border-slate-100">
<span class="block text-[10px] text-slate-500">Schritte</span>
<strong class="text-sm font-mono text-slate-800">{steps_y:,}</strong>
</div>
<div class="p-3 rounded-xl bg-slate-50 border border-slate-100">
<span class="block text-[10px] text-slate-500">Ruhepuls</span>
<strong class="text-sm font-mono text-slate-800">{rhr_y if rhr_y else '—'} bpm</strong>
</div>
</div>
<!-- Sleep & Regeneration Deep Dive -->
<div class="p-4 rounded-2xl bg-indigo-50/50 border border-indigo-100 space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-indigo-900">
<i data-lucide="moon" class="w-4 h-4 text-indigo-600"></i>
Schlaf & Regeneration (Garmin)
</div>
<span class="text-xs font-bold text-indigo-700 font-mono">Score: {sleep_score if sleep_score is not None else '—'} {f'({sleep_qualifier})' if sleep_qualifier else ''}</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 text-center text-xs">
<div class="p-2.5 rounded-xl bg-white border border-indigo-100/80">
<span class="block text-[10px] text-slate-500">Dauer</span>
<strong class="text-sm font-mono text-slate-800">{sleep_dur_str}</strong>
</div>
<div class="p-2.5 rounded-xl bg-white border border-indigo-100/80">
<span class="block text-[10px] text-slate-500">Overnight HRV</span>
<strong class="text-sm font-mono text-emerald-700">{f'{hrv_val} ms' if hrv_val is not None else '—'}</strong>
<span class="block text-[9px] text-slate-400">{hrv_status}</span>
</div>
<div class="p-2.5 rounded-xl bg-white border border-indigo-100/80">
<span class="block text-[10px] text-slate-500">Body Battery</span>
<strong class="text-sm font-mono text-sky-700">{f'+{bb_change}' if bb_change is not None else '—'}</strong>
</div>
<div class="p-2.5 rounded-xl bg-white border border-indigo-100/80">
<span class="block text-[10px] text-slate-500">Ruhepuls</span>
<strong class="text-sm font-mono text-slate-800">{rhr_val if rhr_val is not None else '—'} bpm</strong>
</div>
</div>
<!-- Sleep Stages Breakdown -->
<div class="pt-1">
<div class="flex justify-between text-[11px] text-slate-600 mb-1">
<span>Tief: <strong>{deep_m}m</strong> · Leicht: <strong>{light_m}m</strong> · REM: <strong>{rem_m}m</strong> · Wach: <strong>{awake_m}m</strong></span>
</div>
<div class="w-full bg-slate-200/80 rounded-full h-2 flex overflow-hidden">
<div class="bg-indigo-600 h-2" style="width: {deep_pct}%" title="Tiefschlaf"></div>
<div class="bg-sky-400 h-2" style="width: {light_pct}%" title="Leichtschlaf"></div>
<div class="bg-purple-400 h-2" style="width: {rem_pct}%" title="REM-Schlaf"></div>
<div class="bg-amber-300 h-2" style="width: {awake_pct}%" title="Wach"></div>
</div>
</div>
</div>
<!-- Yesterday Meals List -->
<div>
<h4 class="text-xs font-bold uppercase text-slate-500 mb-2">Gestern gegessen</h4>
<div class="overflow-x-auto max-h-60 border border-slate-100 rounded-xl">
<table class="w-full text-left text-xs whitespace-nowrap">
<thead class="bg-slate-50 text-slate-500 font-bold uppercase sticky top-0">
<tr>
<th class="py-2 px-3">Zeit</th>
<th class="py-2 px-3">Lebensmittel</th>
<th class="py-2 px-3 text-right">Menge</th>
<th class="py-2 px-3 text-right">Protein</th>
<th class="py-2 px-3 text-right">Kcal</th>
</tr>
</thead>
<tbody>
{''.join(yesterday_meals_html)}
</tbody>
</table>
</div>
</div>
</div>
<div class="pt-3 border-t border-slate-100 flex justify-end">
<button onclick="closeModal('modal-yesterday-detail')" class="px-4 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-bold transition">
Schließen
</button>
</div>
</div>
</div>
</div>
<!-- MODAL 3: 7-TAGE TREND DEEP DIVE -->
<div id="modal-7d-trend" class="modal hidden fixed inset-0 z-50 overflow-y-auto">
<div class="min-h-screen px-4 text-center flex items-center justify-center">
<div class="fixed inset-0 bg-slate-900/40 backdrop-blur-xs modal-backdrop" onclick="closeModal('modal-7d-trend')"></div>
<div class="inline-block w-full max-w-2xl bg-white rounded-3xl p-6 text-left shadow-2xl modal-panel relative z-10 my-8">
<div class="flex items-center justify-between pb-4 border-b border-slate-100">
<div>
<h3 class="text-lg font-extrabold text-slate-900">Letzte 7 Tage: Verlauf & Bilanzen</h3>
<p class="text-xs text-slate-500">Ø {format_balance(avg_bal_7)} pro Tag · Ø {avg_steps_7:,} Schritte</p>
</div>
<button onclick="closeModal('modal-7d-trend')" class="p-2 rounded-xl text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="py-4 space-y-3 max-h-[70vh] overflow-y-auto">
{''.join(seven_days_html)}
</div>
<div class="pt-3 border-t border-slate-100 flex justify-end">
<button onclick="closeModal('modal-7d-trend')" class="px-4 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-bold transition">
Schließen
</button>
</div>
</div>
</div>
</div>
<!-- MODAL 4: 30-TAGE GESAMT HISTORIE -->
<div id="modal-30d-history" class="modal hidden fixed inset-0 z-50 overflow-y-auto">
<div class="min-h-screen px-4 text-center flex items-center justify-center">
<div class="fixed inset-0 bg-slate-900/40 backdrop-blur-xs modal-backdrop" onclick="closeModal('modal-30d-history')"></div>
<div class="inline-block w-full max-w-4xl bg-white rounded-3xl p-6 text-left shadow-2xl modal-panel relative z-10 my-8">
<div class="flex items-center justify-between pb-4 border-b border-slate-100">
<div>
<h3 class="text-lg font-extrabold text-slate-900">30-Tage Historie & Gesamtbilanz</h3>
<p class="text-xs text-slate-500">Gesamt-Netto: <strong>{format_balance(total_bal_30)}</strong> · Ø {avg_steps_30:,} Schritte/Tag</p>
</div>
<button onclick="closeModal('modal-30d-history')" class="p-2 rounded-xl text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<div class="py-4 overflow-x-auto max-h-[70vh]">
<table class="w-full text-left text-xs whitespace-nowrap">
<thead class="bg-slate-50 text-slate-500 font-bold uppercase sticky top-0 border-b border-slate-200">
<tr>
<th class="py-2.5 px-3">Datum</th>
<th class="py-2.5 px-3 text-right">Intake</th>
<th class="py-2.5 px-3 text-right">Garmin</th>
<th class="py-2.5 px-3 text-right">Netto</th>
<th class="py-2.5 px-3 text-right">Protein</th>
<th class="py-2.5 px-3 text-right">KH</th>
<th class="py-2.5 px-3 text-right">Fett</th>
<th class="py-2.5 px-3 text-right">Schritte</th>
</tr>
</thead>
<tbody>
{''.join(thirty_days_rows)}
</tbody>
</table>
</div>
<div class="pt-3 border-t border-slate-100 flex justify-end">
<button onclick="closeModal('modal-30d-history')" class="px-4 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-bold transition">
Schließen
</button>
</div>
</div>
</div>
</div>
<script>
// Modal Helpers
function openModal(id) {{
const m = document.getElementById(id);
if (m) {{
m.classList.remove('hidden');
document.body.classList.add('overflow-hidden');
}}
}}
function closeModal(id) {{
const m = document.getElementById(id);
if (m) {{
m.classList.add('hidden');
document.body.classList.remove('overflow-hidden');
}}
}}
// Close on Escape key
document.addEventListener('keydown', (e) => {{
if (e.key === 'Escape') {{
document.querySelectorAll('.modal:not(.hidden)').forEach(m => {{
m.classList.add('hidden');
}});
document.body.classList.remove('overflow-hidden');
}}
}});
// Initialize Lucide Icons
document.addEventListener('DOMContentLoaded', () => {{
lucide.createIcons();
}});
// Service Worker Registration for PWA caching & offline support
if ('serviceWorker' in navigator) {{
window.addEventListener('load', () => {{
navigator.serviceWorker.register('/family/sw.js', {{ scope: '/family/' }})
.then(reg => console.log('[PWA] Service Worker registered:', reg.scope))
.catch(err => console.warn('[PWA] Service Worker registration failed:', err));
}});
}}
// ==============================================================================
// Self-Updating Engine
// ==============================================================================
(function() {{
const currentBuildEpoch = parseInt(document.querySelector('meta[name="build-epoch"]')?.content || '0', 10);
let lastCheckMs = Date.now();
let isChecking = false;
// 1. Server Update Checker
async function checkForUpdates() {{
if (isChecking) return;
isChecking = true;
try {{
const res = await fetch(window.location.pathname + '?_poll=' + Date.now(), {{
cache: 'no-store',
headers: {{ 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache' }}
}});
if (res.ok) {{
const html = await res.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const remoteEpoch = parseInt(doc.querySelector('meta[name="build-epoch"]')?.content || '0', 10);
if (remoteEpoch && currentBuildEpoch && remoteEpoch > currentBuildEpoch) {{
console.log('[AutoUpdate] Server has newer build (' + remoteEpoch + ' > ' + currentBuildEpoch + '). Reloading...');
window.location.reload();
return;
}}
}}
}} catch (err) {{
console.warn('[AutoUpdate] Check failed:', err);
}} finally {{
isChecking = false;
lastCheckMs = Date.now();
}}
}}
// 2. Auto-update on reopen / foreground resume (PWA & Mobile Safari)
document.addEventListener('visibilitychange', () => {{
if (document.visibilityState === 'visible') {{
if (Date.now() - lastCheckMs > 10000) {{
checkForUpdates();
}}
}}
}});
window.addEventListener('pageshow', (e) => {{
if (e.persisted || Date.now() - lastCheckMs > 10000) {{
checkForUpdates();
}}
}});
window.addEventListener('focus', () => {{
if (Date.now() - lastCheckMs > 15000) {{
checkForUpdates();
}}
}});
// 3. Background check every 30 seconds when visible
setInterval(() => {{
if (document.visibilityState === 'visible') {{
checkForUpdates();
}}
}}, 30000);
// 4. Manual refresh trigger
window.triggerManualRefresh = function() {{
const btn = document.getElementById('manual-refresh-btn');
if (btn) {{
const icon = btn.querySelector('svg') || btn.querySelector('i');
if (icon) icon.classList.add('animate-spin');
}}
window.location.reload();
}};
}})();
</script>
</body>
</html>
"""
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 strictly to knowledge-wiki/public/family/moritz.html (and nutrition.html)
wiki_public_family = data_dir.parent / "public" / "family"
wiki_public_family.mkdir(parents=True, exist_ok=True)
out_file = Path(args.output_file) if args.output_file else (wiki_public_family / "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"
family_nutrition.write_text(html, encoding="utf-8")
print(f"✅ Synced locally: {family_nutrition}")
# 2. Upload strictly to protected S3 hosting under /family/
if not args.skip_upload:
try:
url = publish_to_s3(html, key="family/moritz.html", data_dir=data_dir)
print(f"🚀 Published successfully to: {url}")
# Also publish to /family/nutrition.html for backwards compatibility
publish_to_s3(html, key="family/nutrition.html", data_dir=data_dir)
except Exception as e:
print(f"⚠️ S3 upload failed (local testing?): {e}", file=sys.stderr)
if __name__ == "__main__":
main()