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 @@ + + + + +