Single isntances and some updates shit.

This commit is contained in:
Burak Kaan Köse
2026-01-06 11:11:37 +01:00
parent d279c0a8dd
commit f8333aab10
18 changed files with 482 additions and 732 deletions
@@ -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);
+76 -303
View File
@@ -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)
{
+29 -1
View File
@@ -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()
+24 -12
View File
@@ -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()
+2 -1
View File
@@ -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>
+49 -184
View File
@@ -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));
}
}