Super Productivity 重复任务(Recurring Events)实现方案解析:扁平字段向 RRULE 规范化演进的引擎与同步安全设计
【免费下载链接】super-productivitySuper Productivity is an advanced todo list app with integrated Timeboxing and time tracking capabilities. It also comes with integrations for Jira, GitLab, GitHub and Open Project.项目地址: https://gitcode.com/GitHub_Trending/su/super-productivity
本文是 Super Productivity 仓库中docs/research/recurring-events-implementation-plan.md的设计论证(design rationale)指南。它围绕“重复任务(recurring tasks)的模型与引擎如何演进”这一核心主题展开:从约 14 个互相耦合的扁平字段(repeatCycle、repeatEvery、7 个星期布尔量、monthlyWeekOfMonth等),演进到与 RFC 5545 RRULE 同构的规范化模型,同时保证多端 op-log 同步不破坏、旧客户端兼容不破坏、确定性 ID 不漂移。读完本文,你将掌握该方案的三条核心前提、已废弃的“类型化模型”设计及其仍具约束力的正确性约束、当前已实现的能力清单、Phase 1–3 的落地路径、迁移子系统选型(op-log schema 而非pfapi-config.js),以及 RFC 5545 RRULE 的完整速查。
阅读前置:本计划是
feat/rrule-epic分支的配套文档,而非全新提案。分支上已实现的内容(正向/反向 RRULE 转换器、旧客户端兼容契约、默认关闭的按设备引擎开关)比本文更接近当前事实;如需规划或构建,请先以该分支的 roadmap 为准。文档已在 2026-08-21 与分支做法对齐:分支采用“原始rrule字符串”设计并保留(见下文 Decision 说明)。
1. 三条核心前提(Net Premises)
文档在多次多轴评审后收敛为三条驱动全篇的前提,全部可以对照当前仓库源码验证:
- RRULE 引擎已在仓库中。
ical.js@2.2.1已存在于根package.json(第 294 行),且是devDependency而非运行时依赖;它通过 ical-lazy-loader.ts 懒加载,并在两处实际展开 RRULE:一是 get-relevant-events-from-ical.ts(日历集成时展开 VEVENT 的rrule,见其 403–444 行的iter.next()循环与 EXDATE 过滤),二是packages/plugin-dev/caldav-calendar-provider/src/plugin.ts(CalDAV 提供者插件)。caldav-client.service.ts只借用 ical.js 解析 VTODO,不做RRULE 展开。早期草案建议“不要引入 rrule.js”,这一点后来被 epic 分支推翻(分支固定引入rrule@2.8.1作为引擎),详见 Decision 说明。 - 曾经的“关键缺口”已上线。第 N 个星期几(issue #6040)、月末最后一天(issue #7726)、EXDATE 跳过实例(
deletedInstanceDates)均已实现;早期 gap-analysis 文档已过时并被并入本文附录 A/B。 TaskRepeatCfg是同步状态。模型变更必须走 op-log schema 迁移体系(packages/shared-schema/src/migrations/),保持确定性 IDrpt_${repeatCfgId}_${dueDay}稳定,且不得破坏跨版本同步。这是整个方案风险的主导项。
2. 决策记录(已废弃):类型化、与 RRULE 同构的复发模型
已于 2026-08-21 废弃。现行决策是 epic 分支的:在传统扁平字段旁附加一个
rrule?: string,扁平字段作为线格式永远双写(dual-write)。下文是已废弃的类型化模型方案,保留它是因为其正确性约束(DTSTART/正午锚定、EXDATE 按日字符串匹配、UNTIL包含性、WKST、月度锚点优先级、奇偶校验门、线格式不重命名与迁移警告)对分支的序列化器与引擎同样适用——只是持久化形态不同。
被废弃的设想:把复发模式变成一个单一的、带类型的结构化字段——一个与RFC 5545 一一对应的判别联合(discriminated union),取代约 14 个互相依赖的扁平字段(repeatCycle、repeatEvery、7 个星期布尔量、monthlyWeekOfMonth、monthlyWeekday、monthlyLastDay、quickSetting)。
RFC 5545 的RRULE 字符串只在边界处产生/解析(.ics导出、CalDAV)。原始字符串永远不是持久化/同步字段。
2.1 为什么“拒绝原始字符串”的结论已过时(2026-08-21 调和)
早期修订版从三个角度拒绝把原始rrule字符串作为规范字段。对照feat/rrule-epic实际构建的内容,每个异议都被一个当初未预见的决策回答了:
- “不可查询。”分支是增量式添加
rrule?: string,并且永远双写传统扁平字段——它们是旧客户端的线格式(roadmap “Dual-engine endgame” 第 4 节)。选择器与任何字段级消费方都保有结构化表示,投影时无需解析字符串。 - “不可 diff / 不可修复。”同样靠双写:op-log 仍对传统字段做 diff,
data-repair.ts仍修复它们。对字符串本身,分支用显式策略替代静默损坏:引擎是 fail-soft 的(格式错误的rrule→ 记录日志并返回null,绝不 throw);isRRuleValid门控路由(无效 → 回退传统引擎);在后传统引擎时代,对无效字符串的既定行为是暂停 + 修复提示,绝不静默重新排程。 - “热路径性能。”该异议假设展开引擎是 ical.js(异步、懒加载、仅前向)。分支的引擎是rrule.js(
rrule@2.8.1,精确锁定版本)——同步,放在专用的按日粒度、基于 UTC 的出现日工具(store/rrule-occurrence.util.ts)里,运行在现有同步计算器内部;选择器路径不引入任何异步依赖。
旧推理唯一权衡正确的一点是:rrule是一个新的根运行时依赖,项目规则通常禁止。维护者拥有该 epic,故意接受它并把 rrule.js 当作“引擎”对待——锁定精确版本,并以差异规范测试集作为升级绊线(设备间解析器版本漂移会制造重复任务,见 roadmap 的风险模型)。这一取舍已定,请勿再争论,也不要解除版本锁定。
2.2 对目标的诚实告诫(按此设计)
- “数据模型更小”只是部分的。模式子模型确实从 ~14 个字段塌缩为 1 个类型化
recurrence字段,但TaskRepeatCfg仍保持约 18 个字段(任务模板 + SP 扩展 + 追踪字段不可削减)。真正的收益是不变量消除——判别联合让“字段互相矛盾”变得不可表示,删除了隐式优先级这一类 bug(例如“Nth-weekday 锚点压过monthlyLastDay”),并大幅缩小data-repair.ts。应当推销这一点,而不是字节数。 - “覆盖一切”只有一个例外。RFC 5545 无法表达“完成后的 N 天”,因此
repeatFromCompletionDate(SP 的差异化能力)保持为独立的非 RRULE 表示。模型是“RRULE 同构 + 一个例外”,引擎保留两种模式。
3. 类型化模型(已废弃——保留作为理由记录)
已废弃——见上方 Decision 说明。保留它,是因为下面的不变量分析是对传统字段隐式优先级规则的最佳记录。
该草图把TaskRepeatCfgCopy(task-repeat-cfg.model.ts——请编辑TaskRepeatCfgCopy,而不是Readonly别名)中的扁平模式字段替换为一个判别联合加一个结束条件。草图(最终命名待定):
type Weekday = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU'; type RecurrencePattern = | { freq: 'DAILY'; interval: number } | { freq: 'WEEKLY'; interval: number; byDay: Weekday[] } // WKST derived, never persisted — see 1.3 | { freq: 'MONTHLY'; interval: number; on: { monthDay: number } } // BYMONTHDAY=n | { freq: 'MONTHLY'; interval: number; on: { lastDay: true } } // BYMONTHDAY=-1 | { freq: 'MONTHLY'; interval: number; on: { week: 1 | 2 | 3 | 4 | -1; day: Weekday } } // BYDAY=nDD | { freq: 'YEARLY'; interval: number; month: number; day: number }; type RecurrenceEnd = | { type: 'never' } | { type: 'count'; count: number } // COUNT | { type: 'until'; until: string }; // UNTIL — DbDateStr, inclusive end-of-day interface RecurrenceConfigPart { // canonical, RRULE-isomorphic, persisted/synced: recurrence: RecurrencePattern; end: RecurrenceEnd; deletedInstanceDates: string[]; // wire name kept verbatim — `exDates` would be exactly the forbidden rename // SP carve-out — not expressible in RFC 5545: repeatFromCompletionDate?: boolean; }- 判别符是
freq(加上月度的on形态)。非法组合(例如在年度配置上设置星期布尔量)变得不可表示。 - 不持久化任何派生字段。UI 表现物(星期复选框行、“Ends”控件、
quickSetting)在表单打开时由recurrence/end计算、保存时写回——纯视图模型。这化解了文档记录的 formly “整模型 emit” 陷阱(不再有第二个表示可漂移)。 repeatFromCompletionDate选择例外引擎(见下文)。
4. 已修正的当前状态(已上线能力)
在src/app/features/task-repeat-cfg/中验证:
| 能力 | 状态 | 位置 |
|---|---|---|
每日 / 每周 / 每月 / 每年 +repeatEvery间隔 | ✅ | get-next-repeat-occurrence.util.ts |
| 星期选择(每周) | ✅ | 7 个布尔量,task-repeat-cfg.model.ts |
| 每月第 N 个星期几(“第 2 个周二”、“最后一个周五”) | ✅ #6040 | monthlyWeekOfMonth+monthlyWeekday;get-nth-weekday-of-month.util.ts |
| 每月最后一天 | ✅ #7726 | monthlyLastDay;get-next-repeat-occurrence.util.ts 中的月末钳制 |
| 每月第一天 | ✅ | 快捷设置MONTHLY_FIRST_DAY |
| 跳过某次出现(EXDATE) | ✅ | deletedInstanceDates: string[] |
| 完成后复发 | ✅(SP 独有) | repeatFromCompletionDate+ get-effective-repeat-start-date.util.ts |
| 等待完成(不堆积) | ✅ | waitForCompletion |
| 跳过逾期实例 | ✅ | skipOverdue |
| 暂停 / 恢复 | ✅ | isPaused |
| 子任务模板(+ 继承 / 自动更新标志) | ✅ | subTaskTemplates、shouldInheritSubtasks、disableAutoUpdateSubtasks |
| DST 安全计算 | ✅ | 全程本地正午锚定(见 1.3) |
| 确定性多设备 ID | ✅ | rpt_${repeatCfgId}_${dueDay},get-repeatable-task-id.util.ts |
| 人类可读描述 | ✅ | get-task-repeat-info-text.util.ts |
| “下次到期”预览 + 历史热力图 | ✅ | repeat-cfg-preview/、repeat-task-heatmap/ |
真正缺失(Phase 3 交付):结束条件(COUNT/UNTIL)、每月多日(BYMONTHDAY=1,15)、.ics/CalDAV RRULE 生成(Phase 1)。延迟 / YAGNI:RDATE、RECURRENCE-ID、BYWEEKNO、BYYEARDAY、亚日频率、完整双向.ics导入。BYSETPOS:仅序列化器的月末钳制惯用法需要(见 Phase-1 映射表);一般引擎侧展开仍延迟。
5. 引擎决策:保留同步有界引擎
已调和:对传统/开关关闭的配置成立;开关打开的
rrule配置路由到分支的同步 rrule.js 引擎(见 2.1)。ical.js 无论哪种方式都只在边界使用——下文记录原因,且仍然成立。
出现日运行时保持现有的同步有界循环(get-next-repeat-occurrence.util.ts、get-newest-possible-due-date.util.ts),改为读取新的类型化recurrence字段而非扁平字段。ical.js仅用于在导出/CalDAV 边界序列化/解析 RRULE 字符串——绝不出现在出现日热路径上。
后果:
- 同步选择器中不引入懒加载模块的异步依赖;引导/投影路径上没有约 76 KB 的开销(ical-lazy-loader.ts 的注释明确记录 ical.js 约 76 KB)。
- 出现日逻辑几乎不变(同样的
FREQ/INTERVAL/BYxxx数学,只是输入形状不同),因此确定性 ID 的奇偶风险很小,离线 golden-master 测试就足够——不需要生产影子模式。 - 新的常见模式(每月多日、结束条件)是对有界引擎的小扩展。奇异 RRULE 部件(通用
BYSETPOS、BYWEEKNO)并不免费;延迟它们,若确有需要,就通过 ical.js 在热路径之外扩展这些稀有配置。
6. Phase 1 —— 类型化模型 + RRULE 序列化器 + 奇偶校验架
可独立交付;序列化器解锁日历双向同步路线图中的“SP 不生成 RRULE”关键路径项。
6.1 添加类型化recurrence/end字段(增量式,尚非规范)
在现有字段旁添加联合。由于校验使用 typiacreateValidate(容忍多余属性,而非createValidateEquals——在validation-fn.ts中验证),旧客户端读取新字段时既不会拒绝也不会剥离它们——结构性前向兼容。确认data-repair.ts没有任何 pass 会删除它们,并添加前向兼容回归规格。
6.2 双向序列化器(类型化 ⇄ RRULE 字符串)
纯模块(例如task-repeat-cfg/rrule/)。typed → RRULE是简单字符串组装(或ICAL.Recur.fromData({...}).toString());RRULE → typed(用于.ics导入)使用 ical.js 解析。字段映射——必须覆盖一切:
| 类型化模型 | RRULE |
|---|---|
{freq, interval} | FREQ=...;INTERVAL=... |
WEEKLYbyDay | BYDAY=MO,WE,...(WKST:见 6.3) |
MONTHLYon.monthDay | BYMONTHDAY=<n>(n ≤ 28);29–31 → 钳制惯用法BYMONTHDAY=<n>,-1;BYSETPOS=1(SP 钳制到月末,纯BYMONTHDAY会跳过) |
MONTHLYon.lastDay | BYMONTHDAY=-1 |
MONTHLYon.{week,day} | BYDAY=<week><DD>(-1=最后) |
YEARLY{month, day} | BYMONTH=<m>;BYMONTHDAY=<d>;2 月 29 日 → 同一钳制惯用法(BYMONTH=2;BYMONTHDAY=29,-1;BYSETPOS=1,非闰年钳到 2 月 28 日) |
end.count/end.until | COUNT=/UNTIL=(UTC 当日结束) |
deletedInstanceDates | EXDATE(仅导出;线字段名保持原样) |
repeatFromCompletionDate | 不可表达—— 序列化器拒绝/标记;此类配置天然不兼容导出 |
startTime、remindAt、waitForCompletion、skipOverdue、子任务标志、order | SP 扩展,在 RRULE 带外 —— 保留 |
isPaused | 无 RRULE 等价物 —— 序列化器跳过或标记暂停配置(见下) |
lastTaskCreationDay/lastTaskCreation | 内部创建游标,在 RRULE 带外 —— 永不导出,也永不由导入的规则派生 |
isPaused会短路所有出现日生成(task-repeat-cfg.selectors.ts 中两个选择器都先过滤isPaused),因此把暂停配置导出为活跃 RRULE 会承诺 SP 永远不会创建的出现日。
三行 MONTHLY并非独立——引擎按固定优先级解析一个锚点:nth-weekday(monthlyWeekOfMonth+monthlyWeekday,在 get-next-repeat-occurrence.util.ts 首先检查)→monthlyLastDay→startDate的日序。逐行序列化对携带多个锚点的配置发出多个部件,会产生BYMONTHDAY=-1;BYDAY=2TU——在 RFC 5545 中是交集,通常是空集。必须只序列化一个锚点,按上述优先级顺序(epic 分支的legacyTaskRepeatCfgToRRuleswitch 正是这样做的)。
6.3 DTSTART / 日期基准正确性(最容易咬人的部分)
- DTSTART 是锚定日的本地正午,去掉时间分量。传统引擎做日期数学时从不使用
startTime——它在setHours(12,…)处锚定(get-next-repeat-occurrence.util.ts)。非正午的 DTSTART 会让 ical.js 在另一个瞬时产生出现日,可能跨日/DST 边界滚到不同的日历日→ 奇偶被破坏、ID 偏移。startTime保持为展开后的任务模板字段,不参与 DTSTART 日期数学。 - EXDATE 按日字符串匹配,而非瞬时相等:用
getDbDateStr(occurrence)对照exDates过滤生成的出现日。 UNTIL是包含当日的结束。WKST= 生效 startDate 的星期——或干脆不发出WKST并重新锚定 DTSTART,正如 epic 分支的getAlignedStartDate。不是用户的firstDayOfWeek:该设置仅用于显示(src/app/core/date-time-format/custom-date-adapter.ts:19)。引擎按从 startDate 星期起算的滚动 7 天块计数(getDiffInWeeks(startDate, d) % repeatEvery,get-next-repeat-occurrence.util.ts),因此日历周的WKST会移动双周(INTERVAL=2)出现日。反例:startDate 为周三 2026-01-07、INTERVAL=2、BYDAY=MO,FR——SP 触发周一 01-12;WKST=MO触发周一 01-19。
6.4 出现日奇偶 golden master(闸门)
差分测试架:读取类型化字段的引擎,与读取扁平字段的现有引擎产生逐字节一致的出现日,覆盖每种配置形态(每日、每周多日、repeatEvery>1、按日每月、每月第 N 个星期几、每月最后一天、每年、2 月 29 日),跨多年窗口、两个 CI 时区、跨越 DST 边界。限制每种形态的出现次数上限,防止以后加入亚日频率时测试爆炸。基于完成的配置超出测试架范围(不同引擎)。迁移以 100% 奇偶为门槛。序列化器往返(typed → RRULE → typed)做属性测试。
7. Phase 2 —— 带版本迁移(经 op-log schema 体系)
已修正机制。不是
pfapi-config.js——该文件是@deprecated LEGACY CODE(其CROSS_MODEL_VERSION是过时的4.4,且它require的./migrate/cross-model-migrations路径已不存在)。
经由活跃的 op-log schema 体系迁移:
在
packages/shared-schema/src/migrations/中添加vN → vN+1条目(注册表 index.ts),同时提供migrateState(快照)与migrateOperation(在途操作),并提升CURRENT_SCHEMA_VERSION(schema-version.ts)。由src/app/op-log/persistence/schema-migration.service.ts/remote-ops-processing.service.ts应用。转换本身是纯 O(1) 的字符串/结构组装,即使配置很多也很便宜;迁移不得按配置展开出现日。跨版本叙事(解决旧客户端矛盾)。2026-08 修正——见 #9664。早期修订把
MIN_SUPPORTED_SCHEMA_VERSION称为“强制更新闸门”,会推动旧客户端走VERSION_UNSUPPORTED流程。那是反的。它是施加于_本客户端读取的数据_上的下限(remote-ops-processing.service.ts:177、operation-log-sync.service.ts:2318、verify-decrypted-op-integrity.ts:139、migrate.ts:49,115);发送方只盖CURRENT_SCHEMA_VERSION章,且不存在服务端客户端版本闸门。提升它会把已更新的客户端卡死——阻塞发生在上传前的周期内(sync-wrapper.service.ts:595-606),游标不前进,而VERSION_UNSUPPORTED提示故意不带任何补救措施(remote-ops-processing.service.ts:540-546,“更新本设备无济于事”)。它永远不会提示旧设备。这里没有版本闸门。而且
CURRENT_SCHEMA_VERSION的提升也不能为_当前已发布_的设备群提供闸门:master 是 4,提升落到 5——在 v17.0.0–v18.14.0 的容忍带(2 + 3)内——这些客户端会未迁移地应用操作;提升到 6 会阻塞它们,但仍推进其游标,永久跳过这些操作。(v18.14.0 之后的接收端_确实_会安全阻塞,因此一旦该群体淘汰,提升就会成为真正的围栏。)参见 schema-version.ts 与 operation-log-architecture.md §A.7.11 的 Bump Policy 完整说明。真正解决问题的机制已经在
feat/rrule-epic上建好了——不要重新推导。传统排程字段与新表示一起保持填充,作为旧客户端的线格式(util/legacy-cfg-to-rrule.util.ts),遵循精确或空(exact-or-null)契约:规则在传统表达能力范围内时,传统字段在相同日期触发;超出范围时(COUNT/UNTIL、季节性BYMONTH、BYWEEKNO/BYYEARDAY、多日列表、联合外序数),写入LEGACY_NEVER_FIRES_FALLBACK哨兵——一个全 false 的WEEKLY配置,每个已发布版本都确定性地永不触发。旧客户端什么也不创建,而不是在错误日期创建会同步回传的任务。getAlignedStartDate处理startDate的双重职责——既作为月/年日序编码,又作为间隔锚点。默认关闭的按设备开关(RRuleFeatureFlagService、localStorage、永不同步)在 epic 未完成时保持传统引擎权威——是那个开关,而不是 schema 闸门,让半成品阶段变安全。绝不在线格式上重命名
deletedInstanceDates。保持同步字段名;exDates是内存/类型化名称,EXDATE是导出名称。在整实体 LWW 下,赢得冲突的旧客户端会在没有重命名字段的情况下重新发出实体,从而把跳过列表在设备群内销毁;部分更新浅合并路径是第二个销毁向量。(如果更偏好保留字面属性名,也可以那样做——要点是:不要重命名持久化键。)
8. Phase 3 —— RRULE 原生功能
类型化模型成为规范后,新模式 = 类型化联合的新增 + 有界引擎的小扩展:
- 结束条件。
end: {type:'count'|'until'},在出现日循环中作为守卫执行(越过边界返回null)。UI “Ends” 控件由end派生,不多持久化任何东西。标签经T/TranslateService(仅en.json)。 - 每月多日(
BYMONTHDAY=1,15)等,随模型/引擎成长。
Phase 3 测试
- 无超过
COUNT/UNTIL的出现日,每种频率、两个 CI 时区。 - 决定并测试
COUNTvsexDates:被跳过的实例是否消耗一个计数?(ical.js 在 EXDATE 之前计数;SP 在生成后过滤——刻意选择“10 个实际任务” vs “10 个计划”,并测试之。) UNTIL边界:结束日包含,次日排除。
9.repeatFromCompletionDate例外
这不是一个独立的_引擎_——它运行同样的FREQ/INTERVAL计算,但每个周期把开始日期重新锚定到lastTaskCreationDay(见 get-effective-repeat-start-date.util.ts 的源码实现:当repeatFromCompletionDate为真且有lastTaskCreationDay时返回后者)。所以真正的风险是喂入错误的 DTSTART/锚点,而不是“错误的引擎”:
- 它没有稳定的 DTSTART→ 不可 RRULE 表达;序列化器拒绝它(天然不兼容导出)。
- 建模为联合变体 +
repeatFromCompletionDate标志;在任何固定锚点路径之前路由到动态锚定计算。 - 诚实的代码削减核算:只有固定排程的管道被删除;完成路径保留。
10. 风险登记表
| 风险 | 严重度 | 缓解 |
|---|---|---|
| 模型切换导致出现日偏移 → 实例被重新编号 | 阻断 | 保留现有有界引擎;Phase-1 golden master 门控迁移 |
| 跨版本同步:旧客户端读不懂类型化模型 | 阻断 | 不存在版本闸门(#9664)。线格式上按精确或空契约保持传统字段填充;不可表达规则用LEGACY_NEVER_FIRES_FALLBACK;按设备开关门控 epic |
重命名deletedInstanceDates线键导致跳过数据丢失 | 高 | 不重命名持久化键;EXDATE仅用于导出 |
repeatFromCompletionDate被喂入固定 DTSTART → 变成固定日历 | 高 | 在任何固定锚点路径之前路由完成模式;每周期重新锚定 |
错误迁移子系统(pfapi-config.js) | 高 | 使用packages/shared-schema迁移 +schema-migration.service.ts |
| 异步/仅前向 ical.js 迭代导致热路径回归 | 高 | ical.js 仅做字符串解析/序列化;同步有界引擎保持为运行时 |
DTSTART 携带startTime→ 日序滚动 | 高 | DTSTART = 锚定日本地正午;startTime在展开后应用 |
| EXDATE 永不匹配(瞬时 vs 正午) | 中 | 按getDbDateStr日字符串过滤 |
| 双周偏移(WKST 默认值) | 中 | startDate 派生的 WKST,或省略并重新锚定——见 6.3 |
UNTIL丢掉最后一天 | 中 | 包含当日结束 |
| n/a | 不需要——引擎不变;离线 golden master 覆盖奇偶 | |
| n/a | 类型化模型方案无新依赖(epic 确实添加了锁定版rrule——见 Decision 说明) |
11. 可度量的成功标准(闸门)
- Phase-1 奇偶:类型化字段引擎 == 扁平字段引擎,覆盖完整配置形态语料,5 年窗口、两个 CI 时区;测试架在 CI 中绿灯。
- 序列化器往返
typed → RRULE → typed,覆盖每种形态(属性测试),包括每月第 N 个星期几与最后一天。 - 无新运行时依赖(仅 ical.js,边界使用)。(已废弃——epic 故意添加锁定版
rrule;见 Decision 说明。) - 无同步字段键被重命名(
deletedInstanceDates保持线格式)。 - 前向兼容:读取新字段的旧客户端既不报错也不剥离它们(回归规格)。
- 迁移后,
repeatFromCompletionDate、waitForCompletion、skipOverdue、子任务模板与跳过列表行为不变(回归规格绿灯)。 - Phase-3 结束条件带测试在两个 CI 时区通过;UI 结束状态是派生的,而非持久化的。
12. 涉及文件(已验证路径)
| 领域 | 文件 |
|---|---|
| 模型 | task-repeat-cfg.model.ts(TaskRepeatCfgCopy) |
| 出现日引擎(保留,重指向类型化字段) | store/get-next-repeat-occurrence.util.ts、store/get-newest-possible-due-date.util.ts、store/get-first-repeat-occurrence.util.ts、store/get-nth-weekday-of-month.util.ts、store/get-effective-repeat-start-date.util.ts、store/get-effective-last-task-creation-day.util.ts |
| 确定性 ID(必须保持稳定) | get-repeatable-task-id.util.ts |
| 选择器 / 投影 | task-repeat-cfg.selectors.ts |
| 服务 / 创建 | task-repeat-cfg.service.ts |
| 快捷设置 / 对话框 UI | dialog-edit-task-repeat-cfg/(表单常量、快捷设置更新、构建选项) |
| 人类可读文本 | src/app/features/tasks/task-detail-panel/get-task-repeat-info-text.util.ts |
| RRULE 序列化/解析(仅边界) | ical-lazy-loader.ts(复用加载器) |
| 迁移(已修正) | packages/shared-schema/src/migrations/index.ts(+index.ts)、schema-version.ts(CURRENT_SCHEMA_VERSION——注意MIN_SUPPORTED_SCHEMA_VERSION_不是_跨版本闸门,#9664)、src/app/op-log/persistence/schema-migration.service.ts |
旧客户端线兼容(已在feat/rrule-epic上建成) | src/app/features/task-repeat-cfg/util/legacy-cfg-to-rrule.util.ts(legacyTaskRepeatCfgToRRule、rruleToLegacyTaskRepeatCfg、LEGACY_NEVER_FIRES_FALLBACK、getAlignedStartDate)、src/app/features/config/rrule-feature-flag.service.ts |
| 校验 / 修复 | src/app/op-log/validation/(createValidate、data-repair.ts) |
日历写入边界(注意:早于 #6040/#7726/deletedInstanceDates) | ARCHITECTURE-DECISIONS.md 第 9 条——日历写入位于插件中、按提供者 opt-in。CalDAV VEVENT 展开已随caldav-calendar-provider插件上线;单次出现编辑(RECURRENCE-ID/EXDATE)仍未解决,#8148 |
附录 A —— 竞品对比
“用户期待什么”的参考(SP 列为 2026-06 时点验证;剩余 ❌ 才是真正的目标——结束条件)。由原recurring-events-gap-analysis.md/recurring-events-industry-standards.md合并而来。
| 功能 | Google Calendar | Todoist | Things 3 | TickTick | Super Productivity |
|---|---|---|---|---|---|
| 基础(日/周/月/年) | ✅ | ✅ | ✅ | ✅ | ✅ |
| 每 N 间隔 | ✅ | ✅ | ✅ | ✅ | ✅ |
| 星期选择 | ✅ | ✅ | ✅ | ✅ | ✅ |
| 每月第 N 个星期几 | ✅ | ✅ | ✅ | ✅ | ✅(#6040) |
| 每月最后一天 | ✅ | ✅ | ✅ | ✅ | ✅(#7726) |
| N 次后结束 | ✅ | ❌ | ❌ | ✅ | ❌(Phase 3) |
| 指定日期结束 | ✅ | ❌ | ❌ | ✅ | ❌(Phase 3) |
| 完成后复发 | ❌ | ✅ | ✅ | ✅ | ✅(例外) |
| 跳过某次出现 | ✅ | ✅ | ✅ | ✅ | ✅ |
| 自然语言 | ✅ | ✅ | ❌ | ❌ | ✅(信息文本) |
| iCal 导出 | ✅ | ✅ | ❌ | ✅ | ❌(Phase 1) |
附录 B —— RFC 5545 RRULE 参考
iCalendar 规范(RFC 5545)的RRULE属性是类型化模型镜像、序列化器瞄准的复发标准。
核心部件
| 参数 | 含义 | 取值 |
|---|---|---|
| FREQ(必填) | 频率 | YEARLY、MONTHLY、WEEKLY、DAILY、HOURLY、MINUTELY、SECONDLY |
| INTERVAL | 迭代间隔 | 正整数(默认 1) |
| COUNT | 出现次数 | 正整数 |
| UNTIL | 结束日期(时间) | DATE 或 DATE-TIME |
| WKST | 周起始日 | MO…SU(默认MO) |
BYxxx 部件
| 参数 | 含义 | 取值 |
|---|---|---|
| BYDAY | 星期几 | MO…SU,可选序数前缀(2TU、-1FR) |
| BYMONTH | 月份 | 1–12 |
| BYMONTHDAY | 月内日序 | 1..31 或 -31..-1(负值 = 从月末倒数) |
| BYYEARDAY | 年内日序 | 1..366 / -366..-1 |
| BYWEEKNO | ISO 周号 | 1..53 / -53..-1 |
| BYSETPOS | 集合内位置 | 1..366 / -366..-1 |
BYDAY序数前缀:1MO/+1MO= 第一个周一,-1MO= 最后一个周一,2TU= 第二个周二。
示例
FREQ=DAILY;COUNT=10 # 每天,共 10 次 FREQ=WEEKLY;UNTIL=20241231T235959Z;BYDAY=MO,FR FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR # 每隔一周,周一/三/五 FREQ=MONTHLY;BYMONTHDAY=15 # 每月 15 日 FREQ=MONTHLY;BYMONTHDAY=-1 # 每月最后一天 FREQ=MONTHLY;BYDAY=2TU # 每月第二个周二 FREQ=MONTHLY;BYDAY=-1FR # 每月最后一个周五例外:EXDATE排除出现日(= SP 的deletedInstanceDates);RDATE增加出现日(已延迟——见“真正缺失”)。
【免费下载链接】super-productivitySuper Productivity is an advanced todo list app with integrated Timeboxing and time tracking capabilities. It also comes with integrations for Jira, GitLab, GitHub and Open Project.项目地址: https://gitcode.com/GitHub_Trending/su/super-productivity
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考