feat: consolidate datastore into sleep, nutrition, mila databases

This commit is contained in:
Moritz Graf
2026-10-13 09:28:07 +02:00
parent fe4edcc217
commit 7b5d039323
5 changed files with 896 additions and 161 deletions
+399 -118
View File
@@ -154,8 +154,57 @@ class DatastoreEngine:
self._create_inline_schema() self._create_inline_schema()
def _create_inline_schema(self): def _create_inline_schema(self):
schema_src = Path("/Users/moritz/src/infrapuzzle/knowledge-wiki/data/_schema")
if not schema_src.is_dir():
schema_src = Path("/home/node/.openclaw/wiki/main/data/_schema")
if schema_src.is_dir():
for f in ["envelope.sql", "entities.sql", "views.sql"]:
sql_file = schema_src / f
if sql_file.is_file():
self.con.execute(sql_file.read_text(encoding="utf-8"))
return
self.con.execute(""" self.con.execute("""
CREATE TABLE IF NOT EXISTS events ( 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, id VARCHAR PRIMARY KEY,
kind VARCHAR NOT NULL, kind VARCHAR NOT NULL,
subject VARCHAR NOT NULL, subject VARCHAR NOT NULL,
@@ -173,6 +222,30 @@ class DatastoreEngine:
CHECK ("end" IS NULL OR "end" >= start) CHECK ("end" IS NULL OR "end" >= start)
); );
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 OR REPLACE VIEW events AS
SELECT id, kind, subject, actor, start, "end", estimated, meal, note, payload, source_ref, created_at, updated_at, deleted_at FROM nutrition
UNION ALL
SELECT id, kind, subject, actor, start, "end", false AS estimated, NULL AS meal, note, payload, source_ref, created_at, updated_at, deleted_at FROM mila
UNION ALL
SELECT id, 'sleep.session' AS kind, subject, actor, startIso AS start, endIso AS "end", false AS estimated, NULL AS meal, note,
to_json(struct_pack(type := sleepType, location := location, duration_minutes := COALESCE(ROUND(sleepTimeSeconds / 60.0), 0))) AS payload,
NULL AS source_ref, created_at, updated_at, deleted_at FROM sleep;
CREATE TABLE IF NOT EXISTS people ( CREATE TABLE IF NOT EXISTS people (
id VARCHAR PRIMARY KEY, id VARCHAR PRIMARY KEY,
name VARCHAR NOT NULL, name VARCHAR NOT NULL,
@@ -229,38 +302,19 @@ class DatastoreEngine:
restingHeartRate INTEGER, restingHeartRate INTEGER,
raw JSON raw JSON
); );
CREATE OR REPLACE VIEW v_growth AS
SELECT
id,
subject,
actor,
start,
start AT TIME ZONE 'Europe/Berlin' AS local_time,
CAST(start AT TIME ZONE 'Europe/Berlin' AS DATE) AS log_date,
CAST(json_extract(payload, '$.weight_kg') AS DOUBLE) AS weight_kg,
CAST(json_extract(payload, '$.height_cm') AS DOUBLE) AS height_cm,
CAST(json_extract(payload, '$.head_circ_cm') AS DOUBLE) AS head_circ_cm,
note,
created_at
FROM events
WHERE kind = 'growth.measurement' AND deleted_at IS NULL;
CREATE OR REPLACE VIEW v_growth_daily AS
SELECT
subject,
log_date,
ROUND(AVG(weight_kg), 3) AS weight_kg,
ROUND(AVG(height_cm), 1) AS height_cm,
ROUND(AVG(head_circ_cm), 1) AS head_circ_cm,
COUNT(*) AS measurement_count
FROM v_growth
GROUP BY subject, log_date
ORDER BY log_date DESC;
""") """)
def _get_target_db(self, kind: str) -> str:
"""Maps an event kind to one of the 3 target databases: 'sleep', 'nutrition', or 'mila'."""
if kind.startswith("sleep."):
return "sleep"
elif kind.startswith("diaper.") or kind.startswith("growth."):
return "mila"
else:
return "nutrition"
def _load_all_data(self): def _load_all_data(self):
"""Loads all existing JSONL files from entities/, events/, and garmin/ into DuckDB.""" """Loads all existing JSONL files from entities/, sleep/, nutrition/, mila/, and garmin/ into DuckDB."""
# 1. Load entities # 1. Load entities
entities_dir = self.data_dir / "entities" entities_dir = self.data_dir / "entities"
if entities_dir.is_dir(): if entities_dir.is_dir():
@@ -269,18 +323,80 @@ class DatastoreEngine:
if collection in ["people", "foods", "recipes", "places"]: if collection in ["people", "foods", "recipes", "places"]:
self._load_jsonl_file(f, collection) self._load_jsonl_file(f, collection)
# 2. Load events # 2. Load the 3 core databases: sleep, nutrition, mila
for db_name in ["sleep", "nutrition", "mila"]:
db_dir = self.data_dir / db_name
if db_dir.is_dir():
for f in db_dir.glob("*.jsonl"):
self._load_jsonl_file(f, db_name)
# 3. Fallback for legacy events/ if present
events_dir = self.data_dir / "events" events_dir = self.data_dir / "events"
if events_dir.is_dir(): if events_dir.is_dir():
for f in events_dir.glob("**/*.jsonl"): for f in events_dir.glob("**/*.jsonl"):
self._load_jsonl_file(f, "events") domain = f.parent.name
if domain == "sleep":
self._load_legacy_sleep_file(f)
elif domain in ("diaper", "growth"):
self._load_jsonl_file(f, "mila")
else:
self._load_jsonl_file(f, "nutrition")
# 3. Load garmin daily # 4. Load garmin daily
garmin_daily_dir = self.data_dir / "garmin" / "daily" garmin_daily_dir = self.data_dir / "garmin" / "daily"
if garmin_daily_dir.is_dir(): if garmin_daily_dir.is_dir():
for f in garmin_daily_dir.glob("*.jsonl"): for f in garmin_daily_dir.glob("*.jsonl"):
self._load_garmin_daily_file(f) self._load_garmin_daily_file(f)
def _load_legacy_sleep_file(self, file_path: Path):
"""Converts legacy events/sleep/*.jsonl records into the unified sleep table."""
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)
start_str = r.get("start")
start_dt = datetime.datetime.fromisoformat(start_str.replace("Z", "+00:00")).astimezone(BERLIN_TZ)
cal_date = start_dt.strftime("%Y-%m-%d")
end_str = r.get("end")
end_dt = datetime.datetime.fromisoformat(end_str.replace("Z", "+00:00")).astimezone(BERLIN_TZ) if end_str else None
dur_sec = int((end_dt - start_dt).total_seconds()) if end_dt else None
payload = r.get("payload") or {}
stype = payload.get("type") or "nap"
loc = payload.get("location") or "bett"
self.con.execute(
"""
INSERT OR REPLACE INTO sleep (
id, subject, calendarDate, sleepType, location, isOpen,
startIso, endIso, sleepTimeSeconds, napTimeSeconds,
note, actor, created_at, updated_at, deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
r["id"],
r.get("subject", "mila"),
cal_date,
stype,
loc,
end_str is None,
start_str,
end_str,
dur_sec,
dur_sec if stype == "nap" else 0,
r.get("note"),
r.get("actor", "agent:hebamme"),
r.get("created_at") or start_str,
r.get("updated_at"),
r.get("deleted_at"),
],
)
except Exception as e:
logger.warning(f"Error loading legacy sleep from {file_path}: {e}")
def _load_garmin_daily_file(self, file_path: Path): def _load_garmin_daily_file(self, file_path: Path):
"""Reads daily summaries from JSONL into garmin_daily table.""" """Reads daily summaries from JSONL into garmin_daily table."""
if not file_path.is_file() or file_path.stat().st_size == 0: if not file_path.is_file() or file_path.stat().st_size == 0:
@@ -356,6 +472,21 @@ class DatastoreEngine:
if not file_path.is_file() or file_path.stat().st_size == 0: if not file_path.is_file() or file_path.stat().st_size == 0:
return return
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"
},
}
allowed_cols = valid_cols_map.get(table_name)
with open(file_path, "r", encoding="utf-8") as f: with open(file_path, "r", encoding="utf-8") as f:
for line in f: for line in f:
line = line.strip() line = line.strip()
@@ -363,13 +494,22 @@ class DatastoreEngine:
continue continue
try: try:
record = json.loads(line) record = json.loads(line)
if table_name == "events": if table_name in ("nutrition", "mila"):
if "created_at" not in record or not record.get("created_at"): if "created_at" not in record or not record.get("created_at"):
record["created_at"] = record.get("start") or datetime.datetime.now(datetime.timezone.utc).isoformat() record["created_at"] = record.get("start") or datetime.datetime.now(datetime.timezone.utc).isoformat()
if "estimated" not in record: if table_name == "nutrition" and "estimated" not in record:
record["estimated"] = False record["estimated"] = False
if "deleted_at" not in record: if "deleted_at" not in record:
record["deleted_at"] = None record["deleted_at"] = None
elif table_name == "sleep":
if "created_at" not in record or not record.get("created_at"):
record["created_at"] = record.get("startIso") or datetime.datetime.now(datetime.timezone.utc).isoformat()
if "deleted_at" not in record:
record["deleted_at"] = None
if allowed_cols:
columns = [c for c in record.keys() if c in allowed_cols]
else:
columns = list(record.keys()) columns = list(record.keys())
placeholders = ", ".join(["?"] * len(columns)) placeholders = ", ".join(["?"] * len(columns))
col_names = ", ".join([f'"{c}"' for c in columns]) col_names = ", ".join([f'"{c}"' for c in columns])
@@ -388,7 +528,7 @@ class DatastoreEngine:
"""Determines if two event payloads represent the same intake/session.""" """Determines if two event payloads represent the same intake/session."""
if p1 == p2: if p1 == p2:
return True return True
if kind.startswith("nutrition."): if kind.startswith("nutrition.") or kind == "feeding.solid":
food1 = p1.get("food_id") food1 = p1.get("food_id")
food2 = p2.get("food_id") food2 = p2.get("food_id")
item1 = str(p1.get("item", "")).strip().lower() item1 = str(p1.get("item", "")).strip().lower()
@@ -400,8 +540,8 @@ class DatastoreEngine:
or (food2 and item1 and food2 == item1) or (food2 and item1 and food2 == item1)
) )
if same_item: if same_item:
qty1 = p1.get("qty") qty1 = p1.get("qty") or p1.get("amount_g")
qty2 = p2.get("qty") qty2 = p2.get("qty") or p2.get("amount_g")
if qty1 is not None and qty2 is not None: if qty1 is not None and qty2 is not None:
try: try:
return abs(float(qty1) - float(qty2)) < 0.01 return abs(float(qty1) - float(qty2)) < 0.01
@@ -417,7 +557,7 @@ class DatastoreEngine:
if p1.get("pee") == p2.get("pee") and p1.get("poo") == p2.get("poo"): if p1.get("pee") == p2.get("pee") and p1.get("poo") == p2.get("poo"):
return True return True
elif kind.startswith("sleep."): elif kind.startswith("sleep."):
if p1.get("type") == p2.get("type"): if (p1.get("type") or "nap") == (p2.get("type") or "nap"):
return True return True
elif kind.startswith("growth."): elif kind.startswith("growth."):
w1, w2 = p1.get("weight_kg"), p2.get("weight_kg") w1, w2 = p1.get("weight_kg"), p2.get("weight_kg")
@@ -472,13 +612,13 @@ class DatastoreEngine:
return None return None
def _get_open_sleep_sessions(self, subject: str, exclude_id: Optional[str] = None) -> List[Dict[str, Any]]: def _get_open_sleep_sessions(self, subject: str, exclude_id: Optional[str] = None) -> List[Dict[str, Any]]:
"""Finds open sleep sessions (end IS NULL) for subject, sorted chronologically.""" """Finds open sleep sessions (isOpen=true or endIso IS NULL) for subject, sorted chronologically."""
rows = self.con.execute( rows = self.con.execute(
""" """
SELECT id, start, payload, note SELECT id, startIso, sleepType, location, note
FROM events FROM sleep
WHERE kind = 'sleep.session' AND subject = ? AND "end" IS NULL AND deleted_at IS NULL WHERE subject = ? AND (isOpen = true OR endIso IS NULL) AND deleted_at IS NULL
ORDER BY start ASC ORDER BY startIso ASC
""", """,
[subject], [subject],
).fetchall() ).fetchall()
@@ -489,8 +629,8 @@ class DatastoreEngine:
results.append({ results.append({
"id": r[0], "id": r[0],
"start": str(r[1]), "start": str(r[1]),
"payload": json.loads(r[2]) if isinstance(r[2], str) else (r[2] or {}), "payload": {"type": r[2], "location": r[3]},
"note": r[3], "note": r[4],
}) })
return results return results
@@ -512,7 +652,7 @@ class DatastoreEngine:
source_ref: Optional[str] = None, source_ref: Optional[str] = None,
allow_duplicate: bool = False, allow_duplicate: bool = False,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Adds a time-series event, saves to JSONL partition, and returns confirmation.""" """Adds a time-series event into the appropriate database (sleep, nutrition, or mila)."""
event_id = generate_ulid() event_id = generate_ulid()
now_dt = datetime.datetime.now(datetime.timezone.utc) now_dt = datetime.datetime.now(datetime.timezone.utc)
now_iso = now_dt.isoformat() now_iso = now_dt.isoformat()
@@ -555,6 +695,7 @@ class DatastoreEngine:
start_iso = start_dt.isoformat() start_iso = start_dt.isoformat()
end_iso = None end_iso = None
end_dt = None
if end: if end:
end_dt = datetime.datetime.fromisoformat(end) end_dt = datetime.datetime.fromisoformat(end)
if end_dt.tzinfo is None: if end_dt.tzinfo is None:
@@ -585,16 +726,141 @@ class DatastoreEngine:
o_start_dt = datetime.datetime.fromisoformat(str(first_open["start"]).replace("Z", "+00:00")).astimezone(BERLIN_TZ) o_start_dt = datetime.datetime.fromisoformat(str(first_open["start"]).replace("Z", "+00:00")).astimezone(BERLIN_TZ)
open_warning = f"Vorheriges Schlaf-Event ({first_open['id']} ab {o_start_dt.strftime('%d.%m. %H:%M Uhr')}) ist noch offen! Bitte Aufwachzeit nachreichen." open_warning = f"Vorheriges Schlaf-Event ({first_open['id']} ab {o_start_dt.strftime('%d.%m. %H:%M Uhr')}) ist noch offen! Bitte Aufwachzeit nachreichen."
# If adding a new open interval (end is None)
if end_iso is None: if end_iso is None:
# If existing open session is recent (< 8 hours on same calendar day) and not allow_duplicate, prevent accidental duplicate live nap
diff_hours = (start_dt - o_start_dt).total_seconds() / 3600.0 diff_hours = (start_dt - o_start_dt).total_seconds() / 3600.0
if diff_hours < 8.0 and start_dt.date() == o_start_dt.date() and not allow_duplicate: if diff_hours < 8.0 and start_dt.date() == o_start_dt.date() and not allow_duplicate:
raise ValueError( raise ValueError(
f"Subject '{subject}' hat bereits ein aktives Schlaf-Event ({first_open['id']} gestartet um {o_start_dt.strftime('%H:%M Uhr')}). Bitte beende es zuerst." f"Subject '{subject}' hat bereits ein aktives Schlaf-Event ({first_open['id']} gestartet um {o_start_dt.strftime('%H:%M Uhr')}). Bitte beende es zuerst."
) )
record = { target_db = self._get_target_db(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"
if target_db == "sleep":
cal_date = start_dt.astimezone(BERLIN_TZ).strftime("%Y-%m-%d")
sleep_type = payload.get("type") or "nap"
location = payload.get("location") or "bett"
is_open = (end_iso is None)
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": 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": note,
"actor": 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, subject, cal_date, sleep_type, location, is_open,
start_iso, end_iso, start_gmt, end_gmt, start_local, end_local,
dur_sec, nap_sec, note, 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": kind,
"subject": subject,
"actor": actor,
"start": start_iso,
"end": end_iso,
"note": note,
"payload": payload,
"source_ref": 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, kind, subject, actor, start_iso, end_iso, note, json.dumps(payload), source_ref, now_iso, None, None],
)
sort_key = lambda x: (x.get("start", ""), x.get("id", ""))
else:
disk_record = {
"id": event_id,
"kind": kind,
"subject": subject,
"actor": actor,
"start": start_iso,
"end": end_iso,
"estimated": estimated,
"meal": meal,
"note": note,
"payload": payload,
"source_ref": 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, kind, subject, actor, start_iso, end_iso, estimated, meal, note, json.dumps(payload), 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)
record_response = {
"id": event_id, "id": event_id,
"kind": kind, "kind": kind,
"subject": subject, "subject": subject,
@@ -611,44 +877,6 @@ class DatastoreEngine:
"deleted_at": None, "deleted_at": None,
} }
# Insert into in-memory DuckDB
self.con.execute(
"""
INSERT INTO events (id, kind, subject, actor, start, "end", estimated, meal, note, payload, source_ref, created_at, updated_at, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
[
event_id,
kind,
subject,
actor,
start_iso,
end_iso,
estimated,
meal,
note,
json.dumps(payload),
source_ref,
now_iso,
None,
None,
],
)
# Write to partitioned JSONL file
# Partition path: events/<domain>/<YYYY-MM>.jsonl
domain = kind.split(".")[0]
month_str = start_dt.astimezone(BERLIN_TZ).strftime("%Y-%m")
domain_dir = self.data_dir / "events" / domain
domain_dir.mkdir(parents=True, exist_ok=True)
partition_file = domain_dir / f"{month_str}.jsonl"
with open(partition_file, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
# Sort partition deterministically by start, id
self._sort_jsonl_file(partition_file, key_fn=lambda x: (x.get("start", ""), x.get("id", "")))
# Fetch computed feedback # Fetch computed feedback
feedback = self._get_event_feedback(kind, subject, start_dt.date()) feedback = self._get_event_feedback(kind, subject, start_dt.date())
if open_warning: if open_warning:
@@ -663,7 +891,7 @@ class DatastoreEngine:
return { return {
"status": "success", "status": "success",
"event": record, "event": record_response,
"feedback": feedback, "feedback": feedback,
} }
@@ -680,10 +908,36 @@ class DatastoreEngine:
if since and end_time: if since and end_time:
raise ValueError("Cannot specify both 'since' and 'end_time'. Use 'since' for relative times or 'end_time' for absolute times.") raise ValueError("Cannot specify both 'since' and 'end_time'. Use 'since' for relative times or 'end_time' for absolute times.")
target_db = self._get_target_db(kind)
if target_db == "sleep":
if event_id: if event_id:
row = self.con.execute( row = self.con.execute(
""" """
SELECT id, start FROM events SELECT id, startIso, sleepType FROM sleep
WHERE id = ? AND subject = ? AND (isOpen = true OR endIso IS NULL) AND deleted_at IS NULL
""",
[event_id, subject],
).fetchone()
if not row:
raise ValueError(f"No open event with id='{event_id}' found for kind='{kind}' and subject='{subject}'")
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
""",
[subject],
).fetchone()
if not row:
raise ValueError(f"No open event found for kind='{kind}' and subject='{subject}'")
resolved_event_id, start_str, sleep_type = row[0], str(row[1]), row[2]
else:
if 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 WHERE id = ? AND kind = ? AND subject = ? AND "end" IS NULL AND deleted_at IS NULL
""", """,
[event_id, kind, subject], [event_id, kind, subject],
@@ -692,8 +946,8 @@ class DatastoreEngine:
raise ValueError(f"No open event with id='{event_id}' found for kind='{kind}' and subject='{subject}'") raise ValueError(f"No open event with id='{event_id}' found for kind='{kind}' and subject='{subject}'")
else: else:
row = self.con.execute( row = self.con.execute(
""" f"""
SELECT id, start FROM events SELECT id, start FROM "{target_db}"
WHERE kind = ? AND subject = ? AND "end" IS NULL AND deleted_at IS NULL WHERE kind = ? AND subject = ? AND "end" IS NULL AND deleted_at IS NULL
ORDER BY start DESC LIMIT 1 ORDER BY start DESC LIMIT 1
""", """,
@@ -701,8 +955,8 @@ class DatastoreEngine:
).fetchone() ).fetchone()
if not row: if not row:
raise ValueError(f"No open event found for kind='{kind}' and subject='{subject}'") raise ValueError(f"No open event found for kind='{kind}' and subject='{subject}'")
resolved_event_id, start_str, sleep_type = row[0], str(row[1]), None
resolved_event_id, start_str = row[0], str(row[1])
now_dt = datetime.datetime.now(datetime.timezone.utc) now_dt = datetime.datetime.now(datetime.timezone.utc)
if since: if since:
delta = parse_duration(since) delta = parse_duration(since)
@@ -722,24 +976,41 @@ class DatastoreEngine:
end_iso = end_dt.isoformat() end_iso = end_dt.isoformat()
now_iso = now_dt.isoformat() now_iso = now_dt.isoformat()
duration_seconds = int((end_dt.astimezone(BERLIN_TZ) - start_dt.astimezone(BERLIN_TZ)).total_seconds())
duration_minutes = int(duration_seconds / 60)
# Update in DuckDB 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 = duration_seconds if sleep_type == "nap" else 0
self.con.execute( self.con.execute(
""" """
UPDATE events UPDATE sleep
SET endIso = ?, isOpen = false, sleepEndTimestampGMT = ?, sleepEndTimestampLocal = ?,
sleepTimeSeconds = ?, napTimeSeconds = ?, updated_at = ?
WHERE id = ?
""",
[end_iso, end_gmt, end_local, duration_seconds, nap_sec, now_iso, resolved_event_id],
)
self._update_jsonl_record("sleep", resolved_event_id, {
"endIso": end_iso,
"isOpen": False,
"sleepEndTimestampGMT": end_gmt,
"sleepEndTimestampLocal": end_local,
"sleepTimeSeconds": duration_seconds,
"napTimeSeconds": nap_sec,
"updated_at": now_iso,
})
else:
self.con.execute(
f"""
UPDATE "{target_db}"
SET "end" = ?, updated_at = ? SET "end" = ?, updated_at = ?
WHERE id = ? WHERE id = ?
""", """,
[end_iso, now_iso, resolved_event_id], [end_iso, now_iso, resolved_event_id],
) )
self._update_jsonl_record(target_db, resolved_event_id, {"end": end_iso, "updated_at": now_iso})
# Update in JSONL
domain = kind.split(".")[0]
self._update_jsonl_record(domain, resolved_event_id, {"end": end_iso, "updated_at": now_iso})
# Calculate duration
start_dt = datetime.datetime.fromisoformat(start_str.replace("Z", "+00:00"))
duration_minutes = int((end_dt.astimezone(BERLIN_TZ) - start_dt.astimezone(BERLIN_TZ)).total_seconds() / 60)
self._trigger_website_update(subject) self._trigger_website_update(subject)
self._git_sync(f"chore(data): stop {kind} for {subject}") self._git_sync(f"chore(data): stop {kind} for {subject}")
@@ -758,7 +1029,7 @@ class DatastoreEngine:
} }
def delete_event(self, event_id: str) -> Dict[str, Any]: def delete_event(self, event_id: str) -> Dict[str, Any]:
"""Soft-deletes an event.""" """Soft-deletes an event across sleep, nutrition, or mila."""
now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat() now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
row = self.con.execute("SELECT kind, subject FROM events WHERE id = ?", [event_id]).fetchone() row = self.con.execute("SELECT kind, subject FROM events WHERE id = ?", [event_id]).fetchone()
if not row: if not row:
@@ -766,10 +1037,10 @@ class DatastoreEngine:
kind = row[0] kind = row[0]
subject = row[1] subject = row[1]
domain = kind.split(".")[0] target_db = self._get_target_db(kind)
self.con.execute("UPDATE events SET deleted_at = ? WHERE id = ?", [now_iso, event_id]) self.con.execute(f'UPDATE "{target_db}" SET deleted_at = ? WHERE id = ?', [now_iso, event_id])
self._update_jsonl_record(domain, event_id, {"deleted_at": now_iso}) self._update_jsonl_record(target_db, event_id, {"deleted_at": now_iso})
self._trigger_website_update(subject) self._trigger_website_update(subject)
self._git_sync(f"chore(data): delete event {event_id} ({kind}) for {subject}") self._git_sync(f"chore(data): delete event {event_id} ({kind}) for {subject}")
@@ -950,7 +1221,7 @@ class DatastoreEngine:
def _get_event_feedback(self, kind: str, subject: str, target_date: datetime.date) -> Dict[str, Any]: def _get_event_feedback(self, kind: str, subject: str, target_date: datetime.date) -> Dict[str, Any]:
"""Generates compact running status feedback after an insert.""" """Generates compact running status feedback after an insert."""
date_str = target_date.strftime("%Y-%m-%d") date_str = target_date.strftime("%Y-%m-%d")
if kind.startswith("nutrition"): if kind.startswith("nutrition") and subject != "mila":
daily = self.con.execute( 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 = ?", "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], [subject, date_str],
@@ -985,16 +1256,26 @@ class DatastoreEngine:
"wet_count": daily[1], "wet_count": daily[1],
"dirty_count": daily[2], "dirty_count": daily[2],
} }
elif kind.startswith("feeding"): elif kind.startswith("feeding") or (kind.startswith("nutrition") and subject == "mila"):
daily = self.con.execute( daily = self.con.execute(
"SELECT bottle_count, total_consumed_ml FROM v_feeding_daily WHERE subject = ? AND log_date = ?", "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], [subject, date_str],
).fetchone() ).fetchone()
if daily: 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 { return {
"day_total_formatted": f"Fläschchen ({date_str}): {daily[0]} Fläschchen ({int(daily[1])} ml getrunken)", "day_total_formatted": f"Ernährung Mila ({date_str}): {summary_str} · ~{int(tot_kcal)} kcal",
"bottle_count": daily[0], "bottle_count": b_cnt,
"total_consumed_ml": daily[1], "total_consumed_ml": b_ml,
"solid_count": s_cnt,
"total_solid_g": s_g,
"total_kcal": tot_kcal,
} }
elif kind.startswith("growth"): elif kind.startswith("growth"):
row = self.con.execute( row = self.con.execute(
@@ -1056,13 +1337,13 @@ class DatastoreEngine:
for r in records: for r in records:
f.write(json.dumps(r, ensure_ascii=False) + "\n") f.write(json.dumps(r, ensure_ascii=False) + "\n")
def _update_jsonl_record(self, domain: str, event_id: str, updates: Dict[str, Any]): def _update_jsonl_record(self, target_db: str, event_id: str, updates: Dict[str, Any]):
"""Finds and updates a record in events/<domain>/*.jsonl.""" """Finds and updates a record in <target_db>/*.jsonl."""
domain_dir = self.data_dir / "events" / domain db_dir = self.data_dir / target_db
if not domain_dir.is_dir(): if not db_dir.is_dir():
return return
for p_file in domain_dir.glob("*.jsonl"): for p_file in db_dir.glob("*.jsonl"):
records = [] records = []
found = False found = False
with open(p_file, "r", encoding="utf-8") as f: with open(p_file, "r", encoding="utf-8") as f:
+82 -4
View File
@@ -173,12 +173,14 @@ class GarminMirrorWriter:
"""Manages month-partitioned JSONL files with idempotent upserts.""" """Manages month-partitioned JSONL files with idempotent upserts."""
def __init__(self, data_dir: Path): def __init__(self, data_dir: Path):
self.data_dir = data_dir
self.garmin_dir = data_dir / "garmin" self.garmin_dir = data_dir / "garmin"
self.daily_dir = self.garmin_dir / "daily" self.daily_dir = self.garmin_dir / "daily"
self.sleep_dir = self.garmin_dir / "sleep" self.sleep_dir = self.garmin_dir / "sleep"
self.unified_sleep_dir = data_dir / "sleep"
self.activities_dir = self.garmin_dir / "activities" self.activities_dir = self.garmin_dir / "activities"
for d in [self.daily_dir, self.sleep_dir, self.activities_dir]: for d in [self.daily_dir, self.sleep_dir, self.unified_sleep_dir, self.activities_dir]:
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
def _read_existing(self, file_path: Path) -> List[Dict[str, Any]]: def _read_existing(self, file_path: Path) -> List[Dict[str, Any]]:
@@ -230,8 +232,9 @@ class GarminMirrorWriter:
def upsert_sleep_data(self, sleep_data: Dict[str, Any]): def upsert_sleep_data(self, sleep_data: Dict[str, Any]):
cal_date = None cal_date = None
if "dailySleepDTO" in sleep_data and isinstance(sleep_data["dailySleepDTO"], dict): dto = sleep_data.get("dailySleepDTO") if isinstance(sleep_data.get("dailySleepDTO"), dict) else {}
cal_date = sleep_data["dailySleepDTO"].get("calendarDate") if dto:
cal_date = dto.get("calendarDate")
if not cal_date: if not cal_date:
cal_date = sleep_data.get("calendarDate") cal_date = sleep_data.get("calendarDate")
@@ -267,7 +270,82 @@ class GarminMirrorWriter:
new_items.sort(key=_sort_key) new_items.sort(key=_sort_key)
self._write_atomic(target_file, new_items) self._write_atomic(target_file, new_items)
logger.info(f"Upserted sleep data for {cal_date} into {target_file.name}")
# Also upsert into unified data/sleep/<YYYY-MM>.jsonl
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 = self.unified_sleep_dir / f"{month}.jsonl"
existing_unified = self._read_existing(unified_file)
updated_u = False
new_unified = []
for item in existing_unified:
if item.get("subject") == "moritz" and item.get("calendarDate") == cal_date:
new_unified.append(unified_rec)
updated_u = True
else:
new_unified.append(item)
if not updated_u:
new_unified.append(unified_rec)
new_unified.sort(key=lambda x: (str(x.get("calendarDate", "")), str(x.get("startIso", "")), str(x.get("id", ""))))
self._write_atomic(unified_file, new_unified)
logger.info(f"Upserted sleep data for {cal_date} into {target_file.name} and {unified_file.name}")
def upsert_activities(self, activities: List[Dict[str, Any]]): def upsert_activities(self, activities: List[Dict[str, Any]]):
if not activities: if not activities:
@@ -266,6 +266,105 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
[yesterday_str] [yesterday_str]
).fetchall() ).fetchall()
# Query v_sleep_daily for Moritz (Garmin Sleep & Recovery)
sleep_y_row = 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()
# Fallback to latest available sleep entry if yesterday has not synced yet
if not sleep_y_row or sleep_y_row[1] is None or sleep_y_row[1] == 0:
latest_sleep = 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]
sleep_log_date_str = str(latest_sleep[12])
else:
sleep_log_date_str = yesterday_str
else:
sleep_log_date_str = yesterday_str
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 = 0
light_m = 0
rem_m = 0
awake_m = 0
deep_pct = 0
light_pct = 0
rem_pct = 0
awake_pct = 0
if sleep_log_date_str == yesterday_str:
sleep_date_label = yesterday_pretty
else:
try:
d_obj = datetime.date.fromisoformat(sleep_log_date_str)
sleep_date_label = f"{WEEKDAYS_LONG_DE[d_obj.weekday()]}, {d_obj.strftime('%d.%m.%Y')}"
except Exception:
sleep_date_label = sleep_log_date_str
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# 3. LETZTE 7 TAGE (Vollendete Tage bis gestern) # 3. LETZTE 7 TAGE (Vollendete Tage bis gestern)
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -717,6 +816,67 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
</section> </section>
<!-- SCHLAF & ERHOLUNG (GARMIN) TILE -->
<section class="bg-white rounded-3xl p-5 sm:p-6 border border-slate-200/90 shadow-sm hover:shadow-md hover:border-slate-300 transition cursor-pointer group" onclick="openModal('modal-yesterday-detail')">
<div class="flex items-center justify-between pb-3 border-b border-slate-100">
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full bg-indigo-500"></span>
<span class="text-xs font-bold uppercase tracking-wider text-slate-500">Schlaf & Erholung (Garmin)</span>
</div>
<span class="text-xs font-semibold text-slate-400 group-hover:text-indigo-600 flex items-center gap-1 transition">
{sleep_date_label} · Deep Dive <i data-lucide="chevron-right" class="w-3.5 h-3.5"></i>
</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 pt-4">
<!-- Sleep Score -->
<div class="p-3 rounded-2xl bg-slate-50 border border-slate-100">
<span class="block text-[11px] text-slate-500 font-medium">Sleep Score</span>
<div class="flex items-baseline gap-1 mt-0.5">
<span class="text-2xl font-black font-mono text-indigo-700">{sleep_score if sleep_score is not None else '—'}</span>
<span class="text-xs font-semibold text-slate-500">{f'({sleep_qualifier})' if sleep_qualifier else ''}</span>
</div>
</div>
<!-- Duration -->
<div class="p-3 rounded-2xl bg-slate-50 border border-slate-100">
<span class="block text-[11px] text-slate-500 font-medium">Schlafdauer</span>
<div class="text-2xl font-black font-mono text-slate-800 mt-0.5">{sleep_dur_str}</div>
<div class="text-[10px] text-slate-400 mt-0.5">Tief {deep_m}m · Leicht {light_m}m · REM {rem_m}m</div>
</div>
<!-- Overnight HRV -->
<div class="p-3 rounded-2xl bg-slate-50 border border-slate-100">
<span class="block text-[11px] text-slate-500 font-medium">Overnight HRV</span>
<div class="flex items-baseline gap-1 mt-0.5">
<span class="text-2xl font-black font-mono text-emerald-700">{f'{hrv_val} ms' if hrv_val is not None else '—'}</span>
<span class="text-[10px] text-slate-400">{hrv_status}</span>
</div>
</div>
<!-- Body Battery & Resting HR -->
<div class="p-3 rounded-2xl bg-slate-50 border border-slate-100">
<span class="block text-[11px] text-slate-500 font-medium">Body Battery & Puls</span>
<div class="text-2xl font-black font-mono text-sky-700 mt-0.5">{f'+{bb_change}' if bb_change is not None else '—'}</div>
<div class="text-[10px] text-slate-400 mt-0.5">Ruhepuls: {rhr_val if rhr_val is not None else '—'} bpm</div>
</div>
</div>
<!-- Sleep Phase Breakdown Bar -->
<div class="mt-3 pt-3 border-t border-slate-100">
<div class="flex justify-between text-[11px] text-slate-500 mb-1">
<span>Phasen: Tief ({deep_m}m) · Leicht ({light_m}m) · REM ({rem_m}m) · Wach ({awake_m}m)</span>
<span class="font-semibold text-indigo-600">Details anzeigen →</span>
</div>
<div class="w-full bg-slate-100 rounded-full h-2.5 flex overflow-hidden">
<div class="bg-indigo-600 h-2.5" style="width: {deep_pct}%" title="Tiefschlaf"></div>
<div class="bg-sky-400 h-2.5" style="width: {light_pct}%" title="Leichtschlaf"></div>
<div class="bg-purple-400 h-2.5" style="width: {rem_pct}%" title="REM-Schlaf"></div>
<div class="bg-amber-300 h-2.5" style="width: {awake_pct}%" title="Wach"></div>
</div>
</div>
</section>
<!-- AGGREGATIONS-STRIP: Gestern, Letzte 7 Tage, Letzte 30 Tage --> <!-- AGGREGATIONS-STRIP: Gestern, Letzte 7 Tage, Letzte 30 Tage -->
<section class="space-y-3"> <section class="space-y-3">
<div class="flex items-center justify-between px-1"> <div class="flex items-center justify-between px-1">
@@ -992,6 +1152,50 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
</div> </div>
</div> </div>
<!-- Sleep & Regeneration Deep Dive -->
<div class="p-4 rounded-2xl bg-indigo-50/50 border border-indigo-100 space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5 text-xs font-bold uppercase tracking-wider text-indigo-900">
<i data-lucide="moon" class="w-4 h-4 text-indigo-600"></i>
Schlaf & Regeneration (Garmin)
</div>
<span class="text-xs font-bold text-indigo-700 font-mono">Score: {sleep_score if sleep_score is not None else '—'} {f'({sleep_qualifier})' if sleep_qualifier else ''}</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 text-center text-xs">
<div class="p-2.5 rounded-xl bg-white border border-indigo-100/80">
<span class="block text-[10px] text-slate-500">Dauer</span>
<strong class="text-sm font-mono text-slate-800">{sleep_dur_str}</strong>
</div>
<div class="p-2.5 rounded-xl bg-white border border-indigo-100/80">
<span class="block text-[10px] text-slate-500">Overnight HRV</span>
<strong class="text-sm font-mono text-emerald-700">{f'{hrv_val} ms' if hrv_val is not None else '—'}</strong>
<span class="block text-[9px] text-slate-400">{hrv_status}</span>
</div>
<div class="p-2.5 rounded-xl bg-white border border-indigo-100/80">
<span class="block text-[10px] text-slate-500">Body Battery</span>
<strong class="text-sm font-mono text-sky-700">{f'+{bb_change}' if bb_change is not None else '—'}</strong>
</div>
<div class="p-2.5 rounded-xl bg-white border border-indigo-100/80">
<span class="block text-[10px] text-slate-500">Ruhepuls</span>
<strong class="text-sm font-mono text-slate-800">{rhr_val if rhr_val is not None else '—'} bpm</strong>
</div>
</div>
<!-- Sleep Stages Breakdown -->
<div class="pt-1">
<div class="flex justify-between text-[11px] text-slate-600 mb-1">
<span>Tief: <strong>{deep_m}m</strong> · Leicht: <strong>{light_m}m</strong> · REM: <strong>{rem_m}m</strong> · Wach: <strong>{awake_m}m</strong></span>
</div>
<div class="w-full bg-slate-200/80 rounded-full h-2 flex overflow-hidden">
<div class="bg-indigo-600 h-2" style="width: {deep_pct}%" title="Tiefschlaf"></div>
<div class="bg-sky-400 h-2" style="width: {light_pct}%" title="Leichtschlaf"></div>
<div class="bg-purple-400 h-2" style="width: {rem_pct}%" title="REM-Schlaf"></div>
<div class="bg-amber-300 h-2" style="width: {awake_pct}%" title="Wach"></div>
</div>
</div>
</div>
<!-- Yesterday Meals List --> <!-- Yesterday Meals List -->
<div> <div>
<h4 class="text-xs font-bold uppercase text-slate-500 mb-2">Gestern gegessen</h4> <h4 class="text-xs font-bold uppercase text-slate-500 mb-2">Gestern gegessen</h4>
+76 -14
View File
@@ -197,10 +197,18 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
today_night_h, today_night_m = divmod(today_night_min, 60) 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" 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 (conditional) # 3. Today's Feedings & Nutrition (Bottles + Brei/Beikost)
today_feedings = con.execute( today_feedings = con.execute(
f""" f"""
SELECT strftime(local_time, '%H:%M'), consumed_ml, milk_type, note SELECT
strftime(local_time, '%H:%M') AS time_str,
consumed_ml,
milk_type,
item,
solid_g,
kcal,
kind,
note
FROM v_feeding FROM v_feeding
WHERE subject = 'mila' AND log_date = '{today_str}' WHERE subject = 'mila' AND log_date = '{today_str}'
ORDER BY start ASC ORDER BY start ASC
@@ -209,7 +217,12 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
today_feeding_agg = con.execute( today_feeding_agg = con.execute(
f""" f"""
SELECT bottle_count, total_consumed_ml SELECT
bottle_count,
total_consumed_ml,
solid_count,
total_solid_g,
total_kcal
FROM v_feeding_daily FROM v_feeding_daily
WHERE subject = 'mila' AND log_date = '{today_str}' WHERE subject = 'mila' AND log_date = '{today_str}'
""" """
@@ -218,6 +231,9 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
has_feeding_today = bool(today_feedings and len(today_feedings) > 0) has_feeding_today = bool(today_feedings and len(today_feedings) > 0)
today_bottle_count = today_feeding_agg[0] if today_feeding_agg else 0 today_bottle_count = today_feeding_agg[0] if today_feeding_agg else 0
today_bottle_ml = int(today_feeding_agg[1]) if today_feeding_agg else 0 today_bottle_ml = int(today_feeding_agg[1]) if today_feeding_agg else 0
today_solid_count = today_feeding_agg[2] if today_feeding_agg else 0
today_solid_g = int(today_feeding_agg[3]) if today_feeding_agg else 0
today_feeding_kcal = int(today_feeding_agg[4]) if today_feeding_agg else 0
# 4. Today's Diapers (conditional) # 4. Today's Diapers (conditional)
today_diapers = con.execute( today_diapers = con.execute(
@@ -272,6 +288,8 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
s.nap_sleep_minutes, s.nap_sleep_minutes,
COALESCE(f.total_consumed_ml, 0) AS feed_ml, COALESCE(f.total_consumed_ml, 0) AS feed_ml,
COALESCE(f.bottle_count, 0) AS feed_bottles, 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.total_changes, 0) AS diaper_cnt,
COALESCE(d.dirty_count, 0) AS diaper_dirty COALESCE(d.dirty_count, 0) AS diaper_dirty
FROM v_sleep_daily s FROM v_sleep_daily s
@@ -311,8 +329,15 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
night_h, night_m = divmod(r[2], 60) night_h, night_m = divmod(r[2], 60)
night_str = f"{night_h} h {night_m:02d} min" if night_h > 0 else f"{night_m} min" night_str = f"{night_h} h {night_m:02d} min" if night_h > 0 else f"{night_m} min"
nap_str = f"{r[3]} ({r[4]} min)" nap_str = f"{r[3]} ({r[4]} min)"
feed_str = f"{int(r[5])} ml ({r[6]}x)" if r[6] > 0 else "—"
diaper_str = f"{r[7]} ({r[8]} Stuhl)" if r[7] > 0 else "—" feed_parts = []
if r[6] > 0: # feed_bottles
feed_parts.append(f"{int(r[5])} ml ({r[6]}x)")
if r[8] > 0: # feed_solid_count
feed_parts.append(f"{int(r[7])} g Brei ({r[8]}x)")
feed_str = " + ".join(feed_parts) if feed_parts else "—"
diaper_str = f"{r[9]} ({r[10]} Stuhl)" if r[9] > 0 else "—"
# Detailed breakdown of sessions for this day # Detailed breakdown of sessions for this day
day_sess = sessions_by_date.get(d_str, []) day_sess = sessions_by_date.get(d_str, [])
@@ -339,7 +364,7 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
<td class="py-3.5 px-4 font-bold text-purple-300">{tot_h}</td> <td class="py-3.5 px-4 font-bold text-purple-300">{tot_h}</td>
<td class="py-3.5 px-4 text-sky-400 font-semibold">{night_str}</td> <td class="py-3.5 px-4 text-sky-400 font-semibold">{night_str}</td>
<td class="py-3.5 px-4 text-emerald-400">{nap_str}</td> <td class="py-3.5 px-4 text-emerald-400">{nap_str}</td>
<td class="py-3.5 px-4 text-zinc-400">{feed_str}</td> <td class="py-3.5 px-4 text-amber-300 font-medium">{feed_str}</td>
<td class="py-3.5 px-4 text-zinc-400">{diaper_str}</td> <td class="py-3.5 px-4 text-zinc-400">{diaper_str}</td>
</tr> </tr>
<tr class="border-b border-zinc-800/30 bg-zinc-950/30 text-xs"> <tr class="border-b border-zinc-800/30 bg-zinc-950/30 text-xs">
@@ -421,25 +446,62 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
# Conditional feeding card HTML # Conditional feeding card HTML
feeding_section_html = "" feeding_section_html = ""
if has_feeding_today: if has_feeding_today:
bottles_pills = [] feeding_pills = []
for f in today_feedings: for f in today_feedings:
ml_val = f"{int(f[1])} ml" if f[1] else "—" time_str = f[0]
bottles_pills.append(f'<span class="px-2 py-1 rounded bg-amber-500/10 border border-amber-500/20 text-amber-300 text-xs">{f[0]} Uhr ({ml_val})</span>') ml_val = f[1]
milk_type = f[2]
item_name = f[3]
solid_g = f[4]
kcal_val = f[5]
kind = f[6]
if kind == "bottle" or (ml_val is not None and ml_val > 0):
ml_str = f"{int(ml_val)} ml"
milk_desc = f" · {milk_type}" if milk_type else ""
feeding_pills.append(
f'<span class="px-2.5 py-1 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-300 text-xs flex items-center gap-1.5 font-medium">'
f'<span>🍼</span> <span>{time_str} Uhr: <strong>{ml_str}</strong>{milk_desc}</span></span>'
)
else:
food_title = item_name or "Brei"
details = []
if solid_g:
details.append(f"{int(solid_g)} g")
if kcal_val:
details.append(f"~{int(kcal_val)} kcal")
detail_str = f" ({', '.join(details)})" if details else ""
feeding_pills.append(
f'<span class="px-2.5 py-1 rounded-lg bg-orange-500/10 border border-orange-500/20 text-orange-300 text-xs flex items-center gap-1.5 font-medium">'
f'<span>🥣</span> <span>{time_str} Uhr: <strong>{food_title}</strong>{detail_str}</span></span>'
)
feed_summary_parts = []
if today_bottle_count > 0:
feed_summary_parts.append(
f'<span class="font-mono font-bold text-amber-400 text-base">{today_bottle_ml} ml <span class="text-xs text-zinc-400 font-normal">({today_bottle_count}x Flasche)</span></span>'
)
if today_solid_count > 0:
feed_summary_parts.append(
f'<span class="font-mono font-bold text-orange-400 text-base">{today_solid_g} g <span class="text-xs text-zinc-400 font-normal">({today_solid_count}x Brei)</span></span>'
)
feed_summary_html = " · ".join(feed_summary_parts) if feed_summary_parts else '<span class="text-zinc-500 text-sm">—</span>'
feeding_section_html = f""" feeding_section_html = f"""
<div class="bg-zinc-900/80 border border-amber-500/20 rounded-2xl p-5 shadow-lg space-y-3"> <div class="bg-zinc-900/80 border border-amber-500/20 rounded-2xl p-5 shadow-lg space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-xl">🍼</span> <span class="text-xl">🍼🥣</span>
<h3 class="font-semibold text-zinc-200">Fläschchen heute</h3> <h3 class="font-semibold text-zinc-200">Ernährung heute</h3>
</div> </div>
<span class="font-mono font-bold text-amber-400 text-lg">{today_bottle_ml} ml <span class="text-xs text-zinc-400 font-normal">({today_bottle_count}x)</span></span> <div>{feed_summary_html}</div>
</div> </div>
<div class="flex flex-wrap gap-2 pt-1"> <div class="flex flex-wrap gap-2 pt-1">
{' '.join(bottles_pills)} {' '.join(feeding_pills)}
</div> </div>
</div> </div>
""" """
# Conditional diaper card HTML # Conditional diaper card HTML
diaper_section_html = "" diaper_section_html = ""
if has_diapers_today: if has_diapers_today:
@@ -999,7 +1061,7 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
<th class="py-2.5 px-4">24h Gesamt</th> <th class="py-2.5 px-4">24h Gesamt</th>
<th class="py-2.5 px-4">Nacht</th> <th class="py-2.5 px-4">Nacht</th>
<th class="py-2.5 px-4">Schläfchen</th> <th class="py-2.5 px-4">Schläfchen</th>
<th class="py-2.5 px-4">Fläschchen</th> <th class="py-2.5 px-4">Ernährung (Milch & Brei)</th>
<th class="py-2.5 px-4 rounded-r-lg">Windeln</th> <th class="py-2.5 px-4 rounded-r-lg">Windeln</th>
</tr> </tr>
</thead> </thead>
+110
View File
@@ -810,6 +810,116 @@ def test_website_growth_html_rendering(temp_datastore: DatastoreEngine):
assert "who_height" in html assert "who_height" in html
def test_feeding_solid_brei_logging(temp_datastore: DatastoreEngine):
"""Verifies feeding solid (Brei/Beikost) events, feedback formatting, and DuckDB views."""
res = temp_datastore.add_event(
kind="feeding.solid",
subject="mila",
actor="agent:mila_tracker",
start="2026-10-12T12:00:00+02:00",
meal="Mittagessen",
payload={
"item": "Kürbis-Kartoffel-Brei",
"amount_g": 130,
"kcal": 95,
},
note="Guter Appetit",
)
assert res["status"] == "success"
fb = res["feedback"]
assert fb["solid_count"] == 1
assert fb["total_solid_g"] == 130.0
assert "130 g" in fb["day_total_formatted"]
assert "Brei" in fb["day_total_formatted"]
assert res["event"]["payload"]["item"] == "Kürbis-Kartoffel-Brei"
# Verify v_feeding view
rows = temp_datastore.query("SELECT * FROM v_feeding WHERE subject = 'mila' AND kind = 'feeding.solid'")
assert len(rows) == 1
assert rows[0]["item"] == "Kürbis-Kartoffel-Brei"
assert rows[0]["solid_g"] == 130.0
assert rows[0]["kcal"] == 95.0
# Verify v_feeding_daily combined aggregation
daily = temp_datastore.query("SELECT * FROM v_feeding_daily WHERE subject = 'mila' AND log_date = '2026-10-12'")
assert len(daily) == 1
assert daily[0]["solid_count"] == 1
assert daily[0]["total_solid_g"] == 130.0
assert daily[0]["total_kcal"] == 95.0
def test_three_databases_isolation_and_reloading(temp_datastore: DatastoreEngine):
"""Verifies that events are routed to sleep/, nutrition/, mila/ databases and reloaded correctly."""
# 1. Sleep session -> sleep/
res_sleep = temp_datastore.add_event(
kind="sleep.session",
subject="mila",
actor="agent:mila_tracker",
start="2026-10-13T09:00:00+02:00",
end="2026-10-13T10:00:00+02:00",
payload={"type": "nap", "location": "bett"},
)
assert res_sleep["status"] == "success"
# 2. Nutrition intake -> nutrition/
res_nut = temp_datastore.add_event(
kind="nutrition.intake",
subject="moritz",
actor="agent:nutrition_tracker",
start="2026-10-13T08:00:00+02:00",
meal="Frühstück",
payload={"item": "Rührei", "qty": 150, "unit": "g", "protein_g": 18.0, "kcal": 210.0},
)
assert res_nut["status"] == "success"
# 3. Diaper change -> mila/
res_diaper = temp_datastore.add_event(
kind="diaper.change",
subject="mila",
actor="agent:mila_tracker",
start="2026-10-13T10:15:00+02:00",
payload={"pee": True, "poo": False},
)
assert res_diaper["status"] == "success"
# Verify file locations on disk
data_dir = temp_datastore.data_dir
sleep_files = list((data_dir / "sleep").glob("*.jsonl"))
nutrition_files = list((data_dir / "nutrition").glob("*.jsonl"))
mila_files = list((data_dir / "mila").glob("*.jsonl"))
assert len(sleep_files) == 1
assert len(nutrition_files) == 1
assert len(mila_files) == 1
assert not (data_dir / "events").exists()
# Re-instantiate engine from disk (cold load test)
reloaded_engine = DatastoreEngine(data_dir=data_dir)
# Check that events union view contains all 3 records
all_events = reloaded_engine.query("SELECT id, kind, subject FROM events ORDER BY start ASC")
assert len(all_events) == 3
kinds = [e["kind"] for e in all_events]
assert "nutrition.intake" in kinds
assert "sleep.session" in kinds
assert "diaper.change" in kinds
# Check domain daily views
s_view = reloaded_engine.query("SELECT total_sleep_minutes FROM v_sleep_daily WHERE subject = 'mila' AND log_date = '2026-10-13'")
assert len(s_view) == 1
assert s_view[0]["total_sleep_minutes"] == 60
n_view = reloaded_engine.query("SELECT total_kcal FROM v_nutrition_daily WHERE subject = 'moritz' AND log_date = '2026-10-13'")
assert len(n_view) == 1
assert n_view[0]["total_kcal"] == 210.0
d_view = reloaded_engine.query("SELECT total_changes, wet_count FROM v_diaper_daily WHERE subject = 'mila' AND log_date = '2026-10-13'")
assert len(d_view) == 1
assert d_view[0]["total_changes"] == 1
assert d_view[0]["wet_count"] == 1