Files
timeTableFix/docs/organize-algorithm.md
zikai 037b70deec feat: 自动保存/移除撤销与红点, 间隔合并改进(DAILY/稀疏/交错拆分), 删死代码, 更新测试与文档
- organize: seriesKey 移除 weekday, 解锁每3天等 DAILY 序列合并
- organize: fitSeries 放宽阈值(稀疏序列如 BUS LAW Workshop 合并)
- organize: 新增 splitIntoChains, GCD=1 的交错多班次拆为多个系列
- useCalendar: 移除 dirty/history/undo/snapshot/markSaved 死代码
- HeaderBar: 移除撤销按钮与红点●, 下载兜底序列化当前状态
- 测试: 42 项(原32), 覆盖 DAILY/稀疏/交错拆分/两两和候选等场景
- 文档: README 精简, 新增 docs/organize-algorithm.md 与 auto-save-design.md
- 保留移动端 CSS 适配
2026-07-22 06:32:47 +00:00

6.8 KiB
Raw Blame History

间隔合并与链拆分算法

本文档记录 src/lib/organize.js 的算法设计、关键决策与踩过的坑。 测试用例见 test/organize.test.js(含黄金用例 golden-org.ics)。

目标

把导出为 N 个扁平单次事件的重复日程,合并为带 RRULE + EXDATE 的 重复事件。需要覆盖:每周、每两周、每 N 天,以及「稀疏但有规律」(如 约每 3 周一次的 workshop和「交错的多班次」同名同时但分属周一/周二)。

算法分层

1. 分组(seriesKey

键 = [summary, location, dtstartTime, durationMin].join('|')

关键决策:键不含星期。 早期版本键含 weekday = getDay(),导致周期 为非 7 倍数(如每 3 天)的 DAILY 序列落在不同星期,被拆成单事件组, 永远到不了 fitSeries——这是「间隔 3 天无法合并」的根因。移除后, WEEKLY 序列(间隔 7 倍数weekday 本就相同仍归一组DAILY 序列也 能归一组,交给 fitSeries 判断。星期改由 fitSeries 内的 byday 从首日期推导(仅 WEEKLY 有意义DAILY 设 null)。

时长(分钟)保留在键中,不同时长不算同系列。

2. 单一网格拟合(fitSeries

对组内事件:

  1. 求所有相邻 gap天数的 GCD作为「最小公约间隔」d——使每个 gap 均为其整数倍的最小正整数。例如 gap [7,7,14,7] -> d=7(中间跳过 一周14=2×7[3,3,3] -> d=3[21,28] -> d=7
  2. 从首日期到末日期按 d 步进构建期望网格。
  3. missing = 网格上但 absent 的日期;offGrid = present 但不在网格 上的日期。
  4. offGrid 非空 -> 该组不是单一周期序列,返回 null(交由链拆分)。
  5. 否则合并:d 是 7 的倍数 -> WEEKLYbyday 从首日期推导), 否则 -> DAILYbyday=nullexdates = missing

阈值放宽:合并条件仅为 evs.length >= 2offGrid 为空。不再 因「太稀疏」(missing >= present)而拒绝——稀疏但有规律的序列(如 BUS LAW Workshop3 个事件跨 49 天gap 21/28d=7同样合并为 WEEKLY + 5 个 EXDATE。

3. 链拆分(splitIntoChains

仅在 fitSeries 返回 null 时调用,即该组无法落在单一 GCD 网格上。 典型场景:同名同时但分属周一/周二的两个班,排序后 gap 交替为 1,6,1,6,1,6GCD=1。

触发条件:组级 GCD=1(相邻 gap 含互质值,即交错)。fitSeriesinterval === 1 时直接返回 null,交由拆分处理。为何 GCD=1 要拆: d=1 的网格逐日命中所有日期,恒产生单链,把所有不在同子序列的日期 当 EXDATE是伪合并如把周一+周二两班误合成一个 DAILY;INTERVAL=1)。

候选间隔 d:相邻 gap 的两两之和(去重,降序)。仅用两两之和而 非单个 gap因为交错子序列的「偏移」如 1 或 2比真实周期短拿单个 gap 当周期会得到过密的伪网格。此函数仅在 GCD=1交错时调用故两两 之和总是能给出真实周期:

  • gap 1,6 -> 和 7(每周)
  • gap 2,5 -> 和 7(每周)

网格抽取:对每个候选 d,从最早未分配日期起按 d 步进到最末日期, 收集命中的 present 日期(允许跳过周期——缺席的网格点日后记为 EXDATE形成一条链剩余未分配日期重复抽取得若干链。

择优:选产生链数最少d(最大化分组);所有链长度须 ≥2 否则该 d 不合格(单点链说明 d 不适配)。链数相同取较小 d

兜底:无候选 d 能给出全 ≥2 链时,退化为每日期一链(全部保留为 独立事件)。例如仅 2 个相邻同名事件gap 1无两两之和候选

4. organize 总流程

  1. seriesKey 分组。
  2. 每组调 organizeGroup:先 fitSeries;成功则输出 1 个合并事件; 失败则 splitIntoChains 后对每条链独立 fitSeries,链长 <2 或无 规律的保留为独立事件。
  3. 合并后事件保留 base 的原始 UID不重新分配避免重新导入冲突、 保证幂等。

行为对照表

序列 相邻 gap 结果
ACC INFO SYSgolden 7×7,14,7×3 WEEKLY;1 + 1 exdate跳过 9-21
每 3 天 3,3,3,3 DAILY;3
每两周 14,14,14 WEEKLY;2
BUS LAW Workshopgolden 21,28 WEEKLY;1 + 5 exdates
周一+周二两班(同名同时) 1,6,1,6,1,6 拆成 2 个 WEEKLY;1MO / TU
两班各暂停一周gap 2,5,2,5,2,12,2,5,2,5 交错 拆成 2 个 WEEKLY;1各带 1 exdate
2 个相邻同名事件 1 保留 2 个独立事件(无候选)

踩过的坑

坑 1weekday 分组拆碎 DAILY 序列

最初 seriesKeyweekday,每 3 天的事件落在 5 个不同星期,被拆成 5 个单事件组,fitSeries 永远收不到 ≥2 事件。直接调 fitSeries 能 合并,但 organize 的分组步骤先把它拆碎了。根因在分组键,不在 fitSeries 修复:键移除 weekday。

坑 2GCD=1 的平凡网格伪合并

移除 weekday 后,周一+周二两班gap 1,6,1,6GCD=1d=1 网格逐日 命中所有日期offGrid 恒为空,被误合并成一个 DAILY;INTERVAL=1 + 大量 EXDATE。这是「present>0 即合并」与「拆分交错序列」的直接冲突。 修复:fitSeriesinterval===1 时拒绝(交由链拆分)。

坑 3候选 d 选错导致伪链

链拆分最初用「所有日期两两之差」作候选,并按链数最少择优。结果对 gap 2,5,2,5,... 的用例,d=2d=7 都产生 2 条链,平局取较小 d 选了 d=2,得到错误的 DAILY;INTERVAL=2 + 大量 EXDATE 分组。d=2 是交错偏移而非周期。修复:候选改为相邻 gap 两两之和(偏移相加得 真实周期),排除单个 gap。

坑 4BUS LAW Workshop 从「3 flat」变「1 recurring」

原阈值 missing >= present 拒绝了 BUS LAW Workshop5 missing ≥ 3 present保留为 3 个独立事件。放宽阈值后合并为 1 个 WEEKLY + 5 exdates。这是预期行为变化golden 测试相应更新61 事件 -> 6 系列, 原为 8 = 5 系列 + 3 flat

决策记录:为何选「最小公约间隔 + EXDATE」而非「严格相等 gap」

严格相等 gap所有相邻 gap 必须完全一致才合并)会拒绝 ACC INFO SYS gap 含一个 14其余 7破坏现有 golden 合并。最小公约间隔 + EXDATE 允许「跳过周期」gap 是 d 的倍数),既保留 ACC INFO SYS 的合并,又 支持稀疏序列BUS LAW Workshop与 DAILY 序列(每 3 天)。交错的多个 序列则由链拆分处理。三者用同一算法兼得。