diff --git a/skills/datastore/scripts/datastore.py b/skills/datastore/scripts/datastore.py index a99ad7f..59e332b 100755 --- a/skills/datastore/scripts/datastore.py +++ b/skills/datastore/scripts/datastore.py @@ -154,8 +154,57 @@ class DatastoreEngine: self._create_inline_schema() 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(""" - 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, kind VARCHAR NOT NULL, subject VARCHAR NOT NULL, @@ -173,6 +222,30 @@ class DatastoreEngine: 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 ( id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL, @@ -229,38 +302,19 @@ class DatastoreEngine: restingHeartRate INTEGER, raw JSON ); - - CREATE OR REPLACE VIEW v_growth AS - SELECT - id, - subject, - actor, - start, - start AT TIME ZONE 'Europe/Berlin' AS local_time, - CAST(start AT TIME ZONE 'Europe/Berlin' AS DATE) AS log_date, - CAST(json_extract(payload, '$.weight_kg') AS DOUBLE) AS weight_kg, - CAST(json_extract(payload, '$.height_cm') AS DOUBLE) AS height_cm, - CAST(json_extract(payload, '$.head_circ_cm') AS DOUBLE) AS head_circ_cm, - note, - created_at - FROM events - WHERE kind = 'growth.measurement' AND deleted_at IS NULL; - - CREATE OR REPLACE VIEW v_growth_daily AS - SELECT - subject, - log_date, - ROUND(AVG(weight_kg), 3) AS weight_kg, - ROUND(AVG(height_cm), 1) AS height_cm, - ROUND(AVG(head_circ_cm), 1) AS head_circ_cm, - COUNT(*) AS measurement_count - FROM v_growth - GROUP BY subject, log_date - ORDER BY log_date DESC; """) + def _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): - """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 entities_dir = self.data_dir / "entities" if entities_dir.is_dir(): @@ -269,18 +323,80 @@ class DatastoreEngine: if collection in ["people", "foods", "recipes", "places"]: 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" if events_dir.is_dir(): 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" if garmin_daily_dir.is_dir(): for f in garmin_daily_dir.glob("*.jsonl"): 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): """Reads daily summaries from JSONL into garmin_daily table.""" 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: 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: for line in f: line = line.strip() @@ -363,14 +494,23 @@ class DatastoreEngine: continue try: 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"): 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 if "deleted_at" not in record: record["deleted_at"] = None - columns = list(record.keys()) + 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()) placeholders = ", ".join(["?"] * len(columns)) col_names = ", ".join([f'"{c}"' for c in columns]) values = [ @@ -388,7 +528,7 @@ class DatastoreEngine: """Determines if two event payloads represent the same intake/session.""" if p1 == p2: return True - if kind.startswith("nutrition."): + if kind.startswith("nutrition.") or kind == "feeding.solid": food1 = p1.get("food_id") food2 = p2.get("food_id") item1 = str(p1.get("item", "")).strip().lower() @@ -400,8 +540,8 @@ class DatastoreEngine: or (food2 and item1 and food2 == item1) ) if same_item: - qty1 = p1.get("qty") - qty2 = p2.get("qty") + qty1 = p1.get("qty") or p1.get("amount_g") + qty2 = p2.get("qty") or p2.get("amount_g") if qty1 is not None and qty2 is not None: try: 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"): return True elif kind.startswith("sleep."): - if p1.get("type") == p2.get("type"): + if (p1.get("type") or "nap") == (p2.get("type") or "nap"): return True elif kind.startswith("growth."): w1, w2 = p1.get("weight_kg"), p2.get("weight_kg") @@ -472,13 +612,13 @@ class DatastoreEngine: return None 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( """ - SELECT id, start, payload, note - FROM events - WHERE kind = 'sleep.session' AND subject = ? AND "end" IS NULL AND deleted_at IS NULL - ORDER BY start ASC + SELECT id, startIso, sleepType, location, note + FROM sleep + WHERE subject = ? AND (isOpen = true OR endIso IS NULL) AND deleted_at IS NULL + ORDER BY startIso ASC """, [subject], ).fetchall() @@ -489,8 +629,8 @@ class DatastoreEngine: results.append({ "id": r[0], "start": str(r[1]), - "payload": json.loads(r[2]) if isinstance(r[2], str) else (r[2] or {}), - "note": r[3], + "payload": {"type": r[2], "location": r[3]}, + "note": r[4], }) return results @@ -512,7 +652,7 @@ class DatastoreEngine: source_ref: Optional[str] = None, allow_duplicate: bool = False, ) -> 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() now_dt = datetime.datetime.now(datetime.timezone.utc) now_iso = now_dt.isoformat() @@ -555,6 +695,7 @@ class DatastoreEngine: start_iso = start_dt.isoformat() end_iso = None + end_dt = None if end: end_dt = datetime.datetime.fromisoformat(end) 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) 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 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 if diff_hours < 8.0 and start_dt.date() == o_start_dt.date() and not allow_duplicate: 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." ) - 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, "kind": kind, "subject": subject, @@ -611,44 +877,6 @@ class DatastoreEngine: "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//.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 feedback = self._get_event_feedback(kind, subject, start_dt.date()) if open_warning: @@ -663,7 +891,7 @@ class DatastoreEngine: return { "status": "success", - "event": record, + "event": record_response, "feedback": feedback, } @@ -680,29 +908,55 @@ class DatastoreEngine: 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.") - if event_id: - row = self.con.execute( - """ - SELECT id, start FROM events - WHERE id = ? AND kind = ? AND subject = ? AND "end" IS NULL AND deleted_at IS NULL - """, - [event_id, kind, subject], - ).fetchone() - if not row: - raise ValueError(f"No open event with id='{event_id}' found for kind='{kind}' and subject='{subject}'") + target_db = self._get_target_db(kind) + + if target_db == "sleep": + if 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 + """, + [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: - row = self.con.execute( - """ - SELECT id, start FROM events - WHERE kind = ? AND subject = ? AND "end" IS NULL AND deleted_at IS NULL - ORDER BY start DESC LIMIT 1 - """, - [kind, subject], - ).fetchone() - if not row: - raise ValueError(f"No open event found for kind='{kind}' and subject='{subject}'") + 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 + """, + [event_id, kind, 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( + 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 + """, + [kind, 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]), None - resolved_event_id, start_str = row[0], str(row[1]) now_dt = datetime.datetime.now(datetime.timezone.utc) if since: delta = parse_duration(since) @@ -722,24 +976,41 @@ class DatastoreEngine: end_iso = end_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 - self.con.execute( - """ - UPDATE events - SET "end" = ?, updated_at = ? - WHERE id = ? - """, - [end_iso, now_iso, resolved_event_id], - ) - - # 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) + 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( + """ + 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 = ? + WHERE id = ? + """, + [end_iso, now_iso, resolved_event_id], + ) + self._update_jsonl_record(target_db, resolved_event_id, {"end": end_iso, "updated_at": now_iso}) self._trigger_website_update(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]: - """Soft-deletes an event.""" + """Soft-deletes an event across sleep, nutrition, or mila.""" 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: @@ -766,10 +1037,10 @@ class DatastoreEngine: kind = row[0] 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._update_jsonl_record(domain, event_id, {"deleted_at": now_iso}) + 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}) self._trigger_website_update(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]: """Generates compact running status feedback after an insert.""" date_str = target_date.strftime("%Y-%m-%d") - if kind.startswith("nutrition"): + 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], @@ -985,16 +1256,26 @@ class DatastoreEngine: "wet_count": daily[1], "dirty_count": daily[2], } - elif kind.startswith("feeding"): + elif kind.startswith("feeding") or (kind.startswith("nutrition") and subject == "mila"): 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], ).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"Fläschchen ({date_str}): {daily[0]} Fläschchen ({int(daily[1])} ml getrunken)", - "bottle_count": daily[0], - "total_consumed_ml": daily[1], + "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( @@ -1056,13 +1337,13 @@ class DatastoreEngine: for r in records: f.write(json.dumps(r, ensure_ascii=False) + "\n") - def _update_jsonl_record(self, domain: str, event_id: str, updates: Dict[str, Any]): - """Finds and updates a record in events//*.jsonl.""" - domain_dir = self.data_dir / "events" / domain - if not domain_dir.is_dir(): + def _update_jsonl_record(self, target_db: str, event_id: str, updates: Dict[str, Any]): + """Finds and updates a record in /*.jsonl.""" + db_dir = self.data_dir / target_db + if not db_dir.is_dir(): return - for p_file in domain_dir.glob("*.jsonl"): + for p_file in db_dir.glob("*.jsonl"): records = [] found = False with open(p_file, "r", encoding="utf-8") as f: diff --git a/skills/datastore/scripts/garmin_sync.py b/skills/datastore/scripts/garmin_sync.py index 1bcf7a7..b3d2cd4 100755 --- a/skills/datastore/scripts/garmin_sync.py +++ b/skills/datastore/scripts/garmin_sync.py @@ -173,12 +173,14 @@ class GarminMirrorWriter: """Manages month-partitioned JSONL files with idempotent upserts.""" def __init__(self, data_dir: Path): + self.data_dir = data_dir self.garmin_dir = data_dir / "garmin" self.daily_dir = self.garmin_dir / "daily" self.sleep_dir = self.garmin_dir / "sleep" + self.unified_sleep_dir = data_dir / "sleep" 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) 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]): cal_date = None - if "dailySleepDTO" in sleep_data and isinstance(sleep_data["dailySleepDTO"], dict): - cal_date = sleep_data["dailySleepDTO"].get("calendarDate") + dto = sleep_data.get("dailySleepDTO") if isinstance(sleep_data.get("dailySleepDTO"), dict) else {} + if dto: + cal_date = dto.get("calendarDate") if not cal_date: cal_date = sleep_data.get("calendarDate") @@ -267,7 +270,82 @@ class GarminMirrorWriter: new_items.sort(key=_sort_key) 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/.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]]): if not activities: diff --git a/skills/datastore/scripts/update_moritz_website.py b/skills/datastore/scripts/update_moritz_website.py index 3d30cde..852be6b 100644 --- a/skills/datastore/scripts/update_moritz_website.py +++ b/skills/datastore/scripts/update_moritz_website.py @@ -266,6 +266,105 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str: [yesterday_str] ).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) # ------------------------------------------------------------------------- @@ -717,6 +816,67 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str: + +
+
+
+ + Schlaf & Erholung (Garmin) +
+ + {sleep_date_label} · Deep Dive + +
+ +
+ +
+ Sleep Score +
+ {sleep_score if sleep_score is not None else '—'} + {f'({sleep_qualifier})' if sleep_qualifier else ''} +
+
+ + +
+ Schlafdauer +
{sleep_dur_str}
+
Tief {deep_m}m · Leicht {light_m}m · REM {rem_m}m
+
+ + +
+ Overnight HRV +
+ {f'{hrv_val} ms' if hrv_val is not None else '—'} + {hrv_status} +
+
+ + +
+ Body Battery & Puls +
{f'+{bb_change}' if bb_change is not None else '—'}
+
Ruhepuls: {rhr_val if rhr_val is not None else '—'} bpm
+
+
+ + +
+
+ Phasen: Tief ({deep_m}m) · Leicht ({light_m}m) · REM ({rem_m}m) · Wach ({awake_m}m) + Details anzeigen → +
+
+
+
+
+
+
+
+
+
@@ -992,6 +1152,50 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
+ +
+
+
+ + Schlaf & Regeneration (Garmin) +
+ Score: {sleep_score if sleep_score is not None else '—'} {f'({sleep_qualifier})' if sleep_qualifier else ''} +
+ +
+
+ Dauer + {sleep_dur_str} +
+
+ Overnight HRV + {f'{hrv_val} ms' if hrv_val is not None else '—'} + {hrv_status} +
+
+ Body Battery + {f'+{bb_change}' if bb_change is not None else '—'} +
+
+ Ruhepuls + {rhr_val if rhr_val is not None else '—'} bpm +
+
+ + +
+
+ Tief: {deep_m}m · Leicht: {light_m}m · REM: {rem_m}m · Wach: {awake_m}m +
+
+
+
+
+
+
+
+
+

Gestern gegessen

diff --git a/skills/datastore/scripts/update_website.py b/skills/datastore/scripts/update_website.py index 4188683..4d46deb 100644 --- a/skills/datastore/scripts/update_website.py +++ b/skills/datastore/scripts/update_website.py @@ -197,10 +197,18 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str: 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 (conditional) + # 3. Today's Feedings & Nutrition (Bottles + Brei/Beikost) today_feedings = con.execute( 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 WHERE subject = 'mila' AND log_date = '{today_str}' ORDER BY start ASC @@ -209,7 +217,12 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str: today_feeding_agg = con.execute( f""" - SELECT bottle_count, total_consumed_ml + 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}' """ @@ -218,6 +231,9 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str: 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_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) today_diapers = con.execute( @@ -272,6 +288,8 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str: s.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 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_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)" - 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 day_sess = sessions_by_date.get(d_str, []) @@ -339,7 +364,7 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str: {tot_h} {night_str} {nap_str} - {feed_str} + {feed_str} {diaper_str} @@ -421,25 +446,62 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str: # Conditional feeding card HTML feeding_section_html = "" if has_feeding_today: - bottles_pills = [] + feeding_pills = [] for f in today_feedings: - ml_val = f"{int(f[1])} ml" if f[1] else "—" - bottles_pills.append(f'{f[0]} Uhr ({ml_val})') + time_str = f[0] + 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'' + f'🍼 {time_str} Uhr: {ml_str}{milk_desc}' + ) + 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'' + f'🥣 {time_str} Uhr: {food_title}{detail_str}' + ) + + feed_summary_parts = [] + if today_bottle_count > 0: + feed_summary_parts.append( + f'{today_bottle_ml} ml ({today_bottle_count}x Flasche)' + ) + if today_solid_count > 0: + feed_summary_parts.append( + f'{today_solid_g} g ({today_solid_count}x Brei)' + ) + feed_summary_html = " · ".join(feed_summary_parts) if feed_summary_parts else '—' + feeding_section_html = f"""
- 🍼 -

Fläschchen heute

+ 🍼🥣 +

Ernährung heute

- {today_bottle_ml} ml ({today_bottle_count}x) +
{feed_summary_html}
- {' '.join(bottles_pills)} + {' '.join(feeding_pills)}
""" + # Conditional diaper card HTML diaper_section_html = "" if has_diapers_today: @@ -999,7 +1061,7 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str: 24h Gesamt Nacht Schläfchen - Fläschchen + Ernährung (Milch & Brei) Windeln diff --git a/skills/datastore/tests/test_datastore.py b/skills/datastore/tests/test_datastore.py index 29f92b7..9c87f08 100644 --- a/skills/datastore/tests/test_datastore.py +++ b/skills/datastore/tests/test_datastore.py @@ -810,6 +810,116 @@ def test_website_growth_html_rendering(temp_datastore: DatastoreEngine): 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 + + +