Files
infrapuzzle/projects/duckdb-backend/app/engine.py
T

2296 lines
106 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_time": last_feed_time_str,
"last_feed_since_str": last_feed_since_str,
"last_feed_since_minutes": last_feed_since_minutes,
"last_feed_elapsed_minutes": last_feed_since_minutes,
"last_feed_summary": last_feed_summary,
"last_feed_item": (last_feed[5] or ("Fläschchen" if last_feed[6] == "feeding.bottle" else "Mahlzeit")) if last_feed else None,
"last_feed_amount": (last_feed[3] or last_feed[4]) if last_feed else None,
"last_diaper_iso": last_diaper_iso,
"last_diaper_time_str": last_diaper_time_str,
"last_diaper_time": last_diaper_time_str,
"last_diaper_since_str": last_diaper_since_str,
"last_diaper_since_minutes": last_diaper_since_minutes,
"last_diaper_elapsed_minutes": last_diaper_since_minutes,
"last_diaper_type": last_diaper_type,
"last_stool_iso": last_stool_iso,
"last_stool_str": last_stool_str,
"last_stool_time": st_dt.strftime("%H:%M") if last_stool else None,
"last_stool_elapsed_minutes": st_mins if last_stool else None,
},
"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),
}