From 1affa0b0212e5130718a6530a3f0c5a4a3423852 Mon Sep 17 00:00:00 2001 From: timeTable2 dev Date: Mon, 13 Jul 2026 18:18:23 +0800 Subject: [PATCH] docs: add implementation plan --- .../plans/2026-07-13-timetable2.md | 2404 +++++++++++++++++ 1 file changed, 2404 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-13-timetable2.md diff --git a/docs/superpowers/plans/2026-07-13-timetable2.md b/docs/superpowers/plans/2026-07-13-timetable2.md new file mode 100644 index 0000000..45c9c85 --- /dev/null +++ b/docs/superpowers/plans/2026-07-13-timetable2.md @@ -0,0 +1,2404 @@ +# 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 ` + + +``` + +- [ ] **Step 6: Create `src/main.js`** + +Create `D:\zcode\timeTable2\src\main.js`: +```js +import { createApp } from 'vue' +import App from './App.vue' + +createApp(App).mount('#app') +``` + +- [ ] **Step 7: Create placeholder `src/App.vue`** + +Create `D:\zcode\timeTable2\src\App.vue`: +```vue + + + +``` + +- [ ] **Step 8: Install dependencies** + +Run: `cd /d/zcode/timeTable2 && npm install` +Expected: installs vue, ical.js, vite, vitest with no errors (using npmmirror). + +- [ ] **Step 9: Verify dev server starts** + +Run: `cd /d/zcode/timeTable2 && timeout 8 npm run dev 2>&1 | head -20` +Expected: Vite prints "VITE ready" and a local URL, no errors. + +- [ ] **Step 10: Commit** + +```bash +cd /d/zcode/timeTable2 +git add .npmrc package.json package-lock.json vite.config.js vitest.config.js index.html src/main.js src/App.vue +git commit -m "feat: scaffold Vite + Vue3 project with npmmirror" +``` + +--- + +## Task 2: `lib/weekday.js` - weekday constants and helpers + +**Files:** +- Create: `src/lib/weekday.js` +- Create: `test/weekday.test.js` + +- [ ] **Step 1: Write the failing test** + +Create `D:\zcode\timeTable2\test\weekday.test.js`: +```js +import { describe, it, expect } from 'vitest' +import { DOW, DOW_EN, BYDAY_TO_INDEX, bydayFromDate, dowLabel } from '../src/lib/weekday.js' + +describe('weekday', () => { + it('DOW has 7 entries starting Sunday', () => { + expect(DOW).toHaveLength(7) + expect(DOW[0]).toBe('周日') + expect(DOW[6]).toBe('周六') + }) + + it('DOW_EN indexed by JS getDay (0=Sunday)', () => { + expect(DOW_EN).toEqual(['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA']) + }) + + it('BYDAY_TO_INDEX maps SU..SA to 0..6', () => { + expect(BYDAY_TO_INDEX.SU).toBe(0) + expect(BYDAY_TO_INDEX.SA).toBe(6) + }) + + it('bydayFromDate returns BYDAY code for a Date', () => { + // 2026-07-27 is a Monday -> getDay()=1 -> 'MO' + expect(bydayFromDate(new Date(2026, 6, 27))).toBe('MO') + // 2026-08-01 is a Saturday -> getDay()=6 -> 'SA' + expect(bydayFromDate(new Date(2026, 7, 1))).toBe('SA') + }) + + it('dowLabel returns Chinese label for a Date', () => { + expect(dowLabel(new Date(2026, 6, 27))).toBe('周一') + expect(dowLabel(new Date(2026, 6, 26))).toBe('周日') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /d/zcode/timeTable2 && npx vitest run test/weekday.test.js 2>&1 | tail -10` +Expected: FAIL with "Failed to resolve import" or "module not found". + +- [ ] **Step 3: Write the implementation** + +Create `D:\zcode\timeTable2\src\lib\weekday.js`: +```js +// JS getDay() index: 0=Sunday .. 6=Saturday +export const DOW = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] + +// BYDAY codes indexed by JS getDay(): DOW_EN[getDay()] => 'SU'..'SA' +export const DOW_EN = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] + +export const BYDAY_TO_INDEX = { SU: 0, MO: 1, TU: 2, WE: 3, TH: 4, FR: 5, SA: 6 } + +/** Return the BYDAY code ('SU'..'SA') for a JS Date. */ +export function bydayFromDate(date) { + return DOW_EN[date.getDay()] +} + +/** Return the Chinese weekday label for a JS Date. */ +export function dowLabel(date) { + return DOW[date.getDay()] +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /d/zcode/timeTable2 && npx vitest run test/weekday.test.js 2>&1 | tail -10` +Expected: PASS, 5 tests passed. + +- [ ] **Step 5: Commit** + +```bash +cd /d/zcode/timeTable2 +git add src/lib/weekday.js test/weekday.test.js +git commit -m "feat: add weekday constants and helpers" +``` + +--- + +## Task 3: `lib/ical-io.js` - ICS text ↔ plain model (parse) + +This is the largest lib module. We build it in two test-driven halves: parse first, then serialize. + +**Files:** +- Create: `test/golden-org.ics` (copy of `timeTable/org.ics`) +- Create: `src/lib/ical-io.js` +- Create: `test/ical-io.test.js` + +- [ ] **Step 1: Copy golden test fixture** + +Run: `cp /d/zcode/timeTable/org.ics /d/zcode/timeTable2/test/golden-org.ics` +Expected: file copied (61 VEVENTs). + +- [ ] **Step 2: Write the failing parse test** + +Create `D:\zcode\timeTable2\test\ical-io.test.js`: +```js +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import { parseICS } from '../src/lib/ical-io.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const GOLDEN = readFileSync(join(__dirname, 'golden-org.ics'), 'utf-8') + +describe('parseICS', () => { + const { meta, events } = parseICS(GOLDEN) + + it('parses calendar meta', () => { + expect(meta.prodid).toBe('-//Allocate//iCal4j 1.0//EN') + expect(meta.version).toBe('2.0') + expect(meta.calscale).toBe('GREGORIAN') + expect(meta.vtimezones.length).toBe(1) + }) + + it('parses all 61 events', () => { + expect(events).toHaveLength(61) + }) + + it('parses first event fields correctly', () => { + const e = events[0] + expect(e.summary).toBe('ACC INFO SYS, Tutorial') + expect(e.location).toBe('CA_B_B471') + expect(e.dtstartDate).toBe('2026-07-27') + expect(e.dtstartTime).toBe('18:00') + expect(e.dtendTime).toBe('20:00') + expect(e.tzid).toBe('Australia/Melbourne') + expect(e.rrule).toBeNull() + expect(e.exdates).toEqual([]) + }) + + it('preserves description with escaped commas', () => { + const e = events[0] + expect(e.description).toContain('ACF2400_CA_S2_ON-CAMPUS') + expect(e.description).toContain('ACC INFO SYS') + }) + + it('captures dtstamp in _raw', () => { + expect(events[0]._raw.dtstamp).toBe('20260711T085008Z') + }) + + it('events have UIDs', () => { + expect(events[0].uid).toBe('uid0') + expect(events[60].uid).toBe('uid60') + }) +}) +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cd /d/zcode/timeTable2 && npx vitest run test/ical-io.test.js 2>&1 | tail -10` +Expected: FAIL with "Cannot find module '../src/lib/ical-io.js'". + +- [ ] **Step 4: Write `parseICS` implementation** + +Create `D:\zcode\timeTable2\src\lib\ical-io.js`: +```js +import ICAL from 'ical.js' + +// ------------------------------------------------------------------ // +// Timezone registration (ical.js does not bundle IANA zones) +// ------------------------------------------------------------------ // +function registerTimezones(vcalendar) { + for (const vtz of vcalendar.getAllSubcomponents('vtimezone')) { + const tzid = vtz.getFirstPropertyValue('tzid') + if (tzid) { + ICAL.TimezoneService.register(tzid, new ICAL.Timezone({ component: vtz, tzid })) + } + } +} + +// ------------------------------------------------------------------ // +// Helpers +// ------------------------------------------------------------------ // +function pad2(n) { + return String(n).padStart(2, '0') +} + +function timeToISODate(t) { + return `${t.year}-${pad2(t.month)}-${pad2(t.day)}` +} + +function timeToHHMM(t) { + return `${pad2(t.hour)}:${pad2(t.minute)}` +} + +function getTzid(component, propName) { + const prop = component.getFirstProperty(propName) + if (!prop) return 'UTC' + const tzid = prop.getParameter('tzid') + return tzid || 'UTC' +} + +// ------------------------------------------------------------------ // +// Parse a single VEVENT component -> plain EventModel +// ------------------------------------------------------------------ // +function parseEvent(veventComp) { + const event = new ICAL.Event(veventComp) + const start = event.startDate + const end = event.endDate + + // Determine tzid + const tzid = getTzid(veventComp, 'dtstart') + + // RRULE + const recur = veventComp.getFirstPropertyValue('rrule') + let rrule = null + if (recur) { + const bydayArr = recur.getComponent('byday') // string[] + let untilDate = null + if (recur.until) { + const u = recur.until + untilDate = `${u.year}-${pad2(u.month)}-${pad2(u.day)}` + } + rrule = { + freq: recur.freq, + interval: recur.interval || 1, + byday: bydayArr.length > 0 ? bydayArr[0] : null, + untilDate, + } + } + + // EXDATEs + const exdates = [] + for (const prop of veventComp.getAllProperties('exdate')) { + for (const t of prop.getValues()) { + exdates.push(timeToISODate(t)) + } + } + + // _raw: capture DTSTAMP and any other unmapped properties + const _raw = {} + const dtstamp = veventComp.getFirstPropertyValue('dtstamp') + if (dtstamp) _raw.dtstamp = dtstamp.toString() + + return { + uid: veventComp.getFirstPropertyValue('uid') || '', + summary: veventComp.getFirstPropertyValue('summary') || '', + location: veventComp.getFirstPropertyValue('location') || '', + description: veventComp.getFirstPropertyValue('description') || '', + dtstartDate: timeToISODate(start), + dtstartTime: timeToHHMM(start), + dtendTime: timeToHHMM(end), + tzid, + rrule, + exdates, + _raw, + } +} + +// ------------------------------------------------------------------ // +// Parse full ICS text -> { meta, events } +// ------------------------------------------------------------------ // +export function parseICS(text) { + const jcal = ICAL.parse(text) + const vcalendar = new ICAL.Component(jcal) + + // Register timezones from VTIMEZONEs before constructing zoned Times + registerTimezones(vcalendar) + + // Meta + const meta = { + prodid: vcalendar.getFirstPropertyValue('prodid') || '', + version: vcalendar.getFirstPropertyValue('version') || '2.0', + calscale: vcalendar.getFirstPropertyValue('calscale') || 'GREGORIAN', + method: vcalendar.getFirstPropertyValue('method') || null, + xWrCalname: vcalendar.getFirstPropertyValue('x-wr-calname') || null, + xWrTimezone: vcalendar.getFirstPropertyValue('x-wr-timezone') || null, + vtimezones: vcalendar.getAllSubcomponents('vtimezone'), + } + + // Events + const events = vcalendar.getAllSubcomponents('vevent').map(parseEvent) + + return { meta, events } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd /d/zcode/timeTable2 && npx vitest run test/ical-io.test.js 2>&1 | tail -15` +Expected: PASS, 6 tests passed. + +- [ ] **Step 6: Commit** + +```bash +cd /d/zcode/timeTable2 +git add src/lib/ical-io.js test/ical-io.test.js test/golden-org.ics +git commit -m "feat: implement parseICS with ical.js" +``` + +--- + +## Task 4: `lib/ical-io.js` - serialize (round-trip) + +**Files:** +- Modify: `src/lib/ical-io.js` (add `serializeICS`) +- Modify: `test/ical-io.test.js` (add round-trip tests) + +- [ ] **Step 1: Add failing serialize tests** + +Append to `D:\zcode\timeTable2\test\ical-io.test.js` (before the closing, or add a new describe block at the end): +```js +import { serializeICS } from '../src/lib/ical-io.js' + +describe('serializeICS round-trip', () => { + it('round-trips: parse(serialize(parse(x))) equals parse(x)', () => { + const original = parseICS(GOLDEN) + const text = serializeICS(original) + const reparsed = parseICS(text) + + expect(reparsed.events).toHaveLength(original.events.length) + // Spot-check first event + const e0 = reparsed.events[0] + expect(e0.summary).toBe(original.events[0].summary) + expect(e0.dtstartDate).toBe(original.events[0].dtstartDate) + expect(e0.dtstartTime).toBe(original.events[0].dtstartTime) + expect(e0.tzid).toBe(original.events[0].tzid) + expect(e0._raw.dtstamp).toBe(original.events[0]._raw.dtstamp) + }) + + it('preserves VTIMEZONE in output', () => { + const { meta } = parseICS(serializeICS(parseICS(GOLDEN))) + expect(meta.vtimezones).toHaveLength(1) + }) + + it('serializes a manually-built recurring event', () => { + const ev = { + uid: 'test-1', + summary: 'Test Event', + location: 'Room A', + description: 'Desc', + dtstartDate: '2026-07-27', + dtstartTime: '09:00', + dtendTime: '10:30', + tzid: 'UTC', + rrule: { freq: 'WEEKLY', interval: 1, byday: 'MO', untilDate: '2026-10-19' }, + exdates: ['2026-09-21'], + _raw: { dtstamp: '20260711T085008Z' }, + } + const text = serializeICS({ + meta: { + prodid: '-//Test//EN', version: '2.0', calscale: 'GREGORIAN', + method: null, xWrCalname: null, xWrTimezone: null, vtimezones: [], + }, + events: [ev], + }) + expect(text).toContain('BEGIN:VEVENT') + expect(text).toContain('SUMMARY:Test Event') + expect(text).toContain('RRULE:FREQ=WEEKLY') + expect(text).toContain('BYDAY=MO') + expect(text).toContain('EXDATE') + expect(text).toContain('DTSTART:20260727T090000Z') + + // Re-parse and verify + const reparsed = parseICS(text) + expect(reparsed.events[0].rrule.freq).toBe('WEEKLY') + expect(reparsed.events[0].rrule.byday).toBe('MO') + expect(reparsed.events[0].exdates).toContain('2026-09-21') + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /d/zcode/timeTable2 && npx vitest run test/ical-io.test.js 2>&1 | tail -10` +Expected: FAIL with "serializeICS is not a function" or import error. + +- [ ] **Step 3: Add `serializeICS` to `src/lib/ical-io.js`** + +Add this code at the end of `D:\zcode\timeTable2\src\lib\ical-io.js` (after the `parseICS` function): +```js + +// ------------------------------------------------------------------ // +// Helpers for serialization +// ------------------------------------------------------------------ // +function parseISODate(s) { + const [y, m, d] = s.split('-').map(Number) + return { year: y, month: m, day: d } +} + +function parseHHMM(s) { + const [h, m] = s.split(':').map(Number) + return { hour: h, minute: m } +} + +function getZone(tzid) { + if (tzid === 'UTC' || tzid === 'Z') return ICAL.Timezone.utcTimezone + const z = ICAL.TimezoneService.get(tzid) + return z || ICAL.Timezone.utcTimezone +} + +function makeTime(dateStr, timeStr, tzid) { + const d = parseISODate(dateStr) + const t = parseHHMM(timeStr) + return new ICAL.Time( + { year: d.year, month: d.month, day: d.day, hour: t.hour, minute: t.minute, isDate: false }, + getZone(tzid), + ) +} + +// ------------------------------------------------------------------ // +// Serialize { meta, events } -> ICS text +// ------------------------------------------------------------------ // +export function serializeICS({ meta, events }) { + const vcalendar = new ICAL.Component('vcalendar') + + vcalendar.updatePropertyWithValue('prodid', meta.prodid || '-//timeTable2//EN') + vcalendar.updatePropertyWithValue('version', meta.version || '2.0') + if (meta.calscale) vcalendar.updatePropertyWithValue('calscale', meta.calscale) + if (meta.method) vcalendar.updatePropertyWithValue('method', meta.method) + if (meta.xWrCalname) vcalendar.updatePropertyWithValue('x-wr-calname', meta.xWrCalname) + if (meta.xWrTimezone) vcalendar.updatePropertyWithValue('x-wr-timezone', meta.xWrTimezone) + + // Re-add VTIMEZONEs (clone jCal to avoid moving from original) + for (const vtz of meta.vtimezones || []) { + vcalendar.addSubcomponent(new ICAL.Component(vtz.jCal)) + } + + for (const ev of events) { + const vevent = new ICAL.Component('vevent') + + vevent.updatePropertyWithValue('uid', ev.uid) + if (ev.summary) vevent.updatePropertyWithValue('summary', ev.summary) + if (ev.location) vevent.updatePropertyWithValue('location', ev.location) + if (ev.description) vevent.updatePropertyWithValue('description', ev.description) + + // DTSTART + const dtstart = makeTime(ev.dtstartDate, ev.dtstartTime, ev.tzid) + const dsProp = vevent.addPropertyWithValue('dtstart', dtstart) + if (ev.tzid && ev.tzid !== 'UTC') dsProp.setParameter('tzid', ev.tzid) + + // DTEND + const dtend = makeTime(ev.dtstartDate, ev.dtendTime, ev.tzid) + const deProp = vevent.addPropertyWithValue('dtend', dtend) + if (ev.tzid && ev.tzid !== 'UTC') deProp.setParameter('tzid', ev.tzid) + + // DTSTAMP + const dtstamp = ev._raw?.dtstamp || ICAL.Time.now().toUTCString() + vevent.updatePropertyWithValue('dtstamp', dtstamp) + + // RRULE + if (ev.rrule) { + const r = ev.rrule + const recurData = { freq: r.freq, interval: r.interval || 1 } + if (r.freq === 'WEEKLY' && r.byday) recurData.byday = r.byday + if (r.untilDate) { + // UNTIL as UTC (RFC 5545): build zoned time at 23:59 then convert + const untilLocal = makeTime(r.untilDate, '23:59', ev.tzid) + recurData.until = untilLocal.convertToZone(ICAL.Timezone.utcTimezone) + } + const recur = new ICAL.Recur(recurData) + vevent.addPropertyWithValue('rrule', recur) + } + + // EXDATEs (in DTSTART's timezone) + for (const exDate of ev.exdates || []) { + const exTime = makeTime(exDate, ev.dtstartTime, ev.tzid) + const exProp = vevent.addPropertyWithValue('exdate', exTime) + if (ev.tzid && ev.tzid !== 'UTC') exProp.setParameter('tzid', ev.tzid) + } + + vcalendar.addSubcomponent(vevent) + } + + return vcalendar.toString() +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /d/zcode/timeTable2 && npx vitest run test/ical-io.test.js 2>&1 | tail -15` +Expected: PASS, 9 tests passed (6 parse + 3 serialize). + +- [ ] **Step 5: Commit** + +```bash +cd /d/zcode/timeTable2 +git add src/lib/ical-io.js test/ical-io.test.js +git commit -m "feat: implement serializeICS with round-trip support" +``` + +--- + +## Task 5: `lib/recur.js` - recurrence grid expansion + +**Files:** +- Create: `src/lib/recur.js` +- Create: `test/recur.test.js` + +- [ ] **Step 1: Write the failing test** + +Create `D:\zcode\timeTable2\test\recur.test.js`: +```js +import { describe, it, expect } from 'vitest' +import { expandOccurrences, intervalDays } from '../src/lib/recur.js' + +describe('intervalDays', () => { + it('returns 7 for WEEKLY interval=1', () => { + const ev = { rrule: { freq: 'WEEKLY', interval: 1 } } + expect(intervalDays(ev)).toBe(7) + }) + + it('returns 14 for WEEKLY interval=2', () => { + const ev = { rrule: { freq: 'WEEKLY', interval: 2 } } + expect(intervalDays(ev)).toBe(14) + }) + + it('returns interval for DAILY', () => { + const ev = { rrule: { freq: 'DAILY', interval: 3 } } + expect(intervalDays(ev)).toBe(3) + }) +}) + +describe('expandOccurrences', () => { + it('returns single occurrence for non-recurring event', () => { + const ev = { dtstartDate: '2026-07-27', rrule: null, exdates: [] } + const occs = expandOccurrences(ev) + expect(occs).toHaveLength(1) + expect(occs[0]).toEqual({ date: '2026-07-27', skipped: false }) + }) + + it('expands weekly event from Jul 27 to Oct 19', () => { + const ev = { + dtstartDate: '2026-07-27', + rrule: { freq: 'WEEKLY', interval: 1, byday: 'MO', untilDate: '2026-10-19' }, + exdates: ['2026-09-21'], + } + const occs = expandOccurrences(ev) + // Jul 27 + 12 weeks = Oct 19 -> 13 occurrences + expect(occs).toHaveLength(13) + expect(occs[0].date).toBe('2026-07-27') + expect(occs[12].date).toBe('2026-10-19') + // Sep 21 is skipped + const sep21 = occs.find(o => o.date === '2026-09-21') + expect(sep21.skipped).toBe(true) + // Others not skipped + expect(occs[0].skipped).toBe(false) + }) + + it('respects exdates set', () => { + const ev = { + dtstartDate: '2026-07-27', + rrule: { freq: 'WEEKLY', interval: 1, byday: 'MO', untilDate: '2026-08-24' }, + exdates: ['2026-08-03', '2026-08-17'], + } + const occs = expandOccurrences(ev) + expect(occs).toHaveLength(5) // Jul27, Aug3, Aug10, Aug17, Aug24 + expect(occs[1].skipped).toBe(true) // Aug 3 + expect(occs[2].skipped).toBe(false) // Aug 10 + expect(occs[3].skipped).toBe(true) // Aug 17 + }) + + it('caps at 500 occurrences', () => { + const ev = { + dtstartDate: '2020-01-01', + rrule: { freq: 'DAILY', interval: 1, untilDate: '2030-12-31' }, + exdates: [], + } + const occs = expandOccurrences(ev) + expect(occs.length).toBeLessThanOrEqual(500) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /d/zcode/timeTable2 && npx vitest run test/recur.test.js 2>&1 | tail -10` +Expected: FAIL with "Cannot find module". + +- [ ] **Step 3: Write the implementation** + +Create `D:\zcode\timeTable2\src\lib\recur.js`: +```js +/** + * Recurrence grid expansion - port of ical_editor.py's occurrence generation. + * occurrences are computed on-demand from exdates, never stored on the model. + */ + +/** Interval in days for a recurring event. */ +export function intervalDays(ev) { + if (!ev.rrule) return 0 + const { freq, interval } = ev.rrule + const step = interval || 1 + return freq === 'WEEKLY' ? 7 * step : step +} + +function toISO(date) { + const y = date.getFullYear() + const m = String(date.getMonth() + 1).padStart(2, '0') + const d = String(date.getDate()).padStart(2, '0') + return `${y}-${m}-${d}` +} + +/** + * Expand a recurring event into an occurrence grid. + * Returns [{ date: 'YYYY-MM-DD', skipped: boolean }]. + */ +export function expandOccurrences(ev) { + // Non-recurring: single occurrence + if (!ev.rrule) { + return [{ date: ev.dtstartDate, skipped: false }] + } + + const step = intervalDays(ev) + if (step <= 0) { + return [{ date: ev.dtstartDate, skipped: false }] + } + + const start = new Date(ev.dtstartDate + 'T00:00:00') + const until = ev.rrule.untilDate + ? new Date(ev.rrule.untilDate + 'T00:00:00') + : new Date(start.getTime() + 365 * 24 * 3600 * 1000) // default +1 year + + const exdateSet = new Set(ev.exdates || []) + const occs = [] + const cur = new Date(start) + let count = 0 + + while (cur <= until && count < 500) { + const iso = toISO(cur) + occs.push({ date: iso, skipped: exdateSet.has(iso) }) + cur.setDate(cur.getDate() + step) + count++ + } + + return occs +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /d/zcode/timeTable2 && npx vitest run test/recur.test.js 2>&1 | tail -10` +Expected: PASS, 6 tests passed. + +- [ ] **Step 5: Commit** + +```bash +cd /d/zcode/timeTable2 +git add src/lib/recur.js test/recur.test.js +git commit -m "feat: add recurrence grid expansion" +``` + +--- + +## Task 6: `lib/organize.js` - GCD dedup algorithm (port of ical_organizer.py) + +**Files:** +- Create: `src/lib/organize.js` +- Create: `test/organize.test.js` + +- [ ] **Step 1: Write the failing golden test** + +Create `D:\zcode\timeTable2\test\organize.test.js`: +```js +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import { parseICS } from '../src/lib/ical-io.js' +import { organize, seriesKey, detectInterval, fitSeries } from '../src/lib/organize.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const GOLDEN = readFileSync(join(__dirname, 'golden-org.ics'), 'utf-8') +const { events } = parseICS(GOLDEN) + +describe('seriesKey', () => { + it('groups events with same summary/location/time/duration/weekday', () => { + const e1 = events[0] // ACC INFO SYS Tutorial, Mon 18:00 + const e2 = events[1] // same series, next week + expect(seriesKey(e1)).toBe(seriesKey(e2)) + }) + + it('separates events with different summary', () => { + const e1 = events[0] // ACC INFO SYS + // Find a BUS LAW event + const eBus = events.find(e => e.summary.startsWith('BUS LAW, Tutorial')) + expect(seriesKey(e1)).not.toBe(seriesKey(eBus)) + }) +}) + +describe('detectInterval', () => { + it('returns 7 for weekly dates', () => { + const dates = ['2026-07-27', '2026-08-03', '2026-08-10', '2026-08-17'] + expect(detectInterval(dates)).toBe(7) + }) + + it('returns null for single date', () => { + expect(detectInterval(['2026-07-27'])).toBeNull() + }) + + it('returns 7 for dates with a skip (gaps 7,7,14,7)', () => { + const dates = ['2026-07-27', '2026-08-03', '2026-08-10', '2026-08-24', '2026-08-31'] + expect(detectInterval(dates)).toBe(7) + }) +}) + +describe('fitSeries', () => { + it('fits ACC INFO SYS series (12 events, 1 skip)', () => { + const accEvents = events.filter(e => e.summary === 'ACC INFO SYS, Tutorial') + const result = fitSeries(accEvents) + expect(result).not.toBeNull() + expect(result.rrule.freq).toBe('WEEKLY') + expect(result.rrule.interval).toBe(1) + expect(result.rrule.byday).toBe('MO') + expect(result.exdates).toContain('2026-09-21') + }) + + it('rejects BUS LAW Workshop (too sparse: 3 events, 5 missing)', () => { + const wsEvents = events.filter(e => e.summary === 'BUS LAW, Workshop') + expect(wsEvents).toHaveLength(3) + expect(fitSeries(wsEvents)).toBeNull() + }) +}) + +describe('organize (golden test against org.ics)', () => { + const { events: organized, stats } = organize(events) + + it('collapses 61 events into 8', () => { + expect(organized).toHaveLength(8) + expect(stats.series).toBe(5) + expect(stats.flat).toBe(3) + }) + + it('produces 5 recurring events with RRULE', () => { + const recurring = organized.filter(e => e.rrule !== null) + expect(recurring).toHaveLength(5) + }) + + it('produces 3 flat BUS LAW Workshop events', () => { + const flatWs = organized.filter( + e => e.summary === 'BUS LAW, Workshop' && e.rrule === null, + ) + expect(flatWs).toHaveLength(3) + }) + + it('ACC INFO SYS series has 1 exdate (Sep 21)', () => { + const acc = organized.find(e => e.summary === 'ACC INFO SYS, Tutorial') + expect(acc.rrule).not.toBeNull() + expect(acc.exdates).toEqual(['2026-09-21']) + }) + + it('BUS LAW Tutorial series has 2 exdates', () => { + const bl = organized.find(e => e.summary === 'BUS LAW, Tutorial') + expect(bl.rrule).not.toBeNull() + expect(bl.exdates).toHaveLength(2) + expect(bl.exdates).toContain('2026-08-31') + expect(bl.exdates).toContain('2026-09-21') + }) + + it('preserves original UID of base event', () => { + const acc = organized.find(e => e.summary === 'ACC INFO SYS, Tutorial') + expect(acc.uid).toBe('uid0') // first event in series + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /d/zcode/timeTable2 && npx vitest run test/organize.test.js 2>&1 | tail -10` +Expected: FAIL with "Cannot find module". + +- [ ] **Step 3: Write the implementation** + +Create `D:\zcode\timeTable2\src\lib\organize.js`: +```js +/** + * GCD-based recurrence detection - port of ical_organizer.py. + * Collapses a flat list of per-occurrence events into RRULE + EXDATE. + */ +import { bydayFromDate } from './weekday.js' + +// ------------------------------------------------------------------ // +// Grouping +// ------------------------------------------------------------------ // +/** + * Identity of a recurring series. + * Two events belong together iff they share summary, location, start + * wall-clock time, duration (minutes), and weekday. + */ +export function seriesKey(ev) { + const start = new Date(ev.dtstartDate + 'T' + ev.dtstartTime + ':00') + const [sh, sm] = ev.dtstartTime.split(':').map(Number) + const [eh, em] = ev.dtendTime.split(':').map(Number) + const durationMin = (eh * 60 + em) - (sh * 60 + sm) + return [ev.summary, ev.location, ev.dtstartTime, durationMin, start.getDay()].join('|') +} + +// ------------------------------------------------------------------ // +// Interval detection +// ------------------------------------------------------------------ // +function gcd(a, b) { + while (b) { [a, b] = [b, a % b] } + return a +} + +/** + * Return the recurrence interval in days, or null if irregular. + * Computes GCD of all consecutive date gaps. + */ +export function detectInterval(dateStrings) { + if (dateStrings.length < 2) return null + const dates = [...dateStrings].sort() + const gaps = [] + for (let i = 0; i < dates.length - 1; i++) { + const a = new Date(dates[i] + 'T00:00:00') + const b = new Date(dates[i + 1] + 'T00:00:00') + gaps.push(Math.round((b - a) / (24 * 3600 * 1000))) + } + let g = gaps[0] + for (let i = 1; i < gaps.length; i++) { + g = gcd(g, gaps[i]) + } + return g > 0 ? g : null +} + +// ------------------------------------------------------------------ // +// Series fitting +// ------------------------------------------------------------------ /** + * Try to collapse events into one recurring event. + * Returns { base, rrule, exdates } or null to decline. + */ +export function fitSeries(evs) { + if (evs.length < 2) return null + + const sorted = [...evs].sort((a, b) => + a.dtstartDate < b.dtstartDate ? -1 : a.dtstartDate > b.dtstartDate ? 1 : 0, + ) + const starts = sorted.map(e => e.dtstartDate) + const interval = detectInterval(starts) + if (!interval) return null + + const first = sorted[0] + const last = sorted[sorted.length - 1] + const present = new Set(starts) + + // Build expected grid first..last step interval days + const expected = [] + const cur = new Date(first.dtstartDate + 'T00:00:00') + const lastDate = new Date(last.dtstartDate + 'T00:00:00') + while (cur <= lastDate) { + const y = cur.getFullYear() + const m = String(cur.getMonth() + 1).padStart(2, '0') + const d = String(cur.getDate()).padStart(2, '0') + expected.push(`${y}-${m}-${d}`) + cur.setDate(cur.getDate() + interval) + } + + const expectedSet = new Set(expected) + const missing = expected.filter(d => !present.has(d)) + const offGrid = starts.filter(d => !expectedSet.has(d)) + if (offGrid.length > 0) return null + + // "Mostly regular": at least 2 occurrences and occurrences >= skips + if (evs.length < 2 || missing.length >= evs.length) return null + + // Build rrule + const firstDate = new Date(first.dtstartDate + 'T00:00:00') + let freq, step, byday + if (interval % 7 === 0) { + freq = 'WEEKLY' + step = interval / 7 + byday = bydayFromDate(firstDate) + } else { + freq = 'DAILY' + step = interval + byday = null + } + + const rrule = { freq, interval: step, byday, untilDate: last.dtstartDate } + return { base: first, rrule, exdates: missing } +} + +// ------------------------------------------------------------------ // +// Organize +// ------------------------------------------------------------------ // +/** + * Collapse flat events into recurring series. + * Returns { events, stats: { series, flat } }. + */ +export function organize(events) { + // Group by series key + const groups = new Map() + for (const ev of events) { + const key = seriesKey(ev) + if (!groups.has(key)) groups.set(key, []) + groups.get(key).push(ev) + } + + const result = [] + let nSeries = 0 + let nFlat = 0 + + for (const evs of groups.values()) { + // Sort by date + evs.sort((a, b) => + a.dtstartDate < b.dtstartDate ? -1 : a.dtstartDate > b.dtstartDate ? 1 : 0, + ) + + const fit = fitSeries(evs) + if (fit) { + // Collapsed event: preserve base's UID (not reassign) + result.push({ + ...fit.base, + rrule: fit.rrule, + exdates: fit.exdates, + }) + nSeries++ + } else { + // Keep flat + for (const ev of evs) { + result.push(ev) + nFlat++ + } + } + } + + return { events: result, stats: { series: nSeries, flat: nFlat } } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd /d/zcode/timeTable2 && npx vitest run test/organize.test.js 2>&1 | tail -15` +Expected: PASS, all tests (seriesKey 2 + detectInterval 3 + fitSeries 2 + organize 6 = 13) passed. + +- [ ] **Step 5: Run all tests to confirm no regressions** + +Run: `cd /d/zcode/timeTable2 && npx vitest run 2>&1 | tail -15` +Expected: PASS, all tests across all files pass. + +- [ ] **Step 6: Commit** + +```bash +cd /d/zcode/timeTable2 +git add src/lib/organize.js test/organize.test.js +git commit -m "feat: port GCD dedup algorithm from ical_organizer.py" +``` + +--- + +## Task 7: `composables/useCalendar.js` - central store with undo + +**Files:** +- Create: `src/composables/useCalendar.js` + +- [ ] **Step 1: Write the store** + +Create `D:\zcode\timeTable2\src\composables\useCalendar.js`: +```js +import { reactive, computed } from 'vue' +import { parseICS, serializeICS } from '../lib/ical-io.js' +import { organize as organizeEvents } from '../lib/organize.js' + +const MAX_HISTORY = 50 + +// Singleton reactive state +const state = reactive({ + meta: null, + events: [], + selectedUid: null, + history: [], + redoStack: [], + fileName: null, + dirty: false, +}) + +// ------------------------------------------------------------------ // +// Snapshot / undo +// ------------------------------------------------------------------ // +function snapshot() { + return { + meta: state.meta ? JSON.parse(JSON.stringify(state.meta)) : null, + events: JSON.parse(JSON.stringify(state.events)), + selectedUid: state.selectedUid, + } +} + +function pushHistory() { + state.history.push(snapshot()) + if (state.history.length > MAX_HISTORY) state.history.shift() + state.redoStack = [] +} + +function restore(snap) { + state.meta = snap.meta ? JSON.parse(JSON.stringify(snap.meta)) : null + state.events = JSON.parse(JSON.stringify(snap.events)) + state.selectedUid = snap.selectedUid +} + +// ------------------------------------------------------------------ // +// Actions +// ------------------------------------------------------------------ // +function loadFile(file) { + return file.text().then((text) => { + const parsed = parseICS(text) + state.meta = parsed.meta + state.events = parsed.events + state.selectedUid = null + state.history = [] + state.redoStack = [] + state.fileName = file.name + state.dirty = false + }) +} + +function organize() { + if (state.events.length === 0) return + pushHistory() + const result = organizeEvents(state.events) + state.events = result.events + state.dirty = true + return result.stats +} + +function updateEvent(uid, patch) { + pushHistory() + const ev = state.events.find((e) => e.uid === uid) + if (ev) { + Object.assign(ev, patch) + state.dirty = true + } +} + +function deleteEvent(uid) { + pushHistory() + state.events = state.events.filter((e) => e.uid !== uid) + if (state.selectedUid === uid) state.selectedUid = null + state.dirty = true +} + +function addEvent() { + pushHistory() + const today = new Date() + const y = today.getFullYear() + const m = String(today.getMonth() + 1).padStart(2, '0') + const d = String(today.getDate()).padStart(2, '0') + const ev = { + uid: crypto.randomUUID(), + summary: '新日程', + location: '', + description: '', + dtstartDate: `${y}-${m}-${d}`, + dtstartTime: '09:00', + dtendTime: '10:00', + tzid: 'UTC', + rrule: null, + exdates: [], + _raw: { dtstamp: new Date().toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z' }, + } + state.events.push(ev) + state.selectedUid = ev.uid + state.dirty = true +} + +function splitEvent(uid, splitDate) { + const ev = state.events.find((e) => e.uid === uid) + if (!ev || !ev.rrule) return + + // Snap splitDate to the next grid point >= splitDate + const step = ev.rrule.freq === 'WEEKLY' ? 7 * (ev.rrule.interval || 1) : (ev.rrule.interval || 1) + const start = new Date(ev.dtstartDate + 'T00:00:00') + let snap = new Date(splitDate + 'T00:00:00') + // Advance snap to the next grid point on or after splitDate + while (snap < start || ((snap - start) / (24 * 3600 * 1000)) % step !== 0) { + if (snap < start) { snap = new Date(start); break } + snap.setDate(snap.getDate() + 1) + if (snap > new Date(ev.rrule.untilDate + 'T00:00:00')) return // out of range + } + const snapStr = `${snap.getFullYear()}-${String(snap.getMonth() + 1).padStart(2, '0')}-${String(snap.getDate()).padStart(2, '0')}` + + // Part A: rrule.untilDate = day before snap (previous grid point) + const prevGrid = new Date(snap) + prevGrid.setDate(prevGrid.getDate() - step) + const prevStr = `${prevGrid.getFullYear()}-${String(prevGrid.getMonth() + 1).padStart(2, '0')}-${String(prevGrid.getDate()).padStart(2, '0')}` + + pushHistory() + + const partA = JSON.parse(JSON.stringify(ev)) + partA.rrule = { ...ev.rrule, untilDate: prevStr } + partA.exdates = ev.exdates.filter((d) => d < splitDate) + + const partB = JSON.parse(JSON.stringify(ev)) + partB.uid = crypto.randomUUID() + partB.dtstartDate = snapStr + partB.exdates = ev.exdates.filter((d) => d >= splitDate) + + const idx = state.events.findIndex((e) => e.uid === uid) + state.events.splice(idx, 1, partA, partB) + state.selectedUid = partB.uid + state.dirty = true +} + +function toggleOccurrence(uid, date) { + pushHistory() + const ev = state.events.find((e) => e.uid === uid) + if (!ev) return + const i = ev.exdates.indexOf(date) + if (i >= 0) { + ev.exdates.splice(i, 1) + } else { + ev.exdates.push(date) + ev.exdates.sort() + } + state.dirty = true +} + +function undo() { + if (state.history.length === 0) return + state.redoStack.push(snapshot()) + restore(state.history.pop()) +} + +function redo() { + if (state.redoStack.length === 0) return + state.history.push(snapshot()) + restore(state.redoStack.pop()) +} + +function serialize() { + return serializeICS({ meta: state.meta, events: state.events }) +} + +function markSaved() { + state.dirty = false +} + +// ------------------------------------------------------------------ // +// Computed +// ------------------------------------------------------------------ // +const selectedEvent = computed(() => + state.events.find((e) => e.uid === state.selectedUid) || null, +) + +const canUndo = computed(() => state.history.length > 0) +const canRedo = computed(() => state.redoStack.length > 0) +const isLoaded = computed(() => state.events.length > 0) +const stats = computed(() => { + const nRecur = state.events.filter((e) => e.rrule !== null).length + return { total: state.events.length, recurring: nRecur, single: state.events.length - nRecur } +}) + +export function useCalendar() { + return { + state, + selectedEvent, + canUndo, + canRedo, + isLoaded, + stats, + loadFile, + organize, + updateEvent, + deleteEvent, + addEvent, + splitEvent, + toggleOccurrence, + undo, + redo, + serialize, + markSaved, + } +} +``` + +- [ ] **Step 2: Verify it imports without error** + +Run: `cd /d/zcode/timeTable2 && node -e "import('./src/composables/useCalendar.js').then(() => console.log('OK')).catch(e => { console.error(e); process.exit(1) })"` +Expected: prints "OK" (Vue's reactivity works in Node since it's framework-agnostic). + +- [ ] **Step 3: Commit** + +```bash +cd /d/zcode/timeTable2 +git add src/composables/useCalendar.js +git commit -m "feat: add central store with snapshot undo/redo" +``` + +--- + +## Task 8: `DropZone.vue` + `App.vue` - file loading + +**Files:** +- Create: `src/components/DropZone.vue` +- Modify: `src/App.vue` + +- [ ] **Step 1: Create `DropZone.vue`** + +Create `D:\zcode\timeTable2\src\components\DropZone.vue`: +```vue + + + + + +``` + +- [ ] **Step 2: Rewrite `App.vue` with layout skeleton** + +Overwrite `D:\zcode\timeTable2\src\App.vue`: +```vue + + + + + +``` + +- [ ] **Step 3: Verify dev server runs** + +Run: `cd /d/zcode/timeTable2 && timeout 8 npm run dev 2>&1 | head -10` +Expected: Vite ready, no errors. + +- [ ] **Step 4: Commit** + +```bash +cd /d/zcode/timeTable2 +git add src/components/DropZone.vue src/App.vue +git commit -m "feat: add DropZone and App layout skeleton" +``` + +--- + +## Task 9: `EventList.vue` + `HeaderBar.vue` - list and organize + +**Files:** +- Create: `src/components/EventList.vue` +- Create: `src/components/HeaderBar.vue` +- Modify: `src/App.vue` + +- [ ] **Step 1: Create `EventList.vue`** + +Create `D:\zcode\timeTable2\src\components\EventList.vue`: +```vue + + + + + +``` + +- [ ] **Step 2: Create `HeaderBar.vue`** + +Create `D:\zcode\timeTable2\src\components\HeaderBar.vue`: +```vue + + + + + +``` + +- [ ] **Step 3: Wire into `App.vue`** + +Overwrite `D:\zcode\timeTable2\src\App.vue`: +```vue + + + + + +``` + +- [ ] **Step 4: Verify dev server runs** + +Run: `cd /d/zcode/timeTable2 && timeout 8 npm run dev 2>&1 | head -10` +Expected: Vite ready, no errors. + +- [ ] **Step 5: Commit** + +```bash +cd /d/zcode/timeTable2 +git add src/components/EventList.vue src/components/HeaderBar.vue src/App.vue +git commit -m "feat: add EventList and HeaderBar with organize/undo/download" +``` + +--- + +## Task 10: `OccurrenceGrid.vue` - occurrence toggle grid + +**Files:** +- Create: `src/components/OccurrenceGrid.vue` + +- [ ] **Step 1: Create `OccurrenceGrid.vue`** + +Create `D:\zcode\timeTable2\src\components\OccurrenceGrid.vue`: +```vue + + + + + +``` + +- [ ] **Step 2: Commit** + +```bash +cd /d/zcode/timeTable2 +git add src/components/OccurrenceGrid.vue +git commit -m "feat: add OccurrenceGrid component" +``` + +--- + +## Task 11: `EventDetail.vue` - edit form with split + +**Files:** +- Create: `src/components/EventDetail.vue` +- Modify: `src/App.vue` (wire EventDetail) + +- [ ] **Step 1: Create `EventDetail.vue`** + +Create `D:\zcode\timeTable2\src\components\EventDetail.vue`: +```vue + + + + + +``` + +- [ ] **Step 2: Wire EventDetail into `App.vue`** + +Edit `D:\zcode\timeTable2\src\App.vue` -- replace the `
` block and add the import: + +Replace this part: +```vue +
+
从左侧选择一个日程进行编辑
+
+``` +with: +```vue +
+ +
+``` + +And add to the `