# Calendar Reminder Alert Lifecycle Protocol

## Version

- Current: `1.0`
- Status: Draft

---

## Goal

定义日程提醒弹窗在 Android/iOS 的统一行为语义，覆盖提醒触发、用户操作、超时、离线补偿、归档与重装恢复，确保多端一致性和可恢复性。

---

## Canonical Rules

1. 提醒弹窗动作语义跨平台一致：`cancel`、`snooze_10m`、`timeout_30s`。
2. `timeout_30s` 语义固定为忽略当前弹窗并按 10 分钟后再次提醒，不直接归档。
3. `cancel` 必须归档对应日程（`status=archived`）。
4. 到达事件结束时间后（`now >= end_at`）必须停止提醒并归档。
5. 归档后的 UI 渲染必须灰色显示；不强制改写原始 metadata 颜色。
6. 前端动作上报后端采用最终一致性：本地 outbox + 重放机制。

---

## Reminder Payload Contract

```json
{
  "eventId": "uuid",
  "title": "string",
  "startAt": "iso8601-with-offset",
  "endAt": "iso8601-with-offset|null",
  "timezone": "IANA",
  "location": "string|null",
  "notes": "string|null",
  "color": "#RRGGBB|null",
  "mode": "single|aggregate",
  "aggregateIds": ["uuid"],
  "version": 1
}
```

### Constraints

- `eventId` 必填且为 UUID。
- `startAt` 必须带时区偏移。
- `mode=aggregate` 时，`aggregateIds` 至少包含 2 个 id。
- `version` 必填，用于后续协议升级兼容。

---

## Action Contract

动作枚举：

- `cancel`: 用户取消提醒并归档事件
- `snooze_10m`: 用户点击稍后提醒，重排到 `now + 10m`
- `timeout_30s`: 用户 30s 未处理，按 `snooze_10m` 处理
- `auto_archive`: 系统判定事件已过期，自动归档

---

## Outbox Contract (Frontend)

```json
{
  "opId": "uuid",
  "eventId": "uuid",
  "action": "cancel|snooze_10m|timeout_30s|auto_archive",
  "targetStatus": "archived|null",
  "occurredAt": "iso8601-with-offset",
  "retryCount": 0,
  "nextRetryAt": "iso8601-with-offset|null",
  "state": "pending|done|dead",
  "lastError": "string|null"
}
```

### Constraints

- 幂等键：`(eventId, action, occurredAtBucket)`。
- 重试策略：指数退避，最大重试次数可配置。
- `cancel` 和 `auto_archive` 都映射到后端 `PATCH status=archived`。
- Outbox 记录必须本地持久化，App 重启后可恢复。

---

## Scheduling and Compensation Rules

### Normal schedule

- `remindAt = startAt - reminderMinutes`

### Bootstrap/reinstall compensation

启动重建时对每个 active 事件执行：

1. `now < remindAt`：按 `remindAt` 正常调度。
2. `remindAt <= now < endAt`：立刻补偿提醒（建议 `+5s`），然后进入 10 分钟节奏。
3. `now >= endAt`：不再提醒，走归档流程。

---

## Uniqueness and Dedupe Rules

- 通知唯一键：`hash(eventId + cycleStartEpochMinutes + mode)`。
- 每次创建提醒前必须取消同 dedupe key 的旧提醒（upsert 语义）。
- 补偿提醒在同一 cycle 窗口内最多触发一次。
- 启动恢复时要同时参考 pending notification 和 outbox 状态，避免重复调度。

---

## Overlap Rule

- 同一分钟内多个提醒合并为一个 aggregate 弹窗。
- aggregate 弹窗默认操作作用于全部成员事件。
- aggregate 负载中必须包含 `aggregateIds` 以支持后续批处理。

---

## Backend Contract Reuse

- 复用现有接口：`PATCH /schedule-items/{item_id}`，请求体传 `{"status":"archived"}`。
- 建议提供/改造 overlap 查询语义用于启动补偿：
  - `start_at <= window_end`
  - `end_at IS NULL OR end_at >= window_start`
  - `status=active`

---

## Platform Notes

### Android

- 优先 full-screen intent；系统可能因策略降级为 heads-up/横幅。
- 声音和振动受通知通道及系统设置影响。

### iOS

- 支持动作按钮与本地提醒语义。
- 不保证 Android 式强制全屏闹钟弹窗；以锁屏/横幅提醒为主。
