#!/usr/bin/env python3 # /// script # dependencies = [ # "duckdb>=1.1.0", # "python-ulid>=3.0.0", # "pytz>=2024.1", # ] # /// """ render_views.py — Generates clean Markdown view pages and HTML dashboards from DuckDB JSONL. Uses DatastoreEngine for exact schema validation, typed loading, and Berlin timezone attribution. """ from __future__ import annotations import argparse import datetime import os from pathlib import Path from zoneinfo import ZoneInfo from datastore import DatastoreEngine BERLIN_TZ = ZoneInfo("Europe/Berlin") WEEKDAYS_DE = { 0: "Montag", 1: "Dienstag", 2: "Mittwoch", 3: "Donnerstag", 4: "Freitag", 5: "Samstag", 6: "Sonntag", } def find_default_dirs(): candidates = [ Path.cwd() / "knowledge-wiki", Path("/Users/moritz/src/infrapuzzle/knowledge-wiki"), Path("/home/node/.openclaw/wiki/main"), ] for c in candidates: if (c / "data").is_dir(): return c, c / "data" return Path.cwd(), Path.cwd() / "data" def render_nutrition_views(engine: DatastoreEngine, wiki_dir: Path): con = engine.con months = ["2026-09", "2026-10"] for m in months: out_file = wiki_dir / "entities" / "nutrition-log" / "moritz" / f"{m}.md" out_file.parent.mkdir(parents=True, exist_ok=True) month_name = "September" if m.endswith("09") else "Oktober" # Query days in month descending days = con.execute( f""" SELECT DISTINCT log_date FROM v_nutrition WHERE strftime(log_date, '%Y-%m') = '{m}' ORDER BY log_date DESC """ ).fetchall() md = [] md.append("---") md.append(f"id: nutrition-log-moritz-{m}") md.append("pageType: source") md.append(f'title: "Ernährungsprotokoll Moritz — {month_name} 2026"') md.append(f"month: {m}") md.append("tags:") md.append(" - nutrition-log") md.append(" - personal") md.append("lifecycle:") md.append(" state: active") md.append("---") md.append("") md.append(f"# Ernährungsprotokoll Moritz — {month_name} 2026") md.append("") md.append("") md.append("") md.append("") for (d,) in days: d_str = str(d) d_date = datetime.date.fromisoformat(d_str) weekday_str = WEEKDAYS_DE[d_date.weekday()] # Fetch daily aggregate daily_agg = con.execute( f""" SELECT total_protein_g, total_carbs_g, total_sugar_g, total_fat_g, total_fiber_g, total_salt_g, total_kcal FROM v_nutrition_daily WHERE log_date = '{d_str}' AND subject = 'moritz' """ ).fetchone() # Check if daily balance against Garmin is available balance_row = con.execute( f""" SELECT intake_kcal, expenditure_kcal, active_kcal, rmr_kcal, steps, balance_kcal, balance_status FROM v_daily_balance WHERE log_date = '{d_str}' AND subject = 'moritz' """ ).fetchone() md.append(f"## {d_str} {weekday_str}") md.append("") md.append("| Zeit | Input (original) | Menge | Eiweiß | KH | Zucker | Fett | Ballaststoffe | Salz | kcal |") md.append("|---|---|---|---|---|---|---|---|---|---|") # Fetch rows for this day rows = con.execute( f""" SELECT strftime(local_time, '%H:%M'), item, qty, unit, protein_g, carbs_g, sugar_g, fat_g, fiber_g, salt_g, kcal, estimated FROM v_nutrition WHERE log_date = '{d_str}' AND subject = 'moritz' ORDER BY start ASC """ ).fetchall() for r in rows: t_str = r[0] item = r[1] qty = f"{int(r[2]) if r[2].is_integer() else r[2]} {r[3]}" if r[2] is not None and r[3] else "" est = "~" if r[11] else "" p = f"{est}{r[4]:.1f} g" c = f"{est}{r[5]:.1f} g" s = f"{est}{r[6]:.1f} g" f_val = f"{est}{r[7]:.1f} g" fib = f"{est}{r[8]:.1f} g" salt = f"{est}{r[9]:.2f} g" kc = f"{est}{int(round(r[10]))}" md.append(f"| {t_str} | {item} | {qty} | {p} | {c} | {s} | {f_val} | {fib} | {salt} | {kc} |") if daily_agg: tot_p = f"{daily_agg[0]:.1f} g" tot_c = f"{daily_agg[1]:.1f} g" tot_s = f"{daily_agg[2]:.1f} g" tot_f = f"{daily_agg[3]:.1f} g" tot_fib = f"{daily_agg[4]:.1f} g" tot_salt = f"{daily_agg[5]:.2f} g" tot_kc = f"**{int(round(daily_agg[6]))}**" md.append(f"| **Tages-Summe** | | | **{tot_p}** | **{tot_c}** | **{tot_s}** | **{tot_f}** | **{tot_fib}** | **{tot_salt}** | {tot_kc} |") md.append("") # Render Garmin balance block if available if balance_row and balance_row[1] is not None: intake = int(round(balance_row[0])) expend = int(round(balance_row[1])) diff = int(round(balance_row[5])) steps = balance_row[4] or 0 active = balance_row[2] or 0 sign = "+" if diff > 0 else "" md.append(f"- **Vortags-Bilanz (Garmin)**: {intake} kcal Verzehr - {expend} kcal Verbrauch = **{sign}{diff} kcal {balance_row[6]}** ({steps} Schritte, {active} Aktiv-kcal)") md.append("") out_file.write_text("\n".join(md), encoding="utf-8") print(f"Rendered {out_file}") def render_sleep_view(engine: DatastoreEngine, wiki_dir: Path): con = engine.con out_file = wiki_dir / "entities" / "people" / "mila-schlafprotokoll.md" out_file.parent.mkdir(parents=True, exist_ok=True) days = con.execute( """ SELECT DISTINCT log_date FROM v_sleep WHERE subject = 'mila' ORDER BY log_date DESC """ ).fetchall() md = [] md.append("---") md.append("id: mila-schlafprotokoll") md.append("pageType: log") md.append('title: "Schlafprotokoll Mila"') md.append("subject: mila") md.append("tags:") md.append(" - sleep") md.append(" - baby") md.append(" - mila") md.append("lifecycle:") md.append(" state: active") md.append("---") md.append("") md.append("# Schlafprotokoll Mila") md.append("") md.append("") md.append("") md.append("") current_month_label = "" for (d,) in days: d_str = str(d) d_date = datetime.date.fromisoformat(d_str) month_label = f"{'September' if d_date.month == 9 else 'Oktober'} {d_date.year}" if month_label != current_month_label: current_month_label = month_label md.append(f"# {month_label}") md.append("") weekday_str = WEEKDAYS_DE[d_date.weekday()] daily_agg = con.execute( f""" SELECT total_sleep_hours, nap_count, night_sleep_minutes, nap_sleep_minutes FROM v_sleep_daily WHERE log_date = '{d_str}' AND subject = 'mila' """ ).fetchone() day_sum_str = "" if daily_agg: tot_h = daily_agg[0] naps = daily_agg[1] day_sum_str = f" ({tot_h:.2f} h gesamt, {naps} Schläfchen)" md.append(f"## {weekday_str}, {d_date.strftime('%d.%m.%Y')}{day_sum_str}") sessions = con.execute( f""" SELECT strftime(local_start, '%H:%M'), CASE WHEN is_open THEN NULL ELSE strftime(local_end, '%H:%M') END, sleep_type, location, note, duration_minutes, is_open FROM v_sleep WHERE log_date = '{d_str}' AND subject = 'mila' ORDER BY start ASC """ ).fetchall() for s in sessions: s_time = s[0] e_time = s[1] s_type = s[2] loc = s[3] note = s[4] or "" dur = s[5] is_open = s[6] dur_str = f"{dur // 60} h {dur % 60:02d} min" if dur >= 60 else f"{dur} min" if is_open: md.append(f"- {s_time} – | {note or 'Nacht'} (läuft)") else: label = note if note else ("Nacht" if s_type == "night" else "Schläfchen") md.append(f"- {s_time} – {e_time} | {label} ({dur_str})") md.append("") # Preserve narrative analysis block md.append("""--- # Monatsanalyse September 2026 (nachgetragen 03.10.) *Bewertet aus Sicht einer Kinderarzt-Praxis mit Schlafspezialisierung – Hinweise, keine Diagnose.* **Zahlen (19.–30.09., 11 protokollierte Tage):** - Nacht: Ø ~11 h 18 min (10 h 10 – 11 h 55) - Tag: Ø ~2 h 29 min in 4–5 Schläfchen - 24-h-Summe: Ø ~13 h 47 min **Einordnung:** Das liegt am oberen, aber völlig normalen Rand des altersüblichen Bereichs (Säugling ~6 Mon.: 12–16 h/24 h, vgl. [Wikipedia: Schlaf](https://de.wikipedia.org/wiki/Schlaf) und [Wikipedia: Säugling](https://de.wikipedia.org/wiki/S%C3%A4ugling)). **Trends im Monat:** 1. **Konsolidierung:** Von 5 kurzen Mikro-Schläfchen (Sep Anfang) hin zu 3–4 Schläfchen, erste lange Blöcke (85–140 min) am 20./21./27.09. und 02.10. 2. **Spätere Bettzeit:** 18:34–19:55 (Mitte Sep) → 20:10–21:00 (Ende Sep/Okt) – stabile Morgenwache ~06:00–06:50 bleibt. 3. **Durchschlafen:** Durchgehend lange Nachtblöcke, Wachphasen nachts selten und kurz. **Erwartbar ab ~7–8 Monaten:** Übergang zum 3→2-Naps-Schema, Morgenschläfchen fällt zuerst weg. Zirkadiane Reifung unterstützt das (vgl. [Wikipedia: Zirkadiane Rhythmik](https://de.wikipedia.org/wiki/Zirkadiane_Rhythmik)). --- """) out_file.write_text("\n".join(md), encoding="utf-8") print(f"Rendered {out_file}") def render_html_dashboards(engine: DatastoreEngine, html_dir: Path): html_dir.mkdir(parents=True, exist_ok=True) con = engine.con # 1. Nutrition HTML Dashboard nutrition_days = con.execute( """ SELECT d.log_date, d.total_kcal, d.total_protein_g, d.total_carbs_g, d.total_fat_g, d.item_count, b.expenditure_kcal, b.balance_kcal, b.balance_status, b.steps FROM v_nutrition_daily d LEFT JOIN v_daily_balance b ON d.log_date = b.log_date AND d.subject = b.subject WHERE d.subject = 'moritz' ORDER BY d.log_date DESC LIMIT 30 """ ).fetchall() latest_n = nutrition_days[0] if nutrition_days else None latest_kcal = int(round(latest_n[1])) if latest_n else 0 latest_protein = f"{latest_n[2]:.1f}" if latest_n else "0" latest_garmin = f"{int(round(latest_n[6]))} kcal" if latest_n and latest_n[6] is not None else "Ausstehend" latest_balance = f"{int(round(latest_n[7]))} kcal ({latest_n[8]})" if latest_n and latest_n[7] is not None else "—" # Compute 7-day average avg_7 = con.execute( """ SELECT ROUND(AVG(total_kcal), 0), ROUND(AVG(total_protein_g), 1) FROM (SELECT total_kcal, total_protein_g FROM v_nutrition_daily WHERE subject = 'moritz' ORDER BY log_date DESC LIMIT 7) """ ).fetchone() avg_7_kcal = int(avg_7[0]) if avg_7 and avg_7[0] is not None else 0 avg_7_prot = avg_7[1] if avg_7 and avg_7[1] is not None else 0 nutrition_rows_html = [] for r in nutrition_days: d_str = str(r[0]) kc = int(round(r[1])) prot = f"{r[2]:.1f}" carbs = f"{r[3]:.1f}" fat = f"{r[4]:.1f}" exp = int(round(r[6])) if r[6] is not None else "—" diff = int(round(r[7])) if r[7] is not None else "—" diff_class = "text-emerald-400" if (r[7] is not None and r[7] < 0) else ("text-amber-400" if (r[7] is not None and r[7] > 0) else "text-slate-400") steps = f"{r[9]:,}" if r[9] is not None else "—" nutrition_rows_html.append(f""" {d_str} {kc} {prot} g {carbs} g {fat} g {exp} {diff} {steps} """) nutrition_html = f""" Moritz — Ernährung & Energiebilanz

Moritz — Ernährung & Energiebilanz

Strikte DuckDB-Aggregatdaten · Single Source of Truth

Letztes Update: {datetime.date.today().strftime('%d.%m.%Y')}
Letzter Tag (Kcal)
{latest_kcal}
7-Tage-Schnitt: {avg_7_kcal} kcal
Eiweiß Intake
{latest_protein} g
7-Tage-Schnitt: {avg_7_prot} g
Garmin Verbrauch
{latest_garmin}
RMR + Aktivität
Tages-Bilanz
{latest_balance}
Netto-Differenz

Verlauf (Letzte 30 Tage)

{''.join(nutrition_rows_html)}
Datum Aufnahme Protein KH Fett Garmin Bilanz Schritte
""" out_nutrition_html = html_dir / "nutrition.html" out_nutrition_html.write_text(nutrition_html, encoding="utf-8") print(f"Rendered HTML Dashboard: {out_nutrition_html}") # 2. Sleep HTML Dashboard sleep_days = con.execute( """ SELECT log_date, total_sleep_hours, nap_count, night_sleep_minutes, nap_sleep_minutes, total_sessions FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 30 """ ).fetchall() latest_s = sleep_days[0] if sleep_days else None latest_tot_h = f"{latest_s[1]:.2f} h" if latest_s else "0 h" latest_night_str = f"{latest_s[3] // 60} h {latest_s[3] % 60:02d} min" if latest_s else "—" latest_nap_str = f"{latest_s[2]} Naps ({latest_s[4]} min)" if latest_s else "—" # Compute 7-day average total sleep hours avg_sleep = 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 7) """ ).fetchone() avg_sleep_h = f"{avg_sleep[0]:.2f} h" if avg_sleep and avg_sleep[0] is not None else "—" sleep_rows_html = [] for r in sleep_days: d_str = str(r[0]) tot = f"{r[1]:.2f} h" night_h = f"{r[3] // 60} h {r[3] % 60:02d} min" nap_info = f"{r[2]} ({r[4]} min)" sleep_rows_html.append(f""" {d_str} {tot} {night_h} {nap_info} """) sleep_html = f""" Mila — Schlafprotokoll

Mila — Schlafprotokoll

Deterministische Schlafdauer & Wachfenster via DuckDB

Letztes Update: {datetime.date.today().strftime('%d.%m.%Y')}
24h Schlaf
{latest_tot_h}
Letzter Tag
Nachtschlaf
{latest_night_str}
Hauptschlafphase
Tagesschläfchen
{latest_nap_str}
Tagsüber
7-Tage-Schnitt
{avg_sleep_h}
Altersbereich: 12-16 h

Schlafhistorie (Letzte 30 Tage)

{''.join(sleep_rows_html)}
Datum 24h Gesamtschlaf Nachtschlaf Tagesschläfchen (Dauer)
""" out_sleep_html = html_dir / "sleep.html" out_sleep_html.write_text(sleep_html, encoding="utf-8") print(f"Rendered HTML Dashboard: {out_sleep_html}") def main(): parser = argparse.ArgumentParser(description="Render Markdown and HTML views from DuckDB Datastore") parser.add_argument("--wiki-dir", help="Path to knowledge-wiki repository") parser.add_argument("--data-dir", help="Path to knowledge-wiki/data directory") parser.add_argument("--html-dir", help="Path to output HTML dashboards") parser.add_argument("--no-md", action="store_true", help="Skip Markdown rendering") parser.add_argument("--no-html", action="store_true", help="Skip HTML dashboard rendering") args = parser.parse_args() default_wiki, default_data = find_default_dirs() wiki_dir = Path(args.wiki_dir) if args.wiki_dir else default_wiki data_dir = Path(args.data_dir) if args.data_dir else default_data html_dir = Path(args.html_dir) if args.html_dir else (wiki_dir / "public" / "family") engine = DatastoreEngine(data_dir) if not args.no_md: render_nutrition_views(engine, wiki_dir) render_sleep_view(engine, wiki_dir) if not args.no_html: render_html_dashboards(engine, html_dir) if __name__ == "__main__": main()