#!/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'{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"
dur_display = f'{dur_str}' if n[7] else f'{dur_str}'
note_str = f"· {n[5]}" if n[5] else ""
today_nights_html.append(f"""
🌙
{n_st} – {n_et} Uhr
{note_str}
{dur_display}
""")
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"""