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:
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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user