1503 lines
64 KiB
Python
1503 lines
64 KiB
Python
#!/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 (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>
|
||
"""
|
||
|
||
# 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'<span class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-pink-500/15 border border-pink-500/30 text-pink-300 font-mono font-medium">{m["weight_pct"]}</span>' 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'<span class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-purple-500/15 border border-purple-500/30 text-purple-300 font-mono font-medium">{m["height_pct"]}</span>' if m['height_cm'] is not None else ""
|
||
note_display = f'<span class="text-zinc-400 text-xs">{m["note"]}</span>' if m["note"] else '<span class="text-zinc-600 text-xs">—</span>'
|
||
|
||
growth_table_rows.append(f"""
|
||
<tr class="border-b border-zinc-800/50 hover:bg-zinc-800/20 transition">
|
||
<td class="py-2.5 px-4 font-mono font-medium text-zinc-200">{m['date_pretty']}</td>
|
||
<td class="py-2.5 px-4 text-zinc-400">{m['age_str']}</td>
|
||
<td class="py-2.5 px-4 text-pink-300 font-medium font-mono">{w_val} {w_badge}</td>
|
||
<td class="py-2.5 px-4 text-purple-300 font-medium font-mono">{h_val} {h_badge}</td>
|
||
<td class="py-2.5 px-4">{note_display}</td>
|
||
</tr>
|
||
""")
|
||
|
||
growth_table_html = "".join(growth_table_rows) if growth_table_rows else """
|
||
<tr>
|
||
<td colspan="5" class="py-6 px-4 text-center text-zinc-500 text-xs">
|
||
Bisher noch keine manuellen Messwerte eingetragen. Messungen können per Telegram oder CLI erfasst werden.
|
||
</td>
|
||
</tr>
|
||
"""
|
||
|
||
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 & WHO PERCENTILES (COLLAPSIBLE CARD) -->
|
||
<section
|
||
x-data="{{ growthOpen: false, growthTab: 'weight' }}"
|
||
class="bg-zinc-900/80 border border-zinc-800/80 rounded-2xl shadow-xl overflow-hidden transition"
|
||
>
|
||
<!-- Collapsible Header Banner -->
|
||
<button
|
||
type="button"
|
||
@click="growthOpen = !growthOpen; if(growthOpen) {{ $nextTick(() => {{ window.initGrowthCharts && window.initGrowthCharts(); }}); }}"
|
||
class="w-full p-5 flex items-center justify-between text-left hover:bg-zinc-800/30 transition focus:outline-none"
|
||
>
|
||
<div class="flex items-center gap-3">
|
||
<div class="w-9 h-9 rounded-xl bg-pink-500/10 border border-pink-500/20 flex items-center justify-center text-pink-400 text-lg">
|
||
📏
|
||
</div>
|
||
<div>
|
||
<div class="flex items-center gap-2">
|
||
<h2 class="text-base font-bold text-zinc-100">Körpergröße & Gewicht (WHO-Perzentilen)</h2>
|
||
<span class="text-[11px] px-2 py-0.5 rounded-full bg-zinc-800 border border-zinc-700/60 text-zinc-400 font-mono">
|
||
0–24 Monate
|
||
</span>
|
||
</div>
|
||
<p class="text-xs text-zinc-400 mt-0.5">
|
||
Vergleich mit den offiziellen WHO-Standards für Mädchen (P3, P50 Median, P97)
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-3">
|
||
<span class="hidden sm:inline-flex text-xs px-2.5 py-1 rounded-lg bg-zinc-800/80 border border-zinc-700/60 text-zinc-300 font-mono">
|
||
{growth_summary_pill}
|
||
</span>
|
||
<div
|
||
class="w-7 h-7 rounded-lg bg-zinc-800/60 flex items-center justify-center text-zinc-400 transition-transform duration-200"
|
||
:class="{{ 'rotate-180': growthOpen }}"
|
||
>
|
||
<i data-lucide="chevron-down" class="w-4 h-4"></i>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
|
||
<!-- Expandable Content -->
|
||
<div x-show="growthOpen" x-cloak class="p-5 pt-0 border-t border-zinc-800/60 space-y-5">
|
||
<!-- Subheader Pill on Mobile -->
|
||
<div class="sm:hidden pt-3">
|
||
<span class="inline-block text-xs px-2.5 py-1 rounded-lg bg-zinc-800/80 border border-zinc-700/60 text-zinc-300 font-mono">
|
||
{growth_summary_pill}
|
||
</span>
|
||
</div>
|
||
|
||
<!-- Controls: Tab Selector & Legend Info -->
|
||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-4">
|
||
<!-- Toggle: Gewicht vs Körpergröße -->
|
||
<div class="inline-flex p-1 rounded-xl bg-zinc-950/80 border border-zinc-800 self-start">
|
||
<button
|
||
type="button"
|
||
@click="growthTab = 'weight'; $nextTick(() => {{ window.updateGrowthChartTab && window.updateGrowthChartTab('weight'); }});"
|
||
:class="growthTab === 'weight' ? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white shadow-md font-semibold' : 'text-zinc-400 hover:text-white'"
|
||
class="px-4 py-1.5 rounded-lg text-xs transition"
|
||
>
|
||
⚖️ Gewicht (kg)
|
||
</button>
|
||
<button
|
||
type="button"
|
||
@click="growthTab = 'height'; $nextTick(() => {{ window.updateGrowthChartTab && window.updateGrowthChartTab('height'); }});"
|
||
:class="growthTab === 'height' ? 'bg-gradient-to-r from-pink-500 to-purple-600 text-white shadow-md font-semibold' : 'text-zinc-400 hover:text-white'"
|
||
class="px-4 py-1.5 rounded-lg text-xs transition"
|
||
>
|
||
📏 Körpergröße (cm)
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Explanation Legend -->
|
||
<div class="flex items-center gap-3 text-[11px] text-zinc-400 flex-wrap">
|
||
<span class="inline-flex items-center gap-1.5">
|
||
<span class="w-2.5 h-2.5 rounded-full bg-pink-500"></span> Mila
|
||
</span>
|
||
<span class="inline-flex items-center gap-1.5">
|
||
<span class="w-3 h-0.5 bg-purple-400"></span> WHO Median (P50)
|
||
</span>
|
||
<span class="inline-flex items-center gap-1.5">
|
||
<span class="w-3 h-0.5 bg-zinc-500 border-b border-dashed"></span> P3–P97 Normkorridor (94%)
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Chart Canvas Container -->
|
||
<div class="relative w-full h-72 sm:h-80 bg-zinc-950/50 rounded-2xl p-3 border border-zinc-800/60">
|
||
<canvas id="growthChart"></canvas>
|
||
</div>
|
||
|
||
<!-- Measurement History Table -->
|
||
<div class="space-y-2 pt-2">
|
||
<div class="flex items-center justify-between">
|
||
<h3 class="text-xs font-bold uppercase tracking-wider text-zinc-400 flex items-center gap-1.5">
|
||
<span>📋</span>
|
||
<span>Erfasste Messungen</span>
|
||
</h3>
|
||
<span class="text-[11px] text-zinc-500 font-mono">{len(growth_measurements)} Messung(en)</span>
|
||
</div>
|
||
|
||
<div class="overflow-x-auto -mx-5 px-5">
|
||
<table class="w-full text-left text-xs whitespace-nowrap">
|
||
<thead class="text-[10px] uppercase bg-zinc-800/50 text-zinc-400 font-mono">
|
||
<tr>
|
||
<th class="py-2 px-4 rounded-l-lg">Datum</th>
|
||
<th class="py-2 px-4">Alter</th>
|
||
<th class="py-2 px-4">Gewicht</th>
|
||
<th class="py-2 px-4">Körpergröße</th>
|
||
<th class="py-2 px-4 rounded-r-lg">Notiz / Anlass</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{growth_table_html}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</section>
|
||
"""
|
||
|
||
# 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"""<!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="/icons/mila.webmanifest">
|
||
<link rel="apple-touch-icon" href="/icons/mila-apple-touch-icon-v3.png">
|
||
<link rel="apple-touch-icon" sizes="180x180" href="/icons/mila-apple-touch-icon-v3.png">
|
||
<link rel="icon" type="image/png" sizes="192x192" href="/icons/mila-icon-192-v3.png">
|
||
<link rel="icon" type="image/png" sizes="512x512" href="/icons/mila-icon-512-v3.png">
|
||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/mila-icon-32-v3.png">
|
||
<link rel="icon" type="image/png" sizes="16x16" href="/icons/mila-icon-16-v3.png">
|
||
<link rel="shortcut icon" href="/icons/favicon.ico">
|
||
<link rel="icon" href="/icons/favicon.ico">
|
||
<link rel="icon" type="image/svg+xml" href="/icons/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>
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></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="/icons/mila-icon-192-v3.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="/icons/mila-icon-192-v3.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>
|
||
|
||
{growth_section_html}
|
||
|
||
</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();
|
||
}});
|
||
|
||
// ==============================================================================
|
||
// Growth Chart (Chart.js + WHO Percentiles)
|
||
// ==============================================================================
|
||
const growthData = {chart_payload_json};
|
||
let growthChartInstance = null;
|
||
let currentGrowthMetric = 'weight';
|
||
|
||
window.initGrowthCharts = function() {{
|
||
const ctx = document.getElementById('growthChart');
|
||
if (!ctx) return;
|
||
if (growthChartInstance) {{
|
||
growthChartInstance.resize();
|
||
return;
|
||
}}
|
||
window.renderGrowthChart(currentGrowthMetric);
|
||
}};
|
||
|
||
window.updateGrowthChartTab = function(metric) {{
|
||
currentGrowthMetric = metric;
|
||
window.renderGrowthChart(metric);
|
||
}};
|
||
|
||
window.renderGrowthChart = function(metric) {{
|
||
const ctx = document.getElementById('growthChart');
|
||
if (!ctx) return;
|
||
|
||
if (growthChartInstance) {{
|
||
growthChartInstance.destroy();
|
||
}}
|
||
|
||
const isWeight = (metric === 'weight');
|
||
const whoObj = isWeight ? growthData.who_weight : growthData.who_height;
|
||
const milaPoints = isWeight ? growthData.mila_weight : growthData.mila_height;
|
||
const yUnit = isWeight ? 'kg' : 'cm';
|
||
const yLabel = isWeight ? 'Gewicht (kg)' : 'Körpergröße (cm)';
|
||
|
||
growthChartInstance = new Chart(ctx, {{
|
||
type: 'line',
|
||
data: {{
|
||
datasets: [
|
||
{{
|
||
label: 'WHO P97 (Obere Grenze)',
|
||
data: whoObj.p97,
|
||
borderColor: 'rgba(244, 63, 94, 0.45)',
|
||
borderWidth: 1.5,
|
||
borderDash: [4, 4],
|
||
pointRadius: 0,
|
||
pointHoverRadius: 0,
|
||
fill: false,
|
||
tension: 0.3,
|
||
}},
|
||
{{
|
||
label: 'WHO P3 (Untere Grenze)',
|
||
data: whoObj.p3,
|
||
borderColor: 'rgba(56, 189, 248, 0.45)',
|
||
borderWidth: 1.5,
|
||
borderDash: [4, 4],
|
||
pointRadius: 0,
|
||
pointHoverRadius: 0,
|
||
fill: {{
|
||
target: 0,
|
||
above: 'rgba(168, 85, 247, 0.08)',
|
||
}},
|
||
tension: 0.3,
|
||
}},
|
||
{{
|
||
label: 'WHO P50 (Median)',
|
||
data: whoObj.p50,
|
||
borderColor: 'rgba(168, 85, 247, 0.85)',
|
||
borderWidth: 2.5,
|
||
pointRadius: 0,
|
||
pointHoverRadius: 0,
|
||
fill: false,
|
||
tension: 0.3,
|
||
}},
|
||
{{
|
||
label: 'Mila',
|
||
data: milaPoints,
|
||
borderColor: '#EC4899',
|
||
backgroundColor: '#EC4899',
|
||
borderWidth: 2.5,
|
||
pointRadius: 6,
|
||
pointHoverRadius: 8,
|
||
pointBackgroundColor: '#EC4899',
|
||
pointBorderColor: '#FFFFFF',
|
||
pointBorderWidth: 2,
|
||
showLine: true,
|
||
tension: 0.15,
|
||
}}
|
||
]
|
||
}},
|
||
options: {{
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
animation: {{ duration: 350 }},
|
||
interaction: {{ mode: 'nearest', intersect: false }},
|
||
plugins: {{
|
||
legend: {{ display: false }},
|
||
tooltip: {{
|
||
backgroundColor: 'rgba(24, 24, 27, 0.95)',
|
||
borderColor: 'rgba(63, 63, 70, 0.8)',
|
||
borderWidth: 1,
|
||
titleColor: '#F4F4F5',
|
||
bodyColor: '#D4D4D8',
|
||
padding: 10,
|
||
displayColors: true,
|
||
callbacks: {{
|
||
title: function(items) {{
|
||
if (!items.length) return '';
|
||
return 'Alter: ' + items[0].parsed.x + ' Monate';
|
||
}},
|
||
label: function(context) {{
|
||
const dsLabel = context.dataset.label || '';
|
||
const yVal = context.parsed.y;
|
||
if (dsLabel === 'Mila') {{
|
||
const raw = context.raw || {{}};
|
||
const dateStr = raw.date ? ' am ' + raw.date : '';
|
||
const pctStr = raw.pct ? ' (' + raw.pct + ')' : '';
|
||
return '👶 Mila: ' + yVal + ' ' + yUnit + pctStr + dateStr;
|
||
}}
|
||
return dsLabel + ': ' + yVal + ' ' + yUnit;
|
||
}}
|
||
}}
|
||
}}
|
||
}},
|
||
scales: {{
|
||
x: {{
|
||
type: 'linear',
|
||
min: 0,
|
||
max: 24,
|
||
title: {{
|
||
display: true,
|
||
text: 'Alter in Monaten',
|
||
color: '#71717A',
|
||
font: {{ size: 11, family: 'JetBrains Mono' }}
|
||
}},
|
||
grid: {{ color: 'rgba(255, 255, 255, 0.05)' }},
|
||
ticks: {{
|
||
color: '#A1A1AA',
|
||
stepSize: 2,
|
||
callback: function(val) {{ return val + 'M'; }},
|
||
font: {{ family: 'JetBrains Mono', size: 10 }}
|
||
}}
|
||
}},
|
||
y: {{
|
||
title: {{
|
||
display: true,
|
||
text: yLabel,
|
||
color: '#71717A',
|
||
font: {{ size: 11, family: 'JetBrains Mono' }}
|
||
}},
|
||
grid: {{ color: 'rgba(255, 255, 255, 0.05)' }},
|
||
ticks: {{
|
||
color: '#A1A1AA',
|
||
font: {{ family: 'JetBrains Mono', size: 10 }}
|
||
}}
|
||
}}
|
||
}}
|
||
}}
|
||
}});
|
||
}};
|
||
|
||
// 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-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()
|