Skip to content

Zoned occurrence using NodaTime#854

Merged
axunonb merged 22 commits into
ical-org:version/6.0from
maknapp:zoned-occurrence
Dec 28, 2025
Merged

Zoned occurrence using NodaTime#854
axunonb merged 22 commits into
ical-org:version/6.0from
maknapp:zoned-occurrence

Conversation

@maknapp
Copy link
Copy Markdown
Collaborator

@maknapp maknapp commented Aug 19, 2025

In response to #739 (exact comment), I started testing this out and got carried away with how much needed to change to make this work. This is only a draft proposal to change how evaluation works for version 6.

This changes the evaluation to use NodaTime and evaluates entirely in ZonedDateTime. Events are evaluated in their own time zone, or the specified time zone if floating, and all occurrences are returned in the specified time zone. Two tests fail because the expected data should be different when evaluated in a time zone. A few other tests fail because of NodaTime allowed values and formatting changes.

Notes:

  • Evaluation uses only NodaTime, so most of the math operations can be removed from CalDateTime.
  • I only got benchmarks to build, but did not run any.
  • For backward compatibility, I think EvaluationPeriod has all the information to produce the same DtStart, DtEnd/Duration, and time zone values as before, but I only did DtStart for passing tests.
  • I left many documentation comments alone, so many probably do not match anymore.

@axunonb
Copy link
Copy Markdown
Collaborator

axunonb commented Aug 20, 2025

Wow, this PR is huge and needs some time to review. In general this closer move towards NodaTime is what @minichma and I had already discussed.
After having a very short glance: Not sure whether we should expose NodaTime types to the public API. Just to name 2 cons, we would inherit NodaTime’s versioning cadence; upgrading may force consumers to resolve transitive version conflicts. We also have semantic mismatches with RFC 5545m which arises ambiguity.
Unfortunately I'm away for the next 2 1/2 weeks. @minichma how about your availability for having a closer look?

[Edit]
Here are the benchmark results:

BenchmarkDotNet v0.15.0, Windows 11 (10.0.26100.4946/24H2/2024Update/HudsonValley)
13th Gen Intel Core i7-13700K 3.40GHz, 1 CPU, 24 logical and 16 physical cores
.NET SDK 9.0.304
  [Host] : .NET 8.0.19 (8.0.1925.36514), X64 RyuJIT AVX2

Toolchain=InProcessNoEmitToolchain  

v5.1.0

Method Mean Error StdDev Gen0 Gen1 Allocated
SerializeCalendar 6.675 μs 0.0324 μs 0.0303 μs 1.5640 0.0381 24.03 KB
DeserializeCalendar 13.729 μs 0.0704 μs 0.0658 μs 3.1128 0.1526 47.89 KB
GetOccurrences 735.923 μs 6.7346 μs 5.9700 μs 154.2969 83.9844 2375.53 KB

Current PR (Commit 0a6a55e, Store CalDateTime values with NodaTime)

Method Mean Error StdDev Gen0 Gen1 Allocated
SerializeCalendar 6.971 μs 0.0796 μs 0.0745 μs 1.5640 0.0381 24.05 KB
DeserializeCalendar 14.148 μs 0.2801 μs 0.2620 μs 3.1128 0.1526 47.89 KB
GetOccurrences 884.386 μs 8.1679 μs 7.2407 μs 194.3359 47.8516 2977.46 KB

Current PR (Commit 3394bcd, Reduce evaluation allocation)

Reduce evaluation allocation

Method Mean Error StdDev Gen0 Gen1 Allocated
SerializeCalendar 6.683 us 0.0178 us 0.0149 us 1.5640 0.0381 24.05 KB
DeserializeCalendar 13.874 us 0.0230 us 0.0192 us 3.1128 0.1526 47.89 KB
GetOccurrences 756.268 us 3.0936 us 2.7424 us 115.2344 28.3203 1774.68 KB

@sonarqubecloud
Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
B Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@maknapp
Copy link
Copy Markdown
Collaborator Author

maknapp commented Aug 22, 2025

After having a very short glance: Not sure whether we should expose NodaTime types to the public API. Just to name 2 cons, we would inherit NodaTime’s versioning cadence; upgrading may force consumers to resolve transitive version conflicts.

I was considering this when I started and ended up changing the public API to NodaTime just to make everything how I would want to use it, and then cut back rejected changes later. Arguments for public NodaTime is that NodaTime contains time zone data, so using multiple NodaTime versions would lead to time zone database mismatches. TimeZoneResolver exposes DateTimeZone type already, although that is less likely to be used. In my opinion, the clarity of NodaTime is worth the cost of resolving version conflicts, especially for libraries like this that are centered around time.

To remove NodaTime, the public API could be changed to something like below:

IEnumerable<Occurrence> GetOccurrences(string timeZone, DateTimeOffset? startTime = null);

public class Occurrence : IComparable<Occurrence>
{
    public IRecurrable Source { get; private set; }
    public DateTimeOffset Start { get; private set; }
    public DateTimeOffset End { get; private set; }
    public string TimeZoneId { get; private set; }
}

We also have semantic mismatches with RFC 5545m which arises ambiguity.

All CalDateTime values that were replaced with NodaTime values were replaced with the intention of showing that GetOccurrences is evaluating the point in time that an event will occur, removing any ambiguity. This restricts CalDateTime into representing only the DATE[-TIME] values of the ical spec, and nothing more.

@axunonb
Copy link
Copy Markdown
Collaborator

axunonb commented Oct 12, 2025

@maknapp Apologies for the delay in getting back to your PR.

I want to acknowledge the significant effort you've put into this—great work, and thank you!

There are a few points that you, @minichma and I should discuss. This list is by no means exhaustive, but it’s a starting point for further conversation:

  1. Using Zoned occurrence using NodaTime #854 as the foundation for ical.net v6 implies a very short transition window. Any changes made to v5 now would require substantial rework to align with v6, so we should be mindful of that.

  2. The proposal to make NodaTime a first-class citizen in ical.net has clear advantages, but also introduces dependencies and potential drawbacks. For example:

    var result = cal.GetOccurrences(tz.AtStrictly(new LocalDateTime(2025, 11, 2, 0, 15)))
        .TakeWhileBefore(tz.AtStartOfDay(new LocalDate(2025, 11, 3)).ToInstant())
        .Select(x => x.Period)
        .ToList();

    In cases like this, an abstraction layer over NodaTime types and methods could help mitigate coupling and improve maintainability.

  3. Zoned occurrence using NodaTime #854 introduces decisions that differ from v5—e.g., GetOccurrences now requires a startTime argument. We should review these changes carefully to ensure consistency and clarity.

  4. The volume of unit test changes suggests that migrating from v5 to v6 will require significant effort for users. It would be beneficial to explore ways to reduce this burden and ease the transition.

  5. There are several open issues in v5 that are difficult to resolve within its current architecture. We should aim to address these cleanly in v6.

@maknapp @minichma I'd love for both of you to join the discussion around these points and others that come to your mind. Your insights will be invaluable as we shape the direction for ical.net v6. Let's collaborate to make the transition smooth, maintainable and future-proof.

@maknapp
Copy link
Copy Markdown
Collaborator Author

maknapp commented Oct 14, 2025

  1. Using Zoned occurrence using NodaTime #854 as the foundation for ical.net v6 implies a very short transition window. Any changes made to v5 now would require substantial rework to align with v6, so we should be mindful of that.

Most changes in this pull request do not change the general logic/structure of evaluation, so hopefully bringing in v5 changes will involve mostly just changing types to fit.

  1. The proposal to make NodaTime a first-class citizen in ical.net has clear advantages, but also introduces dependencies and potential drawbacks. ... In cases like this, an abstraction layer over NodaTime types and methods could help mitigate coupling and improve maintainability.

NodaTime follows semantic versioning closely, so any future changes to v3 should still work without needing to update ical.net. Setting NodaTime version to "3.1" works right now and should work until NodaTime v4 (if that is ever needed). For nuget, "3.1" means any version >= 3.1, so using ical.net in a project that references NodaTime 3.2.2 would use v3.2.2 and have no conflicts. Supporting older NodaTime versions would require more work.

  1. Zoned occurrence using NodaTime #854 introduces decisions that differ from v5—e.g., GetOccurrences now requires a startTime argument. We should review these changes carefully to ensure consistency and clarity.

For your specific example, GetOccurrences does not require a startTime, but it does require a time zone. I would be happy to discuss any changes. I mostly only use Calendar.GetOccurrences, so any changes to other parts like alarms and freebusy were mostly based on skimming the spec.

  1. The volume of unit test changes suggests that migrating from v5 to v6 will require significant effort for users. It would be beneficial to explore ways to reduce this burden and ease the transition.

It may not be very helpful keeping the various GetOccurrences(CalDateTime? startTime...). The Occurrence results have only ZonedDateTime values, so anyone using CalDateTime results beyond converting them to NodaTime or DateTime will have a lot to change. To make a complete drop-in replacement, the results would have to be wrapped in the original Occurrence type based on the input CalDateTime values. I initially attempted to do this to keep from having to change almost every test, but continuing to use CalDateTime for occurrence results did not make sense, especially for testing.

@axunonb
Copy link
Copy Markdown
Collaborator

axunonb commented Oct 20, 2025

I'm trying to figure out with which commit the evaluation of ambiguous or non-existing dates started to work correctly, e.g. with DST changes. Was it with ba75ef5?
Background: I guess the PR leads to more breaking changes then absolutely necessary because CalDateTime got replaced by Nodatime, and a couple of of public members could also be internal.

Here is the API Diff from v5.1.1 vs PR854

API diff: Ical.Net.dll

Ical.Net.dll

Assembly Version Changed: PR854 vs 5.1.1.0

Namespace Ical.Net

Type Changed: Ical.Net.Calendar

Removed methods:

public virtual CalendarComponents.FreeBusy GetFreeBusy (CalendarComponents.FreeBusy freeBusyRequest);
public virtual CalendarComponents.FreeBusy GetFreeBusy (DataTypes.CalDateTime fromInclusive, DataTypes.CalDateTime toExclusive);
public virtual CalendarComponents.FreeBusy GetFreeBusy (DataTypes.Organizer organizer, System.Collections.Generic.IEnumerable<DataTypes.Attendee> contacts, DataTypes.CalDateTime fromInclusive, DataTypes.CalDateTime toExclusive);
public virtual System.Collections.Generic.IEnumerable<DataTypes.Occurrence> GetOccurrences<T> (DataTypes.CalDateTime startTime, Evaluation.EvaluationOptions options);
public virtual System.Collections.Generic.IEnumerable<DataTypes.Occurrence> GetOccurrences (DataTypes.CalDateTime startTime, Evaluation.EvaluationOptions options);

Added methods:

public virtual CalendarComponents.FreeBusy GetFreeBusy (NodaTime.DateTimeZone timeZone, CalendarComponents.FreeBusy freeBusyRequest);
public virtual CalendarComponents.FreeBusy GetFreeBusy (NodaTime.DateTimeZone timeZone, DataTypes.CalDateTime fromInclusive, DataTypes.CalDateTime toExclusive);
public virtual CalendarComponents.FreeBusy GetFreeBusy (NodaTime.DateTimeZone timeZone, DataTypes.Organizer organizer, System.Collections.Generic.IEnumerable<DataTypes.Attendee> contacts, DataTypes.CalDateTime fromInclusive, DataTypes.CalDateTime toExclusive);
public virtual System.Collections.Generic.IEnumerable<DataTypes.Occurrence> GetOccurrences<T> (NodaTime.ZonedDateTime startTime, Evaluation.EvaluationOptions options);
public virtual System.Collections.Generic.IEnumerable<DataTypes.Occurrence> GetOccurrences (NodaTime.ZonedDateTime startTime, Evaluation.EvaluationOptions options);
public virtual System.Collections.Generic.IEnumerable<DataTypes.Occurrence> GetOccurrences<T> (NodaTime.DateTimeZone tz, NodaTime.Instant? startTime, Evaluation.EvaluationOptions options);
public virtual System.Collections.Generic.IEnumerable<DataTypes.Occurrence> GetOccurrences (NodaTime.DateTimeZone tz, NodaTime.Instant? startTime, Evaluation.EvaluationOptions options);

Type Changed: Ical.Net.CalendarCollection

Removed methods:

public CalendarComponents.FreeBusy GetFreeBusy (CalendarComponents.FreeBusy freeBusyRequest);
public CalendarComponents.FreeBusy GetFreeBusy (DataTypes.Organizer organizer, System.Collections.Generic.IEnumerable<DataTypes.Attendee> contacts, DataTypes.CalDateTime fromInclusive, DataTypes.CalDateTime toExclusive);
public System.Collections.Generic.IEnumerable<DataTypes.Occurrence> GetOccurrences<T> (DataTypes.CalDateTime startTime, Evaluation.EvaluationOptions options);
public System.Collections.Generic.IEnumerable<DataTypes.Occurrence> GetOccurrences (DataTypes.CalDateTime startTime, Evaluation.EvaluationOptions options);

Added methods:

public CalendarComponents.FreeBusy GetFreeBusy (NodaTime.DateTimeZone timeZone, CalendarComponents.FreeBusy freeBusyRequest);
public CalendarComponents.FreeBusy GetFreeBusy (NodaTime.DateTimeZone timeZone, DataTypes.Organizer organizer, System.Collections.Generic.IEnumerable<DataTypes.Attendee> contacts, DataTypes.CalDateTime fromInclusive, DataTypes.CalDateTime toExclusive);
public System.Collections.Generic.IEnumerable<DataTypes.Occurrence> GetOccurrences (NodaTime.ZonedDateTime startTime, Evaluation.EvaluationOptions options);
public System.Collections.Generic.IEnumerable<DataTypes.Occurrence> GetOccurrences<T> (NodaTime.DateTimeZone timeZone, NodaTime.Instant? startTime, Evaluation.EvaluationOptions options);

Type Changed: Ical.Net.CollectionExtensions

Removed method:

public static System.Collections.Generic.IEnumerable<DataTypes.Period> TakeWhileBefore (this System.Collections.Generic.IEnumerable<DataTypes.Period> sequence, DataTypes.CalDateTime periodEnd);

Obsoleted methods:

 [Obsolete ("Use NodaTime.Instant to specify period end.")]
 public static System.Collections.Generic.IEnumerable<DataTypes.Occurrence> TakeWhileBefore (this System.Collections.Generic.IEnumerable<DataTypes.Occurrence> sequence, DataTypes.CalDateTime periodEnd);

Added methods:

public static System.Collections.Generic.IEnumerable<DataTypes.Occurrence> TakeWhileBefore (this System.Collections.Generic.IEnumerable<DataTypes.Occurrence> sequence, NodaTime.Instant periodEnd);
public static System.Collections.Generic.IEnumerable<Evaluation.EvaluationPeriod> TakeWhileBefore (this System.Collections.Generic.IEnumerable<Evaluation.EvaluationPeriod> sequence, NodaTime.Instant periodEnd);

Type Changed: Ical.Net.IGetFreeBusy

Removed methods:

public virtual CalendarComponents.FreeBusy GetFreeBusy (CalendarComponents.FreeBusy freeBusyRequest);
public virtual CalendarComponents.FreeBusy GetFreeBusy (DataTypes.CalDateTime fromInclusive, DataTypes.CalDateTime toExclusive);
public virtual CalendarComponents.FreeBusy GetFreeBusy (DataTypes.Organizer organizer, System.Collections.Generic.IEnumerable<DataTypes.Attendee> contacts, DataTypes.CalDateTime fromInclusive, DataTypes.CalDateTime toExclusive);

Added methods:

public virtual CalendarComponents.FreeBusy GetFreeBusy (NodaTime.DateTimeZone timeZone, CalendarComponents.FreeBusy freeBusyRequest);
public virtual CalendarComponents.FreeBusy GetFreeBusy (NodaTime.DateTimeZone timeZone, DataTypes.CalDateTime fromInclusive, DataTypes.CalDateTime toExclusive);
public virtual CalendarComponents.FreeBusy GetFreeBusy (NodaTime.DateTimeZone timeZone, DataTypes.Organizer organizer, System.Collections.Generic.IEnumerable<DataTypes.Attendee> contacts, DataTypes.CalDateTime fromInclusive, DataTypes.CalDateTime toExclusive);

Type Changed: Ical.Net.IGetOccurrences

Removed method:

public virtual System.Collections.Generic.IEnumerable<DataTypes.Occurrence> GetOccurrences (DataTypes.CalDateTime startTime, Evaluation.EvaluationOptions options);

Added methods:

public virtual System.Collections.Generic.IEnumerable<DataTypes.Occurrence> GetOccurrences (NodaTime.ZonedDateTime startTime, Evaluation.EvaluationOptions options);
public virtual System.Collections.Generic.IEnumerable<DataTypes.Occurrence> GetOccurrences (NodaTime.DateTimeZone timeZone, NodaTime.Instant? startTime, Evaluation.EvaluationOptions options);

Type Changed: Ical.Net.IGetOccurrencesTyped

Removed method:

public virtual System.Collections.Generic.IEnumerable<DataTypes.Occurrence> GetOccurrences<T> (DataTypes.CalDateTime startTime, Evaluation.EvaluationOptions options);

Added methods:

public virtual System.Collections.Generic.IEnumerable<DataTypes.Occurrence> GetOccurrences<T> (NodaTime.ZonedDateTime startTime, Evaluation.EvaluationOptions options);
public virtual System.Collections.Generic.IEnumerable<DataTypes.Occurrence> GetOccurrences<T> (NodaTime.DateTimeZone timeZone, NodaTime.Instant? startTime, Evaluation.EvaluationOptions options);

Type Changed: Ical.Net.VTimeZoneInfo

Removed property:

public virtual DataTypes.RecurrenceIdentifier RecurrenceIdentifier { get; set; }

Removed method:

public virtual System.Collections.Generic.IEnumerable<DataTypes.Occurrence> GetOccurrences (DataTypes.CalDateTime startTime, Evaluation.EvaluationOptions options);

Added methods:

public virtual System.Collections.Generic.IEnumerable<DataTypes.Occurrence> GetOccurrences (NodaTime.ZonedDateTime startTime, Evaluation.EvaluationOptions options);
public virtual System.Collections.Generic.IEnumerable<DataTypes.Occurrence> GetOccurrences (NodaTime.DateTimeZone timeZone, NodaTime.Instant? startTime, Evaluation.EvaluationOptions options);

Removed Type Ical.Net.CalendarExtensions

Namespace Ical.Net.CalendarComponents

Type Changed: Ical.Net.CalendarComponents.Alarm

Removed methods:

public virtual System.Collections.Generic.IList<Ical.Net.DataTypes.AlarmOccurrence> GetOccurrences (IRecurringComponent rc, Ical.Net.DataTypes.CalDateTime fromDate, Ical.Net.Evaluation.EvaluationOptions options);
public virtual System.Collections.Generic.IList<Ical.Net.DataTypes.AlarmOccurrence> Poll (Ical.Net.DataTypes.CalDateTime start, Ical.Net.Evaluation.EvaluationOptions options);

Added methods:

public virtual System.Collections.Generic.IList<Ical.Net.DataTypes.AlarmOccurrence> GetOccurrences (IRecurringComponent rc, NodaTime.DateTimeZone timeZone, NodaTime.Instant? fromDate, Ical.Net.Evaluation.EvaluationOptions options);
public virtual System.Collections.Generic.IList<Ical.Net.DataTypes.AlarmOccurrence> Poll (NodaTime.DateTimeZone timeZone, NodaTime.Instant? start, Ical.Net.Evaluation.EvaluationOptions options);

Type Changed: Ical.Net.CalendarComponents.CalendarEvent

Removed interface:

System.IComparable<CalendarEvent>

Removed property:

public Ical.Net.DataTypes.Duration EffectiveDuration { get; }

Removed method:

public virtual int CompareTo (CalendarEvent other);

Type Changed: Ical.Net.CalendarComponents.FreeBusy

Removed method:

public static FreeBusy Create (Ical.Net.ICalendarObject obj, FreeBusy freeBusyRequest, Ical.Net.Evaluation.EvaluationOptions options);

Added methods:

public static FreeBusy Create (Ical.Net.ICalendarObject obj, NodaTime.DateTimeZone timeZone, FreeBusy freeBusyRequest, Ical.Net.Evaluation.EvaluationOptions options);
public virtual Ical.Net.FreeBusyStatus GetFreeBusyStatus (NodaTime.Instant? dt);

Type Changed: Ical.Net.CalendarComponents.IAlarmContainer

Removed method:

public virtual System.Collections.Generic.IList<Ical.Net.DataTypes.AlarmOccurrence> PollAlarms (Ical.Net.DataTypes.CalDateTime startTime, Ical.Net.DataTypes.CalDateTime endTime);

Added method:

public virtual System.Collections.Generic.IList<Ical.Net.DataTypes.AlarmOccurrence> PollAlarms (NodaTime.DateTimeZone timeZone, NodaTime.Instant? startTime, NodaTime.Instant? endTime);

Type Changed: Ical.Net.CalendarComponents.RecurringComponent

Removed property:

public virtual Ical.Net.DataTypes.RecurrenceIdentifier RecurrenceIdentifier { get; set; }

Removed methods:

public virtual System.Collections.Generic.IEnumerable<Ical.Net.DataTypes.Occurrence> GetOccurrences (Ical.Net.DataTypes.CalDateTime startTime, Ical.Net.Evaluation.EvaluationOptions options);
public virtual System.Collections.Generic.IList<Ical.Net.DataTypes.AlarmOccurrence> PollAlarms ();
public virtual System.Collections.Generic.IList<Ical.Net.DataTypes.AlarmOccurrence> PollAlarms (Ical.Net.DataTypes.CalDateTime startTime, Ical.Net.DataTypes.CalDateTime endTime);

Added methods:

public virtual System.Collections.Generic.IEnumerable<Ical.Net.DataTypes.Occurrence> GetOccurrences (NodaTime.ZonedDateTime startTime, Ical.Net.Evaluation.EvaluationOptions options);
public virtual System.Collections.Generic.IEnumerable<Ical.Net.DataTypes.Occurrence> GetOccurrences (NodaTime.DateTimeZone timeZone, NodaTime.Instant? startTime, Ical.Net.Evaluation.EvaluationOptions options);
public virtual System.Collections.Generic.IList<Ical.Net.DataTypes.AlarmOccurrence> PollAlarms (NodaTime.DateTimeZone timeZone);
public virtual System.Collections.Generic.IList<Ical.Net.DataTypes.AlarmOccurrence> PollAlarms (NodaTime.DateTimeZone timeZone, NodaTime.Instant? startTime, NodaTime.Instant? endTime);

Type Changed: Ical.Net.CalendarComponents.Todo

Removed property:

public Ical.Net.DataTypes.Duration? EffectiveDuration { get; }

Removed methods:

public virtual bool IsActive (Ical.Net.DataTypes.CalDateTime currDt);
public virtual bool IsCompleted (Ical.Net.DataTypes.CalDateTime currDt);

Added methods:

public virtual bool IsActive (NodaTime.ZonedDateTime value);
public virtual bool IsCompleted (NodaTime.ZonedDateTime currDt);

Namespace Ical.Net.DataTypes

Type Changed: Ical.Net.DataTypes.AlarmOccurrence

Removed constructor:

public AlarmOccurrence (Ical.Net.CalendarComponents.Alarm a, CalDateTime dt, Ical.Net.CalendarComponents.IRecurringComponent rc);

Added constructor:

public AlarmOccurrence (Ical.Net.CalendarComponents.Alarm a, NodaTime.ZonedDateTime start, Ical.Net.CalendarComponents.IRecurringComponent rc);

Removed properties:

public CalDateTime DateTime { get; set; }
public Period Period { get; set; }

Modified properties:

 public Ical.Net.CalendarComponents.Alarm Alarm { get; ---set;--- }
 public Ical.Net.CalendarComponents.IRecurringComponent Component { get; ---set;--- }

Added property:

public NodaTime.ZonedDateTime Start { get; }

Type Changed: Ical.Net.DataTypes.CalDateTime

Removed constructor:

public CalDateTime (CalDateTime value);

Added constructors:

public CalDateTime (NodaTime.Instant instant);
public CalDateTime (NodaTime.LocalDate date);
public CalDateTime (NodaTime.LocalDate value, string tzId);
public CalDateTime (NodaTime.LocalDateTime value, string tzId);
public CalDateTime (NodaTime.LocalDate date, NodaTime.LocalTime? time, string tzId);

Removed interface:

System.IComparable<CalDateTime>

Modified properties:

-public System.DateOnly Date { get; }
+public NodaTime.LocalDate Date { get; }
-public System.TimeOnly? Time { get; }
+public NodaTime.LocalTime? Time { get; }

Added properties:

public System.DateOnly DateOnly { get; }
public System.TimeOnly? TimeOnly { get; }

Removed methods:

public virtual int CompareTo (CalDateTime dt);
public bool GreaterThan (CalDateTime dt);
public bool GreaterThanOrEqual (CalDateTime dt);
public bool LessThan (CalDateTime dt);
public bool LessThanOrEqual (CalDateTime dt);
public Duration Subtract (CalDateTime dt);
public System.TimeSpan SubtractExact (CalDateTime dt);
public static bool op_GreaterThan (CalDateTime left, CalDateTime right);
public static bool op_GreaterThanOrEqual (CalDateTime left, CalDateTime right);
public static bool op_LessThan (CalDateTime left, CalDateTime right);
public static bool op_LessThanOrEqual (CalDateTime left, CalDateTime right);

Added methods:

public NodaTime.ZonedDateTime AsZonedOrDefault (NodaTime.DateTimeZone timeZone);
public NodaTime.Instant ToInstant ();
public NodaTime.LocalDateTime ToLocalDateTime ();
public NodaTime.ZonedDateTime ToZonedDateTime ();
public NodaTime.ZonedDateTime ToZonedDateTime (NodaTime.DateTimeZone timeZone);
public NodaTime.ZonedDateTime ToZonedDateTime (string zoneId);

Type Changed: Ical.Net.DataTypes.Duration

Removed method:

public System.TimeSpan ToTimeSpan (CalDateTime start);

Added methods:

public NodaTime.Period GetNominalPart ();
public NodaTime.Duration GetTimePart ();
public NodaTime.Period ToPeriod ();

Type Changed: Ical.Net.DataTypes.FreeBusyEntry

Removed interface:

System.IComparable<Period>

Added methods:

public bool CollidesWith (Period period);
public bool Contains (CalDateTime dt);
public bool Contains (NodaTime.Instant value);

Type Changed: Ical.Net.DataTypes.Occurrence

Removed constructor:

public Occurrence (Ical.Net.CalendarComponents.IRecurrable recurrable, Period period);

Added constructor:

public Occurrence (Ical.Net.CalendarComponents.IRecurrable recurrable, NodaTime.ZonedDateTime start, NodaTime.ZonedDateTime end);

Modified properties:

-public Period Period { get; ---set;--- }
+public System.ValueTuple<NodaTime.ZonedDateTime,NodaTime.ZonedDateTime> Period { get; set; }
 public Ical.Net.CalendarComponents.IRecurrable Source { get; ---set;--- }

Added properties:

public CalDateTime DtStart { get; }
public NodaTime.ZonedDateTime End { get; }
public NodaTime.ZonedDateTime Start { get; }

Added method:

public bool Contains (CalDateTime value);

Type Changed: Ical.Net.DataTypes.Period

Removed interface:

System.IComparable<Period>

Removed properties:

public virtual Duration? EffectiveDuration { get; }
public virtual CalDateTime EffectiveEndTime { get; }

Added property:

public bool HasEndOrDuration { get; }

Removed methods:

public virtual bool CollidesWith (Period period);
public virtual int CompareTo (Period other);
public virtual bool Contains (CalDateTime dt);

Removed Type Ical.Net.DataTypes.RecurrenceIdentifier

Removed Type Ical.Net.DataTypes.RecurrenceRange

Namespace Ical.Net.Evaluation

Type Changed: Ical.Net.Evaluation.Evaluator

Removed methods:

public virtual System.Collections.Generic.IEnumerable<Ical.Net.DataTypes.Period> Evaluate (Ical.Net.DataTypes.CalDateTime referenceDate, Ical.Net.DataTypes.CalDateTime periodStart, EvaluationOptions options);
protected void IncrementDate (ref Ical.Net.DataTypes.CalDateTime dt, Ical.Net.DataTypes.RecurrencePattern pattern, int interval);

Added methods:

public virtual System.Collections.Generic.IEnumerable<EvaluationPeriod> Evaluate (Ical.Net.DataTypes.CalDateTime referenceDate, NodaTime.ZonedDateTime periodStart, EvaluationOptions options);
public virtual System.Collections.Generic.IEnumerable<EvaluationPeriod> Evaluate (Ical.Net.DataTypes.CalDateTime referenceDate, NodaTime.DateTimeZone timeZone, NodaTime.Instant? periodStart, EvaluationOptions options);
protected static void IncrementDate (ref NodaTime.ZonedDateTime dt, Ical.Net.DataTypes.RecurrencePattern pattern, int interval);

Type Changed: Ical.Net.Evaluation.EventEvaluator

Removed property:

protected override Ical.Net.DataTypes.Duration? DefaultDuration { get; }

Removed method:

public override System.Collections.Generic.IEnumerable<Ical.Net.DataTypes.Period> Evaluate (Ical.Net.DataTypes.CalDateTime referenceDate, Ical.Net.DataTypes.CalDateTime periodStart, EvaluationOptions options);

Added methods:

protected override EvaluationPeriod EvaluateRDate (Ical.Net.DataTypes.Period rdate, NodaTime.DateTimeZone referenceTimeZone);
protected override NodaTime.ZonedDateTime GetEnd (NodaTime.ZonedDateTime start);

Type Changed: Ical.Net.Evaluation.IEvaluator

Removed method:

public virtual System.Collections.Generic.IEnumerable<Ical.Net.DataTypes.Period> Evaluate (Ical.Net.DataTypes.CalDateTime referenceDate, Ical.Net.DataTypes.CalDateTime periodStart, EvaluationOptions options);

Added methods:

public virtual System.Collections.Generic.IEnumerable<EvaluationPeriod> Evaluate (Ical.Net.DataTypes.CalDateTime referenceDate, NodaTime.ZonedDateTime periodStart, EvaluationOptions options);
public virtual System.Collections.Generic.IEnumerable<EvaluationPeriod> Evaluate (Ical.Net.DataTypes.CalDateTime referenceDate, NodaTime.DateTimeZone timeZone, NodaTime.Instant? periodStart, EvaluationOptions options);

Type Changed: Ical.Net.Evaluation.RecurrencePatternEvaluator

Removed method:

public override System.Collections.Generic.IEnumerable<Ical.Net.DataTypes.Period> Evaluate (Ical.Net.DataTypes.CalDateTime referenceDate, Ical.Net.DataTypes.CalDateTime periodStart, EvaluationOptions options);

Added method:

public override System.Collections.Generic.IEnumerable<EvaluationPeriod> Evaluate (Ical.Net.DataTypes.CalDateTime referenceDate, NodaTime.DateTimeZone timeZone, NodaTime.Instant? periodStart, EvaluationOptions options);

Type Changed: Ical.Net.Evaluation.RecurringEvaluator

Removed property:

protected virtual Ical.Net.DataTypes.Duration? DefaultDuration { get; }

Removed methods:

public override System.Collections.Generic.IEnumerable<Ical.Net.DataTypes.Period> Evaluate (Ical.Net.DataTypes.CalDateTime referenceDate, Ical.Net.DataTypes.CalDateTime periodStart, EvaluationOptions options);
protected System.Collections.Generic.IEnumerable<Ical.Net.DataTypes.Period> EvaluateRDate ();
protected System.Collections.Generic.IEnumerable<Ical.Net.DataTypes.Period> EvaluateRRule (Ical.Net.DataTypes.CalDateTime referenceDate, Ical.Net.DataTypes.CalDateTime periodStart, EvaluationOptions options);

Added methods:

public override System.Collections.Generic.IEnumerable<EvaluationPeriod> Evaluate (Ical.Net.DataTypes.CalDateTime referenceDate, NodaTime.DateTimeZone timeZone, NodaTime.Instant? periodStart, EvaluationOptions options);
protected System.Collections.Generic.SortedSet<EvaluationPeriod> EvaluateRDate (NodaTime.DateTimeZone referenceTimeZone);
protected virtual EvaluationPeriod EvaluateRDate (Ical.Net.DataTypes.Period rdate, NodaTime.DateTimeZone referenceTimeZone);
protected System.Collections.Generic.IEnumerable<EvaluationPeriod> EvaluateRRule (Ical.Net.DataTypes.CalDateTime referenceDate, NodaTime.DateTimeZone timeZone, NodaTime.Instant? periodStart, EvaluationOptions options);
protected virtual NodaTime.ZonedDateTime GetEnd (NodaTime.ZonedDateTime start);

Type Changed: Ical.Net.Evaluation.TimeZoneInfoEvaluator

Removed property:

protected override Ical.Net.DataTypes.Duration? DefaultDuration { get; }

Added methods:

protected override EvaluationPeriod EvaluateRDate (Ical.Net.DataTypes.Period rdate, NodaTime.DateTimeZone referenceTimeZone);
protected override NodaTime.ZonedDateTime GetEnd (NodaTime.ZonedDateTime start);

Type Changed: Ical.Net.Evaluation.TodoEvaluator

Removed property:

protected override Ical.Net.DataTypes.Duration? DefaultDuration { get; }

Removed method:

public override System.Collections.Generic.IEnumerable<Ical.Net.DataTypes.Period> Evaluate (Ical.Net.DataTypes.CalDateTime referenceDate, Ical.Net.DataTypes.CalDateTime periodStart, EvaluationOptions options);

Added methods:

public override System.Collections.Generic.IEnumerable<EvaluationPeriod> Evaluate (Ical.Net.DataTypes.CalDateTime referenceDate, NodaTime.DateTimeZone timeZone, NodaTime.Instant? periodStart, EvaluationOptions options);
protected override EvaluationPeriod EvaluateRDate (Ical.Net.DataTypes.Period rdate, NodaTime.DateTimeZone referenceTimeZone);
protected override NodaTime.ZonedDateTime GetEnd (NodaTime.ZonedDateTime start);

New Type: Ical.Net.Evaluation.EvaluationPeriod

public class EvaluationPeriod : System.IComparable<EvaluationPeriod> {
	// constructors
	public EvaluationPeriod (NodaTime.ZonedDateTime start, NodaTime.ZonedDateTime? end);
	// properties
	public NodaTime.ZonedDateTime? End { get; }
	public NodaTime.ZonedDateTime Start { get; }
	// methods
	public virtual int CompareTo (EvaluationPeriod other);
	public bool Equals (EvaluationPeriod other);
	public override bool Equals (object obj);
	public override int GetHashCode ();
	public EvaluationPeriod WithZone (NodaTime.DateTimeZone zone);
}

Namespace Ical.Net.Serialization.DataTypes

Removed Type Ical.Net.Serialization.DataTypes.RecurrenceIdentifierSerializer

@maknapp
Copy link
Copy Markdown
Collaborator Author

maknapp commented Oct 21, 2025

ba75ef5 does evaluation similar to how it was before, where time zone is considered only at the end of evaluation instead of at every step. I believe most of the original tests pass with this commit too.

Ambiguous time change was fixed in the next commit, c42f14a (see commit description, 2nd paragraph). This commit changes all evaluation so that time zone is considered everywhere. Compare Evaluator.cs differences between these two commits - LocalDateTime vs ZonedDateTime.

A lot of the API diff is adding DateTimeZone (directly or via ZonedDateTime) to evaluation areas. CalDateTime overloads could be added back to some of these areas, but it would need to produce a runtime error if time zone is missing, or assume system time zone. Runtime errors would make migration more difficult than just removing CalDateTime as an option. Assuming system time zone might work for most cases since that is close how it was before.

@axunonb
Copy link
Copy Markdown
Collaborator

axunonb commented Nov 13, 2025

The Review

I reviewed all commits to understand the rationale behind the design decisions made throughout the implementation process.
It's about the same approach I went with adding evaluation classes for CalDateTime in #809. Maybe you even had a look at it.

The commit history shows a design that evolved as the complexities of NodaTime were integrated. It is a pragmatic refactor — it fixes time zone bugs but inherits the "mixed model" architecture of the library.

The Proposed Design

The critical problem resolved is regarding evaluation: The standard .NET date and time types (DateTime, DateTimeOffset, TimeSpan) are insufficient to represent the full range of date and time concepts defined in the iCalendar specification (RFC 5545). NodaTime provides a richer set of types that can accurately model these concepts, such as LocalDateTime, ZonedDateTime, and Period.

The Mixed Model Problem

Even if we fix the time zone bugs by using NodaTime the fundamental problem remains: the library mixes two different models for date and time handling. The standard .NET date and time types to handle iCalendar data stay in place and forces us to use the very types that cause the time zone bugs and will continue to produce incorrect results.

The question that came to mind: would the final design have looked the same if the solution had been known from the beginning?

A "greenfield" design, knowing from the start that a pure separation was the goal, would look different:

Regarding the separation of the iCalendar data model (CalDateTime, Period) from the evaluation logic, it would be cleaner to consequently remove all evaluation-related methods from the data model classes. This separation was partially achieved with EvaluationPeriod, but Period and CalDateTime still contain evaluation logic.

A Clean Separation Design

CalDateTime (The "Purified" Class)

This class would be stripped of all logic and become a pure data object.

  • It would ONLY contain:
    • public DateTime Value { get; } (The raw System.DateTime read from the file)
    • public string Tzid { get; } (The raw TZID string, e.g., "America/New_York")
    • public bool IsUtc { get; }
    • public bool IsDateOnly { get; }
  • It would NOT contain all the evaluation logic:
    • Any NodaTime properties (ZonedDateTime, DateTimeZone).
    • All math methods (AddHours, AddDays, Subtract).
    • All comparison methods (CompareTo, Equals, GetHashCode would just compare the raw data values).
    • All conversion methods (ToUniversalTime, ToTimeZone).

Period (The "Purified" Class)

This would also be stripped of all logic.

  • It would ONLY contain:
    • public CalDateTime StartTime { get; }
    • public CalDateTime EndTime { get; } (Or public Duration Duration { get; })
  • It would NOT contain:
    • Overlaps()
    • Contains()
    • GetDuration() (it should just store one representation).

CalendarEvent

This is the most critical change.

  • It would NOT contain:
    • GetOccurrences()

Removing GetOccurrences from the event itself is the single most important step to achieving a clean separation. An event doesn't know how to "get" its occurrences; that is a complex calculation that requires an external engine.

IEvaluator / EventEvaluator

This class becomes the entry point for all calculations.
This is the only class that "understands" NodaTime.

Occurrence (The Unambiguous Result)

The result of an evaluation is not a Period or CalDateTime. It's a new, simple object representing an absolute point in time.

public sealed class Occurrence
{
    public NodaTime.Instant Start { get; }
    public NodaTime.Instant End { get; }
    public CalendarEvent SourceEvent { get; }
}

Pros and Cons of This Design

  • Pro: It's architecturally pure. The Data Model is 100% "iCalendar specific" and the Evaluation Model is 100% NodaTime. There is zero overlap.
  • Pro: It's testable. We can test parsing/serialization separately from time zone math.
  • Con: It's a massive breaking change ("V6") and forces users to relearn how to get occurrences.
  • Con: Users must now pull in NodaTime just to ask for occurrences (e.g., to provide a DateTimeZone and an InstantRange).
  • Both: It's quite an effort to migrate existing code to the new model, but it would be a one-time effort that pays off in the long run.

Are these changes worth it? If the goal is to have a robust library that accurately handles all iCalendar scenarios, then this clean separation is likely worth the effort.

@maknapp @minichma Are you ready to go for a new design, or rather just get the bugs fixed quickly?

@minichma
Copy link
Copy Markdown
Collaborator

@axunonb Great summary! The questions you ask are quite fundamental and I can't answer them just add my 2c. The problems that come with today's architecture are quite subtle and show up only in edge cases like ambiguous times during DST changes. I don't think that a relevant number of users have ever had any issues with it. However, the existence of those problems show a fundamental shortcoming of the solution and on the long run it would certainly good to have the architecture fixed. But whether its worth it really depends on the ambitions of the maintainers. As mentioned earlier, I personally won't be able to contribute much to a next version.

Regarding the concrete architecture: I fully agree that today's model types shouldn't be polluted with Noda types because it would make things even more complex (I didn't review much of this PR though so my comments are rather generic). Besides leaving everything as is or doing a full rewrite, there could also be a way in between. I.e. the type causing most issues so far is the Period when used to return occurrences. The type has been designed to represent the ICAL model and is not well suited for dealing with the ambiguities that appear during evaluation. So if we replace that type with some dedicated one that retains the data that is required to resolve any ambiguities (i.e. have a Start and Duration but maybe no End), things may improve a lot already. Additionally we could add extension methods that allow unambiguously converting to Noda types. However, some fundamental problems like resolving the right time zone (e.g. the TimeZoneResolvers.TimeZoneResolver being static) would not be solved by that.

So long story short, a full rework of the evaluation part would be great if there are people (mostly you guys) that have the ambition to get that done. Alternatively some smaller 'fire fighting' approach (i.e. introducing a new type for returning occurrences) could bring quite some improvement too. The latter could also be implemented as intermediate step before taking on the big ones.

@maknapp
Copy link
Copy Markdown
Collaborator Author

maknapp commented Nov 16, 2025

Are you ready to go for a new design

Yes.

It's about the same approach I went with adding evaluation classes for CalDateTime in #809.

I did look through that pull request as I was starting to work through what I wanted to change.

This separation was partially achieved with EvaluationPeriod, but Period and CalDateTime still contain evaluation logic...
CalDateTime (The "Purified" Class). This class would be stripped of all logic and become a pure data object.

This was my plan but I ended up leaving a lot of methods just to reduce changes for tests until we could discuss. While making CalDateTime a pure data object sounds great, it may be fine leaving some methods only if they make sense and are accurate 100% of the time. I do not like keeping CalDateTime.Add() around because of the runtime exception check required and using InZoneLeniently, but date math like AddDays is fine. That said, I do not know how much people actually use CalDateTime math, or if it is useful to have date math but not time math. The ideal situation is to let people make their own extension methods if they need them.

Period (The "Purified" Class) ... it should just store one representation

I'm not sure if you are saying to only store End or Duration but not both? To be clear, Period needs both End and Duration properties. One reason is for deserialize -> serialize equality. Another reason is that start/end is absolute while start/duration is not. From the spec, "For example, recurrence instances of a nominal duration of one day will have an exact duration of more or less than 24 hours on a day where a time zone shift occurs."

Removing GetOccurrences from the event itself is the single most important step

I think I agree with this? I need to go back and look through everything again, but I remember having questions about that area.

Since these changes will require changing a lot of tests, changing the test framework from NUnit to XUnit should be considered. I personally think XUnit is easier to use, but some tests use NUnit features that XUnit does not have (or handles differently). I am no expert on either one, so if there is a strong opinion on this, please share.

@axunonb
Copy link
Copy Markdown
Collaborator

axunonb commented Nov 16, 2025

Period needs both End and Duration

Yes, of course. I had EffectiveDuration and EffectiveEndTime from current main in mind, that both require evaluation.

people actually use CalDateTime math

Hard to say. For sure these methods are very convenient while working with ical.net, that's why we see heavy usage in the unit tests.

it may be fine leaving some methods

As long as we understand CalDateTime represents the wall time only, just like NodaTime.LocalDateTime, that's fine. We could add iCalendar-aware arithmetic calculations as extension methods.

Change from NUnit to XUnit

NUnit is a mature and widely adopted framework, and it’s already well-integrated into our test infrastructure. Introducing a new test runner would mean retooling CI, rewriting attributes, and retraining contributors - none of which feels justified right now. If the goal is cleaner assertions, Shouldly works great alongside NUnit and doesn’t require structural changes.

Comment thread Ical.Net/DataTypes/CalDateTime.cs Outdated
/// </remarks>
/// </summary>
public sealed class CalDateTime : IComparable<CalDateTime>, IFormattable
public sealed class CalDateTime : IFormattable
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.

Finally this should become a small, dump iCalendar wall time representation. Wall time arithmetic could go into extension methods.

/// Gets the day.
/// </summary>
public int Year => Value.Year;
public int Day => _localDate.Day;
Copy link
Copy Markdown
Collaborator

@axunonb axunonb Nov 19, 2025

Choose a reason for hiding this comment

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

Do the wrapped Day, Month, Year, Hour etc. properties bring a benefit to users?
Exposing DateTime could be sufficient?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

They are useful to have and simple enough to keep. They are used significantly in tests. Anyone using CalDateTime directly would probably prefer if (calDateTime.Year == 2025) over if (calDateTime.Date.Year == 2025). For comparison, NodaTime types provide these wrapped properties everywhere.

The time values being nullable vs defaulting to 0 needs some thought though. Nullable is more accurate. Defaulting to 0 matches the values of ToLocalDateTime() and Value.

public int DayOfYear => Value.DayOfYear;
public LocalTime? Time => _localTime;

#if NET6_0_OR_GREATER
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.

The pragma is okay for the CTOR, but should public properties be different across target frameworks?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I changed them to methods ToDateOnly() and ToTimeOnly() to match NodaTime. Is there a specific argument against this?

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.

Yes, it would be good to have the same public API for all target frameworks. The comment is about the #if NET6_0_OR_GREATER pragma influencing the public API.
As the pkg Portable.System.DateTimeOnly is removed and internally replaced by NodaTime types, what would be the purpose for TimeOnly / DateOnly? So I'd vote for dropping TimeOnly and DateOnly altogether.

Comment on lines +396 to +456
return TimeAdjusters.TruncateToSecond(time.Value);
}

public LocalDateTime ToLocalDateTime()
{
var localDate = new LocalDate(_localDate.Year, _localDate.Month, _localDate.Day);

if (_localTime is null)
{
return localDate.AtMidnight();
}
else
{
var time = _localTime.Value;
return localDate.At(new LocalTime(time.Hour, time.Minute, time.Second));
}
}

public Instant ToInstant() => ToZonedDateTime().ToInstant();

public ZonedDateTime ToZonedDateTime()
{
if (_tzId is null)
{
return ToLocalDateTime().InUtc();
}
else
{
return DateUtil.GetZone(_tzId).AtLeniently(ToLocalDateTime());
}
}

public ZonedDateTime ToZonedDateTime(DateTimeZone timeZone)
{
if (_tzId is null)
{
return ToLocalDateTime().InZoneLeniently(timeZone);
}
else
{
return DateUtil.GetZone(_tzId)
.AtLeniently(ToLocalDateTime())
.WithZone(timeZone);
}
}

public ZonedDateTime ToZonedDateTime(string zoneId)
{
return ToZonedDateTime(DateUtil.GetZone(zoneId));
}

public ZonedDateTime AsZonedOrDefault(DateTimeZone timeZone)
{
if (_tzId is null)
{
return ToLocalDateTime().InZoneLeniently(timeZone);
}
else
{
return DateUtil.GetZone(_tzId).AtLeniently(ToLocalDateTime());
}
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.

For evaluation we could use an internal class EvaluationDateTime or alike. NodaTime types cannot cover all iCalendar evaluation rules.

Seconds = seconds;
}

public NodaTime.Period ToPeriod()
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.

Change members returning NodaTime.Period to internal?

/// To convert a duration to a <see cref="TimeSpan"/> while considering the days and weeks as nominal durations,
/// use <see cref="ToTimeSpan"/>.
/// </remarks>
public TimeSpan ToTimeSpan(CalDateTime start)
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.

Yep, remove evaluation

Comment thread Ical.Net/DataTypes/FreeBusyEntry.cs Outdated

if (EndTime is null && Duration is null)
{
throw new ArgumentException("Period must have a duration", nameof(period));
Copy link
Copy Markdown
Collaborator

@axunonb axunonb Nov 19, 2025

Choose a reason for hiding this comment

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

"Period must either have a Duration or an EndTime"?
Or should we create a FreeBusyEntry from EvaluationPeriod?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I fixed this error message. FreeBusyEntry is Period with a FBTYPE (FreeBusyStatus) and the requirement that datetimes MUST be UTC. Nothing verifies that dates are UTC here when it probably should.

public Period Period { get; set; }
public IRecurrable Source { get; set; }
public IRecurrable Source { get; private set; }
public ZonedDateTime Start { get; private 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.

Expose ZonedDateTime for Start and End, or the wall time (CalDateTime) - do we need both?
Make other members internal?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

NodaTime.ZonedDateTime has all the data involved and is what is used while evaluating. Other types like NodaTime.Instant or NodaTime.OffsetDateTime can be used, but time zone would be lost. Since time zone is known already (because GetOccurrences() requires it), it is not unreasonable to drop the time zone and assume that the user knows that ALL occurrences are in the given time zone. CalDateTime can represent an exact time, but it is ambiguous so not very helpful. IF we want to hide NodaTime completely, using DateTimeOffset for Start and End would be the most accurate option.


if (end != null && end.LessThan(start))
throw new ArgumentException($"End time ({end}) must be greater than start time ({start}).", nameof(end));
if (end != null)
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.

Make Period as dump as CalDateTime - a pure iCalendar duration without evaluation?

public ZonedDateTime? End { get; private set; }

public EvaluationPeriod(ZonedDateTime start, ZonedDateTime? end = null)
{
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.

Move logic that currently exist in Period, e.g. start >= end etc.?

Comment thread Ical.Net/Evaluation/EvaluationPeriod.cs Outdated
using NodaTime;

namespace Ical.Net.Evaluation;
public class EvaluationPeriod : IComparable<EvaluationPeriod>
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.

Could all Evaluation classes become internal. Would increase flexibility for future changes without causing a breaking change?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I'm not sure. Some people might be using RecurrencePatternEvaluator directly for expanding RRULEs without using calendar features - similar to using rrule.js. Forcing the use of Calendar would have more allocations and probably be slightly slower.

using NodaTime.TimeZones;

namespace Ical.Net;
internal static class NodaTimeExtensions
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.

Nice!


var datePart = new DateOnly(); // Initialize. At this point, we know that the date part is present
TimeOnly? timePart = null;
var datePart = new LocalDate(); // Initialize. At this point, we know that the date part is present
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.

Yep, pure wall time

Copy link
Copy Markdown
Collaborator

@axunonb axunonb left a comment

Choose a reason for hiding this comment

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

I created a branch version/6.0 where this PR could get merged.
Unit test that currently fail should be modified.
Considering the size of this PR, which next step would you prefer?

@maknapp
Copy link
Copy Markdown
Collaborator Author

maknapp commented Nov 22, 2025

I will get to your line comments above soon and see about fixing the remaining tests. When we are happy with test changes, we can move this over to the v6 branch.

I have been working on a replacement RecurrencePatternEvaluator that I just got most of the tests passing - mostly to the same state this is at. Will share after getting this PR done.

maknapp and others added 18 commits December 28, 2025 11:23
The few tests still failing were left alone on purpose. With some tests,
the test data needs to be changed because it does not make sense now that
all occurrences are in a specific time zone.

For FREEBUSY tests, that was mostly ignored because I am not sure how it
should be used.
The only valid comparison for two CalDateTime values is equality.
The greater and less than operators cannot always return a valid
result. This makes implementing IComparable also invalid.
"20250815" != "20250815T000000" but these operators also did not
say they were less or greater either.
CalDateTime is immutable, so there is no need to copy.
CalDateTime was validating year using DateTime limits. NodaTime
supports more than just positive years, so verify that the year
is positive to match DateTime behavior.
This test had an effective end that exceeded the valid date
range but only actually held a duration, so there was no
exception. The new occurrence format now produces an end
date always, which now exceeds the valid date range.
This was essentially testing in the system time zone before.
Now that evaluation requires a time zone, this required
adjusting the local time to fit the default testing time zone
of US Eastern.
It makes sense to be a nominal duration in this case,
even if the spec does not say so specifically.
@sonarqubecloud
Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
B Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@axunonb axunonb changed the base branch from main to version/6.0 December 28, 2025 17:04
@maknapp maknapp marked this pull request as ready for review December 28, 2025 19:30
@maknapp
Copy link
Copy Markdown
Collaborator Author

maknapp commented Dec 28, 2025

I think this is ready to merge. The failing checks are minor issues that will be addressed with discussed v6 changes.

@axunonb axunonb self-requested a review December 28, 2025 21:57
Copy link
Copy Markdown
Collaborator

@axunonb axunonb left a comment

Choose a reason for hiding this comment

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

Thanks, ready for next steps

@axunonb axunonb merged commit 0b8a35b into ical-org:version/6.0 Dec 28, 2025
4 of 6 checks passed
@axunonb
Copy link
Copy Markdown
Collaborator

axunonb commented Dec 28, 2025

Code coverage for version/6.0 is 0.50% lower than for main.
Let's try to catch up again.

axunonb pushed a commit that referenced this pull request Mar 15, 2026
* Use BCL HashCode

* Fix date parsing in a test

* Evaluate recurrence with nodatime

* Evaluate calendar in a time zone always

This evaluates events in the calendar for a specific time zone.
Events with a time zone are evaluated in that event's time zone and then
converted to the requested time zone. Floating events are evaluated
in the request time zone. This is essentially the same behavior as before,
except the results are actual point-in-time values.

A key evaluation difference is that during backward daylight saving,
the last time offset can be output instead of only the first. Before,
an hourly repeating event would skip the second hour.

Fixes #681, #875

* Change tests to fit zoned API

The few tests still failing were left alone on purpose. With some tests,
the test data needs to be changed because it does not make sense now that
all occurrences are in a specific time zone.

For FREEBUSY tests, that was mostly ignored because I am not sure how it
should be used.

* Remove invalid comparison operators

The only valid comparison for two CalDateTime values is equality.
The greater and less than operators cannot always return a valid
result. This makes implementing IComparable also invalid.
"20250815" != "20250815T000000" but these operators also did not
say they were less or greater either.

* Remove copying of CalDateTime

CalDateTime is immutable, so there is no need to copy.

* Add backward daylight saving tests

* Change benchmarks to fit zoned API

* Store CalDateTime values with NodaTime

* Reduce evaluation allocation

* Fix CalDateTime format test

* Validate CalDateTime year

CalDateTime was validating year using DateTime limits. NodaTime
supports more than just positive years, so verify that the year
is positive to match DateTime behavior.

* Change out of range test

This test had an effective end that exceeded the valid date
range but only actually held a duration, so there was no
exception. The new occurrence format now produces an end
date always, which now exceeds the valid date range.

* Change time zone test

This was essentially testing in the system time zone before.
Now that evaluation requires a time zone, this required
adjusting the local time to fit the default testing time zone
of US Eastern.

* Removed warning

It makes sense to be a nominal duration in this case,
even if the spec does not say so specifically.

* Validate EvaluationPeriod end

* Fix freebusy validation message

* Simplify CalDateTime LocalDateTime conversion

* Change dateonly timeonly to methods

* Remove unused DateUtil methods

* Fix some sonar issues
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants