from __future__ import annotations import datetime import json import logging import math import os import re import threading import time from pathlib import Path from typing import Any, Dict, List, Optional, Tuple, Union from zoneinfo import ZoneInfo import duckdb from ulid import ULID from app.config import DATA_DIR from app.git_sync import GitSyncManager from app.models import EventCreateRequest, EventStopRequest, EntityPutRequest, GarminSyncRequest from app.who_growth import calculate_percentile logger = logging.getLogger("duckdb_backend.engine") BERLIN_TZ = ZoneInfo("Europe/Berlin") MILA_BIRTHDATE = datetime.date(2026, 3, 14) WEEKDAYS_DE = {0: "Mo", 1: "Di", 2: "Mi", 3: "Do", 4: "Fr", 5: "Sa", 6: "So"} WEEKDAYS_LONG_DE = { 0: "Montag", 1: "Dienstag", 2: "Mittwoch", 3: "Donnerstag", 4: "Freitag", 5: "Samstag", 6: "Sonntag", } VALID_COLS_MAP = { "nutrition": { "id", "kind", "subject", "actor", "start", "end", "estimated", "meal", "note", "payload", "source_ref", "created_at", "updated_at", "deleted_at" }, "mila": { "id", "kind", "subject", "actor", "start", "end", "note", "payload", "source_ref", "created_at", "updated_at", "deleted_at" }, "sleep": { "id", "subject", "calendarDate", "sleepType", "location", "isOpen", "startIso", "endIso", "sleepStartTimestampGMT", "sleepEndTimestampGMT", "sleepStartTimestampLocal", "sleepEndTimestampLocal", "sleepTimeSeconds", "napTimeSeconds", "deepSleepSeconds", "lightSleepSeconds", "remSleepSeconds", "awakeSleepSeconds", "awakeCount", "avgSleepStress", "avgHeartRate", "averageSpO2Value", "lowestSpO2Value", "highestSpO2Value", "averageRespirationValue", "sleepScores", "avgOvernightHrv", "hrvStatus", "bodyBatteryChange", "restingHeartRate", "restlessMomentsCount", "note", "actor", "created_at", "updated_at", "deleted_at" }, } def parse_duration(val: str) -> datetime.timedelta: s = val.strip().lower() if not s: raise ValueError("Empty duration string") m_dec_h = re.fullmatch(r"^(\d+(?:\.\d+)?)\s*(?:h|stunden?)$", s) if m_dec_h: return datetime.timedelta(hours=float(m_dec_h.group(1))) m_dec_m = re.fullmatch(r"^(\d+(?:\.\d+)?)\s*(?:m|min|minuten?)$", s) if m_dec_m: return datetime.timedelta(minutes=float(m_dec_m.group(1))) m_comp = re.fullmatch( r"^(?:(\d+)\s*(?:h|stunden?))?\s*(?:(\d+)\s*(?:m|min|minuten?))?\s*(?:(\d+)\s*(?:s|sek|sekunden?))?$", s, ) if m_comp and any(m_comp.groups()): h = int(m_comp.group(1) or 0) m = int(m_comp.group(2) or 0) sec = int(m_comp.group(3) or 0) return datetime.timedelta(hours=h, minutes=m, seconds=sec) if s.isdigit(): return datetime.timedelta(minutes=int(s)) raise ValueError(f"Unrecognized duration format: '{val}'. Expected '15m', '1h', etc.") def calculate_age_str(as_of: datetime.date) -> str: days = (as_of - MILA_BIRTHDATE).days if days < 0: return "Geboren am 14.03.2026" months = days // 30.4375 weeks = (days % 30.4375) // 7 if months < 1: return f"{days // 7} Wochen alt" if weeks > 0: return f"{int(months)} Monate, {int(weeks)} Wochen alt" return f"{int(months)} Monate alt" class DuckDBEngine: def __init__(self, data_dir: Optional[Path] = None, git_sync_manager: Optional[GitSyncManager] = None): self.data_dir = (data_dir or DATA_DIR).resolve() self.git_sync_manager = git_sync_manager self._lock = threading.RLock() self.con = duckdb.connect(":memory:") self._init_schemas() self.reload_all() def _init_schemas(self): with self._lock: try: self.con.execute("INSTALL icu; LOAD icu;") self.con.execute("SET TimeZone = 'UTC';") except Exception: pass self.con.execute(""" CREATE MACRO IF NOT EXISTS distance_m(lat1, lon1, lat2, lon2) AS 6371000 * 2 * asin(sqrt( sin(radians(lat2 - lat1) / 2) ^ 2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(radians(lon2 - lon1) / 2) ^ 2 )); """) schema_dir = self.data_dir / "_schema" if not schema_dir.is_dir(): candidates = [ Path("/wiki/data/_schema"), Path.cwd() / "knowledge-wiki" / "data" / "_schema", Path.cwd().parent.parent / "knowledge-wiki" / "data" / "_schema", ] for c in candidates: if c.is_dir(): schema_dir = c break if schema_dir.is_dir(): for f in ["envelope.sql", "entities.sql", "views.sql"]: sql_file = schema_dir / f if sql_file.is_file(): self.con.execute(sql_file.read_text(encoding="utf-8")) else: self._create_fallback_schema() self.con.execute(""" CREATE OR REPLACE MACRO distance_m(lat1, lon1, lat2, lon2) AS ( 6371000.0 * 2.0 * asin(sqrt( sin(radians(lat2 - lat1) / 2.0) * sin(radians(lat2 - lat1) / 2.0) + cos(radians(lat1)) * cos(radians(lat2)) * sin(radians(lon2 - lon1) / 2.0) * sin(radians(lon2 - lon1) / 2.0) )) ); """) def _create_fallback_schema(self): self.con.execute(""" CREATE TABLE IF NOT EXISTS sleep ( id VARCHAR PRIMARY KEY, subject VARCHAR NOT NULL, calendarDate DATE NOT NULL, sleepType VARCHAR NOT NULL, location VARCHAR, isOpen BOOLEAN DEFAULT false, startIso TIMESTAMPTZ NOT NULL, endIso TIMESTAMPTZ, sleepStartTimestampGMT BIGINT, sleepEndTimestampGMT BIGINT, sleepStartTimestampLocal BIGINT, sleepEndTimestampLocal BIGINT, sleepTimeSeconds INTEGER, napTimeSeconds INTEGER, deepSleepSeconds INTEGER, lightSleepSeconds INTEGER, remSleepSeconds INTEGER, awakeSleepSeconds INTEGER, awakeCount INTEGER, avgSleepStress DOUBLE, avgHeartRate DOUBLE, averageSpO2Value DOUBLE, lowestSpO2Value INTEGER, highestSpO2Value INTEGER, averageRespirationValue DOUBLE, sleepScores JSON, avgOvernightHrv DOUBLE, hrvStatus VARCHAR, bodyBatteryChange INTEGER, restingHeartRate INTEGER, restlessMomentsCount INTEGER, note VARCHAR, actor VARCHAR NOT NULL, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ ); CREATE TABLE IF NOT EXISTS nutrition ( id VARCHAR PRIMARY KEY, kind VARCHAR NOT NULL, subject VARCHAR NOT NULL, actor VARCHAR NOT NULL, start TIMESTAMPTZ NOT NULL, "end" TIMESTAMPTZ, estimated BOOLEAN DEFAULT false, meal VARCHAR, note VARCHAR, payload JSON NOT NULL, source_ref VARCHAR, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ ); CREATE TABLE IF NOT EXISTS mila ( id VARCHAR PRIMARY KEY, kind VARCHAR NOT NULL, subject VARCHAR NOT NULL DEFAULT 'mila', actor VARCHAR NOT NULL, start TIMESTAMPTZ NOT NULL, "end" TIMESTAMPTZ, note VARCHAR, payload JSON NOT NULL, source_ref VARCHAR, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ ); CREATE TABLE IF NOT EXISTS people ( id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL, role VARCHAR, birthdate DATE, notes VARCHAR ); CREATE TABLE IF NOT EXISTS foods ( id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL, brand VARCHAR, category VARCHAR, per_100 JSON, density_g_per_ml DOUBLE, ingredients_text VARCHAR, barcode VARCHAR, source VARCHAR, description VARCHAR ); CREATE TABLE IF NOT EXISTS recipes ( id VARCHAR PRIMARY KEY, title VARCHAR NOT NULL, servings INTEGER DEFAULT 1, ingredients JSON, estimated_delta JSON, devices VARCHAR[], tags VARCHAR[], documents VARCHAR[], description VARCHAR ); CREATE TABLE IF NOT EXISTS places ( id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL, category VARCHAR NOT NULL, lat DOUBLE NOT NULL, lon DOUBLE NOT NULL, address VARCHAR, google_maps_url VARCHAR, tags VARCHAR[], attributes JSON, documents VARCHAR[], notes VARCHAR ); CREATE TABLE IF NOT EXISTS garmin_daily ( calendarDate DATE PRIMARY KEY, totalKilocalories INTEGER, activeKilocalories INTEGER, bmrKilocalories INTEGER, totalSteps INTEGER, restingHeartRate INTEGER, raw JSON ); """) def reload_all(self): """Reloads all JSONL files from data_dir into DuckDB using fast vector-optimized read_json_auto.""" with self._lock: # Clear tables for tbl in ["sleep", "nutrition", "mila", "foods", "recipes", "places", "people", "garmin_daily"]: try: self.con.execute(f'DELETE FROM "{tbl}";') except Exception: pass if not self.data_dir.is_dir(): return # 1. Entities entities_dir = self.data_dir / "entities" if entities_dir.is_dir(): for coll in ["foods", "recipes", "places", "people"]: f = entities_dir / f"{coll}.jsonl" if f.is_file() and f.stat().st_size > 0: try: self.con.execute(f'INSERT OR REPLACE INTO "{coll}" SELECT * FROM read_json_auto(?, union_by_name=true)', [str(f)]) except Exception as e: logger.warning(f"Fast load failed for {coll}, falling back: {e}") self._load_jsonl_file(f, coll) # 2. Nutrition nutrition_files = list((self.data_dir / "nutrition").glob("*.jsonl")) if (self.data_dir / "nutrition").is_dir() else [] valid_nutrition = [str(f) for f in nutrition_files if f.stat().st_size > 0] if valid_nutrition: try: self.con.execute('INSERT OR REPLACE INTO nutrition SELECT * FROM read_json_auto(?, union_by_name=true)', [valid_nutrition]) except Exception as e: logger.warning(f"Fast load failed for nutrition, falling back: {e}") for f in nutrition_files: self._load_jsonl_file(f, "nutrition") # 3. Sleep sleep_files = list((self.data_dir / "sleep").glob("*.jsonl")) if (self.data_dir / "sleep").is_dir() else [] valid_sleep = [str(f) for f in sleep_files if f.stat().st_size > 0] if valid_sleep: try: self.con.execute('INSERT OR REPLACE INTO sleep SELECT * FROM read_json_auto(?, union_by_name=true)', [valid_sleep]) except Exception as e: logger.warning(f"Fast load failed for sleep, falling back: {e}") for f in sleep_files: self._load_jsonl_file(f, "sleep") # 4. Mila mila_files = list((self.data_dir / "mila").glob("*.jsonl")) if (self.data_dir / "mila").is_dir() else [] valid_mila = [str(f) for f in mila_files if f.stat().st_size > 0] if valid_mila: try: self.con.execute( """ INSERT OR REPLACE INTO mila (id, kind, subject, actor, start, "end", note, payload, source_ref, created_at, updated_at, deleted_at) SELECT id, kind, subject, actor, start, "end", note, payload, source_ref, created_at, updated_at, deleted_at FROM read_json_auto(?, union_by_name=true) """, [valid_mila], ) except Exception as e: logger.warning(f"Fast load failed for mila, falling back: {e}") for f in mila_files: self._load_jsonl_file(f, "mila") # 5. Garmin daily garmin_dir = self.data_dir / "garmin" / "daily" garmin_files = list(garmin_dir.glob("*.jsonl")) if garmin_dir.is_dir() else [] valid_garmin = [str(f) for f in garmin_files if f.stat().st_size > 0] if valid_garmin: try: self.con.execute( """ INSERT OR REPLACE INTO garmin_daily SELECT calendarDate, totalKilocalories, activeKilocalories, bmrKilocalories, totalSteps, restingHeartRate, to_json(r) AS raw FROM read_json_auto(?, union_by_name=true) r """, [valid_garmin], ) except Exception as e: logger.warning(f"Fast load failed for garmin daily, falling back: {e}") for f in garmin_files: self._load_garmin_daily(f) def _load_jsonl_file(self, file_path: Path, table_name: str): if not file_path.is_file() or file_path.stat().st_size == 0: return allowed_cols = VALID_COLS_MAP.get(table_name) with open(file_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: r = json.loads(line) if allowed_cols: cols = [c for c in r.keys() if c in allowed_cols] else: cols = list(r.keys()) col_names = ", ".join([f'"{c}"' for c in cols]) placeholders = ", ".join(["?"] * len(cols)) vals = [json.dumps(r[c]) if isinstance(r[c], (dict, list)) else r[c] for c in cols] self.con.execute( f'INSERT OR REPLACE INTO "{table_name}" ({col_names}) VALUES ({placeholders})', vals, ) except Exception as e: logger.warning(f"Error loading {table_name} record from {file_path}: {e}") def _load_garmin_daily(self, file_path: Path): if not file_path.is_file() or file_path.stat().st_size == 0: return with open(file_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: r = json.loads(line) cal_date = r.get("calendarDate") if not cal_date: continue self.con.execute( """ INSERT OR REPLACE INTO garmin_daily (calendarDate, totalKilocalories, activeKilocalories, bmrKilocalories, totalSteps, restingHeartRate, raw) VALUES (?, ?, ?, ?, ?, ?, ?) """, [ cal_date, r.get("totalKilocalories"), r.get("activeKilocalories"), r.get("bmrKilocalories"), r.get("totalSteps"), r.get("restingHeartRate"), json.dumps(r, ensure_ascii=False), ], ) except Exception as e: logger.warning(f"Error loading garmin record from {file_path}: {e}") def get_table_counts(self) -> Dict[str, int]: with self._lock: counts = {} for tbl in ["sleep", "nutrition", "mila", "foods", "recipes", "places", "people", "garmin_daily"]: try: res = self.con.execute(f'SELECT count(*) FROM "{tbl}"').fetchone() counts[tbl] = res[0] if res else 0 except Exception: counts[tbl] = 0 return counts # -------------------------------------------------------------------------- # Read Queries # -------------------------------------------------------------------------- def query(self, sql: str, params: Optional[list] = None) -> List[Dict[str, Any]]: clean_sql = sql.strip() disallowed = ["DROP ", "ALTER ", "TRUNCATE ", "DELETE ", "UPDATE ", "INSERT "] for d in disallowed: if clean_sql.upper().startswith(d): raise ValueError(f"Direct {d.strip()} queries not permitted via query(). Use dedicated write methods.") with self._lock: cursor = self.con.execute(clean_sql, params or []) cols = [desc[0] for desc in cursor.description] rows = cursor.fetchall() results = [] for row in rows: rec = {} for col, val in zip(cols, row): if isinstance(val, (datetime.datetime, datetime.date)): rec[col] = val.isoformat() else: rec[col] = val results.append(rec) return results # -------------------------------------------------------------------------- # Curated Mila Dashboard Payload # -------------------------------------------------------------------------- def get_mila_dashboard(self, as_of_date: Optional[str] = None) -> Dict[str, Any]: t0 = time.perf_counter() now_berlin = datetime.datetime.now(BERLIN_TZ) today_date = datetime.date.fromisoformat(as_of_date) if as_of_date else now_berlin.date() today_str = today_date.strftime("%Y-%m-%d") today_pretty = f"{WEEKDAYS_LONG_DE[today_date.weekday()]}, {today_date.strftime('%d.%m.%Y')}" age_str = calculate_age_str(today_date) with self._lock: # 1. Active open sleep check open_sleep = self.con.execute( """ SELECT id, start, local_start, sleep_type, location, note, date_diff('minute', start, CURRENT_TIMESTAMP) AS duration_mins FROM v_sleep WHERE subject = 'mila' AND is_open = true ORDER BY start DESC LIMIT 1 """ ).fetchone() is_sleeping = open_sleep is not None active_sleep_id = open_sleep[0] if is_sleeping else None active_sleep_duration_str = "" active_sleep_start_time = "" active_sleep_iso = "" wake_window_str = "" wake_window_minutes = 0 last_wake_time_str = "" last_wake_iso = "" if is_sleeping: dur = max(0, open_sleep[6] or 0) h, m = divmod(dur, 60) active_sleep_duration_str = f"{h} h {m:02d} min" if h > 0 else f"{m} min" s_dt = datetime.datetime.fromisoformat(str(open_sleep[2])) active_sleep_start_time = s_dt.strftime("%H:%M") active_sleep_iso = s_dt.isoformat() wake_window_minutes = 0 else: last_closed = self.con.execute( """ SELECT "end", local_end, date_diff('minute', "end", CURRENT_TIMESTAMP) AS mins_ago FROM v_sleep WHERE subject = 'mila' AND is_open = false AND "end" IS NOT NULL ORDER BY "end" DESC LIMIT 1 """ ).fetchone() if last_closed: mins_ago = max(0, last_closed[2] or 0) wake_window_minutes = mins_ago h, m = divmod(mins_ago, 60) wake_window_str = f"{h} h {m:02d} min" if h > 0 else f"{m} min" e_dt = datetime.datetime.fromisoformat(str(last_closed[1])) last_wake_time_str = e_dt.strftime("%H:%M") last_wake_iso = e_dt.isoformat() # Parent Cockpit Live Status: Last Feed & Last Diaper last_feed = self.con.execute( """ SELECT "start", local_time, date_diff('minute', "start", CURRENT_TIMESTAMP) AS mins_ago, consumed_ml, solid_g, item, kind FROM v_feeding WHERE subject = 'mila' AND "start" IS NOT NULL ORDER BY "start" DESC LIMIT 1 """ ).fetchone() last_feed_iso = None last_feed_time_str = None last_feed_since_str = "—" last_feed_since_minutes = None last_feed_summary = None if last_feed: f_mins = max(0, last_feed[2] or 0) last_feed_since_minutes = f_mins f_h, f_m = divmod(f_mins, 60) last_feed_since_str = f"{f_h} h {f_m:02d} min" if f_h > 0 else f"{f_m} min" f_dt = datetime.datetime.fromisoformat(str(last_feed[1])) last_feed_time_str = f_dt.strftime("%H:%M") last_feed_iso = f_dt.isoformat() if last_feed[3]: last_feed_summary = f"{last_feed[3]} ml" elif last_feed[4]: last_feed_summary = f"{last_feed[4]} g {last_feed[5] or ''}".strip() else: last_feed_summary = last_feed[5] or "Mahlzeit" last_diaper = self.con.execute( """ SELECT "start", local_time, date_diff('minute', "start", CURRENT_TIMESTAMP) AS mins_ago, pee, poo, texture FROM v_diaper WHERE subject = 'mila' AND "start" IS NOT NULL ORDER BY "start" DESC LIMIT 1 """ ).fetchone() last_diaper_iso = None last_diaper_time_str = None last_diaper_since_str = "—" last_diaper_since_minutes = None last_diaper_type = None if last_diaper: d_mins = max(0, last_diaper[2] or 0) last_diaper_since_minutes = d_mins d_h, d_m = divmod(d_mins, 60) last_diaper_since_str = f"{d_h} h {d_m:02d} min" if d_h > 0 else f"{d_m} min" d_dt = datetime.datetime.fromisoformat(str(last_diaper[1])) last_diaper_time_str = d_dt.strftime("%H:%M") last_diaper_iso = d_dt.isoformat() if last_diaper[3] and last_diaper[4]: last_diaper_type = "Pipi & Stuhl" elif last_diaper[4]: last_diaper_type = "Stuhl" elif last_diaper[3]: last_diaper_type = "Pipi" else: last_diaper_type = "Windel" last_stool = self.con.execute( """ SELECT "start", local_time, date_diff('minute', "start", CURRENT_TIMESTAMP) AS mins_ago FROM v_diaper WHERE subject = 'mila' AND poo = true AND "start" IS NOT NULL ORDER BY "start" DESC LIMIT 1 """ ).fetchone() last_stool_iso = None last_stool_str = "—" if last_stool: st_mins = max(0, last_stool[2] or 0) st_h, st_m = divmod(st_mins, 60) last_stool_str = f"{st_h} h {st_m:02d} min" if st_h > 0 else f"{st_m} min" st_dt = datetime.datetime.fromisoformat(str(last_stool[1])) last_stool_iso = st_dt.isoformat() # 2. Today's sleep sessions today_sessions = self.con.execute( f""" SELECT id, strftime(local_start, '%H:%M') AS start_fmt, CASE WHEN is_open THEN NULL ELSE strftime(local_end, '%H:%M') END AS end_fmt, sleep_type, location, note, duration_minutes, is_open FROM v_sleep WHERE subject = 'mila' AND ( log_date = '{today_str}' OR (sleep_type = 'night' AND CAST(local_end AS DATE) = '{today_str}') ) ORDER BY start ASC """ ).fetchall() today_naps = [] today_nights = [] for s in today_sessions: s_dict = { "id": s[0], "start": s[1], "end": s[2], "sleep_type": s[3], "location": s[4] or "", "note": s[5] or "", "duration_minutes": s[6], "is_open": bool(s[7]), } if s[3] == "night": today_nights.append(s_dict) else: today_naps.append(s_dict) # Daily sleep aggregate today_agg = self.con.execute( f""" SELECT total_sleep_hours, nap_count, night_sleep_minutes, nap_sleep_minutes FROM v_sleep_daily WHERE subject = 'mila' AND log_date = '{today_str}' """ ).fetchone() morning_night = next((s for s in today_nights if not s["is_open"] and s["duration_minutes"]), None) if morning_night and morning_night["duration_minutes"]: today_night_min = morning_night["duration_minutes"] else: today_night_min = today_agg[2] if today_agg and today_agg[2] is not None else 0 today_nap_count = today_agg[1] if today_agg and today_agg[1] is not None else len(today_naps) today_nap_min = sum(n["duration_minutes"] or 0 for n in today_naps) today_tot_min = today_night_min + today_nap_min today_tot_hours = f"{today_tot_min / 60.0:.2f} h" if today_tot_min > 0 else "0 h" today_night_h, today_night_m = divmod(today_night_min, 60) today_night_dur_str = f"{today_night_h} h {today_night_m:02d} min" if today_night_h > 0 else f"{today_night_m} min" # 3. Today's feedings feedings_rows = self.con.execute( f""" SELECT id, strftime(local_time, '%H:%M') AS time_str, consumed_ml, milk_type, item, solid_g, kcal, kind, note FROM v_feeding WHERE subject = 'mila' AND log_date = '{today_str}' ORDER BY start ASC """ ).fetchall() feedings_list = [] for f in feedings_rows: feedings_list.append({ "id": f[0], "time": f[1], "consumed_ml": f[2], "milk_type": f[3] or "", "item": f[4] or "", "solid_g": f[5], "kcal": f[6], "kind": f[7], "note": f[8] or "", }) feed_agg = self.con.execute( f""" SELECT bottle_count, total_consumed_ml, solid_count, total_solid_g, total_kcal FROM v_feeding_daily WHERE subject = 'mila' AND log_date = '{today_str}' """ ).fetchone() today_bottle_count = int(feed_agg[0]) if feed_agg and feed_agg[0] is not None else 0 today_bottle_ml = int(feed_agg[1]) if feed_agg and feed_agg[1] is not None else 0 today_solid_count = int(feed_agg[2]) if feed_agg and feed_agg[2] is not None else 0 today_solid_g = int(feed_agg[3]) if feed_agg and feed_agg[3] is not None else 0 today_feeding_kcal = int(feed_agg[4]) if feed_agg and feed_agg[4] is not None else 0 # 4. Today's diapers diaper_rows = self.con.execute( f""" SELECT id, strftime(local_time, '%H:%M') AS time_str, pee, poo, texture, note FROM v_diaper WHERE subject = 'mila' AND log_date = '{today_str}' ORDER BY start ASC """ ).fetchall() diaper_list = [] for d in diaper_rows: diaper_list.append({ "id": d[0], "time": d[1], "pee": bool(d[2]), "poo": bool(d[3]), "texture": d[4] or "", "note": d[5] or "", }) diaper_agg = self.con.execute( f""" SELECT total_changes, wet_count, dirty_count FROM v_diaper_daily WHERE subject = 'mila' AND log_date = '{today_str}' """ ).fetchone() today_diaper_count = int(diaper_agg[0]) if diaper_agg and diaper_agg[0] is not None else 0 today_wet_count = int(diaper_agg[1]) if diaper_agg and diaper_agg[1] is not None else 0 today_dirty_count = int(diaper_agg[2]) if diaper_agg and diaper_agg[2] is not None else 0 # 5. 7d and 30d sleep averages avg_7 = self.con.execute( """ SELECT ROUND(AVG(total_sleep_hours), 2), ROUND(AVG(night_sleep_minutes)/60.0, 2), ROUND(AVG(nap_sleep_minutes)/60.0, 2), ROUND(AVG(nap_count), 1) FROM (SELECT total_sleep_hours, night_sleep_minutes, nap_sleep_minutes, nap_count FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7) """ ).fetchone() avg_7_tot = f"{avg_7[0]:.2f} h" if avg_7 and avg_7[0] is not None else "—" avg_7_night = f"{avg_7[1]:.2f} h" if avg_7 and avg_7[1] is not None else "—" avg_7_naps = f"{avg_7[3]} Naps ({avg_7[2]:.2f} h)" if avg_7 and avg_7[3] is not None else "—" avg_30 = self.con.execute( """ SELECT ROUND(AVG(total_sleep_hours), 2) FROM (SELECT total_sleep_hours FROM v_sleep_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 30) """ ).fetchone() avg_30_tot = f"{avg_30[0]:.2f} h" if avg_30 and avg_30[0] is not None else "—" feed_avg_7 = self.con.execute( """ SELECT ROUND(AVG(total_consumed_ml), 0), ROUND(AVG(total_solid_g), 0), ROUND(AVG(total_kcal), 0), ROUND(AVG(bottle_count), 1) FROM (SELECT total_consumed_ml, total_solid_g, total_kcal, bottle_count FROM v_feeding_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7) """ ).fetchone() diaper_avg_7 = self.con.execute( """ SELECT ROUND(AVG(wet_count), 1), ROUND(AVG(total_changes), 1) FROM (SELECT wet_count, total_changes FROM v_diaper_daily WHERE subject = 'mila' ORDER BY log_date DESC LIMIT 7) """ ).fetchone() # 6. Past 30 Days History Table & sessions history_rows_data = self.con.execute( """ WITH dates AS ( SELECT DISTINCT log_date FROM v_sleep_daily WHERE subject = 'mila' UNION SELECT DISTINCT log_date FROM v_feeding_daily WHERE subject = 'mila' UNION SELECT DISTINCT log_date FROM v_diaper_daily WHERE subject = 'mila' ) SELECT dt.log_date, COALESCE(s.total_sleep_hours, 0.0) AS total_sleep_hours, COALESCE(s.night_sleep_minutes, 0) AS night_sleep_minutes, COALESCE(s.nap_count, 0) AS nap_count, COALESCE(s.nap_sleep_minutes, 0) AS nap_sleep_minutes, COALESCE(f.total_consumed_ml, 0) AS feed_ml, COALESCE(f.bottle_count, 0) AS feed_bottles, COALESCE(f.total_solid_g, 0) AS feed_solid_g, COALESCE(f.solid_count, 0) AS feed_solid_count, COALESCE(d.total_changes, 0) AS diaper_cnt, COALESCE(d.dirty_count, 0) AS diaper_dirty, COALESCE(d.wet_count, 0) AS diaper_wet, COALESCE(f.total_kcal, 0) AS feed_kcal FROM dates dt LEFT JOIN v_sleep_daily s ON dt.log_date = s.log_date AND s.subject = 'mila' LEFT JOIN v_feeding_daily f ON dt.log_date = f.log_date AND f.subject = 'mila' LEFT JOIN v_diaper_daily d ON dt.log_date = d.log_date AND d.subject = 'mila' ORDER BY dt.log_date DESC LIMIT 30 """ ).fetchall() all_recent_sessions = self.con.execute( """ SELECT log_date, strftime(local_start, '%H:%M'), CASE WHEN is_open THEN NULL ELSE strftime(local_end, '%H:%M') END, sleep_type, duration_minutes, is_open, note, local_start, local_end FROM v_sleep WHERE subject = 'mila' AND log_date >= (CURRENT_DATE - INTERVAL 30 DAY) ORDER BY start ASC """ ).fetchall() sessions_by_date: Dict[str, list] = {} for row in all_recent_sessions: d_key = str(row[0]) if d_key not in sessions_by_date: sessions_by_date[d_key] = [] sessions_by_date[d_key].append({ "start": row[1], "end": row[2] or "läuft", "type": row[3], "duration_minutes": row[4], "is_open": bool(row[5]), "note": row[6] or "", "local_start": str(row[7]), "local_end": str(row[8]) if row[8] else None, }) history_list = [] for r in history_rows_data: d_str = str(r[0]) d_obj = datetime.date.fromisoformat(d_str) nh, nm = divmod(r[2] or 0, 60) d_sessions = sessions_by_date.get(d_str, []) night_durs = [s["duration_minutes"] for s in d_sessions if s["type"] == "night" and s["duration_minutes"]] longest_night = max(night_durs) if night_durs else int(r[2] or 0) wake_windows = [] day_sessions = [s for s in d_sessions if s["type"] == "nap" and s.get("local_start") and s.get("local_end")] for i in range(len(day_sessions) - 1): try: e_prev = datetime.datetime.fromisoformat(day_sessions[i]["local_end"]) s_next = datetime.datetime.fromisoformat(day_sessions[i + 1]["local_start"]) diff = int((s_next - e_prev).total_seconds() / 60) if 30 <= diff <= 360: wake_windows.append(diff) except Exception: pass avg_wake = round(sum(wake_windows) / len(wake_windows)) if wake_windows else None history_list.append({ "date": d_str, "weekday": WEEKDAYS_DE[d_obj.weekday()], "formatted_date": d_obj.strftime("%d.%m."), "is_today": (d_str == today_str), "total_hours": round(r[1], 2) if r[1] is not None else 0.0, "night_minutes": int(r[2] or 0), "night_duration_str": f"{nh} h {nm:02d} min" if nh > 0 else f"{nm} min", "nap_minutes": int(r[4] or 0), "nap_count": int(r[3] or 0), "nap_str": f"{r[3]} ({r[4]} min)", "longest_night_stretch_min": longest_night, "avg_wake_window_min": avg_wake, "feed_bottles_ml": int(r[5]), "feed_bottles_count": int(r[6]), "feed_solid_g": int(r[7]), "feed_solid_count": int(r[8]), "feed_total_kcal": int(r[12] or 0), "diaper_changes": int(r[9]), "diaper_dirty": int(r[10]), "diaper_wet": int(r[11] or 0), "sessions": d_sessions, }) # 7. Growth Measurements & Percentiles growth_rows = self.con.execute( """ SELECT id, log_date, weight_kg, height_cm, head_circ_cm, note FROM v_growth WHERE subject = 'mila' ORDER BY start ASC """ ).fetchall() growth_measurements = [] for row in growth_rows: r_id, r_date, 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 growth_measurements.append({ "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 "—", "head_circ_cm": r_hc, "note": r_note or "", }) latest_weight_kg = growth_measurements[-1]["weight_kg"] if growth_measurements and growth_measurements[-1]["weight_kg"] else 7.5 age_days_calc = (today_date - MILA_BIRTHDATE).days age_months_calc = max(0.0, age_days_calc / 30.4375) age_norms = { "age_months": round(age_months_calc, 1), "sleep_total_hours_min": 12.0, "sleep_total_hours_max": 15.0, "night_sleep_hours_min": 10.0, "night_sleep_hours_max": 11.5, "longest_night_stretch_hours_min": 5.0, "longest_night_stretch_hours_max": 8.5, "nap_sleep_hours_min": 2.5, "nap_sleep_hours_max": 3.5, "nap_count_min": 2, "nap_count_max": 3, "wake_window_min_minutes": 135, "wake_window_max_minutes": 180, "feed_interval_min_minutes": 180, "feed_interval_max_minutes": 240, "milk_ml_min": 500, "milk_ml_max": 800, "bottle_size_ml_min": 150, "bottle_size_ml_max": 210, "solid_g_min": 150, "solid_g_max": 380, "solid_meals_target": "1–2 Breimahlzeiten (Mittags- & Abendbrei)", "energy_kcal_target": round(latest_weight_kg * 80) if latest_weight_kg else 600, "energy_kcal_min": 550, "energy_kcal_max": 700, "wet_diapers_min": 4, "wet_diapers_max": 6, } execution_time_ms = round((time.perf_counter() - t0) * 1000, 2) git_status = self.git_sync_manager.status if self.git_sync_manager else "ok" git_conflict = self.git_sync_manager.is_conflict if self.git_sync_manager else False return { "status": "ok", "execution_time_ms": execution_time_ms, "git_status": git_status, "git_conflict": git_conflict, "metadata": { "subject": "mila", "today_date": today_str, "today_pretty": today_pretty, "age_str": age_str, }, "live_status": { "is_sleeping": is_sleeping, "active_sleep_id": active_sleep_id, "active_sleep_duration_str": active_sleep_duration_str, "active_sleep_start_time": active_sleep_start_time, "active_sleep_iso": active_sleep_iso, "wake_window_str": wake_window_str, "wake_window_minutes": wake_window_minutes, "last_wake_time_str": last_wake_time_str, "last_wake_iso": last_wake_iso, "last_feed_iso": last_feed_iso, "last_feed_time_str": last_feed_time_str, "last_feed_since_str": last_feed_since_str, "last_feed_since_minutes": last_feed_since_minutes, "last_feed_summary": last_feed_summary, "last_diaper_iso": last_diaper_iso, "last_diaper_time_str": last_diaper_time_str, "last_diaper_since_str": last_diaper_since_str, "last_diaper_since_minutes": last_diaper_since_minutes, "last_diaper_type": last_diaper_type, "last_stool_iso": last_stool_iso, "last_stool_str": last_stool_str, }, "today": { "total_sleep_hours_str": today_tot_hours, "nap_count": today_nap_count, "nap_minutes": today_nap_min, "night_duration_str": today_night_dur_str, "naps": today_naps, "nights": today_nights, "feedings": feedings_list, "feeding_summary": { "bottle_count": today_bottle_count, "total_consumed_ml": today_bottle_ml, "solid_count": today_solid_count, "total_solid_g": today_solid_g, "total_kcal": today_feeding_kcal, }, "diapers": diaper_list, "diaper_summary": { "total_changes": today_diaper_count, "wet_count": today_wet_count, "dirty_count": today_dirty_count, }, }, "averages": { "avg_7_tot": avg_7_tot, "avg_7_night": avg_7_night, "avg_7_naps": avg_7_naps, "avg_30_tot": avg_30_tot, "avg_7_feed_ml": int(feed_avg_7[0]) if feed_avg_7 and feed_avg_7[0] is not None else 0, "avg_7_solid_g": int(feed_avg_7[1]) if feed_avg_7 and feed_avg_7[1] is not None else 0, "avg_7_kcal": int(feed_avg_7[2]) if feed_avg_7 and feed_avg_7[2] is not None else 0, "avg_7_bottles": float(feed_avg_7[3]) if feed_avg_7 and feed_avg_7[3] is not None else 0.0, "avg_7_diapers_wet": float(diaper_avg_7[0]) if diaper_avg_7 and diaper_avg_7[0] is not None else 0.0, "avg_7_diapers_total": float(diaper_avg_7[1]) if diaper_avg_7 and diaper_avg_7[1] is not None else 0.0, }, "history": history_list, "growth": { "measurements": growth_measurements, "latest": growth_measurements[-1] if growth_measurements else None, }, "age_norms": age_norms, } # -------------------------------------------------------------------------- # Curated Moritz Dashboard Payload # -------------------------------------------------------------------------- def get_moritz_dashboard(self, as_of_date: Optional[str] = None) -> Dict[str, Any]: t0 = time.perf_counter() now_berlin = datetime.datetime.now(BERLIN_TZ) today_date = datetime.date.fromisoformat(as_of_date) if as_of_date else now_berlin.date() yesterday_date = today_date - datetime.timedelta(days=1) today_str = today_date.strftime("%Y-%m-%d") yesterday_str = yesterday_date.strftime("%Y-%m-%d") today_pretty = f"{WEEKDAYS_LONG_DE[today_date.weekday()]}, {today_date.strftime('%d.%m.%Y')}" yesterday_pretty = f"{WEEKDAYS_LONG_DE[yesterday_date.weekday()]}, {yesterday_date.strftime('%d.%m.%Y')}" with self._lock: # 1. Heute Nutrition today_summary = self.con.execute( """ SELECT total_protein_g, total_carbs_g, total_sugar_g, total_fat_g, total_fiber_g, total_salt_g, total_kcal, item_count FROM v_nutrition_daily WHERE subject = 'moritz' AND log_date = ? """, [today_str], ).fetchone() today_kcal = round(today_summary[6]) if today_summary and today_summary[6] is not None else 0 today_protein = round(today_summary[0], 1) if today_summary and today_summary[0] is not None else 0.0 today_carbs = round(today_summary[1], 1) if today_summary and today_summary[1] is not None else 0.0 today_fat = round(today_summary[3], 1) if today_summary and today_summary[3] is not None else 0.0 today_count = int(today_summary[7]) if today_summary and today_summary[7] is not None else 0 today_meals_rows = self.con.execute( """ SELECT id, strftime(local_time, '%H:%M') AS time_str, item, qty, unit, protein_g, carbs_g, fat_g, fiber_g, kcal, estimated FROM v_nutrition WHERE subject = 'moritz' AND log_date = ? ORDER BY start ASC """, [today_str], ).fetchall() today_meals = [] for m in today_meals_rows: today_meals.append({ "id": m[0], "time": m[1], "item": m[2] or "", "qty": m[3], "unit": m[4] or "g", "protein_g": round(m[5], 1) if m[5] is not None else 0.0, "carbs_g": round(m[6], 1) if m[6] is not None else 0.0, "fat_g": round(m[7], 1) if m[7] is not None else 0.0, "fiber_g": round(m[8], 1) if m[8] is not None else 0.0, "kcal": round(m[9]) if m[9] is not None else 0, "estimated": bool(m[10]), }) # 2. Gestern 24h Netto-Bilanz yesterday_balance = self.con.execute( """ SELECT intake_kcal, expenditure_kcal, active_kcal, rmr_kcal, steps, balance_kcal, balance_status, protein_g, carbs_g, fat_g FROM v_daily_balance WHERE subject = 'moritz' AND log_date = ? """, [yesterday_str], ).fetchone() if not yesterday_balance or yesterday_balance[1] is None: g_row = self.con.execute( "SELECT total_expenditure_kcal, active_kcal, rmr_kcal, steps, resting_hr FROM v_garmin_daily WHERE log_date = ?", [yesterday_str], ).fetchone() n_row = self.con.execute( "SELECT total_kcal, total_protein_g, total_carbs_g, total_fat_g FROM v_nutrition_daily WHERE subject = 'moritz' AND log_date = ?", [yesterday_str], ).fetchone() intake_y = round(n_row[0]) if n_row and n_row[0] is not None else 0 exp_y = round(g_row[0]) if g_row and g_row[0] is not None else 0 act_y = round(g_row[1]) if g_row and g_row[1] is not None else 0 rmr_y = round(g_row[2]) if g_row and g_row[2] is not None else 0 steps_y = int(g_row[3]) if g_row and g_row[3] is not None else 0 if exp_y > 0: bal_y = intake_y - exp_y status_y = "Defizit" if bal_y < 0 else ("Überschuss" if bal_y > 0 else "Ausgeglichen") else: bal_y = None status_y = "Garmin ausstehend" prot_y = round(n_row[1], 1) if n_row and n_row[1] is not None else 0.0 carbs_y = round(n_row[2], 1) if n_row and n_row[2] is not None else 0.0 fat_y = round(n_row[3], 1) if n_row and n_row[3] is not None else 0.0 rhr_y = int(g_row[4]) if g_row and g_row[4] is not None else None else: intake_y = round(yesterday_balance[0]) if yesterday_balance[0] is not None else 0 exp_y = round(yesterday_balance[1]) if yesterday_balance[1] is not None else 0 act_y = round(yesterday_balance[2]) if yesterday_balance[2] is not None else 0 rmr_y = round(yesterday_balance[3]) if yesterday_balance[3] is not None else 0 steps_y = int(yesterday_balance[4]) if yesterday_balance[4] is not None else 0 bal_y = round(yesterday_balance[5]) if yesterday_balance[5] is not None else (intake_y - exp_y if exp_y > 0 else None) status_y = yesterday_balance[6] or ("Garmin ausstehend" if not exp_y else "Ausgeglichen") prot_y = round(yesterday_balance[7], 1) if yesterday_balance[7] is not None else 0.0 carbs_y = round(yesterday_balance[8], 1) if yesterday_balance[8] is not None else 0.0 fat_y = round(yesterday_balance[9], 1) if yesterday_balance[9] is not None else 0.0 rhr_check = self.con.execute( "SELECT resting_hr FROM v_garmin_daily WHERE log_date = ?", [yesterday_str] ).fetchone() rhr_y = int(rhr_check[0]) if rhr_check and rhr_check[0] is not None else None yesterday_meals_rows = self.con.execute( """ SELECT strftime(local_time, '%H:%M') AS time_str, item, qty, unit, protein_g, carbs_g, fat_g, fiber_g, kcal FROM v_nutrition WHERE subject = 'moritz' AND log_date = ? ORDER BY start ASC """, [yesterday_str], ).fetchall() yesterday_meals = [] for m in yesterday_meals_rows: yesterday_meals.append({ "time": m[0], "item": m[1] or "", "qty": m[2], "unit": m[3] or "g", "protein_g": round(m[4], 1) if m[4] is not None else 0.0, "carbs_g": round(m[5], 1) if m[5] is not None else 0.0, "fat_g": round(m[6], 1) if m[6] is not None else 0.0, "fiber_g": round(m[7], 1) if m[7] is not None else 0.0, "kcal": round(m[8]) if m[8] is not None else 0, }) # 3. Garmin Sleep & Recovery sleep_y_row = self.con.execute( """ SELECT total_sleep_hours, total_sleep_minutes, sleep_score, sleep_score_qualifier, avg_overnight_hrv, hrv_status, body_battery_change, resting_heart_rate, deep_sleep_minutes, light_sleep_minutes, rem_sleep_minutes, awake_sleep_minutes FROM v_sleep_daily WHERE subject = 'moritz' AND log_date = ? """, [yesterday_str], ).fetchone() sleep_date_label = yesterday_pretty if not sleep_y_row or sleep_y_row[1] is None or sleep_y_row[1] == 0: latest_sleep = self.con.execute( """ SELECT total_sleep_hours, total_sleep_minutes, sleep_score, sleep_score_qualifier, avg_overnight_hrv, hrv_status, body_battery_change, resting_heart_rate, deep_sleep_minutes, light_sleep_minutes, rem_sleep_minutes, awake_sleep_minutes, log_date FROM v_sleep_daily WHERE subject = 'moritz' AND total_sleep_minutes > 0 ORDER BY log_date DESC LIMIT 1 """ ).fetchone() if latest_sleep: sleep_y_row = latest_sleep[:12] d_obj = datetime.date.fromisoformat(str(latest_sleep[12])) sleep_date_label = f"{WEEKDAYS_LONG_DE[d_obj.weekday()]}, {d_obj.strftime('%d.%m.%Y')}" if sleep_y_row and sleep_y_row[1]: sleep_dur_tot_m = int(sleep_y_row[1]) s_dh, s_dm = divmod(sleep_dur_tot_m, 60) sleep_dur_str = f"{s_dh}h {s_dm:02d}m" sleep_score = sleep_y_row[2] sleep_qualifier = sleep_y_row[3] or "" hrv_val = round(sleep_y_row[4]) if sleep_y_row[4] is not None else None hrv_status = sleep_y_row[5] or "" bb_change = sleep_y_row[6] rhr_val = sleep_y_row[7] or rhr_y deep_m = int(sleep_y_row[8] or 0) light_m = int(sleep_y_row[9] or 0) rem_m = int(sleep_y_row[10] or 0) awake_m = int(sleep_y_row[11] or 0) tot_stages = (deep_m + light_m + rem_m + awake_m) or 1 deep_pct = round((deep_m / tot_stages) * 100, 1) light_pct = round((light_m / tot_stages) * 100, 1) rem_pct = round((rem_m / tot_stages) * 100, 1) awake_pct = round((awake_m / tot_stages) * 100, 1) else: sleep_dur_str = "—" sleep_score = None sleep_qualifier = "" hrv_val = None hrv_status = "" bb_change = None rhr_val = rhr_y deep_m = light_m = rem_m = awake_m = 0 deep_pct = light_pct = rem_pct = awake_pct = 0.0 # 4. Letzte 7 Tage seven_days = self.con.execute( """ WITH dates AS ( SELECT DISTINCT log_date FROM v_nutrition_daily WHERE subject = 'moritz' UNION SELECT DISTINCT log_date FROM v_garmin_daily ) SELECT dt.log_date, COALESCE(n.total_kcal, 0) AS intake_kcal, COALESCE(g.total_expenditure_kcal, 0) AS expenditure_kcal, COALESCE(g.active_kcal, 0) AS active_kcal, COALESCE(g.steps, 0) AS steps, CASE WHEN g.total_expenditure_kcal IS NOT NULL THEN (COALESCE(n.total_kcal, 0) - g.total_expenditure_kcal) ELSE NULL END AS balance_kcal, COALESCE(n.total_protein_g, 0.0) AS protein_g, COALESCE(n.total_carbs_g, 0.0) AS carbs_g, COALESCE(n.total_fat_g, 0.0) AS fat_g, g.resting_hr FROM dates dt LEFT JOIN v_nutrition_daily n ON dt.log_date = n.log_date AND n.subject = 'moritz' LEFT JOIN v_garmin_daily g ON dt.log_date = g.log_date WHERE dt.log_date < ? ORDER BY dt.log_date DESC LIMIT 7 """, [today_str], ).fetchall() seven_days_list = [] for r in reversed(seven_days): d_obj = datetime.date.fromisoformat(str(r[0])) seven_days_list.append({ "date": str(r[0]), "weekday": WEEKDAYS_DE[d_obj.weekday()], "intake_kcal": round(r[1]) if r[1] is not None else 0, "expenditure_kcal": round(r[2]) if r[2] is not None else 0, "active_kcal": round(r[3]) if r[3] is not None else 0, "steps": int(r[4]) if r[4] is not None else 0, "balance_kcal": round(r[5]) if r[5] is not None else None, "protein_g": round(r[6], 1) if r[6] is not None else 0.0, "carbs_g": round(r[7], 1) if r[7] is not None else 0.0, "fat_g": round(r[8], 1) if r[8] is not None else 0.0, "resting_hr": int(r[9]) if r[9] is not None else None, }) valid_intake_7 = [r["intake_kcal"] for r in seven_days_list if r["intake_kcal"] > 0] valid_exp_7 = [r["expenditure_kcal"] for r in seven_days_list if r["expenditure_kcal"] > 0] valid_bal_7 = [r["balance_kcal"] for r in seven_days_list if r["balance_kcal"] is not None] valid_steps_7 = [r["steps"] for r in seven_days_list if r["steps"] > 0] valid_prot_7 = [r["protein_g"] for r in seven_days_list if r["protein_g"] > 0] avg_intake_7 = round(sum(valid_intake_7) / len(valid_intake_7)) if valid_intake_7 else 0 avg_exp_7 = round(sum(valid_exp_7) / len(valid_exp_7)) if valid_exp_7 else 0 avg_bal_7 = round(sum(valid_bal_7) / len(valid_bal_7)) if valid_bal_7 else (avg_intake_7 - avg_exp_7) avg_steps_7 = round(sum(valid_steps_7) / len(valid_steps_7)) if valid_steps_7 else 0 avg_prot_7 = round(sum(valid_prot_7) / len(valid_prot_7), 1) if valid_prot_7 else 0.0 # 5. Letzte 30 Tage thirty_days = self.con.execute( """ WITH dates AS ( SELECT DISTINCT log_date FROM v_nutrition_daily WHERE subject = 'moritz' UNION SELECT DISTINCT log_date FROM v_garmin_daily ) SELECT dt.log_date, COALESCE(n.total_kcal, 0) AS intake_kcal, COALESCE(g.total_expenditure_kcal, 0) AS expenditure_kcal, COALESCE(g.active_kcal, 0) AS active_kcal, COALESCE(g.rmr_kcal, 0) AS rmr_kcal, COALESCE(g.steps, 0) AS steps, CASE WHEN g.total_expenditure_kcal IS NOT NULL THEN (COALESCE(n.total_kcal, 0) - g.total_expenditure_kcal) ELSE NULL END AS balance_kcal, CASE WHEN g.total_expenditure_kcal IS NULL THEN 'Garmin ausstehend' WHEN (COALESCE(n.total_kcal, 0) - g.total_expenditure_kcal) < 0 THEN 'Defizit' WHEN (COALESCE(n.total_kcal, 0) - g.total_expenditure_kcal) > 0 THEN 'Überschuss' ELSE 'Ausgeglichen' END AS balance_status, COALESCE(n.total_protein_g, 0.0) AS protein_g, COALESCE(n.total_carbs_g, 0.0) AS carbs_g, COALESCE(n.total_fat_g, 0.0) AS fat_g, g.resting_hr FROM dates dt LEFT JOIN v_nutrition_daily n ON dt.log_date = n.log_date AND n.subject = 'moritz' LEFT JOIN v_garmin_daily g ON dt.log_date = g.log_date WHERE dt.log_date <= ? ORDER BY dt.log_date DESC LIMIT 30 """, [today_str], ).fetchall() thirty_days_list = [] for r in thirty_days: d_obj = datetime.date.fromisoformat(str(r[0])) thirty_days_list.append({ "date": str(r[0]), "weekday": WEEKDAYS_DE[d_obj.weekday()], "intake_kcal": round(r[1]) if r[1] is not None else 0, "expenditure_kcal": round(r[2]) if r[2] is not None else 0, "active_kcal": round(r[3]) if r[3] is not None else 0, "rmr_kcal": round(r[4]) if r[4] is not None else 0, "steps": int(r[5]) if r[5] is not None else 0, "balance_kcal": round(r[6]) if r[6] is not None else None, "balance_status": r[7] or ("Defizit" if r[6] and r[6] < 0 else "Überschuss"), "protein_g": round(r[8], 1) if r[8] is not None else 0.0, "carbs_g": round(r[9], 1) if r[9] is not None else 0.0, "fat_g": round(r[10], 1) if r[10] is not None else 0.0, "resting_hr": int(r[11]) if r[11] is not None else None, }) valid_exp_30 = [r["expenditure_kcal"] for r in thirty_days_list if r["expenditure_kcal"] > 0] valid_intake_30 = [r["intake_kcal"] for r in thirty_days_list if r["intake_kcal"] > 0] valid_bal_30 = [r["balance_kcal"] for r in thirty_days_list if r["balance_kcal"] is not None] valid_steps_30 = [r["steps"] for r in thirty_days_list if r["steps"] > 0] avg_intake_30 = round(sum(valid_intake_30) / len(valid_intake_30)) if valid_intake_30 else 0 avg_exp_30 = round(sum(valid_exp_30) / len(valid_exp_30)) if valid_exp_30 else 0 avg_bal_30 = round(sum(valid_bal_30) / len(valid_bal_30)) if valid_bal_30 else 0 total_bal_30 = round(sum(valid_bal_30)) if valid_bal_30 else 0 avg_steps_30 = round(sum(valid_steps_30) / len(valid_steps_30)) if valid_steps_30 else 0 execution_time_ms = round((time.perf_counter() - t0) * 1000, 2) git_status = self.git_sync_manager.status if self.git_sync_manager else "ok" git_conflict = self.git_sync_manager.is_conflict if self.git_sync_manager else False return { "status": "ok", "execution_time_ms": execution_time_ms, "git_status": git_status, "git_conflict": git_conflict, "metadata": { "subject": "moritz", "today_date": today_str, "today_pretty": today_pretty, "yesterday_date": yesterday_str, "yesterday_pretty": yesterday_pretty, "sleep_date_label": sleep_date_label, }, "today": { "total_kcal": today_kcal, "protein_g": today_protein, "carbs_g": today_carbs, "fat_g": today_fat, "item_count": today_count, "meals": today_meals, }, "yesterday": { "intake_kcal": intake_y, "expenditure_kcal": exp_y, "active_kcal": act_y, "rmr_kcal": rmr_y, "steps": steps_y, "balance_kcal": bal_y, "balance_status": status_y, "protein_g": prot_y, "carbs_g": carbs_y, "fat_g": fat_y, "resting_hr": rhr_y, "meals": yesterday_meals, }, "garmin_recovery": { "sleep_dur_str": sleep_dur_str, "sleep_score": sleep_score, "sleep_qualifier": sleep_qualifier, "hrv_val": hrv_val, "hrv_status": hrv_status, "bb_change": bb_change, "resting_hr": rhr_val, "deep_minutes": deep_m, "light_minutes": light_m, "rem_minutes": rem_m, "awake_minutes": awake_m, "deep_pct": deep_pct, "light_pct": light_pct, "rem_pct": rem_pct, "awake_pct": awake_pct, }, "seven_days": { "days": seven_days_list, "avg_intake_kcal": avg_intake_7, "avg_expenditure_kcal": avg_exp_7, "avg_balance_kcal": avg_bal_7, "avg_steps": avg_steps_7, "avg_protein_g": avg_prot_7, }, "thirty_days": { "days": thirty_days_list, "avg_intake_kcal": avg_intake_30, "avg_expenditure_kcal": avg_exp_30, "avg_balance_kcal": avg_bal_30, "total_balance_kcal": total_bal_30, "avg_steps": avg_steps_30, }, } # -------------------------------------------------------------------------- # Mutating Event & Entity Operations # -------------------------------------------------------------------------- def _get_target_db(self, kind: str) -> str: if kind.startswith("sleep."): return "sleep" elif kind.startswith("diaper.") or kind.startswith("growth."): return "mila" else: return "nutrition" def _sort_jsonl_file(self, file_path: Path, key_fn): if not file_path.is_file(): return records = [] with open(file_path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line: try: records.append(json.loads(line)) except Exception: pass records.sort(key=key_fn) with open(file_path, "w", encoding="utf-8") as f: for r in records: f.write(json.dumps(r, ensure_ascii=False) + "\n") def add_event(self, req: EventCreateRequest) -> Dict[str, Any]: with self._lock: event_id = str(ULID()) now_dt = datetime.datetime.now(datetime.timezone.utc) now_iso = now_dt.isoformat() if req.since and req.start: raise ValueError("Cannot specify both 'since' and 'start'.") if req.date: req.payload["date"] = req.date if not req.start and not req.since: start_dt = datetime.datetime.fromisoformat(f"{req.date}T12:00:00").replace(tzinfo=BERLIN_TZ) start_iso = start_dt.isoformat() elif req.since: delta = parse_duration(req.since) start_dt = (now_dt - delta).astimezone(BERLIN_TZ) start_iso = start_dt.isoformat() else: start_dt = datetime.datetime.fromisoformat(req.start) if start_dt.tzinfo is None: start_dt = start_dt.replace(tzinfo=BERLIN_TZ) start_iso = start_dt.isoformat() elif req.since: delta = parse_duration(req.since) start_dt = (now_dt - delta).astimezone(BERLIN_TZ) start_iso = start_dt.isoformat() elif not req.start: start_dt = now_dt.astimezone(BERLIN_TZ) start_iso = start_dt.isoformat() else: start_dt = datetime.datetime.fromisoformat(req.start) if start_dt.tzinfo is None: start_dt = start_dt.replace(tzinfo=BERLIN_TZ) start_iso = start_dt.isoformat() end_iso = None end_dt = None if req.end: end_dt = datetime.datetime.fromisoformat(req.end) if end_dt.tzinfo is None: end_dt = end_dt.replace(tzinfo=BERLIN_TZ) if end_dt < start_dt: raise ValueError(f"End time {req.end} cannot be earlier than start time {req.start}") end_iso = end_dt.isoformat() # 15-Minute Duplicate Guard if not req.allow_duplicate: if req.kind in ("nutrition.intake", "feeding.bottle", "feeding.solid"): item_val = req.payload.get("item") or req.payload.get("food_id") or "" req_ml = req.payload.get("consumed_ml") dup_rows = self.con.execute( """ SELECT id, kind, subject, start, payload FROM events WHERE subject = ? AND kind = ? AND deleted_at IS NULL AND abs(date_diff('minute', CAST(start AS TIMESTAMPTZ), CAST(? AS TIMESTAMPTZ))) <= 15 """, [req.subject, req.kind, start_iso], ).fetchall() for d_id, d_kind, d_subj, d_start, d_payload in dup_rows: dp = json.loads(d_payload) if isinstance(d_payload, str) else (d_payload or {}) d_item = dp.get("item") or dp.get("food_id") or "" d_ml = dp.get("consumed_ml") if (item_val and item_val == d_item) or (req_ml is not None and req_ml == d_ml): fb = self._get_event_feedback(req.kind, req.subject, start_dt.date()) rec = {"id": d_id, "kind": d_kind, "subject": d_subj, "start": str(d_start), "payload": dp} return { "status": "duplicate_skipped", "id": d_id, "duplicate_guard": "skipped", "event": rec, "record": rec, "feedback": fb, } elif req.kind == "diaper.change": dup_rows = self.con.execute( """ SELECT id, kind, subject, start, payload FROM events WHERE subject = ? AND kind = 'diaper.change' AND deleted_at IS NULL AND abs(date_diff('minute', CAST(start AS TIMESTAMPTZ), CAST(? AS TIMESTAMPTZ))) <= 10 """, [req.subject, start_iso], ).fetchall() for d_id, d_kind, d_subj, d_start, d_payload in dup_rows: dp = json.loads(d_payload) if isinstance(d_payload, str) else (d_payload or {}) if dp.get("pee") == req.payload.get("pee") and dp.get("poo") == req.payload.get("poo"): fb = self._get_event_feedback(req.kind, req.subject, start_dt.date()) rec = {"id": d_id, "kind": d_kind, "subject": d_subj, "start": str(d_start), "payload": dp} return { "status": "duplicate_skipped", "id": d_id, "duplicate_guard": "skipped", "event": rec, "record": rec, "feedback": fb, } target_db = self._get_target_db(req.kind) month_str = start_dt.astimezone(BERLIN_TZ).strftime("%Y-%m") db_dir = self.data_dir / target_db db_dir.mkdir(parents=True, exist_ok=True) partition_file = db_dir / f"{month_str}.jsonl" open_session_warning = None if target_db == "sleep": cal_date = start_dt.astimezone(BERLIN_TZ).strftime("%Y-%m-%d") sleep_type = req.payload.get("type") or "nap" location = req.payload.get("location") or "bett" is_open = (end_iso is None) if is_open: open_row = self.con.execute( "SELECT id, startIso FROM sleep WHERE subject = ? AND (isOpen = true OR endIso IS NULL) AND deleted_at IS NULL ORDER BY startIso DESC LIMIT 1", [req.subject], ).fetchone() if open_row: if not req.allow_duplicate: raise ValueError( f"A sleep session is already active for subject '{req.subject}' (id={open_row[0]}, started={open_row[1]}). Stop it first before starting a new one." ) else: open_session_warning = {"active_id": open_row[0], "start": str(open_row[1])} start_gmt = int(start_dt.timestamp() * 1000) end_gmt = int(end_dt.timestamp() * 1000) if end_dt else None start_local = int((start_dt.timestamp() + start_dt.utcoffset().total_seconds()) * 1000) end_local = int((end_dt.timestamp() + end_dt.utcoffset().total_seconds()) * 1000) if end_dt else None dur_sec = int((end_dt - start_dt).total_seconds()) if end_dt else None nap_sec = dur_sec if (sleep_type == "nap" and dur_sec is not None) else 0 disk_record = { "id": event_id, "subject": req.subject, "calendarDate": cal_date, "sleepType": sleep_type, "location": location, "isOpen": is_open, "startIso": start_iso, "endIso": end_iso, "sleepStartTimestampGMT": start_gmt, "sleepEndTimestampGMT": end_gmt, "sleepStartTimestampLocal": start_local, "sleepEndTimestampLocal": end_local, "sleepTimeSeconds": dur_sec, "napTimeSeconds": nap_sec, "deepSleepSeconds": None, "lightSleepSeconds": None, "remSleepSeconds": None, "awakeSleepSeconds": None, "awakeCount": None, "avgSleepStress": None, "avgHeartRate": None, "averageSpO2Value": None, "lowestSpO2Value": None, "highestSpO2Value": None, "averageRespirationValue": None, "sleepScores": None, "avgOvernightHrv": None, "hrvStatus": None, "bodyBatteryChange": None, "restingHeartRate": None, "restlessMomentsCount": None, "note": req.note, "actor": req.actor, "created_at": now_iso, "updated_at": None, "deleted_at": None, } self.con.execute( """ INSERT INTO sleep ( id, subject, calendarDate, sleepType, location, isOpen, startIso, endIso, sleepStartTimestampGMT, sleepEndTimestampGMT, sleepStartTimestampLocal, sleepEndTimestampLocal, sleepTimeSeconds, napTimeSeconds, note, actor, created_at, updated_at, deleted_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, [ event_id, req.subject, cal_date, sleep_type, location, is_open, start_iso, end_iso, start_gmt, end_gmt, start_local, end_local, dur_sec, nap_sec, req.note, req.actor, now_iso, None, None, ], ) sort_key = lambda x: (x.get("calendarDate", ""), x.get("startIso", ""), x.get("id", "")) elif target_db == "mila": disk_record = { "id": event_id, "kind": req.kind, "subject": req.subject, "actor": req.actor, "start": start_iso, "end": end_iso, "note": req.note, "payload": req.payload, "source_ref": req.source_ref, "created_at": now_iso, "updated_at": None, "deleted_at": None, } self.con.execute( """ INSERT INTO mila (id, kind, subject, actor, start, "end", note, payload, source_ref, created_at, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, [event_id, req.kind, req.subject, req.actor, start_iso, end_iso, req.note, json.dumps(req.payload), req.source_ref, now_iso, None, None], ) sort_key = lambda x: (x.get("start", ""), x.get("id", "")) else: disk_record = { "id": event_id, "kind": req.kind, "subject": req.subject, "actor": req.actor, "start": start_iso, "end": end_iso, "estimated": req.estimated, "meal": req.meal, "note": req.note, "payload": req.payload, "source_ref": req.source_ref, "created_at": now_iso, "updated_at": None, "deleted_at": None, } self.con.execute( """ INSERT INTO nutrition (id, kind, subject, actor, start, "end", estimated, meal, note, payload, source_ref, created_at, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, [event_id, req.kind, req.subject, req.actor, start_iso, end_iso, req.estimated, req.meal, req.note, json.dumps(req.payload), req.source_ref, now_iso, None, None], ) sort_key = lambda x: (x.get("start", ""), x.get("id", "")) with open(partition_file, "a", encoding="utf-8") as f: f.write(json.dumps(disk_record, ensure_ascii=False) + "\n") self._sort_jsonl_file(partition_file, key_fn=sort_key) if self.git_sync_manager: self.git_sync_manager.commit_and_push(f"chore(data): log {req.kind} for {req.subject}") feedback = self._get_event_feedback(req.kind, req.subject, start_dt.date()) res = { "status": "success", "id": event_id, "event": disk_record, "record": disk_record, "feedback": feedback, } if open_session_warning: res["open_session_warning"] = open_session_warning return res def stop_event(self, req: EventStopRequest) -> Dict[str, Any]: with self._lock: target_db = self._get_target_db(req.kind) now_dt = datetime.datetime.now(datetime.timezone.utc) if req.since and req.end_time: raise ValueError("Cannot specify both 'since' and 'end_time'.") if req.since: delta = parse_duration(req.since) end_dt = (now_dt - delta).astimezone(BERLIN_TZ) elif req.end_time: end_dt = datetime.datetime.fromisoformat(req.end_time) if end_dt.tzinfo is None: end_dt = end_dt.replace(tzinfo=BERLIN_TZ) else: end_dt = now_dt.astimezone(BERLIN_TZ) if target_db == "sleep": if req.event_id: row = self.con.execute( """ SELECT id, startIso, sleepType FROM sleep WHERE id = ? AND subject = ? AND (isOpen = true OR endIso IS NULL) AND deleted_at IS NULL """, [req.event_id, req.subject], ).fetchone() else: row = self.con.execute( """ SELECT id, startIso, sleepType FROM sleep WHERE subject = ? AND (isOpen = true OR endIso IS NULL) AND deleted_at IS NULL ORDER BY startIso DESC LIMIT 1 """, [req.subject], ).fetchone() if not row: raise ValueError(f"No active open sleep session found for subject '{req.subject}'.") resolved_id, start_str, sleep_type = row[0], str(row[1]), row[2] else: if req.event_id: row = self.con.execute( f""" SELECT id, start FROM "{target_db}" WHERE id = ? AND kind = ? AND subject = ? AND "end" IS NULL AND deleted_at IS NULL """, [req.event_id, req.kind, req.subject], ).fetchone() else: row = self.con.execute( f""" SELECT id, start FROM "{target_db}" WHERE kind = ? AND subject = ? AND "end" IS NULL AND deleted_at IS NULL ORDER BY start DESC LIMIT 1 """, [req.kind, req.subject], ).fetchone() if not row: raise ValueError(f"No active open event found for kind='{req.kind}' and subject='{req.subject}'.") resolved_id, start_str, sleep_type = row[0], str(row[1]), None start_dt = datetime.datetime.fromisoformat(start_str.replace("Z", "+00:00")) if start_dt.tzinfo is None: start_dt = start_dt.replace(tzinfo=BERLIN_TZ) if end_dt < start_dt: raise ValueError(f"End time {end_dt.isoformat()} cannot be earlier than start time {start_dt.isoformat()}") end_iso = end_dt.isoformat() now_iso = now_dt.isoformat() dur_sec = int((end_dt - start_dt).total_seconds()) dur_min = dur_sec // 60 if target_db == "sleep": end_gmt = int(end_dt.timestamp() * 1000) end_local = int((end_dt.timestamp() + end_dt.utcoffset().total_seconds()) * 1000) nap_sec = dur_sec if sleep_type == "nap" else 0 self.con.execute( """ UPDATE sleep SET endIso = ?, isOpen = false, sleepEndTimestampGMT = ?, sleepEndTimestampLocal = ?, sleepTimeSeconds = ?, napTimeSeconds = ?, updated_at = ? WHERE id = ? """, [end_iso, end_gmt, end_local, dur_sec, nap_sec, now_iso, resolved_id], ) self._update_jsonl_record("sleep", resolved_id, { "endIso": end_iso, "isOpen": False, "sleepEndTimestampGMT": end_gmt, "sleepEndTimestampLocal": end_local, "sleepTimeSeconds": dur_sec, "napTimeSeconds": nap_sec, "updated_at": now_iso, }) else: self.con.execute( f'UPDATE "{target_db}" SET "end" = ?, updated_at = ? WHERE id = ?', [end_iso, now_iso, resolved_id], ) self._update_jsonl_record(target_db, resolved_id, {"end": end_iso, "updated_at": now_iso}) if self.git_sync_manager: self.git_sync_manager.commit_and_push(f"chore(data): stop {req.kind} for {req.subject}") feedback = self._get_event_feedback(req.kind, req.subject, end_dt.date()) return { "status": "success", "id": resolved_id, "duration_minutes": dur_min, "end": end_iso, "feedback": feedback, } def delete_event(self, event_id: str) -> Dict[str, Any]: with self._lock: now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat() row = self.con.execute("SELECT kind, subject FROM events WHERE id = ?", [event_id]).fetchone() if not row: raise ValueError(f"Event with id '{event_id}' not found.") kind, subject = row[0], row[1] target_db = self._get_target_db(kind) self.con.execute(f'UPDATE "{target_db}" SET deleted_at = ? WHERE id = ?', [now_iso, event_id]) self._update_jsonl_record(target_db, event_id, {"deleted_at": now_iso}) if self.git_sync_manager: self.git_sync_manager.commit_and_push(f"chore(data): delete event {event_id} ({kind})") return {"status": "success", "id": event_id} def put_entity(self, req: EntityPutRequest) -> Dict[str, Any]: with self._lock: if req.collection not in ["foods", "recipes", "places", "people"]: raise ValueError(f"Unknown entity collection: {req.collection}") entity_id = req.record.get("id") if not entity_id: raise ValueError("Entity record must have an 'id' field.") entities_dir = self.data_dir / "entities" entities_dir.mkdir(parents=True, exist_ok=True) target_file = entities_dir / f"{req.collection}.jsonl" existing_records = [] replaced = False if target_file.is_file(): with open(target_file, "r", encoding="utf-8") as f: for line in f: line = line.strip() if line: try: r = json.loads(line) if r.get("id") == entity_id: existing_records.append(req.record) replaced = True else: existing_records.append(r) except Exception: pass if not replaced: existing_records.append(req.record) existing_records.sort(key=lambda x: str(x.get("id", ""))) with open(target_file, "w", encoding="utf-8") as f: for r in existing_records: f.write(json.dumps(r, ensure_ascii=False) + "\n") columns = list(req.record.keys()) placeholders = ", ".join(["?"] * len(columns)) col_names = ", ".join([f'"{c}"' for c in columns]) values = [ json.dumps(req.record[c]) if isinstance(req.record[c], (dict, list)) else req.record[c] for c in columns ] self.con.execute( f'INSERT OR REPLACE INTO "{req.collection}" ({col_names}) VALUES ({placeholders})', values, ) if self.git_sync_manager: self.git_sync_manager.commit_and_push(f"chore(data): update entity {req.collection}/{entity_id}") return {"status": "success", "collection": req.collection, "id": entity_id} def _update_jsonl_record(self, target_db: str, event_id: str, updates: Dict[str, Any]): db_dir = self.data_dir / target_db if not db_dir.is_dir(): return for p_file in db_dir.glob("*.jsonl"): records = [] found = False with open(p_file, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: r = json.loads(line) if r.get("id") == event_id: r.update(updates) found = True records.append(r) except Exception: pass if found: with open(p_file, "w", encoding="utf-8") as f: for r in records: f.write(json.dumps(r, ensure_ascii=False) + "\n") return def _get_event_feedback(self, kind: str, subject: str, target_date: datetime.date) -> Dict[str, Any]: """Generates compact running status feedback after an insert or stop.""" date_str = target_date.strftime("%Y-%m-%d") if kind.startswith("nutrition") and subject != "mila": daily = self.con.execute( "SELECT total_protein_g, total_carbs_g, total_sugar_g, total_fat_g, total_kcal FROM v_nutrition_daily WHERE subject = ? AND log_date = ?", [subject, date_str], ).fetchone() if daily: return { "day_total_formatted": f"Tag bisher ({date_str}): ~{daily[0]} g Eiweiß · ~{daily[1]} g KH (davon ~{daily[2]} g Zucker) · ~{daily[3]} g Fett · ~{int(daily[4])} kcal", "protein_g": daily[0], "carbs_g": daily[1], "kcal": daily[4], } elif kind.startswith("sleep"): daily = self.con.execute( "SELECT total_sleep_hours, nap_count, night_sleep_minutes FROM v_sleep_daily WHERE subject = ? AND log_date = ?", [subject, date_str], ).fetchone() if daily: return { "day_total_formatted": f"Schlaf ({date_str}): {daily[0]} h gesamt ({daily[1]} Schläfchen, {daily[2]} min Nacht)", "total_hours": daily[0], "nap_count": daily[1], } elif kind.startswith("diaper"): daily = self.con.execute( "SELECT total_changes, wet_count, dirty_count FROM v_diaper_daily WHERE subject = ? AND log_date = ?", [subject, date_str], ).fetchone() if daily: return { "day_total_formatted": f"Windeln ({date_str}): {daily[0]} gewechselt ({daily[1]} Pipi, {daily[2]} Stuhl)", "total_changes": daily[0], "wet_count": daily[1], "dirty_count": daily[2], } elif kind.startswith("feeding") or (kind.startswith("nutrition") and subject == "mila"): daily = self.con.execute( "SELECT bottle_count, total_consumed_ml, solid_count, total_solid_g, total_kcal FROM v_feeding_daily WHERE subject = ? AND log_date = ?", [subject, date_str], ).fetchone() if daily: b_cnt, b_ml, s_cnt, s_g, tot_kcal = daily[0], daily[1], daily[2], daily[3], daily[4] parts = [] if b_cnt > 0 or b_ml > 0: parts.append(f"{b_cnt} Fläschchen ({int(b_ml)} ml getrunken)") if s_cnt > 0 or s_g > 0: parts.append(f"{s_cnt}x Brei/Beikost ({int(s_g)} g)") summary_str = " · ".join(parts) if parts else f"{b_cnt} Fläschchen ({int(b_ml)} ml getrunken)" return { "day_total_formatted": f"Ernährung Mila ({date_str}): {summary_str} · ~{int(tot_kcal)} kcal", "bottle_count": b_cnt, "total_consumed_ml": b_ml, "solid_count": s_cnt, "total_solid_g": s_g, "total_kcal": tot_kcal, } 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 app.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 {} def get_mila_day_detail(self, target_date: str) -> Dict[str, Any]: """Returns the full day breakdown for Mila on target_date YYYY-MM-DD.""" return self.get_mila_dashboard(as_of_date=target_date) def get_moritz_day_detail(self, target_date: str) -> Dict[str, Any]: """Returns the full day breakdown for Moritz on target_date YYYY-MM-DD.""" return self.get_moritz_dashboard(as_of_date=target_date) def get_summary(self, subject: str, date_str: Optional[str] = None) -> Dict[str, Any]: """Returns daily summary for subject (nutrition, sleep, deficit, diaper, feeding, growth).""" with self._lock: target_date = date_str or datetime.datetime.now(BERLIN_TZ).strftime("%Y-%m-%d") res: Dict[str, Any] = {"subject": subject, "date": target_date} nut = self.query(f"SELECT * FROM v_nutrition_daily WHERE subject = '{subject}' AND log_date = '{target_date}'") if nut: res["nutrition"] = nut[0] else: res["nutrition"] = { "total_protein_g": 0.0, "total_carbs_g": 0.0, "total_sugar_g": 0.0, "total_fat_g": 0.0, "total_fiber_g": 0.0, "total_salt_g": 0.0, "total_kcal": 0, "item_count": 0, } slp = self.query(f"SELECT * FROM v_sleep_daily WHERE subject = '{subject}' AND log_date = '{target_date}'") if slp: res["sleep"] = slp[0] open_sleep = self.query( f"SELECT id, start, local_start, sleep_type, location FROM v_sleep WHERE subject = '{subject}' AND is_open = true" ) if open_sleep: res["open_sleep"] = open_sleep[0] dia = self.query(f"SELECT * FROM v_diaper_daily WHERE subject = '{subject}' AND log_date = '{target_date}'") if dia: res["diaper"] = dia[0] fee = self.query(f"SELECT * FROM v_feeding_daily WHERE subject = '{subject}' AND log_date = '{target_date}'") if fee: res["feeding"] = fee[0] 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( self, lat: float, lon: float, radius_m: float = 1000.0, category: Optional[str] = None, ) -> List[Dict[str, Any]]: """Radius search for places using DuckDB distance_m macro.""" with self._lock: cat_filter = f"AND category = '{category}'" if category else "" sql = f""" SELECT id, name, category, lat, lon, address, google_maps_url, tags, notes, documents, ROUND(distance_m({lat}, {lon}, lat, lon), 1) AS distance_meters FROM places WHERE distance_m({lat}, {lon}, lat, lon) <= {radius_m} {cat_filter} ORDER BY distance_meters ASC """ res = self.con.execute(sql).fetchall() col_names = [desc[0] for desc in self.con.description] results = [] for row in res: rec = {} for c, val in zip(col_names, row): if isinstance(val, str) and (val.startswith("{") or val.startswith("[")): try: rec[c] = json.loads(val) except Exception: rec[c] = val else: rec[c] = val results.append(rec) return results def list_entities(self, collection: str) -> List[Dict[str, Any]]: """Lists all entity records in the specified collection.""" with self._lock: if collection not in ["foods", "recipes", "places", "people"]: raise ValueError(f"Unknown entity collection: {collection}") res = self.con.execute(f'SELECT * FROM "{collection}"').fetchall() col_names = [desc[0] for desc in self.con.description] results = [] for row in res: rec = {} for c, val in zip(col_names, row): if isinstance(val, str) and (val.startswith("{") or val.startswith("[")): try: rec[c] = json.loads(val) except Exception: rec[c] = val else: rec[c] = val results.append(rec) return results def sync_garmin(self, req: GarminSyncRequest) -> Dict[str, Any]: """Mirrors Garmin daily, sleep, and activity summaries to jsonl and reloads DuckDB.""" with self._lock: garmin_dir = self.data_dir / "garmin" daily_dir = garmin_dir / "daily" sleep_dir = garmin_dir / "sleep" act_dir = garmin_dir / "activities" unified_sleep_dir = self.data_dir / "sleep" daily_dir.mkdir(parents=True, exist_ok=True) sleep_dir.mkdir(parents=True, exist_ok=True) act_dir.mkdir(parents=True, exist_ok=True) unified_sleep_dir.mkdir(parents=True, exist_ok=True) def _read_existing(file_path: Path) -> List[Dict[str, Any]]: if not file_path.is_file(): return [] items = [] with open(file_path, "r", encoding="utf-8") as f: for line in f: if line.strip(): try: items.append(json.loads(line)) except Exception: pass return items def _write_atomic(file_path: Path, items: List[Dict[str, Any]]): tmp_path = file_path.with_suffix(".tmp") with open(tmp_path, "w", encoding="utf-8") as f: for item in items: f.write(json.dumps(item, ensure_ascii=False) + "\n") tmp_path.replace(file_path) # 1. Upsert daily summaries for summary in req.daily: cal_date = summary.get("calendarDate") if not cal_date: continue month = cal_date[:7] target_file = daily_dir / f"{month}.jsonl" existing = _read_existing(target_file) id_map = {item.get("calendarDate"): item for item in existing if item.get("calendarDate")} id_map[cal_date] = summary sorted_items = sorted(id_map.values(), key=lambda x: str(x.get("calendarDate", ""))) _write_atomic(target_file, sorted_items) # 2. Upsert sleep data for sleep_data in req.sleep: dto = sleep_data.get("dailySleepDTO") if isinstance(sleep_data.get("dailySleepDTO"), dict) else {} cal_date = dto.get("calendarDate") or sleep_data.get("calendarDate") if not cal_date: continue month = cal_date[:7] target_file = sleep_dir / f"{month}.jsonl" existing = _read_existing(target_file) def _get_date(x): d = (x.get("dailySleepDTO") or {}).get("calendarDate") if isinstance(x.get("dailySleepDTO"), dict) else None return str(d or x.get("calendarDate") or "") id_map = {_get_date(item): item for item in existing if _get_date(item)} id_map[cal_date] = sleep_data sorted_items = sorted(id_map.values(), key=_get_date) _write_atomic(target_file, sorted_items) # Unified sleep record for Moritz start_gmt = dto.get("sleepStartTimestampGMT") end_gmt = dto.get("sleepEndTimestampGMT") if start_gmt: start_dt = datetime.datetime.fromtimestamp(start_gmt / 1000, datetime.timezone.utc) start_iso = start_dt.isoformat() else: start_iso = f"{cal_date}T22:30:00+02:00" if end_gmt: end_dt = datetime.datetime.fromtimestamp(end_gmt / 1000, datetime.timezone.utc) end_iso = end_dt.isoformat() else: end_iso = f"{cal_date}T06:30:00+02:00" nap_sec = dto.get("napTimeSeconds") or 0 sleep_sec = dto.get("sleepTimeSeconds") or 0 sleep_type = "nap" if (nap_sec > 0 and sleep_sec == 0) else "night" unified_rec = { "id": str(dto.get("id") or (int(start_gmt) if start_gmt else cal_date)), "subject": "moritz", "calendarDate": cal_date, "sleepType": sleep_type, "location": "bett", "isOpen": False, "startIso": start_iso, "endIso": end_iso, "sleepStartTimestampGMT": dto.get("sleepStartTimestampGMT"), "sleepEndTimestampGMT": dto.get("sleepEndTimestampGMT"), "sleepStartTimestampLocal": dto.get("sleepStartTimestampLocal"), "sleepEndTimestampLocal": dto.get("sleepEndTimestampLocal"), "sleepTimeSeconds": dto.get("sleepTimeSeconds"), "napTimeSeconds": nap_sec, "deepSleepSeconds": dto.get("deepSleepSeconds"), "lightSleepSeconds": dto.get("lightSleepSeconds"), "remSleepSeconds": dto.get("remSleepSeconds"), "awakeSleepSeconds": dto.get("awakeSleepSeconds"), "awakeCount": dto.get("awakeCount"), "avgSleepStress": dto.get("avgSleepStress"), "avgHeartRate": dto.get("avgHeartRate"), "averageSpO2Value": dto.get("averageSpO2Value"), "lowestSpO2Value": dto.get("lowestSpO2Value"), "highestSpO2Value": dto.get("highestSpO2Value"), "averageRespirationValue": dto.get("averageRespirationValue"), "sleepScores": dto.get("sleepScores"), "avgOvernightHrv": sleep_data.get("avgOvernightHrv"), "hrvStatus": sleep_data.get("hrvStatus"), "bodyBatteryChange": sleep_data.get("bodyBatteryChange"), "restingHeartRate": sleep_data.get("restingHeartRate"), "restlessMomentsCount": sleep_data.get("restlessMomentsCount"), "note": None, "actor": "garmin_sync", "created_at": start_iso, "updated_at": None, "deleted_at": None, } unified_file = unified_sleep_dir / f"{month}.jsonl" existing_u = _read_existing(unified_file) new_u = [it for it in existing_u if not (it.get("subject") == "moritz" and it.get("calendarDate") == cal_date)] new_u.append(unified_rec) new_u.sort(key=lambda x: (str(x.get("calendarDate", "")), str(x.get("startIso", "")), str(x.get("id", "")))) _write_atomic(unified_file, new_u) # 3. Upsert activities if req.activities: by_month: Dict[str, List[Dict[str, Any]]] = {} for act in req.activities: start_local = act.get("startTimeLocal", "") m = start_local[:7] if len(start_local) >= 7 else datetime.date.today().strftime("%Y-%m") by_month.setdefault(m, []).append(act) for month, act_list in by_month.items(): target_file = act_dir / f"{month}.jsonl" existing = _read_existing(target_file) id_map = {item.get("activityId"): item for item in existing if item.get("activityId")} for act in act_list: aid = act.get("activityId") if aid: id_map[aid] = act sorted_acts = sorted(id_map.values(), key=lambda x: str(x.get("startTimeLocal", ""))) _write_atomic(target_file, sorted_acts) # Reload all DuckDB tables self.reload_all() if self.git_sync_manager: self.git_sync_manager.commit_and_push("chore(garmin): sync daily/sleep/activities data") return { "status": "success", "synced_daily": len(req.daily), "synced_sleep": len(req.sleep), "synced_activities": len(req.activities), }