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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,14 @@ public class CalendarService(IRecurrenceEngine engine)

```csharp
// Create a weekly recurring meeting
// Note: RecurrenceEndTime is automatically extracted from the RRule UNTIL clause
await engine.CreateRecurrenceAsync(new RecurrenceCreate
{
Organization = "tenant1",
ResourcePath = "user123/calendar",
Type = "meeting",
StartTimeUtc = new DateTime(2025, 1, 6, 14, 0, 0, DateTimeKind.Utc),
StartTime = new DateTime(2025, 1, 6, 14, 0, 0, DateTimeKind.Utc), // Or DateTime.Now for local time
Duration = TimeSpan.FromHours(1),
RecurrenceEndTimeUtc = new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Utc),
RRule = "FREQ=WEEKLY;BYDAY=MO;UNTIL=20251231T235959Z",
TimeZone = "America/New_York"
});
Expand Down
6 changes: 3 additions & 3 deletions src/RecurringThings.MongoDB/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,14 @@ public class CalendarService(IRecurrenceEngine engine)
{
public async Task CreateWeeklyMeetingAsync()
{
// RecurrenceEndTime is automatically extracted from the RRule UNTIL clause
var recurrence = await engine.CreateRecurrenceAsync(new RecurrenceCreate
{
Organization = "tenant1",
ResourcePath = "user123/calendar",
Type = "meeting",
StartTimeUtc = new DateTime(2025, 1, 6, 14, 0, 0, DateTimeKind.Utc),
StartTime = new DateTime(2025, 1, 6, 14, 0, 0, DateTimeKind.Utc), // Or DateTime.Now for local time
Duration = TimeSpan.FromHours(1),
RecurrenceEndTimeUtc = new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Utc),
RRule = "FREQ=WEEKLY;BYDAY=MO;UNTIL=20251231T235959Z",
TimeZone = "America/New_York",
Extensions = new Dictionary<string, string>
Expand Down Expand Up @@ -164,6 +164,6 @@ dotnet test --filter 'Category=Integration'
## Limitations

- MongoDB transactions require replica set (not available on standalone instances)
- All DateTime values must be UTC
- DateTime values can be UTC or Local (`DateTimeKind.Unspecified` is not allowed)
- RRule must use UNTIL (COUNT is not supported)
- UNTIL must have UTC suffix (Z)
6 changes: 3 additions & 3 deletions src/RecurringThings.PostgreSQL/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,14 @@ public class CalendarService(IRecurrenceEngine engine)
{
public async Task CreateWeeklyMeetingAsync()
{
// RecurrenceEndTime is automatically extracted from the RRule UNTIL clause
var recurrence = await engine.CreateRecurrenceAsync(new RecurrenceCreate
{
Organization = "tenant1",
ResourcePath = "user123/calendar",
Type = "meeting",
StartTimeUtc = new DateTime(2025, 1, 6, 14, 0, 0, DateTimeKind.Utc),
StartTime = new DateTime(2025, 1, 6, 14, 0, 0, DateTimeKind.Utc), // Or DateTime.Now for local time
Duration = TimeSpan.FromHours(1),
RecurrenceEndTimeUtc = new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Utc),
RRule = "FREQ=WEEKLY;BYDAY=MO;UNTIL=20251231T235959Z",
TimeZone = "America/New_York",
Extensions = new Dictionary<string, string>
Expand Down Expand Up @@ -174,6 +174,6 @@ dotnet test --filter 'Category=Integration'
## Limitations

- Database must exist before running the application (schema is auto-created)
- All DateTime values must be UTC
- DateTime values can be UTC or Local (`DateTimeKind.Unspecified` is not allowed)
- RRule must use UNTIL (COUNT is not supported)
- UNTIL must have UTC suffix (Z)
3 changes: 1 addition & 2 deletions src/RecurringThings/Engine/IRecurrenceEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ IAsyncEnumerable<CalendarEntry> GetAsync(
/// Type = "appointment",
/// StartTime = DateTime.UtcNow, // Or DateTime.Now for local time
/// Duration = TimeSpan.FromHours(1),
/// RecurrenceEndTime = new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Utc),
/// RRule = "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20251231T235959Z",
/// TimeZone = "America/New_York"
/// });
Expand Down Expand Up @@ -164,7 +163,7 @@ Task<Occurrence> CreateOccurrenceAsync(
/// <exception cref="ArgumentNullException">Thrown when <paramref name="entry"/> is null.</exception>
/// <exception cref="InvalidOperationException">
/// Thrown when attempting to modify immutable fields (Organization, ResourcePath, Type, TimeZone,
/// or recurrence-specific fields like RRule, StartTime, RecurrenceEndTime).
/// or recurrence-specific fields like RRule and StartTime).
/// </exception>
/// <exception cref="KeyNotFoundException">
/// Thrown when the underlying entity (recurrence, occurrence, or override) is not found.
Expand Down
36 changes: 25 additions & 11 deletions src/RecurringThings/Engine/RecurrenceEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -231,9 +231,11 @@ public async Task<Recurrence> CreateRecurrenceAsync(
var validationResult = await _recurrenceCreateValidator.ValidateAsync(request, cancellationToken).ConfigureAwait(false);
validationResult.ThrowIfInvalid();

// Convert input times to UTC if they're local
// Convert input time to UTC if it's local
var startTimeUtc = ConvertToUtc(request.StartTime, request.TimeZone);
var recurrenceEndTimeUtc = ConvertToUtc(request.RecurrenceEndTime, request.TimeZone);

// Extract RecurrenceEndTime from RRule UNTIL clause
var recurrenceEndTimeUtc = ExtractUntilFromRRule(request.RRule);

// Create the recurrence entity
var recurrence = new Recurrence
Expand Down Expand Up @@ -553,8 +555,7 @@ private async Task<CalendarEntry> UpdateRecurrenceAsync(
RecurrenceId = updated.Id,
RecurrenceDetails = new RecurrenceDetails
{
RRule = updated.RRule,
RecurrenceEndTime = updated.RecurrenceEndTime
RRule = updated.RRule
}
};
}
Expand Down Expand Up @@ -594,20 +595,14 @@ private static void ValidateImmutableFields(CalendarEntry entry, Recurrence exis
"Cannot modify StartTime on a recurrence. This field is immutable after creation.");
}

// Only validate RRule and RecurrenceEndTime if RecurrenceDetails is provided
// Only validate RRule if RecurrenceDetails is provided
if (entry.RecurrenceDetails is not null)
{
if (entry.RecurrenceDetails.RRule != existing.RRule)
{
throw new InvalidOperationException(
"Cannot modify RRule. This field is immutable after creation.");
}

if (entry.RecurrenceDetails.RecurrenceEndTime != existing.RecurrenceEndTime)
{
throw new InvalidOperationException(
"Cannot modify RecurrenceEndTime. This field is immutable after creation.");
}
}
}

Expand Down Expand Up @@ -982,6 +977,25 @@ await _overrideRepository.DeleteAsync(
cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// Extracts the UNTIL value from an RRule string and returns it as a UTC DateTime.
/// </summary>
/// <param name="rrule">The RRule string containing an UNTIL clause.</param>
/// <returns>The UNTIL value as a UTC DateTime.</returns>
/// <exception cref="ArgumentException">Thrown when the RRule does not contain a valid UNTIL clause.</exception>
private static DateTime ExtractUntilFromRRule(string rrule)
{
var pattern = new RecurrencePattern(rrule);

if (pattern.Until is null)
{
throw new ArgumentException("RRule must contain UNTIL clause.", nameof(rrule));
}

// Ical.Net parses UNTIL as UTC when the Z suffix is present
return DateTime.SpecifyKind(pattern.Until.Value, DateTimeKind.Utc);
}

/// <summary>
/// Converts a DateTime to UTC. If already UTC, returns as-is. If Local, converts using the specified timezone.
/// </summary>
Expand Down
15 changes: 3 additions & 12 deletions src/RecurringThings/Models/RecurrenceCreate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ namespace RecurringThings.Models;
/// <remarks>
/// <para>DateTime values can be provided in UTC or Local time (Unspecified is not allowed).
/// Local times are converted to UTC internally using the specified <see cref="TimeZone"/>.</para>
/// <para>The RRule UNTIL value must be in UTC (Z suffix) and must match <see cref="RecurrenceEndTime"/> when converted to UTC.</para>
/// <para>The RRule UNTIL value must be in UTC (Z suffix). The recurrence end time is automatically
/// extracted from the RRule UNTIL clause.</para>
/// </remarks>
public sealed class RecurrenceCreate
{
Expand Down Expand Up @@ -58,22 +59,12 @@ public sealed class RecurrenceCreate
/// </remarks>
public required TimeSpan Duration { get; init; }

/// <summary>
/// Gets the timestamp when the recurrence series ends.
/// </summary>
/// <remarks>
/// Can be provided in UTC or Local time (Unspecified is not allowed).
/// Local times are converted to UTC internally using <see cref="TimeZone"/>.
/// Must match the UNTIL value in <see cref="RRule"/> when converted to UTC.
/// Used for efficient query filtering without full virtualization.
/// </remarks>
public required DateTime RecurrenceEndTime { get; init; }

/// <summary>
/// Gets the RFC 5545 recurrence rule defining the pattern.
/// </summary>
/// <remarks>
/// <para>Must use UNTIL (in UTC with Z suffix); COUNT is not supported.</para>
/// <para>The recurrence end time is automatically extracted from the UNTIL clause.</para>
/// <para>Maximum length is 2000 characters.</para>
/// <para>Example: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20251231T235959Z"</para>
/// </remarks>
Expand Down
13 changes: 2 additions & 11 deletions src/RecurringThings/Models/RecurrenceDetails.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
namespace RecurringThings.Models;

using System;

/// <summary>
/// Contains recurrence-specific details when a <see cref="CalendarEntry"/> represents a recurrence pattern.
/// </summary>
Expand All @@ -11,19 +9,12 @@ namespace RecurringThings.Models;
/// </remarks>
public sealed class RecurrenceDetails
{
/// <summary>
/// Gets or sets the UTC timestamp when the recurrence series ends.
/// </summary>
/// <remarks>
/// Matches the UNTIL value in the RRule.
/// </remarks>
public DateTime RecurrenceEndTime { get; set; }

/// <summary>
/// Gets or sets the RFC 5545 recurrence rule defining the pattern.
/// </summary>
/// <remarks>
/// Example: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20251231T235959Z"
/// <para>Example: "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20251231T235959Z"</para>
/// <para>The recurrence end time can be extracted by parsing the UNTIL clause from this RRule.</para>
/// </remarks>
public required string RRule { get; set; }
}
Loading