import { describe, it, expect } from 'vitest' import { expandEventOccurrences, type RecurrenceRule } from './eventRecurrence' // Local-time Date factory (matches scheduleOccurrences.test.ts). Months // are 0-indexed in the Date constructor, 1-indexed here. const dt = ( year: number, month1to12: number, day: number, hours = 0, minutes = 0, ): Date => new Date(year, month1to12 - 1, day, hours, minutes, 0, 0) // Reference calendar: // April 2026 — Wed 1; Thu 2, 9, 16, 23, 30; Tue 7, 14, 21, 28; Mon 6, 13, 20, 27; Sun 5, 12, 19, 26 // May 2026 — Tue 5, 12, 19, 26; Thu 7, 14, 21, 28; Mon 4, 11, 18, 25 // June 2026 — Tue 2, 9, 16, 23, 30; Thu 4, 11, 18, 25; Mon 1, 8, 15, 22, 29 // March 2026 — Mon 2, 9, 16, 23, 30 (five Mondays) describe('expandEventOccurrences', () => { it('returns [] for recurrenceType = none', () => { const result = expandEventOccurrences( { date: dt(2026, 5, 1, 18, 30).toISOString(), recurrenceType: 'none' }, { now: dt(2026, 4, 1), horizon: dt(2026, 7, 1) }, ) expect(result).toEqual([]) }) it('returns [] when event.date is missing or invalid', () => { expect( expandEventOccurrences( { recurrenceType: 'weekly' }, { now: dt(2026, 4, 1), horizon: dt(2026, 5, 1) }, ), ).toEqual([]) expect( expandEventOccurrences( { date: 'not-a-date', recurrenceType: 'weekly' }, { now: dt(2026, 4, 1), horizon: dt(2026, 5, 1) }, ), ).toEqual([]) }) describe('daily', () => { it('yields every day from event.date to horizon at the event HH:mm', () => { const eventDate = dt(2026, 4, 10, 18, 30) // Fri const result = expandEventOccurrences( { date: eventDate.toISOString(), recurrenceType: 'daily' }, { now: dt(2026, 4, 1), horizon: dt(2026, 4, 14, 23, 59) }, ) expect(result.map((d) => d.getDate())).toEqual([10, 11, 12, 13, 14]) for (const d of result) { expect(d.getHours()).toBe(18) expect(d.getMinutes()).toBe(30) } }) it('starts from now when event.date is before now', () => { // Past event.date — enumeration should start at `now`, not backfill. const eventDate = dt(2026, 4, 1, 9, 0) const result = expandEventOccurrences( { date: eventDate.toISOString(), recurrenceType: 'daily' }, { now: dt(2026, 4, 10, 8, 0), horizon: dt(2026, 4, 12, 23, 59) }, ) // `after` is now=04-10 08:00; daily occurrences at 09:00 fall on // the 10th, 11th, 12th. expect(result.map((d) => `${d.getDate()} ${d.getHours()}:${d.getMinutes()}`)) .toEqual(['10 9:0', '11 9:0', '12 9:0']) }) it('returns [] when horizon < after', () => { const eventDate = dt(2026, 4, 10, 18, 30) const result = expandEventOccurrences( { date: eventDate.toISOString(), recurrenceType: 'daily' }, { now: dt(2026, 4, 1), horizon: dt(2026, 4, 9) }, ) expect(result).toEqual([]) }) }) describe('weekly', () => { it('derives the weekday from event.date', () => { // 2026-04-07 is a Tuesday const eventDate = dt(2026, 4, 7, 19, 0) const result = expandEventOccurrences( { date: eventDate.toISOString(), recurrenceType: 'weekly' }, { now: dt(2026, 4, 1), horizon: dt(2026, 4, 30) }, ) expect(result.map((d) => d.getDate())).toEqual([7, 14, 21, 28]) for (const d of result) { expect(d.getDay()).toBe(2) // Tuesday expect(d.getHours()).toBe(19) expect(d.getMinutes()).toBe(0) } }) }) describe('biweekly', () => { it('anchors parity on event.date', () => { const eventDate = dt(2026, 4, 7, 19, 0) // Tuesday const result = expandEventOccurrences( { date: eventDate.toISOString(), recurrenceType: 'biweekly' }, { now: dt(2026, 4, 1), horizon: dt(2026, 6, 30, 23, 59) }, ) // From 04-07: 04-07, 04-21, 05-05, 05-19, 06-02, 06-16, 06-30 expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([ '4-7', '4-21', '5-5', '5-19', '6-2', '6-16', '6-30', ]) }) }) describe('monthlyByDate', () => { it('uses event.date.getDate() as the day-of-month', () => { const eventDate = dt(2026, 4, 13, 18, 30) const result = expandEventOccurrences( { date: eventDate.toISOString(), recurrenceType: 'monthlyByDate' }, { now: dt(2026, 4, 1), horizon: dt(2026, 7, 31) }, ) expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([ '4-13', '5-13', '6-13', '7-13', ]) for (const d of result) { expect(d.getHours()).toBe(18) expect(d.getMinutes()).toBe(30) } }) it('skips months without the required day (the 31st)', () => { const eventDate = dt(2026, 1, 31, 20, 0) const result = expandEventOccurrences( { date: eventDate.toISOString(), recurrenceType: 'monthlyByDate' }, { now: dt(2026, 1, 1), horizon: dt(2026, 5, 31, 23, 59) }, ) expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([ '1-31', '3-31', '5-31', ]) }) }) describe('monthlyByWeekday', () => { it('derives weekday + weekOfMonth from event.date', () => { // 2026-04-13 is the 2nd Monday of April const eventDate = dt(2026, 4, 13, 20, 0) const result = expandEventOccurrences( { date: eventDate.toISOString(), recurrenceType: 'monthlyByWeekday' }, { now: dt(2026, 4, 1), horizon: dt(2026, 7, 31) }, ) // 2nd Monday of each month: Apr 13, May 11, Jun 8, Jul 13 expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([ '4-13', '5-11', '6-8', '7-13', ]) for (const d of result) { expect(d.getDay()).toBe(1) // Monday } }) it('clamps a 5th-occurrence event.date to "fourth"', () => { // 2026-03-30 is the 5th Monday of March. // Expected derivation: 'fourth' Monday (not 'last') — see weekOfMonthOf rationale. const eventDate = dt(2026, 3, 30, 20, 0) const result = expandEventOccurrences( { date: eventDate.toISOString(), recurrenceType: 'monthlyByWeekday' }, { now: dt(2026, 3, 1), horizon: dt(2026, 6, 30) }, ) // 4th Monday of each month: Mar 23 (before now? no — now is Mar 1), // Apr 27, May 25, Jun 22. March 23 is before event.date though. // Since `after = max(now, eventDate) = 2026-03-30`, March's 23rd is // excluded. So: Apr 27, May 25, Jun 22. expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([ '4-27', '5-25', '6-22', ]) }) }) describe('custom', () => { it('merges two monthly rules, sorted and deduplicated', () => { // 3rd Tuesday AND 4th Thursday of each month Apr-Jun 2026. // Apr: Tue 21, Thu 23 → 4-21, 4-23 // May: Tue 19, Thu 28 → 5-19, 5-28 // Jun: Tue 16, Thu 25 → 6-16, 6-25 const rules: RecurrenceRule[] = [ { frequency: 'monthlyByWeekday', weekday: 'tuesday', weekOfMonth: 'third' }, { frequency: 'monthlyByWeekday', weekday: 'thursday', weekOfMonth: 'fourth' }, ] const eventDate = dt(2026, 4, 21, 18, 30) // seed const result = expandEventOccurrences( { date: eventDate.toISOString(), recurrenceType: 'custom', recurrenceRules: rules, }, { now: dt(2026, 4, 1), horizon: dt(2026, 6, 30) }, ) expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([ '4-21', '4-23', '5-19', '5-28', '6-16', '6-25', ]) for (const d of result) { expect(d.getHours()).toBe(18) expect(d.getMinutes()).toBe(30) } }) it('deduplicates when rules overlap', () => { // Every Tuesday AND 3rd Tuesday: the 3rd-Tuesday dates are subsumed // by weekly, so the output equals the weekly sequence alone. const rules: RecurrenceRule[] = [ { frequency: 'weekly', weekday: 'tuesday' }, { frequency: 'monthlyByWeekday', weekday: 'tuesday', weekOfMonth: 'third' }, ] const eventDate = dt(2026, 4, 7, 18, 30) const result = expandEventOccurrences( { date: eventDate.toISOString(), recurrenceType: 'custom', recurrenceRules: rules, }, { now: dt(2026, 4, 1), horizon: dt(2026, 4, 30) }, ) expect(result.map((d) => d.getDate())).toEqual([7, 14, 21, 28]) }) it('drops rule rows with missing dependent fields rather than throwing', () => { const rules: RecurrenceRule[] = [ { frequency: 'monthlyByWeekday', weekday: 'tuesday', weekOfMonth: 'third' }, // Incomplete: weekOfMonth missing — should be dropped. { frequency: 'monthlyByWeekday', weekday: 'friday' }, // Incomplete: dayOfMonth missing — should be dropped. { frequency: 'monthlyByDate' }, ] const eventDate = dt(2026, 4, 21, 18, 30) const result = expandEventOccurrences( { date: eventDate.toISOString(), recurrenceType: 'custom', recurrenceRules: rules, }, { now: dt(2026, 4, 1), horizon: dt(2026, 6, 30) }, ) // Only the first (valid) rule contributes. expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([ '4-21', '5-19', '6-16', ]) }) it('returns [] when recurrenceType=custom but recurrenceRules is empty or missing', () => { const eventDate = dt(2026, 4, 13, 18, 30) expect( expandEventOccurrences( { date: eventDate.toISOString(), recurrenceType: 'custom', recurrenceRules: [], }, { now: dt(2026, 4, 1), horizon: dt(2026, 6, 30) }, ), ).toEqual([]) expect( expandEventOccurrences( { date: eventDate.toISOString(), recurrenceType: 'custom' }, { now: dt(2026, 4, 1), horizon: dt(2026, 6, 30) }, ), ).toEqual([]) }) }) describe('horizon truncation (caller-supplied endDate clipping)', () => { it('stops at horizon', () => { const eventDate = dt(2026, 4, 13, 18, 30) const result = expandEventOccurrences( { date: eventDate.toISOString(), recurrenceType: 'monthlyByDate' }, { now: dt(2026, 4, 1), horizon: dt(2026, 5, 31, 23, 59) }, ) expect(result.map((d) => `${d.getMonth() + 1}-${d.getDate()}`)).toEqual([ '4-13', '5-13', ]) }) }) describe('time-of-day invariant', () => { it('every occurrence has the same local HH:mm as event.date', () => { const eventDate = dt(2026, 4, 7, 15, 45) const result = expandEventOccurrences( { date: eventDate.toISOString(), recurrenceType: 'weekly' }, { now: dt(2026, 3, 1), horizon: dt(2026, 11, 30) }, ) expect(result.length).toBeGreaterThan(20) // spans the DST transitions for (const d of result) { expect(d.getHours()).toBe(15) expect(d.getMinutes()).toBe(45) } }) }) })