feat: consolidate datastore into sleep, nutrition, mila databases
This commit is contained in:
@@ -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/<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
|
||||
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/<domain>/*.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 <target_db>/*.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:
|
||||
|
||||
@@ -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/<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]]):
|
||||
if not activities:
|
||||
|
||||
@@ -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:
|
||||
|
||||
</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 -->
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
@@ -992,6 +1152,50 @@ def generate_dashboard_html(engine: DatastoreEngine) -> str:
|
||||
</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 -->
|
||||
<div>
|
||||
<h4 class="text-xs font-bold uppercase text-slate-500 mb-2">Gestern gegessen</h4>
|
||||
|
||||
@@ -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:
|
||||
<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-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>
|
||||
</tr>
|
||||
<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
|
||||
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'<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>')
|
||||
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'<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"""
|
||||
<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 gap-2">
|
||||
<span class="text-xl">🍼</span>
|
||||
<h3 class="font-semibold text-zinc-200">Fläschchen heute</h3>
|
||||
<span class="text-xl">🍼🥣</span>
|
||||
<h3 class="font-semibold text-zinc-200">Ernährung heute</h3>
|
||||
</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 class="flex flex-wrap gap-2 pt-1">
|
||||
{' '.join(bottles_pills)}
|
||||
{' '.join(feeding_pills)}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
# Conditional diaper card HTML
|
||||
diaper_section_html = ""
|
||||
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">Nacht</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>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
Reference in New Issue
Block a user