feat(pwa): optimize Mila app icons with safe-zone padding to prevent Android/iOS zoom crop

This commit is contained in:
Moritz Graf
2026-10-12 08:25:27 +02:00
parent f5b5deaae7
commit 191c8e9143
11 changed files with 316 additions and 3 deletions
+15
View File
@@ -26,6 +26,9 @@ The `datastore` skill is the source of truth for all structured, repetitive, and
- Searching for beer gardens, playgrounds, stores, or farm stands within a given radius (e.g. 500m or 1km around Grabengasse 7) using native DuckDB spatial distance.
5. **Catalogs & Entities (`foods`, `recipes`, `places`, `people`):**
- Upserting and reading reference data.
6. **Baby Growth & WHO Curves (`growth.measurement`):**
- Logging weight (`weight_kg`) and height/length (`height_cm`).
- Deterministic WHO child growth standards evaluation (P3, P50 Median, P97) based on exact age in months.
---
@@ -139,6 +142,18 @@ uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py pl
# Nightly Garmin mirror sync
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/garmin_sync.py --days 2
# Log Mila's weight and height measurement
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py add growth.measurement \
--subject mila \
--actor agent:hebamme \
--payload '{"weight_kg": 7.4, "height_cm": 67.5}'
# Retroactively log measurement with date
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py add growth.measurement \
--subject mila \
--date 2026-06-15 \
--payload '{"weight_kg": 5.8, "height_cm": 60.0}'
# Render Markdown views & HTML dashboards
PYTHONPATH=/home/node/mop-skills/mop-skills/skills/datastore/scripts \
uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/render_views.py
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 779 B

After

Width:  |  Height:  |  Size: 593 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

After

Width:  |  Height:  |  Size: 164 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 431 KiB

After

Width:  |  Height:  |  Size: 219 KiB

+84 -2
View File
@@ -30,6 +30,10 @@ from zoneinfo import ZoneInfo
import duckdb
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
logger = logging.getLogger("datastore")
def generate_ulid() -> str:
@@ -225,6 +229,34 @@ class DatastoreEngine:
restingHeartRate INTEGER,
raw JSON
);
CREATE OR REPLACE VIEW v_growth AS
SELECT
id,
subject,
actor,
start,
start AT TIME ZONE 'Europe/Berlin' AS local_time,
CAST(start AT TIME ZONE 'Europe/Berlin' AS DATE) AS log_date,
CAST(json_extract(payload, '$.weight_kg') AS DOUBLE) AS weight_kg,
CAST(json_extract(payload, '$.height_cm') AS DOUBLE) AS height_cm,
CAST(json_extract(payload, '$.head_circ_cm') AS DOUBLE) AS head_circ_cm,
note,
created_at
FROM events
WHERE kind = 'growth.measurement' AND deleted_at IS NULL;
CREATE OR REPLACE VIEW v_growth_daily AS
SELECT
subject,
log_date,
ROUND(AVG(weight_kg), 3) AS weight_kg,
ROUND(AVG(height_cm), 1) AS height_cm,
ROUND(AVG(head_circ_cm), 1) AS head_circ_cm,
COUNT(*) AS measurement_count
FROM v_growth
GROUP BY subject, log_date
ORDER BY log_date DESC;
""")
def _load_all_data(self):
@@ -387,6 +419,13 @@ class DatastoreEngine:
elif kind.startswith("sleep."):
if p1.get("type") == p2.get("type"):
return True
elif kind.startswith("growth."):
w1, w2 = p1.get("weight_kg"), p2.get("weight_kg")
h1, h2 = p1.get("height_cm"), p2.get("height_cm")
match_w = (w1 is not None and w2 is not None and abs(float(w1) - float(w2)) < 0.001)
match_h = (h1 is not None and h2 is not None and abs(float(h1) - float(h2)) < 0.001)
if match_w or match_h:
return True
return False
def _is_duplicate_event(
@@ -870,6 +909,11 @@ class DatastoreEngine:
if fee:
res["feeding"] = fee[0]
# 6. Growth latest measurement
grw = self.query(f"SELECT * FROM v_growth WHERE subject = '{subject}' ORDER BY start DESC LIMIT 1")
if grw:
res["growth_latest"] = grw[0]
return res
def find_places_near(
@@ -952,6 +996,44 @@ class DatastoreEngine:
"bottle_count": daily[0],
"total_consumed_ml": daily[1],
}
elif kind.startswith("growth"):
row = self.con.execute(
"SELECT weight_kg, height_cm, head_circ_cm FROM v_growth WHERE subject = ? AND log_date = ? ORDER BY start DESC LIMIT 1",
[subject, date_str],
).fetchone()
if row:
w_kg, h_cm, hc_cm = row[0], row[1], row[2]
p_row = self.con.execute("SELECT birthdate FROM people WHERE id = ?", [subject]).fetchone()
bdate = p_row[0] if p_row and p_row[0] else datetime.date(2026, 3, 14)
if isinstance(bdate, str):
bdate = datetime.date.fromisoformat(bdate)
age_days = (target_date - bdate).days
age_months = max(0.0, age_days / 30.4375)
try:
from who_growth import format_growth_feedback, calculate_percentile
fb_text = format_growth_feedback(w_kg, h_cm, age_months, date_str)
fb_res: Dict[str, Any] = {
"day_total_formatted": fb_text,
"age_months": round(age_months, 2),
}
if w_kg is not None:
pw = calculate_percentile("weight", w_kg, age_months)
fb_res["weight_kg"] = w_kg
fb_res["weight_percentile"] = pw["percentile"]
fb_res["weight_percentile_str"] = pw["percentile_str"]
if h_cm is not None:
ph = calculate_percentile("height", h_cm, age_months)
fb_res["height_cm"] = h_cm
fb_res["height_percentile"] = ph["percentile"]
fb_res["height_percentile_str"] = ph["percentile_str"]
return fb_res
except Exception as e:
logger.warning(f"Error computing growth feedback: {e}")
return {
"day_total_formatted": f"Wachstum ({date_str}): Gewicht {w_kg} kg, Größe {h_cm} cm",
"weight_kg": w_kg,
"height_cm": h_cm,
}
return {}
# --------------------------------------------------------------------------
@@ -1013,11 +1095,11 @@ def run_mcp_server():
TOOLS = [
{
"name": "datastore_add_event",
"description": "Log an event into the structured datastore (e.g. nutrition.intake, sleep.session, feeding.bottle). Computes all macros and totals deterministically.",
"description": "Log an event into the structured datastore (e.g. nutrition.intake, sleep.session, feeding.bottle, growth.measurement). Computes all macros, totals, and WHO percentiles deterministically.",
"inputSchema": {
"type": "object",
"properties": {
"kind": {"type": "string", "description": "e.g. 'nutrition.intake', 'sleep.session', 'feeding.bottle'"},
"kind": {"type": "string", "description": "e.g. 'nutrition.intake', 'sleep.session', 'feeding.bottle', 'growth.measurement'"},
"subject": {"type": "string", "description": "'moritz', 'amelie', or 'mila'"},
"actor": {"type": "string", "description": "'agent:koch', 'agent:hebamme', 'moritz', 'amelie'"},
"payload": {"type": "object", "description": "Detailed payload matching the kind schema"},
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""
who_growth.py — WHO Child Growth Standards (Girls 0–24 Months)
Deterministic percentile and z-score calculations via LMS method
and reference curve data for Mila's growth tracking.
"""
from __future__ import annotations
import math
from typing import Any, Dict, List, Optional, Tuple
# WHO Child Growth Standards (Girls: Birth to 24 Months)
# Source: World Health Organization (WHO) Multi-centre Growth Reference Study (MGRS)
# https://www.who.int/tools/child-growth-standards/standards
WHO_GIRLS_WEIGHT = [
{"month": 0, "L": 0.3809, "M": 3.2322, "S": 0.14171, "p3": 2.4, "p50": 3.2, "p97": 4.2},
{"month": 1, "L": 0.1714, "M": 4.1873, "S": 0.13724, "p3": 3.2, "p50": 4.2, "p97": 5.4},
{"month": 2, "L": 0.0962, "M": 5.1282, "S": 0.13000, "p3": 4.0, "p50": 5.1, "p97": 6.5},
{"month": 3, "L": 0.0402, "M": 5.8458, "S": 0.12619, "p3": 4.6, "p50": 5.8, "p97": 7.4},
{"month": 4, "L": -0.0050, "M": 6.4237, "S": 0.12402, "p3": 5.1, "p50": 6.4, "p97": 8.1},
{"month": 5, "L": -0.0430, "M": 6.8985, "S": 0.12274, "p3": 5.5, "p50": 6.9, "p97": 8.7},
{"month": 6, "L": -0.0756, "M": 7.2970, "S": 0.12204, "p3": 5.8, "p50": 7.3, "p97": 9.2},
{"month": 7, "L": -0.1039, "M": 7.6422, "S": 0.12178, "p3": 6.1, "p50": 7.6, "p97": 9.6},
{"month": 8, "L": -0.1288, "M": 7.9487, "S": 0.12181, "p3": 6.3, "p50": 7.9, "p97": 10.0},
{"month": 9, "L": -0.1507, "M": 8.2254, "S": 0.12199, "p3": 6.6, "p50": 8.2, "p97": 10.4},
{"month": 10, "L": -0.1700, "M": 8.4800, "S": 0.12223, "p3": 6.8, "p50": 8.5, "p97": 10.7},
{"month": 11, "L": -0.1872, "M": 8.7192, "S": 0.12247, "p3": 7.0, "p50": 8.7, "p97": 11.0},
{"month": 12, "L": -0.2024, "M": 8.9481, "S": 0.12268, "p3": 7.1, "p50": 8.9, "p97": 11.3},
{"month": 13, "L": -0.2158, "M": 9.1699, "S": 0.12283, "p3": 7.3, "p50": 9.2, "p97": 11.6},
{"month": 14, "L": -0.2278, "M": 9.3870, "S": 0.12294, "p3": 7.5, "p50": 9.4, "p97": 11.9},
{"month": 15, "L": -0.2384, "M": 9.6008, "S": 0.12299, "p3": 7.7, "p50": 9.6, "p97": 12.2},
{"month": 16, "L": -0.2478, "M": 9.8124, "S": 0.12303, "p3": 7.8, "p50": 9.8, "p97": 12.5},
{"month": 17, "L": -0.2562, "M": 10.0226, "S": 0.12306, "p3": 8.0, "p50": 10.0, "p97": 12.7},
{"month": 18, "L": -0.2637, "M": 10.2315, "S": 0.12309, "p3": 8.2, "p50": 10.2, "p97": 13.0},
{"month": 19, "L": -0.2703, "M": 10.4393, "S": 0.12315, "p3": 8.3, "p50": 10.4, "p97": 13.3},
{"month": 20, "L": -0.2762, "M": 10.6464, "S": 0.12323, "p3": 8.5, "p50": 10.6, "p97": 13.5},
{"month": 21, "L": -0.2815, "M": 10.8534, "S": 0.12335, "p3": 8.7, "p50": 10.9, "p97": 13.8},
{"month": 22, "L": -0.2862, "M": 11.0608, "S": 0.12350, "p3": 8.8, "p50": 11.1, "p97": 14.1},
{"month": 23, "L": -0.2903, "M": 11.2688, "S": 0.12369, "p3": 9.0, "p50": 11.3, "p97": 14.3},
{"month": 24, "L": -0.2941, "M": 11.4775, "S": 0.12390, "p3": 9.2, "p50": 11.5, "p97": 14.6},
]
WHO_GIRLS_HEIGHT = [
{"month": 0, "L": 1.0, "M": 49.1477, "S": 0.03790, "p3": 45.6, "p50": 49.1, "p97": 52.7},
{"month": 1, "L": 1.0, "M": 53.6872, "S": 0.03640, "p3": 50.0, "p50": 53.7, "p97": 57.4},
{"month": 2, "L": 1.0, "M": 57.0673, "S": 0.03568, "p3": 53.2, "p50": 57.1, "p97": 60.9},
{"month": 3, "L": 1.0, "M": 59.8029, "S": 0.03520, "p3": 55.8, "p50": 59.8, "p97": 63.8},
{"month": 4, "L": 1.0, "M": 62.0899, "S": 0.03486, "p3": 58.0, "p50": 62.1, "p97": 66.2},
{"month": 5, "L": 1.0, "M": 64.0301, "S": 0.03463, "p3": 59.9, "p50": 64.0, "p97": 68.2},
{"month": 6, "L": 1.0, "M": 65.7311, "S": 0.03448, "p3": 61.5, "p50": 65.7, "p97": 70.0},
{"month": 7, "L": 1.0, "M": 67.2873, "S": 0.03441, "p3": 62.9, "p50": 67.3, "p97": 71.6},
{"month": 8, "L": 1.0, "M": 68.7498, "S": 0.03440, "p3": 64.3, "p50": 68.7, "p97": 73.2},
{"month": 9, "L": 1.0, "M": 70.1435, "S": 0.03444, "p3": 65.6, "p50": 70.1, "p97": 74.7},
{"month": 10, "L": 1.0, "M": 71.4818, "S": 0.03452, "p3": 66.8, "p50": 71.5, "p97": 76.1},
{"month": 11, "L": 1.0, "M": 72.7710, "S": 0.03464, "p3": 68.0, "p50": 72.8, "p97": 77.5},
{"month": 12, "L": 1.0, "M": 74.0150, "S": 0.03479, "p3": 69.2, "p50": 74.0, "p97": 78.9},
{"month": 13, "L": 1.0, "M": 75.2176, "S": 0.03496, "p3": 70.3, "p50": 75.2, "p97": 80.2},
{"month": 14, "L": 1.0, "M": 76.3817, "S": 0.03514, "p3": 71.3, "p50": 76.4, "p97": 81.4},
{"month": 15, "L": 1.0, "M": 77.5099, "S": 0.03534, "p3": 72.4, "p50": 77.5, "p97": 82.7},
{"month": 16, "L": 1.0, "M": 78.6055, "S": 0.03555, "p3": 73.3, "p50": 78.6, "p97": 83.9},
{"month": 17, "L": 1.0, "M": 79.6710, "S": 0.03576, "p3": 74.3, "p50": 79.7, "p97": 85.0},
{"month": 18, "L": 1.0, "M": 80.7079, "S": 0.03598, "p3": 75.2, "p50": 80.7, "p97": 86.2},
{"month": 19, "L": 1.0, "M": 81.7182, "S": 0.03620, "p3": 76.2, "p50": 81.7, "p97": 87.3},
{"month": 20, "L": 1.0, "M": 82.7036, "S": 0.03643, "p3": 77.0, "p50": 82.7, "p97": 88.4},
{"month": 21, "L": 1.0, "M": 83.6654, "S": 0.03666, "p3": 77.9, "p50": 83.7, "p97": 89.4},
{"month": 22, "L": 1.0, "M": 84.6040, "S": 0.03688, "p3": 78.7, "p50": 84.6, "p97": 90.5},
{"month": 23, "L": 1.0, "M": 85.5202, "S": 0.03711, "p3": 79.6, "p50": 85.5, "p97": 91.5},
{"month": 24, "L": 1.0, "M": 86.4153, "S": 0.03734, "p3": 80.3, "p50": 86.4, "p97": 92.5},
]
def _interpolate_lms(table: List[Dict[str, float]], age_months: float) -> Tuple[float, float, float, float, float, float]:
"""Linearly interpolates L, M, S, p3, p50, p97 for a given age in months."""
m_clamped = max(0.0, min(float(table[-1]["month"]), float(age_months)))
m_low = int(math.floor(m_clamped))
m_high = int(math.ceil(m_clamped))
r_low = table[m_low]
if m_low == m_high:
return r_low["L"], r_low["M"], r_low["S"], r_low["p3"], r_low["p50"], r_low["p97"]
r_high = table[m_high]
frac = m_clamped - m_low
L = r_low["L"] + frac * (r_high["L"] - r_low["L"])
M = r_low["M"] + frac * (r_high["M"] - r_low["M"])
S = r_low["S"] + frac * (r_high["S"] - r_low["S"])
p3 = r_low["p3"] + frac * (r_high["p3"] - r_low["p3"])
p50 = r_low["p50"] + frac * (r_high["p50"] - r_low["p50"])
p97 = r_low["p97"] + frac * (r_high["p97"] - r_low["p97"])
return L, M, S, p3, p50, p97
def calculate_percentile(metric: str, value: float, age_months: float) -> Dict[str, Any]:
"""
Computes exact WHO z-score and percentile using the LMS formula.
metric: 'weight' (in kg) or 'height' (in cm)
"""
table = WHO_GIRLS_WEIGHT if metric == "weight" else WHO_GIRLS_HEIGHT
L, M, S, p3, p50, p97 = _interpolate_lms(table, age_months)
if value <= 0 or M <= 0 or S <= 0:
return {
"value": value,
"age_months": round(age_months, 2),
"z_score": 0.0,
"percentile": 50.0,
"percentile_str": "P50",
"p3": round(p3, 2),
"p50": round(p50, 2),
"p97": round(p97, 2),
"in_normal_range": True,
"status": "normal",
}
# Box-Cox power exponential LMS formula
if abs(L) > 1e-6:
z = ((value / M) ** L - 1.0) / (L * S)
else:
z = math.log(value / M) / S
# Cumulative standard normal distribution
p = 0.5 * (1.0 + math.erf(z / math.sqrt(2.0))) * 100.0
p_clamped = max(0.1, min(99.9, p))
in_normal = 3.0 <= p_clamped <= 97.0
if p_clamped < 3.0:
status = "unter P3"
elif p_clamped > 97.0:
status = "über P97"
else:
status = "im WHO-Normbereich"
return {
"value": value,
"age_months": round(age_months, 2),
"z_score": round(z, 2),
"percentile": round(p_clamped, 1),
"percentile_str": f"P{int(round(p_clamped))}",
"p3": round(p3, 2),
"p50": round(p50, 2),
"p97": round(p97, 2),
"in_normal_range": in_normal,
"status": status,
}
def format_growth_feedback(
weight_kg: Optional[float],
height_cm: Optional[float],
age_months: float,
date_str: str,
) -> str:
"""Formats clear, comforting feedback for CLI and Hebamme agent responses."""
parts = []
if weight_kg is not None:
pw = calculate_percentile("weight", weight_kg, age_months)
parts.append(f"{weight_kg:.2f} kg ({pw['percentile_str']} · WHO Median: {pw['p50']:.1f} kg)")
if height_cm is not None:
ph = calculate_percentile("height", height_cm, age_months)
parts.append(f"{height_cm:.1f} cm ({ph['percentile_str']} · WHO Median: {ph['p50']:.1f} cm)")
if not parts:
return f"📏 Messung ({date_str}) ohne Werte geloggt."
details = " · ".join(parts)
return f"📏 Wachstum ({date_str}, ~{age_months:.1f} Mon): {details} [Normbereich: P3–P97]"
+46
View File
@@ -737,5 +737,51 @@ def test_feeding_logging(temp_datastore: DatastoreEngine):
assert daily[0]["total_consumed_ml"] == 160.0
def test_growth_logging(temp_datastore: DatastoreEngine):
"""Verifies baby growth measurements, WHO percentile feedback, and DuckDB views."""
# Ensure Mila is in people table with birthdate
temp_datastore.put_entity(
collection="people",
record={"id": "mila", "name": "Mila Graf", "birthdate": "2026-03-14", "role": "child"},
)
# Log measurement at approx 6.9 months (2026-10-12)
res = temp_datastore.add_event(
kind="growth.measurement",
subject="mila",
actor="agent:hebamme",
start="2026-10-12T10:00:00+02:00",
payload={"weight_kg": 7.4, "height_cm": 67.5},
note="U-Untersuchung Vorbereitung",
)
assert res["status"] == "success"
fb = res["feedback"]
assert "weight_kg" in fb
assert fb["weight_kg"] == 7.4
assert fb["height_cm"] == 67.5
assert "weight_percentile" in fb
assert 30 <= fb["weight_percentile"] <= 60 # around P41
assert 45 <= fb["height_percentile"] <= 70 # around P56
assert "Normbereich" in fb["day_total_formatted"]
# Verify DuckDB views
growth_rows = temp_datastore.query("SELECT * FROM v_growth WHERE subject = 'mila'")
assert len(growth_rows) == 1
assert growth_rows[0]["weight_kg"] == 7.4
assert growth_rows[0]["height_cm"] == 67.5
assert growth_rows[0]["note"] == "U-Untersuchung Vorbereitung"
daily_rows = temp_datastore.query("SELECT * FROM v_growth_daily WHERE subject = 'mila'")
assert len(daily_rows) == 1
assert daily_rows[0]["measurement_count"] == 1
# Verify get_summary returns growth_latest
summary = temp_datastore.get_summary(subject="mila", date_str="2026-10-12")
assert "growth_latest" in summary
assert summary["growth_latest"]["weight_kg"] == 7.4
assert summary["growth_latest"]["height_cm"] == 67.5