feat(growth): add WHO percentile growth curves, Chart.js collapsible section, and tests
This commit is contained in:
@@ -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', () => {{
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user