1464 lines
67 KiB
Python
1464 lines
67 KiB
Python
#!/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()
|