Single isntances and some updates shit.
This commit is contained in:
@@ -906,14 +906,39 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
|
||||
base.OnCalendarItemUpdated(calendarItem);
|
||||
Debug.WriteLine($"Calendar item updated: {calendarItem.Id}");
|
||||
|
||||
// Updated item might've been from specific time to all-day event, or vice versa.
|
||||
// Remove the event and its occurrences from all visible date ranges first.
|
||||
// Series master events should not be visible on the UI.
|
||||
if (calendarItem.IsRecurringParent)
|
||||
{
|
||||
Debug.WriteLine($"Skipping series master event update: {calendarItem.Title}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (DayRanges.DisplayRange == null) return;
|
||||
|
||||
// Find all days that currently have this item and days that should have it after update
|
||||
var currentDaysWithItem = DayRanges
|
||||
.SelectMany(a => a.CalendarDays)
|
||||
.Where(day => day.EventsCollection.GetCalendarItem(calendarItem.Id) != null)
|
||||
.ToList();
|
||||
|
||||
var targetDaysForItem = DayRanges
|
||||
.SelectMany(a => a.CalendarDays)
|
||||
.Where(a => a.Period.OverlapsWith(calendarItem.Period))
|
||||
.ToList();
|
||||
|
||||
await ExecuteUIThread(() =>
|
||||
{
|
||||
foreach (var dayRange in DayRanges)
|
||||
// Update existing items in-place where the item should remain
|
||||
foreach (var calendarDay in currentDaysWithItem)
|
||||
{
|
||||
foreach (var calendarDay in dayRange.CalendarDays)
|
||||
if (targetDaysForItem.Contains(calendarDay))
|
||||
{
|
||||
// Item should stay in this day - update in-place
|
||||
calendarDay.EventsCollection.UpdateCalendarItem(calendarItem);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Item should no longer be in this day (time changed) - remove it
|
||||
var existingItem = calendarDay.EventsCollection.GetCalendarItem(calendarItem.Id);
|
||||
if (existingItem != null)
|
||||
{
|
||||
@@ -921,36 +946,17 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Re-add to corresponding day ranges to render properly.
|
||||
// Check if event falls into the current date range.
|
||||
if (DayRanges.DisplayRange == null) return;
|
||||
|
||||
// Get all periods from the visible day ranges
|
||||
var visiblePeriods = DayRanges.Select(dr => dr.Period).ToList();
|
||||
|
||||
// For recurring events, expand them to check if any occurrences fall within visible periods
|
||||
// For regular events, just check if they overlap with any period
|
||||
var matchingItems = await _calendarService.GetExpandedRecurringEventsForPeriodsAsync(calendarItem, visiblePeriods);
|
||||
|
||||
foreach (var item in matchingItems)
|
||||
{
|
||||
// Find the days that the event falls into
|
||||
var allDaysForEvent = DayRanges
|
||||
.SelectMany(a => a.CalendarDays)
|
||||
.Where(a => a.Period.OverlapsWith(item.Period));
|
||||
|
||||
foreach (var calendarDay in allDaysForEvent)
|
||||
// Add to new days where the item wasn't present before
|
||||
foreach (var calendarDay in targetDaysForItem)
|
||||
{
|
||||
var calendarItemViewModel = new CalendarItemViewModel(item);
|
||||
|
||||
await ExecuteUIThread(() =>
|
||||
if (!currentDaysWithItem.Contains(calendarDay))
|
||||
{
|
||||
var calendarItemViewModel = new CalendarItemViewModel(calendarItem);
|
||||
calendarDay.EventsCollection.AddCalendarItem(calendarItemViewModel);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
FilterActiveCalendars(DayRanges);
|
||||
}
|
||||
@@ -960,6 +966,14 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
|
||||
base.OnCalendarItemAdded(calendarItem);
|
||||
Debug.WriteLine($"Calendar item added: {calendarItem.Id}");
|
||||
|
||||
// Series master events should not be visible on the UI.
|
||||
// Their instances are already expanded and synced individually.
|
||||
if (calendarItem.IsRecurringParent)
|
||||
{
|
||||
Debug.WriteLine($"Skipping series master event: {calendarItem.Title}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if event falls into the current date range.
|
||||
if (DayRanges.DisplayRange == null) return;
|
||||
|
||||
@@ -1005,28 +1019,20 @@ public partial class CalendarPageViewModel : CalendarBaseViewModel,
|
||||
}
|
||||
|
||||
// Get all periods from the visible day ranges
|
||||
var visiblePeriods = DayRanges.Select(dr => dr.Period).ToList();
|
||||
// Note: Recurring event occurrences are now synced from server as individual instances
|
||||
// No local expansion needed - just check if this item overlaps with visible periods
|
||||
var allDaysForEvent = DayRanges
|
||||
.SelectMany(a => a.CalendarDays)
|
||||
.Where(a => a.Period.OverlapsWith(calendarItem.Period));
|
||||
|
||||
// For recurring events, expand them to check if any occurrences fall within visible periods
|
||||
// For regular events, just check if they overlap with any period
|
||||
var matchingItems = await _calendarService.GetExpandedRecurringEventsForPeriodsAsync(calendarItem, visiblePeriods);
|
||||
|
||||
foreach (var item in matchingItems)
|
||||
foreach (var calendarDay in allDaysForEvent)
|
||||
{
|
||||
// Find the days that the event falls into
|
||||
var allDaysForEvent = DayRanges
|
||||
.SelectMany(a => a.CalendarDays)
|
||||
.Where(a => a.Period.OverlapsWith(item.Period));
|
||||
var calendarItemViewModel = new CalendarItemViewModel(calendarItem);
|
||||
|
||||
foreach (var calendarDay in allDaysForEvent)
|
||||
await ExecuteUIThread(() =>
|
||||
{
|
||||
var calendarItemViewModel = new CalendarItemViewModel(item);
|
||||
|
||||
await ExecuteUIThread(() =>
|
||||
{
|
||||
calendarDay.EventsCollection.AddCalendarItem(calendarItemViewModel);
|
||||
});
|
||||
}
|
||||
calendarDay.EventsCollection.AddCalendarItem(calendarItemViewModel);
|
||||
});
|
||||
}
|
||||
|
||||
FilterActiveCalendars(DayRanges);
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Itenso.TimePeriod;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
|
||||
namespace Wino.Calendar.ViewModels.Data;
|
||||
|
||||
@@ -89,6 +91,35 @@ public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, IC
|
||||
[ObservableProperty]
|
||||
public partial bool IsSelected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The period of the day where this item is currently being displayed.
|
||||
/// Used for multi-day event title formatting.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(DisplayTitle))]
|
||||
public partial ITimePeriod DisplayingPeriod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Calendar settings for time formatting.
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(DisplayTitle))]
|
||||
public partial CalendarSettings CalendarSettings { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display title based on the current displaying period.
|
||||
/// </summary>
|
||||
public string DisplayTitle
|
||||
{
|
||||
get
|
||||
{
|
||||
if (DisplayingPeriod == null || CalendarSettings == null)
|
||||
return Title;
|
||||
|
||||
return GetDisplayTitle(DisplayingPeriod, CalendarSettings);
|
||||
}
|
||||
}
|
||||
|
||||
public ObservableCollection<CalendarEventAttendee> Attendees { get; } = new ObservableCollection<CalendarEventAttendee>();
|
||||
|
||||
public CalendarItemViewModel(CalendarItem calendarItem)
|
||||
@@ -96,5 +127,82 @@ public partial class CalendarItemViewModel : ObservableObject, ICalendarItem, IC
|
||||
CalendarItem = calendarItem;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the underlying CalendarItem with new data and raises property change notifications.
|
||||
/// </summary>
|
||||
/// <param name="calendarItem">The updated calendar item data.</param>
|
||||
public void UpdateFrom(CalendarItem calendarItem)
|
||||
{
|
||||
if (calendarItem == null || calendarItem.Id != CalendarItem.Id)
|
||||
return;
|
||||
|
||||
// Update all mutable properties
|
||||
CalendarItem.Title = calendarItem.Title;
|
||||
CalendarItem.Description = calendarItem.Description;
|
||||
CalendarItem.Location = calendarItem.Location;
|
||||
CalendarItem.StartDate = calendarItem.StartDate;
|
||||
CalendarItem.StartTimeZone = calendarItem.StartTimeZone;
|
||||
CalendarItem.EndTimeZone = calendarItem.EndTimeZone;
|
||||
CalendarItem.DurationInSeconds = calendarItem.DurationInSeconds;
|
||||
CalendarItem.Recurrence = calendarItem.Recurrence;
|
||||
CalendarItem.RecurringCalendarItemId = calendarItem.RecurringCalendarItemId;
|
||||
CalendarItem.OrganizerDisplayName = calendarItem.OrganizerDisplayName;
|
||||
CalendarItem.OrganizerEmail = calendarItem.OrganizerEmail;
|
||||
CalendarItem.IsLocked = calendarItem.IsLocked;
|
||||
CalendarItem.IsHidden = calendarItem.IsHidden;
|
||||
CalendarItem.CustomEventColorHex = calendarItem.CustomEventColorHex;
|
||||
CalendarItem.HtmlLink = calendarItem.HtmlLink;
|
||||
CalendarItem.Status = calendarItem.Status;
|
||||
CalendarItem.Visibility = calendarItem.Visibility;
|
||||
CalendarItem.ShowAs = calendarItem.ShowAs;
|
||||
CalendarItem.UpdatedAt = calendarItem.UpdatedAt;
|
||||
CalendarItem.AssignedCalendar = calendarItem.AssignedCalendar;
|
||||
|
||||
// Raise property changed for all bindable properties
|
||||
OnPropertyChanged(nameof(Title));
|
||||
OnPropertyChanged(nameof(StartDate));
|
||||
OnPropertyChanged(nameof(EndDate));
|
||||
OnPropertyChanged(nameof(DurationInSeconds));
|
||||
OnPropertyChanged(nameof(Period));
|
||||
OnPropertyChanged(nameof(IsAllDayEvent));
|
||||
OnPropertyChanged(nameof(IsMultiDayEvent));
|
||||
OnPropertyChanged(nameof(IsRecurringEvent));
|
||||
OnPropertyChanged(nameof(IsRecurringChild));
|
||||
OnPropertyChanged(nameof(IsRecurringParent));
|
||||
OnPropertyChanged(nameof(AssignedCalendar));
|
||||
OnPropertyChanged(nameof(DisplayTitle));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display title for this calendar item when rendered in a specific day.
|
||||
/// </summary>
|
||||
public string GetDisplayTitle(ITimePeriod displayingPeriod, CalendarSettings calendarSettings)
|
||||
{
|
||||
if (!IsMultiDayEvent)
|
||||
return Title;
|
||||
|
||||
var periodRelation = Period.GetRelation(displayingPeriod);
|
||||
|
||||
if (periodRelation == PeriodRelation.StartInside || periodRelation == PeriodRelation.EnclosingStartTouching)
|
||||
{
|
||||
// Event starts within this day: "HH:mm -> Title"
|
||||
return $"{calendarSettings.GetTimeString(StartDate.TimeOfDay)} -> {Title}";
|
||||
}
|
||||
else if (periodRelation == PeriodRelation.EndInside || periodRelation == PeriodRelation.EnclosingEndTouching)
|
||||
{
|
||||
// Event ends within this day: "Title <- HH:mm"
|
||||
return $"{Title} <- {calendarSettings.GetTimeString(EndDate.TimeOfDay)}";
|
||||
}
|
||||
else if (periodRelation == PeriodRelation.Enclosing)
|
||||
{
|
||||
// Event spans the entire day
|
||||
return $"{Translator.CalendarItemAllDay} {Title}";
|
||||
}
|
||||
else
|
||||
{
|
||||
return Title;
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() => CalendarItem.Title;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ public class CalendarEventCollection
|
||||
{
|
||||
public event EventHandler<ICalendarItem> CalendarItemAdded;
|
||||
public event EventHandler<ICalendarItem> CalendarItemRemoved;
|
||||
public event EventHandler<ICalendarItem> CalendarItemUpdated;
|
||||
|
||||
public event EventHandler CalendarItemsCleared;
|
||||
|
||||
@@ -116,9 +117,13 @@ public class CalendarEventCollection
|
||||
|
||||
private void AddCalendarItemInternal(ObservableRangeCollection<ICalendarItem> collection, ICalendarItem calendarItem, bool create = true)
|
||||
{
|
||||
if (calendarItem is not ICalendarItemViewModel)
|
||||
if (calendarItem is not ICalendarItemViewModel viewModel)
|
||||
throw new ArgumentException("CalendarItem must be of type ICalendarItemViewModel", nameof(calendarItem));
|
||||
|
||||
// Set the displaying context for proper title calculation
|
||||
viewModel.DisplayingPeriod = Period;
|
||||
viewModel.CalendarSettings = Settings;
|
||||
|
||||
collection.Add(calendarItem);
|
||||
|
||||
if (create)
|
||||
@@ -144,6 +149,53 @@ public class CalendarEventCollection
|
||||
CalendarItemRemoved?.Invoke(this, calendarItem);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing calendar item in-place. If the item's type changed (all-day vs regular),
|
||||
/// it will be moved to the appropriate collection.
|
||||
/// </summary>
|
||||
/// <param name="calendarItem">The updated calendar item data.</param>
|
||||
/// <returns>True if the item was found and updated; false otherwise.</returns>
|
||||
public bool UpdateCalendarItem(CalendarItem calendarItem)
|
||||
{
|
||||
var existingItem = _allItems.FirstOrDefault(x => x.Id == calendarItem.Id);
|
||||
if (existingItem == null)
|
||||
return false;
|
||||
|
||||
// Get the collections this item is currently in (before update)
|
||||
var oldCollections = GetProperCollectionsForCalendarItem(existingItem).ToList();
|
||||
|
||||
// Update the underlying data
|
||||
if (existingItem is ICalendarItemViewModel viewModel)
|
||||
{
|
||||
viewModel.UpdateFrom(calendarItem);
|
||||
}
|
||||
|
||||
// Get the collections this item should be in (after update)
|
||||
var newCollections = GetProperCollectionsForCalendarItem(existingItem).ToList();
|
||||
|
||||
// Check if the collections changed
|
||||
var collectionsToRemoveFrom = oldCollections.Except(newCollections).ToList();
|
||||
var collectionsToAddTo = newCollections.Except(oldCollections).ToList();
|
||||
|
||||
// Remove from old collections that are no longer applicable
|
||||
foreach (var collection in collectionsToRemoveFrom)
|
||||
{
|
||||
collection.Remove(existingItem);
|
||||
}
|
||||
|
||||
// Add to new collections that are now applicable
|
||||
foreach (var collection in collectionsToAddTo)
|
||||
{
|
||||
if (!collection.Contains(existingItem))
|
||||
{
|
||||
collection.Add(existingItem);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarItemUpdated?.Invoke(this, existingItem);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_internalAllDayEvents.Clear();
|
||||
|
||||
@@ -4,6 +4,7 @@ using Itenso.TimePeriod;
|
||||
using SQLite;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
|
||||
namespace Wino.Core.Domain.Entities.Calendar;
|
||||
|
||||
@@ -72,9 +73,7 @@ public class CalendarItem : ICalendarItem
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Events that are either an exceptional instance of a recurring event or occurrences.
|
||||
/// IsOccurrence is used to display occurrence instances of parent recurring events.
|
||||
/// IsOccurrence == false && IsRecurringChild == true => exceptional single instance.
|
||||
/// Events that are child instances of a recurring event (occurrences or exceptions).
|
||||
/// </summary>
|
||||
public bool IsRecurringChild
|
||||
{
|
||||
@@ -85,7 +84,7 @@ public class CalendarItem : ICalendarItem
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Events that are either an exceptional instance of a recurring event or occurrences.
|
||||
/// Events that are part of a recurring series (either as parent or child).
|
||||
/// </summary>
|
||||
public bool IsRecurringEvent => IsRecurringChild || IsRecurringParent;
|
||||
|
||||
@@ -140,12 +139,12 @@ public class CalendarItem : ICalendarItem
|
||||
public string HtmlLink { get; set; }
|
||||
public CalendarItemStatus Status { get; set; }
|
||||
public CalendarItemVisibility Visibility { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Indicates how the event should be shown in the calendar (Free, Busy, Tentative, etc.).
|
||||
/// </summary>
|
||||
public CalendarItemShowAs ShowAs { get; set; } = CalendarItemShowAs.Busy;
|
||||
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public Guid CalendarId { get; set; }
|
||||
@@ -154,51 +153,11 @@ public class CalendarItem : ICalendarItem
|
||||
public IAccountCalendar AssignedCalendar { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this item does not really exist in the database or not.
|
||||
/// These are used to display occurrence instances of parent recurring events.
|
||||
/// Id to load information related to this event (attendees, reminders, etc.).
|
||||
/// For child events, if they have their own data, use their own Id.
|
||||
/// For events that share data with their parent, return parent's Id.
|
||||
/// </summary>
|
||||
[Ignore]
|
||||
public bool IsOccurrence { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Id to load information related to this event.
|
||||
/// Occurrences tracked by the parent recurring event if they are not exceptional instances.
|
||||
/// Recurring children here are exceptional instances. They have their own info in the database including Id.
|
||||
/// </summary>
|
||||
public Guid EventTrackingId => IsOccurrence ? RecurringCalendarItemId.Value : Id;
|
||||
|
||||
public CalendarItem CreateRecurrence(DateTime startDate, double durationInSeconds)
|
||||
{
|
||||
// Create a copy with the new start date and duration
|
||||
|
||||
return new CalendarItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Title = Title,
|
||||
Description = Description,
|
||||
Location = Location,
|
||||
StartDate = startDate,
|
||||
DurationInSeconds = durationInSeconds,
|
||||
Recurrence = Recurrence,
|
||||
OrganizerDisplayName = OrganizerDisplayName,
|
||||
OrganizerEmail = OrganizerEmail,
|
||||
RecurringCalendarItemId = Id,
|
||||
AssignedCalendar = AssignedCalendar,
|
||||
CalendarId = CalendarId,
|
||||
CreatedAt = CreatedAt,
|
||||
UpdatedAt = UpdatedAt,
|
||||
Visibility = Visibility,
|
||||
Status = Status,
|
||||
CustomEventColorHex = CustomEventColorHex,
|
||||
HtmlLink = HtmlLink,
|
||||
StartTimeZone = StartTimeZone,
|
||||
EndTimeZone = EndTimeZone,
|
||||
RemoteEventId = RemoteEventId,
|
||||
IsHidden = IsHidden,
|
||||
IsLocked = IsLocked,
|
||||
IsOccurrence = true
|
||||
};
|
||||
}
|
||||
public Guid EventTrackingId => Id;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the start date converted to user's local timezone for display.
|
||||
@@ -219,10 +178,10 @@ public class CalendarItem : ICalendarItem
|
||||
{
|
||||
var sourceTimeZone = TimeZoneInfo.FindSystemTimeZoneById(StartTimeZone);
|
||||
var localTimeZone = TimeZoneInfo.Local;
|
||||
|
||||
|
||||
// Ensure DateTime is Unspecified kind before conversion
|
||||
var unspecifiedDateTime = DateTime.SpecifyKind(StartDate, DateTimeKind.Unspecified);
|
||||
|
||||
|
||||
// Convert from source timezone to local timezone
|
||||
return TimeZoneInfo.ConvertTime(unspecifiedDateTime, sourceTimeZone, localTimeZone);
|
||||
}
|
||||
@@ -253,10 +212,10 @@ public class CalendarItem : ICalendarItem
|
||||
{
|
||||
var sourceTimeZone = TimeZoneInfo.FindSystemTimeZoneById(EndTimeZone);
|
||||
var localTimeZone = TimeZoneInfo.Local;
|
||||
|
||||
|
||||
// Ensure DateTime is Unspecified kind before conversion
|
||||
var unspecifiedDateTime = DateTime.SpecifyKind(EndDate, DateTimeKind.Unspecified);
|
||||
|
||||
|
||||
// Convert from source timezone to local timezone
|
||||
return TimeZoneInfo.ConvertTime(unspecifiedDateTime, sourceTimeZone, localTimeZone);
|
||||
}
|
||||
@@ -267,4 +226,6 @@ public class CalendarItem : ICalendarItem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string GetDisplayTitle(ITimePeriod displayingPeriod, CalendarSettings calendarSettings) => Period.ToString();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using Itenso.TimePeriod;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
|
||||
namespace Wino.Core.Domain.Interfaces;
|
||||
|
||||
@@ -19,4 +20,13 @@ public interface ICalendarItem
|
||||
bool IsRecurringChild { get; }
|
||||
bool IsRecurringParent { get; }
|
||||
bool IsRecurringEvent { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display title for this calendar item when rendered in a specific day.
|
||||
/// For multi-day events, includes start/end time indicators.
|
||||
/// </summary>
|
||||
/// <param name="displayingPeriod">The period of the day where this item is being rendered.</param>
|
||||
/// <param name="calendarSettings">Calendar settings for time formatting.</param>
|
||||
/// <returns>The formatted title string.</returns>
|
||||
string GetDisplayTitle(ITimePeriod displayingPeriod, CalendarSettings calendarSettings);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
namespace Wino.Core.Domain.Interfaces;
|
||||
using Itenso.TimePeriod;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Models.Calendar;
|
||||
|
||||
namespace Wino.Core.Domain.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Temporarily to enforce CalendarItemViewModel. Used in CalendarEventCollection.
|
||||
@@ -6,4 +10,21 @@
|
||||
public interface ICalendarItemViewModel
|
||||
{
|
||||
bool IsSelected { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The period of the day where this item is currently being displayed.
|
||||
/// </summary>
|
||||
ITimePeriod DisplayingPeriod { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Calendar settings for time formatting.
|
||||
/// </summary>
|
||||
CalendarSettings CalendarSettings { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Updates the view model's underlying CalendarItem from new data.
|
||||
/// This allows in-place updates without removing and re-adding items.
|
||||
/// </summary>
|
||||
/// <param name="calendarItem">The updated calendar item data.</param>
|
||||
void UpdateFrom(CalendarItem calendarItem);
|
||||
}
|
||||
|
||||
@@ -24,16 +24,8 @@ public interface ICalendarService
|
||||
/// </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>
|
||||
/// <returns>List of calendar items that fall within the requested period.</returns>
|
||||
Task<List<CalendarItem>> GetCalendarEventsAsync(IAccountCalendar calendar, ITimePeriod period);
|
||||
|
||||
/// <summary>
|
||||
/// Expands a recurring calendar item to check if any of its occurrences fall within the given periods.
|
||||
/// </summary>
|
||||
/// <param name="calendarItem">The calendar item to expand (can be recurring or non-recurring).</param>
|
||||
/// <param name="periods">The list of periods to check against.</param>
|
||||
/// <returns>List of calendar items (either the original item or expanded recurrence instances) that fall within the periods.</returns>
|
||||
Task<List<CalendarItem>> GetExpandedRecurringEventsForPeriodsAsync(CalendarItem calendarItem, IEnumerable<ITimePeriod> periods);
|
||||
|
||||
Task<CalendarItem> GetCalendarItemAsync(Guid accountCalendarId, string remoteEventId);
|
||||
Task UpdateCalendarDeltaSynchronizationToken(Guid calendarId, string deltaToken);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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;
|
||||
@@ -10,8 +8,9 @@ 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.
|
||||
/// Tests for CalendarService, focusing on the GetCalendarEventsAsync method.
|
||||
/// Note: Recurring event occurrences are now synced from the server as individual instances,
|
||||
/// not calculated locally from recurrence patterns.
|
||||
/// </summary>
|
||||
public class CalendarServiceTests : IAsyncLifetime
|
||||
{
|
||||
@@ -119,185 +118,76 @@ public class CalendarServiceTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCalendarEventsAsync_WithDailyRecurringEvent_ReturnsMultipleOccurrences()
|
||||
public async Task GetCalendarEventsAsync_WithRecurringEventInstances_ReturnsAllInstancesInPeriod()
|
||||
{
|
||||
// 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
|
||||
// Arrange - Simulate synced recurring event instances (as they would come from server)
|
||||
var parentId = Guid.NewGuid();
|
||||
var startDate1 = new DateTime(2025, 1, 15, 10, 0, 0, DateTimeKind.Utc);
|
||||
var startDate2 = new DateTime(2025, 1, 16, 10, 0, 0, DateTimeKind.Utc);
|
||||
var startDate3 = new DateTime(2025, 1, 17, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// Parent series master (typically hidden or outside display range)
|
||||
var parentEvent = new CalendarItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Id = parentId,
|
||||
Title = "Daily Standup",
|
||||
Description = "Daily team sync",
|
||||
StartDate = startDate,
|
||||
StartDate = startDate1,
|
||||
DurationInSeconds = 1800, // 30 minutes
|
||||
CalendarId = _testCalendar.Id,
|
||||
IsHidden = false,
|
||||
// Daily recurrence pattern (RFC 5545)
|
||||
Recurrence = "RRULE:FREQ=DAILY;COUNT=5"
|
||||
Recurrence = "RRULE:FREQ=DAILY;COUNT=3"
|
||||
};
|
||||
|
||||
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
|
||||
// Individual occurrence instances (as synced from server)
|
||||
var instance1 = 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,
|
||||
Title = "Daily Standup",
|
||||
StartDate = startDate1,
|
||||
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,
|
||||
RecurringCalendarItemId = parentId,
|
||||
IsHidden = false
|
||||
};
|
||||
|
||||
await _calendarService.CreateNewCalendarItemAsync(modifiedException, null);
|
||||
var instance2 = new CalendarItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Title = "Daily Standup",
|
||||
StartDate = startDate2,
|
||||
DurationInSeconds = 1800,
|
||||
CalendarId = _testCalendar.Id,
|
||||
RecurringCalendarItemId = parentId,
|
||||
IsHidden = false
|
||||
};
|
||||
|
||||
var instance3 = new CalendarItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Title = "Daily Standup",
|
||||
StartDate = startDate3,
|
||||
DurationInSeconds = 1800,
|
||||
CalendarId = _testCalendar.Id,
|
||||
RecurringCalendarItemId = parentId,
|
||||
IsHidden = false
|
||||
};
|
||||
|
||||
await _calendarService.CreateNewCalendarItemAsync(parentEvent, null);
|
||||
await _calendarService.CreateNewCalendarItemAsync(instance1, null);
|
||||
await _calendarService.CreateNewCalendarItemAsync(instance2, null);
|
||||
await _calendarService.CreateNewCalendarItemAsync(instance3, 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));
|
||||
new DateTime(2025, 1, 18, 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();
|
||||
// Assert - Should return parent + 3 instances = 4 total
|
||||
result.Should().HaveCount(4, "parent event plus 3 instances");
|
||||
result.Where(e => e.Title == "Daily Standup").Should().HaveCount(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -415,163 +305,46 @@ public class CalendarServiceTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCalendarEventsAsync_WithRecurringEventWithUNTIL_StopsAfterUntilDate()
|
||||
public async Task GetCalendarEventsAsync_WithRecurringChildEvent_ReturnsChildAsRecurringChild()
|
||||
{
|
||||
// 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
|
||||
// Arrange - Create a parent and child event
|
||||
var parentId = Guid.NewGuid();
|
||||
var parentEvent = new CalendarItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
RemoteEventId = "event-with-until-123",
|
||||
Title = "Weekly Meeting (Until Nov 13)",
|
||||
StartDate = startDate,
|
||||
DurationInSeconds = 3600, // 1 hour
|
||||
Id = parentId,
|
||||
Title = "Parent Recurring Event",
|
||||
StartDate = new DateTime(2025, 1, 15, 10, 0, 0, DateTimeKind.Utc),
|
||||
DurationInSeconds = 3600,
|
||||
CalendarId = _testCalendar.Id,
|
||||
IsHidden = false,
|
||||
// Weekly on Fridays, until November 13, 2025
|
||||
Recurrence = "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=FR;UNTIL=20251113"
|
||||
Recurrence = "RRULE:FREQ=DAILY"
|
||||
};
|
||||
|
||||
// Event without UNTIL - continues indefinitely
|
||||
var eventWithoutUntil = new CalendarItem
|
||||
var childEvent = new CalendarItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
RemoteEventId = "event-without-until-456",
|
||||
Title = "Weekly Meeting (No End)",
|
||||
StartDate = startDate,
|
||||
DurationInSeconds = 3600, // 1 hour
|
||||
Title = "Occurrence Instance",
|
||||
StartDate = new DateTime(2025, 1, 16, 10, 0, 0, DateTimeKind.Utc),
|
||||
DurationInSeconds = 3600,
|
||||
CalendarId = _testCalendar.Id,
|
||||
IsHidden = false,
|
||||
// Weekly on Fridays, no end date
|
||||
Recurrence = "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=FR"
|
||||
RecurringCalendarItemId = parentId,
|
||||
IsHidden = false
|
||||
};
|
||||
|
||||
await _calendarService.CreateNewCalendarItemAsync(eventWithUntil, null);
|
||||
await _calendarService.CreateNewCalendarItemAsync(eventWithoutUntil, null);
|
||||
await _calendarService.CreateNewCalendarItemAsync(parentEvent, null);
|
||||
await _calendarService.CreateNewCalendarItemAsync(childEvent, 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));
|
||||
var period = new TimeRange(
|
||||
new DateTime(2025, 1, 16, 0, 0, 0, DateTimeKind.Utc),
|
||||
new DateTime(2025, 1, 17, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
// Act
|
||||
var resultAfterUntil = await _calendarService.GetCalendarEventsAsync(_testCalendar, periodAfterUntil);
|
||||
var result = await _calendarService.GetCalendarEventsAsync(_testCalendar, period);
|
||||
|
||||
// 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");
|
||||
// Assert
|
||||
result.Should().HaveCount(1);
|
||||
result[0].Title.Should().Be("Occurrence Instance");
|
||||
result[0].IsRecurringChild.Should().BeTrue();
|
||||
result[0].RecurringCalendarItemId.Should().Be(parentId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +161,9 @@ public class GmailChangeProcessor : DefaultChangeProcessor, IGmailChangeProcesso
|
||||
// Hide canceled events.
|
||||
calendarItem.IsHidden = calendarItem.Status == CalendarItemStatus.Cancelled;
|
||||
|
||||
// Set assigned calendar for navigation properties to work.
|
||||
calendarItem.AssignedCalendar = assignedCalendar;
|
||||
|
||||
// Manage the recurring event id.
|
||||
if (parentRecurringEvent != null)
|
||||
{
|
||||
|
||||
@@ -42,11 +42,8 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
|
||||
|
||||
public async Task ManageCalendarEventAsync(Event calendarEvent, AccountCalendar assignedCalendar, MailAccount organizerAccount)
|
||||
{
|
||||
// We parse the occurrences based on the parent event.
|
||||
// There is literally no point to store them because
|
||||
// type=Exception events are the exceptional childs of recurrency parent event.
|
||||
|
||||
if (calendarEvent.Type == EventType.Occurrence) return;
|
||||
// All event types are now handled: SingleInstance, SeriesMaster, Occurrence, and Exception.
|
||||
// Occurrences from CalendarView are individual instances that are saved separately.
|
||||
|
||||
var savingItem = await CalendarService.GetCalendarItemAsync(assignedCalendar.Id, calendarEvent.Id);
|
||||
|
||||
@@ -81,10 +78,12 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
|
||||
savingItem.Description = calendarEvent.Body?.Content;
|
||||
savingItem.Location = calendarEvent.Location?.DisplayName;
|
||||
|
||||
if (calendarEvent.Type == EventType.Exception && !string.IsNullOrEmpty(calendarEvent.SeriesMasterId))
|
||||
// Handle recurring event relationships for both Exception and Occurrence types
|
||||
if ((calendarEvent.Type == EventType.Exception || calendarEvent.Type == EventType.Occurrence)
|
||||
&& !string.IsNullOrEmpty(calendarEvent.SeriesMasterId))
|
||||
{
|
||||
// This is a recurring event exception.
|
||||
// We need to find the parent event and set it as recurring event id.
|
||||
// This is a recurring event instance (either an exception or a regular occurrence).
|
||||
// Link it to the parent series master.
|
||||
|
||||
var parentEvent = await CalendarService.GetCalendarItemAsync(assignedCalendar.Id, calendarEvent.SeriesMasterId);
|
||||
|
||||
@@ -94,12 +93,14 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning($"Parent recurring event is missing for event. Skipping creation of {calendarEvent.Id}");
|
||||
return;
|
||||
// Parent not found yet - this can happen if occurrences sync before the series master.
|
||||
// We still save the event but without the parent link for now.
|
||||
Log.Warning($"Parent recurring event (SeriesMasterId: {calendarEvent.SeriesMasterId}) not found for event {calendarEvent.Id}. Event will be saved without parent link.");
|
||||
}
|
||||
}
|
||||
|
||||
// Convert the recurrence pattern to string for parent recurring events.
|
||||
// Note: We store this for reference but don't use it to calculate occurrences.
|
||||
if (calendarEvent.Type == EventType.SeriesMaster && calendarEvent.Recurrence != null)
|
||||
{
|
||||
savingItem.Recurrence = OutlookIntegratorExtensions.ToRfc5545RecurrenceString(calendarEvent.Recurrence);
|
||||
@@ -234,6 +235,9 @@ public class OutlookChangeProcessor(IDatabaseService databaseService,
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// Set assigned calendar for navigation properties to work.
|
||||
savingItem.AssignedCalendar = assignedCalendar;
|
||||
|
||||
// Use CalendarService to create or update the event
|
||||
if (isNewItem)
|
||||
{
|
||||
|
||||
@@ -392,7 +392,10 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
{
|
||||
var request = _calendarService.Events.List(calendar.RemoteCalendarId);
|
||||
|
||||
request.SingleEvents = false;
|
||||
// Fetch individual event instances (including recurring event occurrences)
|
||||
// rather than recurring event masters. This ensures we get all occurrences
|
||||
// as separate events that can be stored and displayed directly.
|
||||
request.SingleEvents = true;
|
||||
request.ShowDeleted = true;
|
||||
|
||||
if (!string.IsNullOrEmpty(calendar.SynchronizationDeltaToken))
|
||||
@@ -1975,6 +1978,31 @@ public class GmailSynchronizer : WinoSynchronizer<IClientServiceRequest, Message
|
||||
return [new HttpRequestBundle<IClientServiceRequest>(updateRequest, request)];
|
||||
}
|
||||
|
||||
public override List<IRequestBundle<IClientServiceRequest>> DeleteCalendarEvent(DeleteCalendarEventRequest request)
|
||||
{
|
||||
var calendarItem = request.Item;
|
||||
|
||||
// Get the calendar for this event
|
||||
var calendar = calendarItem.AssignedCalendar;
|
||||
if (calendar == null)
|
||||
{
|
||||
throw new InvalidOperationException("Calendar item must have an assigned calendar");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(calendarItem.RemoteEventId))
|
||||
{
|
||||
throw new InvalidOperationException("Cannot delete event without remote event ID");
|
||||
}
|
||||
|
||||
// Delete the event using Google Calendar API
|
||||
var deleteRequest = _calendarService.Events.Delete(calendar.RemoteCalendarId, calendarItem.RemoteEventId);
|
||||
|
||||
// Send cancellation notifications to attendees
|
||||
deleteRequest.SendUpdates = Google.Apis.Calendar.v3.EventsResource.DeleteRequest.SendUpdatesEnum.All;
|
||||
|
||||
return [new HttpRequestBundle<IClientServiceRequest>(deleteRequest, request)];
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public override async Task KillSynchronizerAsync()
|
||||
|
||||
@@ -1811,14 +1811,8 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
|
||||
var messageIteratorAsync = PageIterator<Event, Microsoft.Graph.Me.Calendars.Item.CalendarView.Delta.DeltaGetResponse>.CreatePageIterator(_graphClient, eventsDeltaResponse, (item) =>
|
||||
{
|
||||
// Skip occurrence events during initial sync - only sync master recurring events and single instances
|
||||
// Occurrences are individual instances of recurring events and will be generated from the seriesMaster
|
||||
if (item.Type == Microsoft.Graph.Models.EventType.Occurrence)
|
||||
{
|
||||
_logger.Debug("Skipping occurrence event {EventId} during initial sync", item.Id);
|
||||
return true; // Skip this occurrence
|
||||
}
|
||||
|
||||
// Include all event types: SingleInstance, SeriesMaster, Occurrence, and Exception
|
||||
// CalendarView already expands recurring events into individual occurrences
|
||||
events.Add(item);
|
||||
|
||||
return true;
|
||||
@@ -1835,10 +1829,6 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
|
||||
foreach (var item in events)
|
||||
{
|
||||
if (item.Id == "f275fdd0-8622-4e14-8f5d-b73d7f68018f")
|
||||
{
|
||||
|
||||
}
|
||||
// Declined events are returned as Deleted from the API.
|
||||
// There is no way to distinguish unfortunately atm.
|
||||
|
||||
@@ -2217,6 +2207,28 @@ public class OutlookSynchronizer : WinoSynchronizer<RequestInformation, Message,
|
||||
return [new HttpRequestBundle<RequestInformation>(updateRequest, request)];
|
||||
}
|
||||
|
||||
public override List<IRequestBundle<RequestInformation>> DeleteCalendarEvent(DeleteCalendarEventRequest request)
|
||||
{
|
||||
var calendarItem = request.Item;
|
||||
|
||||
// Get the calendar for this event
|
||||
var calendar = calendarItem.AssignedCalendar;
|
||||
if (calendar == null)
|
||||
{
|
||||
throw new InvalidOperationException("Calendar item must have an assigned calendar");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(calendarItem.RemoteEventId))
|
||||
{
|
||||
throw new InvalidOperationException("Cannot delete event without remote event ID");
|
||||
}
|
||||
|
||||
// Delete the event using Graph API
|
||||
var deleteRequest = _graphClient.Me.Calendars[calendar.RemoteCalendarId].Events[calendarItem.RemoteEventId].ToDeleteRequestInformation();
|
||||
|
||||
return [new HttpRequestBundle<RequestInformation>(deleteRequest, request)];
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public override async Task KillSynchronizerAsync()
|
||||
|
||||
@@ -384,7 +384,7 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
||||
nativeRequests.AddRange(UpdateCalendarEvent(group.ElementAt(0) as UpdateCalendarEventRequest));
|
||||
break;
|
||||
case CalendarSynchronizerOperation.DeleteEvent:
|
||||
// TODO: Implement DeleteCalendarEvent
|
||||
nativeRequests.AddRange(DeleteCalendarEvent(group.ElementAt(0) as DeleteCalendarEventRequest));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -511,6 +511,7 @@ public abstract class WinoSynchronizer<TBaseRequest, TMessageType, TCalendarEven
|
||||
|
||||
public virtual List<IRequestBundle<TBaseRequest>> CreateCalendarEvent(CreateCalendarEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
public virtual List<IRequestBundle<TBaseRequest>> UpdateCalendarEvent(UpdateCalendarEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
public virtual List<IRequestBundle<TBaseRequest>> DeleteCalendarEvent(DeleteCalendarEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
public virtual List<IRequestBundle<TBaseRequest>> AcceptEvent(AcceptEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
public virtual List<IRequestBundle<TBaseRequest>> DeclineEvent(DeclineEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
public virtual List<IRequestBundle<TBaseRequest>> OutlookDeclineEvent(OutlookDeclineEventRequest request) => throw new NotSupportedException(string.Format(Translator.Exception_UnsupportedSynchronizerOperation, this.GetType()));
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
DoubleTapped="ControlDoubleTapped"
|
||||
RightTapped="ControlRightTapped"
|
||||
Tapped="ControlTapped"
|
||||
ToolTipService.ToolTip="{x:Bind CalendarItemTitle, Mode=OneWay}">
|
||||
ToolTipService.ToolTip="{x:Bind CalendarItem.DisplayTitle, Mode=OneWay}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
@@ -57,7 +57,7 @@
|
||||
FontSize="13"
|
||||
Foreground="{x:Bind helpers:XamlHelpers.GetReadableTextColor(CalendarItem.AssignedCalendar.BackgroundColorHex), Mode=OneWay}"
|
||||
HorizontalTextAlignment="Center"
|
||||
Text="{x:Bind CalendarItemTitle, Mode=OneWay}"
|
||||
Text="{x:Bind CalendarItem.DisplayTitle, Mode=OneWay}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<!-- TODO: Event attributes -->
|
||||
|
||||
@@ -21,8 +21,6 @@ public sealed partial class CalendarItemControl : UserControl
|
||||
public static readonly DependencyProperty CalendarItemProperty = DependencyProperty.Register(nameof(CalendarItem), typeof(CalendarItemViewModel), typeof(CalendarItemControl), new PropertyMetadata(null, new PropertyChangedCallback(OnCalendarItemChanged)));
|
||||
public static readonly DependencyProperty IsDraggingProperty = DependencyProperty.Register(nameof(IsDragging), typeof(bool), typeof(CalendarItemControl), new PropertyMetadata(false));
|
||||
public static readonly DependencyProperty IsCustomEventAreaProperty = DependencyProperty.Register(nameof(IsCustomEventArea), typeof(bool), typeof(CalendarItemControl), new PropertyMetadata(false));
|
||||
public static readonly DependencyProperty CalendarItemTitleProperty = DependencyProperty.Register(nameof(CalendarItemTitle), typeof(string), typeof(CalendarItemControl), new PropertyMetadata(string.Empty));
|
||||
public static readonly DependencyProperty DisplayingDateProperty = DependencyProperty.Register(nameof(DisplayingDate), typeof(CalendarDayModel), typeof(CalendarItemControl), new PropertyMetadata(null, new PropertyChangedCallback(OnDisplayDateChanged)));
|
||||
|
||||
/// <summary>
|
||||
/// Whether the control is displaying as regular event or all-multi day area in the day control.
|
||||
@@ -33,22 +31,6 @@ public sealed partial class CalendarItemControl : UserControl
|
||||
set { SetValue(IsCustomEventAreaProperty, value); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Day that the calendar item is rendered at.
|
||||
/// It's needed for title manipulation and some other adjustments later on.
|
||||
/// </summary>
|
||||
public CalendarDayModel DisplayingDate
|
||||
{
|
||||
get { return (CalendarDayModel)GetValue(DisplayingDateProperty); }
|
||||
set { SetValue(DisplayingDateProperty, value); }
|
||||
}
|
||||
|
||||
public string CalendarItemTitle
|
||||
{
|
||||
get { return (string)GetValue(CalendarItemTitleProperty); }
|
||||
set { SetValue(CalendarItemTitleProperty, value); }
|
||||
}
|
||||
|
||||
public CalendarItemViewModel CalendarItem
|
||||
{
|
||||
get { return (CalendarItemViewModel)GetValue(CalendarItemProperty); }
|
||||
@@ -66,77 +48,14 @@ public sealed partial class CalendarItemControl : UserControl
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private static void OnDisplayDateChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (d is CalendarItemControl control)
|
||||
{
|
||||
control.UpdateControlVisuals();
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnCalendarItemChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (d is CalendarItemControl control)
|
||||
{
|
||||
control.UpdateControlVisuals();
|
||||
control.UpdateVisualStates();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateControlVisuals()
|
||||
{
|
||||
// Depending on the calendar item's duration and attributes, we might need to change the display title.
|
||||
// 1. Multi-Day events should display the start date and end date.
|
||||
// 2. Multi-Day events that occupy the whole day just shows 'all day'.
|
||||
// 3. Other events should display the title.
|
||||
|
||||
if (CalendarItem == null) return;
|
||||
if (DisplayingDate == null) return;
|
||||
|
||||
if (CalendarItem.IsMultiDayEvent)
|
||||
{
|
||||
// Multi day events are divided into 3 categories:
|
||||
// 1. All day events
|
||||
// 2. Events that started after the period.
|
||||
// 3. Events that started before the period and finishes within the period.
|
||||
|
||||
var periodRelation = CalendarItem.Period.GetRelation(DisplayingDate.Period);
|
||||
|
||||
if (periodRelation == Itenso.TimePeriod.PeriodRelation.StartInside ||
|
||||
periodRelation == PeriodRelation.EnclosingStartTouching)
|
||||
{
|
||||
// hour -> title
|
||||
CalendarItemTitle = $"{DisplayingDate.CalendarRenderOptions.CalendarSettings.GetTimeString(CalendarItem.StartDate.TimeOfDay)} -> {CalendarItem.Title}";
|
||||
}
|
||||
else if (
|
||||
periodRelation == PeriodRelation.EndInside ||
|
||||
periodRelation == PeriodRelation.EnclosingEndTouching)
|
||||
{
|
||||
// title <- hour
|
||||
CalendarItemTitle = $"{CalendarItem.Title} <- {DisplayingDate.CalendarRenderOptions.CalendarSettings.GetTimeString(CalendarItem.EndDate.TimeOfDay)}";
|
||||
}
|
||||
else if (periodRelation == PeriodRelation.Enclosing)
|
||||
{
|
||||
// This event goes all day and it's multi-day.
|
||||
// Item must be hidden in the calendar but displayed on the custom area at the top.
|
||||
|
||||
CalendarItemTitle = $"{Translator.CalendarItemAllDay} {CalendarItem.Title}";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Not expected, but there it is.
|
||||
CalendarItemTitle = CalendarItem.Title;
|
||||
}
|
||||
|
||||
// Debug.WriteLine($"{CalendarItem.Title} Period relation with {DisplayingDate.Period.ToString()}: {periodRelation}");
|
||||
}
|
||||
else
|
||||
{
|
||||
CalendarItemTitle = CalendarItem.Title;
|
||||
}
|
||||
|
||||
UpdateVisualStates();
|
||||
}
|
||||
|
||||
private void UpdateVisualStates()
|
||||
{
|
||||
if (CalendarItem == null) return;
|
||||
@@ -175,9 +94,9 @@ public sealed partial class CalendarItemControl : UserControl
|
||||
|
||||
await Task.Delay(100);
|
||||
|
||||
if (isSingleTap)
|
||||
if (isSingleTap && CalendarItem != null)
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send(new CalendarItemTappedMessage(CalendarItem, DisplayingDate));
|
||||
WeakReferenceMessenger.Default.Send(new CalendarItemTappedMessage(CalendarItem, null));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,12 @@ public class MailAuthenticatorConfiguration : IAuthenticatorConfig
|
||||
"mail.send",
|
||||
"Mail.Send.Shared",
|
||||
"Mail.ReadWrite.Shared",
|
||||
"User.Read"
|
||||
"User.Read",
|
||||
"Calendars.ReadBasic",
|
||||
"Calendars.ReadWrite",
|
||||
"Calendars.ReadWrite.Shared",
|
||||
"Calendars.Read",
|
||||
"Calendars.Read.Shared",
|
||||
];
|
||||
|
||||
public string GmailAuthenticatorClientId => "973025879644-s7b4ur9p3rlgop6a22u7iuptdc0brnrn.apps.googleusercontent.com";
|
||||
@@ -24,7 +29,10 @@ public class MailAuthenticatorConfiguration : IAuthenticatorConfig
|
||||
"https://mail.google.com/",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/gmail.labels",
|
||||
"https://www.googleapis.com/auth/userinfo.email"
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/calendar",
|
||||
"https://www.googleapis.com/auth/calendar.events",
|
||||
"https://www.googleapis.com/auth/calendar.settings.readonly",
|
||||
];
|
||||
|
||||
public string GmailTokenStoreIdentifier => "WinoMailGmailTokenStore";
|
||||
|
||||
@@ -31,10 +31,7 @@
|
||||
<ItemsControl.ItemTemplate>
|
||||
<!-- Default Calendar Item View Model Template -->
|
||||
<DataTemplate x:DataType="data:CalendarItemViewModel">
|
||||
<controls:CalendarItemControl
|
||||
CalendarItem="{x:Bind}"
|
||||
DisplayingDate="{Binding ElementName=RegularEventItemsControl, Path=DataContext}"
|
||||
IsCustomEventArea="False" />
|
||||
<controls:CalendarItemControl CalendarItem="{x:Bind}" IsCustomEventArea="False" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
<ItemsControl.ItemContainerTransitions>
|
||||
@@ -44,7 +41,8 @@
|
||||
</ItemsControl.ItemContainerTransitions>
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<controls:WinoCalendarPanel HourHeight="{Binding Path=CalendarRenderOptions.CalendarSettings.HourHeight}" Period="{Binding Path=Period}" />
|
||||
<!-- TODO: Make aot safe. -->
|
||||
<controls:WinoCalendarPanel HourHeight="{Binding CalendarRenderOptions.CalendarSettings.HourHeight}" Period="{Binding Period}" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
</ItemsControl>
|
||||
@@ -63,7 +61,6 @@
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- TODO: Not AOT safe. -->
|
||||
<ItemsControl Margin="50,0,16,0" ItemsSource="{x:Bind CalendarDays}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:CalendarDayModel">
|
||||
@@ -72,6 +69,7 @@
|
||||
</ItemsControl.ItemTemplate>
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<!-- TODO: Make aot safe. -->
|
||||
<toolkitControls:UniformGrid
|
||||
Columns="{Binding CalendarRenderOptions.TotalDayCount}"
|
||||
Orientation="Horizontal"
|
||||
@@ -112,8 +110,7 @@
|
||||
ItemsSource="{x:Bind CalendarDays}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<!-- Columns="{Binding CalendarRenderOptions.TotalDayCount}" -->
|
||||
<!-- TODO: Columns should come from TotalDayCount to support custom dates. -->
|
||||
<!-- TODO: Make AOT SAFE. -->
|
||||
<toolkitControls:UniformGrid
|
||||
Columns="{Binding CalendarRenderOptions.TotalDayCount}"
|
||||
Orientation="Horizontal"
|
||||
@@ -279,21 +276,14 @@
|
||||
Margin="0,6">
|
||||
<ItemsControl.ItemTemplateSelector>
|
||||
<selectors:CustomAreaCalendarItemSelector>
|
||||
<!-- TODO: DisplayingDate is not AOT safe. -->
|
||||
<selectors:CustomAreaCalendarItemSelector.AllDayTemplate>
|
||||
<DataTemplate x:DataType="data:CalendarItemViewModel">
|
||||
<controls:CalendarItemControl
|
||||
CalendarItem="{x:Bind}"
|
||||
DisplayingDate="{Binding DataContext, ElementName=PART_AllDayItemsControl}"
|
||||
IsCustomEventArea="True" />
|
||||
<controls:CalendarItemControl CalendarItem="{x:Bind}" IsCustomEventArea="True" />
|
||||
</DataTemplate>
|
||||
</selectors:CustomAreaCalendarItemSelector.AllDayTemplate>
|
||||
<selectors:CustomAreaCalendarItemSelector.MultiDayTemplate>
|
||||
<DataTemplate x:DataType="data:CalendarItemViewModel">
|
||||
<controls:CalendarItemControl
|
||||
CalendarItem="{x:Bind}"
|
||||
DisplayingDate="{Binding DataContext, ElementName=PART_AllDayItemsControl}"
|
||||
IsCustomEventArea="True" />
|
||||
<controls:CalendarItemControl CalendarItem="{x:Bind}" IsCustomEventArea="True" />
|
||||
</DataTemplate>
|
||||
</selectors:CustomAreaCalendarItemSelector.MultiDayTemplate>
|
||||
</selectors:CustomAreaCalendarItemSelector>
|
||||
@@ -382,10 +372,7 @@
|
||||
<ItemsControl x:Name="PART_AllDayItemsControl">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="data:CalendarItemViewModel">
|
||||
<controls:CalendarItemControl
|
||||
CalendarItem="{x:Bind}"
|
||||
DisplayingDate="{Binding DataContext, ElementName=PART_AllDayItemsControl}"
|
||||
IsCustomEventArea="True" />
|
||||
<controls:CalendarItemControl CalendarItem="{x:Bind}" IsCustomEventArea="True" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
<ItemsControl.ItemContainerTransitions>
|
||||
|
||||
@@ -2,14 +2,10 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Ical.Net.CalendarComponents;
|
||||
using Ical.Net.DataTypes;
|
||||
using Itenso.TimePeriod;
|
||||
using Serilog;
|
||||
using Wino.Core.Domain;
|
||||
using Wino.Core.Domain.Entities.Calendar;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
@@ -30,6 +26,20 @@ public class CalendarService : BaseDatabaseService, ICalendarService
|
||||
|
||||
public int[] GetPredefinedReminderMinutes() => PredefinedReminderMinutes;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the AssignedCalendar (and its MailAccount) for a CalendarItem if not already loaded.
|
||||
/// </summary>
|
||||
private async Task LoadAssignedCalendarAsync(CalendarItem calendarItem)
|
||||
{
|
||||
if (calendarItem == null || calendarItem.AssignedCalendar != null) return;
|
||||
|
||||
calendarItem.AssignedCalendar = await Connection.GetAsync<AccountCalendar>(calendarItem.CalendarId);
|
||||
if (calendarItem.AssignedCalendar != null)
|
||||
{
|
||||
calendarItem.AssignedCalendar.MailAccount = await Connection.GetAsync<MailAccount>(calendarItem.AssignedCalendar.AccountId);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<List<AccountCalendar>> GetAccountCalendarsAsync(Guid accountId)
|
||||
=> Connection.Table<AccountCalendar>().Where(x => x.AccountId == accountId).OrderByDescending(a => a.IsPrimary).ToListAsync();
|
||||
|
||||
@@ -142,16 +152,16 @@ public class CalendarService : BaseDatabaseService, ICalendarService
|
||||
|
||||
/// <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.
|
||||
/// Returns all events (single instances and recurring event occurrences) that overlap with the period.
|
||||
/// Note: Recurring events are expected to be synced as individual instances from the server.
|
||||
/// Series master events (parents) are filtered out as they should not be displayed directly.
|
||||
/// </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>
|
||||
/// <returns>List of calendar items that fall within the requested period.</returns>
|
||||
public async Task<List<CalendarItem>> GetCalendarEventsAsync(IAccountCalendar calendar, ITimePeriod period)
|
||||
{
|
||||
// TODO: Implement caching strategy for better performance with large event sets.
|
||||
// Consider using a cache keyed by calendar ID and time period.
|
||||
|
||||
// Fetch all non-hidden events for this calendar
|
||||
var accountEvents = await Connection.Table<CalendarItem>()
|
||||
.Where(x => x.CalendarId == calendar.Id && !x.IsHidden)
|
||||
.ToListAsync();
|
||||
@@ -160,95 +170,18 @@ public class CalendarService : BaseDatabaseService, ICalendarService
|
||||
|
||||
foreach (var calendarItem in accountEvents)
|
||||
{
|
||||
// Skip series master events - they should not be displayed directly.
|
||||
// Individual instances are synced from the server and displayed instead.
|
||||
if (calendarItem.IsRecurringParent)
|
||||
continue;
|
||||
|
||||
calendarItem.AssignedCalendar = calendar;
|
||||
|
||||
// Skip exception instances - they will be handled by their parent recurring event
|
||||
if (calendarItem.RecurringCalendarItemId.HasValue)
|
||||
// Check if the event overlaps with the requested period
|
||||
if (calendarItem.Period.OverlapsWith(period))
|
||||
{
|
||||
continue;
|
||||
result.Add(calendarItem);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(calendarItem.Recurrence))
|
||||
{
|
||||
// Regular non-recurring event - simply check if it overlaps with the requested period.
|
||||
if (calendarItem.Period.OverlapsWith(period))
|
||||
{
|
||||
result.Add(calendarItem);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
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<CalendarItem>()
|
||||
.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;
|
||||
@@ -270,15 +203,7 @@ public class CalendarService : BaseDatabaseService, ICalendarService
|
||||
"SELECT * FROM CalendarItem WHERE Id = ?",
|
||||
id);
|
||||
|
||||
// Load assigned calendar and account.
|
||||
if (calendarItem != null)
|
||||
{
|
||||
calendarItem.AssignedCalendar = await Connection.GetAsync<AccountCalendar>(calendarItem.CalendarId);
|
||||
if (calendarItem.AssignedCalendar != null)
|
||||
{
|
||||
calendarItem.AssignedCalendar.MailAccount = await Connection.GetAsync<MailAccount>(calendarItem.AssignedCalendar.AccountId);
|
||||
}
|
||||
}
|
||||
await LoadAssignedCalendarAsync(calendarItem);
|
||||
|
||||
return calendarItem;
|
||||
}
|
||||
@@ -289,15 +214,7 @@ public class CalendarService : BaseDatabaseService, ICalendarService
|
||||
"SELECT * FROM CalendarItem WHERE CalendarId = ? AND RemoteEventId = ?",
|
||||
accountCalendarId, remoteEventId);
|
||||
|
||||
// Load assigned calendar and account.
|
||||
if (calendarItem != null)
|
||||
{
|
||||
calendarItem.AssignedCalendar = await Connection.GetAsync<AccountCalendar>(calendarItem.CalendarId);
|
||||
if (calendarItem.AssignedCalendar != null)
|
||||
{
|
||||
calendarItem.AssignedCalendar.MailAccount = await Connection.GetAsync<MailAccount>(calendarItem.AssignedCalendar.AccountId);
|
||||
}
|
||||
}
|
||||
await LoadAssignedCalendarAsync(calendarItem);
|
||||
|
||||
return calendarItem;
|
||||
}
|
||||
@@ -310,72 +227,7 @@ public class CalendarService : BaseDatabaseService, ICalendarService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expands a recurring calendar item to check if any of its occurrences fall within the given periods.
|
||||
/// For non-recurring events, returns the item if it overlaps with any period.
|
||||
/// For recurring events, expands occurrences and returns those that fall within any of the periods.
|
||||
/// </summary>
|
||||
/// <param name="calendarItem">The calendar item to expand (can be recurring or non-recurring).</param>
|
||||
/// <param name="periods">The list of periods to check against.</param>
|
||||
/// <returns>List of calendar items (either the original item or expanded recurrence instances) that fall within the periods.</returns>
|
||||
public async Task<List<CalendarItem>> GetExpandedRecurringEventsForPeriodsAsync(CalendarItem calendarItem, IEnumerable<ITimePeriod> periods)
|
||||
{
|
||||
var result = new List<CalendarItem>();
|
||||
|
||||
if (calendarItem == null || periods == null || !periods.Any())
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
// Ensure AssignedCalendar is loaded
|
||||
if (calendarItem.AssignedCalendar == null)
|
||||
{
|
||||
calendarItem.AssignedCalendar = await GetAccountCalendarAsync(calendarItem.CalendarId);
|
||||
}
|
||||
|
||||
// For non-recurring events, check if it overlaps with any of the provided periods
|
||||
if (string.IsNullOrEmpty(calendarItem.Recurrence))
|
||||
{
|
||||
foreach (var period in periods)
|
||||
{
|
||||
if (calendarItem.Period.OverlapsWith(period))
|
||||
{
|
||||
result.Add(calendarItem);
|
||||
break; // Add it only once
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// For recurring events, expand occurrences for the combined date range of all periods
|
||||
// Find the minimum and maximum dates across all periods
|
||||
var minDate = periods.Min(p => p.Start);
|
||||
var maxDate = periods.Max(p => p.End);
|
||||
|
||||
var combinedPeriod = new TimeRange(minDate, maxDate);
|
||||
|
||||
// Expand the recurring event for the combined period
|
||||
var expandedOccurrences = await ExpandRecurringEventAsync(calendarItem, combinedPeriod);
|
||||
|
||||
// Filter occurrences that fall within any of the individual periods
|
||||
foreach (var occurrence in expandedOccurrences)
|
||||
{
|
||||
foreach (var period in periods)
|
||||
{
|
||||
if (occurrence.Period.OverlapsWith(period))
|
||||
{
|
||||
result.Add(occurrence);
|
||||
break; // Add it only once even if it overlaps with multiple periods
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets attendees for a calendar item. For recurring event occurrences,
|
||||
/// callers should pass the EventTrackingId which returns the parent's ID.
|
||||
/// Gets attendees for a calendar item.
|
||||
/// </summary>
|
||||
public Task<List<CalendarEventAttendee>> GetAttendeesAsync(Guid calendarEventTrackingId)
|
||||
=> Connection.Table<CalendarEventAttendee>().Where(x => x.CalendarItemId == calendarEventTrackingId).ToListAsync();
|
||||
@@ -400,7 +252,7 @@ public class CalendarService : BaseDatabaseService, ICalendarService
|
||||
{
|
||||
var eventId = targetDetails.Item.Id;
|
||||
|
||||
// Get the event by Id first.
|
||||
// Get the event by Id first (this already loads AssignedCalendar).
|
||||
var item = await GetCalendarItemAsync(eventId).ConfigureAwait(false);
|
||||
|
||||
bool isRecurringChild = targetDetails.Item.IsRecurringChild;
|
||||
@@ -412,9 +264,9 @@ public class CalendarService : BaseDatabaseService, ICalendarService
|
||||
{
|
||||
if (item == null)
|
||||
{
|
||||
// This is an occurrence of a recurring event.
|
||||
// They don't exist in db.
|
||||
|
||||
// This occurrence doesn't exist in db - return the passed item.
|
||||
// Ensure AssignedCalendar is loaded.
|
||||
await LoadAssignedCalendarAsync(targetDetails.Item);
|
||||
return targetDetails.Item;
|
||||
}
|
||||
else
|
||||
@@ -458,8 +310,7 @@ public class CalendarService : BaseDatabaseService, ICalendarService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets reminders for a calendar item. For recurring event occurrences,
|
||||
/// callers should pass the EventTrackingId which returns the parent's ID.
|
||||
/// Gets reminders for a calendar item.
|
||||
/// </summary>
|
||||
public Task<List<Reminder>> GetRemindersAsync(Guid calendarItemId)
|
||||
=> Connection.Table<Reminder>().Where(r => r.CalendarItemId == calendarItemId).ToListAsync();
|
||||
@@ -492,6 +343,20 @@ public class CalendarService : BaseDatabaseService, ICalendarService
|
||||
|
||||
foreach (var item in attachments)
|
||||
{
|
||||
// Check if an attachment with the same RemoteAttachmentId already exists for this calendar item
|
||||
// to avoid re-downloading already existing attachments.
|
||||
var existingAttachment = await Connection.Table<CalendarAttachment>()
|
||||
.FirstOrDefaultAsync(x => x.CalendarItemId == item.CalendarItemId
|
||||
&& x.RemoteAttachmentId == item.RemoteAttachmentId);
|
||||
|
||||
if (existingAttachment != null)
|
||||
{
|
||||
// Preserve the existing Id, IsDownloaded status, and LocalFilePath
|
||||
item.Id = existingAttachment.Id;
|
||||
item.IsDownloaded = existingAttachment.IsDownloaded;
|
||||
item.LocalFilePath = existingAttachment.LocalFilePath;
|
||||
}
|
||||
|
||||
await Connection.InsertOrReplaceAsync(item, typeof(CalendarAttachment));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user