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

70 KiB
Raw Blame History

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:

{
  "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 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:

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:

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 (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):

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):


// ------------------------------------------------------------------ //
// 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.vue with 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:

  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:

# 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 的关系

timeTablePython 版)使用 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 ✓