Fixed the display date of the calendar items. Created test project for core library, included tests for recurring calendar events.
This commit is contained in:
@@ -66,5 +66,11 @@
|
|||||||
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.7175" />
|
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.7175" />
|
||||||
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.0.250930001-experimental1" />
|
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.0.250930001-experimental1" />
|
||||||
<PackageVersion Include="WinUIEx" Version="2.9.0" />
|
<PackageVersion Include="WinUIEx" Version="2.9.0" />
|
||||||
|
<!-- Testing packages -->
|
||||||
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.11.0" />
|
||||||
|
<PackageVersion Include="xunit" Version="2.9.0" />
|
||||||
|
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
<PackageVersion Include="FluentAssertions" Version="7.0.0" />
|
||||||
|
<PackageVersion Include="Moq" Version="4.20.72" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -293,6 +293,30 @@ public partial class CalendarAppShellViewModel : CalendarBaseViewModel,
|
|||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
protected override void RegisterRecipients()
|
||||||
|
{
|
||||||
|
base.RegisterRecipients();
|
||||||
|
|
||||||
|
UnregisterRecipients();
|
||||||
|
|
||||||
|
Messenger.Register<VisibleDateRangeChangedMessage>(this);
|
||||||
|
Messenger.Register<CalendarEnableStatusChangedMessage>(this);
|
||||||
|
Messenger.Register<NavigateManageAccountsRequested>(this);
|
||||||
|
Messenger.Register<CalendarDisplayTypeChangedMessage>(this);
|
||||||
|
Messenger.Register<DetailsPageStateChangedMessage>(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void UnregisterRecipients()
|
||||||
|
{
|
||||||
|
base.UnregisterRecipients();
|
||||||
|
|
||||||
|
Messenger.Unregister<VisibleDateRangeChangedMessage>(this);
|
||||||
|
Messenger.Unregister<CalendarEnableStatusChangedMessage>(this);
|
||||||
|
Messenger.Unregister<NavigateManageAccountsRequested>(this);
|
||||||
|
Messenger.Unregister<CalendarDisplayTypeChangedMessage>(this);
|
||||||
|
Messenger.Unregister<DetailsPageStateChangedMessage>(this);
|
||||||
|
}
|
||||||
|
|
||||||
public void Receive(VisibleDateRangeChangedMessage message) => HighlightedDateRange = message.DateRange;
|
public void Receive(VisibleDateRangeChangedMessage message) => HighlightedDateRange = message.DateRange;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -658,7 +658,7 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
|
|||||||
// Check all the events for the given date range and calendar.
|
// 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.
|
// 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)
|
foreach (var @event in events)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,13 +17,71 @@ public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, IC
|
|||||||
|
|
||||||
public IAccountCalendar AssignedCalendar => CalendarItem.AssignedCalendar;
|
public IAccountCalendar AssignedCalendar => CalendarItem.AssignedCalendar;
|
||||||
|
|
||||||
public DateTime StartDate { get => CalendarItem.StartDate; set => CalendarItem.StartDate = value; }
|
/// <summary>
|
||||||
|
/// Gets or sets the start date in local time based on the event's timezone.
|
||||||
|
/// The underlying CalendarItem stores dates in UTC.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
/// <summary>
|
||||||
|
/// Gets the end date in local time based on the event's timezone.
|
||||||
|
/// The underlying CalendarItem stores dates in UTC.
|
||||||
|
/// </summary>
|
||||||
|
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 double DurationInSeconds { get => CalendarItem.DurationInSeconds; set => CalendarItem.DurationInSeconds = value; }
|
||||||
|
|
||||||
public ITimePeriod Period => CalendarItem.Period;
|
/// <summary>
|
||||||
|
/// Gets the time period in local time.
|
||||||
|
/// </summary>
|
||||||
|
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 IsAllDayEvent => CalendarItem.IsAllDayEvent;
|
||||||
public bool IsMultiDayEvent => CalendarItem.IsMultiDayEvent;
|
public bool IsMultiDayEvent => CalendarItem.IsMultiDayEvent;
|
||||||
@@ -32,7 +90,7 @@ public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, IC
|
|||||||
public bool IsRecurringParent => CalendarItem.IsRecurringParent;
|
public bool IsRecurringParent => CalendarItem.IsRecurringParent;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool _isSelected;
|
public partial bool IsSelected { get; set; }
|
||||||
|
|
||||||
public ObservableCollection<CalendarEventAttendee> Attendees { get; } = new ObservableCollection<CalendarEventAttendee>();
|
public ObservableCollection<CalendarEventAttendee> Attendees { get; } = new ObservableCollection<CalendarEventAttendee>();
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Linq;
|
|
||||||
using Wino.Calendar.ViewModels.Data;
|
using Wino.Calendar.ViewModels.Data;
|
||||||
using Wino.Core.Domain.Entities.Shared;
|
|
||||||
|
|
||||||
namespace Wino.Calendar.ViewModels.Interfaces;
|
namespace Wino.Calendar.ViewModels.Interfaces;
|
||||||
|
|
||||||
@@ -26,5 +24,5 @@ public interface IAccountCalendarStateService : INotifyPropertyChanged
|
|||||||
/// Enumeration of currently selected calendars.
|
/// Enumeration of currently selected calendars.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
IEnumerable<AccountCalendarViewModel> ActiveCalendars { get; }
|
IEnumerable<AccountCalendarViewModel> ActiveCalendars { get; }
|
||||||
IEnumerable<IGrouping<MailAccount, AccountCalendarViewModel>> GroupedAccountCalendarsEnumerable { get; }
|
// IEnumerable<IGrouping<MailAccount, AccountCalendarViewModel>> GroupedAccountCalendarsEnumerable { get; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,6 @@ public class CalendarItem : ICalendarItem
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public TimeSpan StartDateOffset { get; set; }
|
|
||||||
public TimeSpan EndDateOffset { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// IANA timezone identifier for the start time (e.g., "America/New_York", "Europe/London").
|
/// IANA timezone identifier for the start time (e.g., "America/New_York", "Europe/London").
|
||||||
/// If null or empty, UTC is assumed.
|
/// If null or empty, UTC is assumed.
|
||||||
@@ -180,8 +177,6 @@ public class CalendarItem : ICalendarItem
|
|||||||
Status = Status,
|
Status = Status,
|
||||||
CustomEventColorHex = CustomEventColorHex,
|
CustomEventColorHex = CustomEventColorHex,
|
||||||
HtmlLink = HtmlLink,
|
HtmlLink = HtmlLink,
|
||||||
StartDateOffset = StartDateOffset,
|
|
||||||
EndDateOffset = EndDateOffset,
|
|
||||||
StartTimeZone = StartTimeZone,
|
StartTimeZone = StartTimeZone,
|
||||||
EndTimeZone = EndTimeZone,
|
EndTimeZone = EndTimeZone,
|
||||||
RemoteEventId = RemoteEventId,
|
RemoteEventId = RemoteEventId,
|
||||||
@@ -194,7 +189,7 @@ public class CalendarItem : ICalendarItem
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the start date as a DateTimeOffset with the correct timezone.
|
/// Gets the start date as a DateTimeOffset with the correct timezone.
|
||||||
/// If StartTimeZone is available, uses it to calculate the offset.
|
/// 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).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Ignore]
|
[Ignore]
|
||||||
public DateTimeOffset StartDateTimeOffset
|
public DateTimeOffset StartDateTimeOffset
|
||||||
@@ -206,24 +201,27 @@ public class CalendarItem : ICalendarItem
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(StartTimeZone);
|
var timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(StartTimeZone);
|
||||||
var offset = timeZoneInfo.GetUtcOffset(StartDate);
|
// StartDate is stored in UTC, convert to the specified timezone
|
||||||
return new DateTimeOffset(StartDate, offset);
|
var utcDateTime = DateTime.SpecifyKind(StartDate, DateTimeKind.Utc);
|
||||||
|
var zonedDateTime = TimeZoneInfo.ConvertTimeFromUtc(utcDateTime, timeZoneInfo);
|
||||||
|
var offset = timeZoneInfo.GetUtcOffset(zonedDateTime);
|
||||||
|
return new DateTimeOffset(zonedDateTime, offset);
|
||||||
}
|
}
|
||||||
catch
|
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
|
// Assume UTC (StartDate is stored as UTC in database)
|
||||||
return new DateTimeOffset(StartDate, StartDateOffset);
|
return new DateTimeOffset(StartDate, TimeSpan.Zero);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the end date as a DateTimeOffset with the correct timezone.
|
/// Gets the end date as a DateTimeOffset with the correct timezone.
|
||||||
/// If EndTimeZone is available, uses it to calculate the offset.
|
/// 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).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Ignore]
|
[Ignore]
|
||||||
public DateTimeOffset EndDateTimeOffset
|
public DateTimeOffset EndDateTimeOffset
|
||||||
@@ -235,17 +233,20 @@ public class CalendarItem : ICalendarItem
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(EndTimeZone);
|
var timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(EndTimeZone);
|
||||||
var offset = timeZoneInfo.GetUtcOffset(EndDate);
|
// EndDate is stored in UTC, convert to the specified timezone
|
||||||
return new DateTimeOffset(EndDate, offset);
|
var utcDateTime = DateTime.SpecifyKind(EndDate, DateTimeKind.Utc);
|
||||||
|
var zonedDateTime = TimeZoneInfo.ConvertTimeFromUtc(utcDateTime, timeZoneInfo);
|
||||||
|
var offset = timeZoneInfo.GetUtcOffset(zonedDateTime);
|
||||||
|
return new DateTimeOffset(zonedDateTime, offset);
|
||||||
}
|
}
|
||||||
catch
|
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
|
// Assume UTC (EndDate is stored as UTC in database)
|
||||||
return new DateTimeOffset(EndDate, EndDateOffset);
|
return new DateTimeOffset(EndDate, TimeSpan.Zero);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Itenso.TimePeriod;
|
||||||
using Wino.Core.Domain.Entities.Calendar;
|
using Wino.Core.Domain.Entities.Calendar;
|
||||||
using Wino.Core.Domain.Models.Calendar;
|
using Wino.Core.Domain.Models.Calendar;
|
||||||
|
|
||||||
@@ -16,7 +17,15 @@ public interface ICalendarService
|
|||||||
Task InsertAccountCalendarAsync(AccountCalendar accountCalendar);
|
Task InsertAccountCalendarAsync(AccountCalendar accountCalendar);
|
||||||
Task UpdateAccountCalendarAsync(AccountCalendar accountCalendar);
|
Task UpdateAccountCalendarAsync(AccountCalendar accountCalendar);
|
||||||
Task CreateNewCalendarItemAsync(CalendarItem calendarItem, List<CalendarEventAttendee> attendees);
|
Task CreateNewCalendarItemAsync(CalendarItem calendarItem, List<CalendarEventAttendee> attendees);
|
||||||
Task<List<CalendarItem>> GetCalendarEventsAsync(IAccountCalendar calendar, DayRangeRenderModel dayRangeRenderModel);
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves calendar events for a given calendar within the specified time period.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="calendar">The calendar to retrieve events from.</param>
|
||||||
|
/// <param name="period">The time period to query events for.</param>
|
||||||
|
/// <returns>List of calendar items including regular events and recurring event occurrences.</returns>
|
||||||
|
Task<List<CalendarItem>> GetCalendarEventsAsync(IAccountCalendar calendar, ITimePeriod period);
|
||||||
|
|
||||||
Task<CalendarItem> GetCalendarItemAsync(Guid accountCalendarId, string remoteEventId);
|
Task<CalendarItem> GetCalendarItemAsync(Guid accountCalendarId, string remoteEventId);
|
||||||
Task UpdateCalendarDeltaSynchronizationToken(Guid calendarId, string deltaToken);
|
Task UpdateCalendarDeltaSynchronizationToken(Guid calendarId, string deltaToken);
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ public interface IStatePersistanceService : INotifyPropertyChanged
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
bool IsBackButtonVisible { get; }
|
bool IsBackButtonVisible { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Current application mode (Mail or Calendar).
|
||||||
|
/// Not persisted to configuration, only kept in memory.
|
||||||
|
/// </summary>
|
||||||
|
WinoApplicationMode ApplicationMode { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether event details page is visible in Calendar mode.
|
||||||
|
/// </summary>
|
||||||
|
bool IsEventDetailsVisible { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Setting: Opened pane length for the navigation view.
|
/// Setting: Opened pane length for the navigation view.
|
||||||
|
|||||||
@@ -12,6 +12,6 @@ public interface INavigationService
|
|||||||
NavigationTransitionType transition = NavigationTransitionType.None);
|
NavigationTransitionType transition = NavigationTransitionType.None);
|
||||||
|
|
||||||
Type GetPageType(WinoPage winoPage);
|
Type GetPageType(WinoPage winoPage);
|
||||||
void GoBack();
|
|
||||||
bool ChangeApplicationMode(WinoApplicationMode mode);
|
bool ChangeApplicationMode(WinoApplicationMode mode);
|
||||||
|
void GoBack();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ public class DayRangeRenderModel
|
|||||||
public List<DayHeaderRenderModel> DayHeaders { get; } = [];
|
public List<DayHeaderRenderModel> DayHeaders { get; } = [];
|
||||||
public CalendarRenderOptions CalendarRenderOptions { get; }
|
public CalendarRenderOptions CalendarRenderOptions { get; }
|
||||||
|
|
||||||
|
public int TotalDays => CalendarRenderOptions.TotalDayCount;
|
||||||
|
|
||||||
public DayRangeRenderModel(CalendarRenderOptions calendarRenderOptions)
|
public DayRangeRenderModel(CalendarRenderOptions calendarRenderOptions)
|
||||||
{
|
{
|
||||||
CalendarRenderOptions = calendarRenderOptions;
|
CalendarRenderOptions = calendarRenderOptions;
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
global using System;
|
||||||
|
global using System.Collections.Generic;
|
||||||
|
global using System.Linq;
|
||||||
|
global using System.Threading.Tasks;
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory database service for testing purposes.
|
||||||
|
/// Creates a temporary SQLite database in memory that is destroyed after tests complete.
|
||||||
|
/// </summary>
|
||||||
|
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<MailCopy>(),
|
||||||
|
Connection.CreateTableAsync<MailItemFolder>(),
|
||||||
|
Connection.CreateTableAsync<MailAccount>(),
|
||||||
|
Connection.CreateTableAsync<AccountContact>(),
|
||||||
|
Connection.CreateTableAsync<CustomServerInformation>(),
|
||||||
|
Connection.CreateTableAsync<AccountSignature>(),
|
||||||
|
Connection.CreateTableAsync<MergedInbox>(),
|
||||||
|
Connection.CreateTableAsync<MailAccountPreferences>(),
|
||||||
|
Connection.CreateTableAsync<MailAccountAlias>(),
|
||||||
|
Connection.CreateTableAsync<Thumbnail>(),
|
||||||
|
Connection.CreateTableAsync<KeyboardShortcut>(),
|
||||||
|
Connection.CreateTableAsync<AccountCalendar>(),
|
||||||
|
Connection.CreateTableAsync<CalendarEventAttendee>(),
|
||||||
|
Connection.CreateTableAsync<CalendarItem>(),
|
||||||
|
Connection.CreateTableAsync<Reminder>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (Connection != null)
|
||||||
|
{
|
||||||
|
await Connection.CloseAsync();
|
||||||
|
Connection = null!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for CalendarService, focusing on the GetCalendarEventsAsync method
|
||||||
|
/// which handles both regular and recurring events with RFC 5545 patterns.
|
||||||
|
/// </summary>
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<Platforms>x86;x64;arm64</Platforms>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||||
|
<PackageReference Include="xunit" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="FluentAssertions" />
|
||||||
|
<PackageReference Include="Moq" />
|
||||||
|
<PackageReference Include="Ical.Net" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Wino.Services\Wino.Services.csproj" />
|
||||||
|
<ProjectReference Include="..\Wino.Core.Domain\Wino.Core.Domain.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -215,7 +215,9 @@ public static class OutlookIntegratorExtensions
|
|||||||
{
|
{
|
||||||
if (recurrence.Range.Type == RecurrenceRangeType.EndDate && recurrence.Range.EndDate != null)
|
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)
|
else if (recurrence.Range.Type == RecurrenceRangeType.Numbered && recurrence.Range.NumberOfOccurrences.HasValue)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -96,9 +96,7 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso
|
|||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
Description = calendarEvent.Description ?? parentRecurringEvent.Description,
|
Description = calendarEvent.Description ?? parentRecurringEvent.Description,
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
StartDate = eventStartDateTimeOffset.Value.DateTime,
|
StartDate = eventStartDateTimeOffset.Value.UtcDateTime,
|
||||||
StartDateOffset = eventStartDateTimeOffset.Value.Offset,
|
|
||||||
EndDateOffset = eventEndDateTimeOffset?.Offset ?? parentRecurringEvent.EndDateOffset,
|
|
||||||
DurationInSeconds = totalDurationInSeconds,
|
DurationInSeconds = totalDurationInSeconds,
|
||||||
Location = string.IsNullOrEmpty(calendarEvent.Location) ? parentRecurringEvent.Location : calendarEvent.Location,
|
Location = string.IsNullOrEmpty(calendarEvent.Location) ? parentRecurringEvent.Location : calendarEvent.Location,
|
||||||
|
|
||||||
@@ -136,9 +134,7 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso
|
|||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
Description = calendarEvent.Description,
|
Description = calendarEvent.Description,
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
StartDate = eventStartDateTimeOffset.Value.DateTime,
|
StartDate = eventStartDateTimeOffset.Value.UtcDateTime,
|
||||||
StartDateOffset = eventStartDateTimeOffset.Value.Offset,
|
|
||||||
EndDateOffset = eventEndDateTimeOffset.Value.Offset,
|
|
||||||
DurationInSeconds = totalDurationInSeconds,
|
DurationInSeconds = totalDurationInSeconds,
|
||||||
Location = calendarEvent.Location,
|
Location = calendarEvent.Location,
|
||||||
|
|
||||||
|
|||||||
@@ -63,14 +63,14 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
|
|||||||
|
|
||||||
var durationInSeconds = (eventEndDateTimeOffset - eventStartDateTimeOffset).TotalSeconds;
|
var durationInSeconds = (eventEndDateTimeOffset - eventStartDateTimeOffset).TotalSeconds;
|
||||||
|
|
||||||
|
// Store dates as UTC in the database
|
||||||
savingItem.RemoteEventId = calendarEvent.Id;
|
savingItem.RemoteEventId = calendarEvent.Id;
|
||||||
savingItem.StartDate = eventStartDateTimeOffset.DateTime;
|
savingItem.StartDate = eventStartDateTimeOffset.UtcDateTime;
|
||||||
savingItem.StartDateOffset = eventStartDateTimeOffset.Offset;
|
|
||||||
savingItem.EndDateOffset = eventEndDateTimeOffset.Offset;
|
|
||||||
savingItem.DurationInSeconds = durationInSeconds;
|
savingItem.DurationInSeconds = durationInSeconds;
|
||||||
|
|
||||||
// Store the timezone information from the event
|
// Store the timezone information from the event
|
||||||
// This preserves the original timezone from Outlook, allowing proper reconstruction later
|
// 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.StartTimeZone = calendarEvent.Start?.TimeZone;
|
||||||
savingItem.EndTimeZone = calendarEvent.End?.TimeZone;
|
savingItem.EndTimeZone = calendarEvent.End?.TimeZone;
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@
|
|||||||
<StackPanel
|
<StackPanel
|
||||||
x:Name="AttributeStack"
|
x:Name="AttributeStack"
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
Margin="0,4,0,0"
|
Margin="0,4,4,0"
|
||||||
HorizontalAlignment="Right"
|
HorizontalAlignment="Right"
|
||||||
VerticalAlignment="Top"
|
VerticalAlignment="Top"
|
||||||
Orientation="Horizontal"
|
Orientation="Horizontal"
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using CommunityToolkit.Mvvm.Messaging;
|
using CommunityToolkit.Mvvm.Messaging;
|
||||||
|
using CommunityToolkit.WinUI;
|
||||||
using Itenso.TimePeriod;
|
using Itenso.TimePeriod;
|
||||||
using Microsoft.UI.Xaml;
|
using Microsoft.UI.Xaml;
|
||||||
using Microsoft.UI.Xaml.Controls;
|
using Microsoft.UI.Xaml.Controls;
|
||||||
using Microsoft.UI.Xaml.Input;
|
using Microsoft.UI.Xaml.Input;
|
||||||
|
using Microsoft.UI.Xaml.Media;
|
||||||
using Wino.Calendar.ViewModels.Data;
|
using Wino.Calendar.ViewModels.Data;
|
||||||
using Wino.Calendar.ViewModels.Messages;
|
using Wino.Calendar.ViewModels.Messages;
|
||||||
using Wino.Core.Domain;
|
using Wino.Core.Domain;
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using CommunityToolkit.WinUI;
|
||||||
|
using Microsoft.UI.Xaml;
|
||||||
|
using Microsoft.UI.Xaml.Controls;
|
||||||
|
using Wino.Calendar.Controls;
|
||||||
|
using Wino.Core.Domain.Models.Calendar;
|
||||||
|
|
||||||
|
namespace Wino.Mail.WinUI.Controls.Calendar;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AOT-Safe ItemsControl for use in UniformGrid panels.
|
||||||
|
/// </summary>
|
||||||
|
///
|
||||||
|
public partial class UniformItemsControl : Grid
|
||||||
|
{
|
||||||
|
[GeneratedDependencyProperty]
|
||||||
|
public partial DayRangeRenderModel? RenderModel { get; set; }
|
||||||
|
|
||||||
|
[GeneratedDependencyProperty]
|
||||||
|
public partial List<CalendarDayModel>? ItemsSource { get; set; }
|
||||||
|
|
||||||
|
partial void OnRenderModelChanged(DayRangeRenderModel? newValue)
|
||||||
|
{
|
||||||
|
if (newValue == null || ItemsSource == null) return;
|
||||||
|
|
||||||
|
AdjustColumns();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnItemsSourceChanged(List<CalendarDayModel>? 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<UniformGrid>(this);
|
||||||
|
|
||||||
|
// //if (uniGrid != null)
|
||||||
|
// //{
|
||||||
|
// // uniGrid.Columns = newValue.TotalDays;
|
||||||
|
// //}
|
||||||
|
// }
|
||||||
|
//}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
|
using CommunityToolkit.WinUI;
|
||||||
using Microsoft.Graphics.Canvas.Geometry;
|
using Microsoft.Graphics.Canvas.Geometry;
|
||||||
using Microsoft.Graphics.Canvas.UI.Xaml;
|
using Microsoft.Graphics.Canvas.UI.Xaml;
|
||||||
using Microsoft.UI.Input;
|
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.
|
// 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 positionerRootPoint = e.GetCurrentPoint(PositionerUIElement);
|
||||||
PointerPoint canvasPointerPoint = e.GetCurrentPoint(Canvas);
|
PointerPoint canvasPointerPoint = e.GetCurrentPoint(Canvas);
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ namespace Wino.Calendar.Selectors;
|
|||||||
|
|
||||||
public partial class CustomAreaCalendarItemSelector : DataTemplateSelector
|
public partial class CustomAreaCalendarItemSelector : DataTemplateSelector
|
||||||
{
|
{
|
||||||
public DataTemplate AllDayTemplate { get; set; }
|
public DataTemplate? AllDayTemplate { get; set; }
|
||||||
public DataTemplate MultiDayTemplate { 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)
|
if (item is CalendarItemViewModel calendarItemViewModel)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ using System.Linq;
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using Wino.Calendar.ViewModels.Data;
|
using Wino.Calendar.ViewModels.Data;
|
||||||
using Wino.Calendar.ViewModels.Interfaces;
|
using Wino.Calendar.ViewModels.Interfaces;
|
||||||
using Wino.Core.Domain.Entities.Shared;
|
|
||||||
|
|
||||||
namespace Wino.Mail.WinUI.Services;
|
namespace Wino.Mail.WinUI.Services;
|
||||||
|
|
||||||
@@ -33,16 +32,16 @@ public partial class AccountCalendarStateService : ObservableObject, IAccountCal
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public IEnumerable<IGrouping<MailAccount, AccountCalendarViewModel>> GroupedAccountCalendarsEnumerable
|
//public IEnumerable<IGrouping<MailAccount, AccountCalendarViewModel>> GroupedAccountCalendarsEnumerable
|
||||||
{
|
//{
|
||||||
get
|
// get
|
||||||
{
|
// {
|
||||||
return GroupedAccountCalendars
|
// return GroupedAccountCalendars
|
||||||
.Select(a => a.AccountCalendars)
|
// .Select(a => a.AccountCalendars)
|
||||||
.SelectMany(b => b)
|
// .SelectMany(b => b)
|
||||||
.GroupBy(c => c.Account);
|
// .GroupBy(c => c.Account);
|
||||||
}
|
// }
|
||||||
}
|
//}
|
||||||
|
|
||||||
public AccountCalendarStateService()
|
public AccountCalendarStateService()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ public class NavigationService : NavigationServiceBase, INavigationService
|
|||||||
|
|
||||||
if (coreFrame == null) return false;
|
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 targetPageType = mode == WinoApplicationMode.Mail ? typeof(MailAppShell) : typeof(CalendarAppShell);
|
||||||
var currentPageType = coreFrame.Content?.GetType();
|
var currentPageType = coreFrame.Content?.GetType();
|
||||||
var transitionInfo = GetNavigationTransitionInfo(NavigationTransitionType.DrillIn);
|
var transitionInfo = GetNavigationTransitionInfo(NavigationTransitionType.DrillIn);
|
||||||
@@ -128,18 +131,27 @@ public class NavigationService : NavigationServiceBase, INavigationService
|
|||||||
NavigationTransitionType transition = NavigationTransitionType.None)
|
NavigationTransitionType transition = NavigationTransitionType.None)
|
||||||
{
|
{
|
||||||
var pageType = GetPageType(page);
|
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.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);
|
// Calendar navigations.
|
||||||
bool isCalendarShellActive = shellFrame.Content != null && shellFrame.Content.GetType() == typeof(CalendarAppShell);
|
if (currentApplicationMode == WinoApplicationMode.Calendar)
|
||||||
if (isCalendarShellActive)
|
|
||||||
{
|
{
|
||||||
return shellFrame.Navigate(pageType, parameter);
|
return innerShellFrame.Navigate(pageType, parameter);
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Mail navigations.
|
||||||
|
var currentFrameType = GetCurrentFrameType(ref innerShellFrame);
|
||||||
bool isMailListingPageActive = currentFrameType != null && currentFrameType == typeof(MailListPage);
|
bool isMailListingPageActive = currentFrameType != null && currentFrameType == typeof(MailListPage);
|
||||||
|
|
||||||
// Active page is mail list page and we are refreshing the folder.
|
// Active page is mail list page and we are refreshing the folder.
|
||||||
@@ -187,14 +199,36 @@ public class NavigationService : NavigationServiceBase, INavigationService
|
|||||||
|
|
||||||
if ((currentFrameType != null && currentFrameType != pageType) || currentFrameType == null)
|
if ((currentFrameType != null && currentFrameType != pageType) || currentFrameType == null)
|
||||||
{
|
{
|
||||||
return shellFrame.Navigate(pageType, parameter, transitionInfo);
|
return innerShellFrame.Navigate(pageType, parameter, transitionInfo);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
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.
|
// Standalone EML viewer.
|
||||||
//public void NavigateRendering(MimeMessageInformation mimeMessageInformation, NavigationTransitionType transition = NavigationTransitionType.None)
|
//public void NavigateRendering(MimeMessageInformation mimeMessageInformation, NavigationTransitionType transition = NavigationTransitionType.None)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ namespace Wino.Services;
|
|||||||
|
|
||||||
public class StatePersistenceService : ObservableObject, IStatePersistanceService
|
public class StatePersistenceService : ObservableObject, IStatePersistanceService
|
||||||
{
|
{
|
||||||
public event EventHandler<string> StatePropertyChanged;
|
public event EventHandler<string?>? StatePropertyChanged;
|
||||||
|
|
||||||
private const string OpenPaneLengthKey = nameof(OpenPaneLengthKey);
|
private const string OpenPaneLengthKey = nameof(OpenPaneLengthKey);
|
||||||
private const string MailListPaneLengthKey = nameof(MailListPaneLengthKey);
|
private const string MailListPaneLengthKey = nameof(MailListPaneLengthKey);
|
||||||
@@ -28,9 +28,40 @@ public class StatePersistenceService : ObservableObject, IStatePersistanceServic
|
|||||||
PropertyChanged += ServicePropertyChanged;
|
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;
|
private bool isReadingMail;
|
||||||
|
|
||||||
@@ -68,7 +99,7 @@ public class StatePersistenceService : ObservableObject, IStatePersistanceServic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string coreWindowTitle;
|
private string coreWindowTitle = string.Empty;
|
||||||
|
|
||||||
public string CoreWindowTitle
|
public string CoreWindowTitle
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ using Microsoft.UI.Xaml.Controls;
|
|||||||
using Windows.UI;
|
using Windows.UI;
|
||||||
using Wino.Core.Domain.Interfaces;
|
using Wino.Core.Domain.Interfaces;
|
||||||
using Wino.Mail.WinUI.Interfaces;
|
using Wino.Mail.WinUI.Interfaces;
|
||||||
using Wino.Messaging.Client.Mails;
|
|
||||||
using Wino.Messaging.Client.Shell;
|
using Wino.Messaging.Client.Shell;
|
||||||
using Wino.Messaging.UI;
|
using Wino.Messaging.UI;
|
||||||
using Wino.Views;
|
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)
|
private void BackButtonClicked(Microsoft.UI.Xaml.Controls.TitleBar sender, object args)
|
||||||
{
|
{
|
||||||
WeakReferenceMessenger.Default.Send(new ClearMailSelectionsRequested());
|
NavigationService.GoBack();
|
||||||
WeakReferenceMessenger.Default.Send(new DisposeRenderingFrameRequested());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void MainFrameNavigated(object sender, Microsoft.UI.Xaml.Navigation.NavigationEventArgs e)
|
private void MainFrameNavigated(object sender, Microsoft.UI.Xaml.Navigation.NavigationEventArgs e)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
x:Class="Wino.Styles.WinoCalendarResources"
|
x:Class="Wino.Styles.WinoCalendarResources"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:calendar="using:Wino.Mail.WinUI.Controls.Calendar"
|
||||||
xmlns:controls="using:Wino.Calendar.Controls"
|
xmlns:controls="using:Wino.Calendar.Controls"
|
||||||
xmlns:controls2="using:Wino.Mail.WinUI.Controls"
|
xmlns:controls2="using:Wino.Mail.WinUI.Controls"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
@@ -54,7 +55,6 @@
|
|||||||
<!-- Horizontal template -->
|
<!-- Horizontal template -->
|
||||||
<DataTemplate x:Key="FlipTemplate" x:DataType="models:DayRangeRenderModel">
|
<DataTemplate x:Key="FlipTemplate" x:DataType="models:DayRangeRenderModel">
|
||||||
<Grid
|
<Grid
|
||||||
x:Name="RootGrid"
|
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
ColumnSpacing="0"
|
ColumnSpacing="0"
|
||||||
RowSpacing="0">
|
RowSpacing="0">
|
||||||
@@ -63,6 +63,7 @@
|
|||||||
<RowDefinition Height="*" />
|
<RowDefinition Height="*" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
<!-- TODO: Not AOT safe. -->
|
||||||
<ItemsControl Margin="50,0,16,0" ItemsSource="{x:Bind CalendarDays}">
|
<ItemsControl Margin="50,0,16,0" ItemsSource="{x:Bind CalendarDays}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="models:CalendarDayModel">
|
<DataTemplate x:DataType="models:CalendarDayModel">
|
||||||
@@ -97,7 +98,6 @@
|
|||||||
<controls:WinoDayTimelineCanvas
|
<controls:WinoDayTimelineCanvas
|
||||||
Grid.Column="1"
|
Grid.Column="1"
|
||||||
HalfHourSeperatorColor="{ThemeResource CalendarSeperatorBrush}"
|
HalfHourSeperatorColor="{ThemeResource CalendarSeperatorBrush}"
|
||||||
PositionerUIElement="{Binding ElementName=RootGrid}"
|
|
||||||
RenderOptions="{x:Bind CalendarRenderOptions}"
|
RenderOptions="{x:Bind CalendarRenderOptions}"
|
||||||
SelectedCellBackgroundBrush="{ThemeResource CalendarFieldSelectedBackgroundBrush}"
|
SelectedCellBackgroundBrush="{ThemeResource CalendarFieldSelectedBackgroundBrush}"
|
||||||
SeperatorColor="{ThemeResource CalendarSeperatorBrush}"
|
SeperatorColor="{ThemeResource CalendarSeperatorBrush}"
|
||||||
@@ -112,6 +112,8 @@
|
|||||||
ItemsSource="{x:Bind CalendarDays}">
|
ItemsSource="{x:Bind CalendarDays}">
|
||||||
<ItemsControl.ItemsPanel>
|
<ItemsControl.ItemsPanel>
|
||||||
<ItemsPanelTemplate>
|
<ItemsPanelTemplate>
|
||||||
|
<!-- Columns="{Binding CalendarRenderOptions.TotalDayCount}" -->
|
||||||
|
<!-- TODO: Columns should come from TotalDayCount to support custom dates. -->
|
||||||
<toolkitControls:UniformGrid
|
<toolkitControls:UniformGrid
|
||||||
Columns="{Binding CalendarRenderOptions.TotalDayCount}"
|
Columns="{Binding CalendarRenderOptions.TotalDayCount}"
|
||||||
Orientation="Horizontal"
|
Orientation="Horizontal"
|
||||||
@@ -277,6 +279,7 @@
|
|||||||
Margin="0,6">
|
Margin="0,6">
|
||||||
<ItemsControl.ItemTemplateSelector>
|
<ItemsControl.ItemTemplateSelector>
|
||||||
<selectors:CustomAreaCalendarItemSelector>
|
<selectors:CustomAreaCalendarItemSelector>
|
||||||
|
<!-- TODO: DisplayingDate is not AOT safe. -->
|
||||||
<selectors:CustomAreaCalendarItemSelector.AllDayTemplate>
|
<selectors:CustomAreaCalendarItemSelector.AllDayTemplate>
|
||||||
<DataTemplate x:DataType="data:CalendarItemViewModel">
|
<DataTemplate x:DataType="data:CalendarItemViewModel">
|
||||||
<controls:CalendarItemControl
|
<controls:CalendarItemControl
|
||||||
|
|||||||
@@ -3,4 +3,10 @@ using Wino.Mail.WinUI;
|
|||||||
|
|
||||||
namespace Wino.Calendar.Views.Abstract;
|
namespace Wino.Calendar.Views.Abstract;
|
||||||
|
|
||||||
public abstract class CalendarPageAbstract : BasePage<CalendarPageViewModel> { }
|
public abstract class CalendarPageAbstract : BasePage<CalendarPageViewModel>
|
||||||
|
{
|
||||||
|
protected CalendarPageAbstract()
|
||||||
|
{
|
||||||
|
NavigationCacheMode = Microsoft.UI.Xaml.Navigation.NavigationCacheMode.Enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -156,6 +156,7 @@
|
|||||||
<calendarControls:WinoCalendarView
|
<calendarControls:WinoCalendarView
|
||||||
x:Name="CalendarView"
|
x:Name="CalendarView"
|
||||||
Grid.Row="0"
|
Grid.Row="0"
|
||||||
|
Margin="0,12,0,0"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
DateClickedCommand="{x:Bind ViewModel.DateClickedCommand}"
|
DateClickedCommand="{x:Bind ViewModel.DateClickedCommand}"
|
||||||
HighlightedDateRange="{x:Bind ViewModel.HighlightedDateRange, Mode=OneWay}"
|
HighlightedDateRange="{x:Bind ViewModel.HighlightedDateRange, Mode=OneWay}"
|
||||||
|
|||||||
@@ -18,9 +18,9 @@
|
|||||||
|
|
||||||
<Page.Resources>
|
<Page.Resources>
|
||||||
<CollectionViewSource
|
<CollectionViewSource
|
||||||
x:Key="GroupedCalendarEnumerableViewSource"
|
x:Name="GroupedCalendarEnumerableViewSource"
|
||||||
IsSourceGrouped="True"
|
IsSourceGrouped="True"
|
||||||
Source="{x:Bind ViewModel.AccountCalendarStateService.GroupedAccountCalendarsEnumerable, Mode=OneWay}" />
|
Source="{x:Bind ViewModel.AccountCalendarStateService.GroupedAccountCalendars, Mode=OneWay}" />
|
||||||
</Page.Resources>
|
</Page.Resources>
|
||||||
|
|
||||||
<Border
|
<Border
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
<ListView
|
<ListView
|
||||||
MaxHeight="300"
|
MaxHeight="300"
|
||||||
HorizontalAlignment="Stretch"
|
HorizontalAlignment="Stretch"
|
||||||
ItemsSource="{Binding Source={StaticResource GroupedCalendarEnumerableViewSource}}"
|
ItemsSource="{x:Bind GroupedCalendarEnumerableViewSource.View, Mode=OneWay}"
|
||||||
SelectedItem="{x:Bind ViewModel.SelectedQuickEventAccountCalendar, Mode=TwoWay}"
|
SelectedItem="{x:Bind ViewModel.SelectedQuickEventAccountCalendar, Mode=TwoWay}"
|
||||||
SelectionChanged="QuickEventAccountSelectorSelectionChanged">
|
SelectionChanged="QuickEventAccountSelectorSelectionChanged">
|
||||||
<ListView.ItemTemplate>
|
<ListView.ItemTemplate>
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ public sealed partial class CalendarPage : CalendarPageAbstract,
|
|||||||
public CalendarPage()
|
public CalendarPage()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
NavigationCacheMode = NavigationCacheMode.Enabled;
|
|
||||||
|
|
||||||
ViewModel.DetailsShowCalendarItemChanged += CalendarItemDetailContextChanged;
|
ViewModel.DetailsShowCalendarItemChanged += CalendarItemDetailContextChanged;
|
||||||
}
|
}
|
||||||
@@ -40,6 +39,26 @@ public sealed partial class CalendarPage : CalendarPageAbstract,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override void RegisterRecipients()
|
||||||
|
{
|
||||||
|
base.RegisterRecipients();
|
||||||
|
|
||||||
|
WeakReferenceMessenger.Default.Register<ScrollToDateMessage>(this);
|
||||||
|
WeakReferenceMessenger.Default.Register<ScrollToHourMessage>(this);
|
||||||
|
WeakReferenceMessenger.Default.Register<GoNextDateRequestedMessage>(this);
|
||||||
|
WeakReferenceMessenger.Default.Register<GoPreviousDateRequestedMessage>(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void UnregisterRecipients()
|
||||||
|
{
|
||||||
|
base.UnregisterRecipients();
|
||||||
|
|
||||||
|
WeakReferenceMessenger.Default.Unregister<ScrollToDateMessage>(this);
|
||||||
|
WeakReferenceMessenger.Default.Unregister<ScrollToHourMessage>(this);
|
||||||
|
WeakReferenceMessenger.Default.Unregister<GoNextDateRequestedMessage>(this);
|
||||||
|
WeakReferenceMessenger.Default.Unregister<GoPreviousDateRequestedMessage>(this);
|
||||||
|
}
|
||||||
|
|
||||||
public void Receive(ScrollToHourMessage message) => CalendarControl.NavigateToHour(message.TimeSpan);
|
public void Receive(ScrollToHourMessage message) => CalendarControl.NavigateToHour(message.TimeSpan);
|
||||||
public void Receive(ScrollToDateMessage message) => CalendarControl.NavigateToDay(message.Date);
|
public void Receive(ScrollToDateMessage message) => CalendarControl.NavigateToDay(message.Date);
|
||||||
public void Receive(GoNextDateRequestedMessage message) => CalendarControl.GoNextRange();
|
public void Receive(GoNextDateRequestedMessage message) => CalendarControl.GoNextRange();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
|
|||||||
using CommunityToolkit.Mvvm.Messaging;
|
using CommunityToolkit.Mvvm.Messaging;
|
||||||
using Ical.Net.CalendarComponents;
|
using Ical.Net.CalendarComponents;
|
||||||
using Ical.Net.DataTypes;
|
using Ical.Net.DataTypes;
|
||||||
|
using Itenso.TimePeriod;
|
||||||
using Wino.Core.Domain;
|
using Wino.Core.Domain;
|
||||||
using Wino.Core.Domain.Entities.Calendar;
|
using Wino.Core.Domain.Entities.Calendar;
|
||||||
using Wino.Core.Domain.Enums;
|
using Wino.Core.Domain.Enums;
|
||||||
@@ -90,82 +91,115 @@ public class CalendarService : BaseDatabaseService, ICalendarService
|
|||||||
WeakReferenceMessenger.Default.Send(new CalendarItemAdded(calendarItem));
|
WeakReferenceMessenger.Default.Send(new CalendarItemAdded(calendarItem));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<CalendarItem>> GetCalendarEventsAsync(IAccountCalendar calendar, DayRangeRenderModel dayRangeRenderModel)
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="calendar">The calendar to retrieve events from.</param>
|
||||||
|
/// <param name="period">The time period to query events for.</param>
|
||||||
|
/// <returns>List of calendar items including regular events and recurring event occurrences.</returns>
|
||||||
|
public async Task<List<CalendarItem>> GetCalendarEventsAsync(IAccountCalendar calendar, ITimePeriod period)
|
||||||
{
|
{
|
||||||
// TODO: We might need to implement caching here.
|
// TODO: Implement caching strategy for better performance with large event sets.
|
||||||
// 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.
|
// Consider using a cache keyed by calendar ID and time period.
|
||||||
|
|
||||||
var accountEvents = await Connection.Table<CalendarItem>()
|
var accountEvents = await Connection.Table<CalendarItem>()
|
||||||
.Where(x => x.CalendarId == calendar.Id && !x.IsHidden).ToListAsync();
|
.Where(x => x.CalendarId == calendar.Id && !x.IsHidden)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
var result = new List<CalendarItem>();
|
var result = new List<CalendarItem>();
|
||||||
|
|
||||||
foreach (var ev in accountEvents)
|
foreach (var calendarItem in accountEvents)
|
||||||
{
|
{
|
||||||
ev.AssignedCalendar = calendar;
|
calendarItem.AssignedCalendar = calendar;
|
||||||
|
|
||||||
// Parse recurrence rules
|
// Skip exception instances - they will be handled by their parent recurring event
|
||||||
var calendarEvent = new CalendarEvent
|
if (calendarItem.RecurringCalendarItemId.HasValue)
|
||||||
{
|
{
|
||||||
Start = new CalDateTime(ev.StartDate),
|
continue;
|
||||||
End = new CalDateTime(ev.EndDate),
|
}
|
||||||
};
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(ev.Recurrence))
|
if (string.IsNullOrEmpty(calendarItem.Recurrence))
|
||||||
{
|
{
|
||||||
// No recurrence, only check if we fall into the given period.
|
// Regular non-recurring event - simply check if it overlaps with the requested period.
|
||||||
|
if (calendarItem.Period.OverlapsWith(period))
|
||||||
if (ev.Period.OverlapsWith(dayRangeRenderModel.Period))
|
|
||||||
{
|
{
|
||||||
result.Add(ev);
|
result.Add(calendarItem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// This event has recurrences.
|
// Recurring event - expand occurrences within the period.
|
||||||
// Wino stores exceptional recurrent events as a separate calendar item, without the recurrence rule.
|
// Wino stores recurring events as a series master with RFC 5545 recurrence rules.
|
||||||
// Because each instance of recurrent event can have different attendees, properties etc.
|
// Exception instances (modified or cancelled) are stored separately and linked via RecurringCalendarItemId.
|
||||||
// Even though the event is recurrent, each updated instance is a separate calendar item.
|
var expandedOccurrences = await ExpandRecurringEventAsync(calendarItem, period);
|
||||||
// Calculate the all recurrences, and remove the exceptional instances like hidden ones.
|
result.AddRange(expandedOccurrences);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var recurrenceLines = Regex.Split(ev.Recurrence, Constants.CalendarEventRecurrenceRuleSeperator);
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Expands a recurring event into its occurrences within the specified period.
|
||||||
|
/// Handles exception instances (modified or cancelled occurrences) by excluding them from the expansion.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="recurringEvent">The recurring event series master.</param>
|
||||||
|
/// <param name="period">The time period to expand occurrences within.</param>
|
||||||
|
/// <returns>List of calendar items representing individual occurrences in the period.</returns>
|
||||||
|
private async Task<List<CalendarItem>> ExpandRecurringEventAsync(CalendarItem recurringEvent, ITimePeriod period)
|
||||||
|
{
|
||||||
|
var result = new List<CalendarItem>();
|
||||||
|
|
||||||
|
// 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)
|
foreach (var line in recurrenceLines)
|
||||||
{
|
{
|
||||||
calendarEvent.RecurrenceRules.Add(new RecurrencePattern(line));
|
calendarEvent.RecurrenceRules.Add(new RecurrencePattern(line));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate occurrences in the range.
|
// Calculate all occurrences in the requested period using iCal.NET.
|
||||||
var occurrences = calendarEvent.GetOccurrences(dayRangeRenderModel.Period.Start, dayRangeRenderModel.Period.End);
|
var occurrences = calendarEvent.GetOccurrences(period.Start, period.End);
|
||||||
|
|
||||||
// Get all recurrent exceptional calendar events.
|
// Retrieve exception instances (modified or cancelled occurrences).
|
||||||
var exceptionalRecurrences = await Connection.Table<CalendarItem>()
|
// These are stored as separate CalendarItem records with RecurringCalendarItemId set.
|
||||||
.Where(a => a.RecurringCalendarItemId == ev.Id)
|
var exceptionInstances = await Connection.Table<CalendarItem>()
|
||||||
|
.Where(a => a.RecurringCalendarItemId == recurringEvent.Id)
|
||||||
.ToListAsync()
|
.ToListAsync()
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
foreach (var occurrence in occurrences)
|
foreach (var occurrence in occurrences)
|
||||||
{
|
{
|
||||||
var exactInstanceCheck = exceptionalRecurrences.FirstOrDefault(a =>
|
// Check if this occurrence has been modified/cancelled (exception instance exists).
|
||||||
a.Period.OverlapsWith(dayRangeRenderModel.Period));
|
// 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);
|
||||||
|
|
||||||
if (exactInstanceCheck == null)
|
var exceptionInstance = exceptionInstances.FirstOrDefault(a =>
|
||||||
|
a.StartDate <= occurrenceEnd && a.EndDate >= occurrenceStart);
|
||||||
|
|
||||||
|
if (exceptionInstance == null)
|
||||||
{
|
{
|
||||||
// There is no exception for the period.
|
// No exception - create a virtual occurrence from the series master.
|
||||||
// Change the instance StartDate and Duration.
|
var occurrenceItem = recurringEvent.CreateRecurrence(
|
||||||
|
occurrenceStart,
|
||||||
|
occurrence.Period.Duration.TotalSeconds);
|
||||||
|
|
||||||
var recurrence = ev.CreateRecurrence(occurrence.Period.StartTime.Value, occurrence.Period.Duration.TotalSeconds);
|
result.Add(occurrenceItem);
|
||||||
|
|
||||||
result.Add(recurrence);
|
|
||||||
}
|
}
|
||||||
else
|
else if (!exceptionInstance.IsHidden && exceptionInstance.Period.OverlapsWith(period))
|
||||||
{
|
{
|
||||||
// There is a single instance of this recurrent event.
|
// Exception exists and is not hidden - include the modified version.
|
||||||
// It will be added as single item if it's not hidden.
|
exceptionInstance.AssignedCalendar = recurringEvent.AssignedCalendar;
|
||||||
// We don't need to do anything here.
|
result.Add(exceptionInstance);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// If exception is hidden, skip this occurrence entirely.
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -55,6 +55,11 @@
|
|||||||
</Project>
|
</Project>
|
||||||
<Project Path="Wino.SourceGenerators/Wino.SourceGenerators.csproj" />
|
<Project Path="Wino.SourceGenerators/Wino.SourceGenerators.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
<Project Path="Wino.Core.Tests/Wino.Core.Tests.csproj">
|
||||||
|
<Platform Solution="*|arm64" Project="arm64" />
|
||||||
|
<Platform Solution="*|x64" Project="x64" />
|
||||||
|
<Platform Solution="*|x86" Project="x86" />
|
||||||
|
</Project>
|
||||||
<Project Path="Wino.Mail.WinUI/Wino.Mail.WinUI.csproj" Id="bf340564-2cc8-486d-924d-8474cb5f3316">
|
<Project Path="Wino.Mail.WinUI/Wino.Mail.WinUI.csproj" Id="bf340564-2cc8-486d-924d-8474cb5f3316">
|
||||||
<Platform Solution="*|arm64" Project="ARM64" />
|
<Platform Solution="*|arm64" Project="ARM64" />
|
||||||
<Platform Solution="*|x64" Project="x64" />
|
<Platform Solution="*|x64" Project="x64" />
|
||||||
|
|||||||
Reference in New Issue
Block a user