diff --git a/skills/datastore/SKILL.md b/skills/datastore/SKILL.md index 1755c91..a81297c 100644 --- a/skills/datastore/SKILL.md +++ b/skills/datastore/SKILL.md @@ -49,6 +49,7 @@ Logs a point or interval event. - For `nutrition.intake`: `{"item": "...", "qty": 200, "unit": "g"|"ml", "protein_g": ..., "kcal": ...}` (or `{"food_id": "oatly-haferdrink", "qty": 200, "unit": "ml"}`). - For `sleep.session`: `{"type": "nap"|"night", "location": "bett"|"auto"|"kinderwagen"}`. - `start` (string, optional): ISO 8601 timestamp (defaults to current time). + - `since` (string, optional): Relative start offset backwards from now (e.g. `'5m'`, `'15m'`, `'1h'`). Use for "schläft seit X Min" to avoid LLM mental math! - `end` (string, optional): ISO 8601 timestamp. Leave empty for open sleep intervals! - `meal` (string, optional): e.g. `'Frühstück'`, `'Mittagessen'`, `'Snack'`. - `estimated` (boolean, optional): Set to `true` if portions or nutrients are estimated. @@ -105,6 +106,13 @@ uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py ad --actor agent:hebamme \ --payload '{"type": "nap", "location": "kinderwagen"}' +# Start Mila's nap with relative time offset (e.g. asleep since 15 minutes) +uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py add sleep.session \ + --subject mila \ + --actor agent:hebamme \ + --since "15m" \ + --payload '{"type": "nap", "location": "bett"}' + # Stop Mila's nap (closes open interval) uv run /home/node/mop-skills/mop-skills/skills/datastore/scripts/datastore.py stop sleep.session \ --subject mila diff --git a/skills/datastore/scripts/datastore.py b/skills/datastore/scripts/datastore.py index c084200..13e339a 100755 --- a/skills/datastore/scripts/datastore.py +++ b/skills/datastore/scripts/datastore.py @@ -44,6 +44,41 @@ def generate_ulid() -> str: BERLIN_TZ = ZoneInfo("Europe/Berlin") +def parse_duration(val: str) -> datetime.timedelta: + """Parses duration strings like '15m', '15 min', '1h', '1h30m', '1.5h' into timedelta.""" + s = val.strip().lower() + if not s: + raise ValueError("Empty duration string") + + # Decimal hours like "1.5h" or "1.5 stunden" + m_dec_h = re.fullmatch(r"^(\d+(?:\.\d+)?)\s*(?:h|stunden?)$", s) + if m_dec_h: + return datetime.timedelta(hours=float(m_dec_h.group(1))) + + # Decimal minutes like "2.5m" or "15 min" + m_dec_m = re.fullmatch(r"^(\d+(?:\.\d+)?)\s*(?:m|min|minuten?)$", s) + if m_dec_m: + return datetime.timedelta(minutes=float(m_dec_m.group(1))) + + # Composite like "1h 30m" or "1h30min" or "45s" + m_comp = re.fullmatch( + r"^(?:(\d+)\s*(?:h|stunden?))?\s*(?:(\d+)\s*(?:m|min|minuten?))?\s*(?:(\d+)\s*(?:s|sek|sekunden?))?$", + s, + ) + if m_comp and any(m_comp.groups()): + h = int(m_comp.group(1) or 0) + m = int(m_comp.group(2) or 0) + sec = int(m_comp.group(3) or 0) + return datetime.timedelta(hours=h, minutes=m, seconds=sec) + + # Pure number without unit: default to minutes + if s.isdigit(): + return datetime.timedelta(minutes=int(s)) + + raise ValueError(f"Unrecognized duration format: '{val}'. Expected formats: '15m', '15 min', '1h', '1h30m'.") + + + def find_data_dir() -> Path: """Discovers the knowledge-wiki data directory.""" if os.environ.get("DATASTORE_DIR"): @@ -279,6 +314,7 @@ class DatastoreEngine: payload: Dict[str, Any], start: Optional[str] = None, end: Optional[str] = None, + since: Optional[str] = None, estimated: bool = False, meal: Optional[str] = None, note: Optional[str] = None, @@ -289,8 +325,15 @@ class DatastoreEngine: now_dt = datetime.datetime.now(datetime.timezone.utc) now_iso = now_dt.isoformat() + if since and start: + raise ValueError("Cannot specify both 'since' and 'start'. Use 'since' for relative times or 'start' for absolute times.") + # Parse or default start time - if not start: + if since: + delta = parse_duration(since) + start_dt = (now_dt - delta).astimezone(BERLIN_TZ) + start_iso = start_dt.isoformat() + elif not start: start_dt = now_dt.astimezone(BERLIN_TZ) start_iso = start_dt.isoformat() else: @@ -713,6 +756,7 @@ def run_mcp_server(): "payload": {"type": "object", "description": "Detailed payload matching the kind schema"}, "start": {"type": "string", "description": "ISO 8601 timestamp (optional, defaults to now)"}, "end": {"type": "string", "description": "ISO 8601 timestamp (optional for intervals)"}, + "since": {"type": "string", "description": "Relative start offset backwards from now (e.g. '5m', '15m', '1h'). Use when logging events that started X minutes ago without computing absolute timestamps."}, "estimated": {"type": "boolean", "description": "True if portions or macros are estimated"}, "meal": {"type": "string", "description": "Optional meal grouping (e.g. 'Frühstück', 'Mittagessen')"}, "note": {"type": "string", "description": "Free text notes or context"}, @@ -832,6 +876,7 @@ def run_mcp_server(): payload=args["payload"], start=args.get("start"), end=args.get("end"), + since=args.get("since"), estimated=args.get("estimated", False), meal=args.get("meal"), note=args.get("note"), @@ -906,6 +951,7 @@ def run_cli(): p_add.add_argument("--payload", required=True, help="JSON payload string") p_add.add_argument("--start", help="ISO start time") p_add.add_argument("--end", help="ISO end time") + p_add.add_argument("--since", help="Relative start offset backwards from now (e.g. '5m', '15m', '1h')") p_add.add_argument("--meal", help="Meal grouping") p_add.add_argument("--note", help="Free-form note") p_add.add_argument("--estimated", action="store_true", help="Flag as estimated") @@ -944,6 +990,7 @@ def run_cli(): payload=payload, start=args.start, end=args.end, + since=args.since, meal=args.meal, note=args.note, estimated=args.estimated, diff --git a/skills/datastore/tests/test_datastore.py b/skills/datastore/tests/test_datastore.py index 0b93dde..f8d1d17 100644 --- a/skills/datastore/tests/test_datastore.py +++ b/skills/datastore/tests/test_datastore.py @@ -293,3 +293,94 @@ def test_garmin_daily_and_daily_balance(temp_datastore: DatastoreEngine): assert row["balance_kcal"] == -744.0 # 2146 - 2890 assert row["balance_status"] == "Defizit" + +def test_add_event_with_since(temp_datastore: DatastoreEngine): + """Verifies that relative offsets like '15m' calculate start time deterministically in Berlin timezone.""" + from zoneinfo import ZoneInfo + berlin_tz = ZoneInfo("Europe/Berlin") + + before = datetime.datetime.now(berlin_tz) + res = temp_datastore.add_event( + kind="sleep.session", + subject="mila", + actor="agent:hebamme", + since="15m", + payload={"type": "nap", "location": "bett"}, + ) + after = datetime.datetime.now(berlin_tz) + + assert res["status"] == "success" + event = res["event"] + assert event["end"] is None + + start_dt = datetime.datetime.fromisoformat(event["start"]) + assert start_dt.tzinfo is not None + # Verify Berlin timezone offset (+02:00 in CEST or +01:00 in CET) + assert start_dt.utcoffset() == before.utcoffset() + + # The logged start must be ~15 minutes before the test run + expected_approx = before - datetime.timedelta(minutes=15) + assert abs((start_dt - expected_approx).total_seconds()) < 5 + + # Test error if both start and since are provided + with pytest.raises(ValueError, match="Cannot specify both 'since' and 'start'"): + temp_datastore.add_event( + kind="nutrition.intake", + subject="moritz", + actor="agent:koch", + start="2026-10-06T12:00:00+02:00", + since="10m", + payload={"item": "Snack", "qty": 10}, + ) + + # Test invalid since format + with pytest.raises(ValueError, match="Unrecognized duration format"): + temp_datastore.add_event( + kind="nutrition.intake", + subject="moritz", + actor="agent:koch", + since="invalid_format", + payload={"item": "Snack", "qty": 10}, + ) + + +def test_sleep_over_midnight_attribution(temp_datastore: DatastoreEngine): + """Verifies that overnight sleep (Wed evening -> Thu morning) stays attributed to Wednesday.""" + # Sleep starts Wednesday 19:15 + res_start = temp_datastore.add_event( + kind="sleep.session", + subject="mila", + actor="agent:hebamme", + start="2026-10-07T19:15:00+02:00", + payload={"type": "night", "location": "bett"}, + ) + assert res_start["status"] == "success" + + # Sleep ends Thursday 07:00 + res_stop = temp_datastore.stop_event( + kind="sleep.session", + subject="mila", + end_time="2026-10-08T07:00:00+02:00", + ) + assert res_stop["status"] == "success" + # Duration: 11 hours 45 min = 705 minutes + assert res_stop["duration_minutes"] == 705 + + # Check v_sleep view: log_date must be the day it STARTED (Wednesday 2026-10-07) + rows = temp_datastore.query("SELECT log_date, duration_minutes, sleep_type FROM v_sleep WHERE subject = 'mila'") + assert len(rows) == 1 + assert rows[0]["log_date"] == "2026-10-07" + assert rows[0]["sleep_type"] == "night" + assert rows[0]["duration_minutes"] == 705 + + # Check daily summary for Wednesday: must contain the full 11.75 hours night sleep + wed_summary = temp_datastore.get_summary(subject="mila", date_str="2026-10-07") + assert "sleep" in wed_summary + assert wed_summary["sleep"]["night_sleep_minutes"] == 705 + assert wed_summary["sleep"]["total_sleep_hours"] == 11.75 + + # Check daily summary for Thursday: must NOT contain Wednesday night's sleep + thu_summary = temp_datastore.get_summary(subject="mila", date_str="2026-10-08") + assert "sleep" not in thu_summary or thu_summary["sleep"] is None + +