70 KiB
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)thenevent.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[]; eachprop.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.utcTimezoneis 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
.npmrcwith npmmirror registry
Create D:\zcode\timeTable2\.npmrc:
registry=https://registry.npmmirror.com
- Step 2: Create
package.json
Create D:\zcode\timeTable2\package.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:
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:
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:
<!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:
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:
<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
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:
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 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
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 oftimeTable/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:
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
parseICSimplementation
Create D:\zcode\timeTable2\src\lib\ical-io.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
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(addserializeICS) -
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):
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
serializeICStosrc/lib/ical-io.js
Add this code at the end of D:\zcode\timeTable2\src\lib\ical-io.js (after the parseICS function):
// ------------------------------------------------------------------ //
// 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
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:
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:
/**
* 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
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:
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:
/**
* 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
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:
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
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:
<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.vuewith layout skeleton
Overwrite D:\zcode\timeTable2\src\App.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
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:
<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:
<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:
<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
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:
<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
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:
<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:
<div class="detail-area">
<div class="empty">从左侧选择一个日程进行编辑</div>
</div>
with:
<div class="detail-area">
<EventDetail />
</div>
And add to the <script setup> block, after the EventList import:
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
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:
- Navigate to the Vite dev URL (http://localhost:5173)
- Verify the DropZone is visible
- Upload
D:\zcode\timeTable\org.icsvia the file input - Verify 61 events appear in the sidebar
- Click "整理去重" -- verify it collapses to 8 events (5 recurring + 3 single)
- Click a recurring event -- verify the occurrence grid shows green/red cells
- Click a cell -- verify it toggles skip state
- Click "下载 ICS" -- verify a file downloads
- 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:
# 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 移植:
- 按 (标题 + 地点 + 开始时间 + 时长 + 星期) 将事件分组
- 计算组内相邻日期间隔的 GCD,识别:7→WEEKLY、1→DAILY、14→WEEKLY INTERVAL=2
- 构建期望网格,缺失的日期写入
EXDATE - 不符合规律的组(跳过次数 ≥ 事件数)保留为独立事件
与 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 ✓