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 适配
This commit is contained in:
73
docs/auto-save-design.md
Normal file
73
docs/auto-save-design.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# 自动保存设计
|
||||
|
||||
本文档记录 `src/composables/useCalendar.js` 与
|
||||
`src/components/HeaderBar.vue` 的保存模型重构。
|
||||
|
||||
## 背景
|
||||
|
||||
原设计有「dirty 标记 + 下载前确认弹窗 + 快照式 undo/redo(最多 50 步)」。
|
||||
EventDetail 的表单已改为自动应用(`watch(form)` 即时写回 store,
|
||||
commit `1c6ecf9`),dirty 仅用于「下载前提示未保存」与红点显示。撤销是
|
||||
独立的历史栈。
|
||||
|
||||
## 重构:移除 dirty / undo
|
||||
|
||||
纯前端工具,无持久化需求,所有编辑本就即时生效(自动应用 watch)。
|
||||
dirty 标记与确认弹窗是冗余的仪式感;undo 栈在自动应用场景下也意义
|
||||
有限(每次 keystroke 都会 push 历史,栈迅速填满噪声)。移除以降低
|
||||
复杂度、减少状态字段。
|
||||
|
||||
### 删除的死代码(`useCalendar.js`)
|
||||
|
||||
- `state.history`(快照栈)、`state.dirty`
|
||||
- `MAX_HISTORY` 常量
|
||||
- `snapshot()`、`pushHistory()`、`restore()`
|
||||
- `undo()` action、`canUndo` computed
|
||||
- `markSaved()` action
|
||||
- 各 action(`organize`/`updateEvent`/`deleteEvent`/`addEvent`/
|
||||
`splitEvent`/`toggleOccurrence`)内的 `pushHistory()` 与
|
||||
`state.dirty = true` 调用
|
||||
- `loadFile` 内的 `state.history = []`、`state.dirty = false`
|
||||
- 不再使用的 `cloneEvent` 导入(snapshot 曾用)
|
||||
|
||||
### `HeaderBar.vue` 改动
|
||||
|
||||
- 移除「撤销」按钮
|
||||
- 移除下载按钮后的红点 `●`(`<span class="dirty-dot">`)与
|
||||
`.dirty-dot` 样式
|
||||
- `onDownload()`:移除 `if (state.dirty && !confirm(...))` 确认弹窗与
|
||||
`markSaved()` 调用;直接 `serialize()` 下载
|
||||
|
||||
## 自动保存语义
|
||||
|
||||
- **所有编辑即时生效**:`updateEvent`/`deleteEvent`/`addEvent`/
|
||||
`splitEvent`/`toggleOccurrence` 直接 mutate 响应式 `state.events`,
|
||||
无中间缓冲。EventDetail 的 `watch(form, { deep: true })` 把表单变化
|
||||
立即 patch 回 store。
|
||||
- **下载兜底**:`onDownload` 调 `serialize()` 序列化**当前最新状态**。
|
||||
即便有任何未触发渲染的边界改动,下载时也会被序列化包含。文件名取
|
||||
原文件名去 `.ics` 加 `.edited.ics`。
|
||||
- **occurrences 不持久化**:EventModel 不存 occurrences,`OccurrenceGrid`
|
||||
渲染时实时调 `expandOccurrences(ev)` 计算,`toggleOccurrence` 直接改
|
||||
`exdates`。无缓存,无不同步风险。
|
||||
|
||||
## 状态字段(重构后)
|
||||
|
||||
```js
|
||||
const state = reactive({
|
||||
meta: null,
|
||||
events: [],
|
||||
selectedUid: null,
|
||||
fileName: null,
|
||||
viewMode: 'edit', // 'edit' | 'month' | 'week'
|
||||
previewDate: todayISO(),
|
||||
})
|
||||
```
|
||||
|
||||
无 `history`、无 `dirty`、无 `redoStack`。store 仅承载当前状态与视图锚点。
|
||||
|
||||
## 取消确认弹窗的理由
|
||||
|
||||
原 `onDownload` 在 `dirty` 时弹「有未保存的更改,仍要下载?」。但自动
|
||||
应用下,所有更改已在 store 中,下载即序列化 store,不存在「未保存」。
|
||||
弹窗只会误导用户以为有数据丢失风险。移除后下载是一次性操作。
|
||||
128
docs/organize-algorithm.md
Normal file
128
docs/organize-algorithm.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# 间隔合并与链拆分算法
|
||||
|
||||
本文档记录 `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 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` 总流程
|
||||
|
||||
1. 按 `seriesKey` 分组。
|
||||
2. 每组调 `organizeGroup`:先 `fitSeries`;成功则输出 1 个合并事件;
|
||||
失败则 `splitIntoChains` 后对每条链独立 `fitSeries`,链长 <2 或无
|
||||
规律的保留为独立事件。
|
||||
3. 合并后事件保留 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 天)。交错的多个
|
||||
序列则由链拆分处理。三者用同一算法兼得。
|
||||
Reference in New Issue
Block a user