feat(growth): add WHO percentile growth curves, Chart.js collapsible section, and tests

This commit is contained in:
Moritz Graf
2026-10-12 08:32:38 +02:00
parent 191c8e9143
commit 7b9b3ff0eb
2 changed files with 428 additions and 0 deletions
+399
View File
@@ -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:
</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",
@@ -565,6 +799,7 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
</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">
@@ -775,6 +1010,8 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
</div>
</section>
{growth_section_html}
</main>
<!-- Floating Refresh Footer -->
@@ -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', () => {{
+29
View File
@@ -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