diff --git a/Directory.Packages.props b/Directory.Packages.props
index 9eb02f44..1eac7046 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -66,5 +66,11 @@
+
+
+
+
+
+
diff --git a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs
index d7a4ba58..e0c69066 100644
--- a/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs
+++ b/Wino.Calendar.ViewModels/CalendarAppShellViewModel.cs
@@ -293,6 +293,30 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
#endregion
+ protected override void RegisterRecipients()
+ {
+ base.RegisterRecipients();
+
+ UnregisterRecipients();
+
+ Messenger.Register(this);
+ Messenger.Register(this);
+ Messenger.Register(this);
+ Messenger.Register(this);
+ Messenger.Register(this);
+ }
+
+ protected override void UnregisterRecipients()
+ {
+ base.UnregisterRecipients();
+
+ Messenger.Unregister(this);
+ Messenger.Unregister(this);
+ Messenger.Unregister(this);
+ Messenger.Unregister(this);
+ Messenger.Unregister(this);
+ }
+
public void Receive(VisibleDateRangeChangedMessage message) => HighlightedDateRange = message.DateRange;
///
diff --git a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs
index 14a8c916..8eeed6eb 100644
--- a/Wino.Calendar.ViewModels/CalendarPageViewModel.cs
+++ b/Wino.Calendar.ViewModels/CalendarPageViewModel.cs
@@ -658,7 +658,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
// Check all the events for the given date range and calendar.
// Then find the day representation for all the events returned, and add to the collection.
- var events = await _calendarService.GetCalendarEventsAsync(calendarViewModel, dayRangeRenderModel).ConfigureAwait(false);
+ var events = await _calendarService.GetCalendarEventsAsync(calendarViewModel, dayRangeRenderModel.Period).ConfigureAwait(false);
foreach (var @event in events)
{
diff --git a/Wino.Calendar.ViewModels/Data/CalendarItemViewModel.cs b/Wino.Calendar.ViewModels/Data/CalendarItemViewModel.cs
index f402a562..1af336ca 100644
--- a/Wino.Calendar.ViewModels/Data/CalendarItemViewModel.cs
+++ b/Wino.Calendar.ViewModels/Data/CalendarItemViewModel.cs
@@ -17,13 +17,71 @@ public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, IC
public IAccountCalendar AssignedCalendar => CalendarItem.AssignedCalendar;
- public DateTime StartDate { get => CalendarItem.StartDate; set => CalendarItem.StartDate = value; }
+ ///
+ /// Gets or sets the start date in local time based on the event's timezone.
+ /// The underlying CalendarItem stores dates in UTC.
+ ///
+ public DateTime StartDate
+ {
+ get
+ {
+ // Convert from UTC stored in database to local time using the event's timezone
+ var startDateTimeOffset = CalendarItem.StartDateTimeOffset;
+ return startDateTimeOffset.LocalDateTime;
+ }
+ set
+ {
+ // When setting, convert from local time to UTC for storage
+ // Preserve the timezone information
+ if (!string.IsNullOrEmpty(CalendarItem.StartTimeZone))
+ {
+ try
+ {
+ var timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(CalendarItem.StartTimeZone);
+ var utcDateTime = TimeZoneInfo.ConvertTimeToUtc(value, timeZoneInfo);
+ CalendarItem.StartDate = utcDateTime;
+ }
+ catch
+ {
+ // If timezone lookup fails, assume value is already in UTC
+ CalendarItem.StartDate = value;
+ }
+ }
+ else
+ {
+ // No timezone info, assume UTC
+ CalendarItem.StartDate = value;
+ }
+ }
+ }
- public DateTime EndDate => CalendarItem.EndDate;
+ ///
+ /// Gets the end date in local time based on the event's timezone.
+ /// The underlying CalendarItem stores dates in UTC.
+ ///
+ public DateTime EndDate
+ {
+ get
+ {
+ // Convert from UTC stored in database to local time using the event's timezone
+ var endDateTimeOffset = CalendarItem.EndDateTimeOffset;
+ return endDateTimeOffset.LocalDateTime;
+ }
+ }
public double DurationInSeconds { get => CalendarItem.DurationInSeconds; set => CalendarItem.DurationInSeconds = value; }
- public ITimePeriod Period => CalendarItem.Period;
+ ///
+ /// Gets the time period in local time.
+ ///
+ public ITimePeriod Period
+ {
+ get
+ {
+ // Return a period using local times for UI display
+ return new TimeRange(StartDate, EndDate);
+ }
+ }
public bool IsAllDayEvent => CalendarItem.IsAllDayEvent;
public bool IsMultiDayEvent => CalendarItem.IsMultiDayEvent;
@@ -32,7 +90,7 @@ public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, IC
public bool IsRecurringParent => CalendarItem.IsRecurringParent;
[ObservableProperty]
- private bool _isSelected;
+ public partial bool IsSelected { get; set; }
public ObservableCollection Attendees { get; } = new ObservableCollection();
@@ -42,4 +100,4 @@ public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, IC
}
public override string ToString() => CalendarItem.Title;
-}
+}
\ No newline at end of file
diff --git a/Wino.Calendar.ViewModels/Interfaces/IAccountCalendarStateService.cs b/Wino.Calendar.ViewModels/Interfaces/IAccountCalendarStateService.cs
index 9aaf17a4..c0d4dd61 100644
--- a/Wino.Calendar.ViewModels/Interfaces/IAccountCalendarStateService.cs
+++ b/Wino.Calendar.ViewModels/Interfaces/IAccountCalendarStateService.cs
@@ -2,9 +2,7 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
-using System.Linq;
using Wino.Calendar.ViewModels.Data;
-using Wino.Core.Domain.Entities.Shared;
namespace Wino.Calendar.ViewModels.Interfaces;
@@ -26,5 +24,5 @@ public interface IAccountCalendarStateService : INotifyPropertyChanged
/// Enumeration of currently selected calendars.
///
IEnumerable ActiveCalendars { get; }
- IEnumerable> GroupedAccountCalendarsEnumerable { get; }
+ // IEnumerable> GroupedAccountCalendarsEnumerable { get; }
}
diff --git a/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs b/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs
index 4baba8cd..ab47d74e 100644
--- a/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs
+++ b/Wino.Core.Domain/Entities/Calendar/CalendarItem.cs
@@ -27,9 +27,6 @@ public class CalendarItem : ICalendarItem
}
}
- public TimeSpan StartDateOffset { get; set; }
- public TimeSpan EndDateOffset { get; set; }
-
///
/// IANA timezone identifier for the start time (e.g., "America/New_York", "Europe/London").
/// If null or empty, UTC is assumed.
@@ -180,8 +177,6 @@ public class CalendarItem : ICalendarItem
Status = Status,
CustomEventColorHex = CustomEventColorHex,
HtmlLink = HtmlLink,
- StartDateOffset = StartDateOffset,
- EndDateOffset = EndDateOffset,
StartTimeZone = StartTimeZone,
EndTimeZone = EndTimeZone,
RemoteEventId = RemoteEventId,
@@ -194,7 +189,7 @@ public class CalendarItem : ICalendarItem
///
/// Gets the start date as a DateTimeOffset with the correct timezone.
/// If StartTimeZone is available, uses it to calculate the offset.
- /// Otherwise, uses the stored StartDateOffset or assumes UTC.
+ /// Otherwise, assumes UTC (StartDate is stored as UTC in database).
///
[Ignore]
public DateTimeOffset StartDateTimeOffset
@@ -206,24 +201,27 @@ public class CalendarItem : ICalendarItem
try
{
var timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(StartTimeZone);
- var offset = timeZoneInfo.GetUtcOffset(StartDate);
- return new DateTimeOffset(StartDate, offset);
+ // StartDate is stored in UTC, convert to the specified timezone
+ var utcDateTime = DateTime.SpecifyKind(StartDate, DateTimeKind.Utc);
+ var zonedDateTime = TimeZoneInfo.ConvertTimeFromUtc(utcDateTime, timeZoneInfo);
+ var offset = timeZoneInfo.GetUtcOffset(zonedDateTime);
+ return new DateTimeOffset(zonedDateTime, offset);
}
catch
{
- // If timezone lookup fails, fall back to stored offset
+ // If timezone lookup fails, assume UTC
}
}
- // Fall back to stored offset, or UTC if offset is zero
- return new DateTimeOffset(StartDate, StartDateOffset);
+ // Assume UTC (StartDate is stored as UTC in database)
+ return new DateTimeOffset(StartDate, TimeSpan.Zero);
}
}
///
/// Gets the end date as a DateTimeOffset with the correct timezone.
/// If EndTimeZone is available, uses it to calculate the offset.
- /// Otherwise, uses the stored EndDateOffset or assumes UTC.
+ /// Otherwise, assumes UTC (EndDate is stored as UTC in database).
///
[Ignore]
public DateTimeOffset EndDateTimeOffset
@@ -235,17 +233,20 @@ public class CalendarItem : ICalendarItem
try
{
var timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(EndTimeZone);
- var offset = timeZoneInfo.GetUtcOffset(EndDate);
- return new DateTimeOffset(EndDate, offset);
+ // EndDate is stored in UTC, convert to the specified timezone
+ var utcDateTime = DateTime.SpecifyKind(EndDate, DateTimeKind.Utc);
+ var zonedDateTime = TimeZoneInfo.ConvertTimeFromUtc(utcDateTime, timeZoneInfo);
+ var offset = timeZoneInfo.GetUtcOffset(zonedDateTime);
+ return new DateTimeOffset(zonedDateTime, offset);
}
catch
{
- // If timezone lookup fails, fall back to stored offset
+ // If timezone lookup fails, assume UTC
}
}
- // Fall back to stored offset, or UTC if offset is zero
- return new DateTimeOffset(EndDate, EndDateOffset);
+ // Assume UTC (EndDate is stored as UTC in database)
+ return new DateTimeOffset(EndDate, TimeSpan.Zero);
}
}
}
diff --git a/Wino.Core.Domain/Interfaces/ICalendarService.cs b/Wino.Core.Domain/Interfaces/ICalendarService.cs
index f0e18b4a..e598048b 100644
--- a/Wino.Core.Domain/Interfaces/ICalendarService.cs
+++ b/Wino.Core.Domain/Interfaces/ICalendarService.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
+using Itenso.TimePeriod;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Models.Calendar;
@@ -16,7 +17,15 @@ public interface ICalendarService
Task InsertAccountCalendarAsync(AccountCalendar accountCalendar);
Task UpdateAccountCalendarAsync(AccountCalendar accountCalendar);
Task CreateNewCalendarItemAsync(CalendarItem calendarItem, List attendees);
- Task> GetCalendarEventsAsync(IAccountCalendar calendar, DayRangeRenderModel dayRangeRenderModel);
+
+ ///
+ /// Retrieves calendar events for a given calendar within the specified time period.
+ ///
+ /// The calendar to retrieve events from.
+ /// The time period to query events for.
+ /// List of calendar items including regular events and recurring event occurrences.
+ Task> GetCalendarEventsAsync(IAccountCalendar calendar, ITimePeriod period);
+
Task GetCalendarItemAsync(Guid accountCalendarId, string remoteEventId);
Task UpdateCalendarDeltaSynchronizationToken(Guid calendarId, string deltaToken);
diff --git a/Wino.Core.Domain/Interfaces/IStatePersistenceService.cs b/Wino.Core.Domain/Interfaces/IStatePersistenceService.cs
index 8ed05626..43dfbd10 100644
--- a/Wino.Core.Domain/Interfaces/IStatePersistenceService.cs
+++ b/Wino.Core.Domain/Interfaces/IStatePersistenceService.cs
@@ -28,6 +28,16 @@ public interface IStatePersistanceService : INotifyPropertyChanged
///
bool IsBackButtonVisible { get; }
+ ///
+ /// Current application mode (Mail or Calendar).
+ /// Not persisted to configuration, only kept in memory.
+ ///
+ WinoApplicationMode ApplicationMode { get; set; }
+
+ ///
+ /// Whether event details page is visible in Calendar mode.
+ ///
+ bool IsEventDetailsVisible { get; set; }
///
/// Setting: Opened pane length for the navigation view.
diff --git a/Wino.Core.Domain/Interfaces/IWinoNavigationService.cs b/Wino.Core.Domain/Interfaces/IWinoNavigationService.cs
index b343dd00..954c8bf2 100644
--- a/Wino.Core.Domain/Interfaces/IWinoNavigationService.cs
+++ b/Wino.Core.Domain/Interfaces/IWinoNavigationService.cs
@@ -12,6 +12,6 @@ public interface INavigationService
NavigationTransitionType transition = NavigationTransitionType.None);
Type GetPageType(WinoPage winoPage);
- void GoBack();
bool ChangeApplicationMode(WinoApplicationMode mode);
+ void GoBack();
}
diff --git a/Wino.Core.Domain/Models/Calendar/DayRangeRenderModel.cs b/Wino.Core.Domain/Models/Calendar/DayRangeRenderModel.cs
index 17245c32..90a661e2 100644
--- a/Wino.Core.Domain/Models/Calendar/DayRangeRenderModel.cs
+++ b/Wino.Core.Domain/Models/Calendar/DayRangeRenderModel.cs
@@ -18,6 +18,8 @@ public class DayRangeRenderModel
public List DayHeaders { get; } = [];
public CalendarRenderOptions CalendarRenderOptions { get; }
+ public int TotalDays => CalendarRenderOptions.TotalDayCount;
+
public DayRangeRenderModel(CalendarRenderOptions calendarRenderOptions)
{
CalendarRenderOptions = calendarRenderOptions;
diff --git a/Wino.Core.Tests/GlobalUsings.cs b/Wino.Core.Tests/GlobalUsings.cs
new file mode 100644
index 00000000..29ee885e
--- /dev/null
+++ b/Wino.Core.Tests/GlobalUsings.cs
@@ -0,0 +1,4 @@
+global using System;
+global using System.Collections.Generic;
+global using System.Linq;
+global using System.Threading.Tasks;
diff --git a/Wino.Core.Tests/Helpers/InMemoryDatabaseService.cs b/Wino.Core.Tests/Helpers/InMemoryDatabaseService.cs
new file mode 100644
index 00000000..79df91c5
--- /dev/null
+++ b/Wino.Core.Tests/Helpers/InMemoryDatabaseService.cs
@@ -0,0 +1,57 @@
+using SQLite;
+using Wino.Core.Domain.Entities.Calendar;
+using Wino.Core.Domain.Entities.Mail;
+using Wino.Core.Domain.Entities.Shared;
+using Wino.Services;
+
+namespace Wino.Core.Tests.Helpers;
+
+///
+/// In-memory database service for testing purposes.
+/// Creates a temporary SQLite database in memory that is destroyed after tests complete.
+///
+public class InMemoryDatabaseService : IDatabaseService
+{
+ public SQLiteAsyncConnection Connection { get; private set; }
+
+ public InMemoryDatabaseService()
+ {
+ // Use :memory: for a truly in-memory database or a temporary file
+ Connection = new SQLiteAsyncConnection(":memory:");
+ }
+
+ public async Task InitializeAsync()
+ {
+ await CreateTablesAsync();
+ }
+
+ private async Task CreateTablesAsync()
+ {
+ await Task.WhenAll(
+ Connection.CreateTableAsync(),
+ Connection.CreateTableAsync(),
+ Connection.CreateTableAsync(),
+ Connection.CreateTableAsync(),
+ Connection.CreateTableAsync(),
+ Connection.CreateTableAsync(),
+ Connection.CreateTableAsync(),
+ Connection.CreateTableAsync(),
+ Connection.CreateTableAsync(),
+ Connection.CreateTableAsync(),
+ Connection.CreateTableAsync(),
+ Connection.CreateTableAsync(),
+ Connection.CreateTableAsync(),
+ Connection.CreateTableAsync(),
+ Connection.CreateTableAsync()
+ );
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ if (Connection != null)
+ {
+ await Connection.CloseAsync();
+ Connection = null!;
+ }
+ }
+}
diff --git a/Wino.Core.Tests/Services/CalendarServiceTests.cs b/Wino.Core.Tests/Services/CalendarServiceTests.cs
new file mode 100644
index 00000000..8da2bfcb
--- /dev/null
+++ b/Wino.Core.Tests/Services/CalendarServiceTests.cs
@@ -0,0 +1,577 @@
+using FluentAssertions;
+using Itenso.TimePeriod;
+using Wino.Core.Domain;
+using Wino.Core.Domain.Entities.Calendar;
+using Wino.Core.Domain.Interfaces;
+using Wino.Core.Tests.Helpers;
+using Wino.Services;
+using Xunit;
+
+namespace Wino.Core.Tests.Services;
+
+///
+/// Tests for CalendarService, focusing on the GetCalendarEventsAsync method
+/// which handles both regular and recurring events with RFC 5545 patterns.
+///
+public class CalendarServiceTests : IAsyncLifetime
+{
+ private InMemoryDatabaseService _databaseService = null!;
+ private CalendarService _calendarService = null!;
+ private AccountCalendar _testCalendar = null!;
+
+ public async Task InitializeAsync()
+ {
+ _databaseService = new InMemoryDatabaseService();
+ await _databaseService.InitializeAsync();
+ _calendarService = new CalendarService(_databaseService);
+
+ // Create a test calendar
+ _testCalendar = new AccountCalendar
+ {
+ Id = Guid.NewGuid(),
+ AccountId = Guid.NewGuid(),
+ Name = "Test Calendar",
+ TimeZone = "UTC",
+ IsPrimary = true,
+ BackgroundColorHex = "#FF5733",
+ TextColorHex = "#FFFFFF"
+ };
+
+ await _calendarService.InsertAccountCalendarAsync(_testCalendar);
+ }
+
+ public async Task DisposeAsync()
+ {
+ await _databaseService.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task GetCalendarEventsAsync_WithNoEvents_ReturnsEmptyList()
+ {
+ // Arrange
+ var period = new TimeRange(DateTime.UtcNow.Date, DateTime.UtcNow.Date.AddDays(7));
+
+ // Act
+ var result = await _calendarService.GetCalendarEventsAsync(_testCalendar, period);
+
+ // Assert
+ result.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task GetCalendarEventsAsync_WithSingleNonRecurringEvent_ReturnsEvent()
+ {
+ // Arrange
+ var startDate = new DateTime(2025, 1, 15, 10, 0, 0, DateTimeKind.Utc);
+ var calendarItem = new CalendarItem
+ {
+ Id = Guid.NewGuid(),
+ Title = "Team Meeting",
+ Description = "Weekly sync",
+ StartDate = startDate,
+ DurationInSeconds = 3600, // 1 hour
+ CalendarId = _testCalendar.Id,
+ IsHidden = false
+ };
+
+ await _calendarService.CreateNewCalendarItemAsync(calendarItem, null);
+
+ var period = new TimeRange(
+ new DateTime(2025, 1, 15, 0, 0, 0, DateTimeKind.Utc),
+ new DateTime(2025, 1, 16, 0, 0, 0, DateTimeKind.Utc));
+
+ // Act
+ var result = await _calendarService.GetCalendarEventsAsync(_testCalendar, period);
+
+ // Assert
+ result.Should().HaveCount(1);
+ result[0].Title.Should().Be("Team Meeting");
+ result[0].StartDate.Should().Be(startDate);
+ }
+
+ [Fact]
+ public async Task GetCalendarEventsAsync_WithNonRecurringEvent_OutsidePeriod_ReturnsEmpty()
+ {
+ // Arrange
+ var startDate = new DateTime(2025, 1, 15, 10, 0, 0, DateTimeKind.Utc);
+ var calendarItem = new CalendarItem
+ {
+ Id = Guid.NewGuid(),
+ Title = "Team Meeting",
+ StartDate = startDate,
+ DurationInSeconds = 3600,
+ CalendarId = _testCalendar.Id,
+ IsHidden = false
+ };
+
+ await _calendarService.CreateNewCalendarItemAsync(calendarItem, null);
+
+ // Query for a different week
+ var period = new TimeRange(
+ new DateTime(2025, 1, 22, 0, 0, 0, DateTimeKind.Utc),
+ new DateTime(2025, 1, 29, 0, 0, 0, DateTimeKind.Utc));
+
+ // Act
+ var result = await _calendarService.GetCalendarEventsAsync(_testCalendar, period);
+
+ // Assert
+ result.Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task GetCalendarEventsAsync_WithDailyRecurringEvent_ReturnsMultipleOccurrences()
+ {
+ // Arrange - Create a daily recurring event starting Jan 15, 2025
+ var startDate = new DateTime(2025, 1, 15, 10, 0, 0, DateTimeKind.Utc);
+ var recurringEvent = new CalendarItem
+ {
+ Id = Guid.NewGuid(),
+ Title = "Daily Standup",
+ Description = "Daily team sync",
+ StartDate = startDate,
+ DurationInSeconds = 1800, // 30 minutes
+ CalendarId = _testCalendar.Id,
+ IsHidden = false,
+ // Daily recurrence pattern (RFC 5545)
+ Recurrence = "RRULE:FREQ=DAILY;COUNT=5"
+ };
+
+ await _calendarService.CreateNewCalendarItemAsync(recurringEvent, null);
+
+ // Query for the week containing the recurring events
+ var period = new TimeRange(
+ new DateTime(2025, 1, 15, 0, 0, 0, DateTimeKind.Utc),
+ new DateTime(2025, 1, 20, 0, 0, 0, DateTimeKind.Utc));
+
+ // Act
+ var result = await _calendarService.GetCalendarEventsAsync(_testCalendar, period);
+
+ // Assert
+ result.Should().HaveCount(5, "because the event recurs daily for 5 days");
+ result.Should().AllSatisfy(e =>
+ {
+ e.Title.Should().Be("Daily Standup");
+ e.DurationInSeconds.Should().Be(1800);
+ e.IsRecurringChild.Should().BeTrue();
+ e.IsOccurrence.Should().BeTrue();
+ });
+
+ // Verify the dates are sequential
+ var dates = result.Select(e => e.StartDate.Date).OrderBy(d => d).ToList();
+ dates.Should().HaveCount(5);
+ dates[0].Should().Be(new DateTime(2025, 1, 15).Date);
+ dates[1].Should().Be(new DateTime(2025, 1, 16).Date);
+ dates[2].Should().Be(new DateTime(2025, 1, 17).Date);
+ dates[3].Should().Be(new DateTime(2025, 1, 18).Date);
+ dates[4].Should().Be(new DateTime(2025, 1, 19).Date);
+ }
+
+ [Fact]
+ public async Task GetCalendarEventsAsync_WithWeeklyRecurringEvent_ReturnsCorrectOccurrences()
+ {
+ // Arrange - Create a weekly recurring event on Mondays
+ var startDate = new DateTime(2025, 1, 6, 14, 0, 0, DateTimeKind.Utc); // Monday
+ var recurringEvent = new CalendarItem
+ {
+ Id = Guid.NewGuid(),
+ Title = "Weekly Review",
+ StartDate = startDate,
+ DurationInSeconds = 3600, // 1 hour
+ CalendarId = _testCalendar.Id,
+ IsHidden = false,
+ // Weekly recurrence on Mondays
+ Recurrence = "RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=4"
+ };
+
+ await _calendarService.CreateNewCalendarItemAsync(recurringEvent, null);
+
+ // Query for a month
+ var period = new TimeRange(
+ new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc),
+ new DateTime(2025, 2, 1, 0, 0, 0, DateTimeKind.Utc));
+
+ // Act
+ var result = await _calendarService.GetCalendarEventsAsync(_testCalendar, period);
+
+ // Assert
+ result.Should().HaveCount(4, "because the event recurs weekly for 4 weeks");
+ result.Should().AllSatisfy(e =>
+ {
+ e.Title.Should().Be("Weekly Review");
+ e.StartDate.DayOfWeek.Should().Be(DayOfWeek.Monday);
+ });
+ }
+
+ [Fact]
+ public async Task GetCalendarEventsAsync_WithRecurringEventAndException_ExcludesException()
+ {
+ // Arrange - Create a daily recurring event
+ var startDate = new DateTime(2025, 1, 15, 10, 0, 0, DateTimeKind.Utc);
+ var recurringEvent = new CalendarItem
+ {
+ Id = Guid.NewGuid(),
+ Title = "Daily Meeting",
+ StartDate = startDate,
+ DurationInSeconds = 1800,
+ CalendarId = _testCalendar.Id,
+ IsHidden = false,
+ Recurrence = "RRULE:FREQ=DAILY;COUNT=5"
+ };
+
+ await _calendarService.CreateNewCalendarItemAsync(recurringEvent, null);
+
+ // Create an exception instance for Jan 17 (cancelled)
+ var exceptionInstance = new CalendarItem
+ {
+ Id = Guid.NewGuid(),
+ Title = "Daily Meeting (Cancelled)",
+ StartDate = new DateTime(2025, 1, 17, 10, 0, 0, DateTimeKind.Utc),
+ DurationInSeconds = 1800,
+ CalendarId = _testCalendar.Id,
+ RecurringCalendarItemId = recurringEvent.Id,
+ IsHidden = true // Cancelled/hidden
+ };
+
+ await _calendarService.CreateNewCalendarItemAsync(exceptionInstance, null);
+
+ var period = new TimeRange(
+ new DateTime(2025, 1, 15, 0, 0, 0, DateTimeKind.Utc),
+ new DateTime(2025, 1, 20, 0, 0, 0, DateTimeKind.Utc));
+
+ // Act
+ var result = await _calendarService.GetCalendarEventsAsync(_testCalendar, period);
+
+ // Assert
+ result.Should().HaveCount(4, "because one occurrence is cancelled/hidden");
+ result.Should().NotContain(e => e.StartDate.Date == new DateTime(2025, 1, 17).Date);
+ }
+
+ [Fact]
+ public async Task GetCalendarEventsAsync_WithRecurringEventAndModifiedException_ReturnsModifiedVersion()
+ {
+ // Arrange - Create a daily recurring event
+ var startDate = new DateTime(2025, 1, 15, 10, 0, 0, DateTimeKind.Utc);
+ var recurringEvent = new CalendarItem
+ {
+ Id = Guid.NewGuid(),
+ Title = "Daily Meeting",
+ StartDate = startDate,
+ DurationInSeconds = 1800,
+ CalendarId = _testCalendar.Id,
+ IsHidden = false,
+ Recurrence = "RRULE:FREQ=DAILY;COUNT=5"
+ };
+
+ await _calendarService.CreateNewCalendarItemAsync(recurringEvent, null);
+
+ // Create a modified exception instance for Jan 17 (time and duration changed)
+ // The exception starts at 10:00 just like the original occurrence
+ var modifiedException = new CalendarItem
+ {
+ Id = Guid.NewGuid(),
+ Title = "Daily Meeting (Rescheduled)",
+ StartDate = new DateTime(2025, 1, 17, 10, 0, 0, DateTimeKind.Utc), // Same time, different properties
+ DurationInSeconds = 3600, // Different duration (1 hour instead of 30 min)
+ CalendarId = _testCalendar.Id,
+ RecurringCalendarItemId = recurringEvent.Id,
+ IsHidden = false
+ };
+
+ await _calendarService.CreateNewCalendarItemAsync(modifiedException, null);
+
+ var period = new TimeRange(
+ new DateTime(2025, 1, 15, 0, 0, 0, DateTimeKind.Utc),
+ new DateTime(2025, 1, 20, 0, 0, 0, DateTimeKind.Utc));
+
+ // Act
+ var result = await _calendarService.GetCalendarEventsAsync(_testCalendar, period);
+
+ // Assert
+ result.Should().HaveCount(5, "4 normal occurrences + 1 modified exception");
+
+ // Check the modified exception - it should have the updated duration
+ var jan17Events = result.Where(e => e.StartDate.Date == new DateTime(2025, 1, 17).Date).ToList();
+ jan17Events.Should().HaveCount(1, "only the modified exception should appear for Jan 17");
+
+ var modifiedEvent = jan17Events.First();
+ modifiedEvent.Title.Should().Be("Daily Meeting (Rescheduled)");
+ modifiedEvent.DurationInSeconds.Should().Be(3600);
+ modifiedEvent.IsRecurringChild.Should().BeTrue();
+ modifiedEvent.IsOccurrence.Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task GetCalendarEventsAsync_WithHiddenEvent_ExcludesFromResults()
+ {
+ // Arrange
+ var startDate = new DateTime(2025, 1, 15, 10, 0, 0, DateTimeKind.Utc);
+ var hiddenEvent = new CalendarItem
+ {
+ Id = Guid.NewGuid(),
+ Title = "Hidden Event",
+ StartDate = startDate,
+ DurationInSeconds = 3600,
+ CalendarId = _testCalendar.Id,
+ IsHidden = true
+ };
+
+ await _calendarService.CreateNewCalendarItemAsync(hiddenEvent, null);
+
+ var period = new TimeRange(
+ new DateTime(2025, 1, 15, 0, 0, 0, DateTimeKind.Utc),
+ new DateTime(2025, 1, 16, 0, 0, 0, DateTimeKind.Utc));
+
+ // Act
+ var result = await _calendarService.GetCalendarEventsAsync(_testCalendar, period);
+
+ // Assert
+ result.Should().BeEmpty("because hidden events should be excluded");
+ }
+
+ [Fact]
+ public async Task GetCalendarEventsAsync_WithAllDayEvent_ReturnsEvent()
+ {
+ // Arrange
+ var startDate = new DateTime(2025, 1, 15, 0, 0, 0, DateTimeKind.Utc); // Midnight
+ var allDayEvent = new CalendarItem
+ {
+ Id = Guid.NewGuid(),
+ Title = "Company Holiday",
+ StartDate = startDate,
+ DurationInSeconds = 86400, // 24 hours
+ CalendarId = _testCalendar.Id,
+ IsHidden = false
+ };
+
+ await _calendarService.CreateNewCalendarItemAsync(allDayEvent, null);
+
+ var period = new TimeRange(
+ new DateTime(2025, 1, 15, 0, 0, 0, DateTimeKind.Utc),
+ new DateTime(2025, 1, 16, 0, 0, 0, DateTimeKind.Utc));
+
+ // Act
+ var result = await _calendarService.GetCalendarEventsAsync(_testCalendar, period);
+
+ // Assert
+ result.Should().HaveCount(1);
+ result[0].Title.Should().Be("Company Holiday");
+ result[0].IsAllDayEvent.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task GetCalendarEventsAsync_WithMultipleCalendars_ReturnsOnlyRequestedCalendarEvents()
+ {
+ // Arrange - Create another calendar
+ var secondCalendar = new AccountCalendar
+ {
+ Id = Guid.NewGuid(),
+ AccountId = _testCalendar.AccountId,
+ Name = "Second Calendar",
+ TimeZone = "UTC",
+ IsPrimary = false,
+ BackgroundColorHex = "#00FF00",
+ TextColorHex = "#000000"
+ };
+
+ await _calendarService.InsertAccountCalendarAsync(secondCalendar);
+
+ // Add events to both calendars
+ var startDate = new DateTime(2025, 1, 15, 10, 0, 0, DateTimeKind.Utc);
+
+ var event1 = new CalendarItem
+ {
+ Id = Guid.NewGuid(),
+ Title = "Calendar 1 Event",
+ StartDate = startDate,
+ DurationInSeconds = 3600,
+ CalendarId = _testCalendar.Id,
+ IsHidden = false
+ };
+
+ var event2 = new CalendarItem
+ {
+ Id = Guid.NewGuid(),
+ Title = "Calendar 2 Event",
+ StartDate = startDate,
+ DurationInSeconds = 3600,
+ CalendarId = secondCalendar.Id,
+ IsHidden = false
+ };
+
+ await _calendarService.CreateNewCalendarItemAsync(event1, null);
+ await _calendarService.CreateNewCalendarItemAsync(event2, null);
+
+ var period = new TimeRange(
+ new DateTime(2025, 1, 15, 0, 0, 0, DateTimeKind.Utc),
+ new DateTime(2025, 1, 16, 0, 0, 0, DateTimeKind.Utc));
+
+ // Act - Query only the first calendar
+ var result = await _calendarService.GetCalendarEventsAsync(_testCalendar, period);
+
+ // Assert
+ result.Should().HaveCount(1);
+ result[0].Title.Should().Be("Calendar 1 Event");
+ result[0].CalendarId.Should().Be(_testCalendar.Id);
+ }
+
+ [Fact]
+ public async Task GetCalendarEventsAsync_WithRecurringEventWithUNTIL_StopsAfterUntilDate()
+ {
+ // Arrange - Create two weekly recurring events with same pattern
+ // Event 1: Has UNTIL date of Nov 13, 2025 (should stop after this date)
+ // Event 2: No UNTIL date (continues indefinitely)
+
+ var startDate = new DateTime(2025, 10, 10, 14, 0, 0, DateTimeKind.Utc); // Friday, Oct 10, 2025
+
+ // Event with UNTIL - should stop on Nov 13, 2025
+ var eventWithUntil = new CalendarItem
+ {
+ Id = Guid.NewGuid(),
+ RemoteEventId = "event-with-until-123",
+ Title = "Weekly Meeting (Until Nov 13)",
+ StartDate = startDate,
+ DurationInSeconds = 3600, // 1 hour
+ CalendarId = _testCalendar.Id,
+ IsHidden = false,
+ // Weekly on Fridays, until November 13, 2025
+ Recurrence = "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=FR;UNTIL=20251113"
+ };
+
+ // Event without UNTIL - continues indefinitely
+ var eventWithoutUntil = new CalendarItem
+ {
+ Id = Guid.NewGuid(),
+ RemoteEventId = "event-without-until-456",
+ Title = "Weekly Meeting (No End)",
+ StartDate = startDate,
+ DurationInSeconds = 3600, // 1 hour
+ CalendarId = _testCalendar.Id,
+ IsHidden = false,
+ // Weekly on Fridays, no end date
+ Recurrence = "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=FR"
+ };
+
+ await _calendarService.CreateNewCalendarItemAsync(eventWithUntil, null);
+ await _calendarService.CreateNewCalendarItemAsync(eventWithoutUntil, null);
+
+ // Query for a period AFTER the UNTIL date (Nov 20 - Nov 30, 2025)
+ // This is past November 13, so only the event without UNTIL should appear
+ var periodAfterUntil = new TimeRange(
+ new DateTime(2025, 11, 20, 0, 0, 0, DateTimeKind.Utc),
+ new DateTime(2025, 11, 30, 0, 0, 0, DateTimeKind.Utc));
+
+ // Act
+ var resultAfterUntil = await _calendarService.GetCalendarEventsAsync(_testCalendar, periodAfterUntil);
+
+ // Assert - Only the event without UNTIL should appear
+ // In Nov 20-30 period, there are 2 Fridays: Nov 21 and Nov 28
+ // Both should only be from the event WITHOUT UNTIL
+ resultAfterUntil.Should().HaveCount(2, "there are 2 Fridays in Nov 20-30 period");
+ resultAfterUntil.Should().AllSatisfy(e =>
+ {
+ e.Title.Should().Be("Weekly Meeting (No End)");
+ e.RecurringCalendarItemId.Should().Be(eventWithoutUntil.Id);
+ });
+
+ // Verify NO occurrences from the event with UNTIL appear after the UNTIL date
+ var withUntilOccurrences = resultAfterUntil.Where(e => e.RecurringCalendarItemId == eventWithUntil.Id).ToList();
+ withUntilOccurrences.Should().BeEmpty("the event with UNTIL=Nov 13 should not appear after that date");
+
+ // Query for a period BEFORE the UNTIL date (Oct 10 - Nov 10, 2025)
+ // Both events should appear since we're before the UNTIL date
+ var periodBeforeUntil = new TimeRange(
+ new DateTime(2025, 10, 10, 0, 0, 0, DateTimeKind.Utc),
+ new DateTime(2025, 11, 10, 0, 0, 0, DateTimeKind.Utc));
+
+ var resultBeforeUntil = await _calendarService.GetCalendarEventsAsync(_testCalendar, periodBeforeUntil);
+
+ // Should have occurrences from both events
+ // From Oct 10 to Nov 10, Fridays are: Oct 10, 17, 24, 31, Nov 7
+ // That's 5 Fridays, so we expect 10 total (5 from each event)
+ resultBeforeUntil.Should().HaveCount(10, "both events should have 5 occurrences each in this period");
+
+ var untilEventOccurrences = resultBeforeUntil.Where(e => e.RecurringCalendarItemId == eventWithUntil.Id).ToList();
+ var noUntilEventOccurrences = resultBeforeUntil.Where(e => e.RecurringCalendarItemId == eventWithoutUntil.Id).ToList();
+
+ untilEventOccurrences.Should().HaveCount(5);
+ noUntilEventOccurrences.Should().HaveCount(5);
+ }
+
+ [Fact]
+ public async Task GetCalendarEventsAsync_WithDuplicateRecurringEvents_OnlyShowsNonExpiredOccurrences()
+ {
+ // Arrange - Simulates the scenario where you have the same recurring event
+ // synced twice with different RemoteEventIds, one with UNTIL and one without
+
+ var startDate = new DateTime(2025, 10, 10, 14, 0, 0, DateTimeKind.Utc); // Friday, Oct 10, 2025
+
+ // First sync: Event with UNTIL (older version that expired)
+ var expiredEvent = new CalendarItem
+ {
+ Id = Guid.NewGuid(),
+ RemoteEventId = "recurring-event-v1",
+ Title = "Team Standup",
+ StartDate = startDate,
+ DurationInSeconds = 1800, // 30 min
+ CalendarId = _testCalendar.Id,
+ IsHidden = false,
+ Recurrence = "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=FR;UNTIL=20251113T000000Z"
+ };
+
+ // Second sync: Same event but without UNTIL (updated version)
+ var activeEvent = new CalendarItem
+ {
+ Id = Guid.NewGuid(),
+ RemoteEventId = "recurring-event-v2",
+ Title = "Team Standup",
+ StartDate = startDate,
+ DurationInSeconds = 1800,
+ CalendarId = _testCalendar.Id,
+ IsHidden = false,
+ Recurrence = "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=FR" // No UNTIL - continues indefinitely
+ };
+
+ await _calendarService.CreateNewCalendarItemAsync(expiredEvent, null);
+ await _calendarService.CreateNewCalendarItemAsync(activeEvent, null);
+
+ // Query for December 2025 (well after the UNTIL date of Nov 13)
+ var decemberPeriod = new TimeRange(
+ new DateTime(2025, 12, 1, 0, 0, 0, DateTimeKind.Utc),
+ new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Utc));
+
+ // Act
+ var decemberResults = await _calendarService.GetCalendarEventsAsync(_testCalendar, decemberPeriod);
+
+ // Assert - Should only see occurrences from the active event (without UNTIL)
+ // December 2025 has Fridays on: 5, 12, 19, 26
+ decemberResults.Should().HaveCount(4, "December has 4 Fridays");
+ decemberResults.Should().AllSatisfy(e =>
+ {
+ e.RecurringCalendarItemId.Should().Be(activeEvent.Id, "only the event without UNTIL should appear");
+ e.Title.Should().Be("Team Standup");
+ });
+
+ // Verify the expired event doesn't contribute any occurrences
+ var expiredOccurrences = decemberResults.Where(e => e.RecurringCalendarItemId == expiredEvent.Id).ToList();
+ expiredOccurrences.Should().BeEmpty("the expired event with UNTIL=Nov 13 should not generate occurrences in December");
+
+ // Also test a period that spans the UNTIL boundary (November 1-30)
+ var novemberPeriod = new TimeRange(
+ new DateTime(2025, 11, 1, 0, 0, 0, DateTimeKind.Utc),
+ new DateTime(2025, 11, 30, 23, 59, 59, DateTimeKind.Utc));
+
+ var novemberResults = await _calendarService.GetCalendarEventsAsync(_testCalendar, novemberPeriod);
+
+ // November 2025 Fridays: 7, 14, 21, 28
+ // Event with UNTIL stops on Nov 13, so Nov 7 is the last occurrence for that one
+ // Event without UNTIL continues, so it has all 4 occurrences
+
+ var expiredEventInNov = novemberResults.Where(e => e.RecurringCalendarItemId == expiredEvent.Id).ToList();
+ var activeEventInNov = novemberResults.Where(e => e.RecurringCalendarItemId == activeEvent.Id).ToList();
+
+ expiredEventInNov.Should().HaveCount(1, "expired event only appears on Nov 7 (before UNTIL=20251113)");
+ expiredEventInNov[0].StartDate.Day.Should().Be(7);
+
+ activeEventInNov.Should().HaveCount(4, "active event appears on all 4 Fridays");
+ }
+}
diff --git a/Wino.Core.Tests/Wino.Core.Tests.csproj b/Wino.Core.Tests/Wino.Core.Tests.csproj
new file mode 100644
index 00000000..540f3825
--- /dev/null
+++ b/Wino.Core.Tests/Wino.Core.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ x86;x64;arm64
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs
index 56368809..ab95ff0d 100644
--- a/Wino.Core/Extensions/OutlookIntegratorExtensions.cs
+++ b/Wino.Core/Extensions/OutlookIntegratorExtensions.cs
@@ -215,7 +215,9 @@ public static class OutlookIntegratorExtensions
{
if (recurrence.Range.Type == RecurrenceRangeType.EndDate && recurrence.Range.EndDate != null)
{
- ruleBuilder.Append($"UNTIL={recurrence.Range.EndDate.Value:yyyyMMddTHHmmssZ};");
+ // RFC 5545 requires YYYYMMDD or YYYYMMDDTHHMMSSinvalid format (no dashes or colons)
+ var untilDate = recurrence.Range.EndDate.Value.DateTime.ToString("yyyyMMdd'T'HHmmss'Z'", System.Globalization.CultureInfo.InvariantCulture);
+ ruleBuilder.Append($"UNTIL={untilDate};");
}
else if (recurrence.Range.Type == RecurrenceRangeType.Numbered && recurrence.Range.NumberOfOccurrences.HasValue)
{
diff --git a/Wino.Core/Integration/Processors/GmailChangeProcessor.cs b/Wino.Core/Integration/Processors/GmailChangeProcessor.cs
index 5fe2f6ca..8b3e694a 100644
--- a/Wino.Core/Integration/Processors/GmailChangeProcessor.cs
+++ b/Wino.Core/Integration/Processors/GmailChangeProcessor.cs
@@ -96,9 +96,7 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso
CreatedAt = DateTimeOffset.UtcNow,
Description = calendarEvent.Description ?? parentRecurringEvent.Description,
Id = Guid.NewGuid(),
- StartDate = eventStartDateTimeOffset.Value.DateTime,
- StartDateOffset = eventStartDateTimeOffset.Value.Offset,
- EndDateOffset = eventEndDateTimeOffset?.Offset ?? parentRecurringEvent.EndDateOffset,
+ StartDate = eventStartDateTimeOffset.Value.UtcDateTime,
DurationInSeconds = totalDurationInSeconds,
Location = string.IsNullOrEmpty(calendarEvent.Location) ? parentRecurringEvent.Location : calendarEvent.Location,
@@ -136,9 +134,7 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso
CreatedAt = DateTimeOffset.UtcNow,
Description = calendarEvent.Description,
Id = Guid.NewGuid(),
- StartDate = eventStartDateTimeOffset.Value.DateTime,
- StartDateOffset = eventStartDateTimeOffset.Value.Offset,
- EndDateOffset = eventEndDateTimeOffset.Value.Offset,
+ StartDate = eventStartDateTimeOffset.Value.UtcDateTime,
DurationInSeconds = totalDurationInSeconds,
Location = calendarEvent.Location,
diff --git a/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs
index 371c6876..6123d14c 100644
--- a/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs
+++ b/Wino.Core/Integration/Processors/OutlookChangeProcessor.cs
@@ -63,14 +63,14 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
var durationInSeconds = (eventEndDateTimeOffset - eventStartDateTimeOffset).TotalSeconds;
+ // Store dates as UTC in the database
savingItem.RemoteEventId = calendarEvent.Id;
- savingItem.StartDate = eventStartDateTimeOffset.DateTime;
- savingItem.StartDateOffset = eventStartDateTimeOffset.Offset;
- savingItem.EndDateOffset = eventEndDateTimeOffset.Offset;
+ savingItem.StartDate = eventStartDateTimeOffset.UtcDateTime;
savingItem.DurationInSeconds = durationInSeconds;
// Store the timezone information from the event
// This preserves the original timezone from Outlook, allowing proper reconstruction later
+ // If no timezone is provided, null will indicate UTC
savingItem.StartTimeZone = calendarEvent.Start?.TimeZone;
savingItem.EndTimeZone = calendarEvent.End?.TimeZone;
diff --git a/Wino.Mail.WinUI/Controls/Calendar/CalendarItemControl.xaml b/Wino.Mail.WinUI/Controls/Calendar/CalendarItemControl.xaml
index f89d1470..13a42dc2 100644
--- a/Wino.Mail.WinUI/Controls/Calendar/CalendarItemControl.xaml
+++ b/Wino.Mail.WinUI/Controls/Calendar/CalendarItemControl.xaml
@@ -64,7 +64,7 @@
+/// AOT-Safe ItemsControl for use in UniformGrid panels.
+///
+///
+public partial class UniformItemsControl : Grid
+{
+ [GeneratedDependencyProperty]
+ public partial DayRangeRenderModel? RenderModel { get; set; }
+
+ [GeneratedDependencyProperty]
+ public partial List? ItemsSource { get; set; }
+
+ partial void OnRenderModelChanged(DayRangeRenderModel? newValue)
+ {
+ if (newValue == null || ItemsSource == null) return;
+
+ AdjustColumns();
+ }
+
+ partial void OnItemsSourceChanged(List? newValue)
+ {
+ if (newValue == null || ItemsSource == null) return;
+
+ AdjustColumns();
+ }
+
+ private void AdjustColumns()
+ {
+ if (RenderModel == null || ItemsSource == null) return;
+
+ Children.Clear();
+ ColumnDefinitions.Clear();
+
+ var columns = RenderModel.TotalDays;
+
+ // First divide.
+ for (int i = 0; i < columns; i++)
+ {
+ ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+ }
+
+ // Then add items.
+ for (int i = 0; i < columns; i++)
+ {
+ var item = ItemsSource[i];
+
+ var control = new DayColumnControl()
+ {
+ DayModel = item
+ };
+
+ SetColumn(control, i);
+ Children.Add(control);
+ }
+ }
+}
+//public partial class UniformItemsControl : ItemsControl
+//{
+// private const string ControlUniformGridName = "PART_UniformGrid";
+
+// [GeneratedDependencyProperty]
+// public partial DayRangeRenderModel? RenderModel { get; set; }
+
+// partial void OnRenderModelChanged(DayRangeRenderModel? newValue)
+// {
+// if (newValue == null) return;
+
+// // Adjust the ItemsPanel based on the RenderModel's columns.
+// var uniGrid = WinoVisualTreeHelper.FindDescendants(this);
+
+// //if (uniGrid != null)
+// //{
+// // uniGrid.Columns = newValue.TotalDays;
+// //}
+// }
+//}
diff --git a/Wino.Mail.WinUI/Controls/Calendar/WinoDayTimelineCanvas.cs b/Wino.Mail.WinUI/Controls/Calendar/WinoDayTimelineCanvas.cs
index 7ba154e5..f2566fb3 100644
--- a/Wino.Mail.WinUI/Controls/Calendar/WinoDayTimelineCanvas.cs
+++ b/Wino.Mail.WinUI/Controls/Calendar/WinoDayTimelineCanvas.cs
@@ -1,5 +1,7 @@
using System;
using System.Diagnostics;
+using System.Linq;
+using CommunityToolkit.WinUI;
using Microsoft.Graphics.Canvas.Geometry;
using Microsoft.Graphics.Canvas.UI.Xaml;
using Microsoft.UI.Input;
@@ -110,6 +112,11 @@ public partial class WinoDayTimelineCanvas : Control, IDisposable
// When users click to cell we need to find the day, hour and minutes (first 30 minutes or second 30 minutes) that it represents on the timeline.
+ if (PositionerUIElement == null)
+ {
+ PositionerUIElement = this.FindParents().LastOrDefault(a => a is Grid);
+ }
+
PointerPoint positionerRootPoint = e.GetCurrentPoint(PositionerUIElement);
PointerPoint canvasPointerPoint = e.GetCurrentPoint(Canvas);
diff --git a/Wino.Mail.WinUI/Selectors/CustomAreaCalendarItemSelector.cs b/Wino.Mail.WinUI/Selectors/CustomAreaCalendarItemSelector.cs
index 35996d01..8c962126 100644
--- a/Wino.Mail.WinUI/Selectors/CustomAreaCalendarItemSelector.cs
+++ b/Wino.Mail.WinUI/Selectors/CustomAreaCalendarItemSelector.cs
@@ -6,10 +6,10 @@ namespace Wino.Calendar.Selectors;
public partial class CustomAreaCalendarItemSelector : DataTemplateSelector
{
- public DataTemplate AllDayTemplate { get; set; }
- public DataTemplate MultiDayTemplate { get; set; }
+ public DataTemplate? AllDayTemplate { get; set; }
+ public DataTemplate? MultiDayTemplate { get; set; }
- protected override DataTemplate SelectTemplateCore(object item, DependencyObject container)
+ protected override DataTemplate? SelectTemplateCore(object item, DependencyObject container)
{
if (item is CalendarItemViewModel calendarItemViewModel)
{
diff --git a/Wino.Mail.WinUI/Services/AccountCalendarStateService.cs b/Wino.Mail.WinUI/Services/AccountCalendarStateService.cs
index 9dc8ab03..50f1e119 100644
--- a/Wino.Mail.WinUI/Services/AccountCalendarStateService.cs
+++ b/Wino.Mail.WinUI/Services/AccountCalendarStateService.cs
@@ -5,7 +5,6 @@ using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using Wino.Calendar.ViewModels.Data;
using Wino.Calendar.ViewModels.Interfaces;
-using Wino.Core.Domain.Entities.Shared;
namespace Wino.Mail.WinUI.Services;
@@ -33,16 +32,16 @@ public partial class AccountCalendarStateService : ObservableObject, IAccountCal
}
}
- public IEnumerable> GroupedAccountCalendarsEnumerable
- {
- get
- {
- return GroupedAccountCalendars
- .Select(a => a.AccountCalendars)
- .SelectMany(b => b)
- .GroupBy(c => c.Account);
- }
- }
+ //public IEnumerable> GroupedAccountCalendarsEnumerable
+ //{
+ // get
+ // {
+ // return GroupedAccountCalendars
+ // .Select(a => a.AccountCalendars)
+ // .SelectMany(b => b)
+ // .GroupBy(c => c.Account);
+ // }
+ //}
public AccountCalendarStateService()
{
diff --git a/Wino.Mail.WinUI/Services/NavigationService.cs b/Wino.Mail.WinUI/Services/NavigationService.cs
index 0942267d..0ebdf56b 100644
--- a/Wino.Mail.WinUI/Services/NavigationService.cs
+++ b/Wino.Mail.WinUI/Services/NavigationService.cs
@@ -87,6 +87,9 @@ public class NavigationService : NavigationServiceBase, INavigationService
if (coreFrame == null) return false;
+ // Update the application mode in state persistence service
+ _statePersistanceService.ApplicationMode = mode;
+
var targetPageType = mode == WinoApplicationMode.Mail ? typeof(MailAppShell) : typeof(CalendarAppShell);
var currentPageType = coreFrame.Content?.GetType();
var transitionInfo = GetNavigationTransitionInfo(NavigationTransitionType.DrillIn);
@@ -128,73 +131,104 @@ public class NavigationService : NavigationServiceBase, INavigationService
NavigationTransitionType transition = NavigationTransitionType.None)
{
var pageType = GetPageType(page);
- Frame shellFrame = GetCoreFrame(NavigationReferenceFrame.InnerShellFrame);
+
+ var currentApplicationMode = GetCoreFrame(NavigationReferenceFrame.ShellFrame)?.Content?.GetType() == typeof(MailAppShell)
+ ? WinoApplicationMode.Mail
+ : WinoApplicationMode.Calendar;
_statePersistanceService.IsReadingMail = _renderingPageTypes.Contains(page);
+ _statePersistanceService.IsEventDetailsVisible = page == WinoPage.EventDetailsPage;
- if (shellFrame != null)
+ Frame innerShellFrame = GetCoreFrame(NavigationReferenceFrame.InnerShellFrame);
+
+ if (innerShellFrame != null)
{
- var currentFrameType = GetCurrentFrameType(ref shellFrame);
- bool isCalendarShellActive = shellFrame.Content != null && shellFrame.Content.GetType() == typeof(CalendarAppShell);
- if (isCalendarShellActive)
+ // Calendar navigations.
+ if (currentApplicationMode == WinoApplicationMode.Calendar)
{
- return shellFrame.Navigate(pageType, parameter);
+ return innerShellFrame.Navigate(pageType, parameter);
}
- bool isMailListingPageActive = currentFrameType != null && currentFrameType == typeof(MailListPage);
-
- // Active page is mail list page and we are refreshing the folder.
- if (isMailListingPageActive && currentFrameType == pageType && parameter is NavigateMailFolderEventArgs folderNavigationArgs)
+ else
{
- // No need for new navigation, just refresh the folder.
- WeakReferenceMessenger.Default.Send(new ActiveMailFolderChangedEvent(folderNavigationArgs.BaseFolderMenuItem, folderNavigationArgs.FolderInitLoadAwaitTask));
- WeakReferenceMessenger.Default.Send(new DisposeRenderingFrameRequested());
+ // Mail navigations.
+ var currentFrameType = GetCurrentFrameType(ref innerShellFrame);
+ bool isMailListingPageActive = currentFrameType != null && currentFrameType == typeof(MailListPage);
- return true;
- }
-
- var transitionInfo = GetNavigationTransitionInfo(transition);
-
- // This page must be opened in the Frame placed in MailListingPage.
- if (isMailListingPageActive && frame == NavigationReferenceFrame.RenderingFrame)
- {
- var listingFrame = GetCoreFrame(NavigationReferenceFrame.RenderingFrame);
-
- if (listingFrame == null) return false;
-
- // Active page is mail list page and we are opening a mail item.
- // No navigation needed, just refresh the rendered mail item.
- if (listingFrame.Content != null
- && listingFrame.Content.GetType() == GetPageType(WinoPage.MailRenderingPage)
- && parameter is MailItemViewModel mailItemViewModel
- && page != WinoPage.ComposePage)
+ // Active page is mail list page and we are refreshing the folder.
+ if (isMailListingPageActive && currentFrameType == pageType && parameter is NavigateMailFolderEventArgs folderNavigationArgs)
{
- WeakReferenceMessenger.Default.Send(new NewMailItemRenderingRequestedEvent(mailItemViewModel));
- }
- else if (listingFrame.Content != null
- && listingFrame.Content.GetType() == GetPageType(WinoPage.IdlePage)
- && pageType == typeof(IdlePage))
- {
- // Idle -> Idle navigation. Ignore.
+ // No need for new navigation, just refresh the folder.
+ WeakReferenceMessenger.Default.Send(new ActiveMailFolderChangedEvent(folderNavigationArgs.BaseFolderMenuItem, folderNavigationArgs.FolderInitLoadAwaitTask));
+ WeakReferenceMessenger.Default.Send(new DisposeRenderingFrameRequested());
+
return true;
}
- else
+
+ var transitionInfo = GetNavigationTransitionInfo(transition);
+
+ // This page must be opened in the Frame placed in MailListingPage.
+ if (isMailListingPageActive && frame == NavigationReferenceFrame.RenderingFrame)
{
- listingFrame.Navigate(pageType, parameter, transitionInfo);
+ var listingFrame = GetCoreFrame(NavigationReferenceFrame.RenderingFrame);
+
+ if (listingFrame == null) return false;
+
+ // Active page is mail list page and we are opening a mail item.
+ // No navigation needed, just refresh the rendered mail item.
+ if (listingFrame.Content != null
+ && listingFrame.Content.GetType() == GetPageType(WinoPage.MailRenderingPage)
+ && parameter is MailItemViewModel mailItemViewModel
+ && page != WinoPage.ComposePage)
+ {
+ WeakReferenceMessenger.Default.Send(new NewMailItemRenderingRequestedEvent(mailItemViewModel));
+ }
+ else if (listingFrame.Content != null
+ && listingFrame.Content.GetType() == GetPageType(WinoPage.IdlePage)
+ && pageType == typeof(IdlePage))
+ {
+ // Idle -> Idle navigation. Ignore.
+ return true;
+ }
+ else
+ {
+ listingFrame.Navigate(pageType, parameter, transitionInfo);
+ }
+
+ return true;
}
- return true;
- }
-
- if ((currentFrameType != null && currentFrameType != pageType) || currentFrameType == null)
- {
- return shellFrame.Navigate(pageType, parameter, transitionInfo);
+ if ((currentFrameType != null && currentFrameType != pageType) || currentFrameType == null)
+ {
+ return innerShellFrame.Navigate(pageType, parameter, transitionInfo);
+ }
}
}
return false;
}
- public void GoBack() => throw new NotImplementedException("GoBack method is not implemented in Wino Mail.");
+ public void GoBack()
+ {
+ if (_statePersistanceService.ApplicationMode == WinoApplicationMode.Calendar)
+ {
+ var innerShellFrame = GetCoreFrame(NavigationReferenceFrame.InnerShellFrame);
+ if (innerShellFrame?.CanGoBack == true)
+ {
+ innerShellFrame.GoBack();
+
+ // Calendar mode: Navigate back from EventDetailsPage
+ _statePersistanceService.IsEventDetailsVisible = false;
+ }
+ }
+ else
+ {
+ // Mail mode: Clear selections and dispose rendering frame
+ _statePersistanceService.IsReadingMail = false;
+
+ WeakReferenceMessenger.Default.Send(new ClearMailSelectionsRequested());
+ WeakReferenceMessenger.Default.Send(new DisposeRenderingFrameRequested());
+ }
+ }
// Standalone EML viewer.
//public void NavigateRendering(MimeMessageInformation mimeMessageInformation, NavigationTransitionType transition = NavigationTransitionType.None)
diff --git a/Wino.Mail.WinUI/Services/StatePersistenceService.cs b/Wino.Mail.WinUI/Services/StatePersistenceService.cs
index 42046ea3..99f6f801 100644
--- a/Wino.Mail.WinUI/Services/StatePersistenceService.cs
+++ b/Wino.Mail.WinUI/Services/StatePersistenceService.cs
@@ -9,7 +9,7 @@ namespace Wino.Services;
public class StatePersistenceService : ObservableObject, IStatePersistanceService
{
- public event EventHandler StatePropertyChanged;
+ public event EventHandler? StatePropertyChanged;
private const string OpenPaneLengthKey = nameof(OpenPaneLengthKey);
private const string MailListPaneLengthKey = nameof(MailListPaneLengthKey);
@@ -28,9 +28,40 @@ public class StatePersistenceService : ObservableObject, IStatePersistanceServic
PropertyChanged += ServicePropertyChanged;
}
- private void ServicePropertyChanged(object sender, PropertyChangedEventArgs e) => StatePropertyChanged?.Invoke(this, e.PropertyName);
+ private void ServicePropertyChanged(object? sender, PropertyChangedEventArgs e) => StatePropertyChanged?.Invoke(this, e?.PropertyName ?? string.Empty);
- public bool IsBackButtonVisible => IsReadingMail && IsReaderNarrowed;
+ public bool IsBackButtonVisible =>
+ ApplicationMode == WinoApplicationMode.Mail
+ ? IsReadingMail && IsReaderNarrowed
+ : IsEventDetailsVisible;
+
+ private WinoApplicationMode applicationMode = WinoApplicationMode.Mail;
+
+ public WinoApplicationMode ApplicationMode
+ {
+ get => applicationMode;
+ set
+ {
+ if (SetProperty(ref applicationMode, value))
+ {
+ OnPropertyChanged(nameof(IsBackButtonVisible));
+ }
+ }
+ }
+
+ private bool isEventDetailsVisible;
+
+ public bool IsEventDetailsVisible
+ {
+ get => isEventDetailsVisible;
+ set
+ {
+ if (SetProperty(ref isEventDetailsVisible, value))
+ {
+ OnPropertyChanged(nameof(IsBackButtonVisible));
+ }
+ }
+ }
private bool isReadingMail;
@@ -68,7 +99,7 @@ public class StatePersistenceService : ObservableObject, IStatePersistanceServic
}
}
- private string coreWindowTitle;
+ private string coreWindowTitle = string.Empty;
public string CoreWindowTitle
{
diff --git a/Wino.Mail.WinUI/ShellWindow.xaml.cs b/Wino.Mail.WinUI/ShellWindow.xaml.cs
index dc896c4a..fefe8f7e 100644
--- a/Wino.Mail.WinUI/ShellWindow.xaml.cs
+++ b/Wino.Mail.WinUI/ShellWindow.xaml.cs
@@ -9,7 +9,6 @@ using Microsoft.UI.Xaml.Controls;
using Windows.UI;
using Wino.Core.Domain.Interfaces;
using Wino.Mail.WinUI.Interfaces;
-using Wino.Messaging.Client.Mails;
using Wino.Messaging.Client.Shell;
using Wino.Messaging.UI;
using Wino.Views;
@@ -114,8 +113,7 @@ public sealed partial class ShellWindow : WindowEx, IWinoShellWindow, IRecipient
private void BackButtonClicked(Microsoft.UI.Xaml.Controls.TitleBar sender, object args)
{
- WeakReferenceMessenger.Default.Send(new ClearMailSelectionsRequested());
- WeakReferenceMessenger.Default.Send(new DisposeRenderingFrameRequested());
+ NavigationService.GoBack();
}
private void MainFrameNavigated(object sender, Microsoft.UI.Xaml.Navigation.NavigationEventArgs e)
diff --git a/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml b/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml
index 38ee859e..2bf25250 100644
--- a/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml
+++ b/Wino.Mail.WinUI/Styles/WinoCalendarResources.xaml
@@ -2,6 +2,7 @@
x:Class="Wino.Styles.WinoCalendarResources"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:calendar="using:Wino.Mail.WinUI.Controls.Calendar"
xmlns:controls="using:Wino.Calendar.Controls"
xmlns:controls2="using:Wino.Mail.WinUI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
@@ -54,7 +55,6 @@
@@ -63,6 +63,7 @@
+
@@ -97,7 +98,6 @@
+
+
+
{ }
+public abstract class CalendarPageAbstract : BasePage
+{
+ protected CalendarPageAbstract()
+ {
+ NavigationCacheMode = Microsoft.UI.Xaml.Navigation.NavigationCacheMode.Enabled;
+ }
+}
diff --git a/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml b/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml
index 051afacb..97cdb33f 100644
--- a/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml
+++ b/Wino.Mail.WinUI/Views/Calendar/CalendarAppShell.xaml
@@ -156,6 +156,7 @@
+ Source="{x:Bind ViewModel.AccountCalendarStateService.GroupedAccountCalendars, Mode=OneWay}" />
diff --git a/Wino.Mail.WinUI/Views/Calendar/CalendarPage.xaml.cs b/Wino.Mail.WinUI/Views/Calendar/CalendarPage.xaml.cs
index ee5e0500..caf18b1d 100644
--- a/Wino.Mail.WinUI/Views/Calendar/CalendarPage.xaml.cs
+++ b/Wino.Mail.WinUI/Views/Calendar/CalendarPage.xaml.cs
@@ -22,7 +22,6 @@ public sealed partial class CalendarPage : CalendarPageAbstract,
public CalendarPage()
{
InitializeComponent();
- NavigationCacheMode = NavigationCacheMode.Enabled;
ViewModel.DetailsShowCalendarItemChanged += CalendarItemDetailContextChanged;
}
@@ -40,6 +39,26 @@ public sealed partial class CalendarPage : CalendarPageAbstract,
}
}
+ protected override void RegisterRecipients()
+ {
+ base.RegisterRecipients();
+
+ WeakReferenceMessenger.Default.Register(this);
+ WeakReferenceMessenger.Default.Register(this);
+ WeakReferenceMessenger.Default.Register(this);
+ WeakReferenceMessenger.Default.Register(this);
+ }
+
+ protected override void UnregisterRecipients()
+ {
+ base.UnregisterRecipients();
+
+ WeakReferenceMessenger.Default.Unregister(this);
+ WeakReferenceMessenger.Default.Unregister(this);
+ WeakReferenceMessenger.Default.Unregister(this);
+ WeakReferenceMessenger.Default.Unregister(this);
+ }
+
public void Receive(ScrollToHourMessage message) => CalendarControl.NavigateToHour(message.TimeSpan);
public void Receive(ScrollToDateMessage message) => CalendarControl.NavigateToDay(message.Date);
public void Receive(GoNextDateRequestedMessage message) => CalendarControl.GoNextRange();
diff --git a/Wino.Services/CalendarService.cs b/Wino.Services/CalendarService.cs
index 328bb5c2..4508cd61 100644
--- a/Wino.Services/CalendarService.cs
+++ b/Wino.Services/CalendarService.cs
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
using CommunityToolkit.Mvvm.Messaging;
using Ical.Net.CalendarComponents;
using Ical.Net.DataTypes;
+using Itenso.TimePeriod;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Calendar;
using Wino.Core.Domain.Enums;
@@ -90,87 +91,120 @@ public class CalendarService : BaseDatabaseService, ICalendarService
WeakReferenceMessenger.Default.Send(new CalendarItemAdded(calendarItem));
}
- public async Task> GetCalendarEventsAsync(IAccountCalendar calendar, DayRangeRenderModel dayRangeRenderModel)
+ ///
+ /// Retrieves calendar events for a given calendar within the specified time period.
+ /// This includes regular events and expanded recurring event occurrences based on RFC 5545 patterns.
+ ///
+ /// The calendar to retrieve events from.
+ /// The time period to query events for.
+ /// List of calendar items including regular events and recurring event occurrences.
+ public async Task> GetCalendarEventsAsync(IAccountCalendar calendar, ITimePeriod period)
{
- // TODO: We might need to implement caching here.
- // I don't know how much of the events we'll have in total, but this logic scans all events every time for given calendar.
+ // TODO: Implement caching strategy for better performance with large event sets.
+ // Consider using a cache keyed by calendar ID and time period.
var accountEvents = await Connection.Table()
- .Where(x => x.CalendarId == calendar.Id && !x.IsHidden).ToListAsync();
+ .Where(x => x.CalendarId == calendar.Id && !x.IsHidden)
+ .ToListAsync();
var result = new List();
- foreach (var ev in accountEvents)
+ foreach (var calendarItem in accountEvents)
{
- ev.AssignedCalendar = calendar;
+ calendarItem.AssignedCalendar = calendar;
- // Parse recurrence rules
- var calendarEvent = new CalendarEvent
+ // Skip exception instances - they will be handled by their parent recurring event
+ if (calendarItem.RecurringCalendarItemId.HasValue)
{
- Start = new CalDateTime(ev.StartDate),
- End = new CalDateTime(ev.EndDate),
- };
+ continue;
+ }
- if (string.IsNullOrEmpty(ev.Recurrence))
+ if (string.IsNullOrEmpty(calendarItem.Recurrence))
{
- // No recurrence, only check if we fall into the given period.
-
- if (ev.Period.OverlapsWith(dayRangeRenderModel.Period))
+ // Regular non-recurring event - simply check if it overlaps with the requested period.
+ if (calendarItem.Period.OverlapsWith(period))
{
- result.Add(ev);
+ result.Add(calendarItem);
}
}
else
{
- // This event has recurrences.
- // Wino stores exceptional recurrent events as a separate calendar item, without the recurrence rule.
- // Because each instance of recurrent event can have different attendees, properties etc.
- // Even though the event is recurrent, each updated instance is a separate calendar item.
- // Calculate the all recurrences, and remove the exceptional instances like hidden ones.
-
- var recurrenceLines = Regex.Split(ev.Recurrence, Constants.CalendarEventRecurrenceRuleSeperator);
-
- foreach (var line in recurrenceLines)
- {
- calendarEvent.RecurrenceRules.Add(new RecurrencePattern(line));
- }
-
- // Calculate occurrences in the range.
- var occurrences = calendarEvent.GetOccurrences(dayRangeRenderModel.Period.Start, dayRangeRenderModel.Period.End);
-
- // Get all recurrent exceptional calendar events.
- var exceptionalRecurrences = await Connection.Table()
- .Where(a => a.RecurringCalendarItemId == ev.Id)
- .ToListAsync()
- .ConfigureAwait(false);
-
- foreach (var occurrence in occurrences)
- {
- var exactInstanceCheck = exceptionalRecurrences.FirstOrDefault(a =>
- a.Period.OverlapsWith(dayRangeRenderModel.Period));
-
- if (exactInstanceCheck == null)
- {
- // There is no exception for the period.
- // Change the instance StartDate and Duration.
-
- var recurrence = ev.CreateRecurrence(occurrence.Period.StartTime.Value, occurrence.Period.Duration.TotalSeconds);
-
- result.Add(recurrence);
- }
- else
- {
- // There is a single instance of this recurrent event.
- // It will be added as single item if it's not hidden.
- // We don't need to do anything here.
- }
- }
+ // Recurring event - expand occurrences within the period.
+ // Wino stores recurring events as a series master with RFC 5545 recurrence rules.
+ // Exception instances (modified or cancelled) are stored separately and linked via RecurringCalendarItemId.
+ var expandedOccurrences = await ExpandRecurringEventAsync(calendarItem, period);
+ result.AddRange(expandedOccurrences);
}
}
return result;
}
+ ///
+ /// Expands a recurring event into its occurrences within the specified period.
+ /// Handles exception instances (modified or cancelled occurrences) by excluding them from the expansion.
+ ///
+ /// The recurring event series master.
+ /// The time period to expand occurrences within.
+ /// List of calendar items representing individual occurrences in the period.
+ private async Task> ExpandRecurringEventAsync(CalendarItem recurringEvent, ITimePeriod period)
+ {
+ var result = new List();
+
+ // Parse the RFC 5545 recurrence pattern.
+ var calendarEvent = new CalendarEvent
+ {
+ Start = new CalDateTime(recurringEvent.StartDate),
+ End = new CalDateTime(recurringEvent.EndDate),
+ };
+
+ var recurrenceLines = Regex.Split(recurringEvent.Recurrence, Constants.CalendarEventRecurrenceRuleSeperator);
+ foreach (var line in recurrenceLines)
+ {
+ calendarEvent.RecurrenceRules.Add(new RecurrencePattern(line));
+ }
+
+ // Calculate all occurrences in the requested period using iCal.NET.
+ var occurrences = calendarEvent.GetOccurrences(period.Start, period.End);
+
+ // Retrieve exception instances (modified or cancelled occurrences).
+ // These are stored as separate CalendarItem records with RecurringCalendarItemId set.
+ var exceptionInstances = await Connection.Table()
+ .Where(a => a.RecurringCalendarItemId == recurringEvent.Id)
+ .ToListAsync()
+ .ConfigureAwait(false);
+
+ foreach (var occurrence in occurrences)
+ {
+ // Check if this occurrence has been modified/cancelled (exception instance exists).
+ // Compare by checking if an exception instance overlaps with this occurrence's time window.
+ var occurrenceStart = occurrence.Period.StartTime.Value;
+ var occurrenceEnd = occurrence.Period.EndTime?.Value ?? occurrenceStart.Add(occurrence.Period.Duration);
+
+ var exceptionInstance = exceptionInstances.FirstOrDefault(a =>
+ a.StartDate <= occurrenceEnd && a.EndDate >= occurrenceStart);
+
+ if (exceptionInstance == null)
+ {
+ // No exception - create a virtual occurrence from the series master.
+ var occurrenceItem = recurringEvent.CreateRecurrence(
+ occurrenceStart,
+ occurrence.Period.Duration.TotalSeconds);
+
+ result.Add(occurrenceItem);
+ }
+ else if (!exceptionInstance.IsHidden && exceptionInstance.Period.OverlapsWith(period))
+ {
+ // Exception exists and is not hidden - include the modified version.
+ exceptionInstance.AssignedCalendar = recurringEvent.AssignedCalendar;
+ result.Add(exceptionInstance);
+ }
+ // If exception is hidden, skip this occurrence entirely.
+ }
+
+ return result;
+ }
+
public Task GetAccountCalendarAsync(Guid accountCalendarId)
=> Connection.GetAsync(accountCalendarId);
diff --git a/WinoMail.slnx b/WinoMail.slnx
index 04b08333..d8136ddf 100644
--- a/WinoMail.slnx
+++ b/WinoMail.slnx
@@ -55,6 +55,11 @@
+
+
+
+
+