From e1873371279fea434cec415e75c0f6584899a808 Mon Sep 17 00:00:00 2001 From: timeTable2 dev Date: Mon, 13 Jul 2026 18:32:46 +0800 Subject: [PATCH] feat: add recurrence grid expansion --- src/lib/recur.js | 54 ++++++++++++++++++++++++++++++++++++ test/recur.test.js | 69 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 src/lib/recur.js create mode 100644 test/recur.test.js diff --git a/src/lib/recur.js b/src/lib/recur.js new file mode 100644 index 0000000..7e3c3ae --- /dev/null +++ b/src/lib/recur.js @@ -0,0 +1,54 @@ +/** + * 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 +} diff --git a/test/recur.test.js b/test/recur.test.js new file mode 100644 index 0000000..886082b --- /dev/null +++ b/test/recur.test.js @@ -0,0 +1,69 @@ +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) + }) +})