fix(datastore): alert on open sessions without silent deckeln, support stop by ID
This commit is contained in:
@@ -387,9 +387,9 @@ class DatastoreEngine:
|
|||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _auto_close_stale_sleep_sessions(self, subject: str, current_start_dt: datetime.datetime) -> List[str]:
|
def _get_open_sleep_sessions(self, subject: str, exclude_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
"""Auto-closes open sleep sessions that started >10h ago or on a prior calendar day."""
|
"""Finds open sleep sessions (end IS NULL) for subject, sorted chronologically."""
|
||||||
open_sessions = self.con.execute(
|
rows = self.con.execute(
|
||||||
"""
|
"""
|
||||||
SELECT id, start, payload, note
|
SELECT id, start, payload, note
|
||||||
FROM events
|
FROM events
|
||||||
@@ -398,46 +398,17 @@ class DatastoreEngine:
|
|||||||
""",
|
""",
|
||||||
[subject],
|
[subject],
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
results = []
|
||||||
closed_ids = []
|
for r in rows:
|
||||||
for o_id, o_start, o_payload, o_note in open_sessions:
|
if exclude_id and r[0] == exclude_id:
|
||||||
o_start_dt = datetime.datetime.fromisoformat(str(o_start).replace("Z", "+00:00")).astimezone(BERLIN_TZ)
|
continue
|
||||||
diff_hours = (current_start_dt - o_start_dt).total_seconds() / 3600.0
|
results.append({
|
||||||
|
"id": r[0],
|
||||||
# Stale if open for >= 10 hours or started on an earlier day
|
"start": str(r[1]),
|
||||||
if diff_hours >= 10.0 or (current_start_dt.date() > o_start_dt.date() and current_start_dt.hour >= 6):
|
"payload": json.loads(r[2]) if isinstance(r[2], str) else (r[2] or {}),
|
||||||
if o_start_dt.hour >= 17:
|
"note": r[3],
|
||||||
next_day = o_start_dt.date() + datetime.timedelta(days=1)
|
})
|
||||||
auto_end_dt = datetime.datetime.combine(next_day, datetime.time(7, 0), tzinfo=BERLIN_TZ)
|
return results
|
||||||
if auto_end_dt > current_start_dt:
|
|
||||||
auto_end_dt = current_start_dt
|
|
||||||
else:
|
|
||||||
auto_end_dt = o_start_dt + datetime.timedelta(minutes=45)
|
|
||||||
if auto_end_dt > current_start_dt:
|
|
||||||
auto_end_dt = current_start_dt
|
|
||||||
|
|
||||||
auto_end_iso = auto_end_dt.isoformat()
|
|
||||||
now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
||||||
note_suffix = "Auto-beendet (vergessenes Aufwachen)"
|
|
||||||
updated_note = f"{o_note} | {note_suffix}" if o_note else note_suffix
|
|
||||||
|
|
||||||
self.con.execute(
|
|
||||||
"""
|
|
||||||
UPDATE events
|
|
||||||
SET "end" = ?, estimated = true, note = ?, updated_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
[auto_end_iso, updated_note, now_iso, o_id],
|
|
||||||
)
|
|
||||||
self._update_jsonl_record("sleep", o_id, {
|
|
||||||
"end": auto_end_iso,
|
|
||||||
"estimated": True,
|
|
||||||
"note": updated_note,
|
|
||||||
"updated_at": now_iso,
|
|
||||||
})
|
|
||||||
closed_ids.append(o_id)
|
|
||||||
logger.info(f"Auto-closed stale open sleep session {o_id} for {subject} at {auto_end_iso}")
|
|
||||||
return closed_ids
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Event Operations
|
# Event Operations
|
||||||
@@ -501,19 +472,23 @@ class DatastoreEngine:
|
|||||||
"feedback": feedback,
|
"feedback": feedback,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Validation and stale cleanup for sleep sessions
|
# Check open sleep sessions (notice on every log, never silently deckeln!)
|
||||||
|
open_warning = None
|
||||||
if kind == "sleep.session":
|
if kind == "sleep.session":
|
||||||
self._auto_close_stale_sleep_sessions(subject, start_dt)
|
open_sessions = self._get_open_sleep_sessions(subject)
|
||||||
if end_iso is None:
|
if open_sessions:
|
||||||
# Check if there is still an active (non-stale) open session for this subject
|
first_open = open_sessions[0]
|
||||||
open_sessions = self.con.execute(
|
o_start_dt = datetime.datetime.fromisoformat(str(first_open["start"]).replace("Z", "+00:00")).astimezone(BERLIN_TZ)
|
||||||
"SELECT id, start FROM events WHERE kind = 'sleep.session' AND subject = ? AND \"end\" IS NULL AND deleted_at IS NULL",
|
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."
|
||||||
[subject],
|
|
||||||
).fetchall()
|
# If adding a new open interval (end is None)
|
||||||
if open_sessions:
|
if end_iso is None:
|
||||||
raise ValueError(
|
# If existing open session is recent (< 8 hours on same calendar day) and not allow_duplicate, prevent accidental duplicate live nap
|
||||||
f"Subject '{subject}' already has an open sleep session ({open_sessions[0][0]} started at {open_sessions[0][1]}). Please stop it first."
|
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 = {
|
record = {
|
||||||
"id": event_id,
|
"id": event_id,
|
||||||
@@ -572,6 +547,12 @@ class DatastoreEngine:
|
|||||||
|
|
||||||
# Fetch computed feedback
|
# Fetch computed feedback
|
||||||
feedback = self._get_event_feedback(kind, subject, start_dt.date())
|
feedback = self._get_event_feedback(kind, subject, start_dt.date())
|
||||||
|
if open_warning:
|
||||||
|
feedback["open_session_warning"] = open_warning
|
||||||
|
if "day_total_formatted" in feedback and open_warning not in feedback["day_total_formatted"]:
|
||||||
|
feedback["day_total_formatted"] += f"\n{open_warning}"
|
||||||
|
elif "day_total_formatted" not in feedback:
|
||||||
|
feedback["day_total_formatted"] = open_warning
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
@@ -584,22 +565,33 @@ class DatastoreEngine:
|
|||||||
kind: str,
|
kind: str,
|
||||||
subject: str,
|
subject: str,
|
||||||
end_time: Optional[str] = None,
|
end_time: Optional[str] = None,
|
||||||
|
event_id: Optional[str] = None,
|
||||||
actor: Optional[str] = None,
|
actor: Optional[str] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Finds the open event (end IS NULL) for kind and subject, and closes it."""
|
"""Finds the open event (end IS NULL) for kind and subject, and closes it."""
|
||||||
row = self.con.execute(
|
if event_id:
|
||||||
"""
|
row = self.con.execute(
|
||||||
SELECT id, start FROM events
|
"""
|
||||||
WHERE kind = ? AND subject = ? AND "end" IS NULL AND deleted_at IS NULL
|
SELECT id, start FROM events
|
||||||
ORDER BY start DESC LIMIT 1
|
WHERE id = ? AND kind = ? AND subject = ? AND "end" IS NULL AND deleted_at IS NULL
|
||||||
""",
|
""",
|
||||||
[kind, subject],
|
[event_id, kind, subject],
|
||||||
).fetchone()
|
).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, 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 not row:
|
resolved_event_id, start_str = row[0], str(row[1])
|
||||||
raise ValueError(f"No open event found for kind='{kind}' and subject='{subject}'")
|
|
||||||
|
|
||||||
event_id, start_str = row[0], str(row[1])
|
|
||||||
now_dt = datetime.datetime.now(datetime.timezone.utc)
|
now_dt = datetime.datetime.now(datetime.timezone.utc)
|
||||||
if end_time:
|
if end_time:
|
||||||
end_dt = datetime.datetime.fromisoformat(end_time)
|
end_dt = datetime.datetime.fromisoformat(end_time)
|
||||||
@@ -618,12 +610,12 @@ class DatastoreEngine:
|
|||||||
SET "end" = ?, updated_at = ?
|
SET "end" = ?, updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
""",
|
""",
|
||||||
[end_iso, now_iso, event_id],
|
[end_iso, now_iso, resolved_event_id],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update in JSONL
|
# Update in JSONL
|
||||||
domain = kind.split(".")[0]
|
domain = kind.split(".")[0]
|
||||||
self._update_jsonl_record(domain, event_id, {"end": end_iso, "updated_at": now_iso})
|
self._update_jsonl_record(domain, resolved_event_id, {"end": end_iso, "updated_at": now_iso})
|
||||||
|
|
||||||
# Calculate duration
|
# Calculate duration
|
||||||
start_dt = datetime.datetime.fromisoformat(start_str.replace("Z", "+00:00"))
|
start_dt = datetime.datetime.fromisoformat(start_str.replace("Z", "+00:00"))
|
||||||
@@ -631,7 +623,7 @@ class DatastoreEngine:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"event_id": event_id,
|
"event_id": resolved_event_id,
|
||||||
"kind": kind,
|
"kind": kind,
|
||||||
"subject": subject,
|
"subject": subject,
|
||||||
"start": start_str,
|
"start": start_str,
|
||||||
@@ -1117,6 +1109,7 @@ def run_cli():
|
|||||||
p_stop.add_argument("kind", nargs="?", default=None, help="e.g. sleep.session")
|
p_stop.add_argument("kind", nargs="?", default=None, help="e.g. sleep.session")
|
||||||
p_stop.add_argument("--kind", dest="kind_flag", help="e.g. sleep.session")
|
p_stop.add_argument("--kind", dest="kind_flag", help="e.g. sleep.session")
|
||||||
p_stop.add_argument("--subject", required=True, help="mila, moritz")
|
p_stop.add_argument("--subject", required=True, help="mila, moritz")
|
||||||
|
p_stop.add_argument("--id", dest="event_id", help="Specific event ID to close")
|
||||||
p_stop.add_argument("--end", help="ISO end time (defaults to now)")
|
p_stop.add_argument("--end", help="ISO end time (defaults to now)")
|
||||||
|
|
||||||
# Query
|
# Query
|
||||||
@@ -1166,7 +1159,7 @@ def run_cli():
|
|||||||
kind = args.kind or args.kind_flag
|
kind = args.kind or args.kind_flag
|
||||||
if not kind:
|
if not kind:
|
||||||
parser.error("the 'stop' command requires 'kind' (positional or via --kind)")
|
parser.error("the 'stop' command requires 'kind' (positional or via --kind)")
|
||||||
res = engine.stop_event(kind=kind, subject=args.subject, end_time=args.end)
|
res = engine.stop_event(kind=kind, subject=args.subject, end_time=args.end, event_id=getattr(args, "event_id", None))
|
||||||
print(json.dumps(res, indent=2, ensure_ascii=False))
|
print(json.dumps(res, indent=2, ensure_ascii=False))
|
||||||
elif args.command == "query":
|
elif args.command == "query":
|
||||||
res = engine.query(args.sql)
|
res = engine.query(args.sql)
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ def test_baby_sleep_open_and_close(temp_datastore: DatastoreEngine):
|
|||||||
assert summary["open_sleep"]["sleep_type"] == "nap"
|
assert summary["open_sleep"]["sleep_type"] == "nap"
|
||||||
|
|
||||||
# 2. Prevent overlapping second open sleep
|
# 2. Prevent overlapping second open sleep
|
||||||
with pytest.raises(ValueError, match="already has an open sleep session"):
|
with pytest.raises(ValueError, match="hat bereits ein aktives Schlaf-Event"):
|
||||||
temp_datastore.add_event(
|
temp_datastore.add_event(
|
||||||
kind="sleep.session",
|
kind="sleep.session",
|
||||||
subject="mila",
|
subject="mila",
|
||||||
@@ -573,8 +573,8 @@ def test_cli_kind_flag_and_positional(temp_datastore: DatastoreEngine):
|
|||||||
assert stop_data["duration_minutes"] == 60
|
assert stop_data["duration_minutes"] == 60
|
||||||
|
|
||||||
|
|
||||||
def test_auto_close_stale_sleep_session(temp_datastore: DatastoreEngine):
|
def test_open_session_warning_without_silent_deckeln(temp_datastore: DatastoreEngine):
|
||||||
"""Verifies that an open sleep session from yesterday is auto-closed when a new sleep session is logged."""
|
"""Verifies that an unclosed sleep session is NOT silently deckelt, but alerts on every new log."""
|
||||||
# 1. Open night sleep session yesterday at 19:15
|
# 1. Open night sleep session yesterday at 19:15
|
||||||
res_night = temp_datastore.add_event(
|
res_night = temp_datastore.add_event(
|
||||||
kind="sleep.session",
|
kind="sleep.session",
|
||||||
@@ -586,54 +586,65 @@ def test_auto_close_stale_sleep_session(temp_datastore: DatastoreEngine):
|
|||||||
assert res_night["status"] == "success"
|
assert res_night["status"] == "success"
|
||||||
night_id = res_night["event"]["id"]
|
night_id = res_night["event"]["id"]
|
||||||
|
|
||||||
# 2. Add midday nap today at 12:35 without closing the night session first
|
# 2. Add midday nap today (12:35 to 12:55)
|
||||||
res_nap = temp_datastore.add_event(
|
res_nap = temp_datastore.add_event(
|
||||||
kind="sleep.session",
|
kind="sleep.session",
|
||||||
subject="mila",
|
subject="mila",
|
||||||
actor="agent:hebamme",
|
actor="agent:hebamme",
|
||||||
start="2026-10-09T12:35:00+02:00",
|
start="2026-10-09T12:35:00+02:00",
|
||||||
|
end="2026-10-09T12:55:00+02:00",
|
||||||
payload={"type": "nap", "location": "bett"},
|
payload={"type": "nap", "location": "bett"},
|
||||||
)
|
)
|
||||||
assert res_nap["status"] == "success"
|
assert res_nap["status"] == "success"
|
||||||
nap_id = res_nap["event"]["id"]
|
|
||||||
assert nap_id != night_id
|
|
||||||
|
|
||||||
# 3. Query the night session to verify it was auto-closed with estimated=True and end=07:00
|
# 3. Verify night session is STILL open (never silently deckelt!)
|
||||||
rows = temp_datastore.query(f"SELECT id, start, \"end\", estimated, note FROM events WHERE id = '{night_id}'")
|
rows = temp_datastore.query(f"SELECT id, start, \"end\", estimated FROM events WHERE id = '{night_id}'")
|
||||||
assert len(rows) == 1
|
assert len(rows) == 1
|
||||||
night_row = rows[0]
|
assert rows[0]["end"] is None
|
||||||
assert night_row["end"] is not None
|
assert rows[0]["estimated"] is False
|
||||||
assert "07:00:00" in str(night_row["end"])
|
|
||||||
assert night_row["estimated"] is True
|
# 4. Verify that feedback gives prominent notice about the open session
|
||||||
assert "Auto-beendet" in str(night_row["note"])
|
feedback = res_nap["feedback"]
|
||||||
|
assert "open_session_warning" in feedback
|
||||||
|
assert "ist noch offen" in feedback["open_session_warning"]
|
||||||
|
assert "ist noch offen" in feedback["day_total_formatted"]
|
||||||
|
|
||||||
|
|
||||||
def test_auto_close_stale_sleep_session_on_bounded_nap(temp_datastore: DatastoreEngine):
|
def test_stop_event_by_id(temp_datastore: DatastoreEngine):
|
||||||
"""Verifies that logging a bounded interval today auto-closes a forgotten night session from yesterday."""
|
"""Verifies closing a specific open sleep session by ID when multiple might exist."""
|
||||||
res_night = temp_datastore.add_event(
|
res1 = temp_datastore.add_event(
|
||||||
kind="sleep.session",
|
kind="sleep.session",
|
||||||
subject="mila",
|
subject="mila",
|
||||||
actor="agent:hebamme",
|
actor="agent:hebamme",
|
||||||
start="2026-10-08T19:15:00+02:00",
|
start="2026-10-08T19:15:00+02:00",
|
||||||
payload={"type": "night", "location": "bett"},
|
payload={"type": "night"},
|
||||||
)
|
)
|
||||||
night_id = res_night["event"]["id"]
|
id1 = res1["event"]["id"]
|
||||||
|
|
||||||
# Add bounded nap from 15:20 to 15:35
|
res2 = temp_datastore.add_event(
|
||||||
res_nap = temp_datastore.add_event(
|
|
||||||
kind="sleep.session",
|
kind="sleep.session",
|
||||||
subject="mila",
|
subject="mila",
|
||||||
actor="agent:hebamme",
|
actor="agent:hebamme",
|
||||||
start="2026-10-09T15:20:00+02:00",
|
start="2026-10-09T12:35:00+02:00",
|
||||||
end="2026-10-09T15:35:00+02:00",
|
payload={"type": "nap"},
|
||||||
payload={"type": "nap", "location": "bett"},
|
|
||||||
)
|
)
|
||||||
assert res_nap["status"] == "success"
|
id2 = res2["event"]["id"]
|
||||||
|
|
||||||
# Verify night session was closed
|
# Close specific event id1
|
||||||
rows = temp_datastore.query(f"SELECT id, \"end\", estimated FROM events WHERE id = '{night_id}'")
|
res_stop = temp_datastore.stop_event(
|
||||||
assert rows[0]["end"] is not None
|
kind="sleep.session",
|
||||||
assert rows[0]["estimated"] is True
|
subject="mila",
|
||||||
|
event_id=id1,
|
||||||
|
end_time="2026-10-09T07:15:00+02:00",
|
||||||
|
)
|
||||||
|
assert res_stop["status"] == "success"
|
||||||
|
assert res_stop["event_id"] == id1
|
||||||
|
|
||||||
|
# Verify id1 is closed and id2 is still open
|
||||||
|
row1 = temp_datastore.query(f"SELECT \"end\" FROM events WHERE id = '{id1}'")[0]
|
||||||
|
row2 = temp_datastore.query(f"SELECT \"end\" FROM events WHERE id = '{id2}'")[0]
|
||||||
|
assert row1["end"] is not None
|
||||||
|
assert row2["end"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_cli_rejects_invalid_kind(temp_datastore: DatastoreEngine):
|
def test_cli_rejects_invalid_kind(temp_datastore: DatastoreEngine):
|
||||||
|
|||||||
Reference in New Issue
Block a user