From 317113a1b33c357736be993f003acf061f1b8965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Kaan=20K=C3=B6se?= Date: Thu, 19 Feb 2026 02:09:36 +0100 Subject: [PATCH] Fixing timezone issue with caldav. --- .../CalDavEventTimeMappingTests.cs | 111 ++++++++++++++++++ .../Processors/ImapChangeProcessor.cs | 56 +++++++-- Wino.Core/Synchronizers/ImapSynchronizer.cs | 24 +++- Wino.Services/CalDavClient.cs | 19 ++- 4 files changed, 199 insertions(+), 11 deletions(-) create mode 100644 Wino.Core.Tests/Synchronizers/CalDavEventTimeMappingTests.cs diff --git a/Wino.Core.Tests/Synchronizers/CalDavEventTimeMappingTests.cs b/Wino.Core.Tests/Synchronizers/CalDavEventTimeMappingTests.cs new file mode 100644 index 00000000..de07df39 --- /dev/null +++ b/Wino.Core.Tests/Synchronizers/CalDavEventTimeMappingTests.cs @@ -0,0 +1,111 @@ +using System.Reflection; +using FluentAssertions; +using Moq; +using Wino.Core.Domain.Entities.Calendar; +using Wino.Core.Domain.Interfaces; +using Wino.Core.Domain.Models.Calendar; +using Wino.Core.Integration.Processors; +using Wino.Services; +using Xunit; + +namespace Wino.Core.Tests.Synchronizers; + +public class CalDavEventTimeMappingTests +{ + [Fact] + public void ParseCalendarData_UtcEvent_AssignsUtcTimeZone() + { + const string ics = """ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Wino Mail//Tests//EN + CALSCALE:GREGORIAN + BEGIN:VEVENT + UID:utc-event + DTSTAMP:20260201T000000Z + DTSTART:20260219T010000Z + DTEND:20260219T020000Z + SUMMARY:UTC Event + END:VEVENT + END:VCALENDAR + """; + + var events = ParseEvents(ics); + + events.Should().ContainSingle(); + events[0].StartTimeZone.Should().Be(TimeZoneInfo.Utc.Id); + events[0].EndTimeZone.Should().Be(TimeZoneInfo.Utc.Id); + } + + [Fact] + public async Task ManageCalendarEventAsync_PersistsWallClockTimeForSourceTimeZone() + { + var calendar = new AccountCalendar + { + Id = Guid.NewGuid(), + Name = "Calendar" + }; + + var remoteEvent = new CalDavCalendarEvent + { + RemoteEventId = "event-1", + Title = "Wall Clock Event", + Start = new DateTimeOffset(2026, 2, 19, 1, 0, 0, TimeSpan.FromHours(1)), + End = new DateTimeOffset(2026, 2, 19, 2, 0, 0, TimeSpan.FromHours(1)), + StartTimeZone = "Europe/Berlin", + EndTimeZone = "Europe/Berlin" + }; + + CalendarItem? capturedItem = null; + var calendarService = new Mock(); + calendarService + .Setup(x => x.GetCalendarItemAsync(calendar.Id, remoteEvent.RemoteEventId)) + .ReturnsAsync((CalendarItem?)null); + calendarService + .Setup(x => x.CreateNewCalendarItemAsync(It.IsAny(), It.IsAny>())) + .Callback>((item, _) => capturedItem = item) + .Returns(Task.CompletedTask); + calendarService + .Setup(x => x.SaveRemindersAsync(It.IsAny(), It.IsAny>())) + .Returns(Task.CompletedTask); + + var sut = new ImapChangeProcessor( + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + calendarService.Object, + Mock.Of(), + Mock.Of()); + + await sut.ManageCalendarEventAsync(remoteEvent, calendar, organizerAccount: null); + + capturedItem.Should().NotBeNull(); + var savedItem = capturedItem!; + savedItem.StartDate.Should().Be(new DateTime(2026, 2, 19, 1, 0, 0)); + savedItem.DurationInSeconds.Should().Be(3600); + savedItem.StartTimeZone.Should().Be("Europe/Berlin"); + savedItem.EndTimeZone.Should().Be("Europe/Berlin"); + } + + private static List ParseEvents(string icsContent) + { + var parseMethod = typeof(CalDavClient).GetMethod( + "ParseCalendarData", + BindingFlags.NonPublic | BindingFlags.Static); + + parseMethod.Should().NotBeNull(); + + var result = parseMethod!.Invoke( + null, + [ + icsContent, + "https://calendar.example.com/event.ics", + "\"etag\"", + new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2026, 12, 31, 23, 59, 59, TimeSpan.Zero) + ]); + + return result.Should().BeOfType>().Subject; + } +} diff --git a/Wino.Core/Integration/Processors/ImapChangeProcessor.cs b/Wino.Core/Integration/Processors/ImapChangeProcessor.cs index 57e82864..02a27f85 100644 --- a/Wino.Core/Integration/Processors/ImapChangeProcessor.cs +++ b/Wino.Core/Integration/Processors/ImapChangeProcessor.cs @@ -40,18 +40,29 @@ public class ImapChangeProcessor : DefaultChangeProcessor, IImapChangeProcessor var savingItemId = existingItem?.Id ?? Guid.NewGuid(); var savingItem = existingItem ?? new CalendarItem { Id = savingItemId }; - var start = calendarEvent.Start.UtcDateTime; - var end = calendarEvent.End.UtcDateTime; + var startTimeZone = NormalizeTimeZoneId(calendarEvent.StartTimeZone, calendarEvent.Start); + var endTimeZone = NormalizeTimeZoneId(calendarEvent.EndTimeZone, calendarEvent.End); + if (string.IsNullOrWhiteSpace(endTimeZone)) + endTimeZone = startTimeZone; - if (end <= start) - end = start.AddHours(1); + var start = ConvertToEventWallClock(calendarEvent.Start, startTimeZone); + var end = ConvertToEventWallClock(calendarEvent.End, endTimeZone); + + var durationInSeconds = (calendarEvent.End - calendarEvent.Start).TotalSeconds; + if (durationInSeconds <= 0) + { + if (end <= start) + end = start.AddHours(1); + + durationInSeconds = (end - start).TotalSeconds; + } savingItem.RemoteEventId = calendarEvent.RemoteEventId; savingItem.CalendarId = assignedCalendar.Id; savingItem.StartDate = start; - savingItem.DurationInSeconds = (end - start).TotalSeconds; - savingItem.StartTimeZone = calendarEvent.StartTimeZone; - savingItem.EndTimeZone = calendarEvent.EndTimeZone; + savingItem.DurationInSeconds = durationInSeconds; + savingItem.StartTimeZone = startTimeZone; + savingItem.EndTimeZone = endTimeZone; savingItem.Title = calendarEvent.Title; savingItem.Description = calendarEvent.Description; savingItem.Location = calendarEvent.Location; @@ -158,4 +169,35 @@ public class ImapChangeProcessor : DefaultChangeProcessor, IImapChangeProcessor await DeleteCalendarItemAsync(item.Id).ConfigureAwait(false); } + + private static string NormalizeTimeZoneId(string timeZoneId, DateTimeOffset value) + { + if (!string.IsNullOrWhiteSpace(timeZoneId)) + return timeZoneId; + + if (value != default && value.Offset == TimeSpan.Zero) + return TimeZoneInfo.Utc.Id; + + return string.Empty; + } + + private static DateTime ConvertToEventWallClock(DateTimeOffset value, string eventTimeZoneId) + { + if (value == default) + return default; + + if (string.IsNullOrWhiteSpace(eventTimeZoneId)) + return DateTime.SpecifyKind(value.DateTime, DateTimeKind.Unspecified); + + try + { + var eventTimeZone = TimeZoneInfo.FindSystemTimeZoneById(eventTimeZoneId); + var inEventTimeZone = TimeZoneInfo.ConvertTime(value, eventTimeZone); + return DateTime.SpecifyKind(inEventTimeZone.DateTime, DateTimeKind.Unspecified); + } + catch + { + return DateTime.SpecifyKind(value.DateTime, DateTimeKind.Unspecified); + } + } } diff --git a/Wino.Core/Synchronizers/ImapSynchronizer.cs b/Wino.Core/Synchronizers/ImapSynchronizer.cs index 76e27be8..1e47443e 100644 --- a/Wino.Core/Synchronizers/ImapSynchronizer.cs +++ b/Wino.Core/Synchronizers/ImapSynchronizer.cs @@ -1768,8 +1768,11 @@ public class ImapSynchronizer : WinoSynchronizer();