diff --git a/src/lib/organize.js b/src/lib/organize.js new file mode 100644 index 0000000..564d1e3 --- /dev/null +++ b/src/lib/organize.js @@ -0,0 +1,154 @@ +/** + * 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 } } +} diff --git a/test/organize.test.js b/test/organize.test.js new file mode 100644 index 0000000..3388d1e --- /dev/null +++ b/test/organize.test.js @@ -0,0 +1,100 @@ +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 + }) +})