Weekly planning session — Phase 2.
Fires Monday mornings at a configurable time. Assembles the week's backlog
tasks (filtered by deadline proximity, priority, or staleness), resolves
dependency order, dry-runs slot assignments, and posts a plan to Discord
for user confirmation.
On user reply "confirm" → applies the plan (creates calendar events).
On user reply "skip [title]" → removes that task and re-posts.
Pending proposals are held in memory — an in-process dict is sufficient for
Phase 2 with a single user.
See docs/scheduling.md and docs/agents.md.
WeeklyPlanner
WeeklyPlanner(db: Database, scheduler: Scheduler, recalculator: PriorityRecalculator, service: NotificationService, calendar_client: GoogleCalendarClient, calendar_id: str, user_id: str, fire_hour: int = _DEFAULT_FIRE_HOUR, fire_minute: int = _DEFAULT_FIRE_MINUTE)
Assembles and proposes a weekly schedule every Monday morning.
Usage
planner = WeeklyPlanner(db, scheduler, recalculator, service,
calendar_client, calendar_id, user_id)
asyncio.create_task(planner.run())
Source code in src/donna/scheduling/weekly_planner.py
| def __init__(
self,
db: Database,
scheduler: Scheduler,
recalculator: PriorityRecalculator,
service: NotificationService,
calendar_client: GoogleCalendarClient,
calendar_id: str,
user_id: str,
fire_hour: int = _DEFAULT_FIRE_HOUR,
fire_minute: int = _DEFAULT_FIRE_MINUTE,
) -> None:
self._db = db
self._scheduler = scheduler
self._recalculator = recalculator
self._service = service
self._calendar_client = calendar_client
self._calendar_id = calendar_id
self._user_id = user_id
self._fire_hour = fire_hour
self._fire_minute = fire_minute
# In-memory pending proposals: {proposal_id: {"slots": [...], "tasks": [...]}}
self._pending: dict[str, dict[str, Any]] = {}
|
run
async
Sleep until the next Monday at fire_hour:fire_minute, fire, repeat.
Source code in src/donna/scheduling/weekly_planner.py
| async def run(self) -> None:
"""Sleep until the next Monday at fire_hour:fire_minute, fire, repeat."""
logger.info(
"weekly_planner_started",
fire_hour=self._fire_hour,
fire_minute=self._fire_minute,
user_id=self._user_id,
)
while True:
now = datetime.now(tz=UTC)
next_fire = _next_monday_fire(now, self._fire_hour, self._fire_minute)
wait_seconds = (next_fire - now).total_seconds()
logger.info(
"weekly_planner_waiting",
next_fire=next_fire.isoformat(),
wait_seconds=int(wait_seconds),
)
await asyncio.sleep(max(wait_seconds, 0))
try:
await self._fire(datetime.now(tz=UTC))
except Exception:
logger.exception("weekly_planner_fire_failed", user_id=self._user_id)
|
handle_plan_reply
async
handle_plan_reply(message_text: str, now: datetime | None = None) -> bool
Handle a user reply to a weekly plan proposal.
Returns True if the reply was handled (matched a pending proposal),
False if no pending proposal exists.
Source code in src/donna/scheduling/weekly_planner.py
| async def handle_plan_reply(
self, message_text: str, now: datetime | None = None
) -> bool:
"""Handle a user reply to a weekly plan proposal.
Returns True if the reply was handled (matched a pending proposal),
False if no pending proposal exists.
"""
now = now or datetime.now(tz=UTC)
# Expire old proposals.
expired = [pid for pid, p in self._pending.items() if p["expires_at"] < now]
for pid in expired:
del self._pending[pid]
if not self._pending:
return False
# Use the most recent pending proposal.
proposal_id = max(self._pending)
proposal = self._pending[proposal_id]
text = message_text.strip().lower()
if text == "confirm":
await self._apply_proposal(proposal_id)
del self._pending[proposal_id]
return True
if text.startswith("skip "):
skip_title = message_text.strip()[5:].strip().strip("'\"")
tasks = proposal["tasks"]
slots = proposal["slots"]
new_pairs = [
(t, s) for t, s in zip(tasks, slots, strict=False)
if skip_title.lower() not in t.title.lower()
]
proposal["tasks"] = [t for t, _ in new_pairs]
proposal["slots"] = [s for _, s in new_pairs]
# Re-post updated proposal.
msg = self._format_proposal(
proposal_id, new_pairs, now
)
await self._service.dispatch(
notification_type="weekly_plan",
content=f"Updated plan (skipped '{skip_title}'):\n{msg}",
channel="tasks",
priority=4,
)
return True
return False
|