diff --git a/skills/datastore/scripts/update_website.py b/skills/datastore/scripts/update_website.py
index 66f58ae..93d61ec 100644
--- a/skills/datastore/scripts/update_website.py
+++ b/skills/datastore/scripts/update_website.py
@@ -37,6 +37,7 @@ 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)
@@ -464,6 +465,239 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
"""
+ # Growth Measurements & WHO Percentiles
+ growth_records = con.execute(
+ """
+ SELECT
+ id,
+ log_date,
+ local_time,
+ weight_kg,
+ height_cm,
+ head_circ_cm,
+ note
+ FROM v_growth
+ WHERE subject = 'mila'
+ ORDER BY start ASC
+ """
+ ).fetchall()
+
+ growth_measurements = []
+ mila_weight_points = []
+ mila_height_points = []
+ growth_table_rows = []
+
+ for row in growth_records:
+ r_id, r_date, r_time, r_weight, r_height, r_hc, r_note = row
+ d_obj = datetime.date.fromisoformat(str(r_date))
+ age_days = (d_obj - MILA_BIRTHDATE).days
+ age_months = max(0.0, age_days / 30.4375)
+
+ pw = calculate_percentile("weight", r_weight, age_months) if r_weight is not None else None
+ ph = calculate_percentile("height", r_height, age_months) if r_height is not None else None
+
+ meas_dict = {
+ "id": r_id,
+ "date_str": str(r_date),
+ "date_pretty": d_obj.strftime("%d.%m.%Y"),
+ "age_str": calculate_age_str(d_obj),
+ "age_months": round(age_months, 1),
+ "weight_kg": r_weight,
+ "weight_pct": pw["percentile_str"] if pw else "—",
+ "height_cm": r_height,
+ "height_pct": ph["percentile_str"] if ph else "—",
+ "note": r_note or "",
+ }
+ growth_measurements.append(meas_dict)
+
+ if r_weight is not None:
+ mila_weight_points.append({
+ "x": round(age_months, 2),
+ "y": round(r_weight, 2),
+ "date": d_obj.strftime("%d.%m.%Y"),
+ "pct": pw["percentile_str"] if pw else "",
+ })
+ if r_height is not None:
+ mila_height_points.append({
+ "x": round(age_months, 2),
+ "y": round(r_height, 1),
+ "date": d_obj.strftime("%d.%m.%Y"),
+ "pct": ph["percentile_str"] if ph else "",
+ })
+
+ for m in reversed(growth_measurements):
+ w_val = f"{m['weight_kg']:.2f} kg" if m['weight_kg'] is not None else "—"
+ w_badge = f'{m["weight_pct"]}' if m['weight_kg'] is not None else ""
+ h_val = f"{m['height_cm']:.1f} cm" if m['height_cm'] is not None else "—"
+ h_badge = f'{m["height_pct"]}' if m['height_cm'] is not None else ""
+ note_display = f'{m["note"]}' if m["note"] else '—'
+
+ growth_table_rows.append(f"""
+
+ | {m['date_pretty']} |
+ {m['age_str']} |
+ {w_val} {w_badge} |
+ {h_val} {h_badge} |
+ {note_display} |
+
+ """)
+
+ growth_table_html = "".join(growth_table_rows) if growth_table_rows else """
+
+ |
+ Bisher noch keine manuellen Messwerte eingetragen. Messungen können per Telegram oder CLI erfasst werden.
+ |
+
+ """
+
+ if growth_measurements:
+ latest_meas = growth_measurements[-1]
+ w_part = f"{latest_meas['weight_kg']:.2f} kg ({latest_meas['weight_pct']})" if latest_meas["weight_kg"] is not None else ""
+ h_part = f"{latest_meas['height_cm']:.1f} cm ({latest_meas['height_pct']})" if latest_meas["height_cm"] is not None else ""
+ both_part = " · ".join(filter(None, [w_part, h_part]))
+ growth_summary_pill = f"Letzte Messung: {both_part} ({latest_meas['date_pretty']})"
+ else:
+ growth_summary_pill = "Noch keine Messungen erfasst"
+
+ who_w_p3 = [{"x": r["month"], "y": r["p3"]} for r in WHO_GIRLS_WEIGHT]
+ who_w_p50 = [{"x": r["month"], "y": r["p50"]} for r in WHO_GIRLS_WEIGHT]
+ who_w_p97 = [{"x": r["month"], "y": r["p97"]} for r in WHO_GIRLS_WEIGHT]
+
+ who_h_p3 = [{"x": r["month"], "y": r["p3"]} for r in WHO_GIRLS_HEIGHT]
+ who_h_p50 = [{"x": r["month"], "y": r["p50"]} for r in WHO_GIRLS_HEIGHT]
+ who_h_p97 = [{"x": r["month"], "y": r["p97"]} for r in WHO_GIRLS_HEIGHT]
+
+ chart_payload_json = json.dumps({
+ "who_weight": {"p3": who_w_p3, "p50": who_w_p50, "p97": who_w_p97},
+ "who_height": {"p3": who_h_p3, "p50": who_h_p50, "p97": who_h_p97},
+ "mila_weight": mila_weight_points,
+ "mila_height": mila_height_points,
+ })
+
+ growth_section_html = f"""
+
+
+
+
+
+
+
+
+
+
+ {growth_summary_pill}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Mila
+
+
+ WHO Median (P50)
+
+
+ P3–P97 Normkorridor (94%)
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 📋
+ Erfasste Messungen
+
+ {len(growth_measurements)} Messung(en)
+
+
+
+
+
+
+ | Datum |
+ Alter |
+ Gewicht |
+ Körpergröße |
+ Notiz / Anlass |
+
+
+
+ {growth_table_html}
+
+
+
+
+
+
+
+ """
+
# PWA Manifest
manifest_dict = {
"id": "/family/mila.html",
@@ -565,6 +799,7 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
+
@@ -775,6 +1010,8 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
+ {growth_section_html}
+
@@ -788,6 +1025,168 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
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', () => {{
diff --git a/skills/datastore/tests/test_datastore.py b/skills/datastore/tests/test_datastore.py
index 7ea239e..29f92b7 100644
--- a/skills/datastore/tests/test_datastore.py
+++ b/skills/datastore/tests/test_datastore.py
@@ -782,6 +782,35 @@ def test_growth_logging(temp_datastore: DatastoreEngine):
assert summary["growth_latest"]["height_cm"] == 67.5
+def test_website_growth_html_rendering(temp_datastore: DatastoreEngine):
+ """Verifies that update_website generates valid HTML containing the growth section and Chart.js code."""
+ from update_website import generate_dashboard_html
+
+ temp_datastore.put_entity(
+ collection="people",
+ record={"id": "mila", "name": "Mila Graf", "birthdate": "2026-03-14", "role": "child"},
+ )
+ temp_datastore.add_event(
+ kind="growth.measurement",
+ subject="mila",
+ actor="agent:hebamme",
+ start="2026-10-12T10:00:00+02:00",
+ payload={"weight_kg": 7.45, "height_cm": 68.0},
+ note="U5 Untersuchung",
+ )
+
+ html = generate_dashboard_html(temp_datastore)
+ assert "Körpergröße & Gewicht (WHO-Perzentilen)" in html
+ assert "7.45 kg" in html
+ assert "68.0 cm" in html
+ assert "U5 Untersuchung" in html
+ assert "chart.umd.min.js" in html
+ assert "growthChart" in html
+ assert "who_weight" in html
+ assert "who_height" in html
+
+
+