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

740 lines
32 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_website.py — Builds and publishes the streamlined, mobile-first,
PWA-installable Mila family dashboard to https://hosting.haumdaucher.de/family/mila.html.
Key Features:
1. Current Day strictly ON TOP: Live sleep/wake status (active sleep or current wake window),
detailed today breakdown (night sleep segments + each individual nap with exact start/end).
2. Conditional Modules: Fläschchen (feeding) and Windeln (diapers) are rendered dynamically
and hidden gracefully when not logged.
3. 30-Day History Table with collapsible/detailed nap timelines per day.
4. Instant S3 deployment to MinIO (permanent URL: /family/mila.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")
MILA_BIRTHDATE = datetime.date(2026, 3, 14)
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 calculate_age_str(as_of: datetime.date) -> str:
"""Calculates age in months and weeks from Mila's birthdate (14.03.2026)."""
days = (as_of - MILA_BIRTHDATE).days
if days < 0:
return "Geboren am 14.03.2026"
months = days // 30.4375
weeks = (days % 30.4375) // 7
if months < 1:
return f"{days // 7} Wochen alt"
if weeks > 0:
return f"{int(months)} Monate, {int(weeks)} Wochen alt"
return f"{int(months)} Monate alt"
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 generate_dashboard_html(engine: DatastoreEngine) -> str:
con = engine.con
now_berlin = datetime.datetime.now(BERLIN_TZ)
today_date = now_berlin.date()
today_str = today_date.strftime("%Y-%m-%d")
today_pretty = f"{WEEKDAYS_LONG_DE[today_date.weekday()]}, {today_date.strftime('%d.%m.%Y')}"
age_str = calculate_age_str(today_date)
# 1. Active open sleep check
open_sleep = con.execute(
"""
SELECT id, start, local_start, sleep_type, location, note,
date_diff('minute', start, CURRENT_TIMESTAMP) AS duration_mins
FROM v_sleep
WHERE subject = 'mila' AND is_open = true
ORDER BY start DESC LIMIT 1
"""
).fetchone()
is_sleeping = open_sleep is not None
active_sleep_duration_str = ""
active_sleep_start_time = ""
wake_window_str = ""
last_wake_time_str = ""
if is_sleeping:
dur = max(0, open_sleep[6] or 0)
h, m = divmod(dur, 60)
active_sleep_duration_str = f"{h} h {m:02d} min" if h > 0 else f"{m} min"
s_dt = datetime.datetime.fromisoformat(str(open_sleep[2]))
active_sleep_start_time = s_dt.strftime("%H:%M")
else:
# Calculate current wake window from last closed sleep session
last_closed = con.execute(
"""
SELECT "end", local_end, date_diff('minute', "end", CURRENT_TIMESTAMP) AS mins_ago
FROM v_sleep
WHERE subject = 'mila' AND is_open = false AND "end" IS NOT NULL
ORDER BY "end" DESC LIMIT 1
"""
).fetchone()
if last_closed:
mins_ago = max(0, last_closed[2] or 0)
h, m = divmod(mins_ago, 60)
wake_window_str = f"{h} h {m:02d} min" if h > 0 else f"{m} min"
e_dt = datetime.datetime.fromisoformat(str(last_closed[1]))
last_wake_time_str = e_dt.strftime("%H:%M")
# 2. Today's sleep details
today_sessions = con.execute(
f"""
SELECT id, strftime(local_start, '%H:%M') AS start_fmt,
CASE WHEN is_open THEN NULL ELSE strftime(local_end, '%H:%M') END AS end_fmt,
sleep_type, location, note, duration_minutes, is_open
FROM v_sleep
WHERE subject = 'mila' AND log_date = '{today_str}'
ORDER BY start ASC
"""
).fetchall()
today_naps = []
today_nights = []
for s in today_sessions:
if s[3] == "night":
today_nights.append(s)
else:
today_naps.append(s)
# Today daily aggregates
today_agg = con.execute(
f"""
SELECT total_sleep_hours, nap_count, night_sleep_minutes, nap_sleep_minutes
FROM v_sleep_daily
WHERE subject = 'mila' AND log_date = '{today_str}'
"""
).fetchone()
today_tot_hours = f"{today_agg[0]:.2f} h" if today_agg and today_agg[0] else "0 h"
today_night_min = today_agg[2] if today_agg else 0
today_night_h, today_night_m = divmod(today_night_min, 60)
today_night_dur_str = f"{today_night_h} h {today_night_m:02d} min" if today_night_h > 0 else f"{today_night_m} min"
today_nap_count = today_agg[1] if today_agg else 0
today_nap_min = today_agg[3] if today_agg else 0
# 3. Today's Feedings (conditional)
today_feedings = con.execute(
f"""
SELECT strftime(local_time, '%H:%M'), consumed_ml, milk_type, note
FROM v_feeding
WHERE subject = 'mila' AND log_date = '{today_str}'
ORDER BY start ASC
"""
).fetchall()
today_feeding_agg = con.execute(
f"""
SELECT bottle_count, total_consumed_ml
FROM v_feeding_daily
WHERE subject = 'mila' AND log_date = '{today_str}'
"""
).fetchone()
has_feeding_today = bool(today_feedings and len(today_feedings) > 0)
today_bottle_count = today_feeding_agg[0] if today_feeding_agg else 0
today_bottle_ml = int(today_feeding_agg[1]) if today_feeding_agg else 0
# 4. Today's Diapers (conditional)
today_diapers = con.execute(
f"""
SELECT strftime(local_time, '%H:%M'), pee, poo, texture, note
FROM v_diaper
WHERE subject = 'mila' AND log_date = '{today_str}'
ORDER BY start ASC
"""
).fetchall()
today_diaper_agg = con.execute(
f"""
SELECT total_changes, wet_count, dirty_count
FROM v_diaper_daily
WHERE subject = 'mila' AND log_date = '{today_str}'
"""
).fetchone()
has_diapers_today = bool(today_diapers and len(today_diapers) > 0)
today_diaper_count = today_diaper_agg[0] if today_diaper_agg else 0
today_wet_count = today_diaper_agg[1] if today_diaper_agg else 0
today_dirty_count = today_diaper_agg[2] if today_diaper_agg else 0
# 5. 7-Day and 30-Day Sleep Metrics
avg_7 = con.execute(
"""
SELECT ROUND(AVG(total_sleep_hours), 2), ROUND(AVG(night_sleep_minutes)/60.0, 2), ROUND(AVG(nap_sleep_minutes)/60.0, 2), ROUND(AVG(nap_count), 1)
FROM (SELECT total_sleep_hours, night_sleep_minutes, nap_sleep_minutes, nap_count FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7)
"""
).fetchone()
avg_7_tot = f"{avg_7[0]:.2f} h" if avg_7 and avg_7[0] is not None else "—"
avg_7_night = f"{avg_7[1]:.2f} h" if avg_7 and avg_7[1] is not None else "—"
avg_7_naps = f"{avg_7[3]} Naps ({avg_7[2]:.2f} h)" if avg_7 and avg_7[3] is not None else "—"
avg_30 = con.execute(
"""
SELECT ROUND(AVG(total_sleep_hours), 2)
FROM (SELECT total_sleep_hours FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 30)
"""
).fetchone()
avg_30_tot = f"{avg_30[0]:.2f} h" if avg_30 and avg_30[0] is not None else "—"
# 6. Past 30 Days History Table
history_days = con.execute(
"""
SELECT
s.log_date,
s.total_sleep_hours,
s.night_sleep_minutes,
s.nap_count,
s.nap_sleep_minutes,
COALESCE(f.total_consumed_ml, 0) AS feed_ml,
COALESCE(f.bottle_count, 0) AS feed_bottles,
COALESCE(d.total_changes, 0) AS diaper_cnt,
COALESCE(d.dirty_count, 0) AS diaper_dirty
FROM v_sleep_daily s
LEFT JOIN v_feeding_daily f ON s.log_date = f.log_date AND s.subject = f.subject
LEFT JOIN v_diaper_daily d ON s.log_date = d.log_date AND s.subject = d.subject
WHERE s.subject = 'mila'
ORDER BY s.log_date DESC
LIMIT 30
"""
).fetchall()
# Pre-fetch individual sessions for the last 30 days for expandable details
sessions_by_date = {}
all_recent_sessions = con.execute(
"""
SELECT log_date, strftime(local_start, '%H:%M'),
CASE WHEN is_open THEN NULL ELSE strftime(local_end, '%H:%M') END,
sleep_type, duration_minutes, is_open, note
FROM v_sleep
WHERE subject = 'mila' AND log_date >= (CURRENT_DATE - INTERVAL 30 DAY)
ORDER BY start ASC
"""
).fetchall()
for row in all_recent_sessions:
d_key = str(row[0])
if d_key not in sessions_by_date:
sessions_by_date[d_key] = []
sessions_by_date[d_key].append(row)
# Build history rows HTML
history_rows = []
for r in history_days:
d_str = str(r[0])
d_obj = datetime.date.fromisoformat(d_str)
wkday = WEEKDAYS_DE[d_obj.weekday()]
tot_h = f"{r[1]:.2f} h"
night_h, night_m = divmod(r[2], 60)
night_str = f"{night_h} h {night_m:02d} min" if night_h > 0 else f"{night_m} min"
nap_str = f"{r[3]} ({r[4]} min)"
feed_str = f"{int(r[5])} ml ({r[6]}x)" if r[6] > 0 else "—"
diaper_str = f"{r[7]} ({r[8]} Stuhl)" if r[7] > 0 else "—"
# Detailed breakdown of sessions for this day
day_sess = sessions_by_date.get(d_str, [])
sess_badges = []
for s in day_sess:
s_st = s[1]
s_et = s[2] or "läuft"
s_dur = f"{s[4]}m" if s[4] else ""
badge_color = "bg-purple-950/60 text-purple-300 border-purple-800/40" if s[3] == "night" else "bg-emerald-950/60 text-emerald-300 border-emerald-800/40"
sess_badges.append(
f'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border {badge_color}">{s_st}–{s_et} ({s_dur})</span>'
)
sess_badges_html = " ".join(sess_badges) if sess_badges else '<span class="text-zinc-500 text-xs">—</span>'
is_today_row = (d_str == today_str)
row_bg = "bg-purple-950/20" if is_today_row else "hover:bg-zinc-900/40"
history_rows.append(f"""
<tr class="border-b border-zinc-800/60 {row_bg} transition">
<td class="py-3.5 px-4 font-mono font-medium text-zinc-200">
{wkday}, {d_obj.strftime('%d.%m.')}
{' <span class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-300 border border-purple-500/30">Heute</span>' if is_today_row else ''}
</td>
<td class="py-3.5 px-4 font-bold text-purple-300">{tot_h}</td>
<td class="py-3.5 px-4 text-sky-400 font-semibold">{night_str}</td>
<td class="py-3.5 px-4 text-emerald-400">{nap_str}</td>
<td class="py-3.5 px-4 text-zinc-400">{feed_str}</td>
<td class="py-3.5 px-4 text-zinc-400">{diaper_str}</td>
</tr>
<tr class="border-b border-zinc-800/30 bg-zinc-950/30 text-xs">
<td colspan="6" class="py-1.5 px-4 text-zinc-400 space-x-1">
<span class="text-zinc-500 font-mono text-[10px] uppercase">Einzelschläfchen:</span> {sess_badges_html}
</td>
</tr>
""")
# Render today's naps cards HTML
today_naps_html = []
if today_naps:
for idx, n in enumerate(today_naps, 1):
n_st = n[1]
n_et = n[2] or "läuft gerade"
n_dur = f"{n[6]} min" if n[6] is not None else "in Ausführung"
n_loc = f"({n[4]})" if n[4] else ""
n_note = f"· {n[5]}" if n[5] else ""
running_pulse = '<span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>' if n[7] else ''
today_naps_html.append(f"""
<div class="flex items-center justify-between p-3 rounded-xl bg-zinc-900/60 border border-zinc-800/60 hover:border-emerald-500/30 transition">
<div class="flex items-center gap-3">
<div class="w-7 h-7 rounded-lg bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400 font-mono text-xs font-bold">
#{idx}
</div>
<div>
<div class="text-sm font-semibold text-zinc-200 flex items-center gap-2">
<span>{n_st} – {n_et} Uhr</span>
{running_pulse}
</div>
<div class="text-xs text-zinc-400">{n_loc} {n_note}</div>
</div>
</div>
<div class="text-right">
<span class="text-sm font-bold text-emerald-400 font-mono">{n_dur}</span>
</div>
</div>
""")
else:
today_naps_html.append("""
<div class="p-4 rounded-xl bg-zinc-900/40 border border-zinc-800/40 text-center text-zinc-500 text-sm">
Heute bisher noch kein Tagesschläfchen eingetragen.
</div>
""")
# Render today's nights cards HTML
today_nights_html = []
if today_nights:
for n in today_nights:
n_st = n[1]
n_et = n[2] or "läuft"
dur_m = n[6] or 0
dh, dm = divmod(dur_m, 60)
dur_str = f"{dh} h {dm:02d} min" if dh > 0 else f"{dm} min"
note_str = f"· {n[5]}" if n[5] else ""
today_nights_html.append(f"""
<div class="flex items-center justify-between p-3 rounded-xl bg-zinc-900/60 border border-zinc-800/60">
<div class="flex items-center gap-3">
<div class="w-7 h-7 rounded-lg bg-sky-500/10 border border-sky-500/20 flex items-center justify-center text-sky-400 text-xs">
🌙
</div>
<div>
<div class="text-sm font-semibold text-zinc-200">{n_st} – {n_et} Uhr</div>
<div class="text-xs text-zinc-400">{note_str}</div>
</div>
</div>
<div class="text-right font-mono font-bold text-sky-400 text-sm">{dur_str}</div>
</div>
""")
else:
today_nights_html.append("""
<div class="p-3 rounded-xl bg-zinc-900/40 border border-zinc-800/40 text-center text-zinc-500 text-xs">
Kein Nachtschlaf für das Tagesdatum zugeordnet (Nachtstart zählt zum Vortag).
</div>
""")
# Conditional feeding card HTML
feeding_section_html = ""
if has_feeding_today:
bottles_pills = []
for f in today_feedings:
ml_val = f"{int(f[1])} ml" if f[1] else "—"
bottles_pills.append(f'<span class="px-2 py-1 rounded bg-amber-500/10 border border-amber-500/20 text-amber-300 text-xs">{f[0]} Uhr ({ml_val})</span>')
feeding_section_html = f"""
<div class="bg-zinc-900/80 border border-amber-500/20 rounded-2xl p-5 shadow-lg space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xl">🍼</span>
<h3 class="font-semibold text-zinc-200">Fläschchen heute</h3>
</div>
<span class="font-mono font-bold text-amber-400 text-lg">{today_bottle_ml} ml <span class="text-xs text-zinc-400 font-normal">({today_bottle_count}x)</span></span>
</div>
<div class="flex flex-wrap gap-2 pt-1">
{' '.join(bottles_pills)}
</div>
</div>
"""
# Conditional diaper card HTML
diaper_section_html = ""
if has_diapers_today:
diaper_pills = []
for d in today_diapers:
content = []
if d[1]: content.append("Pipi")
if d[2]: content.append("Kacka")
d_desc = "+".join(content) or "Wechsel"
diaper_pills.append(f'<span class="px-2 py-1 rounded bg-teal-500/10 border border-teal-500/20 text-teal-300 text-xs">{d[0]} Uhr ({d_desc})</span>')
diaper_section_html = f"""
<div class="bg-zinc-900/80 border border-teal-500/20 rounded-2xl p-5 shadow-lg space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xl">🧷</span>
<h3 class="font-semibold text-zinc-200">Windeln heute</h3>
</div>
<span class="font-mono font-bold text-teal-400 text-lg">{today_diaper_count} gewechselt <span class="text-xs text-zinc-400 font-normal">({today_wet_count} nass · {today_dirty_count} Stuhl)</span></span>
</div>
<div class="flex flex-wrap gap-2 pt-1">
{' '.join(diaper_pills)}
</div>
</div>
"""
# PWA Manifest as inline Data URI
manifest_dict = {
"name": "Mila Tracker",
"short_name": "Mila",
"start_url": "/family/mila.html",
"display": "standalone",
"background_color": "#090A0F",
"theme_color": "#090A0F",
"icons": [
{
"src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='50' r='48' fill='%237C3AED'/%3E%3Ctext x='50' y='65' font-size='42' font-family='sans-serif' text-anchor='middle' fill='white'%3E%F0%9F%A7%B8%3C/text%3E%3C/svg%3E",
"sizes": "192x192 512x512",
"type": "image/svg+xml"
}
]
}
manifest_uri = "data:application/manifest+json," + json.dumps(manifest_dict)
# Full HTML Document
html = f"""<!DOCTYPE html>
<html lang="de" class="dark" x-data="{{ activeTab: 'today' }}">
<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">
<title>Mila — Schlaf & Entwicklung</title>
<!-- PWA & Mobile Web Capabilities -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Mila">
<meta name="theme-color" content="#090A0F">
<link rel="manifest" href="{manifest_uri}">
<link rel="apple-touch-icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='50' r='48' fill='%237C3AED'/%3E%3Ctext x='50' y='65' font-size='42' font-family='sans-serif' text-anchor='middle' fill='white'%3E%F0%9F%A7%B8%3C/text%3E%3C/svg%3E">
<!-- Typography & Styling -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Syne:wght@700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {{
darkMode: 'class',
theme: {{
extend: {{
fontFamily: {{
heading: ['Syne', 'sans-serif'],
sans: ['Plus Jakarta Sans', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
}}
}}
}}
}}
</script>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
</head>
<body class="bg-[#090A0F] text-zinc-100 min-h-screen font-sans antialiased selection:bg-purple-500 selection:text-white pb-16">
<!-- Header & Live Status (Always sticky on mobile) -->
<header class="border-b border-zinc-800/80 bg-zinc-950/80 backdrop-blur-xl sticky top-0 z-40 px-4 py-3 sm:px-8">
<div class="max-w-4xl mx-auto flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-2xl bg-gradient-to-tr from-purple-600 via-pink-500 to-amber-400 p-0.5 shadow-lg shadow-purple-500/20">
<div class="w-full h-full bg-zinc-950 rounded-[14px] flex items-center justify-center text-lg">
🧸
</div>
</div>
<div>
<div class="flex items-center gap-2">
<h1 class="text-lg sm:text-xl font-heading font-extrabold tracking-tight bg-gradient-to-r from-purple-300 via-pink-300 to-indigo-200 bg-clip-text text-transparent">
Mila Graf
</h1>
<span class="text-[11px] px-2 py-0.5 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-300 font-medium">
{age_str}
</span>
</div>
<p class="text-xs text-zinc-400">{today_pretty}</p>
</div>
</div>
<!-- Live State Badge -->
<div>
{'<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-purple-500/15 border border-purple-500/30 text-purple-300 text-xs font-semibold shadow-sm shadow-purple-500/10"><span class="w-2 h-2 rounded-full bg-purple-400 animate-pulse"></span><span>Schl\u00e4ft (' + active_sleep_duration_str + ')</span></div>' if is_sleeping else '<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-emerald-500/15 border border-emerald-500/30 text-emerald-300 text-xs font-semibold shadow-sm shadow-emerald-500/10"><span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span><span>Wach (' + wake_window_str + ')</span></div>'}
</div>
</div>
</header>
<main class="max-w-4xl mx-auto p-4 sm:p-6 space-y-6">
<!-- HERO SECTION: CURRENT DAY STRICTLY ON TOP -->
<section class="space-y-4">
<!-- Hero Banner / Current State Card -->
<div class="p-6 rounded-3xl {'bg-gradient-to-br from-purple-900/30 via-zinc-900/70 to-zinc-950 border border-purple-500/30' if is_sleeping else 'bg-gradient-to-br from-emerald-900/20 via-zinc-900/70 to-zinc-950 border border-emerald-500/30'} shadow-2xl relative overflow-hidden">
<div class="relative z-10 space-y-2">
<div class="flex items-center gap-2 text-xs font-mono tracking-wider uppercase {'text-purple-400 font-bold' if is_sleeping else 'text-emerald-400 font-bold'}">
<span>{'💤 Status: Schläft gerade' if is_sleeping else '☀️ Status: Wach & Munter'}</span>
</div>
<div class="text-3xl sm:text-4xl font-extrabold tracking-tight text-white font-heading">
{f'Seit {active_sleep_start_time} Uhr ({active_sleep_duration_str})' if is_sleeping else f'Wachfenster: {wake_window_str}'}
</div>
<p class="text-sm text-zinc-400">
{f'Eingeschlafen im Bett. Schlafintervall l\u00e4uft aktuell.' if is_sleeping else f'Letztes Schläfchen beendet um {last_wake_time_str} Uhr.'}
</p>
</div>
</div>
<!-- Key Metrics Strip for Today -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div class="bg-zinc-900/80 border border-zinc-800 rounded-2xl p-4 shadow-md">
<div class="text-[11px] font-semibold uppercase tracking-wider text-zinc-400">24h Schlaf Heute</div>
<div class="text-2xl font-extrabold text-purple-300 font-mono mt-1">{today_tot_hours}</div>
<div class="text-[11px] text-zinc-500 mt-0.5">Nacht + Schläfchen</div>
</div>
<div class="bg-zinc-900/80 border border-zinc-800 rounded-2xl p-4 shadow-md">
<div class="text-[11px] font-semibold uppercase tracking-wider text-zinc-400">Tagesschläfchen</div>
<div class="text-2xl font-extrabold text-emerald-400 font-mono mt-1">{today_nap_count} Naps</div>
<div class="text-[11px] text-zinc-500 mt-0.5">{today_nap_min} min gesamt</div>
</div>
<div class="bg-zinc-900/80 border border-zinc-800 rounded-2xl p-4 shadow-md">
<div class="text-[11px] font-semibold uppercase tracking-wider text-zinc-400">Nachtschlaf</div>
<div class="text-2xl font-extrabold text-sky-400 font-mono mt-1">{today_night_dur_str}</div>
<div class="text-[11px] text-zinc-500 mt-0.5">Hauptschlafphase</div>
</div>
<div class="bg-zinc-900/80 border border-zinc-800 rounded-2xl p-4 shadow-md">
<div class="text-[11px] font-semibold uppercase tracking-wider text-zinc-400">7-Tage-Schnitt</div>
<div class="text-2xl font-extrabold text-pink-400 font-mono mt-1">{avg_7_tot}</div>
<div class="text-[11px] text-zinc-500 mt-0.5">Alterskorridor: 12–15h</div>
</div>
</div>
<!-- TODAY'S DETAILED TIMELINE: NAPS & NIGHT -->
<div class="bg-zinc-900/80 border border-zinc-800/80 rounded-2xl p-5 shadow-xl space-y-4">
<div class="flex items-center justify-between border-b border-zinc-800/80 pb-3">
<div class="flex items-center gap-2">
<span class="text-lg">📋</span>
<h2 class="text-base font-bold text-zinc-100">Heutiger Tagesablauf im Detail</h2>
</div>
<span class="text-xs font-mono text-zinc-400">{today_str}</span>
</div>
<!-- Tagesschläfchen Breakdown -->
<div class="space-y-2">
<div class="text-xs font-bold text-emerald-400 uppercase tracking-wider flex items-center gap-1.5">
<span>🌿</span>
<span>Tagesschläfchen ({today_nap_count} erfasst)</span>
</div>
<div class="space-y-2">
{''.join(today_naps_html)}
</div>
</div>
<!-- Nachtschlaf Breakdown -->
<div class="space-y-2 pt-2 border-t border-zinc-800/50">
<div class="text-xs font-bold text-sky-400 uppercase tracking-wider flex items-center gap-1.5">
<span>🌙</span>
<span>Nachtschlaf</span>
</div>
<div class="space-y-2">
{''.join(today_nights_html)}
</div>
</div>
</div>
<!-- CONDITIONAL MODULES: BOTTLES & DIAPERS -->
{feeding_section_html}
{diaper_section_html}
</section>
<!-- DEVELOPMENT & TREND BENCHMARKS -->
<section class="bg-gradient-to-r from-purple-950/30 via-zinc-900/60 to-zinc-900/30 border border-zinc-800 rounded-2xl p-5 shadow-md">
<div class="flex items-center gap-2 mb-3">
<span class="text-lg">📈</span>
<h2 class="text-sm font-bold uppercase tracking-wider text-zinc-300">Entwicklungs-Benchmarks & Tendenzen</h2>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs text-zinc-400">
<div class="p-3 rounded-xl bg-zinc-950/50 border border-zinc-800/60 space-y-1">
<div class="text-zinc-500 font-medium">7-Tage-Ø Gesamtschlaf</div>
<div class="text-lg font-bold text-purple-300 font-mono">{avg_7_tot}</div>
<div>Nacht: <span class="text-sky-300 font-mono">{avg_7_night}</span> · Tag: <span class="text-emerald-300 font-mono">{avg_7_naps}</span></div>
</div>
<div class="p-3 rounded-xl bg-zinc-950/50 border border-zinc-800/60 space-y-1">
<div class="text-zinc-500 font-medium">30-Tage-Ø Gesamtschlaf</div>
<div class="text-lg font-bold text-pink-300 font-mono">{avg_30_tot}</div>
<div>Stabile zirkadiane Konsolidierung</div>
</div>
<div class="p-3 rounded-xl bg-zinc-950/50 border border-zinc-800/60 space-y-1">
<div class="text-zinc-500 font-medium">Altersreferenz (7 Monate)</div>
<div class="text-lg font-bold text-amber-300 font-mono">12 – 15 Std</div>
<div>Typisch 2–3 Naps, Wachfenster ~2.25–3.25h</div>
</div>
</div>
</section>
<!-- 30-DAY HISTORY TABLE -->
<section class="bg-zinc-900/80 border border-zinc-800/80 rounded-2xl p-5 shadow-xl space-y-4">
<div class="flex items-center justify-between pb-2 border-b border-zinc-800/60">
<div class="flex items-center gap-2">
<span class="text-lg">📅</span>
<h2 class="text-base font-bold text-zinc-100">Historie (Letzte 30 Tage)</h2>
</div>
<span class="text-xs text-zinc-400">DuckDB Aggregatdaten</span>
</div>
<div class="overflow-x-auto -mx-5 px-5">
<table class="w-full text-left text-sm whitespace-nowrap">
<thead class="text-[11px] uppercase bg-zinc-800/50 text-zinc-400">
<tr>
<th class="py-2.5 px-4 rounded-l-lg">Datum</th>
<th class="py-2.5 px-4">24h Gesamt</th>
<th class="py-2.5 px-4">Nacht</th>
<th class="py-2.5 px-4">Schläfchen</th>
<th class="py-2.5 px-4">Fläschchen</th>
<th class="py-2.5 px-4 rounded-r-lg">Windeln</th>
</tr>
</thead>
<tbody>
{''.join(history_rows)}
</tbody>
</table>
</div>
</section>
</main>
<!-- Floating Refresh Footer -->
<footer class="max-w-4xl mx-auto px-4 text-center text-xs text-zinc-500 space-y-1">
<p>Aktualisiert: {now_berlin.strftime('%d.%m.%Y um %H:%M:%S')} Uhr · Single Source of Truth via DuckDB Datastore</p>
<p class="text-[11px]">Tipp: Im Safari-Menü auf „Zum Home-Bildschirm" tippen für die native App-Nutzung.</p>
</footer>
<script>
document.addEventListener('DOMContentLoaded', () => {{
lucide.createIcons();
}});
</script>
</body>
</html>
"""
return html
def publish_to_s3(html_content: str) -> str:
"""Uploads the rendered HTML to MinIO hosting bucket at family/mila.html."""
s3 = get_s3_client()
key = "family/mila.html"
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",
)
url = f"{DEFAULT_BASE_URL}/{key}"
return url
def main():
parser = argparse.ArgumentParser(description="Render and publish Mila family 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 Mila Dashboard from DuckDB...")
html = generate_dashboard_html(engine)
# 1. Save locally to knowledge-wiki/public/family/mila.html (or specified file)
wiki_public = data_dir.parent / "public" / "family"
wiki_public.mkdir(parents=True, exist_ok=True)
out_file = Path(args.output_file) if args.output_file else (wiki_public / "mila.html")
out_file.write_text(html, encoding="utf-8")
print(f"✅ Saved locally: {out_file}")
# 2. Upload to S3 hosting
if not args.skip_upload:
try:
url = publish_to_s3(html)
print(f"🚀 Published successfully to: {url}")
except Exception as e:
print(f"⚠️ S3 upload failed (local testing?): {e}", file=sys.stderr)
if __name__ == "__main__":
main()