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:
18
README.md
18
README.md
@@ -5,11 +5,11 @@
|
|||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- 拖入即用:拖拽或选择 `.ics` 文件,浏览器内直接解析
|
- 拖入即用:拖拽或选择 `.ics` 文件,浏览器内直接解析
|
||||||
- 智能去重:自动检测重复模式,将扁平事件合并为 RRULE 重复事件 + EXDATE
|
- 智能去重:检测事件间隔(每周 / 每两周 / 每 N 天),将扁平事件合并为 RRULE 重复事件 + EXDATE;交错的多班次自动拆分为多个系列
|
||||||
|
- 自动保存:所有编辑即时生效,下载时兜底序列化最新状态,无需手动保存
|
||||||
- 可视化编辑:编辑标题/地点/时间/重复规则,点击网格切换排除日期
|
- 可视化编辑:编辑标题/地点/时间/重复规则,点击网格切换排除日期
|
||||||
- 拆分事件:把一个重复事件按日期拆成前后两段
|
- 拆分事件:把一个重复事件按日期拆成前后两段
|
||||||
- 增删事件:新增单次/重复事件,删除任意事件
|
- 增删事件:新增单次/重复事件,删除任意事件
|
||||||
- 撤销重做:快照式 undo/redo,最多 50 步
|
|
||||||
- 导出 ICS:下载整理后的 `.ics`,可直接导入日历应用
|
- 导出 ICS:下载整理后的 `.ics`,可直接导入日历应用
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
@@ -25,21 +25,25 @@ timeTableFix/
|
|||||||
│ ├── main.js 应用挂载
|
│ ├── main.js 应用挂载
|
||||||
│ ├── App.vue 布局装配
|
│ ├── App.vue 布局装配
|
||||||
│ ├── composables/
|
│ ├── composables/
|
||||||
│ │ └── useCalendar.js 中心 store + 快照 undo/redo
|
│ │ └── useCalendar.js 中心 store(自动保存,无 undo)
|
||||||
│ ├── lib/
|
│ ├── lib/
|
||||||
│ │ ├── weekday.js 星期常量与工具
|
│ │ ├── weekday.js 星期常量与工具
|
||||||
│ │ ├── date.js 日期工具
|
│ │ ├── date.js 日期工具
|
||||||
|
│ │ ├── colors.js 事件配色
|
||||||
|
│ │ ├── uuid.js UUID 生成(含非安全上下文回退)
|
||||||
│ │ ├── ical-io.js ICS 文本 <-> plain model
|
│ │ ├── ical-io.js ICS 文本 <-> plain model
|
||||||
│ │ ├── recur.js RRULE 网格展开
|
│ │ ├── recur.js RRULE 网格展开
|
||||||
│ │ ├── organize.js GCD 去重算法
|
│ │ ├── organize.js 间隔去重 + 链拆分算法
|
||||||
│ │ ├── event-factory.js 事件构造工厂
|
│ │ ├── event-factory.js 事件构造工厂
|
||||||
│ │ └── split.js 事件拆分逻辑
|
│ │ └── split.js 事件拆分逻辑
|
||||||
│ └── components/
|
│ └── components/
|
||||||
│ ├── HeaderBar.vue 顶栏:整理/撤销/重做/导入/下载
|
│ ├── HeaderBar.vue 顶栏:整理/导入/下载
|
||||||
│ ├── DropZone.vue 拖拽/选择文件
|
│ ├── DropZone.vue 拖拽/选择文件
|
||||||
│ ├── EventList.vue 侧栏事件列表
|
│ ├── EventList.vue 侧栏事件列表
|
||||||
│ ├── EventDetail.vue 编辑表单 + 拆分
|
│ ├── EventDetail.vue 编辑表单 + 拆分
|
||||||
│ └── OccurrenceGrid.vue 排除日期网格
|
│ ├── OccurrenceGrid.vue 排除日期网格
|
||||||
|
│ ├── MonthPreview.vue 月预览
|
||||||
|
│ └── WeekPreview.vue 周预览
|
||||||
├── test/ Vitest 单元测试
|
├── test/ Vitest 单元测试
|
||||||
│ ├── golden-org.ics 测试用例(61 事件的真实课表)
|
│ ├── golden-org.ics 测试用例(61 事件的真实课表)
|
||||||
│ ├── weekday.test.js
|
│ ├── weekday.test.js
|
||||||
@@ -47,6 +51,8 @@ timeTableFix/
|
|||||||
│ ├── recur.test.js
|
│ ├── recur.test.js
|
||||||
│ └── organize.test.js
|
│ └── organize.test.js
|
||||||
└── docs/ 设计文档
|
└── docs/ 设计文档
|
||||||
|
├── organize-algorithm.md 间隔合并与链拆分算法详解
|
||||||
|
└── auto-save-design.md 自动保存设计
|
||||||
```
|
```
|
||||||
|
|
||||||
## Ubuntu 从零安装
|
## Ubuntu 从零安装
|
||||||
|
|||||||
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 天)。交错的多个
|
||||||
|
序列则由链拆分处理。三者用同一算法兼得。
|
||||||
19
src/App.vue
19
src/App.vue
@@ -68,7 +68,7 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
.app-header h1 { font-size: 18px; font-weight: 600; }
|
.app-header h1 { font-size: 18px; font-weight: 600; white-space: nowrap; }
|
||||||
.view-switcher {
|
.view-switcher {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: #1a252f;
|
background: #1a252f;
|
||||||
@@ -91,4 +91,21 @@ body {
|
|||||||
}
|
}
|
||||||
.app-main { flex: 1; display: flex; overflow: hidden; }
|
.app-main { flex: 1; display: flex; overflow: hidden; }
|
||||||
.detail-area { flex: 1; overflow-y: auto; }
|
.detail-area { flex: 1; overflow-y: auto; }
|
||||||
|
|
||||||
|
/* 移动端适配 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app-header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.header-left {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.app-header h1 { font-size: 15px; }
|
||||||
|
.view-switcher button { padding: 5px 10px; font-size: 12px; }
|
||||||
|
.app-main { flex-direction: column; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -91,4 +91,10 @@ function onPick(e) {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dropzone { margin: 16px; border-radius: 8px; }
|
||||||
|
.hint { font-size: 13px; }
|
||||||
|
.pick-btn { padding: 8px 20px; font-size: 13px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -263,4 +263,11 @@ function onToggleOcc(date) {
|
|||||||
}
|
}
|
||||||
.split-panel p { width: 100%; font-size: 13px; color: #7f8c8d; margin-bottom: 8px; }
|
.split-panel p { width: 100%; font-size: 13px; color: #7f8c8d; margin-bottom: 8px; }
|
||||||
.split-panel input { padding: 6px 10px; border: 1px solid #d5dbdb; border-radius: 6px; }
|
.split-panel input { padding: 6px 10px; border: 1px solid #d5dbdb; border-radius: 6px; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.detail { padding: 16px; }
|
||||||
|
.form-row { flex-direction: column; gap: 0; }
|
||||||
|
.toolbar { flex-wrap: wrap; }
|
||||||
|
.btn { padding: 6px 12px; font-size: 12px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -84,4 +84,13 @@ function dowFor(ev) {
|
|||||||
}
|
}
|
||||||
.badge-recur { background: #e8f5e9; color: #2e7d32; }
|
.badge-recur { background: #e8f5e9; color: #2e7d32; }
|
||||||
.badge-single { background: #fff3e0; color: #e65100; }
|
.badge-single { background: #fff3e0; color: #e65100; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.event-list {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 40vh;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid #e0e3e6;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { useCalendar } from '../composables/useCalendar.js'
|
import { useCalendar } from '../composables/useCalendar.js'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
const { state, canUndo, organize, undo, serialize, markSaved, loadFile } = useCalendar()
|
const { state, organize, serialize, loadFile } = useCalendar()
|
||||||
const fileInput = ref(null)
|
const fileInput = ref(null)
|
||||||
|
|
||||||
function onOrganize() {
|
function onOrganize() {
|
||||||
@@ -24,7 +24,7 @@ function onFilePicked(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onDownload() {
|
function onDownload() {
|
||||||
if (state.dirty && !confirm('有未保存的更改,仍要下载?')) return
|
// 兜底自动保存: 始终序列化当前最新状态 (所有编辑已即时生效)
|
||||||
const text = serialize()
|
const text = serialize()
|
||||||
const blob = new Blob([text], { type: 'text/calendar' })
|
const blob = new Blob([text], { type: 'text/calendar' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
@@ -34,7 +34,6 @@ function onDownload() {
|
|||||||
a.download = base + '.edited.ics'
|
a.download = base + '.edited.ics'
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
markSaved()
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -43,18 +42,16 @@ function onDownload() {
|
|||||||
<button class="btn btn-primary" @click="onOrganize" :disabled="state.events.length === 0">
|
<button class="btn btn-primary" @click="onOrganize" :disabled="state.events.length === 0">
|
||||||
整理去重
|
整理去重
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-secondary" @click="undo" :disabled="!canUndo">撤销</button>
|
|
||||||
<button class="btn btn-secondary" @click="onImport">导入文件</button>
|
<button class="btn btn-secondary" @click="onImport">导入文件</button>
|
||||||
<button class="btn btn-success" @click="onDownload" :disabled="state.events.length === 0">
|
<button class="btn btn-success" @click="onDownload" :disabled="state.events.length === 0">
|
||||||
下载 ICS
|
下载 ICS
|
||||||
</button>
|
</button>
|
||||||
<span v-if="state.dirty" class="dirty-dot" title="有未保存更改">●</span>
|
|
||||||
<input ref="fileInput" type="file" accept=".ics" @change="onFilePicked" hidden />
|
<input ref="fileInput" type="file" accept=".ics" @change="onFilePicked" hidden />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.header-actions { display: flex; gap: 10px; align-items: center; }
|
.header-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; justify-content: center; }
|
||||||
.btn {
|
.btn {
|
||||||
padding: 7px 16px;
|
padding: 7px 16px;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -63,6 +60,7 @@ function onDownload() {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.btn-primary { background: #3498db; color: #fff; }
|
.btn-primary { background: #3498db; color: #fff; }
|
||||||
@@ -71,5 +69,9 @@ function onDownload() {
|
|||||||
.btn-success:hover:not(:disabled) { background: #229954; }
|
.btn-success:hover:not(:disabled) { background: #229954; }
|
||||||
.btn-secondary { background: #ecf0f1; color: #2c3e50; }
|
.btn-secondary { background: #ecf0f1; color: #2c3e50; }
|
||||||
.btn-secondary:hover:not(:disabled) { background: #d5dbdb; }
|
.btn-secondary:hover:not(:disabled) { background: #d5dbdb; }
|
||||||
.dirty-dot { color: #e74c3c; font-size: 16px; }
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header-actions { gap: 6px; }
|
||||||
|
.btn { padding: 5px 10px; font-size: 12px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -244,4 +244,16 @@ function hideTooltip() {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.month-preview { padding: 10px 8px; }
|
||||||
|
.preview-toolbar { gap: 8px; flex-wrap: wrap; }
|
||||||
|
.nav-btn { padding: 4px 10px; font-size: 12px; }
|
||||||
|
.month-label { font-size: 14px; min-width: 100px; }
|
||||||
|
.grid-header { font-size: 11px; padding: 5px 0; }
|
||||||
|
.grid-cell { padding: 2px 3px; }
|
||||||
|
.day-num { font-size: 11px; }
|
||||||
|
.event-chip { font-size: 9px; padding: 0 3px; }
|
||||||
|
.event-tooltip { max-width: calc(100vw - 32px); bottom: 8px; right: 8px; left: 8px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -268,4 +268,20 @@ function tooltip(ev) {
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.week-preview { padding: 10px 4px; }
|
||||||
|
.preview-toolbar { gap: 8px; flex-wrap: wrap; }
|
||||||
|
.nav-btn { padding: 4px 10px; font-size: 12px; }
|
||||||
|
.week-label { font-size: 13px; min-width: 140px; }
|
||||||
|
.week-grid { min-width: 600px; }
|
||||||
|
.time-col { width: 40px; }
|
||||||
|
.time-slot { font-size: 10px; padding-right: 3px; }
|
||||||
|
.day-header { font-size: 10px; height: 34px; }
|
||||||
|
.day-date { font-size: 12px; }
|
||||||
|
.time-spacer { height: 34px; }
|
||||||
|
.event-block { font-size: 10px; padding: 1px 2px; }
|
||||||
|
.event-title { font-size: 10px; }
|
||||||
|
.event-time { font-size: 9px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,46 +1,20 @@
|
|||||||
import { reactive, computed } from 'vue'
|
import { reactive, computed } from 'vue'
|
||||||
import { parseICS, serializeICS } from '../lib/ical-io.js'
|
import { parseICS, serializeICS } from '../lib/ical-io.js'
|
||||||
import { organize as organizeEvents } from '../lib/organize.js'
|
import { organize as organizeEvents } from '../lib/organize.js'
|
||||||
import { createEvent, cloneEvent } from '../lib/event-factory.js'
|
import { createEvent } from '../lib/event-factory.js'
|
||||||
import { splitRecurringEvent } from '../lib/split.js'
|
import { splitRecurringEvent } from '../lib/split.js'
|
||||||
import { todayISO, addDays, startOfMonth, parseISODate, pad2 } from '../lib/date.js'
|
import { todayISO, addDays, startOfMonth, parseISODate, pad2 } from '../lib/date.js'
|
||||||
|
|
||||||
const MAX_HISTORY = 50
|
|
||||||
|
|
||||||
// 单例响应式状态
|
// 单例响应式状态
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
meta: null,
|
meta: null,
|
||||||
events: [],
|
events: [],
|
||||||
selectedUid: null,
|
selectedUid: null,
|
||||||
history: [],
|
|
||||||
fileName: null,
|
fileName: null,
|
||||||
dirty: false,
|
|
||||||
viewMode: 'edit', // 'edit' | 'month' | 'week'
|
viewMode: 'edit', // 'edit' | 'month' | 'week'
|
||||||
previewDate: todayISO(), // 预览锚定日期 (月预览取当月, 周预览取当周)
|
previewDate: todayISO(), // 预览锚定日期 (月预览取当月, 周预览取当周)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
|
||||||
// 快照 / 撤销
|
|
||||||
// ------------------------------------------------------------------ //
|
|
||||||
function snapshot() {
|
|
||||||
return {
|
|
||||||
meta: state.meta ? cloneEvent(state.meta) : null,
|
|
||||||
events: state.events.map(cloneEvent),
|
|
||||||
selectedUid: state.selectedUid,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pushHistory() {
|
|
||||||
state.history.push(snapshot())
|
|
||||||
if (state.history.length > MAX_HISTORY) state.history.shift()
|
|
||||||
}
|
|
||||||
|
|
||||||
function restore(snap) {
|
|
||||||
state.meta = snap.meta ? cloneEvent(snap.meta) : null
|
|
||||||
state.events = snap.events.map(cloneEvent)
|
|
||||||
state.selectedUid = snap.selectedUid
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
// 文件加载
|
// 文件加载
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
@@ -50,14 +24,12 @@ function loadFile(file) {
|
|||||||
state.meta = parsed.meta
|
state.meta = parsed.meta
|
||||||
state.events = parsed.events
|
state.events = parsed.events
|
||||||
state.selectedUid = null
|
state.selectedUid = null
|
||||||
state.history = []
|
|
||||||
state.fileName = file.name
|
state.fileName = file.name
|
||||||
state.dirty = false
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
// 事件操作
|
// 事件操作 (所有改动即时生效 -- 自动保存)
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
function selectEvent(uid) {
|
function selectEvent(uid) {
|
||||||
state.selectedUid = uid
|
state.selectedUid = uid
|
||||||
@@ -65,39 +37,31 @@ function selectEvent(uid) {
|
|||||||
|
|
||||||
function organize() {
|
function organize() {
|
||||||
if (state.events.length === 0) return
|
if (state.events.length === 0) return
|
||||||
pushHistory()
|
|
||||||
const result = organizeEvents(state.events)
|
const result = organizeEvents(state.events)
|
||||||
state.events = result.events
|
state.events = result.events
|
||||||
state.dirty = true
|
|
||||||
return result.stats
|
return result.stats
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新指定事件的字段 (自动记录历史)。
|
* 更新指定事件的字段。
|
||||||
* 供 EventDetail 的自动应用 (watch) 调用。
|
* 供 EventDetail 的自动应用 (watch) 调用 -- 表单变化即写回, 无需显式保存。
|
||||||
*/
|
*/
|
||||||
function updateEvent(uid, patch) {
|
function updateEvent(uid, patch) {
|
||||||
pushHistory()
|
|
||||||
const ev = state.events.find((e) => e.uid === uid)
|
const ev = state.events.find((e) => e.uid === uid)
|
||||||
if (ev) {
|
if (ev) {
|
||||||
Object.assign(ev, patch)
|
Object.assign(ev, patch)
|
||||||
state.dirty = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteEvent(uid) {
|
function deleteEvent(uid) {
|
||||||
pushHistory()
|
|
||||||
state.events = state.events.filter((e) => e.uid !== uid)
|
state.events = state.events.filter((e) => e.uid !== uid)
|
||||||
if (state.selectedUid === uid) state.selectedUid = null
|
if (state.selectedUid === uid) state.selectedUid = null
|
||||||
state.dirty = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function addEvent() {
|
function addEvent() {
|
||||||
pushHistory()
|
|
||||||
const ev = createEvent()
|
const ev = createEvent()
|
||||||
state.events.push(ev)
|
state.events.push(ev)
|
||||||
state.selectedUid = ev.uid
|
state.selectedUid = ev.uid
|
||||||
state.dirty = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitEvent(uid, splitDate) {
|
function splitEvent(uid, splitDate) {
|
||||||
@@ -107,16 +71,13 @@ function splitEvent(uid, splitDate) {
|
|||||||
const parts = splitRecurringEvent(ev, splitDate)
|
const parts = splitRecurringEvent(ev, splitDate)
|
||||||
if (!parts) return
|
if (!parts) return
|
||||||
|
|
||||||
pushHistory()
|
|
||||||
const [partA, partB] = parts
|
const [partA, partB] = parts
|
||||||
const idx = state.events.findIndex((e) => e.uid === uid)
|
const idx = state.events.findIndex((e) => e.uid === uid)
|
||||||
state.events.splice(idx, 1, partA, partB)
|
state.events.splice(idx, 1, partA, partB)
|
||||||
state.selectedUid = partB.uid
|
state.selectedUid = partB.uid
|
||||||
state.dirty = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleOccurrence(uid, date) {
|
function toggleOccurrence(uid, date) {
|
||||||
pushHistory()
|
|
||||||
const ev = state.events.find((e) => e.uid === uid)
|
const ev = state.events.find((e) => e.uid === uid)
|
||||||
if (!ev) return
|
if (!ev) return
|
||||||
const i = ev.exdates.indexOf(date)
|
const i = ev.exdates.indexOf(date)
|
||||||
@@ -126,25 +87,15 @@ function toggleOccurrence(uid, date) {
|
|||||||
ev.exdates.push(date)
|
ev.exdates.push(date)
|
||||||
ev.exdates.sort()
|
ev.exdates.sort()
|
||||||
}
|
}
|
||||||
state.dirty = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function undo() {
|
|
||||||
if (state.history.length === 0) return
|
|
||||||
restore(state.history.pop())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
// 序列化
|
// 序列化 (下载时兜底: 始终序列化当前最新状态)
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
function serialize() {
|
function serialize() {
|
||||||
return serializeICS({ meta: state.meta, events: state.events })
|
return serializeICS({ meta: state.meta, events: state.events })
|
||||||
}
|
}
|
||||||
|
|
||||||
function markSaved() {
|
|
||||||
state.dirty = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
// 视图切换 / 预览导航
|
// 视图切换 / 预览导航
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
@@ -185,7 +136,6 @@ const selectedEvent = computed(() =>
|
|||||||
state.events.find((e) => e.uid === state.selectedUid) || null,
|
state.events.find((e) => e.uid === state.selectedUid) || null,
|
||||||
)
|
)
|
||||||
|
|
||||||
const canUndo = computed(() => state.history.length > 0)
|
|
||||||
const isLoaded = computed(() => state.events.length > 0)
|
const isLoaded = computed(() => state.events.length > 0)
|
||||||
const stats = computed(() => {
|
const stats = computed(() => {
|
||||||
const nRecur = state.events.filter((e) => e.rrule !== null).length
|
const nRecur = state.events.filter((e) => e.rrule !== null).length
|
||||||
@@ -196,7 +146,6 @@ export function useCalendar() {
|
|||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
selectedEvent,
|
selectedEvent,
|
||||||
canUndo,
|
|
||||||
isLoaded,
|
isLoaded,
|
||||||
stats,
|
stats,
|
||||||
loadFile,
|
loadFile,
|
||||||
@@ -207,9 +156,7 @@ export function useCalendar() {
|
|||||||
addEvent,
|
addEvent,
|
||||||
splitEvent,
|
splitEvent,
|
||||||
toggleOccurrence,
|
toggleOccurrence,
|
||||||
undo,
|
|
||||||
serialize,
|
serialize,
|
||||||
markSaved,
|
|
||||||
setViewMode,
|
setViewMode,
|
||||||
prevMonth,
|
prevMonth,
|
||||||
nextMonth,
|
nextMonth,
|
||||||
|
|||||||
@@ -1,9 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* 基于 GCD 的重复检测 - 移植自 ical_organizer.py。
|
* 重复事件合并 - 将扁平的单次事件列表合并为 RRULE + EXDATE。
|
||||||
* 将扁平的单次事件列表合并为 RRULE + EXDATE。
|
*
|
||||||
|
* 算法分两层:
|
||||||
|
*
|
||||||
|
* 1. 分组 (seriesKey): 标题/地点/开始墙钟时间/时长相同的事件归一组。
|
||||||
|
* 键中不含星期 -- 周期为非 7 倍数 (如每 3 天) 的 DAILY 序列会落在
|
||||||
|
* 不同星期, 含星期会把它们拆成单事件组, 永远到不了 fitSeries。
|
||||||
|
* 星期改由 fitSeries 内的 byday 从首日期推导, 仅 WEEKLY 有意义。
|
||||||
|
*
|
||||||
|
* 2. 系列拟合 (fitSeries): 求所有相邻 gap 的 GCD 作为「最小公约间隔」d
|
||||||
|
* (使每个 gap 均为其整数倍的最小正整数), 据此构建期望网格。所有
|
||||||
|
* present 日期必须在网格上 (offGrid 为空); 缺席的网格日期记为
|
||||||
|
* EXDATE。不再因「太稀疏」拒绝 -- 稀疏但有规律的序列 (如约每 3 周
|
||||||
|
* 一次的 workshop) 同样合并。
|
||||||
|
*
|
||||||
|
* 3. 链拆分 (splitIntoChains): 当一组日期无法落在单一 GCD 网格上
|
||||||
|
* (offGrid 非空, 典型如同名同时但分属周一/周二的两个班, gap 交替
|
||||||
|
* 为 1,6,1,6), 把日期拆成若干「等间隔链」: 候选间隔取所有日期对的
|
||||||
|
* 差 (降序), 从最早未分配日期起贪心抽取最长链。每条链独立 fitSeries。
|
||||||
|
* 这样交错的两个每周班会正确拆成 2 个 WEEKLY 事件, 而非误合并成
|
||||||
|
* 一个 DAILY;INTERVAL=1。
|
||||||
|
*
|
||||||
|
* 详见 docs/organize-algorithm.md。
|
||||||
*/
|
*/
|
||||||
import { bydayFromDate } from './weekday.js'
|
import { bydayFromDate } from './weekday.js'
|
||||||
import { toDate, toISODate, addDays, daysBetween } from './date.js'
|
import { toDate, addDays, daysBetween } from './date.js'
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
// 分组
|
// 分组
|
||||||
@@ -11,14 +32,13 @@ import { toDate, toISODate, addDays, daysBetween } from './date.js'
|
|||||||
/**
|
/**
|
||||||
* 重复系列的标识。
|
* 重复系列的标识。
|
||||||
* 两个事件属于同一系列当且仅当: 标题、地点、开始墙钟时间、
|
* 两个事件属于同一系列当且仅当: 标题、地点、开始墙钟时间、
|
||||||
* 时长 (分钟)、星期均相同。
|
* 时长 (分钟) 均相同。星期不参与分组 (见模块头注释)。
|
||||||
*/
|
*/
|
||||||
export function seriesKey(ev) {
|
export function seriesKey(ev) {
|
||||||
const [sh, sm] = ev.dtstartTime.split(':').map(Number)
|
const [sh, sm] = ev.dtstartTime.split(':').map(Number)
|
||||||
const [eh, em] = ev.dtendTime.split(':').map(Number)
|
const [eh, em] = ev.dtendTime.split(':').map(Number)
|
||||||
const durationMin = (eh * 60 + em) - (sh * 60 + sm)
|
const durationMin = (eh * 60 + em) - (sh * 60 + sm)
|
||||||
const weekday = toDate(ev.dtstartDate).getDay()
|
return [ev.summary, ev.location, ev.dtstartTime, durationMin].join('|')
|
||||||
return [ev.summary, ev.location, ev.dtstartTime, durationMin, weekday].join('|')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
@@ -30,8 +50,11 @@ function gcd(a, b) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 返回重复间隔 (天数), 不规则时返回 null。
|
* 返回「最小公约间隔」(天数), 不规则时返回 null。
|
||||||
* 计算所有相邻日期间隔的 GCD。
|
*
|
||||||
|
* 计算 d = 所有相邻日期间隔的 GCD -- 这是使每个 gap 都为其整数倍的
|
||||||
|
* 最小正整数。例如 gap [7,7,14,7] -> d=7 (中间跳过一周, 14=2*7);
|
||||||
|
* gap [3,3,3] -> d=3 (每 3 天); gap [21,28] -> d=7。
|
||||||
*/
|
*/
|
||||||
export function detectInterval(dateStrings) {
|
export function detectInterval(dateStrings) {
|
||||||
if (dateStrings.length < 2) return null
|
if (dateStrings.length < 2) return null
|
||||||
@@ -70,29 +93,10 @@ function buildGrid(firstDate, lastDate, interval, present) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尝试将事件合并为一个重复事件。
|
* 由天数间隔构造 RRULE。7 的倍数 -> WEEKLY (byday 从首日期推导),
|
||||||
* 返回 { base, rrule, exdates }, 无法合并时返回 null。
|
* 否则 -> DAILY (byday 为 null)。
|
||||||
*/
|
*/
|
||||||
export function fitSeries(evs) {
|
function buildRRule(interval, firstDate, lastDateStr) {
|
||||||
if (evs.length < 2) return null
|
|
||||||
|
|
||||||
const sorted = [...evs].sort(byDate)
|
|
||||||
const starts = sorted.map((e) => e.dtstartDate)
|
|
||||||
const interval = detectInterval(starts)
|
|
||||||
if (!interval) return null
|
|
||||||
|
|
||||||
const first = sorted[0]
|
|
||||||
const last = sorted[sorted.length - 1]
|
|
||||||
const present = new Set(starts)
|
|
||||||
|
|
||||||
const { missing, offGrid } = buildGrid(first.dtstartDate, last.dtstartDate, interval, present)
|
|
||||||
if (offGrid.length > 0) return null
|
|
||||||
|
|
||||||
// "基本规律": 至少 2 个 occurrence, 且 occurrence 数 >= 跳过数
|
|
||||||
if (evs.length < 2 || missing.length >= evs.length) return null
|
|
||||||
|
|
||||||
// 构建 rrule
|
|
||||||
const firstDate = toDate(first.dtstartDate)
|
|
||||||
let freq, step, byday
|
let freq, step, byday
|
||||||
if (interval % 7 === 0) {
|
if (interval % 7 === 0) {
|
||||||
freq = 'WEEKLY'
|
freq = 'WEEKLY'
|
||||||
@@ -103,11 +107,160 @@ export function fitSeries(evs) {
|
|||||||
step = interval
|
step = interval
|
||||||
byday = null
|
byday = null
|
||||||
}
|
}
|
||||||
|
return { freq, interval: step, byday, untilDate: lastDateStr }
|
||||||
|
}
|
||||||
|
|
||||||
const rrule = { freq, interval: step, byday, untilDate: last.dtstartDate }
|
/**
|
||||||
|
* 尝试将事件合并为一个重复事件 (单一 GCD 网格)。
|
||||||
|
* 返回 { base, rrule, exdates }, 无法合并时返回 null。
|
||||||
|
*
|
||||||
|
* 拒绝条件:
|
||||||
|
* - < 2 个事件, 或无 GCD 间隔;
|
||||||
|
* - GCD = 1: 相邻 gap 含互质值 (如 1,6 或 2,5), 说明是交错的多个
|
||||||
|
* 序列, 单一 DAILY;INTERVAL=1 网格会把所有不在同子序列的日期当
|
||||||
|
* EXDATE, 是伪合并。交由 splitIntoChains 拆分。
|
||||||
|
* - offGrid 非空: present 日期不全在网格上。
|
||||||
|
*/
|
||||||
|
export function fitSeries(evs) {
|
||||||
|
if (evs.length < 2) return null
|
||||||
|
|
||||||
|
const sorted = [...evs].sort(byDate)
|
||||||
|
const starts = sorted.map((e) => e.dtstartDate)
|
||||||
|
const interval = detectInterval(starts)
|
||||||
|
if (!interval) return null
|
||||||
|
if (interval === 1) return null
|
||||||
|
|
||||||
|
const first = sorted[0]
|
||||||
|
const last = sorted[sorted.length - 1]
|
||||||
|
const present = new Set(starts)
|
||||||
|
|
||||||
|
const { missing, offGrid } = buildGrid(first.dtstartDate, last.dtstartDate, interval, present)
|
||||||
|
// present 日期必须全部落在规律网格上, 否则该组不是单一周期序列
|
||||||
|
// (需交由 splitIntoChains 拆分)。
|
||||||
|
if (offGrid.length > 0) return null
|
||||||
|
|
||||||
|
const rrule = buildRRule(interval, toDate(first.dtstartDate), last.dtstartDate)
|
||||||
return { base: first, rrule, exdates: missing }
|
return { base: first, rrule, exdates: missing }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// 链拆分
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
/**
|
||||||
|
* 把一组日期拆成若干「等间隔链」, 每条链允许中间跳过若干周期
|
||||||
|
* (跳过的日期将作为 EXDATE 由 fitSeries 记录)。
|
||||||
|
*
|
||||||
|
* 仅在单一 GCD 网格失败 (组级 GCD=1, 即存在互质相邻 gap, 典型为
|
||||||
|
* 交错的多个周序列, 如同名同时的周一+周二两班) 时调用。
|
||||||
|
*
|
||||||
|
* 候选间隔 d: 相邻 gap 两两之和 (覆盖「配对相加得真实周期」的情况,
|
||||||
|
* 如 gap 1,6 -> 周期 7; gap 2,5 -> 周期 7)。仅用两两之和而非单个
|
||||||
|
* gap, 因为交错子序列的「偏移」(如 1 或 2) 比真实周期短, 拿单个 gap
|
||||||
|
* 当周期会得到过密的伪网格。此函数仅在组级 GCD=1 (互质 gap, 即交错)
|
||||||
|
* 时调用, 故两两之和总是能给出真实周期。
|
||||||
|
* 对每个 d 用网格抽取 (从最早未分配日期起按 d 步进, 收集命中的存在
|
||||||
|
* 日期, 跳过缺席周期) 得到若干链; 选产生链数最少的 d (最大化分组)。
|
||||||
|
*
|
||||||
|
* 例: 周一+周二交错 (gap 1,6,1,6,1,6) -> d=7 抽出链 [5,12,19] 与
|
||||||
|
* [6,13,20], 得 2 个 WEEKLY 事件。
|
||||||
|
* 例: gap 2,5,2,5,2,12,2,5,2,5 -> d=7 抽出链 [0,7,14,28,35,42]
|
||||||
|
* (中间跳过 21) 与 [2,9,16,30,37] (中间跳过 23), 得 2 个 WEEKLY
|
||||||
|
* 事件, 各带 1 个 EXDATE。
|
||||||
|
*/
|
||||||
|
export function splitIntoChains(dateStrings) {
|
||||||
|
const dates = [...new Set(dateStrings)].sort()
|
||||||
|
if (dates.length < 2) return dates.length === 1 ? [dates] : []
|
||||||
|
|
||||||
|
// 候选间隔: 相邻 gap 两两之和 (见上方注释)。仅 >1 者。
|
||||||
|
const gaps = []
|
||||||
|
for (let i = 0; i < dates.length - 1; i++) {
|
||||||
|
gaps.push(daysBetween(dates[i], dates[i + 1]))
|
||||||
|
}
|
||||||
|
const candSet = new Set()
|
||||||
|
for (let i = 0; i < gaps.length - 1; i++) {
|
||||||
|
const s = gaps[i] + gaps[i + 1]
|
||||||
|
if (s > 1) candSet.add(s)
|
||||||
|
}
|
||||||
|
const candidates = [...candSet].sort((a, b) => b - a)
|
||||||
|
|
||||||
|
// 对给定 d 用网格抽取链 (允许跳过周期)。返回链数组。
|
||||||
|
function extractChains(d) {
|
||||||
|
const remaining = new Set(dates)
|
||||||
|
const chains = []
|
||||||
|
while (remaining.size > 0) {
|
||||||
|
const start = [...remaining].sort()[0]
|
||||||
|
// 从 start 按 d 步进到最末日期, 收集命中的存在日期
|
||||||
|
const chain = []
|
||||||
|
let cur = start
|
||||||
|
const lastDate = dates[dates.length - 1]
|
||||||
|
while (cur <= lastDate) {
|
||||||
|
if (remaining.has(cur)) chain.push(cur)
|
||||||
|
cur = addDays(cur, d)
|
||||||
|
}
|
||||||
|
if (chain.length === 0) {
|
||||||
|
// start 不在任何网格点 (不应发生, 因 cur=start 命中), 兜底
|
||||||
|
chains.push([start])
|
||||||
|
remaining.delete(start)
|
||||||
|
} else {
|
||||||
|
chains.push(chain)
|
||||||
|
for (const dd of chain) remaining.delete(dd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chains
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选产生链数最少的 d (链数相同取较小 d, 周期更细)
|
||||||
|
let best = null
|
||||||
|
for (const d of candidates) {
|
||||||
|
const chains = extractChains(d)
|
||||||
|
// 只接受所有链长度 >= 2 的方案 (单点链说明 d 不适配)
|
||||||
|
const valid = chains.every((c) => c.length >= 2)
|
||||||
|
const score = chains.length
|
||||||
|
if (valid && (!best || score < best.score || (score === best.score && d < best.d))) {
|
||||||
|
best = { d, chains, score }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (best) return best.chains
|
||||||
|
|
||||||
|
// 兜底: 无候选 d 能给出全 >=2 链, 退化为每日期一链 (全部 flat)
|
||||||
|
return dates.map((d) => [d])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对一组同系列事件做合并: 先尝试单一 fitSeries, 失败 (offGrid) 则
|
||||||
|
* splitIntoChains 后对每条链独立 fitSeries。返回 EventModel[]。
|
||||||
|
*/
|
||||||
|
function organizeGroup(evs) {
|
||||||
|
evs.sort(byDate)
|
||||||
|
|
||||||
|
const fit = fitSeries(evs)
|
||||||
|
if (fit) {
|
||||||
|
// 合并后的事件: 保留 base 的 UID (不重新分配)
|
||||||
|
return [{ ...fit.base, rrule: fit.rrule, exdates: fit.exdates }]
|
||||||
|
}
|
||||||
|
|
||||||
|
// offGrid 非空: 拆链。按日期建立 date->event 映射 (同日期取首个)。
|
||||||
|
const byDateMap = new Map()
|
||||||
|
for (const ev of evs) {
|
||||||
|
if (!byDateMap.has(ev.dtstartDate)) byDateMap.set(ev.dtstartDate, ev)
|
||||||
|
}
|
||||||
|
const chains = splitIntoChains(evs.map((e) => e.dtstartDate))
|
||||||
|
|
||||||
|
const result = []
|
||||||
|
for (const chain of chains) {
|
||||||
|
const chainEvs = chain.map((d) => byDateMap.get(d)).filter(Boolean)
|
||||||
|
const chainFit = fitSeries(chainEvs)
|
||||||
|
if (chainFit) {
|
||||||
|
result.push({ ...chainFit.base, rrule: chainFit.rrule, exdates: chainFit.exdates })
|
||||||
|
} else {
|
||||||
|
// 链长 < 2 或无规律: 保留为独立事件
|
||||||
|
for (const ev of chainEvs) result.push(ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
// 整理
|
// 整理
|
||||||
// ------------------------------------------------------------------ //
|
// ------------------------------------------------------------------ //
|
||||||
@@ -129,23 +282,11 @@ export function organize(events) {
|
|||||||
let nFlat = 0
|
let nFlat = 0
|
||||||
|
|
||||||
for (const evs of groups.values()) {
|
for (const evs of groups.values()) {
|
||||||
evs.sort(byDate)
|
const merged = organizeGroup(evs)
|
||||||
|
for (const ev of merged) {
|
||||||
const fit = fitSeries(evs)
|
result.push(ev)
|
||||||
if (fit) {
|
if (ev.rrule) nSeries++
|
||||||
// 合并后的事件: 保留 base 的 UID (不重新分配)
|
else nFlat++
|
||||||
result.push({
|
|
||||||
...fit.base,
|
|
||||||
rrule: fit.rrule,
|
|
||||||
exdates: fit.exdates,
|
|
||||||
})
|
|
||||||
nSeries++
|
|
||||||
} else {
|
|
||||||
// 保留为独立事件
|
|
||||||
for (const ev of evs) {
|
|
||||||
result.push(ev)
|
|
||||||
nFlat++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,30 @@ import { readFileSync } from 'node:fs'
|
|||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import { parseICS } from '../src/lib/ical-io.js'
|
import { parseICS } from '../src/lib/ical-io.js'
|
||||||
import { organize, seriesKey, detectInterval, fitSeries } from '../src/lib/organize.js'
|
import { organize, seriesKey, detectInterval, fitSeries, splitIntoChains } from '../src/lib/organize.js'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const GOLDEN = readFileSync(join(__dirname, 'golden-org.ics'), 'utf-8')
|
const GOLDEN = readFileSync(join(__dirname, 'golden-org.ics'), 'utf-8')
|
||||||
const { events } = parseICS(GOLDEN)
|
const { events } = parseICS(GOLDEN)
|
||||||
|
|
||||||
|
// 构造最小 EventModel 用于合成用例
|
||||||
|
function mk(dtstart, summary, loc = 'L', st = '09:00', et = '10:00') {
|
||||||
|
return {
|
||||||
|
uid: 'uid-' + dtstart + '-' + summary,
|
||||||
|
summary, location: loc, description: '',
|
||||||
|
dtstartDate: dtstart, dtstartTime: st, dtendTime: et,
|
||||||
|
tzid: 'UTC', rrule: null, exdates: [], _raw: { dtstamp: '20260711T085008Z' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 由相对天偏移生成 ISO 日期 (基准 2026-01-05 = 周一)
|
||||||
|
function iso(off) {
|
||||||
|
const d = new Date(2026, 0, 5)
|
||||||
|
d.setDate(d.getDate() + off)
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
describe('seriesKey', () => {
|
describe('seriesKey', () => {
|
||||||
it('groups events with same summary/location/time/duration/weekday', () => {
|
it('groups events with same summary/location/time/duration', () => {
|
||||||
const e1 = events[0] // ACC INFO SYS Tutorial, Mon 18:00
|
const e1 = events[0] // ACC INFO SYS Tutorial, Mon 18:00
|
||||||
const e2 = events[1] // same series, next week
|
const e2 = events[1] // same series, next week
|
||||||
expect(seriesKey(e1)).toBe(seriesKey(e2))
|
expect(seriesKey(e1)).toBe(seriesKey(e2))
|
||||||
@@ -18,10 +34,17 @@ describe('seriesKey', () => {
|
|||||||
|
|
||||||
it('separates events with different summary', () => {
|
it('separates events with different summary', () => {
|
||||||
const e1 = events[0] // ACC INFO SYS
|
const e1 = events[0] // ACC INFO SYS
|
||||||
// Find a BUS LAW event
|
|
||||||
const eBus = events.find(e => e.summary.startsWith('BUS LAW, Tutorial'))
|
const eBus = events.find(e => e.summary.startsWith('BUS LAW, Tutorial'))
|
||||||
expect(seriesKey(e1)).not.toBe(seriesKey(eBus))
|
expect(seriesKey(e1)).not.toBe(seriesKey(eBus))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('groups DAILY-series events that fall on different weekdays', () => {
|
||||||
|
// 每 3 天 -> 落在 周一/周四/周日/周三/周六, 星期各不同
|
||||||
|
const every3 = [0, 3, 6, 9, 12].map(off => mk(iso(off), 'Med'))
|
||||||
|
const keys = every3.map(seriesKey)
|
||||||
|
// 键不再含星期, 故全部相同
|
||||||
|
expect(new Set(keys).size).toBe(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('detectInterval', () => {
|
describe('detectInterval', () => {
|
||||||
@@ -38,9 +61,14 @@ describe('detectInterval', () => {
|
|||||||
const dates = ['2026-07-27', '2026-08-03', '2026-08-10', '2026-08-24', '2026-08-31']
|
const dates = ['2026-07-27', '2026-08-03', '2026-08-10', '2026-08-24', '2026-08-31']
|
||||||
expect(detectInterval(dates)).toBe(7)
|
expect(detectInterval(dates)).toBe(7)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('returns 3 for every-3-day dates', () => {
|
||||||
|
const dates = ['2026-01-05', '2026-01-08', '2026-01-11', '2026-01-14']
|
||||||
|
expect(detectInterval(dates)).toBe(3)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('fitSeries', () => {
|
describe('fitSeries (single GCD grid)', () => {
|
||||||
it('fits ACC INFO SYS series (12 events, 1 skip)', () => {
|
it('fits ACC INFO SYS series (12 events, 1 skip)', () => {
|
||||||
const accEvents = events.filter(e => e.summary === 'ACC INFO SYS, Tutorial')
|
const accEvents = events.filter(e => e.summary === 'ACC INFO SYS, Tutorial')
|
||||||
const result = fitSeries(accEvents)
|
const result = fitSeries(accEvents)
|
||||||
@@ -51,32 +79,120 @@ describe('fitSeries', () => {
|
|||||||
expect(result.exdates).toContain('2026-09-21')
|
expect(result.exdates).toContain('2026-09-21')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects BUS LAW Workshop (too sparse: 3 events, 5 missing)', () => {
|
it('merges sparse BUS LAW Workshop (gaps 21,28 -> gcd 7) into WEEKLY + exdates', () => {
|
||||||
const wsEvents = events.filter(e => e.summary === 'BUS LAW, Workshop')
|
const wsEvents = events.filter(e => e.summary === 'BUS LAW, Workshop')
|
||||||
expect(wsEvents).toHaveLength(3)
|
expect(wsEvents).toHaveLength(3)
|
||||||
expect(fitSeries(wsEvents)).toBeNull()
|
const result = fitSeries(wsEvents)
|
||||||
|
expect(result).not.toBeNull()
|
||||||
|
expect(result.rrule.freq).toBe('WEEKLY')
|
||||||
|
expect(result.rrule.interval).toBe(1)
|
||||||
|
expect(result.rrule.byday).toBe('WE')
|
||||||
|
expect(result.rrule.untilDate).toBe('2026-09-30')
|
||||||
|
// 8 周跨度内 3 次出现 -> 5 次缺席
|
||||||
|
expect(result.exdates).toHaveLength(5)
|
||||||
|
expect(result.exdates).toContain('2026-08-19')
|
||||||
|
expect(result.exdates).toContain('2026-09-23')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merges every-3-day series into DAILY;INTERVAL=3', () => {
|
||||||
|
const evs = [0, 3, 6, 9, 12].map(off => mk(iso(off), 'Med'))
|
||||||
|
const result = fitSeries(evs)
|
||||||
|
expect(result).not.toBeNull()
|
||||||
|
expect(result.rrule.freq).toBe('DAILY')
|
||||||
|
expect(result.rrule.interval).toBe(3)
|
||||||
|
expect(result.rrule.byday).toBeNull()
|
||||||
|
expect(result.exdates).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merges biweekly (14-day) series into WEEKLY;INTERVAL=2', () => {
|
||||||
|
const evs = [0, 14, 28, 42, 56].map(off => mk(iso(off), 'Bi'))
|
||||||
|
const result = fitSeries(evs)
|
||||||
|
expect(result).not.toBeNull()
|
||||||
|
expect(result.rrule.freq).toBe('WEEKLY')
|
||||||
|
expect(result.rrule.interval).toBe(2)
|
||||||
|
expect(result.rrule.byday).toBe('MO')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects interleaved weekday classes (gcd=1)', () => {
|
||||||
|
// 周一 + 周二, 同名同时同地, 排序后 gap 1,6,1,6,1,6 -> gcd 1
|
||||||
|
const mon = [0, 7, 14].map(off => mk(iso(off), 'Math', 'R1'))
|
||||||
|
const tue = [1, 8, 15].map(off => mk(iso(off), 'Math', 'R1'))
|
||||||
|
expect(fitSeries([...mon, ...tue])).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('splitIntoChains', () => {
|
||||||
|
it('splits interleaved Mon+Tue classes into 2 weekly chains', () => {
|
||||||
|
const dates = [0, 1, 7, 8, 14, 15].map(iso)
|
||||||
|
const chains = splitIntoChains(dates)
|
||||||
|
expect(chains).toHaveLength(2)
|
||||||
|
expect(chains[0]).toEqual([iso(0), iso(7), iso(14)])
|
||||||
|
expect(chains[1]).toEqual([iso(1), iso(8), iso(15)])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('splits user example (gaps 2,5,2,5,2,12,2,5,2,5) into 2 weekly chains', () => {
|
||||||
|
// 两个交错周序列, 中间各暂停一周
|
||||||
|
const dates = [0, 2, 7, 9, 14, 16, 28, 30, 35, 37, 42].map(iso)
|
||||||
|
const chains = splitIntoChains(dates)
|
||||||
|
expect(chains).toHaveLength(2)
|
||||||
|
// 链 A: 0,7,14,28,35,42 (跳过 21)
|
||||||
|
expect(chains[0]).toEqual([iso(0), iso(7), iso(14), iso(28), iso(35), iso(42)])
|
||||||
|
// 链 B: 2,9,16,30,37 (跳过 23)
|
||||||
|
expect(chains[1]).toEqual([iso(2), iso(9), iso(16), iso(30), iso(37)])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('organize', () => {
|
||||||
|
it('merges every-3-day DAILY series (end-to-end via organize)', () => {
|
||||||
|
const evs = [0, 3, 6, 9, 12].map(off => mk(iso(off), 'Med'))
|
||||||
|
const { events: out, stats } = organize(evs)
|
||||||
|
expect(out).toHaveLength(1)
|
||||||
|
expect(stats.series).toBe(1)
|
||||||
|
expect(out[0].rrule.freq).toBe('DAILY')
|
||||||
|
expect(out[0].rrule.interval).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('splits interleaved Mon+Tue into 2 separate WEEKLY events', () => {
|
||||||
|
const mon = [0, 7, 14].map(off => mk(iso(off), 'Math', 'R1'))
|
||||||
|
const tue = [1, 8, 15].map(off => mk(iso(off), 'Math', 'R1'))
|
||||||
|
const { events: out, stats } = organize([...mon, ...tue])
|
||||||
|
expect(out).toHaveLength(2)
|
||||||
|
expect(stats.series).toBe(2)
|
||||||
|
expect(stats.flat).toBe(0)
|
||||||
|
const bydays = out.map(e => e.rrule.byday).sort()
|
||||||
|
expect(bydays).toEqual(['MO', 'TU'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not merge 2 consecutive same-name events (no pairwise-sum candidate)', () => {
|
||||||
|
const evs = [0, 1].map(off => mk(iso(off), 'OneOff'))
|
||||||
|
const { events: out, stats } = organize(evs)
|
||||||
|
expect(stats.series).toBe(0)
|
||||||
|
expect(stats.flat).toBe(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('organize (golden test against org.ics)', () => {
|
describe('organize (golden test against org.ics)', () => {
|
||||||
const { events: organized, stats } = organize(events)
|
const { events: organized, stats } = organize(events)
|
||||||
|
|
||||||
it('collapses 61 events into 8', () => {
|
it('collapses 61 events into 6 (6 series, 0 flat)', () => {
|
||||||
expect(organized).toHaveLength(8)
|
expect(organized).toHaveLength(6)
|
||||||
expect(stats.series).toBe(5)
|
expect(stats.series).toBe(6)
|
||||||
expect(stats.flat).toBe(3)
|
expect(stats.flat).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('produces 5 recurring events with RRULE', () => {
|
it('produces 6 recurring events with RRULE', () => {
|
||||||
const recurring = organized.filter(e => e.rrule !== null)
|
const recurring = organized.filter(e => e.rrule !== null)
|
||||||
expect(recurring).toHaveLength(5)
|
expect(recurring).toHaveLength(6)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('produces 3 flat BUS LAW Workshop events', () => {
|
it('merges BUS LAW Workshop into 1 recurring event with 5 exdates', () => {
|
||||||
const flatWs = organized.filter(
|
const ws = organized.filter(e => e.summary === 'BUS LAW, Workshop')
|
||||||
e => e.summary === 'BUS LAW, Workshop' && e.rrule === null,
|
expect(ws).toHaveLength(1)
|
||||||
)
|
expect(ws[0].rrule).not.toBeNull()
|
||||||
expect(flatWs).toHaveLength(3)
|
expect(ws[0].rrule.freq).toBe('WEEKLY')
|
||||||
|
expect(ws[0].exdates).toHaveLength(5)
|
||||||
|
expect(ws[0].exdates).toContain('2026-08-19')
|
||||||
|
expect(ws[0].exdates).toContain('2026-09-23')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ACC INFO SYS series has 1 exdate (Sep 21)', () => {
|
it('ACC INFO SYS series has 1 exdate (Sep 21)', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user