1021 lines
44 KiB
Python
1021 lines
44 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
|
||
|
||
BERLIN_TZ = ZoneInfo("Europe/Berlin")
|
||
MILA_BIRTHDATE = datetime.date(2026, 3, 14)
|
||
|
||
WEEKDAYS_DE = {
|
||
0: "Mo",
|
||
1: "Di",
|
||
2: "Mi",
|
||
3: "Do",
|
||
4: "Fr",
|
||
5: "Sa",
|
||
6: "So",
|
||
}
|
||
|
||
WEEKDAYS_LONG_DE = {
|
||
0: "Montag",
|
||
1: "Dienstag",
|
||
2: "Mittwoch",
|
||
3: "Donnerstag",
|
||
4: "Freitag",
|
||
5: "Samstag",
|
||
6: "Sonntag",
|
||
}
|
||
|
||
DEFAULT_ENDPOINT = os.environ.get("MINIO_ENDPOINT", "http://minio.minio.svc.cluster.local:9000")
|
||
DEFAULT_ACCESS_KEY = os.environ.get("MINIO_ACCESS_KEY", "openclaw")
|
||
DEFAULT_SECRET_KEY = os.environ.get("MINIO_SECRET_KEY", "5uu23t69xC2Oigf8Bky0KTlI")
|
||
DEFAULT_BUCKET = os.environ.get("MINIO_BUCKET", "hosting")
|
||
DEFAULT_BASE_URL = os.environ.get("HOSTING_BASE_URL", "https://hosting.haumdaucher.de").rstrip("/")
|
||
|
||
|
||
def calculate_age_str(as_of: datetime.date) -> str:
|
||
"""Calculates age in months and weeks from Mila's birthdate (14.03.2026)."""
|
||
days = (as_of - MILA_BIRTHDATE).days
|
||
if days < 0:
|
||
return "Geboren am 14.03.2026"
|
||
months = days // 30.4375
|
||
weeks = (days % 30.4375) // 7
|
||
if months < 1:
|
||
return f"{days // 7} Wochen alt"
|
||
if weeks > 0:
|
||
return f"{int(months)} Monate, {int(weeks)} Wochen alt"
|
||
return f"{int(months)} Monate alt"
|
||
|
||
|
||
def get_s3_client():
|
||
import boto3
|
||
from botocore.client import Config
|
||
|
||
return boto3.client(
|
||
"s3",
|
||
endpoint_url=DEFAULT_ENDPOINT,
|
||
aws_access_key_id=DEFAULT_ACCESS_KEY,
|
||
aws_secret_access_key=DEFAULT_SECRET_KEY,
|
||
config=Config(signature_version="s3v4", connect_timeout=5, retries={"max_attempts": 2}),
|
||
region_name="us-east-1",
|
||
)
|
||
|
||
|
||
def generate_dashboard_html(engine: DatastoreEngine) -> str:
|
||
con = engine.con
|
||
now_berlin = datetime.datetime.now(BERLIN_TZ)
|
||
today_date = now_berlin.date()
|
||
today_str = today_date.strftime("%Y-%m-%d")
|
||
today_pretty = f"{WEEKDAYS_LONG_DE[today_date.weekday()]}, {today_date.strftime('%d.%m.%Y')}"
|
||
age_str = calculate_age_str(today_date)
|
||
|
||
# 1. Active open sleep check
|
||
open_sleep = con.execute(
|
||
"""
|
||
SELECT id, start, local_start, sleep_type, location, note,
|
||
date_diff('minute', start, CURRENT_TIMESTAMP) AS duration_mins
|
||
FROM v_sleep
|
||
WHERE subject = 'mila' AND is_open = true
|
||
ORDER BY start DESC LIMIT 1
|
||
"""
|
||
).fetchone()
|
||
|
||
is_sleeping = open_sleep is not None
|
||
active_sleep_duration_str = ""
|
||
active_sleep_start_time = ""
|
||
active_sleep_iso = ""
|
||
wake_window_str = ""
|
||
last_wake_time_str = ""
|
||
last_wake_iso = ""
|
||
|
||
if is_sleeping:
|
||
dur = max(0, open_sleep[6] or 0)
|
||
h, m = divmod(dur, 60)
|
||
active_sleep_duration_str = f"{h} h {m:02d} min" if h > 0 else f"{m} min"
|
||
s_dt = datetime.datetime.fromisoformat(str(open_sleep[2]))
|
||
active_sleep_start_time = s_dt.strftime("%H:%M")
|
||
active_sleep_iso = s_dt.isoformat()
|
||
else:
|
||
# Calculate current wake window from last closed sleep session
|
||
last_closed = con.execute(
|
||
"""
|
||
SELECT "end", local_end, date_diff('minute', "end", CURRENT_TIMESTAMP) AS mins_ago
|
||
FROM v_sleep
|
||
WHERE subject = 'mila' AND is_open = false AND "end" IS NOT NULL
|
||
ORDER BY "end" DESC LIMIT 1
|
||
"""
|
||
).fetchone()
|
||
if last_closed:
|
||
mins_ago = max(0, last_closed[2] or 0)
|
||
h, m = divmod(mins_ago, 60)
|
||
wake_window_str = f"{h} h {m:02d} min" if h > 0 else f"{m} min"
|
||
e_dt = datetime.datetime.fromisoformat(str(last_closed[1]))
|
||
last_wake_time_str = e_dt.strftime("%H:%M")
|
||
last_wake_iso = e_dt.isoformat()
|
||
|
||
# 2. Today's sleep details
|
||
today_sessions = con.execute(
|
||
f"""
|
||
SELECT id, strftime(local_start, '%H:%M') AS start_fmt,
|
||
CASE WHEN is_open THEN NULL ELSE strftime(local_end, '%H:%M') END AS end_fmt,
|
||
sleep_type, location, note, duration_minutes, is_open
|
||
FROM v_sleep
|
||
WHERE subject = 'mila' AND (
|
||
log_date = '{today_str}'
|
||
OR (sleep_type = 'night' AND CAST(local_end AS DATE) = '{today_str}')
|
||
)
|
||
ORDER BY start ASC
|
||
"""
|
||
).fetchall()
|
||
|
||
today_naps = []
|
||
today_nights = []
|
||
for s in today_sessions:
|
||
if s[3] == "night":
|
||
today_nights.append(s)
|
||
else:
|
||
today_naps.append(s)
|
||
|
||
# Today daily aggregates
|
||
today_agg = con.execute(
|
||
f"""
|
||
SELECT total_sleep_hours, nap_count, night_sleep_minutes, nap_sleep_minutes
|
||
FROM v_sleep_daily
|
||
WHERE subject = 'mila' AND log_date = '{today_str}'
|
||
"""
|
||
).fetchone()
|
||
|
||
# Prefer the completed night sleep that ended this morning/today for the top card
|
||
morning_night = next((s for s in today_nights if not s[7] and s[6] is not None), None)
|
||
if morning_night and morning_night[6]:
|
||
today_night_min = morning_night[6]
|
||
else:
|
||
today_night_min = today_agg[2] if today_agg else 0
|
||
|
||
today_nap_count = today_agg[1] if today_agg else len(today_naps)
|
||
today_nap_min = sum(n[6] or 0 for n in today_naps)
|
||
|
||
today_tot_min = today_night_min + today_nap_min
|
||
today_tot_hours = f"{today_tot_min / 60.0:.2f} h" if today_tot_min > 0 else "0 h"
|
||
|
||
today_night_h, today_night_m = divmod(today_night_min, 60)
|
||
today_night_dur_str = f"{today_night_h} h {today_night_m:02d} min" if today_night_h > 0 else f"{today_night_m} min"
|
||
|
||
# 3. Today's Feedings (conditional)
|
||
today_feedings = con.execute(
|
||
f"""
|
||
SELECT strftime(local_time, '%H:%M'), consumed_ml, milk_type, note
|
||
FROM v_feeding
|
||
WHERE subject = 'mila' AND log_date = '{today_str}'
|
||
ORDER BY start ASC
|
||
"""
|
||
).fetchall()
|
||
|
||
today_feeding_agg = con.execute(
|
||
f"""
|
||
SELECT bottle_count, total_consumed_ml
|
||
FROM v_feeding_daily
|
||
WHERE subject = 'mila' AND log_date = '{today_str}'
|
||
"""
|
||
).fetchone()
|
||
|
||
has_feeding_today = bool(today_feedings and len(today_feedings) > 0)
|
||
today_bottle_count = today_feeding_agg[0] if today_feeding_agg else 0
|
||
today_bottle_ml = int(today_feeding_agg[1]) if today_feeding_agg else 0
|
||
|
||
# 4. Today's Diapers (conditional)
|
||
today_diapers = con.execute(
|
||
f"""
|
||
SELECT strftime(local_time, '%H:%M'), pee, poo, texture, note
|
||
FROM v_diaper
|
||
WHERE subject = 'mila' AND log_date = '{today_str}'
|
||
ORDER BY start ASC
|
||
"""
|
||
).fetchall()
|
||
|
||
today_diaper_agg = con.execute(
|
||
f"""
|
||
SELECT total_changes, wet_count, dirty_count
|
||
FROM v_diaper_daily
|
||
WHERE subject = 'mila' AND log_date = '{today_str}'
|
||
"""
|
||
).fetchone()
|
||
|
||
has_diapers_today = bool(today_diapers and len(today_diapers) > 0)
|
||
today_diaper_count = today_diaper_agg[0] if today_diaper_agg else 0
|
||
today_wet_count = today_diaper_agg[1] if today_diaper_agg else 0
|
||
today_dirty_count = today_diaper_agg[2] if today_diaper_agg else 0
|
||
|
||
# 5. 7-Day and 30-Day Sleep Metrics
|
||
avg_7 = con.execute(
|
||
"""
|
||
SELECT ROUND(AVG(total_sleep_hours), 2), ROUND(AVG(night_sleep_minutes)/60.0, 2), ROUND(AVG(nap_sleep_minutes)/60.0, 2), ROUND(AVG(nap_count), 1)
|
||
FROM (SELECT total_sleep_hours, night_sleep_minutes, nap_sleep_minutes, nap_count FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7)
|
||
"""
|
||
).fetchone()
|
||
avg_7_tot = f"{avg_7[0]:.2f} h" if avg_7 and avg_7[0] is not None else "—"
|
||
avg_7_night = f"{avg_7[1]:.2f} h" if avg_7 and avg_7[1] is not None else "—"
|
||
avg_7_naps = f"{avg_7[3]} Naps ({avg_7[2]:.2f} h)" if avg_7 and avg_7[3] is not None else "—"
|
||
|
||
avg_30 = con.execute(
|
||
"""
|
||
SELECT ROUND(AVG(total_sleep_hours), 2)
|
||
FROM (SELECT total_sleep_hours FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 30)
|
||
"""
|
||
).fetchone()
|
||
avg_30_tot = f"{avg_30[0]:.2f} h" if avg_30 and avg_30[0] is not None else "—"
|
||
|
||
# 6. Past 30 Days History Table
|
||
history_days = con.execute(
|
||
"""
|
||
SELECT
|
||
s.log_date,
|
||
s.total_sleep_hours,
|
||
s.night_sleep_minutes,
|
||
s.nap_count,
|
||
s.nap_sleep_minutes,
|
||
COALESCE(f.total_consumed_ml, 0) AS feed_ml,
|
||
COALESCE(f.bottle_count, 0) AS feed_bottles,
|
||
COALESCE(d.total_changes, 0) AS diaper_cnt,
|
||
COALESCE(d.dirty_count, 0) AS diaper_dirty
|
||
FROM v_sleep_daily s
|
||
LEFT JOIN v_feeding_daily f ON s.log_date = f.log_date AND s.subject = f.subject
|
||
LEFT JOIN v_diaper_daily d ON s.log_date = d.log_date AND s.subject = d.subject
|
||
WHERE s.subject = 'mila'
|
||
ORDER BY s.log_date DESC
|
||
LIMIT 30
|
||
"""
|
||
).fetchall()
|
||
|
||
# Pre-fetch individual sessions for the last 30 days for expandable details
|
||
sessions_by_date = {}
|
||
all_recent_sessions = con.execute(
|
||
"""
|
||
SELECT log_date, strftime(local_start, '%H:%M'),
|
||
CASE WHEN is_open THEN NULL ELSE strftime(local_end, '%H:%M') END,
|
||
sleep_type, duration_minutes, is_open, note
|
||
FROM v_sleep
|
||
WHERE subject = 'mila' AND log_date >= (CURRENT_DATE - INTERVAL 30 DAY)
|
||
ORDER BY start ASC
|
||
"""
|
||
).fetchall()
|
||
for row in all_recent_sessions:
|
||
d_key = str(row[0])
|
||
if d_key not in sessions_by_date:
|
||
sessions_by_date[d_key] = []
|
||
sessions_by_date[d_key].append(row)
|
||
|
||
# Build history rows HTML
|
||
history_rows = []
|
||
for r in history_days:
|
||
d_str = str(r[0])
|
||
d_obj = datetime.date.fromisoformat(d_str)
|
||
wkday = WEEKDAYS_DE[d_obj.weekday()]
|
||
tot_h = f"{r[1]:.2f} h"
|
||
night_h, night_m = divmod(r[2], 60)
|
||
night_str = f"{night_h} h {night_m:02d} min" if night_h > 0 else f"{night_m} min"
|
||
nap_str = f"{r[3]} ({r[4]} min)"
|
||
feed_str = f"{int(r[5])} ml ({r[6]}x)" if r[6] > 0 else "—"
|
||
diaper_str = f"{r[7]} ({r[8]} Stuhl)" if r[7] > 0 else "—"
|
||
|
||
# Detailed breakdown of sessions for this day
|
||
day_sess = sessions_by_date.get(d_str, [])
|
||
sess_badges = []
|
||
for s in day_sess:
|
||
s_st = s[1]
|
||
s_et = s[2] or "läuft"
|
||
s_dur = f"{s[4]}m" if s[4] else ""
|
||
badge_color = "bg-purple-950/60 text-purple-300 border-purple-800/40" if s[3] == "night" else "bg-emerald-950/60 text-emerald-300 border-emerald-800/40"
|
||
sess_badges.append(
|
||
f'<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border {badge_color}">{s_st}–{s_et} ({s_dur})</span>'
|
||
)
|
||
sess_badges_html = " ".join(sess_badges) if sess_badges else '<span class="text-zinc-500 text-xs">—</span>'
|
||
|
||
is_today_row = (d_str == today_str)
|
||
row_bg = "bg-purple-950/20" if is_today_row else "hover:bg-zinc-900/40"
|
||
|
||
history_rows.append(f"""
|
||
<tr class="border-b border-zinc-800/60 {row_bg} transition">
|
||
<td class="py-3.5 px-4 font-mono font-medium text-zinc-200">
|
||
{wkday}, {d_obj.strftime('%d.%m.')}
|
||
{' <span class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-300 border border-purple-500/30">Heute</span>' if is_today_row else ''}
|
||
</td>
|
||
<td class="py-3.5 px-4 font-bold text-purple-300">{tot_h}</td>
|
||
<td class="py-3.5 px-4 text-sky-400 font-semibold">{night_str}</td>
|
||
<td class="py-3.5 px-4 text-emerald-400">{nap_str}</td>
|
||
<td class="py-3.5 px-4 text-zinc-400">{feed_str}</td>
|
||
<td class="py-3.5 px-4 text-zinc-400">{diaper_str}</td>
|
||
</tr>
|
||
<tr class="border-b border-zinc-800/30 bg-zinc-950/30 text-xs">
|
||
<td colspan="6" class="py-1.5 px-4 text-zinc-400 space-x-1">
|
||
<span class="text-zinc-500 font-mono text-[10px] uppercase">Einzelschläfchen:</span> {sess_badges_html}
|
||
</td>
|
||
</tr>
|
||
""")
|
||
|
||
# Render today's naps cards HTML
|
||
today_naps_html = []
|
||
if today_naps:
|
||
for idx, n in enumerate(today_naps, 1):
|
||
n_st = n[1]
|
||
n_et = n[2] or "läuft gerade"
|
||
n_dur = f"{n[6]} min" if n[6] is not None else "in Ausführung"
|
||
n_loc = f"({n[4]})" if n[4] else ""
|
||
n_note = f"· {n[5]}" if n[5] else ""
|
||
running_pulse = '<span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>' if n[7] else ''
|
||
|
||
today_naps_html.append(f"""
|
||
<div class="flex items-center justify-between p-3 rounded-xl bg-zinc-900/60 border border-zinc-800/60 hover:border-emerald-500/30 transition">
|
||
<div class="flex items-center gap-3">
|
||
<div class="w-7 h-7 rounded-lg bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400 font-mono text-xs font-bold">
|
||
#{idx}
|
||
</div>
|
||
<div>
|
||
<div class="text-sm font-semibold text-zinc-200 flex items-center gap-2">
|
||
<span>{n_st} – {n_et} Uhr</span>
|
||
{running_pulse}
|
||
</div>
|
||
<div class="text-xs text-zinc-400">{n_loc} {n_note}</div>
|
||
</div>
|
||
</div>
|
||
<div class="text-right">
|
||
<span class="text-sm font-bold text-emerald-400 font-mono">{n_dur}</span>
|
||
</div>
|
||
</div>
|
||
""")
|
||
else:
|
||
today_naps_html.append("""
|
||
<div class="p-4 rounded-xl bg-zinc-900/40 border border-zinc-800/40 text-center text-zinc-500 text-sm">
|
||
Heute bisher noch kein Tagesschläfchen eingetragen.
|
||
</div>
|
||
""")
|
||
|
||
# Render today's nights cards HTML
|
||
today_nights_html = []
|
||
if today_nights:
|
||
for n in today_nights:
|
||
n_st = n[1]
|
||
n_et = n[2] or "läuft"
|
||
dur_m = n[6] or 0
|
||
dh, dm = divmod(dur_m, 60)
|
||
dur_str = f"{dh} h {dm:02d} min" if dh > 0 else f"{dm} min"
|
||
dur_display = f'<span class="live-sleep-timer" data-start-iso="{active_sleep_iso}">{dur_str}</span>' if n[7] else f'<span>{dur_str}</span>'
|
||
note_str = f"· {n[5]}" if n[5] else ""
|
||
today_nights_html.append(f"""
|
||
<div class="flex items-center justify-between p-3 rounded-xl bg-zinc-900/60 border border-zinc-800/60">
|
||
<div class="flex items-center gap-3">
|
||
<div class="w-7 h-7 rounded-lg bg-sky-500/10 border border-sky-500/20 flex items-center justify-center text-sky-400 text-xs">
|
||
🌙
|
||
</div>
|
||
<div>
|
||
<div class="text-sm font-semibold text-zinc-200">{n_st} – {n_et} Uhr</div>
|
||
<div class="text-xs text-zinc-400">{note_str}</div>
|
||
</div>
|
||
</div>
|
||
<div class="text-right font-mono font-bold text-sky-400 text-sm">{dur_display}</div>
|
||
</div>
|
||
""")
|
||
else:
|
||
today_nights_html.append("""
|
||
<div class="p-3 rounded-xl bg-zinc-900/40 border border-zinc-800/40 text-center text-zinc-500 text-xs">
|
||
Kein Nachtschlaf für das Tagesdatum zugeordnet (Nachtstart zählt zum Vortag).
|
||
</div>
|
||
""")
|
||
|
||
# Conditional feeding card HTML
|
||
feeding_section_html = ""
|
||
if has_feeding_today:
|
||
bottles_pills = []
|
||
for f in today_feedings:
|
||
ml_val = f"{int(f[1])} ml" if f[1] else "—"
|
||
bottles_pills.append(f'<span class="px-2 py-1 rounded bg-amber-500/10 border border-amber-500/20 text-amber-300 text-xs">{f[0]} Uhr ({ml_val})</span>')
|
||
feeding_section_html = f"""
|
||
<div class="bg-zinc-900/80 border border-amber-500/20 rounded-2xl p-5 shadow-lg space-y-3">
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-xl">🍼</span>
|
||
<h3 class="font-semibold text-zinc-200">Fläschchen heute</h3>
|
||
</div>
|
||
<span class="font-mono font-bold text-amber-400 text-lg">{today_bottle_ml} ml <span class="text-xs text-zinc-400 font-normal">({today_bottle_count}x)</span></span>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2 pt-1">
|
||
{' '.join(bottles_pills)}
|
||
</div>
|
||
</div>
|
||
"""
|
||
|
||
# Conditional diaper card HTML
|
||
diaper_section_html = ""
|
||
if has_diapers_today:
|
||
diaper_pills = []
|
||
for d in today_diapers:
|
||
content = []
|
||
if d[1]: content.append("Pipi")
|
||
if d[2]: content.append("Kacka")
|
||
d_desc = "+".join(content) or "Wechsel"
|
||
diaper_pills.append(f'<span class="px-2 py-1 rounded bg-teal-500/10 border border-teal-500/20 text-teal-300 text-xs">{d[0]} Uhr ({d_desc})</span>')
|
||
diaper_section_html = f"""
|
||
<div class="bg-zinc-900/80 border border-teal-500/20 rounded-2xl p-5 shadow-lg space-y-3">
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-xl">🧷</span>
|
||
<h3 class="font-semibold text-zinc-200">Windeln heute</h3>
|
||
</div>
|
||
<span class="font-mono font-bold text-teal-400 text-lg">{today_diaper_count} gewechselt <span class="text-xs text-zinc-400 font-normal">({today_wet_count} nass · {today_dirty_count} Stuhl)</span></span>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2 pt-1">
|
||
{' '.join(diaper_pills)}
|
||
</div>
|
||
</div>
|
||
"""
|
||
|
||
# PWA Manifest (RFC-compliant base64 data URI + external webmanifest)
|
||
import base64
|
||
manifest_dict = {
|
||
"name": "Mila — Schlaf & Entwicklung",
|
||
"short_name": "Mila",
|
||
"description": "Schlaf-, Trink- und Windeltagebuch für Mila Graf",
|
||
"start_url": "/family/mila.html",
|
||
"scope": "/family/",
|
||
"display": "standalone",
|
||
"orientation": "portrait-primary",
|
||
"background_color": "#090A0F",
|
||
"theme_color": "#090A0F",
|
||
"icons": [
|
||
{
|
||
"src": "/family/mila-icon-192.png",
|
||
"sizes": "192x192",
|
||
"type": "image/png",
|
||
"purpose": "any maskable"
|
||
},
|
||
{
|
||
"src": "/family/mila-icon-512.png",
|
||
"sizes": "512x512",
|
||
"type": "image/png",
|
||
"purpose": "any maskable"
|
||
},
|
||
{
|
||
"src": "/family/mila-apple-touch-icon.png",
|
||
"sizes": "180x180",
|
||
"type": "image/png"
|
||
},
|
||
{
|
||
"src": "/family/mila-icon.svg",
|
||
"sizes": "any",
|
||
"type": "image/svg+xml"
|
||
}
|
||
]
|
||
}
|
||
manifest_json = json.dumps(manifest_dict)
|
||
manifest_uri = f"data:application/manifest+json;base64,{base64.b64encode(manifest_json.encode('utf-8')).decode('utf-8')}"
|
||
|
||
# Full HTML Document
|
||
html = f"""<!DOCTYPE html>
|
||
<html lang="de" class="dark" x-data="{{ activeTab: 'today' }}">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||
<meta name="build-time" content="{now_berlin.isoformat()}">
|
||
<meta name="build-epoch" content="{int(now_berlin.timestamp())}">
|
||
<title>Mila — Schlaf & Entwicklung</title>
|
||
|
||
<!-- PWA & Mobile Web Capabilities -->
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<meta name="mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
<meta name="apple-mobile-web-app-title" content="Mila">
|
||
<meta name="application-name" content="Mila">
|
||
<meta name="theme-color" content="#090A0F">
|
||
<meta name="msapplication-TileColor" content="#090A0F">
|
||
|
||
<link rel="manifest" href="/family/mila.webmanifest">
|
||
<link rel="manifest" href="{manifest_uri}">
|
||
<link rel="apple-touch-icon" href="/family/mila-apple-touch-icon.png">
|
||
<link rel="apple-touch-icon" sizes="180x180" href="/family/mila-apple-touch-icon.png">
|
||
<link rel="icon" type="image/png" sizes="192x192" href="/family/mila-icon-192.png">
|
||
<link rel="icon" type="image/png" sizes="512x512" href="/family/mila-icon-512.png">
|
||
<link rel="icon" type="image/png" sizes="32x32" href="/family/mila-icon-32.png">
|
||
<link rel="icon" type="image/png" sizes="16x16" href="/family/mila-icon-16.png">
|
||
<link rel="shortcut icon" href="/family/favicon.ico">
|
||
<link rel="icon" href="/family/favicon.ico">
|
||
<link rel="icon" type="image/svg+xml" href="/family/mila-icon.svg">
|
||
|
||
<!-- Typography & Styling -->
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Syne:wght@700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
<script>
|
||
tailwind.config = {{
|
||
darkMode: 'class',
|
||
theme: {{
|
||
extend: {{
|
||
fontFamily: {{
|
||
heading: ['Syne', 'sans-serif'],
|
||
sans: ['Plus Jakarta Sans', 'sans-serif'],
|
||
mono: ['JetBrains Mono', 'monospace'],
|
||
}}
|
||
}}
|
||
}}
|
||
}}
|
||
</script>
|
||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||
<script src="https://unpkg.com/lucide@latest"></script>
|
||
</head>
|
||
<body class="bg-[#090A0F] text-zinc-100 min-h-screen font-sans antialiased selection:bg-purple-500 selection:text-white pb-16">
|
||
|
||
<!-- PWA Install Banner (Android / Desktop Chrome) -->
|
||
<div id="pwa-install-banner" class="hidden max-w-4xl mx-auto px-4 pt-3">
|
||
<div class="flex items-center justify-between p-3 rounded-2xl bg-gradient-to-r from-purple-900/80 via-pink-900/50 to-zinc-900 border border-purple-500/40 shadow-xl backdrop-blur-md">
|
||
<div class="flex items-center gap-3">
|
||
<img src="/family/mila-icon-192.png" alt="Mila" class="w-9 h-9 rounded-xl object-cover shadow-md border border-purple-500/40">
|
||
<div>
|
||
<p class="text-xs font-bold text-white">Mila App installieren</p>
|
||
<p class="text-[11px] text-zinc-300">Direkt auf dem Homescreen nutzen</p>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<button id="pwa-install-btn" class="px-3.5 py-1.5 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-400 hover:to-pink-400 text-white text-xs font-bold shadow-lg shadow-purple-500/30 active:scale-95 transition">
|
||
Installieren
|
||
</button>
|
||
<button onclick="document.getElementById('pwa-install-banner').classList.add('hidden')" class="p-1 rounded-lg text-zinc-400 hover:text-white">
|
||
<i data-lucide="x" class="w-4 h-4"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- iOS Safari Install Guidance Banner -->
|
||
<div id="ios-install-banner" class="hidden max-w-4xl mx-auto px-4 pt-3">
|
||
<div class="flex items-center justify-between p-3 rounded-2xl bg-zinc-900/90 border border-purple-500/30 shadow-lg backdrop-blur-md">
|
||
<div class="flex items-center gap-2.5">
|
||
<span class="text-base">📱</span>
|
||
<p class="text-xs text-zinc-300">
|
||
Als App nutzen: Im Safari-Menü auf <span class="inline-flex items-center text-purple-400 font-semibold"><i data-lucide="share" class="w-3.5 h-3.5 inline mx-0.5"></i> Teilen</span> und <span class="text-white font-semibold">„Zum Home-Bildschirm“</span> (+) tippen.
|
||
</p>
|
||
</div>
|
||
<button onclick="dismissIosBanner()" class="p-1 rounded-lg text-zinc-400 hover:text-white">
|
||
<i data-lucide="x" class="w-4 h-4"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Header & Live Status (Always sticky on mobile) -->
|
||
<header class="border-b border-zinc-800/80 bg-zinc-950/80 backdrop-blur-xl sticky top-0 z-40 px-4 py-3 sm:px-8">
|
||
<div class="max-w-4xl mx-auto flex items-center justify-between">
|
||
<div class="flex items-center gap-3">
|
||
<div class="w-10 h-10 rounded-2xl bg-gradient-to-tr from-purple-600 via-pink-500 to-teal-400 p-0.5 shadow-lg shadow-purple-500/20 overflow-hidden flex items-center justify-center">
|
||
<img src="/family/mila-icon-192.png" alt="Mila" class="w-full h-full object-cover rounded-[14px]">
|
||
</div>
|
||
<div>
|
||
<div class="flex items-center gap-2">
|
||
<h1 class="text-lg sm:text-xl font-heading font-extrabold tracking-tight bg-gradient-to-r from-purple-300 via-pink-300 to-indigo-200 bg-clip-text text-transparent">
|
||
Mila Graf
|
||
</h1>
|
||
<span class="text-[11px] px-2 py-0.5 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-300 font-medium">
|
||
{age_str}
|
||
</span>
|
||
</div>
|
||
<p class="text-xs text-zinc-400">{today_pretty}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Live State Badge & 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="manual-refresh-btn" onclick="triggerManualRefresh()" title="Neu laden" class="p-2 rounded-xl bg-zinc-900/80 border border-zinc-800 text-zinc-400 hover:text-white hover:border-purple-500/40 hover:bg-zinc-800 transition">
|
||
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="max-w-4xl mx-auto p-4 sm:p-6 space-y-6">
|
||
|
||
<!-- HERO SECTION: CURRENT DAY STRICTLY ON TOP -->
|
||
<section class="space-y-4">
|
||
|
||
<!-- Hero Banner / Current State Card -->
|
||
<div class="p-6 rounded-3xl {'bg-gradient-to-br from-purple-900/30 via-zinc-900/70 to-zinc-950 border border-purple-500/30' if is_sleeping else 'bg-gradient-to-br from-emerald-900/20 via-zinc-900/70 to-zinc-950 border border-emerald-500/30'} shadow-2xl relative overflow-hidden">
|
||
<div class="relative z-10 space-y-2">
|
||
<div class="flex items-center gap-2 text-xs font-mono tracking-wider uppercase {'text-purple-400 font-bold' if is_sleeping else 'text-emerald-400 font-bold'}">
|
||
<span>{'💤 Status: Schläft gerade' if is_sleeping else '☀️ Status: Wach & Munter'}</span>
|
||
</div>
|
||
<div class="text-3xl sm:text-4xl font-extrabold tracking-tight text-white font-heading">
|
||
{f'Seit {active_sleep_start_time} Uhr (<span class="live-sleep-timer" data-start-iso="{active_sleep_iso}">{active_sleep_duration_str}</span>)' if is_sleeping else f'Wachfenster: <span class="live-wake-timer" data-end-iso="{last_wake_iso}">{wake_window_str}</span>'}
|
||
</div>
|
||
<p class="text-sm text-zinc-400">
|
||
{f'Eingeschlafen im Bett. Schlafintervall l\u00e4uft aktuell.' if is_sleeping else f'Letztes Schläfchen beendet um {last_wake_time_str} Uhr.'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Key Metrics Strip for Today -->
|
||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||
<div class="bg-zinc-900/80 border border-zinc-800 rounded-2xl p-4 shadow-md">
|
||
<div class="text-[11px] font-semibold uppercase tracking-wider text-zinc-400">24h Schlaf Heute</div>
|
||
<div class="text-2xl font-extrabold text-purple-300 font-mono mt-1">{today_tot_hours}</div>
|
||
<div class="text-[11px] text-zinc-500 mt-0.5">Nacht + Schläfchen</div>
|
||
</div>
|
||
<div class="bg-zinc-900/80 border border-zinc-800 rounded-2xl p-4 shadow-md">
|
||
<div class="text-[11px] font-semibold uppercase tracking-wider text-zinc-400">Tagesschläfchen</div>
|
||
<div class="text-2xl font-extrabold text-emerald-400 font-mono mt-1">{today_nap_count} Naps</div>
|
||
<div class="text-[11px] text-zinc-500 mt-0.5">{today_nap_min} min gesamt</div>
|
||
</div>
|
||
<div class="bg-zinc-900/80 border border-zinc-800 rounded-2xl p-4 shadow-md">
|
||
<div class="text-[11px] font-semibold uppercase tracking-wider text-zinc-400">Nachtschlaf</div>
|
||
<div class="text-2xl font-extrabold text-sky-400 font-mono mt-1">{today_night_dur_str}</div>
|
||
<div class="text-[11px] text-zinc-500 mt-0.5">Hauptschlafphase</div>
|
||
</div>
|
||
<div class="bg-zinc-900/80 border border-zinc-800 rounded-2xl p-4 shadow-md">
|
||
<div class="text-[11px] font-semibold uppercase tracking-wider text-zinc-400">7-Tage-Schnitt</div>
|
||
<div class="text-2xl font-extrabold text-pink-400 font-mono mt-1">{avg_7_tot}</div>
|
||
<div class="text-[11px] text-zinc-500 mt-0.5">Alterskorridor: 12–15h</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- TODAY'S DETAILED TIMELINE: NAPS & NIGHT -->
|
||
<div class="bg-zinc-900/80 border border-zinc-800/80 rounded-2xl p-5 shadow-xl space-y-4">
|
||
<div class="flex items-center justify-between border-b border-zinc-800/80 pb-3">
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-lg">📋</span>
|
||
<h2 class="text-base font-bold text-zinc-100">Heutiger Tagesablauf im Detail</h2>
|
||
</div>
|
||
<span class="text-xs font-mono text-zinc-400">{today_str}</span>
|
||
</div>
|
||
|
||
<!-- Tagesschläfchen Breakdown -->
|
||
<div class="space-y-2">
|
||
<div class="text-xs font-bold text-emerald-400 uppercase tracking-wider flex items-center gap-1.5">
|
||
<span>🌿</span>
|
||
<span>Tagesschläfchen ({today_nap_count} erfasst)</span>
|
||
</div>
|
||
<div class="space-y-2">
|
||
{''.join(today_naps_html)}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Nachtschlaf Breakdown -->
|
||
<div class="space-y-2 pt-2 border-t border-zinc-800/50">
|
||
<div class="text-xs font-bold text-sky-400 uppercase tracking-wider flex items-center gap-1.5">
|
||
<span>🌙</span>
|
||
<span>Nachtschlaf</span>
|
||
</div>
|
||
<div class="space-y-2">
|
||
{''.join(today_nights_html)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CONDITIONAL MODULES: BOTTLES & DIAPERS -->
|
||
{feeding_section_html}
|
||
{diaper_section_html}
|
||
|
||
</section>
|
||
|
||
<!-- DEVELOPMENT & TREND BENCHMARKS -->
|
||
<section class="bg-gradient-to-r from-purple-950/30 via-zinc-900/60 to-zinc-900/30 border border-zinc-800 rounded-2xl p-5 shadow-md">
|
||
<div class="flex items-center gap-2 mb-3">
|
||
<span class="text-lg">📈</span>
|
||
<h2 class="text-sm font-bold uppercase tracking-wider text-zinc-300">Entwicklungs-Benchmarks & Tendenzen</h2>
|
||
</div>
|
||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs text-zinc-400">
|
||
<div class="p-3 rounded-xl bg-zinc-950/50 border border-zinc-800/60 space-y-1">
|
||
<div class="text-zinc-500 font-medium">7-Tage-Ø Gesamtschlaf</div>
|
||
<div class="text-lg font-bold text-purple-300 font-mono">{avg_7_tot}</div>
|
||
<div>Nacht: <span class="text-sky-300 font-mono">{avg_7_night}</span> · Tag: <span class="text-emerald-300 font-mono">{avg_7_naps}</span></div>
|
||
</div>
|
||
<div class="p-3 rounded-xl bg-zinc-950/50 border border-zinc-800/60 space-y-1">
|
||
<div class="text-zinc-500 font-medium">30-Tage-Ø Gesamtschlaf</div>
|
||
<div class="text-lg font-bold text-pink-300 font-mono">{avg_30_tot}</div>
|
||
<div>Stabile zirkadiane Konsolidierung</div>
|
||
</div>
|
||
<div class="p-3 rounded-xl bg-zinc-950/50 border border-zinc-800/60 space-y-1">
|
||
<div class="text-zinc-500 font-medium">Altersreferenz (7 Monate)</div>
|
||
<div class="text-lg font-bold text-amber-300 font-mono">12 – 15 Std</div>
|
||
<div>Typisch 2–3 Naps, Wachfenster ~2.25–3.25h</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- 30-DAY HISTORY TABLE -->
|
||
<section class="bg-zinc-900/80 border border-zinc-800/80 rounded-2xl p-5 shadow-xl space-y-4">
|
||
<div class="flex items-center justify-between pb-2 border-b border-zinc-800/60">
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-lg">📅</span>
|
||
<h2 class="text-base font-bold text-zinc-100">Historie (Letzte 30 Tage)</h2>
|
||
</div>
|
||
<span class="text-xs text-zinc-400">DuckDB Aggregatdaten</span>
|
||
</div>
|
||
|
||
<div class="overflow-x-auto -mx-5 px-5">
|
||
<table class="w-full text-left text-sm whitespace-nowrap">
|
||
<thead class="text-[11px] uppercase bg-zinc-800/50 text-zinc-400">
|
||
<tr>
|
||
<th class="py-2.5 px-4 rounded-l-lg">Datum</th>
|
||
<th class="py-2.5 px-4">24h Gesamt</th>
|
||
<th class="py-2.5 px-4">Nacht</th>
|
||
<th class="py-2.5 px-4">Schläfchen</th>
|
||
<th class="py-2.5 px-4">Fläschchen</th>
|
||
<th class="py-2.5 px-4 rounded-r-lg">Windeln</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{''.join(history_rows)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
</main>
|
||
|
||
<!-- Floating Refresh Footer -->
|
||
<footer class="max-w-4xl mx-auto px-4 text-center text-xs text-zinc-500 space-y-1">
|
||
<p>Aktualisiert: <span id="build-time-display">{now_berlin.strftime('%d.%m.%Y um %H:%M:%S')} Uhr</span> · <button onclick="triggerManualRefresh()" class="text-purple-400 hover:text-purple-300 underline font-mono transition">Live Sync</button></p>
|
||
<p class="text-[11px]">Tipp: Im Safari-Menü auf „Zum Home-Bildschirm" tippen für die native App-Nutzung.</p>
|
||
</footer>
|
||
|
||
<script>
|
||
document.addEventListener('DOMContentLoaded', () => {{
|
||
lucide.createIcons();
|
||
}});
|
||
|
||
// Service Worker Registration for PWA installability
|
||
if ('serviceWorker' in navigator) {{
|
||
window.addEventListener('load', () => {{
|
||
navigator.serviceWorker.register('/family/sw.js').catch(() => {{
|
||
navigator.serviceWorker.register('sw.js').catch(console.error);
|
||
}});
|
||
}});
|
||
}}
|
||
|
||
// Chrome/Android PWA BeforeInstallPrompt
|
||
let deferredInstallPrompt = null;
|
||
window.addEventListener('beforeinstallprompt', (e) => {{
|
||
e.preventDefault();
|
||
deferredInstallPrompt = e;
|
||
const banner = document.getElementById('pwa-install-banner');
|
||
if (banner) banner.classList.remove('hidden');
|
||
}});
|
||
|
||
const installBtn = document.getElementById('pwa-install-btn');
|
||
if (installBtn) {{
|
||
installBtn.addEventListener('click', async () => {{
|
||
if (!deferredInstallPrompt) return;
|
||
deferredInstallPrompt.prompt();
|
||
const {{ outcome }} = await deferredInstallPrompt.userChoice;
|
||
if (outcome === 'accepted') {{
|
||
const banner = document.getElementById('pwa-install-banner');
|
||
if (banner) banner.classList.add('hidden');
|
||
}}
|
||
deferredInstallPrompt = null;
|
||
}});
|
||
}}
|
||
|
||
// iOS Safari Detection & Guidance Banner
|
||
const isIos = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
||
const isStandalone = window.navigator.standalone === true || window.matchMedia('(display-mode: standalone)').matches;
|
||
if (isIos && !isStandalone && !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');
|
||
}}
|
||
|
||
// ==============================================================================
|
||
// Self-Updating Engine & Live Tickers
|
||
// ==============================================================================
|
||
(function() {{
|
||
const currentBuildEpoch = parseInt(document.querySelector('meta[name="build-epoch"]')?.content || '0', 10);
|
||
let lastCheckMs = Date.now();
|
||
let isChecking = false;
|
||
|
||
// 1. Live Running Sleep/Wake Timer (increments live in DOM every 5 seconds)
|
||
function tickLiveTimers() {{
|
||
document.querySelectorAll('.live-sleep-timer').forEach(el => {{
|
||
const iso = el.dataset.startIso;
|
||
if (!iso) return;
|
||
const startMs = new Date(iso).getTime();
|
||
if (isNaN(startMs)) return;
|
||
const diffMs = Math.max(0, Date.now() - startMs);
|
||
const mins = Math.floor(diffMs / 60000);
|
||
const h = Math.floor(mins / 60);
|
||
const m = mins % 60;
|
||
el.textContent = h > 0 ? (h + ' h ' + String(m).padStart(2, '0') + ' min') : (m + ' min');
|
||
}});
|
||
document.querySelectorAll('.live-wake-timer').forEach(el => {{
|
||
const iso = el.dataset.endIso;
|
||
if (!iso) return;
|
||
const endMs = new Date(iso).getTime();
|
||
if (isNaN(endMs)) return;
|
||
const diffMs = Math.max(0, Date.now() - endMs);
|
||
const mins = Math.floor(diffMs / 60000);
|
||
const h = Math.floor(mins / 60);
|
||
const m = mins % 60;
|
||
el.textContent = h > 0 ? (h + ' h ' + String(m).padStart(2, '0') + ' min') : (m + ' min');
|
||
}});
|
||
}}
|
||
setInterval(tickLiveTimers, 5000);
|
||
tickLiveTimers();
|
||
|
||
// 2. Server Update Checker
|
||
async function checkForUpdates() {{
|
||
if (isChecking) return;
|
||
isChecking = true;
|
||
try {{
|
||
const res = await fetch(window.location.pathname + '?_poll=' + Date.now(), {{
|
||
cache: 'no-store',
|
||
headers: {{ 'Cache-Control': 'no-cache, no-store, must-revalidate', 'Pragma': 'no-cache' }}
|
||
}});
|
||
if (res.ok) {{
|
||
const html = await res.text();
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(html, 'text/html');
|
||
const remoteEpoch = parseInt(doc.querySelector('meta[name="build-epoch"]')?.content || '0', 10);
|
||
|
||
if (remoteEpoch && currentBuildEpoch && remoteEpoch > currentBuildEpoch) {{
|
||
console.log('[AutoUpdate] Server has newer build (' + remoteEpoch + ' > ' + currentBuildEpoch + '). Reloading...');
|
||
window.location.reload();
|
||
return;
|
||
}}
|
||
}}
|
||
}} catch (err) {{
|
||
console.warn('[AutoUpdate] Check failed:', err);
|
||
}} finally {{
|
||
isChecking = false;
|
||
lastCheckMs = Date.now();
|
||
}}
|
||
}}
|
||
|
||
// 3. Auto-update on reopen / foreground resume (PWA & Mobile Safari)
|
||
document.addEventListener('visibilitychange', () => {{
|
||
if (document.visibilityState === 'visible') {{
|
||
tickLiveTimers();
|
||
if (Date.now() - lastCheckMs > 10000) {{
|
||
checkForUpdates();
|
||
}}
|
||
}}
|
||
}});
|
||
|
||
window.addEventListener('pageshow', (e) => {{
|
||
tickLiveTimers();
|
||
if (e.persisted || Date.now() - lastCheckMs > 10000) {{
|
||
checkForUpdates();
|
||
}}
|
||
}});
|
||
|
||
window.addEventListener('focus', () => {{
|
||
tickLiveTimers();
|
||
if (Date.now() - lastCheckMs > 15000) {{
|
||
checkForUpdates();
|
||
}}
|
||
}});
|
||
|
||
// 4. Background check every 30 seconds when visible
|
||
setInterval(() => {{
|
||
if (document.visibilityState === 'visible') {{
|
||
checkForUpdates();
|
||
}}
|
||
}}, 30000);
|
||
|
||
// 5. Manual refresh trigger
|
||
window.triggerManualRefresh = function() {{
|
||
const btn = document.getElementById('manual-refresh-btn');
|
||
if (btn) {{
|
||
const icon = btn.querySelector('svg') || btn.querySelector('i');
|
||
if (icon) icon.classList.add('animate-spin');
|
||
}}
|
||
window.location.reload();
|
||
}};
|
||
}})();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
return html
|
||
|
||
|
||
def publish_to_s3(html_content: str, data_dir: Optional[Path] = None) -> str:
|
||
"""Uploads the rendered HTML, webmanifest, service worker and icons to MinIO hosting bucket."""
|
||
s3 = get_s3_client()
|
||
assets_dir = SCRIPT_DIR.parent / "assets"
|
||
|
||
# HTML files (strictly under protected family/ path)
|
||
key = "family/mila.html"
|
||
s3.put_object(
|
||
Bucket=DEFAULT_BUCKET,
|
||
Key=key,
|
||
Body=html_content.encode("utf-8"),
|
||
ContentType="text/html; charset=utf-8",
|
||
CacheControl="no-cache, no-store, must-revalidate",
|
||
)
|
||
|
||
# Static PWA Assets to upload (strictly under family/)
|
||
static_assets = [
|
||
("mila.webmanifest", "application/manifest+json; charset=utf-8", "no-cache, no-store, must-revalidate"),
|
||
("sw.js", "application/javascript; charset=utf-8", "no-cache, no-store, must-revalidate"),
|
||
("mila-icon-192.png", "image/png", "public, max-age=604800"),
|
||
("mila-icon-512.png", "image/png", "public, max-age=604800"),
|
||
("mila-apple-touch-icon.png", "image/png", "public, max-age=604800"),
|
||
("mila-icon-32.png", "image/png", "public, max-age=604800"),
|
||
("mila-icon-16.png", "image/png", "public, max-age=604800"),
|
||
("favicon.ico", "image/x-icon", "public, max-age=604800"),
|
||
("mila-icon.svg", "image/svg+xml", "public, max-age=604800"),
|
||
]
|
||
|
||
for fname, ctype, cache_ctrl in static_assets:
|
||
candidates = [assets_dir / fname]
|
||
if data_dir:
|
||
candidates.append(data_dir.parent / "public" / "family" / fname)
|
||
body_bytes = None
|
||
for candidate in candidates:
|
||
if candidate.is_file():
|
||
body_bytes = candidate.read_bytes()
|
||
break
|
||
if body_bytes:
|
||
try:
|
||
s3.put_object(
|
||
Bucket=DEFAULT_BUCKET,
|
||
Key=f"family/{fname}",
|
||
Body=body_bytes,
|
||
ContentType=ctype,
|
||
CacheControl=cache_ctrl,
|
||
)
|
||
except Exception as ex:
|
||
print(f"Warning: could not upload family/{fname}: {ex}", file=sys.stderr)
|
||
|
||
url = f"{DEFAULT_BASE_URL}/family/mila.html"
|
||
return url
|
||
|
||
|
||
def main():
|
||
parser = argparse.ArgumentParser(description="Render and publish Mila family dashboard")
|
||
parser.add_argument("--data-dir", help="Path to knowledge-wiki/data directory")
|
||
parser.add_argument("--output-file", help="Path to save HTML locally")
|
||
parser.add_argument("--skip-upload", action="store_true", help="Skip S3 upload")
|
||
args = parser.parse_args()
|
||
|
||
data_dir = Path(args.data_dir) if args.data_dir else find_data_dir()
|
||
engine = DatastoreEngine(data_dir)
|
||
|
||
print("Building Mila Dashboard from DuckDB...")
|
||
html = generate_dashboard_html(engine)
|
||
|
||
# 1. Save locally strictly to knowledge-wiki/public/family/mila.html
|
||
wiki_public_family = data_dir.parent / "public" / "family"
|
||
wiki_public_family.mkdir(parents=True, exist_ok=True)
|
||
out_file = Path(args.output_file) if args.output_file else (wiki_public_family / "mila.html")
|
||
out_file.write_text(html, encoding="utf-8")
|
||
print(f"✅ Saved locally: {out_file}")
|
||
|
||
# 2. Upload to S3 hosting
|
||
if not args.skip_upload:
|
||
try:
|
||
url = publish_to_s3(html, data_dir)
|
||
print(f"🚀 Published successfully to: {url}")
|
||
except Exception as e:
|
||
print(f"⚠️ S3 upload failed (local testing?): {e}", file=sys.stderr)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|