diff --git a/config.json b/config.json index 04735f5..19e3c41 100644 --- a/config.json +++ b/config.json @@ -38,7 +38,7 @@ }, "cooldowns": { "email": 1800, - "calendar": 0, + "calendar": 1800, "kb_index": 14400, "git": 14400 } diff --git a/src/heartbeat.py b/src/heartbeat.py index 3cb1b28..79638ff 100644 --- a/src/heartbeat.py +++ b/src/heartbeat.py @@ -8,7 +8,7 @@ a Claude CLI session is triggered to handle them. import json import logging import subprocess -from datetime import datetime, timezone +from datetime import datetime, date, timezone from pathlib import Path log = logging.getLogger(__name__) @@ -63,11 +63,11 @@ def run_heartbeat(config: dict | None = None) -> str: results.append(email_result) checks["email"] = now.isoformat() - # Check 2: Calendar (critical β€” pierces quiet hours) - if check_flags.get("calendar"): - cal_result = _check_calendar(state) + # Check 2: Calendar β€” daily summary + next-event reminder (no quiet hours bypass) + if check_flags.get("calendar") and not is_quiet and _should_run("calendar", checks, now, cooldowns): + cal_result = _check_calendar_smart(state, quiet_hours) if cal_result: - critical.append(cal_result) + results.append(cal_result) checks["calendar"] = now.isoformat() # Check 3: KB index freshness + auto-reindex @@ -171,14 +171,77 @@ def _check_email(state: dict) -> str | None: return None -def _check_calendar(state: dict) -> str | None: - """Check upcoming calendar events via tools/calendar_check.py. Parses JSON.""" +def _check_calendar_smart(state: dict, quiet_hours: tuple) -> str | None: + """Smart calendar check: daily summary once, then only next-event reminders. + + - First run after quiet hours: full day summary (all events today) + - Subsequent runs: only the nearest event within 45 min, deduplicated + """ script = TOOLS_DIR / "calendar_check.py" if not script.exists(): return None + + today_str = date.today().isoformat() + cal_state = state.setdefault("calendar", {}) + sent_summary_date = cal_state.get("daily_summary_date") + reminded_events = cal_state.get("reminded_events", {}) + + # Clean old reminded_events (from previous days) + reminded_events = { + k: v for k, v in reminded_events.items() if v == today_str + } + cal_state["reminded_events"] = reminded_events + + is_first_run_today = sent_summary_date != today_str + + if is_first_run_today: + # Daily summary: all events for today + return _calendar_daily_summary(cal_state, today_str) + else: + # Next-event reminder (within 45 min, deduplicated) + return _calendar_next_reminder(cal_state, today_str, reminded_events) + + +def _calendar_daily_summary(cal_state: dict, today_str: str) -> str | None: + """Fetch all today's events and return a daily summary.""" + script = TOOLS_DIR / "calendar_check.py" try: result = subprocess.run( - ["python3", str(script), "soon"], + ["python3", str(script), "today"], + capture_output=True, text=True, timeout=30, + cwd=str(PROJECT_ROOT) + ) + if result.returncode != 0: + return None + output = result.stdout.strip() + if not output: + return None + data = json.loads(output) + today_events = data.get("today", []) + if not today_events: + cal_state["daily_summary_date"] = today_str + return None + + parts = [] + for event in today_events: + time = event.get("time", "") + name = event.get("summary", "?") + parts.append(f" {time} β€” {name}") + + cal_state["daily_summary_date"] = today_str + return "πŸ“… Program azi:\n" + "\n".join(parts) + except Exception as e: + log.warning("Calendar daily summary failed: %s", e) + return None + + +def _calendar_next_reminder(cal_state: dict, today_str: str, + reminded_events: dict) -> str | None: + """Remind only for the next upcoming event within 45 min, if not already reminded.""" + script = TOOLS_DIR / "calendar_check.py" + try: + result = subprocess.run( + ["python3", str(script), "soon", "1"], capture_output=True, text=True, timeout=30, cwd=str(PROJECT_ROOT) ) @@ -191,21 +254,28 @@ def _check_calendar(state: dict) -> str | None: upcoming = data.get("upcoming", []) if not upcoming: return None - parts = [] - for event in upcoming: - mins = event.get("minutes_until", "?") - name = event.get("summary", "?") - time = event.get("time", "") - parts.append(f"in {mins} min β€” {name} ({time})") - return "Calendar: " + "; ".join(parts) - except json.JSONDecodeError: - # Fallback: treat as plain text - output = result.stdout.strip() - if output: - return f"Calendar: {output}" - return None + + event = upcoming[0] + mins = event.get("minutes_until", 999) + name = event.get("summary", "?") + time = event.get("time", "") + + # Only remind if within 45 minutes + if isinstance(mins, (int, float)) and mins > 45: + return None + + # Dedup key: event name + time + dedup_key = f"{name}@{time}" + if dedup_key in reminded_events: + return None + + # Mark as reminded + reminded_events[dedup_key] = today_str + cal_state["reminded_events"] = reminded_events + + return f"⏰ in {mins} min β€” {name} ({time})" except Exception as e: - log.warning("Calendar check failed: %s", e) + log.warning("Calendar next reminder failed: %s", e) return None diff --git a/tests/test_heartbeat.py b/tests/test_heartbeat.py index 7ef5233..fc66402 100644 --- a/tests/test_heartbeat.py +++ b/tests/test_heartbeat.py @@ -2,14 +2,16 @@ import json import time -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, timedelta, timezone from pathlib import Path from unittest.mock import MagicMock, patch import pytest from src.heartbeat import ( - _check_calendar, + _calendar_daily_summary, + _calendar_next_reminder, + _check_calendar_smart, _check_email, _check_git, _check_kb_index, @@ -207,52 +209,113 @@ class TestCheckEmail: # --------------------------------------------------------------------------- -class TestCheckCalendar: - """Test calendar check via tools/calendar_check.py with JSON parsing.""" +class TestCheckCalendarSmart: + """Test smart calendar: daily summary + next-event reminders with dedup.""" def test_no_script(self, tmp_env): - assert _check_calendar({}) is None + assert _check_calendar_smart({}, (23, 8)) is None - def test_json_with_upcoming(self, tmp_env): + def test_first_run_sends_daily_summary(self, tmp_env): + """First run after quiet hours should return full day summary.""" + script = tmp_env["tools"] / "calendar_check.py" + script.write_text("pass") + output = json.dumps({ + "today": [ + {"summary": "Standup", "time": "09:00"}, + {"summary": "Lunch", "time": "12:00"}, + ], + "tomorrow": [] + }) + mock_result = MagicMock(returncode=0, stdout=output) + state = {} + with patch("src.heartbeat.subprocess.run", return_value=mock_result): + result = _check_calendar_smart(state, (23, 8)) + assert "Program azi" in result + assert "09:00 β€” Standup" in result + assert "12:00 β€” Lunch" in result + assert state["calendar"]["daily_summary_date"] == date.today().isoformat() + + def test_second_run_sends_next_event_only(self, tmp_env): + """After daily summary, only remind for next event within 45 min.""" script = tmp_env["tools"] / "calendar_check.py" script.write_text("pass") output = json.dumps({ "upcoming": [ - {"summary": "NLP Session", "minutes_until": 45, "time": "15:00"}, + {"summary": "Meeting", "minutes_until": 30, "time": "10:00"}, ] }) mock_result = MagicMock(returncode=0, stdout=output) + state = {"calendar": {"daily_summary_date": date.today().isoformat(), "reminded_events": {}}} with patch("src.heartbeat.subprocess.run", return_value=mock_result): - result = _check_calendar({}) - assert result == "Calendar: in 45 min β€” NLP Session (15:00)" + result = _check_calendar_smart(state, (23, 8)) + assert "in 30 min β€” Meeting (10:00)" in result + assert "Meeting@10:00" in state["calendar"]["reminded_events"] - def test_json_no_upcoming(self, tmp_env): + def test_dedup_prevents_repeat_reminder(self, tmp_env): + """Same event should not be reminded twice.""" script = tmp_env["tools"] / "calendar_check.py" script.write_text("pass") - output = json.dumps({"upcoming": []}) + output = json.dumps({ + "upcoming": [ + {"summary": "Meeting", "minutes_until": 25, "time": "10:00"}, + ] + }) mock_result = MagicMock(returncode=0, stdout=output) + today_str = date.today().isoformat() + state = {"calendar": { + "daily_summary_date": today_str, + "reminded_events": {"Meeting@10:00": today_str} + }} with patch("src.heartbeat.subprocess.run", return_value=mock_result): - assert _check_calendar({}) is None + result = _check_calendar_smart(state, (23, 8)) + assert result is None - def test_plaintext_fallback(self, tmp_env): + def test_event_beyond_45_min_not_reminded(self, tmp_env): + """Events more than 45 min away should not trigger a reminder.""" script = tmp_env["tools"] / "calendar_check.py" script.write_text("pass") - mock_result = MagicMock(returncode=0, stdout="Meeting at 3pm\n") + output = json.dumps({ + "upcoming": [ + {"summary": "Later", "minutes_until": 90, "time": "14:00"}, + ] + }) + mock_result = MagicMock(returncode=0, stdout=output) + state = {"calendar": {"daily_summary_date": date.today().isoformat(), "reminded_events": {}}} with patch("src.heartbeat.subprocess.run", return_value=mock_result): - assert _check_calendar({}) == "Calendar: Meeting at 3pm" + result = _check_calendar_smart(state, (23, 8)) + assert result is None - def test_empty_output(self, tmp_env): + def test_no_events_today(self, tmp_env): + """No events returns None and still marks summary as sent.""" script = tmp_env["tools"] / "calendar_check.py" script.write_text("pass") - mock_result = MagicMock(returncode=0, stdout="\n") + output = json.dumps({"today": [], "tomorrow": []}) + mock_result = MagicMock(returncode=0, stdout=output) + state = {} with patch("src.heartbeat.subprocess.run", return_value=mock_result): - assert _check_calendar({}) is None + result = _check_calendar_smart(state, (23, 8)) + assert result is None + assert state["calendar"]["daily_summary_date"] == date.today().isoformat() + + def test_old_reminded_events_cleaned(self, tmp_env): + """Reminded events from previous days are cleaned up.""" + script = tmp_env["tools"] / "calendar_check.py" + script.write_text("pass") + output = json.dumps({"today": [{"summary": "X", "time": "09:00"}], "tomorrow": []}) + mock_result = MagicMock(returncode=0, stdout=output) + state = {"calendar": { + "daily_summary_date": "2020-01-01", + "reminded_events": {"OldEvent@09:00": "2020-01-01"} + }} + with patch("src.heartbeat.subprocess.run", return_value=mock_result): + _check_calendar_smart(state, (23, 8)) + assert "OldEvent@09:00" not in state["calendar"]["reminded_events"] def test_subprocess_exception(self, tmp_env): script = tmp_env["tools"] / "calendar_check.py" script.write_text("pass") with patch("src.heartbeat.subprocess.run", side_effect=OSError("fail")): - assert _check_calendar({}) is None + assert _check_calendar_smart({}, (23, 8)) is None # --------------------------------------------------------------------------- @@ -401,7 +464,7 @@ class TestRunHeartbeat: def test_all_ok(self, tmp_env): with patch("src.heartbeat._check_email", return_value=None), \ - patch("src.heartbeat._check_calendar", return_value=None), \ + patch("src.heartbeat._check_calendar_smart", return_value=None), \ patch("src.heartbeat._check_kb_index", return_value=None), \ patch("src.heartbeat._check_git", return_value=None), \ patch("src.heartbeat._run_claude_extra", return_value=None): @@ -410,7 +473,7 @@ class TestRunHeartbeat: def test_with_results(self, tmp_env): with patch("src.heartbeat._check_email", return_value="Email: 2 necitite (X, Y)"), \ - patch("src.heartbeat._check_calendar", return_value=None), \ + patch("src.heartbeat._check_calendar_smart", return_value=None), \ patch("src.heartbeat._check_kb_index", return_value="KB: 1 fiΘ™iere reindexate"), \ patch("src.heartbeat._check_git", return_value=None), \ patch("src.heartbeat._is_quiet_hour", return_value=False), \ @@ -418,29 +481,22 @@ class TestRunHeartbeat: result = run_heartbeat() assert result == "Email: 2 necitite (X, Y) | KB: 1 fiΘ™iere reindexate" - def test_quiet_hours_suppresses_normal(self, tmp_env): + def test_quiet_hours_suppresses_all(self, tmp_env): + """During quiet hours, calendar is also suppressed (no longer critical).""" with patch("src.heartbeat._check_email", return_value="Email: 5 necitite (A)"), \ - patch("src.heartbeat._check_calendar", return_value=None), \ + patch("src.heartbeat._check_calendar_smart", return_value="Calendar stuff") as mock_cal, \ patch("src.heartbeat._check_kb_index", return_value=None), \ patch("src.heartbeat._check_git", return_value="Git: 2 uncommitted"), \ patch("src.heartbeat._is_quiet_hour", return_value=True): result = run_heartbeat() assert result == "HEARTBEAT_OK" - - def test_quiet_hours_allows_critical_calendar(self, tmp_env): - with patch("src.heartbeat._check_email", return_value="Email: 5 necitite (A)"), \ - patch("src.heartbeat._check_calendar", return_value="Calendar: in 30 min β€” Meeting (15:00)"), \ - patch("src.heartbeat._check_kb_index", return_value=None), \ - patch("src.heartbeat._check_git", return_value="Git: 2 uncommitted"), \ - patch("src.heartbeat._is_quiet_hour", return_value=True): - result = run_heartbeat() - assert result == "Calendar: in 30 min β€” Meeting (15:00)" + mock_cal.assert_not_called() def test_config_disables_check(self, tmp_env): """Checks can be disabled via config.""" config = {"heartbeat": {"checks": {"git": False}}} with patch("src.heartbeat._check_email", return_value=None), \ - patch("src.heartbeat._check_calendar", return_value=None), \ + patch("src.heartbeat._check_calendar_smart", return_value=None), \ patch("src.heartbeat._check_kb_index", return_value=None), \ patch("src.heartbeat._check_git", return_value="Git: 5 uncommitted") as mock_git, \ patch("src.heartbeat._run_claude_extra", return_value=None): @@ -452,7 +508,7 @@ class TestRunHeartbeat: """Quiet hours can be overridden via config.""" config = {"heartbeat": {"quiet_hours": [0, 1]}} # only 0-1 is quiet with patch("src.heartbeat._check_email", return_value=None), \ - patch("src.heartbeat._check_calendar", return_value=None), \ + patch("src.heartbeat._check_calendar_smart", return_value=None), \ patch("src.heartbeat._check_kb_index", return_value=None), \ patch("src.heartbeat._check_git", return_value="Git: 3 uncommitted"), \ patch("src.heartbeat._is_quiet_hour", return_value=False), \ @@ -462,7 +518,7 @@ class TestRunHeartbeat: def test_saves_state_after_run(self, tmp_env): with patch("src.heartbeat._check_email", return_value=None), \ - patch("src.heartbeat._check_calendar", return_value=None), \ + patch("src.heartbeat._check_calendar_smart", return_value=None), \ patch("src.heartbeat._check_kb_index", return_value=None), \ patch("src.heartbeat._check_git", return_value=None), \ patch("src.heartbeat._run_claude_extra", return_value=None): @@ -473,7 +529,7 @@ class TestRunHeartbeat: def test_saves_check_timestamps(self, tmp_env): with patch("src.heartbeat._check_email", return_value=None), \ - patch("src.heartbeat._check_calendar", return_value=None), \ + patch("src.heartbeat._check_calendar_smart", return_value=None), \ patch("src.heartbeat._check_kb_index", return_value=None), \ patch("src.heartbeat._check_git", return_value=None), \ patch("src.heartbeat._run_claude_extra", return_value=None):