#!/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'{s_st}–{s_et} ({s_dur})' ) sess_badges_html = " ".join(sess_badges) if sess_badges else '—' 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""" {wkday}, {d_obj.strftime('%d.%m.')} {' Heute' if is_today_row else ''} {tot_h} {night_str} {nap_str} {feed_str} {diaper_str} Einzelschläfchen: {sess_badges_html} """) # 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 = '' if n[7] else '' today_naps_html.append(f"""
#{idx}
{n_st} – {n_et} Uhr {running_pulse}
{n_loc} {n_note}
{n_dur}
""") else: today_naps_html.append("""
Heute bisher noch kein Tagesschläfchen eingetragen.
""") # 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"""
🌙
{n_st} – {n_et} Uhr
{note_str}
{dur_str}
""") else: today_nights_html.append("""
Kein Nachtschlaf für das Tagesdatum zugeordnet (Nachtstart zählt zum Vortag).
""") # 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'{f[0]} Uhr ({ml_val})') feeding_section_html = f"""
🍼

Fläschchen heute

{today_bottle_ml} ml ({today_bottle_count}x)
{' '.join(bottles_pills)}
""" # 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'{d[0]} Uhr ({d_desc})') diaper_section_html = f"""
🧷

Windeln heute

{today_diaper_count} gewechselt ({today_wet_count} nass · {today_dirty_count} Stuhl)
{' '.join(diaper_pills)}
""" # 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""" Mila — Schlaf & Entwicklung
🧸

Mila Graf

{age_str}

{today_pretty}

{'
Schl\u00e4ft (' + active_sleep_duration_str + ')
' if is_sleeping else '
Wach (' + wake_window_str + ')
'}
{'💤 Status: Schläft gerade' if is_sleeping else '☀️ Status: Wach & Munter'}
{f'Seit {active_sleep_start_time} Uhr ({active_sleep_duration_str})' if is_sleeping else f'Wachfenster: {wake_window_str}'}

{f'Eingeschlafen im Bett. Schlafintervall l\u00e4uft aktuell.' if is_sleeping else f'Letztes Schläfchen beendet um {last_wake_time_str} Uhr.'}

24h Schlaf Heute
{today_tot_hours}
Nacht + Schläfchen
Tagesschläfchen
{today_nap_count} Naps
{today_nap_min} min gesamt
Nachtschlaf
{today_night_dur_str}
Hauptschlafphase
7-Tage-Schnitt
{avg_7_tot}
Alterskorridor: 12–15h
📋

Heutiger Tagesablauf im Detail

{today_str}
🌿 Tagesschläfchen ({today_nap_count} erfasst)
{''.join(today_naps_html)}
🌙 Nachtschlaf
{''.join(today_nights_html)}
{feeding_section_html} {diaper_section_html}
📈

Entwicklungs-Benchmarks & Tendenzen

7-Tage-Ø Gesamtschlaf
{avg_7_tot}
Nacht: {avg_7_night} · Tag: {avg_7_naps}
30-Tage-Ø Gesamtschlaf
{avg_30_tot}
Stabile zirkadiane Konsolidierung
Altersreferenz (7 Monate)
12 – 15 Std
Typisch 2–3 Naps, Wachfenster ~2.25–3.25h
📅

Historie (Letzte 30 Tage)

DuckDB Aggregatdaten
{''.join(history_rows)}
Datum 24h Gesamt Nacht Schläfchen Fläschchen Windeln
""" 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()