---
title: Calendar & Schedule Feature
status: completed
date: 2026-06-23
origin: docs/brainstorms/2026-06-23-calendar-schedule-requirements.md
type: feature
---

## Summary

在 AgentKit 客户端嵌入行事历，通过混合模式（ReAct 工具调用 + 时间关键词触发的后处理提取）自动捕获对话中的日程，支持完整的手动管理——循环事件、自定义类型、标签、三种视图（日历/卡片/列表），双向同步 Apple Calendar (CalDAV)、Outlook (Graph API) 和 iCal/ICS，配套独立的多渠道提醒子系统。

## Problem Frame

Agent 对话中频繁产生可执行的时间安排，但这些信息当前无处记录。痛点不在"缺少一个日历应用"，而在"Agent 产生的日程与日历之间没有桥梁"。本计划实现这架桥梁：Agent 工具调用 + 后处理提取 + 手动管理 + 外部同步 + 提醒分发，形成完整的日程闭环。

**Scope: Deep** — 跨前端 UI、后端 API、Agent 工具、外部同步、提醒子系统五大领域。

## Requirements Traceability

| Requirement Group | R-IDs | Covered by Units |
|---|---|---|
| 事件数据模型 | R1-R5 | U1, U2 |
| 视图模式 | R6-R9 | U10 (KTD-12: R9 = summary + drawer) |
| Agent 自动识别 | R10-R15 | U3, U4 (R15 source distinction: U10) |
| 手动管理 | R16-R19 | U10, U11 |
| 外部日历同步 | R20-R24 | U6, U7, U8 (SyncManager: U6) |
| 提醒子系统 | R25-R29 | U5, U12 |
| 共享与邀请 | R30-R33 | U2, U11 (R30 user search: U2, R33 invited styling: U10) |
| UI/UX | R34-R37 | U10, U11 |

## Key Technical Decisions

**KTD-1. Mirror documents subsystem for backend structure.** 日历后端镜像 `src/agentkit/documents/` 的结构——`calendar/db.py` (aiosqlite bare-connection)、`calendar/models.py` (dataclass DTO)、`calendar/service.py` (business logic)、`calendar/tool.py` (agent tool)。这是代码库中最近验证过的模式，与现有架构一致，避免引入 SQLAlchemy engine/session 的复杂性。Rationale: aiosqlite bare-connection 已在 auth 和 documents 子系统中验证，轻量且与异步架构兼容。

**KTD-2. asyncio loop for reminder scheduler, not APScheduler.** 提醒调度器跟随 `src/agentkit/server/task_store.py` 的 `start()`/`stop()` + `asyncio.create_task` 循环模式，在 `app.py` lifespan 中启停。Rationale: 不引入新依赖，与现有后台任务模式一致（task_store cleanup loop、session writer loop）。`ponytail: asyncio.sleep 轮询的精度是秒级，如果未来需要亚秒级调度可升级到 APScheduler。` **Note:** 需求文档提到"新增依赖：后台调度器（如 APScheduler）"，本决策选择不引入 APScheduler 而复用 asyncio loop 模式——这是有意识的偏离，因为 task_store 已验证此模式可行，且避免新依赖。

**KTD-3. python-dateutil.rrule for recurrence expansion.** 使用 `dateutil.rrule` 处理 RRULE 规则展开（RFC 5545 兼容），支持 FREQ/INTERVAL/COUNT/UNTIL/BYDAY。Rationale: 标准库 `datetime` 不支持循环规则，dateutil 是 Python 生态中唯一成熟的 RRULE 实现，且是 icalendar 库的依赖。

**KTD-4. caldav library for Apple Calendar, httpx for Outlook Graph API.** Apple Calendar 同步使用 `caldav` 库（CalDAV 协议），Outlook 同步直接用 `httpx` 调用 Microsoft Graph REST API（不引入 msgraph SDK）。Rationale: caldav 库是 Python 生态中唯一成熟的 CalDAV 客户端；msgraph SDK 过重（数百 MB），httpx 已在依赖中，Graph REST API 简单直接。

**KTD-5. icalendar library for ICS import/export.** 使用 `icalendar` 库生成和解析 .ics 文件。Rationale: 手写 iCalendar 格式容易出错（编码、转义、时区），icalendar 库是事实标准。

**KTD-6. FullCalendar Vue3 for calendar grid views.** 前端日历网格视图（月/周/日）使用 `@fullcalendar/vue3`，样式定制为 Notion Calendar 风格。Rationale: 从零实现带拖拽的月/周/日网格是数千行代码，FullCalendar 是成熟标准库，支持拖拽创建/移动、循环事件展开、视图切换。`ponytail: FullCalendar 是本计划中唯一的大型前端依赖，如果后续需要移除，日历网格组件是独立的可替换层。`

**KTD-7. Post-processing hook in chat.py after assistant reply persistence.** 后处理提取钩子插入 `src/agentkit/server/routes/chat.py` 的 `_handle_chat_message()` 中，在 assistant 回复持久化之后（REACT 路径约 line 1158，DIRECT_CHAT 路径约 line 971）触发。先做零 LLM 的正则关键词检测，命中后才调用 LLM 提取。Rationale: 这是对话轮次结束的自然插入点，不改变现有流程，仅在回复完成后追加一个异步后台任务。

**KTD-8. aiosmtplib for email reminder channel.** 邮件提醒渠道使用 `aiosmtplib`（异步 SMTP 客户端）。Rationale: smtplib 是同步的会阻塞事件循环，aiosmtplib 是标准异步替代品。

**KTD-9. Reuse existing LLM gateway for post-processing extraction.** 后处理提取的 LLM 调用复用 `agentkit.llm` 网关，不新建 LLM 客户端。Rationale: LLM 网关已有 fallback、缓存、用量追踪，直接复用。

**KTD-10. WebSocket piggyback for real-time calendar push.** 日历实时更新（Agent 创建事件、提醒触发、邀请通知、同步冲突）复用现有 chat WebSocket 连接，新增 `calendar_event_created`、`calendar_reminder`、`calendar_invitation`、`calendar_sync_conflict` 消息类型。Rationale: 避免新建 WS 连接，前端 chat store 已有消息分发机制。

**KTD-11. UTC storage + local display timezone strategy.** 所有时间字段（`start_time`、`end_time`、`scheduled_time`、`last_modified`、`created_at`）以 ISO 8601 UTC 存储在数据库中，前端显示时由浏览器自动转换为本地时区。RRULE 展开基于 UTC 计算，显示时再转本地。Rationale: 统一存储避免时区歧义，浏览器 `Intl.DateTimeFormat` 或 dayjs UTC 插件处理显示转换。`ponytail: 不支持用户手动选择时区——浏览器本地时区即显示时区，跨时区用户需自行调整设备时区。`

**KTD-12. Right panel = summary view, drawer = full management.** 右面板日历 tab 仅展示摘要视图（今日 + 未来 3 条事件），点击"展开"按钮打开 80% 宽度的大抽屉，抽屉内包含完整的三视图管理（日历网格/卡片/列表）+ 事件编辑器 + 邀请管理。Rationale: 右面板空间有限，摘要视图满足"快速查看"需求；完整管理功能需要大画布，抽屉模式不离开当前页面上下文。

## Architecture

### Module Map

```
src/agentkit/calendar/                    # New subsystem (mirrors documents/)
  __init__.py
  models.py                               # @dataclass: CalendarEvent, EventType, Tag, Reminder, ReminderRule, ExternalCalendarConfig, Invitation
  db.py                                   # aiosqlite: init_calendar_db(), CRUD functions
  service.py                              # CalendarService: business logic, event CRUD, type/tag management
  recurrence.py                           # RRULE expansion wrapper (dateutil.rrule)
  scheduler.py                            # ReminderScheduler: start/stop asyncio loop, scan + dispatch
  reminders.py                            # ReminderDispatcher: client push, email, webhook channels
  extraction.py                           # PostProcessingExtractor: keyword gate + LLM extraction
  sync/
    __init__.py
    base.py                               # AbstractSyncProvider interface
    caldav_provider.py                    # Apple Calendar sync via caldav library
    outlook_provider.py                   # Outlook sync via httpx + Graph API
    ics_provider.py                       # ICS import/export via icalendar library
    manager.py                            # SyncManager: orchestrate providers, conflict resolution

src/agentkit/tools/calendar_tool.py       # CalendarTool(Tool): create_event, query_events, update_event, delete_event

src/agentkit/server/routes/calendar.py    # APIRouter(prefix="/calendar"): REST endpoints

src/agentkit/server/frontend/src/
  api/calendar.ts                         # CalendarApiClient extends BaseApiClient
  stores/calendar.ts                      # useCalendarStore (Pinia Composition API)
  components/calendar/
    CalendarPanel.vue                     # Right panel tab content (summary view only — KTD-12)
    CalendarDrawer.vue                    # Large drawer wrapper (full management — KTD-12)
    CalendarGrid.vue                      # FullCalendar wrapper (month/week/day)
    CardView.vue                          # Kanban-style card view
    ListView.vue                          # Sorted list view
    EventBadge.vue                        # Source distinction badge (manual/agent/post_extract — G3)
    EventEditor.vue                       # Event create/edit form (a-drawer)
    ReminderConfig.vue                    # Reminder rule configuration
    SyncSettings.vue                      # External calendar sync config
    InvitationManager.vue                 # Invitation response UI
  views/CalendarView.vue                  # Top-level route page (optional, for drawer mode)
```

### Wiring Points

| Integration | File | Location |
|---|---|---|
| DB init + service | `src/agentkit/server/app.py` | lifespan startup (~line 263, next to `init_documents_db()`) |
| Route registration | `src/agentkit/server/app.py` | ~line 954, `app.include_router(calendar.router, prefix="/api/v1")` |
| Tool registration | `src/agentkit/server/app.py` | ~line 268, `agent._tool_registry.register(CalendarTool(...))` |
| Scheduler start | `src/agentkit/server/app.py` | lifespan startup (~line 151, next to `task_store.start_cleanup()`) |
| Scheduler stop | `src/agentkit/server/app.py` | lifespan shutdown (~line 430) |
| SyncManager start | `src/agentkit/server/app.py` | lifespan startup (next to reminder scheduler) |
| SyncManager stop | `src/agentkit/server/app.py` | lifespan shutdown (next to reminder scheduler stop) |
| Post-processing hook | `src/agentkit/server/routes/chat.py` | ~line 1158 (REACT), ~line 971 (DIRECT_CHAT) |
| WebSocket push | `src/agentkit/server/routes/chat.py` | `chat_manager.send_json(session_id, {...})` |
| Frontend right panel tab | `src/agentkit/server/frontend/src/components/layout/AgentLayout.vue` | `bottomRightTabs` array (~line 153) |
| Frontend WS handler | `src/agentkit/server/frontend/src/stores/chat.ts` | `handleWsMessage()` switch (~line 586) |
| Frontend WS types | `src/agentkit/server/frontend/src/api/types.ts` | `WsServerMessage` union (~line 111) |

### Data Model

```python
# src/agentkit/calendar/models.py (dataclasses, mirrors documents/models.py)

@dataclass
class EventType:
    id: str               # UUID
    user_id: str          # FK to users
    name: str             # "会议", "截止", "个人"
    color: str            # "#4A90D9"
    is_default: bool      # system-provided types

@dataclass
class Tag:
    id: str
    user_id: str
    name: str

@dataclass
class CalendarEvent:
    id: str
    user_id: str
    title: str
    description: str
    start_time: str       # ISO 8601 UTC (see KTD-11)
    end_time: str         # ISO 8601 UTC
    is_all_day: bool
    location: str
    event_type_id: str | None
    rrule: str | None     # RFC 5545 RRULE string, e.g. "FREQ=WEEKLY;BYDAY=MO;COUNT=10"
    source: str           # "manual" | "agent" | "post_extract"
    is_invited: bool      # True if this event arrived via invitation (R33 — special styling)
    conversation_id: str | None  # origin traceability for agent/extracted events
    external_id: str | None      # ID in external calendar (for sync)
    external_provider: str | None  # "caldav" | "outlook"
    last_modified: str    # ISO 8601 UTC, for conflict resolution (last-write-wins)
    created_at: str       # ISO 8601 UTC

@dataclass
class EventTag:           # many-to-many junction
    event_id: str
    tag_id: str

@dataclass
class ReminderRule:
    id: str
    event_id: str         # FK to events (nullable for type-level defaults)
    event_type_id: str    # FK to event_types (for default reminders)
    offset_minutes: int   # -15 = 15 min before, -1440 = 1 day before
    channels: list[str]   # ["client", "email", "webhook"]

@dataclass
class ReminderDelivery:
    id: str
    reminder_rule_id: str
    event_id: str
    scheduled_time: str
    status: str           # "pending" | "sent" | "failed" | "read"
    channel: str
    attempts: int
    last_error: str | None

@dataclass
class ExternalCalendarConfig:
    id: str
    user_id: str
    provider: str         # "caldav" | "outlook"
    credentials: str      # encrypted JSON (CalDAV URL+user+app_password, or OAuth refresh_token)
    sync_frequency: int   # minutes
    sync_scope: list[str] # event type IDs to sync, empty = all
    last_sync: str | None
    sync_token: str | None  # delta token for incremental sync

@dataclass
class Invitation:
    id: str
    event_id: str
    inviter_user_id: str
    invitee_email: str
    status: str           # "pending" | "accepted" | "declined" | "tentative"
    responded_at: str | None
```

---

## Implementation Units

### U1. Backend data model & storage

- **Goal:** Define calendar dataclasses, aiosqlite DB schema with CRUD functions, and RRULE recurrence expansion wrapper.
- **Files:**
  - `src/agentkit/calendar/__init__.py` (new)
  - `src/agentkit/calendar/models.py` (new — dataclasses above)
  - `src/agentkit/calendar/db.py` (new — `init_calendar_db()`, `insert_event()`, `get_event()`, `list_events()`, `update_event()`, `delete_event()`, same for types/tags/reminders/configs/invitations)
  - `src/agentkit/calendar/recurrence.py` (new — `expand_rrule(rrule_str, start_time, range_start, range_end) -> list[str]` wrapper around `dateutil.rrule`)
- **Patterns:** Mirror `src/agentkit/documents/db.py` (aiosqlite bare-connection, `PRAGMA journal_mode=WAL`, module-level async functions). Mirror `src/agentkit/documents/models.py` for dataclass DTO pattern. `recurrence.py` wraps `dateutil.rrule.rrulestr()` + `between()` for date-range expansion.
- **DB schema:** 7 tables — `calendar_events`, `calendar_event_types`, `calendar_tags`, `calendar_event_tags`, `calendar_reminder_rules`, `calendar_reminder_deliveries`, `calendar_external_configs`, `calendar_invitations`. All with `String(36)` UUIDs, ISO 8601 UTC timestamps (see KTD-11), indexes on `user_id` and `start_time`.
- **RRULE expansion (G1, G9):** `recurrence.py` provides `expand_rrule(rrule_str, dtstart, range_start, range_end)` returning a list of ISO 8601 UTC occurrence strings within the range. Used by: (a) `CalendarService.list_events()` to expand recurring events for display, (b) `ReminderScheduler` to find occurrences entering reminder window, (c) sync providers to push individual occurrences if external calendar requires expanded events. All times in UTC per KTD-11.
- **Test file:** `tests/unit/calendar/test_db.py`, `tests/unit/calendar/test_recurrence.py`
- **Test scenarios:**
  - `test_init_calendar_db_creates_all_tables` — call `init_calendar_db()`, verify tables exist
  - `test_insert_and_get_event_roundtrip` — insert event, fetch by id, all fields preserved (including `is_invited`)
  - `test_list_events_by_user_filtered_by_date_range` — insert 3 events, query range covering 2
  - `test_update_event_modifies_fields` — insert, update title, verify
  - `test_delete_event_removes_record` — insert, delete, verify gone
  - `test_event_type_crud` — create/list/update/delete event types
  - `test_tag_many_to_many` — event with 3 tags, query by tag returns event
  - `test_reminder_rule_crud` — create rule for event, create default rule for type
  - `test_external_config_stores_encrypted_credentials` — insert config, verify credentials field is opaque
  - `test_expand_rrule_weekly_count` — `FREQ=WEEKLY;BYDAY=MO;COUNT=4` from Monday → 4 occurrences
  - `test_expand_rrule_daily_range_filter` — `FREQ=DAILY` starting Jan 1, range Jan 3–Jan 5 → 3 occurrences
  - `test_expand_rrule_until_clause` — `FREQ=DAILY;UNTIL=20260131` → occurrences stop at Jan 31
  - `test_expand_rrule_no_rrule_returns_single` — `rrule=None` → returns `[start_time]` only
  - `test_expand_rrule_all_day_event` — all-day event with RRULE, verify date (not datetime) expansion
  - `test_expand_rrule_dst_boundary` — event crossing DST transition, verify UTC consistency
- **Dependencies:** None (foundation unit)

### U2. Backend calendar service & REST API

- **Goal:** Business logic layer + REST endpoints for full calendar CRUD, event types, tags, invitations, and non-admin user search for invitations.
- **Files:**
  - `src/agentkit/calendar/service.py` (new — `CalendarService` class)
  - `src/agentkit/server/routes/calendar.py` (new — `APIRouter(prefix="/calendar", tags=["calendar"])`)
- **Patterns:** Mirror `src/agentkit/documents/service.py` (service class with injected dependencies) and `src/agentkit/server/routes/documents.py` (route module with `_verify_api_key` or `require_authenticated` dependency, Pydantic request/response models, `request.app.state.calendar_service` access).
- **Service methods:** `create_event()`, `get_event()`, `list_events()` (with RRULE expansion via `recurrence.expand_rrule`), `update_event()`, `delete_event()`, `list_event_types()`, `create_event_type()`, `update_event_type()`, `list_tags()`, `create_tag()`, `create_invitation()`, `respond_to_invitation()`, `list_invitations()`, `search_users(q)` (returns `[{username, email}]` for invitation picker — G5/A3).
- **REST endpoints:**
  - `POST /api/v1/calendar/events` — create event
  - `GET /api/v1/calendar/events?start=...&end=...&type_id=...&tag_id=...` — list with filters (tag_id filter covers G2)
  - `GET /api/v1/calendar/events/{id}` — get single
  - `PATCH /api/v1/calendar/events/{id}` — update
  - `DELETE /api/v1/calendar/events/{id}` — delete
  - `POST /api/v1/calendar/events/{id}/invitations` — invite user by email (creates Invitation + pushes `calendar_invitation` WS to invitee — G6)
  - `POST /api/v1/calendar/invitations/{id}/respond` — accept/decline/tentative
  - `GET /api/v1/calendar/invitations` — list pending invitations for current user
  - `GET /api/v1/calendar/users/search?q=xxx` — non-admin user search by username/email, returns `[{username, email}]` only (no user_id leak) — G5/A3. Auth: any authenticated user.
  - `GET /api/v1/calendar/event-types` — list types
  - `POST /api/v1/calendar/event-types` — create type
  - `PATCH /api/v1/calendar/event-types/{id}` — update type
  - `GET /api/v1/calendar/tags` — list tags
  - `POST /api/v1/calendar/tags` — create tag
- **User search implementation (G5/A3):** `search_users(q)` queries `users` table (from `src/agentkit/server/auth/models.py` `UserModel`) by `username ILIKE '%q%' OR email ILIKE '%q%'`, returns top 10 results as `[{username, email}]`. Does NOT require admin permission — uses standard authenticated user dependency. Existing `GET /api/v1/admin/users` requires admin and has no username/email search, so this new endpoint is necessary.
- **Invitation notification (G6):** When `create_invitation()` is called, after persisting the `Invitation` record, push `calendar_invitation` WS message to the invitee's active session(s) via `chat_manager.send_json()`. Invitee frontend shows a notification + updates invitation list.
- **Wiring:** `app.py` lifespan — `await init_calendar_db()`, `app.state.calendar_service = CalendarService()`, `app.include_router(calendar.router, prefix="/api/v1")`.
- **Test file:** `tests/unit/calendar/test_service.py`, `tests/unit/calendar/test_routes.py`
- **Test scenarios:**
  - `test_create_event_with_type_and_tags` — create event with type_id and 2 tags, verify all linked
  - `test_list_events_filters_by_date_range` — 3 events across days, filter returns correct subset
  - `test_list_events_filters_by_type_and_tag` — filter combination (G2)
  - `test_list_events_filters_by_tag_only` — 5 events, 2 with tag X, filter tag_id=X returns only 2 (G2)
  - `test_list_events_expands_recurring` — event with `FREQ=DAILY;COUNT=3`, list range covering 2 days → returns 2 expanded occurrences
  - `test_update_event_partial_fields` — PATCH only title, other fields unchanged
  - `test_delete_event_cascades_reminders_and_tags` — delete event, verify reminder rules and junction rows removed
  - `test_create_invitation_pushes_ws_to_invitee` — create invitation, verify `chat_manager.send_json()` called with `calendar_invitation` type (G6)
  - `test_respond_to_invitation_updates_status` — invite, respond "accepted", verify status + `responded_at` set
  - `test_search_users_by_username` — seed 3 users, search "alice" returns matching user with username+email only (G5)
  - `test_search_users_by_email` — search by email domain returns matches (G5)
  - `test_search_users_no_match_returns_empty` — search "zzz" returns `[]`
  - `test_search_users_requires_auth` — no auth header → 401
  - `test_search_users_returns_max_10` — seed 15 matching users, verify only 10 returned
  - `test_event_type_default_color_persistence` — create type with color, verify roundtrip
  - `test_route_create_event_requires_auth` — no auth header → 401
  - `test_route_list_events_returns_paginated` — large result set with limit/offset
- **Dependencies:** U1

### U3. Agent calendar tool (ReAct integration)

- **Goal:** CalendarTool for ReAct loop — Agent autonomously calls `create_event`, `query_events`, `update_event`, `delete_event`.
- **Files:**
  - `src/agentkit/tools/calendar_tool.py` (new — `CalendarTool(Tool)`)
- **Patterns:** Mirror `src/agentkit/tools/document_tool.py` — extend `Tool` ABC, define `input_schema` as JSON Schema dict, `execute(**kwargs)` dispatches by `action` parameter, returns `{"success": True/False, ...}` dict, errors caught not raised.
- **Tool actions:**
  - `create_event` — params: title, start_time, end_time, description, location, event_type_name, tag_names, rrule, is_all_day
  - `query_events` — params: start_date, end_date, limit
  - `update_event` — params: event_id, fields to update
  - `delete_event` — params: event_id
- **Source marking:** All events created via tool set `source="agent"` and `conversation_id` from execution context.
- **Wiring:** `app.py` ~line 268 — `agent._tool_registry.register(CalendarTool(service=calendar_service))`.
- **Test file:** `tests/unit/tools/test_calendar_tool.py`
- **Test scenarios:**
  - `test_create_event_action_returns_success` — call execute with create_event action, verify event in DB with source="agent"
  - `test_create_event_with_recurrence_sets_rrule` — rrule param stored correctly
  - `test_query_events_returns_list` — create 2 events, query, verify both returned
  - `test_update_event_action_modifies_fields` — create then update title
  - `test_delete_event_action_removes_record` — create then delete
  - `test_invalid_action_returns_error` — unknown action → `{"success": False, "error": "..."}`
  - `test_missing_required_field_returns_error` — create_event without title → error
  - `test_created_event_has_conversation_id` — verify conversation_id is set from context
- **Dependencies:** U2

### U4. Post-processing extraction (keyword gating + LLM)

- **Goal:** After conversation turn, detect time keywords (zero-LLM regex), if hit then call LLM to extract schedule info, create events with `source="post_extract"`.
- **Files:**
  - `src/agentkit/calendar/extraction.py` (new — `PostProcessingExtractor` class)
- **Patterns:** Keyword regex follows the `_TOOL_CONTEXT_RE` / greeting regex pattern in `src/agentkit/chat/request_preprocessor.py`. LLM call via `agentkit.llm` gateway (reuse existing `LLMGateway.complete()` or `acomplete()`).
- **Keyword regex:** `明天|后天|下周|本周|今天下午|今天上午|上午|下午|晚上|\d+点|\d+月\d+日|\d+号|开会|截止|deadline|schedule|reminder|提醒|预约|约定|安排`
- **LLM extraction prompt:** System prompt instructs LLM to extract events as JSON array `[{title, start_time, end_time, description, event_type}]` from conversation text. Empty array if no events.
- **Flow:**
  1. Hook fires after assistant reply persisted in `chat.py` (~line 1158 REACT, ~line 971 DIRECT_CHAT)
  2. `asyncio.create_task(extractor.extract(conversation_text, conversation_id, user_id))`
  3. Regex keyword scan — if no match, return immediately (zero LLM cost)
  4. If match, call LLM gateway with extraction prompt
  5. Parse JSON response, create events with `source="post_extract"`
  6. Push `calendar_event_created` WS message to client
- **Wiring:** `chat.py` — after `sm.append_message()` for assistant reply, call `asyncio.create_task(post_extractor.extract(...))`. Extractor instance on `app.state.post_extractor`.
- **Test file:** `tests/unit/calendar/test_extraction.py`
- **Test scenarios:**
  - `test_keyword_regex_matches_chinese_time_words` — "明天下午3点" matches, "继续优化吧" doesn't
  - `test_keyword_regex_matches_english_time_words` — "deadline tomorrow" matches
  - `test_no_keyword_skips_llm_call` — mock LLM gateway, verify `.complete()` never called when no keyword
  - `test_keyword_hit_triggers_llm_extraction` — mock LLM returns `[{title: "会议", start_time: "..."}]`, verify event created with source="post_extract"
  - `test_llm_returns_empty_array_creates_nothing` — LLM returns `[]`, no events created
  - `test_malformed_llm_response_handled_gracefully` — LLM returns invalid JSON, no crash, logged
  - `test_extracted_events_have_conversation_id` — verify conversation_id set
  - `test_extraction_does_not_block_chat_response` — verify extract is async, chat reply returns before extraction completes
- **Dependencies:** U2, U3 (for source marking pattern)

### U5. Reminder subsystem (scheduler + multi-channel dispatch)

- **Goal:** Background scheduler scans upcoming events, matches reminder rules, dispatches via client push / email / webhook, tracks delivery status with retry.
- **Files:**
  - `src/agentkit/calendar/scheduler.py` (new — `ReminderScheduler` class)
  - `src/agentkit/calendar/reminders.py` (new — `ReminderDispatcher` class)
- **Patterns:** Scheduler follows `src/agentkit/server/task_store.py` `start()`/`stop()` + `asyncio.create_task` loop pattern. Dispatcher uses strategy pattern — one method per channel.
- **Scheduler loop:**
  1. Every 60 seconds: query events where `start_time + offset_minutes` is within next 60s window
  2. For each match, check if `ReminderDelivery` already exists (idempotency)
  3. If not, create `ReminderDelivery` records (one per channel) and dispatch
  4. Failed deliveries retried up to 3 times with exponential backoff
- **Dispatcher channels:**
  - `client` — `chat_manager.send_json(session_id, {"type": "calendar_reminder", "data": {...}})`
  - `email` — `aiosmtplib.send()` with SMTP config from `agentkit.yaml`
  - `webhook` — `httpx.post()` to user-configured webhook URL
- **Default reminders:** When event created, if its `event_type_id` has default `ReminderRule`s, clone them to the event.
- **Wiring:** `app.py` lifespan startup — `reminder_scheduler = ReminderScheduler(calendar_service, dispatcher); app.state.reminder_scheduler = reminder_scheduler; await reminder_scheduler.start()`. Shutdown — `await reminder_scheduler.stop()`.
- **Test file:** `tests/unit/calendar/test_scheduler.py`, `tests/unit/calendar/test_reminders.py`
- **Test scenarios:**
  - `test_scheduler_finds_event_within_reminder_window` — event 10 min away, rule offset -15min, scheduler finds it
  - `test_scheduler_skips_event_outside_window` — event 2 hours away, rule offset -15min, not found
  - `test_idempotent_delivery_no_duplicate` — scheduler runs twice, only one delivery record created
  - `test_client_channel_sends_ws_message` — mock chat_manager, verify send_json called
  - `test_email_channel_sends_smtp` — mock aiosmtplib, verify send called with correct params
  - `test_webhook_channel_posts_to_url` — mock httpx, verify POST called
  - `test_failed_delivery_retries_up_to_3_times` — mock channel to fail, verify 3 attempts
  - `test_default_reminders_inherited_from_event_type` — create event with type that has default rules, verify rules cloned
  - `test_scheduler_start_stop_lifecycle` — start(), verify task running, stop(), verify cancelled
- **Dependencies:** U1, U2

### U6. External sync — CalDAV (Apple Calendar)

- **Goal:** Bidirectional sync with Apple Calendar via CalDAV protocol using `caldav` library, plus SyncManager orchestration.
- **Files:**
  - `src/agentkit/calendar/sync/base.py` (new — `AbstractSyncProvider` interface: `pull_changes()`, `push_changes()`, `test_connection()`)
  - `src/agentkit/calendar/sync/caldav_provider.py` (new — `CalDAVSyncProvider`)
  - `src/agentkit/calendar/sync/manager.py` (new — `SyncManager` class — G8)
- **Patterns:** Provider interface allows future providers (Google Calendar) without changing SyncManager. CalDAV connection uses `caldav.DAVClient(url)` with user Apple ID + app-specific password.
- **SyncManager (G8):** `SyncManager` orchestrates all providers. Methods: `sync_all(user_id)` (iterates user's `ExternalCalendarConfig`s, calls provider pull/push), `sync_provider(config_id)` (single provider), `resolve_conflict(local_event, remote_event)` (last-write-wins + WS notification). Called by: (a) scheduled sync (asyncio loop, same pattern as reminder scheduler), (b) manual "Sync Now" button, (c) after local event creation/update (push to external). `SyncManager` is registered on `app.state.sync_manager` and started/stopped in `app.py` lifespan.
- **Sync flow:**
  1. `pull_changes()` — `calendar.date_search(start, end)` to fetch events in range, match by `external_id` (CalDAV UID), create/update local events
  2. `push_changes()` — for local events modified since `last_sync`, `calendar.save_event(ical_data)` to push to CalDAV
  3. Conflict: `last_modified` comparison, last-write-wins, log conflict + push `calendar_sync_conflict` WS notification to user (G4)
- **ICS generation:** Use `icalendar` library to convert `CalendarEvent` → iCalendar VEVENT (with RRULE if present).
- **Config:** `ExternalCalendarConfig` stores CalDAV URL, username, app-specific password (encrypted). User configures via settings UI.
- **Test file:** `tests/unit/calendar/test_sync_caldav.py`, `tests/unit/calendar/test_sync_manager.py`
- **Test scenarios:**
  - `test_caldav_provider_pull_creates_local_events` — mock caldav client returning 2 events, verify local events created with external_id set
  - `test_caldav_provider_pull_updates_existing_event` — event already exists (matched by external_id), remote modified, local updated
  - `test_caldav_provider_push_creates_remote_event` — local event with no external_id, push creates remote, external_id stored
  - `test_caldav_provider_push_updates_remote_event` — local event modified, push updates remote
  - `test_caldav_conflict_last_write_wins` — both sides modified, newer last_modified wins, conflict logged
  - `test_caldav_conflict_sends_ws_notification` — conflict detected, verify `chat_manager.send_json()` called with `calendar_sync_conflict` type (G4)
  - `test_caldav_rrule_roundtrip` — event with RRULE synced, verify RRULE preserved in both directions
  - `test_caldav_test_connection_success` — mock returns calendars, test_connection() returns True
  - `test_caldav_test_connection_failure` — mock raises, test_connection() returns False with error message
  - `test_sync_manager_sync_all_iterates_providers` — 2 configs, verify both providers called (G8)
  - `test_sync_manager_sync_provider_updates_last_sync` — sync completes, verify config.last_sync updated (G8)
  - `test_sync_manager_resolve_conflict_notifies_user` — conflict, verify WS push (G8/G4)
- **Dependencies:** U2, U8 (ICS generation)

### U7. External sync — Outlook (Microsoft Graph API)

- **Goal:** Bidirectional sync with Outlook Calendar via Microsoft Graph REST API using `httpx`.
- **Files:**
  - `src/agentkit/calendar/sync/outlook_provider.py` (new — `OutlookSyncProvider`)
- **Patterns:** Same `AbstractSyncProvider` interface. OAuth2 flow — user authorizes via browser, app stores refresh token, auto-refreshes access token. Graph API endpoints: `GET /me/calendarView/delta` for incremental pull, `POST /me/events` for create, `PATCH /me/events/{id}` for update.
- **Delta sync:** Use Graph API delta query (`$deltaToken` / `$skipToken`) for incremental changes — first call is full sync, subsequent calls are incremental.
- **OAuth flow:**
  1. User clicks "Connect Outlook" in settings UI
  2. Redirect to Microsoft login with `Calendars.ReadWrite` scope
  3. Callback receives authorization code, exchanges for access + refresh token
  4. Tokens stored in `ExternalCalendarConfig.credentials` (encrypted JSON)
  5. On token expiry (401), auto-refresh using refresh token
- **Config:** `ExternalCalendarConfig` stores OAuth client_id, refresh_token, access_token (with expiry). Client ID configured in `agentkit.yaml`.
- **Test file:** `tests/unit/calendar/test_sync_outlook.py`
- **Test scenarios:**
  - `test_outlook_provider_pull_delta_initial_sync` — mock httpx returning 3 events, verify local events created
  - `test_outlook_provider_pull_delta_incremental` — mock returning 1 new + 1 updated, verify only those processed
  - `test_outlook_provider_push_creates_remote_event` — local event pushed, remote ID stored
  - `test_outlook_provider_push_updates_remote_event` — local modification pushed
  - `test_outlook_token_refresh_on_401` — mock 401 response, verify refresh token used, request retried
  - `test_outlook_conflict_last_write_wins` — both sides modified, newer wins
  - `test_outlook_rrule_roundtrip` — recurring event synced, pattern preserved
  - `test_outlook_test_connection_success` — mock Graph /me returns user, True
- **Dependencies:** U2, U8 (ICS for RRULE conversion)

### U8. iCal/ICS import/export

- **Goal:** Import .ics files to local calendar, export local calendar to .ics file, using `icalendar` library.
- **Files:**
  - `src/agentkit/calendar/sync/ics_provider.py` (new — `ICSProvider`)
- **Patterns:** `icalendar.Calendar.from_ical(ics_bytes)` for import, `icalendar.Calendar()` + `add_component()` for export. Map VEVENT fields to `CalendarEvent` dataclass.
- **Import flow:** Parse ICS → for each VEVENT, create `CalendarEvent` (map SUMMARY→title, DTSTART→start_time, RRULE→rrule, etc.) → skip events with duplicate UID already imported.
- **Export flow:** Query events in date range → for each, create VEVENT component → assemble into Calendar → serialize to .ics bytes → return as downloadable file.
- **REST endpoints** (in `routes/calendar.py`):
  - `POST /api/v1/calendar/import-ics` — upload .ics file, parse, create events
  - `GET /api/v1/calendar/export-ics?start=...&end=...` — download .ics file
- **Test file:** `tests/unit/calendar/test_ics_provider.py`
- **Test scenarios:**
  - `test_import_simple_ics_creates_event` — single VEVENT ICS, verify event created with correct fields
  - `test_import_recurring_ics_preserves_rrule` — VEVENT with RRULE, verify rrule field set
  - `test_import_all_day_event` — DTSTART is date not datetime, verify is_all_day=True
  - `test_import_skips_duplicate_uid` — import same ICS twice, second import creates no new events
  - `test_export_produces_valid_ics` — create event, export, parse result with icalendar, verify roundtrip
  - `test_export_includes_recurrence` — event with rrule, export, verify RRULE in ICS
  - `test_export_date_range_filter` — 3 events, export range covering 2, verify only 2 in ICS
  - `test_import_malformed_ics_raises_error` — invalid ICS bytes, verify graceful error
- **Dependencies:** U2

### U9. Frontend store, API client & types

- **Goal:** Pinia store, API client, and TypeScript types for calendar feature.
- **Files:**
  - `src/agentkit/server/frontend/src/api/calendar.ts` (new — `CalendarApiClient extends BaseApiClient`, types co-located)
  - `src/agentkit/server/frontend/src/stores/calendar.ts` (new — `useCalendarStore` Composition API)
  - `src/agentkit/server/frontend/src/api/types.ts` (modify — add `calendar_event_created`, `calendar_reminder`, `calendar_invitation`, `calendar_sync_conflict` to `WsServerMessage` union)
- **Patterns:** API client mirrors `src/agentkit/server/frontend/src/api/documents.ts` (extend `BaseApiClient`, singleton export, types co-located, runtime type guard `isCalendarEvent`). Store mirrors `src/agentkit/server/frontend/src/stores/settings.ts` (Composition API, `ref()` state, `computed()` getters, async actions with try/catch + `isLoading`).
- **API client methods:** `listEvents()`, `createEvent()`, `updateEvent()`, `deleteEvent()`, `listEventTypes()`, `createEventType()`, `listTags()`, `createTag()`, `importIcs()`, `exportIcs()`, `createInvitation()`, `respondToInvitation()`, `listInvitations()`, `searchUsers(q)` (G5/A3), `listExternalConfigs()`, `createExternalConfig()`, `testExternalConnection()`, `syncNow(configId)`.
- **Store state:** `events`, `eventTypes`, `tags`, `isLoading`, `error`, `selectedEvent`, `viewMode` ('calendar'|'card'|'list'), `dateRange`, `pendingInvitations`, `syncConflicts`.
- **Store actions:** `loadEvents()`, `createEvent()`, `updateEvent()`, `deleteEvent()`, `loadEventTypes()`, `loadTags()`, `setViewMode()`, `loadInvitations()`, `respondToInvitation()`, `searchUsers(q)`, `handleWsEvent()` (dispatch for `calendar_event_created`, `calendar_reminder`, `calendar_invitation`, `calendar_sync_conflict` WS messages).
- **WS integration:** `stores/chat.ts` `handleWsMessage()` — add `case 'calendar_event_created'`, `case 'calendar_reminder'`, `case 'calendar_invitation'`, `case 'calendar_sync_conflict'` branches that call into calendar store (lazy import pattern to avoid circular deps).
- **Test file:** `tests/unit/frontend/calendar/store.test.ts` (if frontend test infra exists; otherwise manual verification via `npm run typecheck`)
- **Test scenarios:**
  - Type check: `npm run typecheck` passes with new types
  - `test_loadEvents_populates_state` — mock API, call loadEvents, verify events ref populated
  - `test_createEvent_adds_to_state` — call createEvent, verify events array updated
  - `test_handleWsEvent_calendar_event_created` — call handleWsEvent with calendar_event_created payload, verify event added to state
  - `test_handleWsEvent_calendar_reminder` — call handleWsEvent with reminder payload, verify notification shown
  - `test_handleWsEvent_calendar_invitation` — call handleWsEvent with invitation payload, verify pendingInvitations updated + notification shown (G6)
  - `test_handleWsEvent_calendar_sync_conflict` — call handleWsEvent with conflict payload, verify syncConflicts updated + alert shown (G4)
  - `test_searchUsers_returns_results` — call searchUsers("ali"), verify results populated (G5)
  - `test_viewMode_switch` — setViewMode('card'), verify state
- **Dependencies:** U2 (API contract must exist)

### U10. Frontend calendar views (3 views + right panel tab + drawer)

- **Goal:** Three view modes (calendar grid / card / list), right panel tab integration (summary only), expandable drawer (full management), with event source distinction and invited-event styling.
- **Files:**
  - `src/agentkit/server/frontend/src/components/calendar/CalendarPanel.vue` (new — right panel tab, summary view only per KTD-12)
  - `src/agentkit/server/frontend/src/components/calendar/CalendarDrawer.vue` (new — large drawer wrapper, full management per KTD-12)
  - `src/agentkit/server/frontend/src/components/calendar/CalendarGrid.vue` (new — FullCalendar wrapper)
  - `src/agentkit/server/frontend/src/components/calendar/CardView.vue` (new — kanban card view)
  - `src/agentkit/server/frontend/src/components/calendar/ListView.vue` (new — sorted list view)
  - `src/agentkit/server/frontend/src/components/calendar/EventBadge.vue` (new — source distinction badge/icon component — G3)
  - `src/agentkit/server/frontend/src/components/layout/AgentLayout.vue` (modify — add `calendar` to `bottomRightTabs`)
  - `src/agentkit/server/frontend/src/components/layout/tabs/CalendarTab.vue` (new — tab content wrapper)
  - `package.json` (modify — add `@fullcalendar/vue3`, `@fullcalendar/daygrid`, `@fullcalendar/timegrid`, `@fullcalendar/interaction` deps)
- **Patterns:** Right panel tab follows existing `QuadrantPanel` tab pattern (add to `bottomRightTabs`, add named slot). Drawer follows `a-drawer` pattern from `SkillDetail.vue` (but wider, ~80% viewport). FullCalendar initialized in `CalendarGrid.vue` with Vue3 wrapper.
- **Event source distinction (G3, R15):** Each event displays a visual indicator of its origin:
  - `manual` — no badge (default)
  - `agent` — small robot icon (🤖 or `RobotOutlined`) in event card/grid item
  - `post_extract` — small sparkle icon (✨ or `ThunderboltOutlined`) in event card/grid item
  - `EventBadge.vue` component renders the appropriate icon based on `event.source` field, used in CalendarGrid/CardView/ListView
- **Invited event styling (G7, R33):** Events with `is_invited=True` render with a distinct visual treatment:
  - Dashed border instead of solid
  - Subtle background tint (e.g., light blue overlay)
  - "受邀" tag displayed next to title
  - Clicking an invited event opens InvitationManager (respond UI) instead of EventEditor
- **Timezone display (KTD-11):** All times received from API are UTC ISO 8601. Frontend converts to local timezone for display using `dayjs(utcTime).local()` or native `Intl.DateTimeFormat`. No user-facing timezone selector — browser local timezone is used.
- **CalendarPanel (right panel tab — summary only per KTD-12):**
  - Shows today's events + next 3 upcoming events as compact list
  - Each item shows time + title + source badge (G3)
  - "Expand" button opens `CalendarDrawer`
  - Visual highlight animation when new event created (via WS event trigger)
  - Does NOT include view mode switcher, event editor, or full calendar grid — those live in the drawer
- **CalendarDrawer (large drawer — full management per KTD-12):**
  - `a-drawer` with `width="80%"` and `placement="right"`
  - View mode switcher (calendar/card/list) at top
  - Contains `CalendarGrid`, `CardView`, or `ListView` based on selected mode
  - "New Event" button opens `EventEditor` (U11)
  - Invitation management accessible via tab/section (U11)
- **CalendarGrid (FullCalendar):**
  - Month/Week/Day views via FullCalendar
  - Drag-to-create (select time range → open EventEditor)
  - Drag-to-move (drag event to new time → call updateEvent)
  - Events colored by `event_type.color`
  - Recurring events expanded by backend (U2 `list_events` with RRULE expansion) — FullCalendar receives pre-expanded occurrences
  - Invited events (is_invited=True) styled with dashed border class (G7)
  - Styled with Notion Calendar aesthetic: clean borders, ample whitespace, soft colors
- **CardView:**
  - Kanban-style, grouped by date or event type (toggle)
  - Cards draggable between groups (changes date or type)
  - Each card shows source badge (G3) and invited styling (G7)
- **ListView:**
  - Sorted by start_time, supports inline edit of title, batch select with checkbox
  - Source column with badge (G3)
- **Wiring:** `AgentLayout.vue` — add `{ key: 'calendar', label: '日历', icon: CalendarOutlined }` to `bottomRightTabs`, add `<template #calendar><CalendarTab /></template>` slot.
- **Test file:** Manual verification + `npm run typecheck`
- **Test scenarios:**
  - Type check passes
  - Right panel shows calendar tab with today's events (summary only — no grid)
  - Each event in panel shows correct source badge (manual=none, agent=robot, post_extract=sparkle) (G3)
  - Clicking "Expand" opens drawer with 80% width
  - Drawer contains view mode switcher + full calendar grid
  - Calendar grid shows month view by default, can switch to week/day
  - Drag-select time range opens event editor
  - Drag event to new time updates event
  - Invited events show dashed border + "受邀" tag in all views (G7)
  - Clicking invited event opens invitation response UI, not editor (G7)
  - Card view groups by date, cards draggable, source badge visible (G3)
  - List view sorted by time, inline edit works, source column visible (G3)
  - New event from Agent triggers highlight animation in panel
  - Times displayed in browser local timezone (KTD-11)
- **Dependencies:** U9

### U11. Frontend event editor & management UI

- **Goal:** Event create/edit form, drag-and-drop rescheduling, batch operations, invitation management.
- **Files:**
  - `src/agentkit/server/frontend/src/components/calendar/EventEditor.vue` (new — `a-drawer` width 560)
  - `src/agentkit/server/frontend/src/components/calendar/InvitationManager.vue` (new — invitation list + respond buttons)
- **Patterns:** Event editor follows `a-drawer` + form pattern from `SkillDetail.vue`. Form uses Ant Design Vue form components (`a-form`, `a-input`, `a-date-picker`, `a-time-picker`, `a-select`, `a-tag`).
- **EventEditor fields:**
  - Title (a-input, required)
  - Description (a-textarea)
  - Date range (a-range-picker) + time (a-time-picker, hidden if all-day)
  - All-day toggle (a-switch)
  - Location (a-input)
  - Event type (a-select, colored options)
  - Tags (a-select mode="tags", user can create new)
  - Recurrence (a-select: none/daily/weekly/monthly/custom + custom RRULE builder)
  - Reminders (list of offset + channel selectors, add/remove)
- **Batch operations:** In ListView, checkbox multi-select → toolbar with "Delete", "Change Type", "Add Tag" buttons.
- **InvitationManager:** Shows list of invitees with status icons, "Invite" button opens user search dialog (calls `GET /api/v1/calendar/users/search?q=xxx` from U2 — G5/A3), invitee view shows accept/decline/tentative buttons. Also displays incoming invitations (received via `calendar_invitation` WS push from U2 — G6) with response buttons.
- **Test file:** Manual verification + `npm run typecheck`
- **Test scenarios:**
  - Type check passes
  - Create event with all fields, verify saved
  - Edit existing event, verify changes persisted
  - Set recurrence weekly, verify RRULE generated
  - Add 2 reminders (15min client + 1day email), verify saved
  - Batch select 3 events, delete, verify removed
  - Batch select, change type, verify all updated
  - Invite user by email, verify invitation created (G5)
  - User search dialog returns results from `/api/v1/calendar/users/search` (G5)
  - Invitation response (accept) updates status
  - Incoming invitation notification displays when `calendar_invitation` WS received (G6)
- **Dependencies:** U9, U10

### U12. Frontend reminder & external sync settings UI

- **Goal:** Reminder notification display, external calendar connection settings, sync status.
- **Files:**
  - `src/agentkit/server/frontend/src/components/calendar/ReminderConfig.vue` (new — default reminder rules per event type)
  - `src/agentkit/server/frontend/src/components/calendar/SyncSettings.vue` (new — external calendar config)
- **Patterns:** Settings panels follow existing settings component patterns. Notification display uses Ant Design Vue `a-notification` or `a-message` component.
- **ReminderConfig:**
  - Per event type: list of default reminder rules (offset + channels)
  - Add/remove rules, save to backend
  - When new event created with this type, rules auto-inherited
- **SyncSettings:**
  - List of configured external calendars (provider, status, last_sync)
  - "Add Apple Calendar" → form for CalDAV URL + Apple ID + app-specific password
  - "Add Outlook" → OAuth redirect button
  - Per-provider: sync frequency, sync scope (which event types), "Sync Now" button, "Test Connection" button
  - Conflict notifications displayed as alerts when `calendar_sync_conflict` WS message received (G4) — shows event title, provider, conflict details, and resolution taken (last-write-wins)
- **Notification display:**
  - `calendar_reminder` WS message → `a-notification.warning()` with event title + time
  - `calendar_invitation` WS message → `a-notification.info()` with inviter name + event title, link to respond (G6)
  - `calendar_sync_conflict` WS message → `a-alert` in SyncSettings panel showing conflict details (G4)
  - Tauri mode: also trigger system notification via Tauri notification API
- **Test file:** Manual verification + `npm run typecheck`
- **Test scenarios:**
  - Type check passes
  - Add default reminder rule to "会议" type, verify saved
  - Create event of type "会议", verify reminder inherited
  - Add Apple Calendar config, test connection (mock success)
  - Add Outlook config via OAuth (mock redirect)
  - "Sync Now" triggers sync, status updates
  - Reminder WS message displays notification
  - Invitation WS message displays notification with respond link (G6)
  - Conflict WS message displays alert in SyncSettings (G4)
- **Dependencies:** U9, U10

---

## Key Flows

- F1. Agent tool-call creates event
  - **Trigger:** Agent identifies schedule in ReAct loop
  - **Actors:** A2 (Agent), A1 (User)
  - **Steps:** Agent calls `CalendarTool.execute(action="create_event", ...)` → service writes to DB with `source="agent"` → WS push `calendar_event_created` to client → right panel highlights new event → Agent tells user "已创建日程"
  - **Covers:** R10, R13, R37

- F2. Post-processing extraction (keyword-gated)
  - **Trigger:** Conversation turn ends, assistant reply persisted
  - **Actors:** A2 (post-processing layer), A1 (User)
  - **Steps:** `asyncio.create_task(extractor.extract(...))` → regex keyword scan → if no match, return (zero LLM) → if match, LLM extraction → parse JSON → create events with `source="post_extract"` → WS push → insert "检测到 N 条日程" hint in chat
  - **Covers:** R11, R12, R14

- F3. External calendar sync
  - **Trigger:** Scheduled sync task (every N minutes per provider config, run by `SyncManager`) or manual "Sync Now"
  - **Actors:** A3 (external service), A1 (User)
  - **Steps:** `SyncManager.sync(provider)` → `provider.pull_changes()` (fetch remote, match by external_id, create/update local) → `provider.push_changes()` (push local changes to remote) → conflict detection (last_modified comparison) → last-write-wins → `calendar_sync_conflict` WS notification to user (G4)
  - **Covers:** R20, R21, R23

- F4. Reminder dispatch
  - **Trigger:** Scheduler loop (every 60s) finds event entering reminder window
  - **Actors:** A4 (reminder subsystem), A1 (User)
  - **Steps:** Query events where `start_time + offset_minutes` in next 60s → check idempotency (delivery exists?) → create `ReminderDelivery` records → dispatch per channel (client WS / email SMTP / webhook HTTP) → update delivery status → retry on failure (up to 3x)
  - **Covers:** R25, R26, R27, R29

- F5. Manual event creation
  - **Trigger:** User clicks "New" or drag-selects time range in calendar view
  - **Actors:** A1 (User)
  - **Steps:** Open `EventEditor` drawer → fill form (title/time/type/tags/reminders) → save → `calendarApi.createEvent()` → store updates → event appears in view → if external sync configured, async push to external calendar
  - **Covers:** R16, R18

## Dependencies

### New Python dependencies (add to `pyproject.toml`)

| Package | Version | Used by |
|---|---|---|
| `python-dateutil>=2.9` | latest | U1 (RRULE expansion) |
| `icalendar>=6.0` | latest | U6, U7, U8 (ICS format) |
| `caldav>=1.3` | latest | U6 (Apple Calendar sync) |
| `aiosmtplib>=3.0` | latest | U5 (email reminder channel) |

### New Node dependencies (add to `package.json`)

| Package | Used by |
|---|---|
| `@fullcalendar/vue3` | U10 (calendar grid) |
| `@fullcalendar/daygrid` | U10 (month view) |
| `@fullcalendar/timegrid` | U10 (week/day view) |
| `@fullcalendar/interaction` | U10 (drag-and-drop) |

### Existing dependencies reused

- `httpx>=0.27` — Outlook Graph API calls (U7), webhook reminders (U5)
- `aiosqlite>=0.20` — local storage (U1)
- `pydantic>=2.0` — request/response models (U2)
- `fastapi>=0.110` — REST routes (U2)
- `redis>=5.0` — optional cache for sync tokens (U6, U7)

### Sequencing

```
Phase 1 (Foundation):     U1 → U2
Phase 2 (Agent):          U3, U4 (parallel, both depend on U2)
Phase 3 (Reminders):      U5 (depends on U1, U2)
Phase 4 (External sync):  U8 → U6, U7 (U6/U7 parallel, both depend on U8)
Phase 5 (Frontend base):  U9 (depends on U2 API contract)
Phase 6 (Frontend UI):    U10 → U11, U12 (U11/U12 parallel, both depend on U10)
```

Units within the same phase can be developed in parallel. Phases are sequential — each phase's units depend on the prior phase's output.

## Risks and Mitigations

| Risk | Severity | Mitigation |
|---|---|---|
| FullCalendar bundle size impacts frontend load | Medium | Lazy-load calendar components (`defineAsyncComponent`), only load when tab activated |
| CalDAV iCloud requires app-specific password (user friction) | Medium | Document setup steps in UI, provide "How to get app-specific password" link |
| Outlook OAuth flow complexity in Tauri desktop mode | Medium | Use system browser for OAuth (not embedded webview), redirect to localhost callback |
| Post-processing LLM extraction adds latency/cost | Medium | Keyword gate ensures LLM only called when time keywords present; extraction is async, doesn't block chat |
| RRULE edge cases (DST, timezone, Feb 29) | Low | `dateutil.rrule` handles RFC 5545 edge cases; store all times in UTC, convert at display |
| Sync conflict data loss | High | Last-write-wins with conflict notification; never auto-delete, always log |
| Reminder scheduler misses events if server restarts | Medium | On startup, scan for events in reminder window that have no delivery records and dispatch them |

## Scope Boundaries

### In scope (this plan)

- Personal calendar with manual + Agent + post-processing event creation
- Three view modes (calendar grid, card, list)
- Apple Calendar (CalDAV), Outlook (Graph API), iCal/ICS bidirectional sync
- Multi-channel reminder subsystem (client push, email, webhook)
- Event invitations with accept/decline/tentative
- Right panel tab + expandable drawer UX

### Deferred for later

- Google Calendar integration (not selected by user, add CalDAV/Google API provider later)
- Team shared calendars (multi-user co-editing same calendar)
- Calendar analytics/reports (event statistics, time distribution)
- Resource booking (meeting rooms, equipment)
- Mobile native app (current is Web/Tauri only)
- Calendar subscription (read-only iCal feed URL for others to subscribe)

### Outside this product's identity

- Replacing professional calendar applications (Google Calendar, Notion Calendar) — AgentKit calendar's differentiation is Agent auto-detection, not full-featured calendar competition
- Project management (task tracking, Gantt charts, dependency management)

## Implementation History

- **U1-U12 实现完成** (commits `2ea799f`..`394d734`): 12 个实现单元全部交付，104 个单元测试通过，ruff clean，frontend typecheck clean。
- **Intent router 测试修复** (commit `4ea7801`): 修复预存测试失败——关键词匹配平局应保持列表顺序而非字母序。
- **代码走查修复** (commit `3fdee65`): 使用 ce-code-review skill 多代理审查，发现 23 个问题（2 Critical / 15 Major / 6 Minor），全部修复。关键修复包括：RRULE 无限循环防护、邀请/事件类型授权校验、SQLite 外键 PRAGMA、DTSTART 时区 bug、同步循环 since 重置、CalDAV 超时、Outlook token 过期检测、Webhook SSRF 防护、ICS 导入 DoS 限制。
