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

1069 lines
46 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 = ""
active_sleep_iso = ""
wake_window_str = ""
last_wake_time_str = ""
last_wake_iso = ""
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")
active_sleep_iso = s_dt.isoformat()
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")
last_wake_iso = e_dt.isoformat()
# 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}'
OR (sleep_type = 'night' AND CAST(local_end AS 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()
# Prefer the completed night sleep that ended this morning/today for the top card
morning_night = next((s for s in today_nights if not s[7] and s[6] is not None), None)
if morning_night and morning_night[6]:
today_night_min = morning_night[6]
else:
today_night_min = today_agg[2] if today_agg else 0
today_nap_count = today_agg[1] if today_agg else len(today_naps)
today_nap_min = sum(n[6] or 0 for n in today_naps)
today_tot_min = today_night_min + today_nap_min
today_tot_hours = f"{today_tot_min / 60.0:.2f} h" if today_tot_min > 0 else "0 h"
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"
# 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"
dur_display = f'<span class="live-sleep-timer" data-start-iso="{active_sleep_iso}">{dur_str}</span>' if n[7] else f'<span>{dur_str}</span>'
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_display}</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
manifest_dict = {
"id": "/family/mila.html",
"name": "Mila — Schlaf & Entwicklung",
"short_name": "Mila",
"description": "Schlaf-, Trink- und Windeltagebuch für Mila Graf",
"start_url": "/family/mila.html",
"scope": "/family/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#090A0F",
"theme_color": "#090A0F",
"icons": [
{
"src": "/family/mila-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/family/mila-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/family/mila-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/family/mila-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/family/mila-apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "/family/mila-icon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
]
}
# 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">
<meta name="build-time" content="{now_berlin.isoformat()}">
<meta name="build-epoch" content="{int(now_berlin.timestamp())}">
<title>Mila — Schlaf & Entwicklung</title>
<!-- PWA & Mobile Web Capabilities -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="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="application-name" content="Mila">
<meta name="theme-color" content="#090A0F">
<meta name="msapplication-TileColor" content="#090A0F">
<link rel="manifest" href="/family/mila.webmanifest" crossorigin="use-credentials">
<link rel="apple-touch-icon" href="/family/mila-apple-touch-icon.png">
<link rel="apple-touch-icon" sizes="180x180" href="/family/mila-apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="192x192" href="/family/mila-icon-192.png">
<link rel="icon" type="image/png" sizes="512x512" href="/family/mila-icon-512.png">
<link rel="icon" type="image/png" sizes="32x32" href="/family/mila-icon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/family/mila-icon-16.png">
<link rel="shortcut icon" href="/family/favicon.ico">
<link rel="icon" href="/family/favicon.ico">
<link rel="icon" type="image/svg+xml" href="/family/mila-icon.svg">
<!-- 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">
<!-- PWA Install Banner (Android / Desktop Chrome) -->
<div id="pwa-install-banner" class="hidden max-w-4xl mx-auto px-4 pt-3">
<div class="flex items-center justify-between p-3 rounded-2xl bg-gradient-to-r from-purple-900/80 via-pink-900/50 to-zinc-900 border border-purple-500/40 shadow-xl backdrop-blur-md">
<div class="flex items-center gap-3">
<img src="/family/mila-icon-192.png" alt="Mila" class="w-9 h-9 rounded-xl object-cover shadow-md border border-purple-500/40">
<div>
<p class="text-xs font-bold text-white">Mila App installieren</p>
<p class="text-[11px] text-zinc-300">Direkt auf dem Homescreen nutzen</p>
</div>
</div>
<div class="flex items-center gap-2">
<button id="pwa-install-btn" class="px-3.5 py-1.5 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-400 hover:to-pink-400 text-white text-xs font-bold shadow-lg shadow-purple-500/30 active:scale-95 transition">
Installieren
</button>
<button onclick="document.getElementById('pwa-install-banner').classList.add('hidden')" class="p-1 rounded-lg text-zinc-400 hover:text-white">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
</div>
</div>
<!-- iOS Safari Install Guidance Banner -->
<div id="ios-install-banner" class="hidden max-w-4xl mx-auto px-4 pt-3">
<div class="flex items-center justify-between p-3 rounded-2xl bg-zinc-900/90 border border-purple-500/30 shadow-lg backdrop-blur-md">
<div class="flex items-center gap-2.5">
<span class="text-base">📱</span>
<p class="text-xs text-zinc-300">
Als App nutzen: Im Safari-Menü auf <span class="inline-flex items-center text-purple-400 font-semibold"><i data-lucide="share" class="w-3.5 h-3.5 inline mx-0.5"></i> Teilen</span> und <span class="text-white font-semibold">„Zum Home-Bildschirm“</span> (+) tippen.
</p>
</div>
<button onclick="dismissIosBanner()" class="p-1 rounded-lg text-zinc-400 hover:text-white">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
</div>
<!-- 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-teal-400 p-0.5 shadow-lg shadow-purple-500/20 overflow-hidden flex items-center justify-center">
<img src="/family/mila-icon-192.png" alt="Mila" class="w-full h-full object-cover rounded-[14px]">
</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, Install Button & Manual Refresh Button -->
<div class="flex items-center gap-2">
{'<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 (<span class="live-sleep-timer" data-start-iso="' + active_sleep_iso + '">' + active_sleep_duration_str + '</span>)</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 (<span class="live-wake-timer" data-end-iso="' + last_wake_iso + '">' + wake_window_str + '</span>)</span></div>'}
<button id="header-pwa-install-btn" onclick="triggerPwaInstall()" class="hidden inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl bg-purple-500/10 hover:bg-purple-500/20 border border-purple-500/30 text-purple-300 text-xs font-semibold transition" title="Mila App installieren">
<i data-lucide="download" class="w-3.5 h-3.5"></i>
<span class="hidden sm:inline">Installieren</span>
</button>
<button id="manual-refresh-btn" onclick="triggerManualRefresh()" title="Neu laden" class="p-2 rounded-xl bg-zinc-900/80 border border-zinc-800 text-zinc-400 hover:text-white hover:border-purple-500/40 hover:bg-zinc-800 transition">
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
</button>
</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 (<span class="live-sleep-timer" data-start-iso="{active_sleep_iso}">{active_sleep_duration_str}</span>)' if is_sleeping else f'Wachfenster: <span class="live-wake-timer" data-end-iso="{last_wake_iso}">{wake_window_str}</span>'}
</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: <span id="build-time-display">{now_berlin.strftime('%d.%m.%Y um %H:%M:%S')} Uhr</span> · <button onclick="triggerManualRefresh()" class="text-purple-400 hover:text-purple-300 underline font-mono transition">Live Sync</button></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();
}});
// Service Worker Registration for PWA installability & 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));
}});
}}
// Chrome/Android/Desktop PWA BeforeInstallPrompt & iOS Detection
let deferredInstallPrompt = null;
const isIos = (/iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)) && !window.MSStream;
const isStandalone = window.navigator.standalone === true || window.matchMedia('(display-mode: standalone)').matches;
window.addEventListener('beforeinstallprompt', (e) => {{
e.preventDefault();
deferredInstallPrompt = e;
const banner = document.getElementById('pwa-install-banner');
if (banner && !isStandalone) banner.classList.remove('hidden');
const headerBtn = document.getElementById('header-pwa-install-btn');
if (headerBtn && !isStandalone) headerBtn.classList.remove('hidden');
}});
window.addEventListener('appinstalled', () => {{
console.log('[PWA] App successfully installed');
const banner = document.getElementById('pwa-install-banner');
if (banner) banner.classList.add('hidden');
const headerBtn = document.getElementById('header-pwa-install-btn');
if (headerBtn) headerBtn.classList.add('hidden');
deferredInstallPrompt = null;
}});
const installBtn = document.getElementById('pwa-install-btn');
if (installBtn) {{
installBtn.addEventListener('click', () => {{
triggerPwaInstall();
}});
}}
// iOS Safari Detection & Guidance Banner
if (isIos && !isStandalone) {{
const headerBtn = document.getElementById('header-pwa-install-btn');
if (headerBtn) headerBtn.classList.remove('hidden');
if (!localStorage.getItem('dismissed_ios_mila_pwa')) {{
const iosBanner = document.getElementById('ios-install-banner');
if (iosBanner) iosBanner.classList.remove('hidden');
}}
}}
function dismissIosBanner() {{
localStorage.setItem('dismissed_ios_mila_pwa', 'true');
const iosBanner = document.getElementById('ios-install-banner');
if (iosBanner) iosBanner.classList.add('hidden');
}}
window.triggerPwaInstall = function() {{
if (deferredInstallPrompt) {{
deferredInstallPrompt.prompt();
deferredInstallPrompt.userChoice.then(({{ outcome }}) => {{
if (outcome === 'accepted') {{
const banner = document.getElementById('pwa-install-banner');
if (banner) banner.classList.add('hidden');
const headerBtn = document.getElementById('header-pwa-install-btn');
if (headerBtn) headerBtn.classList.add('hidden');
}}
deferredInstallPrompt = null;
}});
}} else if (isIos) {{
const iosBanner = document.getElementById('ios-install-banner');
if (iosBanner) {{
iosBanner.classList.remove('hidden');
iosBanner.scrollIntoView({{ behavior: 'smooth' }});
}} else {{
alert("Als App nutzen: Im Safari-Menü auf 'Teilen' [↑] tippen und 'Zum Home-Bildschirm' (+) wählen.");
}}
}} else {{
alert("Zum Installieren: Im Browser-Menü auf 'App installieren' oder das Installations-Symbol in der Adressleiste tippen.");
}}
}};
// ==============================================================================
// Self-Updating Engine & Live Tickers
// ==============================================================================
(function() {{
const currentBuildEpoch = parseInt(document.querySelector('meta[name="build-epoch"]')?.content || '0', 10);
let lastCheckMs = Date.now();
let isChecking = false;
// 1. Live Running Sleep/Wake Timer (increments live in DOM every 5 seconds)
function tickLiveTimers() {{
document.querySelectorAll('.live-sleep-timer').forEach(el => {{
const iso = el.dataset.startIso;
if (!iso) return;
const startMs = new Date(iso).getTime();
if (isNaN(startMs)) return;
const diffMs = Math.max(0, Date.now() - startMs);
const mins = Math.floor(diffMs / 60000);
const h = Math.floor(mins / 60);
const m = mins % 60;
el.textContent = h > 0 ? (h + ' h ' + String(m).padStart(2, '0') + ' min') : (m + ' min');
}});
document.querySelectorAll('.live-wake-timer').forEach(el => {{
const iso = el.dataset.endIso;
if (!iso) return;
const endMs = new Date(iso).getTime();
if (isNaN(endMs)) return;
const diffMs = Math.max(0, Date.now() - endMs);
const mins = Math.floor(diffMs / 60000);
const h = Math.floor(mins / 60);
const m = mins % 60;
el.textContent = h > 0 ? (h + ' h ' + String(m).padStart(2, '0') + ' min') : (m + ' min');
}});
}}
setInterval(tickLiveTimers, 5000);
tickLiveTimers();
// 2. 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();
}}
}}
// 3. Auto-update on reopen / foreground resume (PWA & Mobile Safari)
document.addEventListener('visibilitychange', () => {{
if (document.visibilityState === 'visible') {{
tickLiveTimers();
if (Date.now() - lastCheckMs > 10000) {{
checkForUpdates();
}}
}}
}});
window.addEventListener('pageshow', (e) => {{
tickLiveTimers();
if (e.persisted || Date.now() - lastCheckMs > 10000) {{
checkForUpdates();
}}
}});
window.addEventListener('focus', () => {{
tickLiveTimers();
if (Date.now() - lastCheckMs > 15000) {{
checkForUpdates();
}}
}});
// 4. Background check every 30 seconds when visible
setInterval(() => {{
if (document.visibilityState === 'visible') {{
checkForUpdates();
}}
}}, 30000);
// 5. 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 publish_to_s3(html_content: str, data_dir: Optional[Path] = None) -> str:
"""Uploads the rendered HTML, webmanifest, service worker and icons to MinIO hosting bucket."""
s3 = get_s3_client()
assets_dir = SCRIPT_DIR.parent / "assets"
# HTML files (strictly under protected family/ path)
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",
)
# Static PWA Assets to upload (strictly under family/)
static_assets = [
("mila.webmanifest", "application/manifest+json; charset=utf-8", "no-cache, no-store, must-revalidate"),
("sw.js", "application/javascript; charset=utf-8", "no-cache, no-store, must-revalidate"),
("mila-icon-192.png", "image/png", "public, max-age=604800"),
("mila-icon-512.png", "image/png", "public, max-age=604800"),
("mila-apple-touch-icon.png", "image/png", "public, max-age=604800"),
("mila-icon-32.png", "image/png", "public, max-age=604800"),
("mila-icon-16.png", "image/png", "public, max-age=604800"),
("favicon.ico", "image/x-icon", "public, max-age=604800"),
("mila-icon.svg", "image/svg+xml", "public, max-age=604800"),
]
for fname, ctype, cache_ctrl in static_assets:
candidates = [assets_dir / fname]
if data_dir:
candidates.append(data_dir.parent / "public" / "family" / fname)
body_bytes = None
for candidate in candidates:
if candidate.is_file():
body_bytes = candidate.read_bytes()
break
if body_bytes:
try:
s3.put_object(
Bucket=DEFAULT_BUCKET,
Key=f"family/{fname}",
Body=body_bytes,
ContentType=ctype,
CacheControl=cache_ctrl,
)
except Exception as ex:
print(f"Warning: could not upload family/{fname}: {ex}", file=sys.stderr)
url = f"{DEFAULT_BASE_URL}/family/mila.html"
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 strictly to knowledge-wiki/public/family/mila.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 / "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, data_dir)
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()