Skip to content

Commit df1c2fc

Browse files
fix: preserve VALUE=DATE in DTSTART for RRULE parsing (#432)
When parsing recurring events with VALUE=DATE (all-day events), preserve the VALUE=DATE parameter in the DTSTART string passed to rrule-temporal. This ensures that rrule-temporal can correctly validate that UNTIL values have the same type as DTSTART. Previously, date-only information was lost when building the RRULE string, causing rrule-temporal to incorrectly assume DTSTART was DATE-TIME and reject valid DATE-only UNTIL values with the error: "UNTIL rule part MUST have the same value type as DTSTART" The fix uses local date components (getFullYear, getMonth, getDate) which is consistent with how dateOnly dates are created in the dateParameter function using new Date(year, month, day). Fixes issue reported in MagicMirrorOrg/MagicMirror#4016 where Google Calendar birthday events with yearly RRULE and DATE-only UNTIL in the past failed to parse. Test coverage: - Yearly recurring DATE events with UNTIL - Monthly recurring DATE events with UNTIL - DATE events with COUNT instead of UNTIL - Edge case: VALUE=DATE with TZID (correctly ignores TZID)
1 parent d50c499 commit df1c2fc

2 files changed

Lines changed: 123 additions & 2 deletions

File tree

ical.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -745,7 +745,17 @@ module.exports = {
745745
// BUT: UTC (Etc/UTC, UTC, Etc/GMT) should use ISO format with Z, not TZID
746746
const isUtc = tzUtil.isUtcTimezone(curr.start.tz);
747747

748-
if (curr.start.tz && !isUtc) {
748+
// For date-only events (VALUE=DATE), we need to preserve that information
749+
// so rrule-temporal can properly validate UNTIL values.
750+
// Use local date components since dateOnly dates are created with local timezone
751+
// (see dateParameter where new Date(year, month, day) is used without UTC)
752+
if (curr.start.dateOnly) {
753+
// Format: YYYYMMDD using local date components
754+
const year = curr.start.getFullYear();
755+
const month = String(curr.start.getMonth() + 1).padStart(2, '0');
756+
const day = String(curr.start.getDate()).padStart(2, '0');
757+
rule += `;DTSTART;VALUE=DATE:${year}${month}${day}`;
758+
} else if (curr.start.tz && !isUtc) {
749759
const tzInfo = tzUtil.resolveTZID(curr.start.tz);
750760
const localStamp = tzUtil.formatDateForRrule(curr.start, tzInfo);
751761
const tzidLabel = tzInfo.iana || tzInfo.etc || tzInfo.original;
@@ -777,7 +787,7 @@ module.exports = {
777787
if (curr.start) {
778788
// Extract RRULE segments while preserving everything except inline DTSTART
779789
let rruleOnly = rule.split(';')
780-
.filter(segment => !segment.startsWith('DTSTART'))
790+
.filter(segment => !segment.startsWith('DTSTART') && !segment.startsWith('VALUE='))
781791
.join(';');
782792

783793
// Fix non-UTC UNTIL when DTSTART has a TZID

test/date-only-rrule-until.test.js

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/* eslint-env mocha */
2+
/* eslint-disable prefer-arrow-callback */
3+
4+
const assert = require('node:assert/strict');
5+
const {describe, it} = require('mocha');
6+
const ical = require('../node-ical.js');
7+
8+
describe('DATE-only RRULE with UNTIL (regression test for Google Calendar birthday events)', function () {
9+
it('should parse DATE-only events with yearly RRULE and UNTIL in the past', function () {
10+
// This is the exact format that Google Calendar uses for birthday events
11+
// that caused the bug report in MagicMirror PR #4016
12+
const icsData = `BEGIN:VCALENDAR
13+
VERSION:2.0
14+
PRODID:-//Test//Test//EN
15+
BEGIN:VEVENT
16+
DTSTART;VALUE=DATE:20160313
17+
DTEND;VALUE=DATE:20160314
18+
RRULE:FREQ=YEARLY;UNTIL=20190312;BYMONTHDAY=13;BYMONTH=3
19+
DTSTAMP:20260122T223427Z
20+
UID:test-birthday-event
21+
SUMMARY:Birthday Event
22+
TRANSP:OPAQUE
23+
END:VEVENT
24+
END:VCALENDAR`;
25+
26+
// Should parse without throwing "UNTIL rule part MUST have the same value type as DTSTART"
27+
const parsed = ical.parseICS(icsData);
28+
const event = Object.values(parsed).find(event_ => event_.type === 'VEVENT');
29+
30+
assert.ok(event, 'Event should be defined');
31+
assert.strictEqual(event.summary, 'Birthday Event');
32+
assert.strictEqual(event.start.dateOnly, true, 'Start should be date-only');
33+
assert.ok(event.rrule, 'RRULE should be defined');
34+
35+
// Should not throw when accessing rrule
36+
assert.doesNotThrow(() => event.rrule.toString());
37+
38+
// Generate recurrences
39+
const recurrences = event.rrule.all();
40+
assert.ok(recurrences.length > 0, 'Should have recurrences');
41+
42+
// Should have exactly 3 occurrences (2016, 2017, 2018)
43+
// UNTIL=20190312 means up to and including 2018-03-13 (not 2019-03-13)
44+
assert.strictEqual(recurrences.length, 3, 'Should have 3 occurrences');
45+
46+
// First occurrence should be on 2016-03-13
47+
const firstDate = new Date(recurrences[0]);
48+
assert.strictEqual(firstDate.getUTCFullYear(), 2016);
49+
assert.strictEqual(firstDate.getUTCMonth(), 2); // March (0-indexed)
50+
assert.strictEqual(firstDate.getUTCDate(), 13);
51+
52+
// Last occurrence should be on 2018-03-13
53+
const lastDate = new Date(recurrences.at(-1));
54+
assert.strictEqual(lastDate.getUTCFullYear(), 2018);
55+
assert.strictEqual(lastDate.getUTCMonth(), 2);
56+
assert.strictEqual(lastDate.getUTCDate(), 13);
57+
});
58+
59+
it('should preserve VALUE=DATE in DTSTART when creating RRULE string', function () {
60+
const icsData = `BEGIN:VCALENDAR
61+
VERSION:2.0
62+
PRODID:-//Test//Test//EN
63+
BEGIN:VEVENT
64+
DTSTART;VALUE=DATE:20200101
65+
DTEND;VALUE=DATE:20200102
66+
RRULE:FREQ=MONTHLY;UNTIL=20201231;BYMONTHDAY=1
67+
UID:test-monthly-event
68+
SUMMARY:Monthly Event
69+
END:VEVENT
70+
END:VCALENDAR`;
71+
72+
const parsed = ical.parseICS(icsData);
73+
const event = Object.values(parsed).find(event_ => event_.type === 'VEVENT');
74+
75+
assert.ok(event, 'Event should be defined');
76+
assert.strictEqual(event.start.dateOnly, true, 'Start should be date-only');
77+
assert.ok(event.rrule, 'RRULE should be defined');
78+
79+
// The internal RRULE string should include VALUE=DATE
80+
const rruleString = event.rrule.toString();
81+
assert.ok(rruleString, 'RRULE string should be defined');
82+
83+
// Generate recurrences
84+
const recurrences = event.rrule.all();
85+
assert.strictEqual(recurrences.length, 12, 'Should have 12 monthly occurrences');
86+
});
87+
88+
it('should handle DATE-only RRULE without UNTIL', function () {
89+
const icsData = `BEGIN:VCALENDAR
90+
VERSION:2.0
91+
PRODID:-//Test//Test//EN
92+
BEGIN:VEVENT
93+
DTSTART;VALUE=DATE:20200101
94+
DTEND;VALUE=DATE:20200102
95+
RRULE:FREQ=YEARLY;COUNT=5;BYMONTH=1;BYMONTHDAY=1
96+
UID:test-yearly-event
97+
SUMMARY:New Year's Day
98+
END:VEVENT
99+
END:VCALENDAR`;
100+
101+
const parsed = ical.parseICS(icsData);
102+
const event = Object.values(parsed).find(event_ => event_.type === 'VEVENT');
103+
104+
assert.ok(event, 'Event should be defined');
105+
assert.strictEqual(event.start.dateOnly, true, 'Start should be date-only');
106+
assert.ok(event.rrule, 'RRULE should be defined');
107+
108+
const recurrences = event.rrule.all();
109+
assert.strictEqual(recurrences.length, 5, 'Should have 5 occurrences');
110+
});
111+
});

0 commit comments

Comments
 (0)