"""Small time helpers so Lyra can perceive 'now' and how long it's been. Timestamps are stored as UTC ISO strings; these turn them into a wall-clock stamp and human-scale gaps ("3 days") that get injected into her context and her reflection — so elapsed time is something she registers instead of being invisible between turns. These report time as a neutral fact; what (if anything) a long silence *means* to her is left to her own reflection, not prescribed here. """ from __future__ import annotations from datetime import datetime, timezone from zoneinfo import ZoneInfo from lyra import config def now() -> datetime: return datetime.now(timezone.utc) def _local_tz() -> ZoneInfo | timezone: """Brian's configured local zone (falls back to UTC if it can't be loaded).""" try: return ZoneInfo(config.load().timezone) except Exception: return timezone.utc def _parse(iso: str) -> datetime: dt = datetime.fromisoformat(iso) return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) def short(iso_or_dt: str | datetime | None = None) -> str: """Local time-of-day like '10:45pm', for timeline rows.""" dt = _parse(iso_or_dt) if isinstance(iso_or_dt, str) else (iso_or_dt or now()) return dt.astimezone(_local_tz()).strftime("%-I:%M%p").lower() def stamp(dt: datetime | None = None) -> str: """Wall-clock stamp in Brian's local timezone, e.g. 'Friday, 27 Jun 2026, 01:50 EDT'. Times are stored UTC; this is what she *reads*, so 'what time is it' answers in his time, not UTC.""" return (dt or now()).astimezone(_local_tz()).strftime("%A, %d %b %Y, %H:%M %Z") def gap_seconds(since_iso: str | None, ref: datetime | None = None) -> float | None: """Seconds elapsed since `since_iso` (None -> None). The numeric counterpart to humanize_gap, for code that needs to threshold on elapsed time.""" if not since_iso: return None ref = ref or now() return max(0.0, (ref - _parse(since_iso)).total_seconds()) def humanize_gap(since_iso: str | None, ref: datetime | None = None) -> str | None: """A coarse human description of how long since `since_iso` (None -> None).""" if not since_iso: return None ref = ref or now() secs = max(0.0, (ref - _parse(since_iso)).total_seconds()) mins, hours, days = secs / 60, secs / 3600, secs / 86400 if secs < 90: return "moments" if mins < 90: return f"{round(mins)} minutes" if hours < 36: return f"{round(hours)} hours" if days < 14: return f"{round(days)} days" if days < 60: return f"{round(days / 7)} weeks" if days < 545: return f"{round(days / 30)} months" return f"{round(days / 365, 1)} years"