#!/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 from who_growth import WHO_GIRLS_WEIGHT, WHO_GIRLS_HEIGHT, calculate_percentile 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 & Nutrition (Bottles + Brei/Beikost) today_feedings = con.execute( f""" SELECT strftime(local_time, '%H:%M') AS time_str, consumed_ml, milk_type, item, solid_g, kcal, kind, 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, solid_count, total_solid_g, total_kcal 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 today_solid_count = today_feeding_agg[2] if today_feeding_agg else 0 today_solid_g = int(today_feeding_agg[3]) if today_feeding_agg else 0 today_feeding_kcal = int(today_feeding_agg[4]) 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(f.total_solid_g, 0) AS feed_solid_g, COALESCE(f.solid_count, 0) AS feed_solid_count, 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_parts = [] if r[6] > 0: # feed_bottles feed_parts.append(f"{int(r[5])} ml ({r[6]}x)") if r[8] > 0: # feed_solid_count feed_parts.append(f"{int(r[7])} g Brei ({r[8]}x)") feed_str = " + ".join(feed_parts) if feed_parts else "—" diaper_str = f"{r[9]} ({r[10]} Stuhl)" if r[9] > 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: feeding_pills = [] for f in today_feedings: time_str = f[0] ml_val = f[1] milk_type = f[2] item_name = f[3] solid_g = f[4] kcal_val = f[5] kind = f[6] if kind == "bottle" or (ml_val is not None and ml_val > 0): ml_str = f"{int(ml_val)} ml" milk_desc = f" · {milk_type}" if milk_type else "" feeding_pills.append( f'' f'🍼 {time_str} Uhr: {ml_str}{milk_desc}' ) else: food_title = item_name or "Brei" details = [] if solid_g: details.append(f"{int(solid_g)} g") if kcal_val: details.append(f"~{int(kcal_val)} kcal") detail_str = f" ({', '.join(details)})" if details else "" feeding_pills.append( f'' f'🥣 {time_str} Uhr: {food_title}{detail_str}' ) feed_summary_parts = [] if today_bottle_count > 0: feed_summary_parts.append( f'{today_bottle_ml} ml ({today_bottle_count}x Flasche)' ) if today_solid_count > 0: feed_summary_parts.append( f'{today_solid_g} g ({today_solid_count}x Brei)' ) feed_summary_html = " · ".join(feed_summary_parts) if feed_summary_parts else '—' feeding_section_html = f"""
🍼🥣

Ernährung heute

{feed_summary_html}
{' '.join(feeding_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)}
""" # Growth Measurements & WHO Percentiles growth_records = con.execute( """ SELECT id, log_date, local_time, weight_kg, height_cm, head_circ_cm, note FROM v_growth WHERE subject = 'mila' ORDER BY start ASC """ ).fetchall() growth_measurements = [] mila_weight_points = [] mila_height_points = [] growth_table_rows = [] for row in growth_records: r_id, r_date, r_time, r_weight, r_height, r_hc, r_note = row d_obj = datetime.date.fromisoformat(str(r_date)) age_days = (d_obj - MILA_BIRTHDATE).days age_months = max(0.0, age_days / 30.4375) pw = calculate_percentile("weight", r_weight, age_months) if r_weight is not None else None ph = calculate_percentile("height", r_height, age_months) if r_height is not None else None meas_dict = { "id": r_id, "date_str": str(r_date), "date_pretty": d_obj.strftime("%d.%m.%Y"), "age_str": calculate_age_str(d_obj), "age_months": round(age_months, 1), "weight_kg": r_weight, "weight_pct": pw["percentile_str"] if pw else "—", "height_cm": r_height, "height_pct": ph["percentile_str"] if ph else "—", "note": r_note or "", } growth_measurements.append(meas_dict) if r_weight is not None: mila_weight_points.append({ "x": round(age_months, 2), "y": round(r_weight, 2), "date": d_obj.strftime("%d.%m.%Y"), "pct": pw["percentile_str"] if pw else "", }) if r_height is not None: mila_height_points.append({ "x": round(age_months, 2), "y": round(r_height, 1), "date": d_obj.strftime("%d.%m.%Y"), "pct": ph["percentile_str"] if ph else "", }) for m in reversed(growth_measurements): w_val = f"{m['weight_kg']:.2f} kg" if m['weight_kg'] is not None else "—" w_badge = f'{m["weight_pct"]}' if m['weight_kg'] is not None else "" h_val = f"{m['height_cm']:.1f} cm" if m['height_cm'] is not None else "—" h_badge = f'{m["height_pct"]}' if m['height_cm'] is not None else "" note_display = f'{m["note"]}' if m["note"] else '—' growth_table_rows.append(f""" {m['date_pretty']} {m['age_str']} {w_val} {w_badge} {h_val} {h_badge} {note_display} """) growth_table_html = "".join(growth_table_rows) if growth_table_rows else """ Bisher noch keine manuellen Messwerte eingetragen. Messungen können per Telegram oder CLI erfasst werden. """ if growth_measurements: latest_meas = growth_measurements[-1] w_part = f"{latest_meas['weight_kg']:.2f} kg ({latest_meas['weight_pct']})" if latest_meas["weight_kg"] is not None else "" h_part = f"{latest_meas['height_cm']:.1f} cm ({latest_meas['height_pct']})" if latest_meas["height_cm"] is not None else "" both_part = " · ".join(filter(None, [w_part, h_part])) growth_summary_pill = f"Letzte Messung: {both_part} ({latest_meas['date_pretty']})" else: growth_summary_pill = "Noch keine Messungen erfasst" who_w_p3 = [{"x": r["month"], "y": r["p3"]} for r in WHO_GIRLS_WEIGHT] who_w_p50 = [{"x": r["month"], "y": r["p50"]} for r in WHO_GIRLS_WEIGHT] who_w_p97 = [{"x": r["month"], "y": r["p97"]} for r in WHO_GIRLS_WEIGHT] who_h_p3 = [{"x": r["month"], "y": r["p3"]} for r in WHO_GIRLS_HEIGHT] who_h_p50 = [{"x": r["month"], "y": r["p50"]} for r in WHO_GIRLS_HEIGHT] who_h_p97 = [{"x": r["month"], "y": r["p97"]} for r in WHO_GIRLS_HEIGHT] chart_payload_json = json.dumps({ "who_weight": {"p3": who_w_p3, "p50": who_w_p50, "p97": who_w_p97}, "who_height": {"p3": who_h_p3, "p50": who_h_p50, "p97": who_h_p97}, "mila_weight": mila_weight_points, "mila_height": mila_height_points, }) growth_section_html = f"""
{growth_summary_pill}
Mila WHO Median (P50) P3–P97 Normkorridor (94%)

📋 Erfasste Messungen

{len(growth_measurements)} Messung(en)
{growth_table_html}
Datum Alter Gewicht Körpergröße Notiz / Anlass
""" # 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": "/icons/mila-icon-192-v3.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { "src": "/icons/mila-icon-192-v3.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { "src": "/icons/mila-icon-512-v3.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { "src": "/icons/mila-icon-512-v3.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }, { "src": "/icons/mila-apple-touch-icon-v3.png", "sizes": "180x180", "type": "image/png" }, { "src": "/icons/mila-icon.svg", "sizes": "any", "type": "image/svg+xml" } ] } # Full HTML Document html = f""" Mila — Schlaf & Entwicklung
Mila

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 Ernährung (Milch & Brei) Windeln
{growth_section_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-apple-touch-icon-v3.png", "image/png", "no-cache, must-revalidate, max-age=0"), ("mila-icon-192-v3.png", "image/png", "no-cache, must-revalidate, max-age=0"), ("mila-icon-512-v3.png", "image/png", "no-cache, must-revalidate, max-age=0"), ("mila-icon-32-v3.png", "image/png", "no-cache, must-revalidate, max-age=0"), ("mila-icon-16-v3.png", "image/png", "no-cache, must-revalidate, max-age=0"), ("mila-apple-touch-icon.png", "image/png", "no-cache, must-revalidate, max-age=0"), ("mila-icon-192.png", "image/png", "no-cache, must-revalidate, max-age=0"), ("mila-icon-512.png", "image/png", "no-cache, must-revalidate, max-age=0"), ("mila-icon-32.png", "image/png", "no-cache, must-revalidate, max-age=0"), ("mila-icon-16.png", "image/png", "no-cache, must-revalidate, max-age=0"), ("favicon.ico", "image/x-icon", "no-cache, must-revalidate, max-age=0"), ("mila-icon.svg", "image/svg+xml", "no-cache, must-revalidate, max-age=0"), ] for fname, ctype, cache_ctrl in static_assets: candidates = [assets_dir / fname] if data_dir: candidates.append(data_dir.parent / "public" / "family" / fname) candidates.append(data_dir.parent / "public" / "icons" / fname) body_bytes = None for candidate in candidates: if candidate.is_file(): body_bytes = candidate.read_bytes() break if body_bytes: # 1. Upload to family/ (for backward compatibility / scoped access) 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) # 2. Upload to public icons/ prefix (unauthenticated access for iOS SpringBoard and PWA manifests) if fname.startswith("mila-") or fname in ("favicon.ico", "mila.webmanifest"): try: s3.put_object( Bucket=DEFAULT_BUCKET, Key=f"icons/{fname}", Body=body_bytes, ContentType=ctype, CacheControl=cache_ctrl, ) except Exception as ex: print(f"Warning: could not upload icons/{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}") # Ensure public/icons directory exists and is populated locally too wiki_public_icons = data_dir.parent / "public" / "icons" wiki_public_icons.mkdir(parents=True, exist_ok=True) assets_dir = SCRIPT_DIR.parent / "assets" for asset in assets_dir.glob("mila-*"): dest = wiki_public_icons / asset.name if not dest.exists() or dest.stat().st_size != asset.stat().st_size: dest.write_bytes(asset.read_bytes()) for extra in ["favicon.ico", "mila.webmanifest"]: src = assets_dir / extra if src.is_file(): dest = wiki_public_icons / extra if not dest.exists() or dest.stat().st_size != src.stat().st_size: dest.write_bytes(src.read_bytes()) # 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()