# 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 `