Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions lib/ical-parser-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,25 @@ function normalizeRruleUntil(rruleOnly, startDate, tzUtil) {
const untilEnd = untilStart + untilMatch[0].length;

if (startDate.dateOnly) {
if (timePart && zSuffix) {
// When UNTIL is in UTC (e.g. from Outlook: UNTIL=20261019T230000Z for DTSTART=20261020),
// the UTC timestamp reflects midnight in the creator's positive-offset timezone.
const year = Number(datePart.slice(0, 4));
const monthIndex = Number(datePart.slice(4, 6)) - 1;
const day = Number(datePart.slice(6, 8));
const hours = Number(timePart.slice(1, 3));
const minutes = Number(timePart.slice(3, 5));
const seconds = Number(timePart.slice(5, 7));
const utcTimestamp = Date.UTC(year, monthIndex, day, hours, minutes, seconds);
const dtstartTime = Date.UTC(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());

// If UTC UNTIL is within 24h before dtstart due to timezone crossing midnight:
const normalizedDate = (utcTimestamp < dtstartTime && (dtstartTime - utcTimestamp) <= 86_400_000)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
? buildDateOnlyStamp(startDate)
: datePart;
return rruleOnly.slice(0, untilStart) + `UNTIL=${normalizedDate}` + rruleOnly.slice(untilEnd);
}

if (timePart) {
return rruleOnly.slice(0, untilStart) + `UNTIL=${datePart}` + rruleOnly.slice(untilEnd);
}
Expand Down
37 changes: 37 additions & 0 deletions test/date-only-rrule-until.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -200,4 +200,41 @@ END:VCALENDAR`;
const recurrences = event.rrule.all();
assert.ok(recurrences.length > 0, 'Should have recurrences');
});

it('should parse Outlook yearly date-only recurrence with UTC UNTIL offset', function () {
const ics = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Microsoft Corporation//Outlook 16.0 MIMEDIR//EN
BEGIN:VEVENT
SUMMARY:Annual Company Anniversary
DTSTART;VALUE=DATE:20261020
DTEND;VALUE=DATE:20261021
RRULE:FREQ=YEARLY;UNTIL=20261019T230000Z;INTERVAL=1;BYMONTHDAY=20;BYMONTH=10
UID:test-outlook-rrule-until
END:VEVENT
END:VCALENDAR`;

const parsed = ical.sync.parseICS(ics);
const event = Object.values(parsed).find(event_ => event_.summary === 'Annual Company Anniversary');
assert.ok(event, 'Event should exist');
assert.strictEqual(event.start.dateOnly, true, 'Start should be date-only');

const instances = ical.expandRecurringEvent(event, {
from: new Date('2026-01-01'),
to: new Date('2027-01-01'),
});

assert.strictEqual(instances.length, 1, 'Should expand to 1 occurrence');
assert.strictEqual(instances[0].start.getFullYear(), 2026);
assert.strictEqual(instances[0].start.getMonth(), 9);
assert.strictEqual(instances[0].start.getDate(), 20);

const recurrences = event.rrule.all();
assert.strictEqual(recurrences.length, 1, 'Should have 1 recurrence via rrule.all()');
const firstDate = new Date(recurrences[0]);
assert.strictEqual(firstDate.getFullYear(), 2026);
assert.strictEqual(firstDate.getMonth(), 9);
assert.strictEqual(firstDate.getDate(), 20);
});
});