feat(datastore): add relative --since duration flag and overnight sleep test

This commit is contained in:
Moritz Graf
2026-10-09 09:25:04 +02:00
parent 0489ef7d62
commit 77a0e56d96
3 changed files with 147 additions and 1 deletions
+48 -1
View File
@@ -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,