2405 lines
70 KiB
Markdown
2405 lines
70 KiB
Markdown
# timeTable2 Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Build a pure-frontend Vue3+Vite ICS calendar organizer/editor that parses any `.ics`, collapses flat repeating events into RRULE+EXDATE, supports full editing (add/delete/split), and exports--with zero Python backend.
|
||
|
||
**Architecture:** Plain-model source of truth in a single reactive composable store with snapshot undo. `lib/` holds pure functions (ICAL I/O, GCD organize, recurrence expand) ported 1:1 from `timeTable/ical_organizer.py` and `ical_editor.py`. Components are presentational only. ical.js handles only text↔object conversion; all recurrence logic is hand-ported.
|
||
|
||
**Tech Stack:** Vue 3.5 (SFC `<script setup>`), Vite 6, ical.js ^2.1, Vitest for unit tests, npmmirror registry.
|
||
|
||
**Design spec:** `docs/superpowers/specs/2026-07-13-timetable2-design.md`
|
||
|
||
---
|
||
|
||
## ical.js API notes (verified against ical.js 2.1.0 source)
|
||
|
||
These apply throughout--refer back here:
|
||
|
||
- **Import:** `import ICAL from 'ical.js'` -- default export only, NO named exports.
|
||
- **Parse:** `ICAL.parse(text)` -> jCal array; `new ICAL.Component(jcal)` wraps it.
|
||
- **Subcomponents:** `comp.getAllSubcomponents('vevent')` -> `Component[]`.
|
||
- **Scalar props:** `comp.getFirstPropertyValue('summary')` -> string|null.
|
||
- **DTSTART/DTEND:** `new ICAL.Event(veventComp)` then `event.startDate` / `event.endDate` -> `ICAL.Time`. Fields: `.year .month .day .hour .minute` (month/day are 1-based).
|
||
- **TZID:** `comp.getFirstProperty('dtstart').getParameter('tzid')` -> string|undefined.
|
||
- **RRULE:** `comp.getFirstPropertyValue('rrule')` -> `ICAL.Recur|null`. Direct fields: `.freq .interval .until(Time|null) .count`. BY-parts: `recur.getComponent('byday')` -> `string[]` (NOT `.byday`). Construct: `new ICAL.Recur({freq, interval, byday, until})` (any-case keys; byday may be string or array).
|
||
- **EXDATE:** `comp.getAllProperties('exdate')` -> `Property[]`; each `prop.getValues()` -> `ICAL.Time[]`.
|
||
- **Timezone trap:** IANA zones NOT bundled. Must register from parsed VTIMEZONEs before constructing zoned Times:
|
||
`ICAL.TimezoneService.register(tzid, new ICAL.Timezone({component: vtzComp, tzid}))`.
|
||
- **UTC conversion:** `time.convertToZone(ICAL.Timezone.utcTimezone)` -> new Time. `ICAL.Timezone.utcTimezone` is a static singleton.
|
||
- **Construct calendar:** `new ICAL.Component('vcalendar')`; `updatePropertyWithValue('prodid', x)` for singletons; `addSubcomponent(childComp)` to add VEVENT/VTIMEZONE.
|
||
- **VTIMEZONE round-trip:** `addSubcomponent(vtzComp)` moves it (removes from old parent). To reuse without moving: `new ICAL.Component(vtzComp.jCal)` (shallow jCal copy).
|
||
- **Serialize:** `comp.toString()` -> folded CRLF ICS text.
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
```
|
||
timeTable2/
|
||
├── .gitignore (exists)
|
||
├── .npmrc npmmirror registry
|
||
├── index.html Vite entry
|
||
├── package.json
|
||
├── vite.config.js
|
||
├── vitest.config.js
|
||
├── README.md
|
||
├── docs/superpowers/... (exists: spec + this plan)
|
||
├── test/
|
||
│ ├── golden-org.ics copy of timeTable/org.ics (test fixture)
|
||
│ ├── ical-io.test.js
|
||
│ ├── recur.test.js
|
||
│ └── organize.test.js
|
||
└── src/
|
||
├── main.js
|
||
├── App.vue
|
||
├── composables/useCalendar.js
|
||
├── lib/
|
||
│ ├── weekday.js
|
||
│ ├── ical-io.js
|
||
│ ├── recur.js
|
||
│ └── organize.js
|
||
└── components/
|
||
├── HeaderBar.vue
|
||
├── DropZone.vue
|
||
├── EventList.vue
|
||
├── EventDetail.vue
|
||
└── OccurrenceGrid.vue
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1: Scaffold Vite + Vue3 project with npmmirror
|
||
|
||
**Files:**
|
||
- Create: `.npmrc`
|
||
- Create: `package.json`
|
||
- Create: `vite.config.js`
|
||
- Create: `vitest.config.js`
|
||
- Create: `index.html`
|
||
- Create: `src/main.js`
|
||
- Create: `src/App.vue` (placeholder)
|
||
|
||
- [ ] **Step 1: Create `.npmrc` with npmmirror registry**
|
||
|
||
Create `D:\zcode\timeTable2\.npmrc`:
|
||
```
|
||
registry=https://registry.npmmirror.com
|
||
```
|
||
|
||
- [ ] **Step 2: Create `package.json`**
|
||
|
||
Create `D:\zcode\timeTable2\package.json`:
|
||
```json
|
||
{
|
||
"name": "timetable2",
|
||
"version": "0.1.0",
|
||
"private": true,
|
||
"type": "module",
|
||
"scripts": {
|
||
"dev": "vite",
|
||
"build": "vite build",
|
||
"preview": "vite preview",
|
||
"test": "vitest run",
|
||
"test:watch": "vitest"
|
||
},
|
||
"dependencies": {
|
||
"ical.js": "^2.1.0",
|
||
"vue": "^3.5.0"
|
||
},
|
||
"devDependencies": {
|
||
"@vitejs/plugin-vue": "^5.2.0",
|
||
"vite": "^6.0.0",
|
||
"vitest": "^2.1.0"
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create `vite.config.js`**
|
||
|
||
Create `D:\zcode\timeTable2\vite.config.js`:
|
||
```js
|
||
import { defineConfig } from 'vite'
|
||
import vue from '@vitejs/plugin-vue'
|
||
|
||
export default defineConfig({
|
||
plugins: [vue()],
|
||
base: './',
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 4: Create `vitest.config.js`**
|
||
|
||
Create `D:\zcode\timeTable2\vitest.config.js`:
|
||
```js
|
||
import { defineConfig } from 'vitest/config'
|
||
import vue from '@vitejs/plugin-vue'
|
||
|
||
export default defineConfig({
|
||
plugins: [vue()],
|
||
test: {
|
||
environment: 'node',
|
||
},
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 5: Create `index.html`**
|
||
|
||
Create `D:\zcode\timeTable2\index.html`:
|
||
```html
|
||
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>ICS 日程整理器</title>
|
||
</head>
|
||
<body>
|
||
<div id="app"></div>
|
||
<script type="module" src="/src/main.js"></script>
|
||
</body>
|
||
</html>
|
||
```
|
||
|
||
- [ ] **Step 6: Create `src/main.js`**
|
||
|
||
Create `D:\zcode\timeTable2\src\main.js`:
|
||
```js
|
||
import { createApp } from 'vue'
|
||
import App from './App.vue'
|
||
|
||
createApp(App).mount('#app')
|
||
```
|
||
|
||
- [ ] **Step 7: Create placeholder `src/App.vue`**
|
||
|
||
Create `D:\zcode\timeTable2\src\App.vue`:
|
||
```vue
|
||
<script setup>
|
||
</script>
|
||
|
||
<template>
|
||
<div style="font-family: sans-serif; padding: 40px;">
|
||
<h1>📅 ICS 日程整理器</h1>
|
||
<p>脚手架就绪</p>
|
||
</div>
|
||
</template>
|
||
```
|
||
|
||
- [ ] **Step 8: Install dependencies**
|
||
|
||
Run: `cd /d/zcode/timeTable2 && npm install`
|
||
Expected: installs vue, ical.js, vite, vitest with no errors (using npmmirror).
|
||
|
||
- [ ] **Step 9: Verify dev server starts**
|
||
|
||
Run: `cd /d/zcode/timeTable2 && timeout 8 npm run dev 2>&1 | head -20`
|
||
Expected: Vite prints "VITE ready" and a local URL, no errors.
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
cd /d/zcode/timeTable2
|
||
git add .npmrc package.json package-lock.json vite.config.js vitest.config.js index.html src/main.js src/App.vue
|
||
git commit -m "feat: scaffold Vite + Vue3 project with npmmirror"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: `lib/weekday.js` - weekday constants and helpers
|
||
|
||
**Files:**
|
||
- Create: `src/lib/weekday.js`
|
||
- Create: `test/weekday.test.js`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `D:\zcode\timeTable2\test\weekday.test.js`:
|
||
```js
|
||
import { describe, it, expect } from 'vitest'
|
||
import { DOW, DOW_EN, BYDAY_TO_INDEX, bydayFromDate, dowLabel } from '../src/lib/weekday.js'
|
||
|
||
describe('weekday', () => {
|
||
it('DOW has 7 entries starting Sunday', () => {
|
||
expect(DOW).toHaveLength(7)
|
||
expect(DOW[0]).toBe('周日')
|
||
expect(DOW[6]).toBe('周六')
|
||
})
|
||
|
||
it('DOW_EN indexed by JS getDay (0=Sunday)', () => {
|
||
expect(DOW_EN).toEqual(['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'])
|
||
})
|
||
|
||
it('BYDAY_TO_INDEX maps SU..SA to 0..6', () => {
|
||
expect(BYDAY_TO_INDEX.SU).toBe(0)
|
||
expect(BYDAY_TO_INDEX.SA).toBe(6)
|
||
})
|
||
|
||
it('bydayFromDate returns BYDAY code for a Date', () => {
|
||
// 2026-07-27 is a Monday -> getDay()=1 -> 'MO'
|
||
expect(bydayFromDate(new Date(2026, 6, 27))).toBe('MO')
|
||
// 2026-08-01 is a Saturday -> getDay()=6 -> 'SA'
|
||
expect(bydayFromDate(new Date(2026, 7, 1))).toBe('SA')
|
||
})
|
||
|
||
it('dowLabel returns Chinese label for a Date', () => {
|
||
expect(dowLabel(new Date(2026, 6, 27))).toBe('周一')
|
||
expect(dowLabel(new Date(2026, 6, 26))).toBe('周日')
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd /d/zcode/timeTable2 && npx vitest run test/weekday.test.js 2>&1 | tail -10`
|
||
Expected: FAIL with "Failed to resolve import" or "module not found".
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
Create `D:\zcode\timeTable2\src\lib\weekday.js`:
|
||
```js
|
||
// JS getDay() index: 0=Sunday .. 6=Saturday
|
||
export const DOW = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||
|
||
// BYDAY codes indexed by JS getDay(): DOW_EN[getDay()] => 'SU'..'SA'
|
||
export const DOW_EN = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']
|
||
|
||
export const BYDAY_TO_INDEX = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 }
|
||
|
||
/** Return the BYDAY code ('SU'..'SA') for a JS Date. */
|
||
export function bydayFromDate(date) {
|
||
return DOW_EN[date.getDay()]
|
||
}
|
||
|
||
/** Return the Chinese weekday label for a JS Date. */
|
||
export function dowLabel(date) {
|
||
return DOW[date.getDay()]
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd /d/zcode/timeTable2 && npx vitest run test/weekday.test.js 2>&1 | tail -10`
|
||
Expected: PASS, 5 tests passed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /d/zcode/timeTable2
|
||
git add src/lib/weekday.js test/weekday.test.js
|
||
git commit -m "feat: add weekday constants and helpers"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: `lib/ical-io.js` - ICS text ↔ plain model (parse)
|
||
|
||
This is the largest lib module. We build it in two test-driven halves: parse first, then serialize.
|
||
|
||
**Files:**
|
||
- Create: `test/golden-org.ics` (copy of `timeTable/org.ics`)
|
||
- Create: `src/lib/ical-io.js`
|
||
- Create: `test/ical-io.test.js`
|
||
|
||
- [ ] **Step 1: Copy golden test fixture**
|
||
|
||
Run: `cp /d/zcode/timeTable/org.ics /d/zcode/timeTable2/test/golden-org.ics`
|
||
Expected: file copied (61 VEVENTs).
|
||
|
||
- [ ] **Step 2: Write the failing parse test**
|
||
|
||
Create `D:\zcode\timeTable2\test\ical-io.test.js`:
|
||
```js
|
||
import { describe, it, expect } from 'vitest'
|
||
import { readFileSync } from 'node:fs'
|
||
import { fileURLToPath } from 'node:url'
|
||
import { dirname, join } from 'node:path'
|
||
import { parseICS } from '../src/lib/ical-io.js'
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||
const GOLDEN = readFileSync(join(__dirname, 'golden-org.ics'), 'utf-8')
|
||
|
||
describe('parseICS', () => {
|
||
const { meta, events } = parseICS(GOLDEN)
|
||
|
||
it('parses calendar meta', () => {
|
||
expect(meta.prodid).toBe('-//Allocate//iCal4j 1.0//EN')
|
||
expect(meta.version).toBe('2.0')
|
||
expect(meta.calscale).toBe('GREGORIAN')
|
||
expect(meta.vtimezones.length).toBe(1)
|
||
})
|
||
|
||
it('parses all 61 events', () => {
|
||
expect(events).toHaveLength(61)
|
||
})
|
||
|
||
it('parses first event fields correctly', () => {
|
||
const e = events[0]
|
||
expect(e.summary).toBe('ACC INFO SYS, Tutorial')
|
||
expect(e.location).toBe('CA_B_B471')
|
||
expect(e.dtstartDate).toBe('2026-07-27')
|
||
expect(e.dtstartTime).toBe('18:00')
|
||
expect(e.dtendTime).toBe('20:00')
|
||
expect(e.tzid).toBe('Australia/Melbourne')
|
||
expect(e.rrule).toBeNull()
|
||
expect(e.exdates).toEqual([])
|
||
})
|
||
|
||
it('preserves description with escaped commas', () => {
|
||
const e = events[0]
|
||
expect(e.description).toContain('ACF2400_CA_S2_ON-CAMPUS')
|
||
expect(e.description).toContain('ACC INFO SYS')
|
||
})
|
||
|
||
it('captures dtstamp in _raw', () => {
|
||
expect(events[0]._raw.dtstamp).toBe('20260711T085008Z')
|
||
})
|
||
|
||
it('events have UIDs', () => {
|
||
expect(events[0].uid).toBe('uid0')
|
||
expect(events[60].uid).toBe('uid60')
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: Run test to verify it fails**
|
||
|
||
Run: `cd /d/zcode/timeTable2 && npx vitest run test/ical-io.test.js 2>&1 | tail -10`
|
||
Expected: FAIL with "Cannot find module '../src/lib/ical-io.js'".
|
||
|
||
- [ ] **Step 4: Write `parseICS` implementation**
|
||
|
||
Create `D:\zcode\timeTable2\src\lib\ical-io.js`:
|
||
```js
|
||
import ICAL from 'ical.js'
|
||
|
||
// ------------------------------------------------------------------ //
|
||
// Timezone registration (ical.js does not bundle IANA zones)
|
||
// ------------------------------------------------------------------ //
|
||
function registerTimezones(vcalendar) {
|
||
for (const vtz of vcalendar.getAllSubcomponents('vtimezone')) {
|
||
const tzid = vtz.getFirstPropertyValue('tzid')
|
||
if (tzid) {
|
||
ICAL.TimezoneService.register(tzid, new ICAL.Timezone({ component: vtz, tzid }))
|
||
}
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------ //
|
||
// Helpers
|
||
// ------------------------------------------------------------------ //
|
||
function pad2(n) {
|
||
return String(n).padStart(2, '0')
|
||
}
|
||
|
||
function timeToISODate(t) {
|
||
return `${t.year}-${pad2(t.month)}-${pad2(t.day)}`
|
||
}
|
||
|
||
function timeToHHMM(t) {
|
||
return `${pad2(t.hour)}:${pad2(t.minute)}`
|
||
}
|
||
|
||
function getTzid(component, propName) {
|
||
const prop = component.getFirstProperty(propName)
|
||
if (!prop) return 'UTC'
|
||
const tzid = prop.getParameter('tzid')
|
||
return tzid || 'UTC'
|
||
}
|
||
|
||
// ------------------------------------------------------------------ //
|
||
// Parse a single VEVENT component -> plain EventModel
|
||
// ------------------------------------------------------------------ //
|
||
function parseEvent(veventComp) {
|
||
const event = new ICAL.Event(veventComp)
|
||
const start = event.startDate
|
||
const end = event.endDate
|
||
|
||
// Determine tzid
|
||
const tzid = getTzid(veventComp, 'dtstart')
|
||
|
||
// RRULE
|
||
const recur = veventComp.getFirstPropertyValue('rrule')
|
||
let rrule = null
|
||
if (recur) {
|
||
const bydayArr = recur.getComponent('byday') // string[]
|
||
let untilDate = null
|
||
if (recur.until) {
|
||
const u = recur.until
|
||
untilDate = `${u.year}-${pad2(u.month)}-${pad2(u.day)}`
|
||
}
|
||
rrule = {
|
||
freq: recur.freq,
|
||
interval: recur.interval || 1,
|
||
byday: bydayArr.length > 0 ? bydayArr[0] : null,
|
||
untilDate,
|
||
}
|
||
}
|
||
|
||
// EXDATEs
|
||
const exdates = []
|
||
for (const prop of veventComp.getAllProperties('exdate')) {
|
||
for (const t of prop.getValues()) {
|
||
exdates.push(timeToISODate(t))
|
||
}
|
||
}
|
||
|
||
// _raw: capture DTSTAMP and any other unmapped properties
|
||
const _raw = {}
|
||
const dtstamp = veventComp.getFirstPropertyValue('dtstamp')
|
||
if (dtstamp) _raw.dtstamp = dtstamp.toString()
|
||
|
||
return {
|
||
uid: veventComp.getFirstPropertyValue('uid') || '',
|
||
summary: veventComp.getFirstPropertyValue('summary') || '',
|
||
location: veventComp.getFirstPropertyValue('location') || '',
|
||
description: veventComp.getFirstPropertyValue('description') || '',
|
||
dtstartDate: timeToISODate(start),
|
||
dtstartTime: timeToHHMM(start),
|
||
dtendTime: timeToHHMM(end),
|
||
tzid,
|
||
rrule,
|
||
exdates,
|
||
_raw,
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------ //
|
||
// Parse full ICS text -> { meta, events }
|
||
// ------------------------------------------------------------------ //
|
||
export function parseICS(text) {
|
||
const jcal = ICAL.parse(text)
|
||
const vcalendar = new ICAL.Component(jcal)
|
||
|
||
// Register timezones from VTIMEZONEs before constructing zoned Times
|
||
registerTimezones(vcalendar)
|
||
|
||
// Meta
|
||
const meta = {
|
||
prodid: vcalendar.getFirstPropertyValue('prodid') || '',
|
||
version: vcalendar.getFirstPropertyValue('version') || '2.0',
|
||
calscale: vcalendar.getFirstPropertyValue('calscale') || 'GREGORIAN',
|
||
method: vcalendar.getFirstPropertyValue('method') || null,
|
||
xWrCalname: vcalendar.getFirstPropertyValue('x-wr-calname') || null,
|
||
xWrTimezone: vcalendar.getFirstPropertyValue('x-wr-timezone') || null,
|
||
vtimezones: vcalendar.getAllSubcomponents('vtimezone'),
|
||
}
|
||
|
||
// Events
|
||
const events = vcalendar.getAllSubcomponents('vevent').map(parseEvent)
|
||
|
||
return { meta, events }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run test to verify it passes**
|
||
|
||
Run: `cd /d/zcode/timeTable2 && npx vitest run test/ical-io.test.js 2>&1 | tail -15`
|
||
Expected: PASS, 6 tests passed.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd /d/zcode/timeTable2
|
||
git add src/lib/ical-io.js test/ical-io.test.js test/golden-org.ics
|
||
git commit -m "feat: implement parseICS with ical.js"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: `lib/ical-io.js` - serialize (round-trip)
|
||
|
||
**Files:**
|
||
- Modify: `src/lib/ical-io.js` (add `serializeICS`)
|
||
- Modify: `test/ical-io.test.js` (add round-trip tests)
|
||
|
||
- [ ] **Step 1: Add failing serialize tests**
|
||
|
||
Append to `D:\zcode\timeTable2\test\ical-io.test.js` (before the closing, or add a new describe block at the end):
|
||
```js
|
||
import { serializeICS } from '../src/lib/ical-io.js'
|
||
|
||
describe('serializeICS round-trip', () => {
|
||
it('round-trips: parse(serialize(parse(x))) equals parse(x)', () => {
|
||
const original = parseICS(GOLDEN)
|
||
const text = serializeICS(original)
|
||
const reparsed = parseICS(text)
|
||
|
||
expect(reparsed.events).toHaveLength(original.events.length)
|
||
// Spot-check first event
|
||
const e0 = reparsed.events[0]
|
||
expect(e0.summary).toBe(original.events[0].summary)
|
||
expect(e0.dtstartDate).toBe(original.events[0].dtstartDate)
|
||
expect(e0.dtstartTime).toBe(original.events[0].dtstartTime)
|
||
expect(e0.tzid).toBe(original.events[0].tzid)
|
||
expect(e0._raw.dtstamp).toBe(original.events[0]._raw.dtstamp)
|
||
})
|
||
|
||
it('preserves VTIMEZONE in output', () => {
|
||
const { meta } = parseICS(serializeICS(parseICS(GOLDEN)))
|
||
expect(meta.vtimezones).toHaveLength(1)
|
||
})
|
||
|
||
it('serializes a manually-built recurring event', () => {
|
||
const ev = {
|
||
uid: 'test-1',
|
||
summary: 'Test Event',
|
||
location: 'Room A',
|
||
description: 'Desc',
|
||
dtstartDate: '2026-07-27',
|
||
dtstartTime: '09:00',
|
||
dtendTime: '10:30',
|
||
tzid: 'UTC',
|
||
rrule: { freq: 'WEEKLY', interval: 1, byday: 'MO', untilDate: '2026-10-19' },
|
||
exdates: ['2026-09-21'],
|
||
_raw: { dtstamp: '20260711T085008Z' },
|
||
}
|
||
const text = serializeICS({
|
||
meta: {
|
||
prodid: '-//Test//EN', version: '2.0', calscale: 'GREGORIAN',
|
||
method: null, xWrCalname: null, xWrTimezone: null, vtimezones: [],
|
||
},
|
||
events: [ev],
|
||
})
|
||
expect(text).toContain('BEGIN:VEVENT')
|
||
expect(text).toContain('SUMMARY:Test Event')
|
||
expect(text).toContain('RRULE:FREQ=WEEKLY')
|
||
expect(text).toContain('BYDAY=MO')
|
||
expect(text).toContain('EXDATE')
|
||
expect(text).toContain('DTSTART:20260727T090000Z')
|
||
|
||
// Re-parse and verify
|
||
const reparsed = parseICS(text)
|
||
expect(reparsed.events[0].rrule.freq).toBe('WEEKLY')
|
||
expect(reparsed.events[0].rrule.byday).toBe('MO')
|
||
expect(reparsed.events[0].exdates).toContain('2026-09-21')
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `cd /d/zcode/timeTable2 && npx vitest run test/ical-io.test.js 2>&1 | tail -10`
|
||
Expected: FAIL with "serializeICS is not a function" or import error.
|
||
|
||
- [ ] **Step 3: Add `serializeICS` to `src/lib/ical-io.js`**
|
||
|
||
Add this code at the end of `D:\zcode\timeTable2\src\lib\ical-io.js` (after the `parseICS` function):
|
||
```js
|
||
|
||
// ------------------------------------------------------------------ //
|
||
// Helpers for serialization
|
||
// ------------------------------------------------------------------ //
|
||
function parseISODate(s) {
|
||
const [y, m, d] = s.split('-').map(Number)
|
||
return { year: y, month: m, day: d }
|
||
}
|
||
|
||
function parseHHMM(s) {
|
||
const [h, m] = s.split(':').map(Number)
|
||
return { hour: h, minute: m }
|
||
}
|
||
|
||
function getZone(tzid) {
|
||
if (tzid === 'UTC' || tzid === 'Z') return ICAL.Timezone.utcTimezone
|
||
const z = ICAL.TimezoneService.get(tzid)
|
||
return z || ICAL.Timezone.utcTimezone
|
||
}
|
||
|
||
function makeTime(dateStr, timeStr, tzid) {
|
||
const d = parseISODate(dateStr)
|
||
const t = parseHHMM(timeStr)
|
||
return new ICAL.Time(
|
||
{ year: d.year, month: d.month, day: d.day, hour: t.hour, minute: t.minute, isDate: false },
|
||
getZone(tzid),
|
||
)
|
||
}
|
||
|
||
// ------------------------------------------------------------------ //
|
||
// Serialize { meta, events } -> ICS text
|
||
// ------------------------------------------------------------------ //
|
||
export function serializeICS({ meta, events }) {
|
||
const vcalendar = new ICAL.Component('vcalendar')
|
||
|
||
vcalendar.updatePropertyWithValue('prodid', meta.prodid || '-//timeTable2//EN')
|
||
vcalendar.updatePropertyWithValue('version', meta.version || '2.0')
|
||
if (meta.calscale) vcalendar.updatePropertyWithValue('calscale', meta.calscale)
|
||
if (meta.method) vcalendar.updatePropertyWithValue('method', meta.method)
|
||
if (meta.xWrCalname) vcalendar.updatePropertyWithValue('x-wr-calname', meta.xWrCalname)
|
||
if (meta.xWrTimezone) vcalendar.updatePropertyWithValue('x-wr-timezone', meta.xWrTimezone)
|
||
|
||
// Re-add VTIMEZONEs (clone jCal to avoid moving from original)
|
||
for (const vtz of meta.vtimezones || []) {
|
||
vcalendar.addSubcomponent(new ICAL.Component(vtz.jCal))
|
||
}
|
||
|
||
for (const ev of events) {
|
||
const vevent = new ICAL.Component('vevent')
|
||
|
||
vevent.updatePropertyWithValue('uid', ev.uid)
|
||
if (ev.summary) vevent.updatePropertyWithValue('summary', ev.summary)
|
||
if (ev.location) vevent.updatePropertyWithValue('location', ev.location)
|
||
if (ev.description) vevent.updatePropertyWithValue('description', ev.description)
|
||
|
||
// DTSTART
|
||
const dtstart = makeTime(ev.dtstartDate, ev.dtstartTime, ev.tzid)
|
||
const dsProp = vevent.addPropertyWithValue('dtstart', dtstart)
|
||
if (ev.tzid && ev.tzid !== 'UTC') dsProp.setParameter('tzid', ev.tzid)
|
||
|
||
// DTEND
|
||
const dtend = makeTime(ev.dtstartDate, ev.dtendTime, ev.tzid)
|
||
const deProp = vevent.addPropertyWithValue('dtend', dtend)
|
||
if (ev.tzid && ev.tzid !== 'UTC') deProp.setParameter('tzid', ev.tzid)
|
||
|
||
// DTSTAMP
|
||
const dtstamp = ev._raw?.dtstamp || ICAL.Time.now().toUTCString()
|
||
vevent.updatePropertyWithValue('dtstamp', dtstamp)
|
||
|
||
// RRULE
|
||
if (ev.rrule) {
|
||
const r = ev.rrule
|
||
const recurData = { freq: r.freq, interval: r.interval || 1 }
|
||
if (r.freq === 'WEEKLY' && r.byday) recurData.byday = r.byday
|
||
if (r.untilDate) {
|
||
// UNTIL as UTC (RFC 5545): build zoned time at 23:59 then convert
|
||
const untilLocal = makeTime(r.untilDate, '23:59', ev.tzid)
|
||
recurData.until = untilLocal.convertToZone(ICAL.Timezone.utcTimezone)
|
||
}
|
||
const recur = new ICAL.Recur(recurData)
|
||
vevent.addPropertyWithValue('rrule', recur)
|
||
}
|
||
|
||
// EXDATEs (in DTSTART's timezone)
|
||
for (const exDate of ev.exdates || []) {
|
||
const exTime = makeTime(exDate, ev.dtstartTime, ev.tzid)
|
||
const exProp = vevent.addPropertyWithValue('exdate', exTime)
|
||
if (ev.tzid && ev.tzid !== 'UTC') exProp.setParameter('tzid', ev.tzid)
|
||
}
|
||
|
||
vcalendar.addSubcomponent(vevent)
|
||
}
|
||
|
||
return vcalendar.toString()
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `cd /d/zcode/timeTable2 && npx vitest run test/ical-io.test.js 2>&1 | tail -15`
|
||
Expected: PASS, 9 tests passed (6 parse + 3 serialize).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /d/zcode/timeTable2
|
||
git add src/lib/ical-io.js test/ical-io.test.js
|
||
git commit -m "feat: implement serializeICS with round-trip support"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: `lib/recur.js` - recurrence grid expansion
|
||
|
||
**Files:**
|
||
- Create: `src/lib/recur.js`
|
||
- Create: `test/recur.test.js`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `D:\zcode\timeTable2\test\recur.test.js`:
|
||
```js
|
||
import { describe, it, expect } from 'vitest'
|
||
import { expandOccurrences, intervalDays } from '../src/lib/recur.js'
|
||
|
||
describe('intervalDays', () => {
|
||
it('returns 7 for WEEKLY interval=1', () => {
|
||
const ev = { rrule: { freq: 'WEEKLY', interval: 1 } }
|
||
expect(intervalDays(ev)).toBe(7)
|
||
})
|
||
|
||
it('returns 14 for WEEKLY interval=2', () => {
|
||
const ev = { rrule: { freq: 'WEEKLY', interval: 2 } }
|
||
expect(intervalDays(ev)).toBe(14)
|
||
})
|
||
|
||
it('returns interval for DAILY', () => {
|
||
const ev = { rrule: { freq: 'DAILY', interval: 3 } }
|
||
expect(intervalDays(ev)).toBe(3)
|
||
})
|
||
})
|
||
|
||
describe('expandOccurrences', () => {
|
||
it('returns single occurrence for non-recurring event', () => {
|
||
const ev = { dtstartDate: '2026-07-27', rrule: null, exdates: [] }
|
||
const occs = expandOccurrences(ev)
|
||
expect(occs).toHaveLength(1)
|
||
expect(occs[0]).toEqual({ date: '2026-07-27', skipped: false })
|
||
})
|
||
|
||
it('expands weekly event from Jul 27 to Oct 19', () => {
|
||
const ev = {
|
||
dtstartDate: '2026-07-27',
|
||
rrule: { freq: 'WEEKLY', interval: 1, byday: 'MO', untilDate: '2026-10-19' },
|
||
exdates: ['2026-09-21'],
|
||
}
|
||
const occs = expandOccurrences(ev)
|
||
// Jul 27 + 12 weeks = Oct 19 -> 13 occurrences
|
||
expect(occs).toHaveLength(13)
|
||
expect(occs[0].date).toBe('2026-07-27')
|
||
expect(occs[12].date).toBe('2026-10-19')
|
||
// Sep 21 is skipped
|
||
const sep21 = occs.find(o => o.date === '2026-09-21')
|
||
expect(sep21.skipped).toBe(true)
|
||
// Others not skipped
|
||
expect(occs[0].skipped).toBe(false)
|
||
})
|
||
|
||
it('respects exdates set', () => {
|
||
const ev = {
|
||
dtstartDate: '2026-07-27',
|
||
rrule: { freq: 'WEEKLY', interval: 1, byday: 'MO', untilDate: '2026-08-24' },
|
||
exdates: ['2026-08-03', '2026-08-17'],
|
||
}
|
||
const occs = expandOccurrences(ev)
|
||
expect(occs).toHaveLength(5) // Jul27, Aug3, Aug10, Aug17, Aug24
|
||
expect(occs[1].skipped).toBe(true) // Aug 3
|
||
expect(occs[2].skipped).toBe(false) // Aug 10
|
||
expect(occs[3].skipped).toBe(true) // Aug 17
|
||
})
|
||
|
||
it('caps at 500 occurrences', () => {
|
||
const ev = {
|
||
dtstartDate: '2020-01-01',
|
||
rrule: { freq: 'DAILY', interval: 1, untilDate: '2030-12-31' },
|
||
exdates: [],
|
||
}
|
||
const occs = expandOccurrences(ev)
|
||
expect(occs.length).toBeLessThanOrEqual(500)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd /d/zcode/timeTable2 && npx vitest run test/recur.test.js 2>&1 | tail -10`
|
||
Expected: FAIL with "Cannot find module".
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
Create `D:\zcode\timeTable2\src\lib\recur.js`:
|
||
```js
|
||
/**
|
||
* Recurrence grid expansion - port of ical_editor.py's occurrence generation.
|
||
* occurrences are computed on-demand from exdates, never stored on the model.
|
||
*/
|
||
|
||
/** Interval in days for a recurring event. */
|
||
export function intervalDays(ev) {
|
||
if (!ev.rrule) return 0
|
||
const { freq, interval } = ev.rrule
|
||
const step = interval || 1
|
||
return freq === 'WEEKLY' ? 7 * step : step
|
||
}
|
||
|
||
function toISO(date) {
|
||
const y = date.getFullYear()
|
||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||
const d = String(date.getDate()).padStart(2, '0')
|
||
return `${y}-${m}-${d}`
|
||
}
|
||
|
||
/**
|
||
* Expand a recurring event into an occurrence grid.
|
||
* Returns [{ date: 'YYYY-MM-DD', skipped: boolean }].
|
||
*/
|
||
export function expandOccurrences(ev) {
|
||
// Non-recurring: single occurrence
|
||
if (!ev.rrule) {
|
||
return [{ date: ev.dtstartDate, skipped: false }]
|
||
}
|
||
|
||
const step = intervalDays(ev)
|
||
if (step <= 0) {
|
||
return [{ date: ev.dtstartDate, skipped: false }]
|
||
}
|
||
|
||
const start = new Date(ev.dtstartDate + 'T00:00:00')
|
||
const until = ev.rrule.untilDate
|
||
? new Date(ev.rrule.untilDate + 'T00:00:00')
|
||
: new Date(start.getTime() + 365 * 24 * 3600 * 1000) // default +1 year
|
||
|
||
const exdateSet = new Set(ev.exdates || [])
|
||
const occs = []
|
||
const cur = new Date(start)
|
||
let count = 0
|
||
|
||
while (cur <= until && count < 500) {
|
||
const iso = toISO(cur)
|
||
occs.push({ date: iso, skipped: exdateSet.has(iso) })
|
||
cur.setDate(cur.getDate() + step)
|
||
count++
|
||
}
|
||
|
||
return occs
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd /d/zcode/timeTable2 && npx vitest run test/recur.test.js 2>&1 | tail -10`
|
||
Expected: PASS, 6 tests passed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /d/zcode/timeTable2
|
||
git add src/lib/recur.js test/recur.test.js
|
||
git commit -m "feat: add recurrence grid expansion"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: `lib/organize.js` - GCD dedup algorithm (port of ical_organizer.py)
|
||
|
||
**Files:**
|
||
- Create: `src/lib/organize.js`
|
||
- Create: `test/organize.test.js`
|
||
|
||
- [ ] **Step 1: Write the failing golden test**
|
||
|
||
Create `D:\zcode\timeTable2\test\organize.test.js`:
|
||
```js
|
||
import { describe, it, expect } from 'vitest'
|
||
import { readFileSync } from 'node:fs'
|
||
import { fileURLToPath } from 'node:url'
|
||
import { dirname, join } from 'node:path'
|
||
import { parseICS } from '../src/lib/ical-io.js'
|
||
import { organize, seriesKey, detectInterval, fitSeries } from '../src/lib/organize.js'
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||
const GOLDEN = readFileSync(join(__dirname, 'golden-org.ics'), 'utf-8')
|
||
const { events } = parseICS(GOLDEN)
|
||
|
||
describe('seriesKey', () => {
|
||
it('groups events with same summary/location/time/duration/weekday', () => {
|
||
const e1 = events[0] // ACC INFO SYS Tutorial, Mon 18:00
|
||
const e2 = events[1] // same series, next week
|
||
expect(seriesKey(e1)).toBe(seriesKey(e2))
|
||
})
|
||
|
||
it('separates events with different summary', () => {
|
||
const e1 = events[0] // ACC INFO SYS
|
||
// Find a BUS LAW event
|
||
const eBus = events.find(e => e.summary.startsWith('BUS LAW, Tutorial'))
|
||
expect(seriesKey(e1)).not.toBe(seriesKey(eBus))
|
||
})
|
||
})
|
||
|
||
describe('detectInterval', () => {
|
||
it('returns 7 for weekly dates', () => {
|
||
const dates = ['2026-07-27', '2026-08-03', '2026-08-10', '2026-08-17']
|
||
expect(detectInterval(dates)).toBe(7)
|
||
})
|
||
|
||
it('returns null for single date', () => {
|
||
expect(detectInterval(['2026-07-27'])).toBeNull()
|
||
})
|
||
|
||
it('returns 7 for dates with a skip (gaps 7,7,14,7)', () => {
|
||
const dates = ['2026-07-27', '2026-08-03', '2026-08-10', '2026-08-24', '2026-08-31']
|
||
expect(detectInterval(dates)).toBe(7)
|
||
})
|
||
})
|
||
|
||
describe('fitSeries', () => {
|
||
it('fits ACC INFO SYS series (12 events, 1 skip)', () => {
|
||
const accEvents = events.filter(e => e.summary === 'ACC INFO SYS, Tutorial')
|
||
const result = fitSeries(accEvents)
|
||
expect(result).not.toBeNull()
|
||
expect(result.rrule.freq).toBe('WEEKLY')
|
||
expect(result.rrule.interval).toBe(1)
|
||
expect(result.rrule.byday).toBe('MO')
|
||
expect(result.exdates).toContain('2026-09-21')
|
||
})
|
||
|
||
it('rejects BUS LAW Workshop (too sparse: 3 events, 5 missing)', () => {
|
||
const wsEvents = events.filter(e => e.summary === 'BUS LAW, Workshop')
|
||
expect(wsEvents).toHaveLength(3)
|
||
expect(fitSeries(wsEvents)).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('organize (golden test against org.ics)', () => {
|
||
const { events: organized, stats } = organize(events)
|
||
|
||
it('collapses 61 events into 8', () => {
|
||
expect(organized).toHaveLength(8)
|
||
expect(stats.series).toBe(5)
|
||
expect(stats.flat).toBe(3)
|
||
})
|
||
|
||
it('produces 5 recurring events with RRULE', () => {
|
||
const recurring = organized.filter(e => e.rrule !== null)
|
||
expect(recurring).toHaveLength(5)
|
||
})
|
||
|
||
it('produces 3 flat BUS LAW Workshop events', () => {
|
||
const flatWs = organized.filter(
|
||
e => e.summary === 'BUS LAW, Workshop' && e.rrule === null,
|
||
)
|
||
expect(flatWs).toHaveLength(3)
|
||
})
|
||
|
||
it('ACC INFO SYS series has 1 exdate (Sep 21)', () => {
|
||
const acc = organized.find(e => e.summary === 'ACC INFO SYS, Tutorial')
|
||
expect(acc.rrule).not.toBeNull()
|
||
expect(acc.exdates).toEqual(['2026-09-21'])
|
||
})
|
||
|
||
it('BUS LAW Tutorial series has 2 exdates', () => {
|
||
const bl = organized.find(e => e.summary === 'BUS LAW, Tutorial')
|
||
expect(bl.rrule).not.toBeNull()
|
||
expect(bl.exdates).toHaveLength(2)
|
||
expect(bl.exdates).toContain('2026-08-31')
|
||
expect(bl.exdates).toContain('2026-09-21')
|
||
})
|
||
|
||
it('preserves original UID of base event', () => {
|
||
const acc = organized.find(e => e.summary === 'ACC INFO SYS, Tutorial')
|
||
expect(acc.uid).toBe('uid0') // first event in series
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run test to verify it fails**
|
||
|
||
Run: `cd /d/zcode/timeTable2 && npx vitest run test/organize.test.js 2>&1 | tail -10`
|
||
Expected: FAIL with "Cannot find module".
|
||
|
||
- [ ] **Step 3: Write the implementation**
|
||
|
||
Create `D:\zcode\timeTable2\src\lib\organize.js`:
|
||
```js
|
||
/**
|
||
* GCD-based recurrence detection - port of ical_organizer.py.
|
||
* Collapses a flat list of per-occurrence events into RRULE + EXDATE.
|
||
*/
|
||
import { bydayFromDate } from './weekday.js'
|
||
|
||
// ------------------------------------------------------------------ //
|
||
// Grouping
|
||
// ------------------------------------------------------------------ //
|
||
/**
|
||
* Identity of a recurring series.
|
||
* Two events belong together iff they share summary, location, start
|
||
* wall-clock time, duration (minutes), and weekday.
|
||
*/
|
||
export function seriesKey(ev) {
|
||
const start = new Date(ev.dtstartDate + 'T' + ev.dtstartTime + ':00')
|
||
const [sh, sm] = ev.dtstartTime.split(':').map(Number)
|
||
const [eh, em] = ev.dtendTime.split(':').map(Number)
|
||
const durationMin = (eh * 60 + em) - (sh * 60 + sm)
|
||
return [ev.summary, ev.location, ev.dtstartTime, durationMin, start.getDay()].join('|')
|
||
}
|
||
|
||
// ------------------------------------------------------------------ //
|
||
// Interval detection
|
||
// ------------------------------------------------------------------ //
|
||
function gcd(a, b) {
|
||
while (b) { [a, b] = [b, a % b] }
|
||
return a
|
||
}
|
||
|
||
/**
|
||
* Return the recurrence interval in days, or null if irregular.
|
||
* Computes GCD of all consecutive date gaps.
|
||
*/
|
||
export function detectInterval(dateStrings) {
|
||
if (dateStrings.length < 2) return null
|
||
const dates = [...dateStrings].sort()
|
||
const gaps = []
|
||
for (let i = 0; i < dates.length - 1; i++) {
|
||
const a = new Date(dates[i] + 'T00:00:00')
|
||
const b = new Date(dates[i + 1] + 'T00:00:00')
|
||
gaps.push(Math.round((b - a) / (24 * 3600 * 1000)))
|
||
}
|
||
let g = gaps[0]
|
||
for (let i = 1; i < gaps.length; i++) {
|
||
g = gcd(g, gaps[i])
|
||
}
|
||
return g > 0 ? g : null
|
||
}
|
||
|
||
// ------------------------------------------------------------------ //
|
||
// Series fitting
|
||
// ------------------------------------------------------------------ /**
|
||
* Try to collapse events into one recurring event.
|
||
* Returns { base, rrule, exdates } or null to decline.
|
||
*/
|
||
export function fitSeries(evs) {
|
||
if (evs.length < 2) return null
|
||
|
||
const sorted = [...evs].sort((a, b) =>
|
||
a.dtstartDate < b.dtstartDate ? -1 : a.dtstartDate > b.dtstartDate ? 1 : 0,
|
||
)
|
||
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)
|
||
|
||
// Build expected grid first..last step interval days
|
||
const expected = []
|
||
const cur = new Date(first.dtstartDate + 'T00:00:00')
|
||
const lastDate = new Date(last.dtstartDate + 'T00:00:00')
|
||
while (cur <= lastDate) {
|
||
const y = cur.getFullYear()
|
||
const m = String(cur.getMonth() + 1).padStart(2, '0')
|
||
const d = String(cur.getDate()).padStart(2, '0')
|
||
expected.push(`${y}-${m}-${d}`)
|
||
cur.setDate(cur.getDate() + interval)
|
||
}
|
||
|
||
const expectedSet = new Set(expected)
|
||
const missing = expected.filter(d => !present.has(d))
|
||
const offGrid = starts.filter(d => !expectedSet.has(d))
|
||
if (offGrid.length > 0) return null
|
||
|
||
// "Mostly regular": at least 2 occurrences and occurrences >= skips
|
||
if (evs.length < 2 || missing.length >= evs.length) return null
|
||
|
||
// Build rrule
|
||
const firstDate = new Date(first.dtstartDate + 'T00:00:00')
|
||
let freq, step, byday
|
||
if (interval % 7 === 0) {
|
||
freq = 'WEEKLY'
|
||
step = interval / 7
|
||
byday = bydayFromDate(firstDate)
|
||
} else {
|
||
freq = 'DAILY'
|
||
step = interval
|
||
byday = null
|
||
}
|
||
|
||
const rrule = { freq, interval: step, byday, untilDate: last.dtstartDate }
|
||
return { base: first, rrule, exdates: missing }
|
||
}
|
||
|
||
// ------------------------------------------------------------------ //
|
||
// Organize
|
||
// ------------------------------------------------------------------ //
|
||
/**
|
||
* Collapse flat events into recurring series.
|
||
* Returns { events, stats: { series, flat } }.
|
||
*/
|
||
export function organize(events) {
|
||
// Group by series key
|
||
const groups = new Map()
|
||
for (const ev of events) {
|
||
const key = seriesKey(ev)
|
||
if (!groups.has(key)) groups.set(key, [])
|
||
groups.get(key).push(ev)
|
||
}
|
||
|
||
const result = []
|
||
let nSeries = 0
|
||
let nFlat = 0
|
||
|
||
for (const evs of groups.values()) {
|
||
// Sort by date
|
||
evs.sort((a, b) =>
|
||
a.dtstartDate < b.dtstartDate ? -1 : a.dtstartDate > b.dtstartDate ? 1 : 0,
|
||
)
|
||
|
||
const fit = fitSeries(evs)
|
||
if (fit) {
|
||
// Collapsed event: preserve base's UID (not reassign)
|
||
result.push({
|
||
...fit.base,
|
||
rrule: fit.rrule,
|
||
exdates: fit.exdates,
|
||
})
|
||
nSeries++
|
||
} else {
|
||
// Keep flat
|
||
for (const ev of evs) {
|
||
result.push(ev)
|
||
nFlat++
|
||
}
|
||
}
|
||
}
|
||
|
||
return { events: result, stats: { series: nSeries, flat: nFlat } }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test to verify it passes**
|
||
|
||
Run: `cd /d/zcode/timeTable2 && npx vitest run test/organize.test.js 2>&1 | tail -15`
|
||
Expected: PASS, all tests (seriesKey 2 + detectInterval 3 + fitSeries 2 + organize 6 = 13) passed.
|
||
|
||
- [ ] **Step 5: Run all tests to confirm no regressions**
|
||
|
||
Run: `cd /d/zcode/timeTable2 && npx vitest run 2>&1 | tail -15`
|
||
Expected: PASS, all tests across all files pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd /d/zcode/timeTable2
|
||
git add src/lib/organize.js test/organize.test.js
|
||
git commit -m "feat: port GCD dedup algorithm from ical_organizer.py"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: `composables/useCalendar.js` - central store with undo
|
||
|
||
**Files:**
|
||
- Create: `src/composables/useCalendar.js`
|
||
|
||
- [ ] **Step 1: Write the store**
|
||
|
||
Create `D:\zcode\timeTable2\src\composables\useCalendar.js`:
|
||
```js
|
||
import { reactive, computed } from 'vue'
|
||
import { parseICS, serializeICS } from '../lib/ical-io.js'
|
||
import { organize as organizeEvents } from '../lib/organize.js'
|
||
|
||
const MAX_HISTORY = 50
|
||
|
||
// Singleton reactive state
|
||
const state = reactive({
|
||
meta: null,
|
||
events: [],
|
||
selectedUid: null,
|
||
history: [],
|
||
redoStack: [],
|
||
fileName: null,
|
||
dirty: false,
|
||
})
|
||
|
||
// ------------------------------------------------------------------ //
|
||
// Snapshot / undo
|
||
// ------------------------------------------------------------------ //
|
||
function snapshot() {
|
||
return {
|
||
meta: state.meta ? JSON.parse(JSON.stringify(state.meta)) : null,
|
||
events: JSON.parse(JSON.stringify(state.events)),
|
||
selectedUid: state.selectedUid,
|
||
}
|
||
}
|
||
|
||
function pushHistory() {
|
||
state.history.push(snapshot())
|
||
if (state.history.length > MAX_HISTORY) state.history.shift()
|
||
state.redoStack = []
|
||
}
|
||
|
||
function restore(snap) {
|
||
state.meta = snap.meta ? JSON.parse(JSON.stringify(snap.meta)) : null
|
||
state.events = JSON.parse(JSON.stringify(snap.events))
|
||
state.selectedUid = snap.selectedUid
|
||
}
|
||
|
||
// ------------------------------------------------------------------ //
|
||
// Actions
|
||
// ------------------------------------------------------------------ //
|
||
function loadFile(file) {
|
||
return file.text().then((text) => {
|
||
const parsed = parseICS(text)
|
||
state.meta = parsed.meta
|
||
state.events = parsed.events
|
||
state.selectedUid = null
|
||
state.history = []
|
||
state.redoStack = []
|
||
state.fileName = file.name
|
||
state.dirty = false
|
||
})
|
||
}
|
||
|
||
function organize() {
|
||
if (state.events.length === 0) return
|
||
pushHistory()
|
||
const result = organizeEvents(state.events)
|
||
state.events = result.events
|
||
state.dirty = true
|
||
return result.stats
|
||
}
|
||
|
||
function updateEvent(uid, patch) {
|
||
pushHistory()
|
||
const ev = state.events.find((e) => e.uid === uid)
|
||
if (ev) {
|
||
Object.assign(ev, patch)
|
||
state.dirty = true
|
||
}
|
||
}
|
||
|
||
function deleteEvent(uid) {
|
||
pushHistory()
|
||
state.events = state.events.filter((e) => e.uid !== uid)
|
||
if (state.selectedUid === uid) state.selectedUid = null
|
||
state.dirty = true
|
||
}
|
||
|
||
function addEvent() {
|
||
pushHistory()
|
||
const today = new Date()
|
||
const y = today.getFullYear()
|
||
const m = String(today.getMonth() + 1).padStart(2, '0')
|
||
const d = String(today.getDate()).padStart(2, '0')
|
||
const ev = {
|
||
uid: crypto.randomUUID(),
|
||
summary: '新日程',
|
||
location: '',
|
||
description: '',
|
||
dtstartDate: `${y}-${m}-${d}`,
|
||
dtstartTime: '09:00',
|
||
dtendTime: '10:00',
|
||
tzid: 'UTC',
|
||
rrule: null,
|
||
exdates: [],
|
||
_raw: { dtstamp: new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z' },
|
||
}
|
||
state.events.push(ev)
|
||
state.selectedUid = ev.uid
|
||
state.dirty = true
|
||
}
|
||
|
||
function splitEvent(uid, splitDate) {
|
||
const ev = state.events.find((e) => e.uid === uid)
|
||
if (!ev || !ev.rrule) return
|
||
|
||
// Snap splitDate to the next grid point >= splitDate
|
||
const step = ev.rrule.freq === 'WEEKLY' ? 7 * (ev.rrule.interval || 1) : (ev.rrule.interval || 1)
|
||
const start = new Date(ev.dtstartDate + 'T00:00:00')
|
||
let snap = new Date(splitDate + 'T00:00:00')
|
||
// Advance snap to the next grid point on or after splitDate
|
||
while (snap < start || ((snap - start) / (24 * 3600 * 1000)) % step !== 0) {
|
||
if (snap < start) { snap = new Date(start); break }
|
||
snap.setDate(snap.getDate() + 1)
|
||
if (snap > new Date(ev.rrule.untilDate + 'T00:00:00')) return // out of range
|
||
}
|
||
const snapStr = `${snap.getFullYear()}-${String(snap.getMonth() + 1).padStart(2, '0')}-${String(snap.getDate()).padStart(2, '0')}`
|
||
|
||
// Part A: rrule.untilDate = day before snap (previous grid point)
|
||
const prevGrid = new Date(snap)
|
||
prevGrid.setDate(prevGrid.getDate() - step)
|
||
const prevStr = `${prevGrid.getFullYear()}-${String(prevGrid.getMonth() + 1).padStart(2, '0')}-${String(prevGrid.getDate()).padStart(2, '0')}`
|
||
|
||
pushHistory()
|
||
|
||
const partA = JSON.parse(JSON.stringify(ev))
|
||
partA.rrule = { ...ev.rrule, untilDate: prevStr }
|
||
partA.exdates = ev.exdates.filter((d) => d < splitDate)
|
||
|
||
const partB = JSON.parse(JSON.stringify(ev))
|
||
partB.uid = crypto.randomUUID()
|
||
partB.dtstartDate = snapStr
|
||
partB.exdates = ev.exdates.filter((d) => d >= splitDate)
|
||
|
||
const idx = state.events.findIndex((e) => e.uid === uid)
|
||
state.events.splice(idx, 1, partA, partB)
|
||
state.selectedUid = partB.uid
|
||
state.dirty = true
|
||
}
|
||
|
||
function toggleOccurrence(uid, date) {
|
||
pushHistory()
|
||
const ev = state.events.find((e) => e.uid === uid)
|
||
if (!ev) return
|
||
const i = ev.exdates.indexOf(date)
|
||
if (i >= 0) {
|
||
ev.exdates.splice(i, 1)
|
||
} else {
|
||
ev.exdates.push(date)
|
||
ev.exdates.sort()
|
||
}
|
||
state.dirty = true
|
||
}
|
||
|
||
function undo() {
|
||
if (state.history.length === 0) return
|
||
state.redoStack.push(snapshot())
|
||
restore(state.history.pop())
|
||
}
|
||
|
||
function redo() {
|
||
if (state.redoStack.length === 0) return
|
||
state.history.push(snapshot())
|
||
restore(state.redoStack.pop())
|
||
}
|
||
|
||
function serialize() {
|
||
return serializeICS({ meta: state.meta, events: state.events })
|
||
}
|
||
|
||
function markSaved() {
|
||
state.dirty = false
|
||
}
|
||
|
||
// ------------------------------------------------------------------ //
|
||
// Computed
|
||
// ------------------------------------------------------------------ //
|
||
const selectedEvent = computed(() =>
|
||
state.events.find((e) => e.uid === state.selectedUid) || null,
|
||
)
|
||
|
||
const canUndo = computed(() => state.history.length > 0)
|
||
const canRedo = computed(() => state.redoStack.length > 0)
|
||
const isLoaded = computed(() => state.events.length > 0)
|
||
const stats = computed(() => {
|
||
const nRecur = state.events.filter((e) => e.rrule !== null).length
|
||
return { total: state.events.length, recurring: nRecur, single: state.events.length - nRecur }
|
||
})
|
||
|
||
export function useCalendar() {
|
||
return {
|
||
state,
|
||
selectedEvent,
|
||
canUndo,
|
||
canRedo,
|
||
isLoaded,
|
||
stats,
|
||
loadFile,
|
||
organize,
|
||
updateEvent,
|
||
deleteEvent,
|
||
addEvent,
|
||
splitEvent,
|
||
toggleOccurrence,
|
||
undo,
|
||
redo,
|
||
serialize,
|
||
markSaved,
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Verify it imports without error**
|
||
|
||
Run: `cd /d/zcode/timeTable2 && node -e "import('./src/composables/useCalendar.js').then(() => console.log('OK')).catch(e => { console.error(e); process.exit(1) })"`
|
||
Expected: prints "OK" (Vue's reactivity works in Node since it's framework-agnostic).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /d/zcode/timeTable2
|
||
git add src/composables/useCalendar.js
|
||
git commit -m "feat: add central store with snapshot undo/redo"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: `DropZone.vue` + `App.vue` - file loading
|
||
|
||
**Files:**
|
||
- Create: `src/components/DropZone.vue`
|
||
- Modify: `src/App.vue`
|
||
|
||
- [ ] **Step 1: Create `DropZone.vue`**
|
||
|
||
Create `D:\zcode\timeTable2\src\components\DropZone.vue`:
|
||
```vue
|
||
<script setup>
|
||
import { ref } from 'vue'
|
||
import { useCalendar } from '../composables/useCalendar.js'
|
||
|
||
const { loadFile } = useCalendar()
|
||
const dragOver = ref(false)
|
||
const error = ref('')
|
||
|
||
function onDrop(e) {
|
||
dragOver.value = false
|
||
error.value = ''
|
||
const file = e.dataTransfer.files[0]
|
||
if (!file) return
|
||
if (!file.name.toLowerCase().endsWith('.ics')) {
|
||
error.value = '请选择 .ics 文件'
|
||
return
|
||
}
|
||
loadFile(file).catch((err) => {
|
||
error.value = '解析失败:' + (err.message || err)
|
||
})
|
||
}
|
||
|
||
function onPick(e) {
|
||
error.value = ''
|
||
const file = e.target.files[0]
|
||
if (!file) return
|
||
loadFile(file).catch((err) => {
|
||
error.value = '解析失败:' + (err.message || err)
|
||
})
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
class="dropzone"
|
||
:class="{ active: dragOver }"
|
||
@dragover.prevent="dragOver = true"
|
||
@dragleave.prevent="dragOver = false"
|
||
@drop.prevent="onDrop"
|
||
>
|
||
<div class="dropzone-content">
|
||
<div class="icon">📅</div>
|
||
<p class="hint">拖入 .ics 文件,或</p>
|
||
<label class="pick-btn">
|
||
点击选择文件
|
||
<input type="file" accept=".ics" @change="onPick" hidden />
|
||
</label>
|
||
<p v-if="error" class="error">{{ error }}</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.dropzone {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: 2px dashed #bdc3c7;
|
||
border-radius: 12px;
|
||
margin: 40px;
|
||
transition: all 0.2s;
|
||
}
|
||
.dropzone.active {
|
||
border-color: #3498db;
|
||
background: #eaf4fc;
|
||
}
|
||
.dropzone-content {
|
||
text-align: center;
|
||
}
|
||
.icon {
|
||
font-size: 48px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.hint {
|
||
color: #7f8c8d;
|
||
font-size: 14px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.pick-btn {
|
||
display: inline-block;
|
||
padding: 10px 24px;
|
||
background: #3498db;
|
||
color: #fff;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
transition: background 0.15s;
|
||
}
|
||
.pick-btn:hover {
|
||
background: #2980b9;
|
||
}
|
||
.error {
|
||
color: #e74c3c;
|
||
font-size: 13px;
|
||
margin-top: 16px;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2: Rewrite `App.vue` with layout skeleton**
|
||
|
||
Overwrite `D:\zcode\timeTable2\src\App.vue`:
|
||
```vue
|
||
<script setup>
|
||
import { useCalendar } from './composables/useCalendar.js'
|
||
import DropZone from './components/DropZone.vue'
|
||
|
||
const { isLoaded } = useCalendar()
|
||
</script>
|
||
|
||
<template>
|
||
<div class="app">
|
||
<header class="app-header">
|
||
<h1>📅 ICS 日程整理器</h1>
|
||
</header>
|
||
<main class="app-main">
|
||
<DropZone v-if="!isLoaded" />
|
||
<div v-else class="placeholder">
|
||
<p>文件已加载(列表与编辑器待实现)</p>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
</template>
|
||
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
font-family: -apple-system, "Segoe UI", Roboto, "Microsoft YaHei", sans-serif;
|
||
background: #f5f6f8;
|
||
color: #222;
|
||
line-height: 1.5;
|
||
}
|
||
.app { min-height: 100vh; display: flex; flex-direction: column; }
|
||
.app-header {
|
||
background: #2c3e50;
|
||
color: #fff;
|
||
padding: 14px 24px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.app-header h1 { font-size: 18px; font-weight: 600; }
|
||
.app-main { flex: 1; display: flex; overflow: hidden; }
|
||
.placeholder {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #95a5a6;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 3: Verify dev server runs**
|
||
|
||
Run: `cd /d/zcode/timeTable2 && timeout 8 npm run dev 2>&1 | head -10`
|
||
Expected: Vite ready, no errors.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd /d/zcode/timeTable2
|
||
git add src/components/DropZone.vue src/App.vue
|
||
git commit -m "feat: add DropZone and App layout skeleton"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: `EventList.vue` + `HeaderBar.vue` - list and organize
|
||
|
||
**Files:**
|
||
- Create: `src/components/EventList.vue`
|
||
- Create: `src/components/HeaderBar.vue`
|
||
- Modify: `src/App.vue`
|
||
|
||
- [ ] **Step 1: Create `EventList.vue`**
|
||
|
||
Create `D:\zcode\timeTable2\src\components\EventList.vue`:
|
||
```vue
|
||
<script setup>
|
||
import { useCalendar } from '../composables/useCalendar.js'
|
||
import { DOW } from '../lib/weekday.js'
|
||
|
||
const { state, stats, selectEvent, addEvent } = useCalendar()
|
||
|
||
function selectEvent(uid) {
|
||
state.selectedUid = uid
|
||
}
|
||
|
||
function dowFor(ev) {
|
||
return DOW[new Date(ev.dtstartDate + 'T00:00:00').getDay()]
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="event-list">
|
||
<div class="stat">
|
||
共 {{ stats.total }} 个日程({{ stats.recurring }} 重复 / {{ stats.single }} 单次)
|
||
</div>
|
||
<button class="add-btn" @click="addEvent">➕ 新增日程</button>
|
||
<div class="list">
|
||
<div
|
||
v-for="ev in state.events"
|
||
:key="ev.uid"
|
||
class="event-item"
|
||
:class="{ active: ev.uid === state.selectedUid }"
|
||
@click="selectEvent(ev.uid)"
|
||
>
|
||
<div class="title">
|
||
{{ ev.summary || '(无标题)' }}
|
||
<span class="badge" :class="ev.rrule ? 'badge-recur' : 'badge-single'">
|
||
{{ ev.rrule ? '重复' : '单次' }}
|
||
</span>
|
||
</div>
|
||
<div class="meta">
|
||
{{ dowFor(ev) }} {{ ev.dtstartTime }}–{{ ev.dtendTime }}
|
||
<span v-if="ev.location"> · {{ ev.location }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.event-list {
|
||
width: 340px;
|
||
background: #fff;
|
||
border-right: 1px solid #e0e3e6;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
.stat { font-size: 12px; color: #95a5a6; padding: 12px 16px 8px; }
|
||
.add-btn {
|
||
margin: 0 16px 8px;
|
||
padding: 8px;
|
||
border: 1px dashed #3498db;
|
||
background: #eaf4fc;
|
||
color: #2980b9;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
}
|
||
.add-btn:hover { background: #d4eaf9; }
|
||
.list { flex: 1; overflow-y: auto; }
|
||
.event-item {
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid #eef0f2;
|
||
cursor: pointer;
|
||
transition: background 0.12s;
|
||
}
|
||
.event-item:hover { background: #f8f9fa; }
|
||
.event-item.active {
|
||
background: #eaf4fc;
|
||
border-left: 3px solid #3498db;
|
||
}
|
||
.title { font-weight: 600; font-size: 14px; color: #2c3e50; margin-bottom: 3px; }
|
||
.meta { font-size: 12px; color: #7f8c8d; }
|
||
.badge {
|
||
display: inline-block;
|
||
padding: 1px 7px;
|
||
border-radius: 10px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
margin-left: 6px;
|
||
}
|
||
.badge-recur { background: #e8f5e9; color: #2e7d32; }
|
||
.badge-single { background: #fff3e0; color: #e65100; }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2: Create `HeaderBar.vue`**
|
||
|
||
Create `D:\zcode\timeTable2\src\components\HeaderBar.vue`:
|
||
```vue
|
||
<script setup>
|
||
import { useCalendar } from '../composables/useCalendar.js'
|
||
import { ref } from 'vue'
|
||
|
||
const { state, canUndo, canRedo, organize, undo, redo, serialize, markSaved } = useCalendar()
|
||
const fileInput = ref(null)
|
||
|
||
function onOrganize() {
|
||
const stats = organize()
|
||
if (stats && stats.series === 0) {
|
||
alert('未发现可合并的重复模式')
|
||
}
|
||
}
|
||
|
||
function onImport() {
|
||
fileInput.value?.click()
|
||
}
|
||
|
||
function onFilePicked(e) {
|
||
const file = e.target.files[0]
|
||
if (!file) return
|
||
const { loadFile } = useCalendar()
|
||
loadFile(file).catch((err) => alert('解析失败:' + (err.message || err)))
|
||
e.target.value = ''
|
||
}
|
||
|
||
function onDownload() {
|
||
if (state.dirty && !confirm('有未保存的更改,仍要下载?')) return
|
||
const text = serialize()
|
||
const blob = new Blob([text], { type: 'text/calendar' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
const base = state.fileName ? state.fileName.replace(/\.ics$/i, '') : 'calendar'
|
||
a.href = url
|
||
a.download = base + '.edited.ics'
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
markSaved()
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="header-actions">
|
||
<button class="btn btn-primary" @click="onOrganize" :disabled="state.events.length === 0">
|
||
🔁 整理去重
|
||
</button>
|
||
<button class="btn btn-secondary" @click="undo" :disabled="!canUndo">↶ 撤销</button>
|
||
<button class="btn btn-secondary" @click="redo" :disabled="!canRedo">↷ 重做</button>
|
||
<button class="btn btn-secondary" @click="onImport">📥 导入文件</button>
|
||
<button class="btn btn-success" @click="onDownload" :disabled="state.events.length === 0">
|
||
⬇ 下载 ICS
|
||
</button>
|
||
<span v-if="state.dirty" class="dirty-dot" title="有未保存更改">●</span>
|
||
<input ref="fileInput" type="file" accept=".ics" @change="onFilePicked" hidden />
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.header-actions { display: flex; gap: 10px; align-items: center; }
|
||
.btn {
|
||
padding: 7px 16px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
transition: background 0.15s;
|
||
}
|
||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.btn-primary { background: #3498db; color: #fff; }
|
||
.btn-primary:hover:not(:disabled) { background: #2980b9; }
|
||
.btn-success { background: #27ae60; color: #fff; }
|
||
.btn-success:hover:not(:disabled) { background: #229954; }
|
||
.btn-secondary { background: #ecf0f1; color: #2c3e50; }
|
||
.btn-secondary:hover:not(:disabled) { background: #d5dbdb; }
|
||
.dirty-dot { color: #e74c3c; font-size: 16px; }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 3: Wire into `App.vue`**
|
||
|
||
Overwrite `D:\zcode\timeTable2\src\App.vue`:
|
||
```vue
|
||
<script setup>
|
||
import { useCalendar } from './composables/useCalendar.js'
|
||
import DropZone from './components/DropZone.vue'
|
||
import HeaderBar from './components/HeaderBar.vue'
|
||
import EventList from './components/EventList.vue'
|
||
|
||
const { isLoaded } = useCalendar()
|
||
</script>
|
||
|
||
<template>
|
||
<div class="app">
|
||
<header class="app-header">
|
||
<h1>📅 ICS 日程整理器</h1>
|
||
<HeaderBar />
|
||
</header>
|
||
<main class="app-main">
|
||
<DropZone v-if="!isLoaded" />
|
||
<template v-else>
|
||
<EventList />
|
||
<div class="detail-area">
|
||
<div class="empty">从左侧选择一个日程进行编辑</div>
|
||
</div>
|
||
</template>
|
||
</main>
|
||
</div>
|
||
</template>
|
||
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
font-family: -apple-system, "Segoe UI", Roboto, "Microsoft YaHei", sans-serif;
|
||
background: #f5f6f8;
|
||
color: #222;
|
||
line-height: 1.5;
|
||
}
|
||
.app { min-height: 100vh; display: flex; flex-direction: column; }
|
||
.app-header {
|
||
background: #2c3e50;
|
||
color: #fff;
|
||
padding: 14px 24px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.app-header h1 { font-size: 18px; font-weight: 600; }
|
||
.app-main { flex: 1; display: flex; overflow: hidden; }
|
||
.detail-area { flex: 1; overflow-y: auto; }
|
||
.empty {
|
||
text-align: center;
|
||
padding: 60px 20px;
|
||
color: #95a5a6;
|
||
}
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 4: Verify dev server runs**
|
||
|
||
Run: `cd /d/zcode/timeTable2 && timeout 8 npm run dev 2>&1 | head -10`
|
||
Expected: Vite ready, no errors.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /d/zcode/timeTable2
|
||
git add src/components/EventList.vue src/components/HeaderBar.vue src/App.vue
|
||
git commit -m "feat: add EventList and HeaderBar with organize/undo/download"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: `OccurrenceGrid.vue` - occurrence toggle grid
|
||
|
||
**Files:**
|
||
- Create: `src/components/OccurrenceGrid.vue`
|
||
|
||
- [ ] **Step 1: Create `OccurrenceGrid.vue`**
|
||
|
||
Create `D:\zcode\timeTable2\src\components\OccurrenceGrid.vue`:
|
||
```vue
|
||
<script setup>
|
||
import { computed } from 'vue'
|
||
import { DOW } from '../lib/weekday.js'
|
||
import { expandOccurrences } from '../lib/recur.js'
|
||
|
||
const props = defineProps({
|
||
event: { type: Object, required: true },
|
||
})
|
||
const emit = defineEmits(['toggle'])
|
||
|
||
const occurrences = computed(() => expandOccurrences(props.event))
|
||
|
||
function dowFor(dateStr) {
|
||
return DOW[new Date(dateStr + 'T00:00:00').getDay()]
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div>
|
||
<p class="hint">
|
||
点击日期切换"排除/包含"状态。排除的日期(红色删除线)会写入 EXDATE。
|
||
</p>
|
||
<div class="occ-grid">
|
||
<div
|
||
v-for="occ in occurrences"
|
||
:key="occ.date"
|
||
class="occ"
|
||
:class="occ.skipped ? 'skipped' : 'active'"
|
||
@click="emit('toggle', occ.date)"
|
||
>
|
||
{{ occ.date }}<br />
|
||
<span class="dow">{{ dowFor(occ.date) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.hint {
|
||
font-size: 12px;
|
||
color: #7f8c8d;
|
||
margin-bottom: 8px;
|
||
}
|
||
.occ-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||
gap: 8px;
|
||
max-height: 360px;
|
||
overflow-y: auto;
|
||
padding: 4px;
|
||
}
|
||
.occ {
|
||
padding: 8px 10px;
|
||
border: 1px solid #d5dbdb;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
text-align: center;
|
||
transition: all 0.12s;
|
||
}
|
||
.occ:hover { border-color: #3498db; }
|
||
.occ.skipped {
|
||
background: #fce4ec;
|
||
border-color: #e74c3c;
|
||
color: #c0392b;
|
||
text-decoration: line-through;
|
||
}
|
||
.occ.active {
|
||
background: #e8f5e9;
|
||
border-color: #27ae60;
|
||
color: #1e7d32;
|
||
}
|
||
.dow { font-size: 10px; color: #95a5a6; }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
cd /d/zcode/timeTable2
|
||
git add src/components/OccurrenceGrid.vue
|
||
git commit -m "feat: add OccurrenceGrid component"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: `EventDetail.vue` - edit form with split
|
||
|
||
**Files:**
|
||
- Create: `src/components/EventDetail.vue`
|
||
- Modify: `src/App.vue` (wire EventDetail)
|
||
|
||
- [ ] **Step 1: Create `EventDetail.vue`**
|
||
|
||
Create `D:\zcode\timeTable2\src\components\EventDetail.vue`:
|
||
```vue
|
||
<script setup>
|
||
import { ref, watch } from 'vue'
|
||
import { useCalendar } from '../composables/useCalendar.js'
|
||
import { DOW_EN } from '../lib/weekday.js'
|
||
import OccurrenceGrid from './OccurrenceGrid.vue'
|
||
|
||
const { selectedEvent, updateEvent, deleteEvent, splitEvent, toggleOccurrence } = useCalendar()
|
||
|
||
// Local form state, synced when selection changes
|
||
const form = ref({})
|
||
const showSplit = ref(false)
|
||
const splitDate = ref('')
|
||
|
||
function syncForm(ev) {
|
||
if (!ev) { form.value = {}; return }
|
||
form.value = {
|
||
summary: ev.summary || '',
|
||
location: ev.location || '',
|
||
description: ev.description || '',
|
||
dtstart_time: ev.dtstartTime || '',
|
||
dtend_time: ev.dtendTime || '',
|
||
is_recurring: ev.rrule !== null,
|
||
freq: ev.rrule?.freq || 'WEEKLY',
|
||
interval: ev.rrule?.interval || 1,
|
||
byday: ev.rrule?.byday || 'MO',
|
||
until_date: ev.rrule?.untilDate || '',
|
||
}
|
||
}
|
||
|
||
watch(selectedEvent, syncForm, { immediate: true })
|
||
|
||
function applyChanges() {
|
||
const ev = selectedEvent.value
|
||
if (!ev) return
|
||
const patch = {
|
||
summary: form.value.summary,
|
||
location: form.value.location,
|
||
description: form.value.description,
|
||
dtstartTime: form.value.dtstart_time,
|
||
dtendTime: form.value.dtend_time,
|
||
}
|
||
if (form.value.is_recurring) {
|
||
patch.rrule = {
|
||
freq: form.value.freq,
|
||
interval: Number(form.value.interval) || 1,
|
||
byday: form.value.freq === 'WEEKLY' ? form.value.byday : null,
|
||
untilDate: form.value.until_date || ev.rrule?.untilDate || '',
|
||
}
|
||
} else {
|
||
patch.rrule = null
|
||
patch.exdates = []
|
||
}
|
||
updateEvent(ev.uid, patch)
|
||
}
|
||
|
||
function onDelete() {
|
||
const ev = selectedEvent.value
|
||
if (!ev) return
|
||
if (confirm(`确定删除"${ev.summary}"?`)) {
|
||
deleteEvent(ev.uid)
|
||
}
|
||
}
|
||
|
||
function onSplit() {
|
||
const ev = selectedEvent.value
|
||
if (!ev || !ev.rrule) return
|
||
if (!splitDate.value) {
|
||
alert('请选择拆分日期')
|
||
return
|
||
}
|
||
if (splitDate.value <= ev.dtstartDate || splitDate.value > ev.rrule.untilDate) {
|
||
alert('拆分日期必须在开始日期和结束日期之间')
|
||
return
|
||
}
|
||
splitEvent(ev.uid, splitDate.value)
|
||
showSplit.value = false
|
||
splitDate.value = ''
|
||
}
|
||
|
||
function onToggleOcc(date) {
|
||
const ev = selectedEvent.value
|
||
if (!ev) return
|
||
toggleOccurrence(ev.uid, date)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div v-if="!selectedEvent" class="empty">从左侧选择一个日程进行编辑</div>
|
||
<div v-else class="detail">
|
||
<!-- Basic info -->
|
||
<div class="section-title">基本信息</div>
|
||
<div class="form-group">
|
||
<label>标题 (SUMMARY)</label>
|
||
<input type="text" v-model="form.summary" />
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>开始时间</label>
|
||
<input type="time" v-model="form.dtstart_time" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>结束时间</label>
|
||
<input type="time" v-model="form.dtend_time" />
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>地点 (LOCATION)</label>
|
||
<input type="text" v-model="form.location" />
|
||
</div>
|
||
<div class="form-group">
|
||
<label>描述 (DESCRIPTION)</label>
|
||
<textarea v-model="form.description"></textarea>
|
||
</div>
|
||
|
||
<!-- RRULE -->
|
||
<div class="section-title">重复规则 (RRULE)</div>
|
||
<div class="form-group">
|
||
<label>
|
||
<input type="checkbox" v-model="form.is_recurring" /> 启用重复
|
||
</label>
|
||
</div>
|
||
<div v-if="form.is_recurring">
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>频率</label>
|
||
<select v-model="form.freq">
|
||
<option value="WEEKLY">每周 (WEEKLY)</option>
|
||
<option value="DAILY">每天 (DAILY)</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>间隔 (INTERVAL)</label>
|
||
<input type="number" v-model.number="form.interval" min="1" style="width: 80px" />
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>重复到 (UNTIL)</label>
|
||
<input type="date" v-model="form.until_date" />
|
||
</div>
|
||
<div class="form-group" v-if="form.freq === 'WEEKLY'">
|
||
<label>星期 (BYDAY)</label>
|
||
<select v-model="form.byday">
|
||
<option v-for="(d, i) in DOW_EN" :key="d" :value="d">
|
||
{{ ['周日','周一','周二','周三','周四','周五','周六'][i] }}
|
||
</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Occurrence grid -->
|
||
<div v-if="form.is_recurring" class="section-title">occurrence / 排除日期</div>
|
||
<OccurrenceGrid
|
||
v-if="form.is_recurring && selectedEvent.rrule"
|
||
:event="selectedEvent"
|
||
@toggle="onToggleOcc"
|
||
/>
|
||
|
||
<!-- Toolbar -->
|
||
<div class="toolbar">
|
||
<button class="btn btn-primary" @click="applyChanges">应用更改</button>
|
||
<button
|
||
v-if="selectedEvent.rrule"
|
||
class="btn btn-secondary"
|
||
@click="showSplit = !showSplit"
|
||
>
|
||
✂ 拆分此日程
|
||
</button>
|
||
<button class="btn btn-danger" @click="onDelete">删除此日程</button>
|
||
</div>
|
||
|
||
<!-- Split panel -->
|
||
<div v-if="showSplit && selectedEvent.rrule" class="split-panel">
|
||
<p>从指定日期起,把此重复事件拆成前后两段。</p>
|
||
<input type="date" v-model="splitDate" />
|
||
<button class="btn btn-primary" @click="onSplit">确认拆分</button>
|
||
<button class="btn btn-secondary" @click="showSplit = false">取消</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.detail { padding: 24px 32px; }
|
||
.empty {
|
||
text-align: center;
|
||
padding: 60px 20px;
|
||
color: #95a5a6;
|
||
}
|
||
.section-title {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: #2c3e50;
|
||
margin: 20px 0 12px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 2px solid #ecf0f1;
|
||
}
|
||
.form-group { margin-bottom: 16px; }
|
||
.form-group label {
|
||
display: block;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #34495e;
|
||
margin-bottom: 5px;
|
||
}
|
||
.form-group input,
|
||
.form-group textarea,
|
||
.form-group select {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
border: 1px solid #d5dbdb;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
}
|
||
.form-group textarea { resize: vertical; min-height: 60px; }
|
||
.form-row { display: flex; gap: 16px; }
|
||
.form-row .form-group { flex: 1; }
|
||
.toolbar {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 20px;
|
||
padding-top: 16px;
|
||
border-top: 1px solid #ecf0f1;
|
||
}
|
||
.btn {
|
||
padding: 7px 16px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
transition: background 0.15s;
|
||
}
|
||
.btn-primary { background: #3498db; color: #fff; }
|
||
.btn-primary:hover { background: #2980b9; }
|
||
.btn-secondary { background: #ecf0f1; color: #2c3e50; }
|
||
.btn-secondary:hover { background: #d5dbdb; }
|
||
.btn-danger { background: #e74c3c; color: #fff; }
|
||
.btn-danger:hover { background: #c0392b; }
|
||
.split-panel {
|
||
margin-top: 16px;
|
||
padding: 16px;
|
||
background: #f8f9fa;
|
||
border-radius: 8px;
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
.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; }
|
||
</style>
|
||
```
|
||
|
||
- [ ] **Step 2: Wire EventDetail into `App.vue`**
|
||
|
||
Edit `D:\zcode\timeTable2\src\App.vue` -- replace the `<div class="detail-area">` block and add the import:
|
||
|
||
Replace this part:
|
||
```vue
|
||
<div class="detail-area">
|
||
<div class="empty">从左侧选择一个日程进行编辑</div>
|
||
</div>
|
||
```
|
||
with:
|
||
```vue
|
||
<div class="detail-area">
|
||
<EventDetail />
|
||
</div>
|
||
```
|
||
|
||
And add to the `<script setup>` block, after the `EventList` import:
|
||
```js
|
||
import EventDetail from './components/EventDetail.vue'
|
||
```
|
||
|
||
- [ ] **Step 3: Verify dev server runs**
|
||
|
||
Run: `cd /d/zcode/timeTable2 && timeout 8 npm run dev 2>&1 | head -10`
|
||
Expected: Vite ready, no errors.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd /d/zcode/timeTable2
|
||
git add src/components/EventDetail.vue src/App.vue
|
||
git commit -m "feat: add EventDetail with edit form, occurrence grid, split"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: End-to-end manual verification + build
|
||
|
||
**Files:**
|
||
- None (verification only, then README)
|
||
|
||
- [ ] **Step 1: Run all unit tests**
|
||
|
||
Run: `cd /d/zcode/timeTable2 && npx vitest run 2>&1 | tail -20`
|
||
Expected: All tests pass (weekday 5 + ical-io 9 + recur 6 + organize 13 = 33 tests).
|
||
|
||
- [ ] **Step 2: Start dev server and verify in browser**
|
||
|
||
Run (background): `cd /d/zcode/timeTable2 && npm run dev`
|
||
|
||
Then use Playwright to:
|
||
1. Navigate to the Vite dev URL (http://localhost:5173)
|
||
2. Verify the DropZone is visible
|
||
3. Upload `D:\zcode\timeTable\org.ics` via the file input
|
||
4. Verify 61 events appear in the sidebar
|
||
5. Click "整理去重" -- verify it collapses to 8 events (5 recurring + 3 single)
|
||
6. Click a recurring event -- verify the occurrence grid shows green/red cells
|
||
7. Click a cell -- verify it toggles skip state
|
||
8. Click "下载 ICS" -- verify a file downloads
|
||
9. Click "↶ 撤销" -- verify it restores previous state
|
||
|
||
Expected: all interactions work without console errors.
|
||
|
||
- [ ] **Step 3: Stop the dev server**
|
||
|
||
Stop the background dev server task.
|
||
|
||
- [ ] **Step 4: Verify production build**
|
||
|
||
Run: `cd /d/zcode/timeTable2 && npm run build 2>&1 | tail -15`
|
||
Expected: Build succeeds, `dist/` directory created with `index.html` and assets.
|
||
|
||
- [ ] **Step 5: Commit build verification (no files, just confirm)**
|
||
|
||
No commit needed for verification, but add a note if any fixes were made.
|
||
|
||
---
|
||
|
||
## Task 13: README.md
|
||
|
||
**Files:**
|
||
- Create: `README.md`
|
||
|
||
- [ ] **Step 1: Write `README.md`**
|
||
|
||
Create `D:\zcode\timeTable2\README.md`:
|
||
```markdown
|
||
# timeTable2 - 纯前端 ICS 日历整理器
|
||
|
||
浏览器内解析、去重、编辑 ICS 日历文件,全程无需 Python 或后端服务。
|
||
|
||
## 功能
|
||
|
||
- 📥 **拖入即用**:拖拽或选择 `.ics` 文件,浏览器内直接解析
|
||
- 🔁 **智能去重**:自动检测重复模式,将 61 个扁平事件合并为 5 个 RRULE 重复事件 + 3 个独立事件
|
||
- ✏️ **可视化编辑**:编辑标题/地点/时间/重复规则,点击网格切换排除日期
|
||
- ✂️ **拆分事件**:把一个重复事件按日期拆成前后两段(如后半学期换教室)
|
||
- ➕ **增删事件**:新增单次/重复事件,删除任意事件
|
||
- ↶ **撤销重做**:快照式 undo/redo,最多 50 步
|
||
- ⬇ **导出 ICS**:编辑完成下载整理后的 `.ics`,可直接导入日历应用
|
||
|
||
## 快速开始
|
||
|
||
```bash
|
||
# 安装依赖(使用 npmmirror 镜像加速)
|
||
npm install
|
||
|
||
# 启动开发服务器
|
||
npm run dev
|
||
|
||
# 构建生产版本
|
||
npm run build
|
||
|
||
# 运行单元测试
|
||
npm test
|
||
```
|
||
|
||
浏览器打开 `http://localhost:5173`,拖入 `.ics` 文件即可。
|
||
|
||
## 项目结构
|
||
|
||
```
|
||
timeTable2/
|
||
├── index.html Vite 入口
|
||
├── package.json
|
||
├── src/
|
||
│ ├── main.js 应用挂载
|
||
│ ├── App.vue 布局装配
|
||
│ ├── composables/
|
||
│ │ └── useCalendar.js 中心 store + 快照 undo/redo
|
||
│ ├── lib/
|
||
│ │ ├── weekday.js 星期常量与工具
|
||
│ │ ├── ical-io.js ICS 文本 <-> plain model(ical.js 封装)
|
||
│ │ ├── recur.js RRULE 网格展开
|
||
│ │ └── organize.js GCD 去重算法(移植自 Python 版)
|
||
│ └── components/
|
||
│ ├── HeaderBar.vue 顶栏:整理/撤销/导入/下载
|
||
│ ├── DropZone.vue 拖拽/选择文件
|
||
│ ├── EventList.vue 侧栏事件列表
|
||
│ ├── EventDetail.vue 编辑表单 + 拆分
|
||
│ └── OccurrenceGrid.vue 排除日期网格
|
||
└── test/ Vitest 单元测试
|
||
```
|
||
|
||
## 算法
|
||
|
||
去重算法从 `timeTable/ical_organizer.py` 1:1 移植:
|
||
|
||
1. 按 (标题 + 地点 + 开始时间 + 时长 + 星期) 将事件分组
|
||
2. 计算组内相邻日期间隔的 **GCD**,识别:7→WEEKLY、1→DAILY、14→WEEKLY INTERVAL=2
|
||
3. 构建期望网格,缺失的日期写入 `EXDATE`
|
||
4. 不符合规律的组(跳过次数 ≥ 事件数)保留为独立事件
|
||
|
||
## 与 timeTable 的关系
|
||
|
||
`timeTable`(Python 版)使用 Flask + icalendar 库,需创建 venv。
|
||
`timeTable2` 是纯前端等价实现,零安装、双击即用,算法行为一致。
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
cd /d/zcode/timeTable2
|
||
git add README.md
|
||
git commit -m "docs: add README"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review Notes
|
||
|
||
**Spec coverage check:**
|
||
- §1 Goals: parse/dedupe/edit/add-delete-split/export → Tasks 3-4 (parse/serialize), 6 (dedupe), 11 (edit/split), 7 (add/delete), 9 (download) ✓
|
||
- §2 Tech stack: Vite/Vue3/ical.js/npmmirror/Vitest → Task 1 ✓
|
||
- §3 Project structure → matches File Structure above ✓
|
||
- §4 Data model (EventModel, CalendarMeta, StoreState) → Tasks 3, 7 ✓
|
||
- §5.1 weekday.js → Task 2 ✓
|
||
- §5.2 ical-io.js (parse + serialize) → Tasks 3, 4 ✓
|
||
- §5.3 recur.js → Task 5 ✓
|
||
- §5.4 organize.js (seriesKey, detectInterval, fitSeries, organize) → Task 6 ✓
|
||
- §5.5 useCalendar.js (store, undo, splitEvent, toggleOccurrence) → Task 7 ✓
|
||
- §6 UI components (HeaderBar, DropZone, EventList, EventDetail, OccurrenceGrid) → Tasks 8-11 ✓
|
||
- §7 Data flows → covered by component wiring ✓
|
||
- §8 Error handling (non-.ics, parse fail, dirty warning) → DropZone + HeaderBar ✓
|
||
- §9 Testing (Vitest, golden org.ics) → Tasks 2,3,4,5,6 ✓
|
||
- §10 Git workflow → each task commits ✓
|
||
- §12 Acceptance criteria → Task 12 verification ✓
|
||
|
||
**Type consistency check:**
|
||
- `seriesKey(ev)` returns string (join with '|') - used in organize.js ✓
|
||
- `detectInterval(dateStrings)` takes array of 'YYYY-MM-DD' strings - matches organize.js usage ✓
|
||
- `fitSeries(evs)` returns `{base, rrule, exdates}` or null - matches organize.js destructuring ✓
|
||
- `organize(events)` returns `{events, stats:{series,flat}}` - matches useCalendar.js ✓
|
||
- `expandOccurrences(ev)` returns `[{date, skipped}]` - matches OccurrenceGrid props ✓
|
||
- `intervalDays(ev)` - exported but only used internally in recur.js; exported for potential test use ✓
|
||
- EventModel fields: uid, summary, location, description, dtstartDate, dtstartTime, dtendTime, tzid, rrule, exdates, _raw - consistent across parse/serialize/organize/store/components ✓
|
||
- rrule shape: `{freq, interval, byday, untilDate}` - consistent across ical-io, recur, organize, EventDetail ✓
|
||
- Store actions: loadFile, organize, updateEvent, deleteEvent, addEvent, splitEvent, toggleOccurrence, undo, redo, serialize, markSaved - all defined in Task 7 ✓
|
||
|
||
**Placeholder scan:** No TBD/TODO. All code blocks are complete. ✓
|
||
|
||
**ical.js API correctness (verified):**
|
||
- `import ICAL from 'ical.js'` (default) ✓
|
||
- `recur.getComponent('byday')` not `.byday` ✓
|
||
- Timezone registration via `TimezoneService.register` ✓
|
||
- `time.convertToZone(ICAL.Timezone.utcTimezone)` for UNTIL ✓
|
||
- `new ICAL.Component(vtz.jCal)` for VTIMEZONE clone ✓
|