Files
timeTableFix/docs/superpowers/plans/2026-07-13-timetable2.md
2026-07-13 18:18:23 +08:00

2405 lines
70 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# 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 modelical.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 ✓