From 7b9b3ff0ebcbec64f2f7c5308197c2c6921cba01 Mon Sep 17 00:00:00 2001 From: Moritz Graf Date: Mon, 12 Oct 2026 08:32:38 +0200 Subject: [PATCH] feat(growth): add WHO percentile growth curves, Chart.js collapsible section, and tests --- skills/datastore/scripts/update_website.py | 399 +++++++++++++++++++++ skills/datastore/tests/test_datastore.py | 29 ++ 2 files changed, 428 insertions(+) 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) +
+ +
+ + + + + + + + + + + + {growth_table_html} + +
DatumAlterGewichtKörpergrößeNotiz / Anlass
+
+
+ +
+
+ """ + # 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 + + +