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

129 lines
6.8 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 间隔合并与链拆分算法
本文档记录 `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 的倍数 -> `WEEKLY``byday` 从首日期推导),
否则 -> `DAILY``byday=null``exdates = missing`
**阈值放宽**:合并条件仅为 `evs.length >= 2``offGrid` 为空。不再
因「太稀疏」(`missing >= present`)而拒绝——稀疏但有规律的序列(如
BUS LAW Workshop3 个事件跨 49 天gap 21/28d=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` 总流程
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 序列
最初 `seriesKey` `weekday` 3 天的事件落在 5 个不同星期被拆成
5 个单事件组`fitSeries` 永远收不到 2 事件直接调 `fitSeries`
合并 `organize` 的分组步骤先把它拆碎了。**根因在分组键不在
`fitSeries`。** 修复键移除 weekday
### 坑 2GCD=1 的平凡网格伪合并
移除 weekday 周一+周二两班gap 1,6,1,6GCD=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。
### 坑 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 天)。交错的多个
序列则由链拆分处理。三者用同一算法兼得。