Skip to content

Calendar time zone provider - #959

Open
maknapp wants to merge 9 commits into
ical-org:mainfrom
maknapp:maknapp/cal-time-zone
Open

Calendar time zone provider#959
maknapp wants to merge 9 commits into
ical-org:mainfrom
maknapp:maknapp/cal-time-zone

Conversation

@maknapp

@maknapp maknapp commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

This adds a Calendar.TimeZoneProvider property to allow users to specify the NodaTime.IDateTimeZoneProvider to use during calendar/event evaluation. The default time zone provider for a calendar is CalendarTimeZoneProviders.TzdbWithAliases with the calendar defined time zones as the fallback. The default time zone provider for components without a calendar parent is CalendarTimeZoneProviders.TzdbWithAliases.

User choice

Users can choose the time zone provider:

  • Just use the default
  • Change the fallback(s) by using CalendarTimeZoneProviders.Combine() to create their own NodaTime.IDateTimeZoneProvider that searches the specified providers in the given order for a matching time zone
  • Be more strict by using NodaTime's DateTimeZoneProviders.Tzdb directly - with or without the calendar's VTIMEZONE components
  • Use only the calendar's VTIMEZONE components without any other provider (cal.TimeZoneProvider = cal.CreateTimeZoneProvider())

VTIMEZONE

VTIMEZONE changes are not automatically handled. VTIMEZONE components are converted to NodaTime.DateTimeZone values on demand and cached. If a VTIMEZONE component is added/removed/changed AFTER being used by the time zone provider, the Calendar.TimeZoneProvider will need to be recreated to be up-to-date with the calendar VTIMEZONE components.

One (unlikely?) issue users might run into is that NodaTime.ZonedDateTime checks equality by ReferenceEquals of its DateTimeZone, so recreating the Calendar.TimeZoneProvider could cause issues if the user tries to compare a ZonedDateTime with another created from a different time zone provider because they would have two different DateTimeZone objects even though they represent the same VTIMEZONE.

Exceptions

Missing time zone errors will now throw NodaTime's DateTimeZoneNotFoundException instead of ArgumentException. I'm not sure if there are downsides vs catching and rethrowing with our own TimeZoneNotFoundException. I do like having a more specific exception instead of ArgumentException. Related #648.

No more time zone exceptions in Calendar.Load(). As stated above, exceptions due to missing time zones are now thrown during evaluation.

Time zone searching

The Calendar.TimeZoneProvider is created on demand with a default value if none is specifically set. The default is NOT an exact copy of the previous TimeZoneResolver. Since this brings more time zone control to the user, it seemed reasonable to remove the more non-standard searching by default: case-insensitive and string.Contains searching. I did add a case-insensitive time zone provider in case that is actually needed - I would like to remove that commit though unless users actually need it. I ignored adding a string.Contains replacement because that seems very inaccurate to me.

Searching XmlSerializationSettings was also removed completely because it is, by NodaTime default, just the TZDB. What is the purpose of searching NodaTime.Xml.XmlSerializationSettings.DateTimeZoneProvider? The user can add this provider if they actually use this, so I think it is safe to ignore it completely.

Global time zone handling removed

DefaultTimeZoneResolver and TimeZoneResolvers were removed. Having a default time zone provider on the calendar is essentially the same thing for users who currently ignore time zones.

CalDateTime

This now requires a time zone provider to convert to a ZonedDateTime. @axunonb mentioned in #749 that there should be a resolver-less overload still, but a user converting to a ZonedDateTime should always be paying attention to the time zone provider anyway. If a user really wants to ignore it, they can just use extensions to use a default time zone provider.

ToDateTimeUtc() is also removed just because I did not want to require a NodaTime time zone provider to create a DateTime. Users avoiding NodaTime can still use ToDateTimeUnspecified() to get just the local time. Users can also use extensions to create a ToDateTimeUtc() however they want.

None of these changes are breaking changes for CalDateTime because they are not in v5.

Fixes #749

maknapp added 9 commits July 11, 2026 09:55
Implements Nodatime's IDateTimeZoneProvider for Calendar using its
VTIMEZONE components.
Evaluate events in custom time zone
This also stops caching the system time zone, which should not have
been happening anyway because the system time zone can change.
Adds a case-insensitive time zone provider to more closely
match previous behavior.
Events within a calendar can be evaluated in parallel, which would
make multiple threads access TimeZoneProvider at the same time.
@maknapp
maknapp requested a review from axunonb July 11, 2026 15:34
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
5.6% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

@codecov

codecov Bot commented Jul 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 72.26891% with 66 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
Ical.Net/CalendarComponents/VTimeZone.cs 67.5% 18 Missing and 8 partials ⚠️
Ical.Net/CalendarTimeZoneProviders.cs 73.4% 9 Missing and 8 partials ⚠️
Ical.Net/Evaluation/TodoEvaluator.cs 10.0% 9 Missing ⚠️
Ical.Net/Evaluation/EventEvaluator.cs 80.8% 2 Missing and 3 partials ⚠️
Ical.Net/Calendar.cs 85.0% 1 Missing and 2 partials ⚠️
Ical.Net/DataTypes/FreeBusyEntry.cs 87.5% 2 Missing ⚠️
Ical.Net/DataTypes/Occurrence.cs 0.0% 2 Missing ⚠️
Ical.Net/CollectionExtensions.cs 0.0% 1 Missing ⚠️
Ical.Net/DataTypes/CalendarDataType.cs 0.0% 0 Missing and 1 partial ⚠️

❌ Your patch check has failed because the patch coverage (72.3%) is below the target coverage (80.0%). You can increase the patch coverage or adjust the target coverage.

Impacted file tree graph

@@           Coverage Diff           @@
##            main    #959     +/-   ##
=======================================
+ Coverage   71.5%   71.9%   +0.5%     
=======================================
  Files        118     116      -2     
  Lines       4585    4698    +113     
  Branches    1068    1082     +14     
=======================================
+ Hits        3277    3379    +102     
- Misses       955     963      +8     
- Partials     353     356      +3     
Files with missing lines Coverage Δ
Ical.Net/CalendarComponents/FreeBusy.cs 79.5% <100.0%> (+0.5%) ⬆️
Ical.Net/CalendarComponents/RecurringComponent.cs 87.4% <100.0%> (ø)
Ical.Net/CalendarObject.cs 74.4% <100.0%> (+10.1%) ⬆️
Ical.Net/DataTypes/CalDateTime.cs 98.8% <100.0%> (-<0.1%) ⬇️
Ical.Net/DataTypes/Period.cs 95.9% <100.0%> (-0.2%) ⬇️
Ical.Net/DataTypes/RecurrenceIdentifier.cs 100.0% <ø> (ø)
Ical.Net/Evaluation/RecurrenceRuleEvaluator.cs 93.2% <100.0%> (+<0.1%) ⬆️
Ical.Net/Evaluation/RecurringEvaluator.cs 95.3% <100.0%> (ø)
Ical.Net/Evaluation/TimeZoneInfoEvaluator.cs 50.0% <100.0%> (+30.0%) ⬆️
Ical.Net/CollectionExtensions.cs 66.7% <0.0%> (ø)
... and 8 more

... and 2 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@axunonb

axunonb commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

That sounds pretty nice. I'll need some time to go through changes.

@axunonb axunonb left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This looks very well designed to me, great!
Nice fix to RECURRENCE-ID.
Few potential issues I came across. See the comments.
Sonar cloud comments: left to your discretion.


var name = info.TimeZoneName ?? "Unknown";

var wallOffset = Offset.FromTimeSpan(info.OffsetTo!.Offset);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should this rather be this way? (subtract OffsetFrom)
Not covered by tests though. See suggested additional tests in VTimeZoneTest

var wallOffset = Offset.FromTimeSpan(info.OffsetTo!.Offset);
var savings = info.Name == Components.Standard
    ? Offset.Zero
    : Offset.FromTimeSpan(info.OffsetTo!.Offset) - Offset.FromTimeSpan(info.OffsetFrom!.Offset);

{
if (data.TzId == null)
{
throw new Exception("Time zone ID must be set");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe introduce a specific VTimeZoneException?
If not use InvalidOperationException?


if (info == null)
{
throw new Exception("Could not find zone interval");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

See above - VTimeZoneException?

Assert.That(results, Has.Count.EqualTo(1));
}

private static Calendar CreateTestCalendar(string tzId, DateTime? earliestTime = null, bool includeHistoricalData = true)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

These additional tests fail with the original code in VTimeZone line 507-511

    [Test, Category("VTimeZone")]
    public void VTimeZone_CalendarDateTimeZone_GetZoneInterval_Should_Calculate_Savings_Correctly()
    {
        // Arrange: Create a calendar with a custom VTIMEZONE that has daylight saving time
        var icalString = @"BEGIN:VCALENDAR
VERSION:2.0
PRODID:ical.net
BEGIN:VTIMEZONE
TZID:Test/DST
BEGIN:STANDARD
DTSTART:19701101T020000
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
TZNAME:EST
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:19700308T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
TZNAME:EDT
END:DAYLIGHT
END:VTIMEZONE
BEGIN:VEVENT
DTSTART;TZID=Test/DST:20250615T120000
DTEND;TZID=Test/DST:20250615T130000
SUMMARY:Test Event
UID:test@example.com
END:VEVENT
END:VCALENDAR";

        var calendar = Calendar.Load(icalString);
        var vtz = calendar.TimeZones.First();

        // Act: Get the DateTimeZone from the VTIMEZONE
        var dateTimeZone = vtz.ToDateTimeZone();

        // Get a zone interval during daylight saving time (June 2025)
        var instantInDst = Instant.FromUtc(2025, 6, 15, 16, 0); // 12:00 EDT = 16:00 UTC
        var dstInterval = dateTimeZone.GetZoneInterval(instantInDst);

        // Get a zone interval during standard time (December 2025)
        var instantInStd = Instant.FromUtc(2025, 12, 15, 17, 0); // 12:00 EST = 17:00 UTC
        var stdInterval = dateTimeZone.GetZoneInterval(instantInStd);

        // Assert: Daylight saving time should have non-zero savings
        using (Assert.EnterMultipleScope())
        {
            // During daylight time (EDT), wall offset is -04:00
            Assert.That(dstInterval.WallOffset.ToTimeSpan(), Is.EqualTo(TimeSpan.FromHours(-4)),
                "Wall offset during DST should be -04:00");

            // Savings should be +01:00 (the difference from standard time)
            Assert.That(dstInterval.Savings.ToTimeSpan(), Is.EqualTo(TimeSpan.FromHours(1)),
                "Savings during DST should be +01:00");

            // Standard offset should be -05:00 (wall offset - savings)
            Assert.That(dstInterval.StandardOffset.ToTimeSpan(), Is.EqualTo(TimeSpan.FromHours(-5)),
                "Standard offset during DST should be -05:00");

            // During standard time (EST), wall offset is -05:00
            Assert.That(stdInterval.WallOffset.ToTimeSpan(), Is.EqualTo(TimeSpan.FromHours(-5)),
                "Wall offset during standard time should be -05:00");

            // Savings should be zero during standard time
            Assert.That(stdInterval.Savings.ToTimeSpan(), Is.EqualTo(TimeSpan.Zero),
                "Savings during standard time should be zero");

            // Standard offset equals wall offset during standard time
            Assert.That(stdInterval.StandardOffset.ToTimeSpan(), Is.EqualTo(TimeSpan.FromHours(-5)),
                "Standard offset during standard time should be -05:00");
        }
    }

    [Test, Category("VTimeZone")]
    public void VTimeZone_CalendarDateTimeZone_GetZoneInterval_Savings_Should_Match_NodaTime_Tzdb()
    {
        // Arrange: Create a VTIMEZONE from a known TZDB zone with daylight saving time
        var tzId = "America/New_York";
        var vtz = VTimeZone.FromDateTimeZone(tzId);
        var calendar = new Calendar();
        calendar.AddChild(vtz);

        var calendarDateTimeZone = vtz.ToDateTimeZone();
        var tzdbZone = CalendarTimeZoneProviders.TzdbWithAliases[tzId];

        // Act & Assert: Compare savings during both DST and standard time
        var testInstants = new[]
        {
        Instant.FromUtc(2025, 7, 15, 12, 0),  // Mid-summer (DST active)
        Instant.FromUtc(2025, 1, 15, 12, 0)   // Mid-winter (standard time)
    };

        foreach (var instant in testInstants)
        {
            var tzdbInterval = tzdbZone.GetZoneInterval(instant);
            var calendarInterval = calendarDateTimeZone.GetZoneInterval(instant);

            using (Assert.EnterMultipleScope())
            {
                Assert.That(calendarInterval.WallOffset, Is.EqualTo(tzdbInterval.WallOffset),
                    $"Wall offset mismatch at {instant}");

                Assert.That(calendarInterval.Savings, Is.EqualTo(tzdbInterval.Savings),
                    $"Savings mismatch at {instant}: Expected {tzdbInterval.Savings}, but got {calendarInterval.Savings}");

                Assert.That(calendarInterval.StandardOffset, Is.EqualTo(tzdbInterval.StandardOffset),
                    $"Standard offset mismatch at {instant}");
            }
        }
    }

public string VersionId => field ??= "combined: " + string.Join(", ", providers.Select(x => x.VersionId));


public DateTimeZone GetSystemDefault() => throw new NotImplementedException();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe implement e.g. like this?

public DateTimeZone GetSystemDefault()
{
    return providers.FirstOrDefault()?.GetSystemDefault()
        ?? DateTimeZoneProviders.Tzdb.GetSystemDefault();
}

protected override NodaTime.ZonedDateTime GetEnd(NodaTime.ZonedDateTime start) => start;

protected override NodaTime.IDateTimeZoneProvider TimeZoneProvider => CalendarTimeZoneProviders.TzdbWithAliases;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Would it make sense to implement like this?

protected override EvaluationPeriod EvaluateRDate(Period rdate, NodaTime.DateTimeZone referenceTimeZone)
{
    var start = rdate.StartTime.ToZonedOrDefault(referenceTimeZone, TimeZoneProvider);
    var end = GetEnd(start);
    return new EvaluationPeriod(start, end);
}

VTimeZoneInfo is used to represent STANDARD and DAYLIGHT components within a VTIMEZONE
(Time zone transitions are instants, not periods with duration.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unrecognized time zone id Customized Time Zone

2 participants