- Upgrade OpenClaw primary model from deepseek-v4.1-flash to deepseek-v4.1-flash-1017 across all interactive agents, heartbeats, compaction, and cron jobs. - Configure hardened OpenRouter provider routing for 1017 (deepinfra, inference-net, parasail, cloudflare) with strict fp8/bf16 quantization and tool_choice verification. - Retain deepseek-v4.1-flash (0910), z-ai/glm-5.3-flash, and qwen3.8-flash in the automated fallback chain. - Update ADR 0002 with 1017 economics (30% price reduction on prompt/cache, +60% intelligence benchmark increase) and a 7-point provider qualification playbook. - Add operational scripts and tooling for session compaction, subagent vector deduplication, gateway preflight, and triage diagnostics in k8s/openclaw. - Update DuckDB backend engine, git-sync logic, and test suites. - Enhance Mila tracking UI components and add container build configuration. - Sync Terraform infrastructure state for Authentik.
2407 lines
112 KiB
Python
2407 lines
112 KiB
Python
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 (use completed days < today_str when available)
|
||
avg_7 = self.con.execute(
|
||
f"""
|
||
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' AND log_date < '{today_str}'
|
||
ORDER BY log_date DESC LIMIT 7
|
||
)
|
||
"""
|
||
).fetchone()
|
||
if not avg_7 or avg_7[0] is None:
|
||
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(
|
||
f"""
|
||
SELECT ROUND(AVG(total_sleep_hours), 2)
|
||
FROM (
|
||
SELECT total_sleep_hours FROM v_sleep_daily
|
||
WHERE subject = 'mila' AND log_date < '{today_str}'
|
||
ORDER BY log_date DESC LIMIT 30
|
||
)
|
||
"""
|
||
).fetchone()
|
||
if not avg_30 or avg_30[0] is None:
|
||
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(
|
||
f"""
|
||
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' AND log_date < '{today_str}'
|
||
ORDER BY log_date DESC LIMIT 7
|
||
)
|
||
"""
|
||
).fetchone()
|
||
if not feed_avg_7 or feed_avg_7[0] is None:
|
||
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(
|
||
f"""
|
||
SELECT ROUND(AVG(wet_count), 1), ROUND(AVG(total_changes), 1)
|
||
FROM (
|
||
SELECT wet_count, total_changes
|
||
FROM v_diaper_daily
|
||
WHERE subject = 'mila' AND log_date < '{today_str}'
|
||
ORDER BY log_date DESC LIMIT 7
|
||
)
|
||
"""
|
||
).fetchone()
|
||
if not diaper_avg_7 or diaper_avg_7[0] is None:
|
||
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)
|
||
d_sessions = list(sessions_by_date.get(d_str, []))
|
||
row_night_min = int(r[2] or 0)
|
||
row_nap_min = int(r[4] or 0)
|
||
row_tot_hours = round(r[1], 2) if r[1] is not None else 0.0
|
||
|
||
if d_str == today_str and row_night_min == 0 and today_night_min > 0:
|
||
row_night_min = today_night_min
|
||
row_tot_hours = round((row_night_min + row_nap_min) / 60.0, 2)
|
||
|
||
nh, nm = divmod(row_night_min, 60)
|
||
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 row_night_min
|
||
|
||
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": row_tot_hours,
|
||
"night_minutes": row_night_min,
|
||
"night_duration_str": f"{nh} h {nm:02d} min" if nh > 0 else f"{nm} min",
|
||
"nap_minutes": row_nap_min,
|
||
"nap_count": int(r[3] or 0),
|
||
"nap_str": f"{r[3]} ({row_nap_min} 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 _parse_event_dt(
|
||
self,
|
||
raw: str,
|
||
now_dt: datetime.datetime,
|
||
actor: str = "",
|
||
base_date: Optional[str] = None,
|
||
) -> datetime.datetime:
|
||
s = raw.strip()
|
||
m_time = re.fullmatch(r"^(\d{1,2}):(\d{2})(?::(\d{2}))?$", s)
|
||
if m_time:
|
||
d_part = base_date or now_dt.astimezone(BERLIN_TZ).strftime("%Y-%m-%d")
|
||
hh = int(m_time.group(1))
|
||
mm = int(m_time.group(2))
|
||
ss = int(m_time.group(3) or 0)
|
||
return datetime.datetime.fromisoformat(f"{d_part}T{hh:02d}:{mm:02d}:{ss:02d}").replace(tzinfo=BERLIN_TZ)
|
||
|
||
dt = datetime.datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||
if dt.tzinfo is None:
|
||
return dt.replace(tzinfo=BERLIN_TZ)
|
||
|
||
if s.endswith("+00:00") or s.endswith("Z"):
|
||
dt_wall_berlin = dt.replace(tzinfo=BERLIN_TZ)
|
||
# Guard against LLM agents formatting local Europe/Berlin wall-clock times with +00:00 / Z
|
||
if (
|
||
(dt > now_dt + datetime.timedelta(minutes=5) and dt_wall_berlin <= now_dt + datetime.timedelta(minutes=5))
|
||
or ((actor or "").startswith("agent:") and dt.second == 0 and dt.microsecond == 0)
|
||
):
|
||
return dt_wall_berlin
|
||
|
||
return dt.astimezone(BERLIN_TZ)
|
||
|
||
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 = self._parse_event_dt(req.start, now_dt, actor=req.actor, base_date=req.date)
|
||
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 = self._parse_event_dt(req.start, now_dt, actor=req.actor)
|
||
start_iso = start_dt.isoformat()
|
||
|
||
end_iso = None
|
||
end_dt = None
|
||
if req.end:
|
||
end_dt = self._parse_event_dt(
|
||
req.end,
|
||
now_dt,
|
||
actor=req.actor,
|
||
base_date=start_dt.astimezone(BERLIN_TZ).strftime("%Y-%m-%d"),
|
||
)
|
||
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()
|
||
|
||
# Populate Mila standard recipe defaults when omitted by tracking agents
|
||
if req.subject == "mila":
|
||
if req.kind == "feeding.solid" or (req.kind == "nutrition.intake" and "brei" in str(req.payload.get("item") or req.payload.get("type") or "").lower()):
|
||
if req.kind == "nutrition.intake":
|
||
req.kind = "feeding.solid"
|
||
has_grams = any(req.payload.get(k) not in (None, 0) for k in ("amount_g", "consumed_g", "qty"))
|
||
if not has_grams:
|
||
item_l = str(req.payload.get("item") or req.meal or "").lower()
|
||
if any(w in item_l for w in ("abend", "nachmittag", "dinner")):
|
||
req.payload["amount_g"] = 200
|
||
req.payload.setdefault("kcal", 110)
|
||
else:
|
||
req.payload["amount_g"] = 190
|
||
req.payload.setdefault("kcal", 135)
|
||
elif req.kind == "feeding.bottle":
|
||
c_ml = req.payload.get("consumed_ml")
|
||
a_ml = req.payload.get("amount_ml")
|
||
l_ml = req.payload.get("left_ml")
|
||
if (c_ml is None or c_ml == 0) and (a_ml is None or a_ml == 0):
|
||
if l_ml is not None:
|
||
req.payload["amount_ml"] = 270
|
||
req.payload["consumed_ml"] = max(0, 270 - int(l_ml))
|
||
else:
|
||
req.payload["amount_ml"] = 270
|
||
req.payload["consumed_ml"] = 270
|
||
|
||
# 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 = self._parse_event_dt(req.end_time, now_dt, actor=req.actor)
|
||
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),
|
||
}
|