import { describe, it, expect } from 'vitest' import calendarUtil from './calendarUtil.js' // ─── rangesOverlap ──────────────────────────────────────────────────────────── describe('rangesOverlap', () => { const d = (h) => new Date(`2024-01-01T${String(h).padStart(2,'0')}:00:00`) it('overlapping ranges', () => { expect(calendarUtil.rangesOverlap(d(1), d(5), d(3), d(7))).toBe(true) }) it('adjacent ranges (touching) do not overlap', () => { expect(calendarUtil.rangesOverlap(d(1), d(3), d(3), d(5))).toBe(false) }) it('non-overlapping ranges', () => { expect(calendarUtil.rangesOverlap(d(1), d(2), d(5), d(7))).toBe(false) }) it('one range contained within the other', () => { expect(calendarUtil.rangesOverlap(d(1), d(10), d(3), d(7))).toBe(true) }) it('identical ranges overlap', () => { expect(calendarUtil.rangesOverlap(d(1), d(5), d(1), d(5))).toBe(true) }) }) // ─── startOfWeek ───────────────────────────────────────────────────────────── describe('startOfWeek', () => { it('Sunday start — Wednesday lands on previous Sunday', () => { const wed = new Date(2024, 0, 3) // Wednesday Jan 3 (local) const result = calendarUtil.startOfWeek(wed, 0) expect(result.getDay()).toBe(0) expect(result.getDate()).toBe(31) // Dec 31 }) it('Monday start — Wednesday lands on previous Monday', () => { const wed = new Date(2024, 0, 3) // Wednesday Jan 3 (local) const result = calendarUtil.startOfWeek(wed, 1) expect(result.getDay()).toBe(1) expect(result.getDate()).toBe(1) // Jan 1 }) it('returns start of same day when date is already weekStartsOn', () => { const sun = new Date(2024, 0, 7) // Sunday Jan 7 (local) const result = calendarUtil.startOfWeek(sun, 0) expect(result.toDateString()).toBe(sun.toDateString()) }) it('zeros out the time', () => { const d = new Date(2024, 0, 3, 15, 30) // Jan 3 15:30 local const result = calendarUtil.startOfWeek(d) expect(result.getHours()).toBe(0) expect(result.getMinutes()).toBe(0) }) }) // ─── buildMonthGrid ─────────────────────────────────────────────────────────── describe('buildMonthGrid', () => { it('always returns 42 dates', () => { expect(calendarUtil.buildMonthGrid(new Date(2024, 0, 1))).toHaveLength(42) expect(calendarUtil.buildMonthGrid(new Date(2024, 1, 1))).toHaveLength(42) }) it('grid starts on a Sunday when weekStartsOn=0', () => { const grid = calendarUtil.buildMonthGrid(new Date(2024, 0, 1), 0) expect(grid[0].getDay()).toBe(0) }) it('grid starts on a Monday when weekStartsOn=1', () => { const grid = calendarUtil.buildMonthGrid(new Date(2024, 0, 1), 1) expect(grid[0].getDay()).toBe(1) }) it('grid includes all days of the month', () => { const grid = calendarUtil.buildMonthGrid(new Date(2024, 2, 1)) // March 2024 const marchDays = grid.filter(d => d.getMonth() === 2) expect(marchDays).toHaveLength(31) }) it('consecutive dates are exactly 1 day apart', () => { const grid = calendarUtil.buildMonthGrid(new Date(2024, 0, 1)) for (let i = 1; i < grid.length; i++) { const diff = grid[i] - grid[i - 1] expect(diff).toBe(86400000) } }) }) // ─── getWeekNumber ──────────────────────────────────────────────────────────── describe('getWeekNumber', () => { it('Jan 1 2024 is week 1', () => { expect(calendarUtil.getWeekNumber(new Date(2024, 0, 1))).toBe(1) }) it('Dec 31 2023 is week 52', () => { expect(calendarUtil.getWeekNumber(new Date(2023, 11, 31))).toBe(52) }) it('Jan 4 2021 is week 1 (ISO rule: first week has Thursday)', () => { expect(calendarUtil.getWeekNumber(new Date(2021, 0, 4))).toBe(1) }) }) // ─── timedEnd ───────────────────────────────────────────────────────────────── describe('timedEnd', () => { const t = (iso) => new Date(iso) it('returns time_end when event has positive duration', () => { const event = { time_start: t('2024-01-01T10:00'), time_end: t('2024-01-01T11:00') } expect(calendarUtil.timedEnd(event)).toEqual(event.time_end) }) it('returns start+1ms for zero-duration event', () => { const start = t('2024-01-01T10:00') const event = { time_start: start, time_end: start } expect(calendarUtil.timedEnd(event).getTime()).toBe(start.getTime() + 1) }) it('returns start+1ms when end < start', () => { const event = { time_start: t('2024-01-01T10:00'), time_end: t('2024-01-01T09:00') } expect(calendarUtil.timedEnd(event).getTime()).toBe(event.time_start.getTime() + 1) }) }) // ─── toISO ─────────────────────────────────────────────────────────────────── describe('toISO', () => { it('non-all-day: parses string directly', () => { const str = '2024-06-15T14:30:00' expect(new Date(calendarUtil.toISO(str, false)).getFullYear()).toBe(2024) }) it('all-day start: midnight', () => { const iso = calendarUtil.toISO('2024-06-15', true, false) expect(new Date(iso).getHours()).toBe(0) expect(new Date(iso).getMinutes()).toBe(0) }) it('all-day end: 23:59:59.999', () => { const iso = calendarUtil.toISO('2024-06-15', true, true) const d = new Date(iso) expect(d.getHours()).toBe(23) expect(d.getMinutes()).toBe(59) expect(d.getSeconds()).toBe(59) expect(d.getMilliseconds()).toBe(999) }) }) // ─── generateOccurrenceDates ────────────────────────────────────────────────── describe('generateOccurrenceDates', () => { const range = [new Date('2024-01-01'), new Date('2024-12-31')] const makeTemplate = (overrides) => ({ time_start: new Date('2024-01-01T10:00'), time_end: new Date('2024-01-01T11:00'), recurrence: { frequency: 'daily', interval: 1 }, ...overrides, }) it('daily — correct count for January', () => { const tmpl = makeTemplate({ recurrence: { frequency: 'daily', interval: 1 } }) const rangeEnd = new Date('2024-01-31T23:59:59') const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], rangeEnd) expect(dates).toHaveLength(31) }) it('daily with interval=2 — every other day', () => { const tmpl = makeTemplate({ recurrence: { frequency: 'daily', interval: 2 } }) const rangeEnd = new Date('2024-01-10') const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], rangeEnd) // Jan 1, 3, 5, 7, 9 = 5 expect(dates).toHaveLength(5) }) it('daily with count limit', () => { const tmpl = makeTemplate({ recurrence: { frequency: 'daily', interval: 1, count: 5 } }) const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], range[1]) expect(dates).toHaveLength(5) }) it('daily with end_date stops before end_date', () => { const tmpl = makeTemplate({ recurrence: { frequency: 'daily', interval: 1, end_date: '2024-01-06' } }) const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], range[1]) // Jan 1-5 (end_date exclusive) expect(dates).toHaveLength(5) }) it('weekly — every Monday for 4 weeks', () => { const tmpl = makeTemplate({ time_start: new Date('2024-01-01T10:00'), // Monday recurrence: { frequency: 'weekly', interval: 1, count: 4 }, }) const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], range[1]) expect(dates).toHaveLength(4) dates.forEach(d => expect(d.getDay()).toBe(1)) // all Mondays }) it('weekly with days_of_week — Mon+Wed+Fri', () => { const tmpl = makeTemplate({ time_start: new Date('2024-01-01T10:00'), // Monday recurrence: { frequency: 'weekly', interval: 1, days_of_week: [1, 3, 5], count: 6 }, }) const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], range[1]) expect(dates).toHaveLength(6) dates.forEach(d => expect([1, 3, 5]).toContain(d.getDay())) }) it('monthly — same day each month for 3 months', () => { const tmpl = makeTemplate({ recurrence: { frequency: 'monthly', interval: 1, count: 3 } }) const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], range[1]) expect(dates).toHaveLength(3) dates.forEach(d => expect(d.getDate()).toBe(1)) }) it('yearly — same day each year', () => { const tmpl = makeTemplate({ recurrence: { frequency: 'yearly', interval: 1, count: 3 } }) const rangeEnd = new Date('2026-12-31') const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], rangeEnd) expect(dates).toHaveLength(3) expect(dates[0].getFullYear()).toBe(2024) expect(dates[1].getFullYear()).toBe(2025) expect(dates[2].getFullYear()).toBe(2026) }) it('no recurrence rule returns empty array', () => { const tmpl = { time_start: new Date(), time_end: new Date(), recurrence: null } expect(calendarUtil.generateOccurrenceDates(tmpl, range[0], range[1])).toEqual([]) }) }) // ─── expandRecurringEvents ─────────────────────────────────────────────────── describe('expandRecurringEvents', () => { const rangeStart = new Date('2024-01-01') const rangeEnd = new Date('2024-01-31') const baseTemplate = { id: 1, title: 'Standup', recurrence_id: 'abc', recurrence_parent_id: null, time_start: new Date(2024, 0, 1, 9, 0), time_end: new Date(2024, 0, 1, 9, 30), recurrence: { frequency: 'daily', interval: 1, count: 5 }, all_day: false, } it('non-recurring events pass through unchanged', () => { const regular = { id: 99, title: 'One-off', recurrence_id: null, recurrence_parent_id: null } const result = calendarUtil.expandRecurringEvents([regular], rangeStart, rangeEnd) expect(result).toContain(regular) }) it('template expands into correct number of occurrences', () => { const result = calendarUtil.expandRecurringEvents([baseTemplate], rangeStart, rangeEnd) expect(result).toHaveLength(5) }) it('occurrences have _isOccurrence flag', () => { const result = calendarUtil.expandRecurringEvents([baseTemplate], rangeStart, rangeEnd) result.forEach(e => expect(e._isOccurrence).toBe(true)) }) it('override replaces its occurrence', () => { const override = { id: 2, title: 'Standup (rescheduled)', recurrence_id: null, recurrence_parent_id: 1, recurrence_exception_date: new Date(2024, 0, 2, 9, 0), time_start: new Date(2024, 0, 2, 10, 0), time_end: new Date(2024, 0, 2, 10, 30), is_cancelled: false, } const result = calendarUtil.expandRecurringEvents([baseTemplate, override], rangeStart, rangeEnd) const jan2 = result.filter(e => { const d = new Date(e.time_start) return d.getMonth() === 0 && d.getDate() === 2 }) expect(jan2).toHaveLength(1) expect(jan2[0].title).toBe('Standup (rescheduled)') }) it('cancelled override removes the occurrence', () => { const cancelled = { id: 3, recurrence_id: null, recurrence_parent_id: 1, recurrence_exception_date: new Date(2024, 0, 2, 9, 0), time_start: new Date(2024, 0, 2, 9, 0), time_end: new Date(2024, 0, 2, 9, 30), is_cancelled: true, } const result = calendarUtil.expandRecurringEvents([baseTemplate, cancelled], rangeStart, rangeEnd) const jan2 = result.filter(e => { const d = new Date(e.time_start) return d.getMonth() === 0 && d.getDate() === 2 }) expect(jan2).toHaveLength(0) expect(result).toHaveLength(4) // 5 minus the cancelled one }) })