feat: add recurrence grid expansion
This commit is contained in:
54
src/lib/recur.js
Normal file
54
src/lib/recur.js
Normal file
@@ -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
|
||||||
|
}
|
||||||
69
test/recur.test.js
Normal file
69
test/recur.test.js
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user