Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
158 changes: 25 additions & 133 deletions ical.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,57 +4,14 @@ const {randomUUID} = require('node:crypto');

// Load Temporal polyfill if not natively available
// TODO: Drop the polyfill branch once our minimum Node version ships Temporal
const Temporal = globalThis.Temporal || require('@js-temporal/polyfill').Temporal;
const Temporal = globalThis.Temporal || require('temporal-polyfill').Temporal;
// Ensure Temporal exists before loading rrule-temporal
globalThis.Temporal ??= Temporal;

const {RRuleTemporal} = require('rrule-temporal');
const {toText: toTextFunction} = require('rrule-temporal/totext');
const tzUtil = require('./tz-utils.js');

/**
* Construct a date-only key (YYYY-MM-DD) from a Date object.
* For date-only events, uses local date components to avoid timezone shifts.
* For date-time events with a timezone, uses Temporal to extract the calendar date
* in the original timezone (avoids UTC shift, e.g. Exchange O365 RECURRENCE-ID
* midnight-CET becoming previous day in UTC – see GitHub issue #459).
* For date-time events without timezone, extracts the date from the ISO timestamp.
* @param {Date} dateValue - Date object with optional dateOnly and tz properties
* @returns {string} Date key in YYYY-MM-DD format
*/
function getDateKey(dateValue) {
if (dateValue.dateOnly) {
return `${dateValue.getFullYear()}-${String(dateValue.getMonth() + 1).padStart(2, '0')}-${String(dateValue.getDate()).padStart(2, '0')}`;
}

// When the Date carries timezone metadata, extract the calendar date in that timezone.
// This prevents midnight-in-local-tz (e.g. 00:00 CET = 23:00 UTC the day before)
// from being mapped to the wrong calendar day.
// Temporal handles both IANA zones and fixed-offset strings (e.g. "+01:00") uniformly.
if (dateValue.tz) {
try {
const resolved = tzUtil.resolveTZID(dateValue.tz);
const tzId = resolved?.iana || resolved?.offset;
if (resolved && !tzId) {
console.warn(
'[node-ical] Could not resolve TZID to an IANA name or UTC offset; falling back to UTC-based date key.',
{tzid: dateValue.tz, resolved},
);
}

if (tzId) {
return Temporal.Instant.fromEpochMilliseconds(dateValue.getTime())
.toZonedDateTimeISO(tzId)
.toPlainDate()
.toString();
}
} catch {
// Fall through to UTC-based key if timezone resolution fails
}
}

return dateValue.toISOString().slice(0, 10);
}
const {getDateKey} = require('./lib/date-utils.js');

/**
* Clone a Date object and preserve custom metadata (tz, dateOnly).
Expand Down Expand Up @@ -791,31 +748,15 @@ module.exports = {
// This a whole day event
if (curr.datetype === 'date') {
const originalStart = curr.start;
// Get the timezone offset
// The internal date is stored in UTC format
const offset = originalStart.getTimezoneOffset();
let nextStart;

// Only east of gmt is a problem
if (offset < 0) {
// Calculate the new startdate with the offset applied, bypass RRULE/Luxon confusion
// Make the internally stored DATE the actual date (not UTC offseted)
// Luxon expects local time, not utc, so gets start date wrong if not adjusted
nextStart = new Date(originalStart.getTime() + (Math.abs(offset) * 60_000));
} else {
// Strip any residual time component by rebuilding local midnight
nextStart = new Date(
originalStart.getFullYear(),
originalStart.getMonth(),
originalStart.getDate(),
0,
0,
0,
0,
);
}

curr.start = nextStart;
// Date-only: pass the wall-clock date from the local components directly,
// no system-timezone offset compensation needed.
const y = originalStart.getFullYear();
const m = originalStart.getMonth();
const d = originalStart.getDate();

// Rebuild as local midnight so downstream RRULE string formatting is unaffected
curr.start = new Date(y, m, d, 0, 0, 0, 0);

// Preserve any metadata that was attached to the original Date instance.
if (originalStart && originalStart.tz) {
Expand Down Expand Up @@ -947,71 +888,22 @@ module.exports = {
curr.rrule = new RRuleCompatWrapper(rruleTemporal);
} else {
// DATE-TIME events: convert curr.start (Date) to Temporal.ZonedDateTime
let dtstartTemporal;

if (curr.start.tz) {
// Has timezone - use Intl to get the local wall-clock time in that timezone
const tzInfo = tzUtil.resolveTZID(curr.start.tz);
const timeZone = tzInfo?.tzid || tzInfo?.iana || curr.start.tz || 'UTC';

try {
// Extract local time components in the target timezone.
// We use Intl.DateTimeFormat because curr.start is a Date in UTC but represents
// wall-clock time in the event's timezone.
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
hour12: false,
});

const parts = formatter.formatToParts(curr.start);
const partMap = {};
for (const part of parts) {
if (part.type !== 'literal') {
partMap[part.type] = Number.parseInt(part.value, 10);
}
}
const tzInfo = curr.start.tz ? tzUtil.resolveTZID(curr.start.tz) : undefined;
let timeZone = 'UTC';
if (tzInfo?.iana || tzInfo?.offset) {
timeZone = tzInfo.iana || tzInfo.offset;
} else if (tzInfo) {
console.warn('[node-ical] TZID resolved to neither IANA nor UTC offset; falling back to UTC for DTSTART conversion.');
}

// Create a PlainDateTime from the local time components
const plainDateTime = Temporal.PlainDateTime.from({
year: partMap.year,
month: partMap.month,
day: partMap.day,
hour: partMap.hour,
minute: partMap.minute,
second: partMap.second,
});

dtstartTemporal = plainDateTime.toZonedDateTime(timeZone, {disambiguation: 'compatible'});
} catch (error) {
// Invalid timezone - fall back to UTC interpretation
console.warn(`[node-ical] Failed to convert timezone "${timeZone}", falling back to UTC: ${error.message}`);
dtstartTemporal = Temporal.ZonedDateTime.from({
year: curr.start.getUTCFullYear(),
month: curr.start.getUTCMonth() + 1,
day: curr.start.getUTCDate(),
hour: curr.start.getUTCHours(),
minute: curr.start.getUTCMinutes(),
second: curr.start.getUTCSeconds(),
timeZone: 'UTC',
});
}
} else {
// No timezone - use UTC
dtstartTemporal = Temporal.ZonedDateTime.from({
year: curr.start.getUTCFullYear(),
month: curr.start.getUTCMonth() + 1,
day: curr.start.getUTCDate(),
hour: curr.start.getUTCHours(),
minute: curr.start.getUTCMinutes(),
second: curr.start.getUTCSeconds(),
timeZone: 'UTC',
});
let dtstartTemporal;
try {
dtstartTemporal = Temporal.Instant.fromEpochMilliseconds(curr.start.getTime())
.toZonedDateTimeISO(timeZone);
} catch (error) {
console.warn(`[node-ical] Failed to convert timezone "${timeZone}", falling back to UTC: ${error?.message ?? String(error)}`);
dtstartTemporal = Temporal.Instant.fromEpochMilliseconds(curr.start.getTime())
.toZonedDateTimeISO('UTC');
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const rruleTemporal = new RRuleTemporal({
Expand Down
53 changes: 53 additions & 0 deletions lib/date-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@

'use strict';

// Load Temporal polyfill if not natively available
const Temporal = globalThis.Temporal || require('temporal-polyfill').Temporal;

const tzUtil = require('../tz-utils.js');

/**
* Construct a date-only key (YYYY-MM-DD) from a Date object.
* For date-only events, uses local date components to avoid timezone shifts.
* For date-time events with a timezone, uses Temporal to extract the calendar date
* in the original timezone (avoids UTC shift, e.g. Exchange O365 RECURRENCE-ID
* midnight-CET becoming previous day in UTC – see GitHub issue #459).
* For date-time events without timezone, extracts the date from the ISO timestamp.
* @param {Date} dateValue - Date object with optional dateOnly and tz properties
* @returns {string} Date key in YYYY-MM-DD format
*/
function getDateKey(dateValue) {
if (dateValue.dateOnly) {
return `${dateValue.getFullYear()}-${String(dateValue.getMonth() + 1).padStart(2, '0')}-${String(dateValue.getDate()).padStart(2, '0')}`;
}

// When the Date carries timezone metadata, extract the calendar date in that timezone.
// This prevents midnight-in-local-tz (e.g. 00:00 CET = 23:00 UTC the day before)
// from being mapped to the wrong calendar day.
// Temporal handles both IANA zones and fixed-offset strings (e.g. "+01:00") uniformly.
if (dateValue.tz) {
try {
const resolved = tzUtil.resolveTZID(dateValue.tz);
const tzId = resolved?.iana || resolved?.offset;
if (resolved && !tzId) {
console.warn(
'[node-ical] Could not resolve TZID to an IANA name or UTC offset; falling back to UTC-based date key.',
{tzid: dateValue.tz, resolved},
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (tzId) {
return Temporal.Instant.fromEpochMilliseconds(dateValue.getTime())
.toZonedDateTimeISO(tzId)
.toPlainDate()
.toString();
}
} catch (error) {
console.warn(`[node-ical] Failed to resolve timezone for date key (TZID="${dateValue.tz}"), falling back to UTC: ${error?.message ?? String(error)}`);
}
}

return dateValue.toISOString().slice(0, 10);
}

module.exports = {getDateKey};
24 changes: 9 additions & 15 deletions node-ical.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const fs = require('node:fs');
const ical = require('./ical.js');
const {getDateKey} = require('./lib/date-utils.js');

/**
* ICal event object.
Expand Down Expand Up @@ -253,22 +254,24 @@ autodetect.parseICS = function (data, cb) {
};

/**
* Generate date key for EXDATE/RECURRENCE-ID lookups
* Must match ical.js getDateKey semantics for lookups to succeed.
* @param {Date} date
* Generate date key for EXDATE/RECURRENCE-ID lookups from an RRULE-generated date.
* RRULE-generated dates carry no .tz or .dateOnly metadata, so isFullDay must be
* passed explicitly to decide between local-time and UTC-based key extraction.
* (For parsed calendar dates that carry .tz/.dateOnly, use getDateKey directly.)
* @param {Date} date - RRULE-generated Date (no .tz, no .dateOnly)
* @param {boolean} isFullDay
* @returns {string} Date key in YYYY-MM-DD format
*/
function generateDateKey(date, isFullDay) {
if (isFullDay) {
// Use local getters for date-only events to match ical.js behavior
// Full-day events: use local getters — RRULE returns local-midnight dates
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}

// For timed events, return date portion only to match ical.js
// Timed events: UTC date portion
return date.toISOString().slice(0, 10);
}

Expand Down Expand Up @@ -425,16 +428,7 @@ function processRecurringInstance(date, event, options, baseDurationMs) {
continue;
}

// Use timezone-aware formatting to extract the calendar date
const tz = exdateValue.tz || 'UTC';
const exdateDateKey = new Intl.DateTimeFormat('en-CA', {
timeZone: tz,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(exdateValue);

if (exdateDateKey === dateKey) {
if (getDateKey(exdateValue) === dateKey) {
return null;
}
}
Expand Down
12 changes: 11 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"node": ">=18"
},
"dependencies": {
"rrule-temporal": "^1.4.6"
"rrule-temporal": "^1.4.6",
"temporal-polyfill": "^0.3.0"
},
"overrides": {
"@js-temporal/polyfill": "npm:temporal-polyfill@^0.3.0"
Expand Down
47 changes: 47 additions & 0 deletions test/date-only-rrule-until.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,4 +152,51 @@ END:VCALENDAR`;
const recurrences = event.rrule.all();
assert.strictEqual(recurrences.length, 3, 'Should have 3 occurrences');
});

it('should normalize VALUE=DATE RRULE start to midnight regardless of server timezone', function () {
// Regression test: the old getTimezoneOffset()-based code shifted the time by the
// server's UTC offset on machines east of UTC (e.g. UTC+2 produced 02:00:00 instead
// of 00:00:00 for a DATE-only event). This was invisible on UTC servers (CI) and only
// surfaced locally – a classic timezone-dependent bug.
//
// This test uses a DTSTART with a time component ("T120000") as it appears in feeds
// from providers like the foobar demoparty, where VALUE=DATE events still carry a time.
// The parser treats these as date-only (dateOnly=true); the time must be ignored.

const icsData = `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Test//Test//EN
BEGIN:VEVENT
DTSTART;VALUE=DATE:20110804T120000
DTEND;VALUE=DATE:20110804T120000
RRULE:FREQ=WEEKLY;BYDAY=MO,FR;INTERVAL=5;UNTIL=20130130T230000Z
DTSTAMP:20260220T120000Z
UID:test-date-only-midnight-normalization
SUMMARY:foobarTV broadcast starts
LOCATION:foobarTV
END:VEVENT
END:VCALENDAR`;

const parsed = ical.parseICS(icsData);
const event = Object.values(parsed).find(event_ => event_.type === 'VEVENT');

assert.ok(event, 'Event should be defined');
assert.strictEqual(event.start.dateOnly, true, 'Start should be date-only');

// The start time must be local midnight (00:00:00), not offset-shifted.
// With the old bug on a UTC+2 machine: getHours() === 2, not 0.
assert.strictEqual(event.start.getHours(), 0, 'DATE-only event must start at local midnight (hour must be 0, not UTC-offset-shifted)');
assert.strictEqual(event.start.getMinutes(), 0, 'DATE-only event minutes must be 0');
assert.strictEqual(event.start.getSeconds(), 0, 'DATE-only event seconds must be 0');

// Date must be August 4, not shifted to August 3 or 5
assert.strictEqual(event.start.getDate(), 4, 'Date must be the 4th');
assert.strictEqual(event.start.getMonth(), 7, 'Month must be August (index 7)');
assert.strictEqual(event.start.getFullYear(), 2011, 'Year must be 2011');

// Recurrences must also expand correctly
assert.ok(event.rrule, 'RRULE should be defined');
const recurrences = event.rrule.all();
assert.ok(recurrences.length > 0, 'Should have recurrences');
});
});
2 changes: 1 addition & 1 deletion test/snapshots/example.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
foobar Summer 2011 starts! is in All Areas on the 4 of Aug at 00:00:00
Main entrance opened is in Main entrance on the 4 of Aug at 00:00:00
Loading doors are opened is in Loading entrances #1 and #2 on the 4 of Aug at 00:00:00
foobarTV broadcast starts is in foobarTV on the 4 of Aug at 02:00:00
foobarTV broadcast starts is in foobarTV on the 4 of Aug at 00:00:00
Loading doors close is in Loading entrances #1 and #2 on the 4 of Aug at 00:00:00
Loading doors are opened is in Loading entrance #1 on the 5 of Aug at 00:00:00
Loading doors close is in Loading entrance #1 on the 5 of Aug at 00:00:00
Expand Down
Loading
Loading