- 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 适配
6.8 KiB
间隔合并与链拆分算法
本文档记录 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)
对组内事件:
- 求所有相邻 gap(天数)的 GCD,作为「最小公约间隔」
d——使每个 gap 均为其整数倍的最小正整数。例如 gap[7,7,14,7] -> d=7(中间跳过 一周,14=2×7);[3,3,3] -> d=3;[21,28] -> d=7。 - 从首日期到末日期按
d步进构建期望网格。 missing= 网格上但 absent 的日期;offGrid= present 但不在网格 上的日期。offGrid非空 -> 该组不是单一周期序列,返回null(交由链拆分)。- 否则合并:
d是 7 的倍数 ->WEEKLY(byday从首日期推导), 否则 ->DAILY(byday=null);exdates = missing。
阈值放宽:合并条件仅为 evs.length >= 2 且 offGrid 为空。不再
因「太稀疏」(missing >= present)而拒绝——稀疏但有规律的序列(如
BUS LAW Workshop,3 个事件跨 49 天,gap 21/28,d=7)同样合并为
WEEKLY + 5 个 EXDATE。
3. 链拆分(splitIntoChains)
仅在 fitSeries 返回 null 时调用,即该组无法落在单一 GCD 网格上。
典型场景:同名同时但分属周一/周二的两个班,排序后 gap 交替为
1,6,1,6,1,6,GCD=1。
触发条件:组级 GCD=1(相邻 gap 含互质值,即交错)。fitSeries 在
interval === 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 总流程
- 按
seriesKey分组。 - 每组调
organizeGroup:先fitSeries;成功则输出 1 个合并事件; 失败则splitIntoChains后对每条链独立fitSeries,链长 <2 或无 规律的保留为独立事件。 - 合并后事件保留 base 的原始 UID(不重新分配),避免重新导入冲突、 保证幂等。
行为对照表
| 序列 | 相邻 gap | 结果 |
|---|---|---|
| ACC INFO SYS(golden) | 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 Workshop(golden) | 21,28 | WEEKLY;1 + 5 exdates |
| 周一+周二两班(同名同时) | 1,6,1,6,1,6 | 拆成 2 个 WEEKLY;1(MO / TU) |
| 两班各暂停一周(gap 2,5,2,5,2,12,2,5,2,5) | 交错 | 拆成 2 个 WEEKLY;1,各带 1 exdate |
| 2 个相邻同名事件 | 1 | 保留 2 个独立事件(无候选) |
踩过的坑
坑 1:weekday 分组拆碎 DAILY 序列
最初 seriesKey 含 weekday,每 3 天的事件落在 5 个不同星期,被拆成
5 个单事件组,fitSeries 永远收不到 ≥2 事件。直接调 fitSeries 能
合并,但 organize 的分组步骤先把它拆碎了。根因在分组键,不在
fitSeries。 修复:键移除 weekday。
坑 2:GCD=1 的平凡网格伪合并
移除 weekday 后,周一+周二两班(gap 1,6,1,6)GCD=1,d=1 网格逐日
命中所有日期,offGrid 恒为空,被误合并成一个 DAILY;INTERVAL=1 +
大量 EXDATE。这是「present>0 即合并」与「拆分交错序列」的直接冲突。
修复:fitSeries 在 interval===1 时拒绝(交由链拆分)。
坑 3:候选 d 选错导致伪链
链拆分最初用「所有日期两两之差」作候选,并按链数最少择优。结果对
gap 2,5,2,5,... 的用例,d=2 与 d=7 都产生 2 条链,平局取较小 d
选了 d=2,得到错误的 DAILY;INTERVAL=2 + 大量 EXDATE 分组。d=2
是交错偏移而非周期。修复:候选改为相邻 gap 两两之和(偏移相加得
真实周期),排除单个 gap。
坑 4:BUS LAW Workshop 从「3 flat」变「1 recurring」
原阈值 missing >= present 拒绝了 BUS LAW Workshop(5 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 天)。交错的多个
序列则由链拆分处理。三者用同一算法兼得。