refactor(heartbeat): smart calendar with daily summary and dedup reminders
Calendar no longer bypasses quiet hours. First run after quiet hours sends full daily summary, subsequent runs only remind for next event within 45 min with deduplication. Calendar cooldown set to 30 min. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -38,7 +38,7 @@
|
||||
},
|
||||
"cooldowns": {
|
||||
"email": 1800,
|
||||
"calendar": 0,
|
||||
"calendar": 1800,
|
||||
"kb_index": 14400,
|
||||
"git": 14400
|
||||
}
|
||||
|
||||
114
src/heartbeat.py
114
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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user