Skip to content
Merged
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
233 changes: 233 additions & 0 deletions Ical.Net.Tests/MatchTimeZoneTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
//
// Copyright ical.net project maintainers and contributors.
// Licensed under the MIT license.
//

using System;
using System.Linq;
using Ical.Net.DataTypes;
using NUnit.Framework;

namespace Ical.Net.Tests;

[TestFixture]
public class MatchTimeZoneTests
{
[Test, Category("Recurrence")]
public void MatchTimeZone_LocalTimeUsaWithTimeZone()
{
// DTSTART with local time and time zone reference (negative offset), UNTIL as UTC
const string ical =
"""
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp//NONSGML Event//EN
BEGIN:VEVENT
UID:example1
SUMMARY:Event with local time and time zone
DTSTART;TZID=America/New_York:20231101T090000
RRULE:FREQ=DAILY;UNTIL=20231105T130000Z
DTEND;TZID=America/New_York:20231101T100000
END:VEVENT
END:VCALENDAR
""";

var calendar = Calendar.Load(ical);
var evt = calendar.Events.First();
var until = evt.RecurrenceRules.First().Until;

var expectedUntil = new DateTime(2023, 11, 05, 13, 00, 00, DateTimeKind.Utc);
var occurrences = evt.GetOccurrences(new CalDateTime(2023, 11, 01), new CalDateTime(2023, 11, 06));

Assert.Multiple(() =>
{
Assert.That(until, Is.EqualTo(expectedUntil));
Assert.That(occurrences.Count, Is.EqualTo(4));
/*
Should have 4 occurrences:
November 1, 2023: 09:00 AM - 10:00 AM (UTC-0400) (America/New_York)
November 2, 2023: 09:00 AM - 10:00 AM (UTC-0400) (America/New_York)
November 3, 2023: 09:00 AM - 10:00 AM (UTC-0400) (America/New_York)
November 4, 2023: 09:00 AM - 10:00 AM (UTC-0400) (America/New_York)

November 5, 2023: 09:00 AM - 10:00 AM (UTC-0500) (America/New_York)
must NOT be included, because 20231105T130000Z => November 5, 2023: 08:00 AM (America/New_York)
(Daylight Saving Time in America/New_York ended on Sunday, November 5, 2023, at 2:00 AM)
*/
});
}

[Test, Category("Recurrence")]
public void MatchTimeZone_LocalTimeJapanWithTimeZone()
{
// DTSTART with local time and time zone reference (positive offset), UNTIL as UTC
const string ical =
"""
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp//NONSGML Event//EN
BEGIN:VEVENT
UID:example1
SUMMARY:Event with local time and time zone
DTSTART;TZID=Asia/Tokyo:20231101T090000
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the test above with NY tz you chose dates involving a DST change (in 2023 in NY winter DST change was Nov 5), but in this test no DST change is involved (there is no DST in Tokyo). Not a problem, just noting this to make sure, its intentional.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I updated the test to use Sydney/Australia with DST change.

RRULE:FREQ=DAILY;UNTIL=20231105T130000Z
DTEND;TZID=Asia/Tokyo:20231101T100000
END:VEVENT
END:VCALENDAR
""";

var calendar = Calendar.Load(ical);
var evt = calendar.Events.First();
var until = evt.RecurrenceRules.First().Until;

var expectedUntil = new DateTime(2023, 11, 05, 13, 00, 00, DateTimeKind.Utc);
var occurrences = evt.GetOccurrences(new CalDateTime(2023, 11, 01), new CalDateTime(2023, 11, 06));

Assert.Multiple(() =>
{
Assert.That(until, Is.EqualTo(expectedUntil));
Assert.That(occurrences.Count, Is.EqualTo(5));
/*
Should have 5 occurrences:
November 1, 2023: 09:00 AM - 10:00 AM (UTC+0900) (Asia/Tokyo)
November 2, 2023: 09:00 AM - 10:00 AM (UTC+0900) (Asia/Tokyo)
November 3, 2023: 09:00 AM - 10:00 AM (UTC+0900) (Asia/Tokyo)
November 4, 2023: 09:00 AM - 10:00 AM (UTC+0900) (Asia/Tokyo)
November 5, 2023: 09:00 AM - 10:00 AM (UTC+0900) (Asia/Tokyo)
*/
});
}

[Test, Category("Recurrence")]
public void MatchTimeZone_UTCTime()
{
// DTSTART and UNTIL with UTC time
const string ical =
"""
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp//NONSGML Event//EN
BEGIN:VEVENT
UID:example2
SUMMARY:Event with UTC time
DTSTART:20231101T090000Z
RRULE:FREQ=DAILY;UNTIL=20231105T090000Z
DTEND:20231101T100000Z
END:VEVENT
END:VCALENDAR
""";

var calendar = Calendar.Load(ical);
var evt = calendar.Events.First();
var until = evt.RecurrenceRules.First().Until;

var expectedUntil = new DateTime(2023, 11, 05, 09, 00, 00, DateTimeKind.Utc);
var occurrences = evt.GetOccurrences(new CalDateTime(2023, 11, 01), new CalDateTime(2023, 11, 06));

Assert.Multiple(() =>
{
Assert.That(until, Is.EqualTo(expectedUntil));
Assert.That(occurrences.Count, Is.EqualTo(5));
});
}

[Test, Category("Recurrence")]
public void MatchTimeZone_FloatingTime()
{
// DTSTART AND UNTIL with floating time
const string ical =
"""
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp//NONSGML Event//EN
BEGIN:VEVENT
UID:example3
SUMMARY:Event with floating time
DTSTART:20231101T090000
RRULE:FREQ=DAILY;UNTIL=20231105T090000
DTEND:20231101T100000
END:VEVENT
END:VCALENDAR
""";

var calendar = Calendar.Load(ical);
var evt = calendar.Events.First();
var until = evt.RecurrenceRules.First().Until;

var expectedUntil = new DateTime(2023, 11, 05, 09, 00, 00, DateTimeKind.Unspecified);
var occurrences = evt.GetOccurrences(new CalDateTime(2023, 11, 01), new CalDateTime(2023, 11, 06));

Assert.Multiple(() =>
{
Assert.That(until, Is.EqualTo(expectedUntil));
Assert.That(occurrences.Count, Is.EqualTo(5));
});

}

[Test, Category("Recurrence")]
public void MatchTimeZone_LocalTimeNoTimeZone()
{
// DTSTART with local time and no time zone reference
const string ical =
"""
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp//NONSGML Event//EN
BEGIN:VEVENT
UID:example4
SUMMARY:Event with local time and no time zone reference
DTSTART:20231101T090000
RRULE:FREQ=DAILY;UNTIL=20231105T090000
DTEND:20231101T100000
END:VEVENT
END:VCALENDAR
""";

var calendar = Calendar.Load(ical);
var evt = calendar.Events.First();
var until = evt.RecurrenceRules.First().Until;

var expectedUntil = new DateTime(2023, 11, 05, 09, 00, 00, DateTimeKind.Unspecified);
var occurrences = evt.GetOccurrences(new CalDateTime(2023, 11, 01), new CalDateTime(2023, 11, 06));

Assert.Multiple(() =>
{
Assert.That(until, Is.EqualTo(expectedUntil));
Assert.That(occurrences.Count, Is.EqualTo(5));
});
}

[Test, Category("Recurrence")]
public void MatchTimeZone_DateOnly()
{
// DTSTART with date-only value
const string ical =
"""
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp//NONSGML Event//EN
BEGIN:VEVENT
UID:example5
SUMMARY:Event with date-only value
DTSTART;VALUE=DATE:20231101
RRULE:FREQ=DAILY;UNTIL=20231105
DTEND;VALUE=DATE:20231102
END:VEVENT
END:VCALENDAR
""";

var calendar = Calendar.Load(ical);
var evt = calendar.Events.First();
var until = evt.RecurrenceRules.First().Until;

var expectedUntil = new DateTime(2023, 11, 05, 00, 00, 00, DateTimeKind.Unspecified);
var occurrences = evt.GetOccurrences(new CalDateTime(2023, 11, 01), new CalDateTime(2023, 11, 06));

Assert.Multiple(() =>
{
Assert.That(until, Is.EqualTo(expectedUntil));
Assert.That(occurrences.Count, Is.EqualTo(5));
});
}
}
49 changes: 33 additions & 16 deletions Ical.Net/Evaluation/RecurrencePatternEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// Licensed under the MIT license.
//

#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
Expand Down Expand Up @@ -30,7 +31,7 @@ private RecurrencePattern ProcessRecurrencePattern(IDateTime referenceDate)
// Convert the UNTIL value to one that matches the same time information as the reference date
if (r.Until != DateTime.MinValue)
{
r.Until = MatchTimeZone(referenceDate, new CalDateTime(r.Until, referenceDate.TzId)).Value;
r.Until = MatchTimeZone(referenceDate, r.Until);
}

if (referenceDate.HasTime)
Expand Down Expand Up @@ -968,24 +969,40 @@ public override HashSet<Period> Evaluate(IDateTime referenceDate, DateTime perio
return new HashSet<Period>(periodQuery);
}

private static IDateTime MatchTimeZone(IDateTime dt1, IDateTime dt2)
private static DateTime MatchTimeZone(IDateTime reference, DateTime until)
{
// Associate the date/time with the first.
var copy = dt2;
copy.AssociateWith(dt1);

// If the dt1 time does not occur in the same time zone as the
// dt2 time, then let's convert it so they can be used in the
// same context (i.e. evaluation).
if (dt1.TzId != null)
/*
The value of the "UNTIL" rule part MUST have the same value type as the
"DTSTART" property. Furthermore, if the "DTSTART" property is
specified as a date with local time, then the UNTIL rule part MUST
also be specified as a date with local time.

If the "DTSTART" property is specified as a date with UTC time or a date with local
time and time zone reference, then the UNTIL rule part MUST be
specified as a date with UTC time.
*/
string? untilTzId;
if (reference.IsFloating)
{
// If 'reference' is floating, then 'until' must be floating
untilTzId = null;
}
else
{
return string.Equals(dt1.TzId, copy.TzId, StringComparison.OrdinalIgnoreCase)
? copy
: copy.ToTimeZone(dt1.TzId);
// If 'reference' has a timezone, 'until' MUST be UTC,
// but in case of UTC rule violation we fall back to the 'reference' timezone
untilTzId = until.Kind == DateTimeKind.Utc
? CalDateTime.UtcTzId
: reference.TzId;
}

return dt1.IsUtc
? new CalDateTime(copy.AsUtc)
: copy;
var untilCalDt = new CalDateTime(until, untilTzId, reference.HasTime);
untilCalDt.AssociateWith(reference);

// If 'reference' is floating, then 'until' is floating, too
return reference.TzId is null
? untilCalDt.Value
// convert to the reference timezone
: untilCalDt.ToTimeZone(reference.TzId).Value;
}
}
Loading