From 31b8b5a2157bc52ff84f583b622b35836129df28 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sun, 13 Sep 2026 16:29:01 -0300 Subject: [PATCH 01/15] Start calendar timezone fix From 209169988ac4db49b1d5e7debbf5d388d6b29fa5 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sun, 13 Sep 2026 16:38:56 -0300 Subject: [PATCH 02/15] Fix calendar event timezone projection --- .../Commands/RunCalendarAutoSyncCommand.cs | 7 +- .../Queries/GetCalendarEventsQuery.cs | 27 +++- .../GetCalendarSyncSuggestionsQuery.cs | 29 +++- .../Services/GoogleCalendarEventFetcher.cs | 15 +- .../RunCalendarAutoSyncCommandHandlerTests.cs | 14 +- .../GetCalendarEventsQueryHandlerTests.cs | 138 ++++++++++++++++++ ...alendarSyncSuggestionsQueryHandlerTests.cs | 44 ++++++ .../GoogleCalendarEventFetcherTests.cs | 51 +++++++ 8 files changed, 307 insertions(+), 18 deletions(-) diff --git a/src/Orbit.Application/Calendar/Commands/RunCalendarAutoSyncCommand.cs b/src/Orbit.Application/Calendar/Commands/RunCalendarAutoSyncCommand.cs index dbf59fd96..1637f8a8f 100644 --- a/src/Orbit.Application/Calendar/Commands/RunCalendarAutoSyncCommand.cs +++ b/src/Orbit.Application/Calendar/Commands/RunCalendarAutoSyncCommand.cs @@ -237,7 +237,7 @@ private async Task CreateSuggestions( if (created >= MaxSuggestionsPerTick) break; if (!reservedEventIds.Add(ev.Id)) continue; - var startDateUtc = ParseStartDateUtc(ev); + var startDateUtc = ResolveStartDateUtc(ev); var rawJson = JsonSerializer.Serialize(ev); var suggestion = GoogleCalendarSyncSuggestion.Create( @@ -308,14 +308,11 @@ private bool IsInQuietHours(User user, DateTime utcNow) return local.Hour >= QuietHoursStart && local.Hour < QuietHoursEnd; } - private static DateTime ParseStartDateUtc(CalendarEventItem ev) + private static DateTime ResolveStartDateUtc(CalendarEventItem ev) { if (ev.StartUtc is { } startUtc) return DateTime.SpecifyKind(startUtc, DateTimeKind.Utc); - if (DateOnly.TryParse(ev.StartDate, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var date)) - return date.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); - return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); } diff --git a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs index 31ff3c6f1..cb9fc44a8 100644 --- a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs +++ b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs @@ -1,3 +1,4 @@ +using System.Globalization; using MediatR; using Microsoft.Extensions.Logging; using Orbit.Application.Behaviors; @@ -21,7 +22,29 @@ public record CalendarEventItem( List Reminders, DateTime? StartUtc = null, string CalendarId = "", - string CalendarName = ""); + string CalendarName = "", + DateTime? EndUtc = null) +{ + internal CalendarEventItem ProjectTo(TimeZoneInfo timeZone) + { + if (StartTime is null || StartUtc is null) + return this; + + var localStart = TimeZoneInfo.ConvertTimeFromUtc( + DateTime.SpecifyKind(StartUtc.Value, DateTimeKind.Utc), + timeZone); + var localEnd = EndUtc is { } endUtc + ? TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(endUtc, DateTimeKind.Utc), timeZone) + : (DateTime?)null; + + return this with + { + StartDate = localStart.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + StartTime = localStart.ToString("HH:mm", CultureInfo.InvariantCulture), + EndTime = localEnd?.ToString("HH:mm", CultureInfo.InvariantCulture) ?? EndTime + }; + } +} public record GetCalendarEventsQuery(Guid UserId) : IRequest>>, IConcurrencyRetryable; @@ -59,8 +82,10 @@ public async Task>> Handle(GetCalendarEventsQuery accessToken, user.GetSelectedCalendarIds(), updatedMin: null, cancellationToken); var importedEventIds = await BuildImportedEventIdSet(request.UserId, cancellationToken); + var timeZone = TimeZoneHelper.FindTimeZone(user.TimeZone); var items = fetched .Where(item => !importedEventIds.Contains(item.Id)) + .Select(item => item.ProjectTo(timeZone)) .ToList(); return Result.Success(items); diff --git a/src/Orbit.Application/Calendar/Queries/GetCalendarSyncSuggestionsQuery.cs b/src/Orbit.Application/Calendar/Queries/GetCalendarSyncSuggestionsQuery.cs index 898b97908..caa5f981b 100644 --- a/src/Orbit.Application/Calendar/Queries/GetCalendarSyncSuggestionsQuery.cs +++ b/src/Orbit.Application/Calendar/Queries/GetCalendarSyncSuggestionsQuery.cs @@ -65,10 +65,11 @@ public async Task>> Handle(GetCalendarSy .ToHashSet(StringComparer.Ordinal); var items = new List(); + var timeZone = TimeZoneHelper.FindTimeZone(user.TimeZone); foreach (var suggestion in suggestions.OrderBy(s => s.StartDateUtc)) { var item = TryBuildSuggestionItem( - suggestion, userToday, importedEventIds, importedLegacyKeys, selectedCalendars); + suggestion, userToday, importedEventIds, importedLegacyKeys, selectedCalendars, timeZone); if (item is not null) items.Add(item); } @@ -81,13 +82,15 @@ public async Task>> Handle(GetCalendarSy DateOnly userToday, HashSet importedEventIds, HashSet importedLegacyKeys, - HashSet? selectedCalendars) + HashSet? selectedCalendars, + TimeZoneInfo timeZone) { - if (DateOnly.FromDateTime(suggestion.StartDateUtc) < userToday) return null; if (importedEventIds.Contains(suggestion.GoogleEventId)) return null; var eventItem = DeserializeEvent(suggestion); if (eventItem is null) return null; + eventItem = eventItem.ProjectTo(timeZone); + if (ResolveStartDate(eventItem, suggestion.StartDateUtc, timeZone) < userToday) return null; if (selectedCalendars is not null && !string.IsNullOrEmpty(eventItem.CalendarId) && !selectedCalendars.Contains(eventItem.CalendarId)) return null; @@ -103,6 +106,26 @@ public async Task>> Handle(GetCalendarSy suggestion.DiscoveredAtUtc); } + private static DateOnly ResolveStartDate( + CalendarEventItem eventItem, + DateTime fallbackStartUtc, + TimeZoneInfo timeZone) + { + if (DateOnly.TryParse( + eventItem.StartDate, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var startDate)) + { + return startDate; + } + + var localStart = TimeZoneInfo.ConvertTimeFromUtc( + DateTime.SpecifyKind(fallbackStartUtc, DateTimeKind.Utc), + timeZone); + return DateOnly.FromDateTime(localStart); + } + private CalendarEventItem? DeserializeEvent(GoogleCalendarSyncSuggestion suggestion) { try diff --git a/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs b/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs index ee268ecdd..03e49febc 100644 --- a/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs +++ b/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs @@ -135,9 +135,10 @@ private static CalendarEventItem MapEvent(Event ev, string calendarId, string ca isRecurring, rrule, BuildReminders(ev, startTime), - ResolveStartUtc(ev.Start), + ResolveUtc(ev.Start), calendarId, - calendarName); + calendarName, + ResolveUtc(ev.End)); } private async Task ResolveRRule( @@ -200,16 +201,16 @@ private static bool IsDefaultOwned(CalendarListEntry entry) => private static string ResolveCalendarName(CalendarListEntry entry) => entry.SummaryOverride ?? entry.Summary ?? string.Empty; - private static DateTime? ResolveStartUtc(EventDateTime? start) + private static DateTime? ResolveUtc(EventDateTime? value) { - if (start is null) + if (value is null) return null; - if (start.DateTimeDateTimeOffset is { } dto) + if (value.DateTimeDateTimeOffset is { } dto) return dto.UtcDateTime; - if (!string.IsNullOrWhiteSpace(start.Date) - && DateOnly.TryParse(start.Date, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var date)) + if (!string.IsNullOrWhiteSpace(value.Date) + && DateOnly.TryParse(value.Date, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var date)) { return date.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); } diff --git a/tests/Orbit.Application.Tests/Commands/Calendar/RunCalendarAutoSyncCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Calendar/RunCalendarAutoSyncCommandHandlerTests.cs index 6d5d11a31..ac63f048d 100644 --- a/tests/Orbit.Application.Tests/Commands/Calendar/RunCalendarAutoSyncCommandHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Commands/Calendar/RunCalendarAutoSyncCommandHandlerTests.cs @@ -526,7 +526,7 @@ await _suggestionRepo.AddAsync( } [Fact] - public async Task Handle_Success_FallsBackToStartDateWhenNoUtcInstant() + public async Task Handle_Success_PersistsAllDayStartInstant() { var user = CreateEnabledProUser(); StubUser(user); @@ -536,7 +536,17 @@ public async Task Handle_Success_FallsBackToStartDateWhenNoUtcInstant() _fetcher.FetchAsync(Arg.Any(), Arg.Any?>(), Arg.Any(), Arg.Any()) .Returns(new List { - new("evt_allday", "All day", null, "2026-04-12", null, null, false, null, []) + new( + "evt_allday", + "All day", + null, + "2026-04-12", + null, + null, + false, + null, + [], + StartUtc: new DateTime(2026, 4, 12, 0, 0, 0, DateTimeKind.Utc)) }); GoogleCalendarSyncSuggestion? captured = null; diff --git a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs index 9401fccb5..2c77d1c1f 100644 --- a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs @@ -40,6 +40,27 @@ private static User CreateTestUser() return User.Create("Test User", "test@example.com").Value; } + private void StubSuccessfulFetch(User user, params CalendarEventItem[] items) + { + _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); + _googleTokenService.GetValidAccessTokenAsync(user, Arg.Any()) + .Returns("valid-access-token"); + _habitRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(new List().AsReadOnly()); + _suggestionRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(new List().AsReadOnly()); + _eventFetcher.FetchAsync( + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + Arg.Any()) + .Returns(items.ToList()); + } + [Fact] public async Task Handle_UserNotFound_ReturnsFailure() { @@ -183,6 +204,123 @@ public async Task Handle_FiltersOutAlreadyImportedHabitsByGoogleEventId() result.Value[0].Id.Should().Be("evt_new"); } + [Fact] + public async Task Handle_TimedTokyoEvent_ProjectsStartIntoSaoPauloTimezone() + { + var user = CreateTestUser(); + user.SetTimeZone("America/Sao_Paulo").IsSuccess.Should().BeTrue(); + StubSuccessfulFetch( + user, + new CalendarEventItem( + "evt_tokyo", + "Tokyo breakfast", + null, + "2026-04-15", + "08:00", + "09:00", + false, + null, + [], + StartUtc: new DateTime(2026, 4, 14, 23, 0, 0, DateTimeKind.Utc), + EndUtc: new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc))); + + var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().ContainSingle(); + result.Value[0].StartDate.Should().Be("2026-04-14"); + result.Value[0].StartTime.Should().Be("20:00"); + result.Value[0].EndTime.Should().Be("21:00"); + } + + [Fact] + public async Task Handle_TimedSaoPauloEvent_ProjectsIntoNextTokyoDay() + { + var user = CreateTestUser(); + user.SetTimeZone("Asia/Tokyo").IsSuccess.Should().BeTrue(); + StubSuccessfulFetch( + user, + new CalendarEventItem( + "evt_sao_paulo", + "Late dinner", + null, + "2026-04-15", + "23:00", + "00:00", + false, + null, + [], + StartUtc: new DateTime(2026, 4, 16, 2, 0, 0, DateTimeKind.Utc), + EndUtc: new DateTime(2026, 4, 16, 3, 0, 0, DateTimeKind.Utc))); + + var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().ContainSingle(); + result.Value[0].StartDate.Should().Be("2026-04-16"); + result.Value[0].StartTime.Should().Be("11:00"); + result.Value[0].EndTime.Should().Be("12:00"); + } + + [Theory] + [InlineData("America/Sao_Paulo")] + [InlineData("Asia/Tokyo")] + public async Task Handle_AllDayEvent_PreservesFloatingDateAndNullTimes(string timeZone) + { + var user = CreateTestUser(); + user.SetTimeZone(timeZone).IsSuccess.Should().BeTrue(); + StubSuccessfulFetch( + user, + new CalendarEventItem( + "evt_all_day", + "Holiday", + null, + "2026-04-15", + null, + null, + false, + null, + [], + StartUtc: new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc), + EndUtc: new DateTime(2026, 4, 16, 0, 0, 0, DateTimeKind.Utc))); + + var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().ContainSingle(); + result.Value[0].StartDate.Should().Be("2026-04-15"); + result.Value[0].StartTime.Should().BeNull(); + result.Value[0].EndTime.Should().BeNull(); + } + + [Fact] + public async Task Handle_UserWithoutTimezone_ProjectsTimedEventIntoUtc() + { + var user = CreateTestUser(); + StubSuccessfulFetch( + user, + new CalendarEventItem( + "evt_utc_fallback", + "Tokyo breakfast", + null, + "2026-04-15", + "08:00", + "09:00", + false, + null, + [], + StartUtc: new DateTime(2026, 4, 14, 23, 0, 0, DateTimeKind.Utc), + EndUtc: new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc))); + + var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().ContainSingle(); + result.Value[0].StartDate.Should().Be("2026-04-14"); + result.Value[0].StartTime.Should().Be("23:00"); + result.Value[0].EndTime.Should().Be("00:00"); + } + [Fact] public async Task Handle_InvalidRefreshToken_MarksReconnectRequiredAndReturnsConnectionFailure() { diff --git a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarSyncSuggestionsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarSyncSuggestionsQueryHandlerTests.cs index 4b542e976..3dd554ed4 100644 --- a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarSyncSuggestionsQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarSyncSuggestionsQueryHandlerTests.cs @@ -157,6 +157,50 @@ public async Task Handle_PastSuggestions_AreExcluded() result.Value[0].GoogleEventId.Should().Be("gcal-future"); } + [Fact] + public async Task Handle_TimedSuggestion_ProjectsBeforeComparingWithUserToday() + { + var user = User.Create("Test", "test@example.com").Value; + user.SetTimeZone("Asia/Tokyo").IsSuccess.Should().BeTrue(); + _userRepo.GetByIdAsync(Arg.Any(), Arg.Any()).Returns(user); + _userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()) + .Returns(new DateOnly(2026, 4, 15)); + + var startUtc = new DateTime(2026, 4, 14, 15, 0, 0, DateTimeKind.Utc); + var eventItem = new CalendarEventItem( + "event-crossing-day", + "UTC afternoon", + null, + "2026-04-14", + "15:00", + "16:00", + false, + null, + [], + StartUtc: startUtc, + EndUtc: startUtc.AddHours(1)); + var suggestion = GoogleCalendarSyncSuggestion.Create( + UserId, + "gcal-crossing-day", + eventItem.Title, + startUtc, + JsonSerializer.Serialize(eventItem), + startUtc); + + _suggestionRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(new List { suggestion }.AsReadOnly()); + + var result = await _handler.Handle(new GetCalendarSyncSuggestionsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().ContainSingle(); + result.Value[0].Event.StartDate.Should().Be("2026-04-15"); + result.Value[0].Event.StartTime.Should().Be("00:00"); + result.Value[0].Event.EndTime.Should().Be("01:00"); + } + [Fact] public async Task Handle_ExistingHabitGoogleEventId_ExcludesMatchingSuggestion() { diff --git a/tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs b/tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs index 7f41188e6..e57c7baca 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs @@ -110,6 +110,57 @@ public async Task FetchAsync_MergesEventsAcrossOwnedCalendarsWithTagging() result.Should().ContainSingle(i => i.CalendarId == "b" && i.CalendarName == "B Override"); } + [Fact] + public async Task FetchAsync_TimedEvent_PreservesStartAndEndUtcInstants() + { + StubCalendars(Calendar("owned", "owner")); + StubEvents( + "owned", + new Event + { + Id = "tokyo-event", + Summary = "Tokyo breakfast", + Start = new EventDateTime + { + DateTimeDateTimeOffset = new DateTimeOffset(2026, 4, 15, 8, 0, 0, TimeSpan.FromHours(9)) + }, + End = new EventDateTime + { + DateTimeDateTimeOffset = new DateTimeOffset(2026, 4, 15, 9, 0, 0, TimeSpan.FromHours(9)) + } + }); + + var result = await _fetcher.FetchAsync(Token, null, null, CancellationToken.None); + + result.Should().ContainSingle(); + result[0].StartUtc.Should().Be(new DateTime(2026, 4, 14, 23, 0, 0, DateTimeKind.Utc)); + result[0].EndUtc.Should().Be(new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc)); + } + + [Fact] + public async Task FetchAsync_AllDayEvent_PreservesFloatingDateWithNullTimes() + { + StubCalendars(Calendar("owned", "owner")); + StubEvents( + "owned", + new Event + { + Id = "all-day-event", + Summary = "Holiday", + Start = new EventDateTime { Date = "2026-04-15" }, + End = new EventDateTime { Date = "2026-04-16" } + }); + + var result = await _fetcher.FetchAsync(Token, null, null, CancellationToken.None); + + result.Should().ContainSingle(); + result[0].StartDate.Should().Be("2026-04-15"); + result[0].StartTime.Should().BeNull(); + result[0].EndTime.Should().BeNull(); + result[0].StartUtc.Should().Be(new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc)); + result[0].EndUtc.Should().Be(new DateTime(2026, 4, 16, 0, 0, 0, DateTimeKind.Utc)); + } + [Fact] public async Task FetchAsync_ExplicitSelection_FetchesOnlyChosenCalendars() { From c91453fb225663a44a16981dc1c353295932d3f1 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sun, 13 Sep 2026 17:39:47 -0300 Subject: [PATCH 03/15] Fix projected calendar end times --- .../Queries/GetCalendarEventsQuery.cs | 4 +- .../GetCalendarEventsQueryHandlerTests.cs | 32 +++++++- ...alendarSyncSuggestionsQueryHandlerTests.cs | 82 +++++++++++++++++++ 3 files changed, 115 insertions(+), 3 deletions(-) diff --git a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs index cb9fc44a8..133aa9d8d 100644 --- a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs +++ b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs @@ -41,7 +41,9 @@ internal CalendarEventItem ProjectTo(TimeZoneInfo timeZone) { StartDate = localStart.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), StartTime = localStart.ToString("HH:mm", CultureInfo.InvariantCulture), - EndTime = localEnd?.ToString("HH:mm", CultureInfo.InvariantCulture) ?? EndTime + EndTime = localEnd is { } sameDayEnd && sameDayEnd.Date == localStart.Date + ? sameDayEnd.ToString("HH:mm", CultureInfo.InvariantCulture) + : null }; } } diff --git a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs index 2c77d1c1f..9c83a9b47 100644 --- a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs @@ -233,6 +233,34 @@ public async Task Handle_TimedTokyoEvent_ProjectsStartIntoSaoPauloTimezone() result.Value[0].EndTime.Should().Be("21:00"); } + [Fact] + public async Task Handle_TimedEventWithoutEndUtc_ProjectsStartAndOmitsEndTime() + { + var user = CreateTestUser(); + user.SetTimeZone("America/Sao_Paulo").IsSuccess.Should().BeTrue(); + StubSuccessfulFetch( + user, + new CalendarEventItem( + "evt_legacy", + "Legacy Tokyo breakfast", + null, + "2026-04-15", + "08:00", + "09:00", + false, + null, + [], + StartUtc: new DateTime(2026, 4, 14, 23, 0, 0, DateTimeKind.Utc))); + + var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().ContainSingle(); + result.Value[0].StartDate.Should().Be("2026-04-14"); + result.Value[0].StartTime.Should().Be("20:00"); + result.Value[0].EndTime.Should().BeNull(); + } + [Fact] public async Task Handle_TimedSaoPauloEvent_ProjectsIntoNextTokyoDay() { @@ -294,7 +322,7 @@ public async Task Handle_AllDayEvent_PreservesFloatingDateAndNullTimes(string ti } [Fact] - public async Task Handle_UserWithoutTimezone_ProjectsTimedEventIntoUtc() + public async Task Handle_UserWithoutTimezone_OmitsEndTimeWhenProjectionCrossesUtcMidnight() { var user = CreateTestUser(); StubSuccessfulFetch( @@ -318,7 +346,7 @@ public async Task Handle_UserWithoutTimezone_ProjectsTimedEventIntoUtc() result.Value.Should().ContainSingle(); result.Value[0].StartDate.Should().Be("2026-04-14"); result.Value[0].StartTime.Should().Be("23:00"); - result.Value[0].EndTime.Should().Be("00:00"); + result.Value[0].EndTime.Should().BeNull(); } [Fact] diff --git a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarSyncSuggestionsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarSyncSuggestionsQueryHandlerTests.cs index 3dd554ed4..be52d2351 100644 --- a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarSyncSuggestionsQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarSyncSuggestionsQueryHandlerTests.cs @@ -1,5 +1,6 @@ using System.Linq.Expressions; using System.Text.Json; +using System.Text.Json.Nodes; using FluentAssertions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -201,6 +202,87 @@ public async Task Handle_TimedSuggestion_ProjectsBeforeComparingWithUserToday() result.Value[0].Event.EndTime.Should().Be("01:00"); } + [Fact] + public async Task Handle_LegacyTimedSuggestionWithoutEndUtc_MatchesEventsQueryProjection() + { + var user = User.Create("Test", "test@example.com").Value; + user.SetTimeZone("America/Sao_Paulo").IsSuccess.Should().BeTrue(); + _userRepo.GetByIdAsync(Arg.Any(), Arg.Any()).Returns(user); + + var startUtc = new DateTime(2026, 4, 14, 23, 0, 0, DateTimeKind.Utc); + var eventItem = new CalendarEventItem( + "event-legacy", + "Legacy Tokyo breakfast", + null, + "2026-04-15", + "08:00", + "09:00", + false, + null, + [], + StartUtc: startUtc); + var legacyJson = JsonNode.Parse(JsonSerializer.Serialize(eventItem))!.AsObject(); + legacyJson.Remove(nameof(CalendarEventItem.EndUtc)); + var suggestion = GoogleCalendarSyncSuggestion.Create( + UserId, + "gcal-legacy", + eventItem.Title, + startUtc, + legacyJson.ToJsonString(), + startUtc); + + _suggestionRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(new List { suggestion }.AsReadOnly()); + + var result = await _handler.Handle(new GetCalendarSyncSuggestionsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().ContainSingle(); + result.Value[0].Event.StartDate.Should().Be("2026-04-14"); + result.Value[0].Event.StartTime.Should().Be("20:00"); + result.Value[0].Event.EndTime.Should().BeNull(); + } + + [Fact] + public async Task Handle_TimedSuggestionCrossingLocalMidnight_OmitsEndTime() + { + var startUtc = new DateTime(2026, 4, 14, 23, 0, 0, DateTimeKind.Utc); + var eventItem = new CalendarEventItem( + "event-overnight", + "Late event", + null, + "2026-04-14", + "23:00", + "00:00", + false, + null, + [], + StartUtc: startUtc, + EndUtc: startUtc.AddHours(1)); + var suggestion = GoogleCalendarSyncSuggestion.Create( + UserId, + "gcal-overnight", + eventItem.Title, + startUtc, + JsonSerializer.Serialize(eventItem), + startUtc); + + _suggestionRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(new List { suggestion }.AsReadOnly()); + + var result = await _handler.Handle(new GetCalendarSyncSuggestionsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().ContainSingle(); + result.Value[0].Event.StartDate.Should().Be("2026-04-14"); + result.Value[0].Event.StartTime.Should().Be("23:00"); + result.Value[0].Event.EndTime.Should().BeNull(); + } + [Fact] public async Task Handle_ExistingHabitGoogleEventId_ExcludesMatchingSuggestion() { From cc86c6128621df19d395276b5b029bf1277a472a Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Sun, 13 Sep 2026 18:09:37 -0300 Subject: [PATCH 04/15] Fix calendar end time during DST fallback --- .../Queries/GetCalendarEventsQuery.cs | 4 ++- .../GetCalendarEventsQueryHandlerTests.cs | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs index 133aa9d8d..6b2f962b5 100644 --- a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs +++ b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs @@ -41,7 +41,9 @@ internal CalendarEventItem ProjectTo(TimeZoneInfo timeZone) { StartDate = localStart.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), StartTime = localStart.ToString("HH:mm", CultureInfo.InvariantCulture), - EndTime = localEnd is { } sameDayEnd && sameDayEnd.Date == localStart.Date + EndTime = localEnd is { } sameDayEnd + && sameDayEnd.Date == localStart.Date + && sameDayEnd.TimeOfDay > localStart.TimeOfDay ? sameDayEnd.ToString("HH:mm", CultureInfo.InvariantCulture) : null }; diff --git a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs index 9c83a9b47..9460fbac1 100644 --- a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs @@ -349,6 +349,35 @@ public async Task Handle_UserWithoutTimezone_OmitsEndTimeWhenProjectionCrossesUt result.Value[0].EndTime.Should().BeNull(); } + [Fact] + public async Task Handle_TimedEventCrossingRepeatedHour_OmitsDescendingEndTime() + { + var user = CreateTestUser(); + user.SetTimeZone("America/New_York").IsSuccess.Should().BeTrue(); + StubSuccessfulFetch( + user, + new CalendarEventItem( + "evt_fall_back", + "Repeated hour", + null, + "2026-11-01", + "01:30", + "01:15", + false, + null, + [], + StartUtc: new DateTime(2026, 11, 1, 5, 30, 0, DateTimeKind.Utc), + EndUtc: new DateTime(2026, 11, 1, 6, 15, 0, DateTimeKind.Utc))); + + var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().ContainSingle(); + result.Value[0].StartDate.Should().Be("2026-11-01"); + result.Value[0].StartTime.Should().Be("01:30"); + result.Value[0].EndTime.Should().BeNull(); + } + [Fact] public async Task Handle_InvalidRefreshToken_MarksReconnectRequiredAndReturnsConnectionFailure() { From 91da4ad13209522abe616c8347a066a977efaea7 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Tue, 15 Sep 2026 23:54:52 -0300 Subject: [PATCH 05/15] Fix calendar end time minute precision --- .../Queries/GetCalendarEventsQuery.cs | 8 ++- .../GetCalendarEventsQueryHandlerTests.cs | 56 +++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs index 6b2f962b5..faadfdfad 100644 --- a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs +++ b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs @@ -36,15 +36,17 @@ internal CalendarEventItem ProjectTo(TimeZoneInfo timeZone) var localEnd = EndUtc is { } endUtc ? TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(endUtc, DateTimeKind.Utc), timeZone) : (DateTime?)null; + var projectedStartTime = localStart.ToString("HH:mm", CultureInfo.InvariantCulture); + var projectedEndTime = localEnd?.ToString("HH:mm", CultureInfo.InvariantCulture); return this with { StartDate = localStart.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), - StartTime = localStart.ToString("HH:mm", CultureInfo.InvariantCulture), + StartTime = projectedStartTime, EndTime = localEnd is { } sameDayEnd && sameDayEnd.Date == localStart.Date - && sameDayEnd.TimeOfDay > localStart.TimeOfDay - ? sameDayEnd.ToString("HH:mm", CultureInfo.InvariantCulture) + && string.CompareOrdinal(projectedEndTime, projectedStartTime) > 0 + ? projectedEndTime : null }; } diff --git a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs index 9460fbac1..4d1eeb081 100644 --- a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs @@ -349,6 +349,62 @@ public async Task Handle_UserWithoutTimezone_OmitsEndTimeWhenProjectionCrossesUt result.Value[0].EndTime.Should().BeNull(); } + [Fact] + public async Task Handle_TimedEventWithinDisplayedMinute_OmitsEqualEndTime() + { + var user = CreateTestUser(); + StubSuccessfulFetch( + user, + new CalendarEventItem( + "evt_same_minute", + "Short event", + null, + "2026-04-15", + "10:00", + "10:00", + false, + null, + [], + StartUtc: new DateTime(2026, 4, 15, 10, 0, 10, DateTimeKind.Utc), + EndUtc: new DateTime(2026, 4, 15, 10, 0, 50, DateTimeKind.Utc))); + + var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().ContainSingle(); + result.Value[0].StartDate.Should().Be("2026-04-15"); + result.Value[0].StartTime.Should().Be("10:00"); + result.Value[0].EndTime.Should().BeNull(); + } + + [Fact] + public async Task Handle_OneMinuteTimedEvent_KeepsLaterEndTime() + { + var user = CreateTestUser(); + StubSuccessfulFetch( + user, + new CalendarEventItem( + "evt_one_minute", + "One minute event", + null, + "2026-04-15", + "10:00", + "10:01", + false, + null, + [], + StartUtc: new DateTime(2026, 4, 15, 10, 0, 0, DateTimeKind.Utc), + EndUtc: new DateTime(2026, 4, 15, 10, 1, 0, DateTimeKind.Utc))); + + var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().ContainSingle(); + result.Value[0].StartDate.Should().Be("2026-04-15"); + result.Value[0].StartTime.Should().Be("10:00"); + result.Value[0].EndTime.Should().Be("10:01"); + } + [Fact] public async Task Handle_TimedEventCrossingRepeatedHour_OmitsDescendingEndTime() { From ee8f3e4497a9097f35133e4dec6cfe5c1fc143db Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Wed, 16 Sep 2026 09:18:38 -0300 Subject: [PATCH 06/15] Fix projected calendar recurrence rule --- .../Calendar/Queries/GetCalendarEventsQuery.cs | 6 +++++- .../Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs | 5 +++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs index faadfdfad..eec745df5 100644 --- a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs +++ b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs @@ -36,17 +36,21 @@ internal CalendarEventItem ProjectTo(TimeZoneInfo timeZone) var localEnd = EndUtc is { } endUtc ? TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(endUtc, DateTimeKind.Utc), timeZone) : (DateTime?)null; + var projectedStartDate = localStart.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); var projectedStartTime = localStart.ToString("HH:mm", CultureInfo.InvariantCulture); var projectedEndTime = localEnd?.ToString("HH:mm", CultureInfo.InvariantCulture); return this with { - StartDate = localStart.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + StartDate = projectedStartDate, StartTime = projectedStartTime, EndTime = localEnd is { } sameDayEnd && sameDayEnd.Date == localStart.Date && string.CompareOrdinal(projectedEndTime, projectedStartTime) > 0 ? projectedEndTime + : null, + RecurrenceRule = string.Equals(StartDate, projectedStartDate, StringComparison.Ordinal) + ? RecurrenceRule : null }; } diff --git a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs index 4d1eeb081..2f849b0db 100644 --- a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs @@ -218,8 +218,8 @@ public async Task Handle_TimedTokyoEvent_ProjectsStartIntoSaoPauloTimezone() "2026-04-15", "08:00", "09:00", - false, - null, + true, + "RRULE:FREQ=WEEKLY;BYDAY=WE", [], StartUtc: new DateTime(2026, 4, 14, 23, 0, 0, DateTimeKind.Utc), EndUtc: new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc))); @@ -231,6 +231,7 @@ public async Task Handle_TimedTokyoEvent_ProjectsStartIntoSaoPauloTimezone() result.Value[0].StartDate.Should().Be("2026-04-14"); result.Value[0].StartTime.Should().Be("20:00"); result.Value[0].EndTime.Should().Be("21:00"); + result.Value[0].RecurrenceRule.Should().BeNull(); } [Fact] From 3d59d9f96d9f3b1459e04a81c194bc9cac93bf69 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Wed, 16 Sep 2026 11:00:46 -0300 Subject: [PATCH 07/15] Guard calendar recurrence across timezone shifts --- .../Queries/GetCalendarEventsQuery.cs | 66 +++++++++++++++++-- .../Services/GoogleCalendarEventFetcher.cs | 3 +- .../GetCalendarEventsQueryHandlerTests.cs | 60 +++++++++++++++++ .../GoogleCalendarEventFetcherTests.cs | 4 +- 4 files changed, 127 insertions(+), 6 deletions(-) diff --git a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs index eec745df5..5cf661caa 100644 --- a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs +++ b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs @@ -23,7 +23,8 @@ public record CalendarEventItem( DateTime? StartUtc = null, string CalendarId = "", string CalendarName = "", - DateTime? EndUtc = null) + DateTime? EndUtc = null, + string? StartTimeZone = null) { internal CalendarEventItem ProjectTo(TimeZoneInfo timeZone) { @@ -49,11 +50,68 @@ internal CalendarEventItem ProjectTo(TimeZoneInfo timeZone) && string.CompareOrdinal(projectedEndTime, projectedStartTime) > 0 ? projectedEndTime : null, - RecurrenceRule = string.Equals(StartDate, projectedStartDate, StringComparison.Ordinal) - ? RecurrenceRule - : null + RecurrenceRule = RecurrenceRule is not null + && IsRecurrenceDateStable(StartDate, StartTime, StartTimeZone, timeZone) + ? RecurrenceRule + : null }; } + + private static bool IsRecurrenceDateStable( + string? startDate, + string? startTime, + string? startTimeZone, + TimeZoneInfo accountTimeZone) + { + if (string.IsNullOrWhiteSpace(startTimeZone) + || !DateTime.TryParseExact( + $"{startDate} {startTime}", + "yyyy-MM-dd HH:mm", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var sourceStart)) + { + return false; + } + + TimeZoneInfo sourceTimeZone; + try + { + sourceTimeZone = TimeZoneInfo.FindSystemTimeZoneById(startTimeZone); + } + catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException or ArgumentException) + { + return false; + } + + // WHY(https://github.com/thomasluizon/orbit-api/issues/526): Four seasonal probes cover both hemispheres' daylight-saving offset pairs. + ReadOnlySpan probeMonths = [1, 4, 7, 10]; + foreach (var month in probeMonths) + { + var sourceLocal = new DateTime( + sourceStart.Year, + month, + 15, + sourceStart.Hour, + sourceStart.Minute, + 0, + DateTimeKind.Unspecified); + DateTime accountLocal; + try + { + accountLocal = TimeZoneInfo.ConvertTime(sourceLocal, sourceTimeZone, accountTimeZone); + } + catch (ArgumentException) + { + return false; + } + + if ((accountLocal.Date - sourceLocal.Date).Days != 0) + return false; + } + + return true; + } } public record GetCalendarEventsQuery(Guid UserId) : IRequest>>, IConcurrencyRetryable; diff --git a/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs b/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs index 03e49febc..d3f1876a2 100644 --- a/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs +++ b/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs @@ -138,7 +138,8 @@ private static CalendarEventItem MapEvent(Event ev, string calendarId, string ca ResolveUtc(ev.Start), calendarId, calendarName, - ResolveUtc(ev.End)); + ResolveUtc(ev.End), + ev.Start?.TimeZone); } private async Task ResolveRRule( diff --git a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs index 2f849b0db..ddf580e51 100644 --- a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs @@ -234,6 +234,66 @@ public async Task Handle_TimedTokyoEvent_ProjectsStartIntoSaoPauloTimezone() result.Value[0].RecurrenceRule.Should().BeNull(); } + [Fact] + public async Task Handle_RecurringEventWithSeasonalDateShift_OmitsRecurrenceRule() + { + var user = CreateTestUser(); + user.SetTimeZone("America/Sao_Paulo").IsSuccess.Should().BeTrue(); + StubSuccessfulFetch( + user, + new CalendarEventItem( + "evt_lisbon_seasonal_shift", + "Lisbon early meeting", + null, + "2026-01-15", + "03:30", + "04:30", + true, + "RRULE:FREQ=WEEKLY;BYDAY=TH", + [], + StartUtc: new DateTime(2026, 1, 15, 3, 30, 0, DateTimeKind.Utc), + EndUtc: new DateTime(2026, 1, 15, 4, 30, 0, DateTimeKind.Utc), + StartTimeZone: "Europe/Lisbon")); + + var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().ContainSingle(); + result.Value[0].StartDate.Should().Be("2026-01-15"); + result.Value[0].StartTime.Should().Be("00:30"); + result.Value[0].RecurrenceRule.Should().BeNull(); + } + + [Fact] + public async Task Handle_RecurringEventWithStableSeasonalDate_KeepsRecurrenceRule() + { + var user = CreateTestUser(); + user.SetTimeZone("America/Sao_Paulo").IsSuccess.Should().BeTrue(); + StubSuccessfulFetch( + user, + new CalendarEventItem( + "evt_lisbon_stable", + "Lisbon afternoon meeting", + null, + "2026-01-15", + "15:00", + "16:00", + true, + "RRULE:FREQ=WEEKLY;BYDAY=TH", + [], + StartUtc: new DateTime(2026, 1, 15, 15, 0, 0, DateTimeKind.Utc), + EndUtc: new DateTime(2026, 1, 15, 16, 0, 0, DateTimeKind.Utc), + StartTimeZone: "Europe/Lisbon")); + + var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().ContainSingle(); + result.Value[0].StartDate.Should().Be("2026-01-15"); + result.Value[0].StartTime.Should().Be("12:00"); + result.Value[0].RecurrenceRule.Should().Be("RRULE:FREQ=WEEKLY;BYDAY=TH"); + } + [Fact] public async Task Handle_TimedEventWithoutEndUtc_ProjectsStartAndOmitsEndTime() { diff --git a/tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs b/tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs index e57c7baca..3197125e4 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs @@ -122,7 +122,8 @@ public async Task FetchAsync_TimedEvent_PreservesStartAndEndUtcInstants() Summary = "Tokyo breakfast", Start = new EventDateTime { - DateTimeDateTimeOffset = new DateTimeOffset(2026, 4, 15, 8, 0, 0, TimeSpan.FromHours(9)) + DateTimeDateTimeOffset = new DateTimeOffset(2026, 4, 15, 8, 0, 0, TimeSpan.FromHours(9)), + TimeZone = "Asia/Tokyo" }, End = new EventDateTime { @@ -135,6 +136,7 @@ public async Task FetchAsync_TimedEvent_PreservesStartAndEndUtcInstants() result.Should().ContainSingle(); result[0].StartUtc.Should().Be(new DateTime(2026, 4, 14, 23, 0, 0, DateTimeKind.Utc)); result[0].EndUtc.Should().Be(new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc)); + result[0].StartTimeZone.Should().Be("Asia/Tokyo"); } [Fact] From ff892dc6787586efdb702c35368021f72acce7ba Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Wed, 16 Sep 2026 14:31:09 -0300 Subject: [PATCH 08/15] Project calendar recurrence weekdays --- .../Queries/GetCalendarEventsQuery.cs | 89 ++++++------- .../Services/GoogleCalendarEventFetcher.cs | 3 +- .../GetCalendarEventsQueryHandlerTests.cs | 117 +++++++++++++++--- .../GoogleCalendarEventFetcherTests.cs | 1 - 4 files changed, 138 insertions(+), 72 deletions(-) diff --git a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs index 5cf661caa..0edd18187 100644 --- a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs +++ b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text.RegularExpressions; using MediatR; using Microsoft.Extensions.Logging; using Orbit.Application.Behaviors; @@ -10,7 +11,7 @@ namespace Orbit.Application.Calendar.Queries; -public record CalendarEventItem( +public partial record CalendarEventItem( string Id, string Title, string? Description, @@ -23,8 +24,7 @@ public record CalendarEventItem( DateTime? StartUtc = null, string CalendarId = "", string CalendarName = "", - DateTime? EndUtc = null, - string? StartTimeZone = null) + DateTime? EndUtc = null) { internal CalendarEventItem ProjectTo(TimeZoneInfo timeZone) { @@ -50,68 +50,57 @@ internal CalendarEventItem ProjectTo(TimeZoneInfo timeZone) && string.CompareOrdinal(projectedEndTime, projectedStartTime) > 0 ? projectedEndTime : null, - RecurrenceRule = RecurrenceRule is not null - && IsRecurrenceDateStable(StartDate, StartTime, StartTimeZone, timeZone) - ? RecurrenceRule - : null + RecurrenceRule = ProjectRecurrenceRule(RecurrenceRule, StartDate, localStart) }; } - private static bool IsRecurrenceDateStable( - string? startDate, - string? startTime, - string? startTimeZone, - TimeZoneInfo accountTimeZone) + private static string? ProjectRecurrenceRule( + string? recurrenceRule, + string? sourceStartDate, + DateTime localStart) { - if (string.IsNullOrWhiteSpace(startTimeZone) - || !DateTime.TryParseExact( - $"{startDate} {startTime}", - "yyyy-MM-dd HH:mm", + if (recurrenceRule is null + || !DateOnly.TryParseExact( + sourceStartDate, + "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var sourceStart)) { - return false; + return recurrenceRule; } - TimeZoneInfo sourceTimeZone; - try - { - sourceTimeZone = TimeZoneInfo.FindSystemTimeZoneById(startTimeZone); - } - catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException or ArgumentException) - { - return false; - } + var dayShift = ((int)localStart.DayOfWeek - (int)sourceStart.DayOfWeek + 7) % 7; + if (dayShift == 0) + return recurrenceRule; - // WHY(https://github.com/thomasluizon/orbit-api/issues/526): Four seasonal probes cover both hemispheres' daylight-saving offset pairs. - ReadOnlySpan probeMonths = [1, 4, 7, 10]; - foreach (var month in probeMonths) + return ByDayTermPattern().Replace(recurrenceRule, match => { - var sourceLocal = new DateTime( - sourceStart.Year, - month, - 15, - sourceStart.Hour, - sourceStart.Minute, - 0, - DateTimeKind.Unspecified); - DateTime accountLocal; - try - { - accountLocal = TimeZoneInfo.ConvertTime(sourceLocal, sourceTimeZone, accountTimeZone); - } - catch (ArgumentException) - { - return false; - } + var shiftedDays = match.Groups[3].Value + .Split(',') + .Select(token => ShiftByDayToken(token, dayShift)); + return $"{match.Groups[1].Value}{match.Groups[2].Value}{string.Join(',', shiftedDays)}"; + }); + } - if ((accountLocal.Date - sourceLocal.Date).Days != 0) - return false; - } + private static string ShiftByDayToken(string token, int dayShift) + { + var match = ByDayTokenPattern().Match(token); + if (!match.Success) + return token; - return true; + var sourceDay = Array.IndexOf(RecurrenceWeekdays, match.Groups[3].Value.ToUpperInvariant()); + var projectedDay = RecurrenceWeekdays[(sourceDay + dayShift) % 7]; + return $"{match.Groups[1].Value}{match.Groups[2].Value}{projectedDay}{match.Groups[4].Value}"; } + + private static readonly string[] RecurrenceWeekdays = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]; + + [GeneratedRegex(@"(^|;|RRULE:)(BYDAY=)([^;]*)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex ByDayTermPattern(); + + [GeneratedRegex(@"^(\s*)([+-]?\d+)?(SU|MO|TU|WE|TH|FR|SA)(\s*)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex ByDayTokenPattern(); } public record GetCalendarEventsQuery(Guid UserId) : IRequest>>, IConcurrencyRetryable; diff --git a/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs b/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs index d3f1876a2..03e49febc 100644 --- a/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs +++ b/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs @@ -138,8 +138,7 @@ private static CalendarEventItem MapEvent(Event ev, string calendarId, string ca ResolveUtc(ev.Start), calendarId, calendarName, - ResolveUtc(ev.End), - ev.Start?.TimeZone); + ResolveUtc(ev.End)); } private async Task ResolveRRule( diff --git a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs index ddf580e51..1d6c443ca 100644 --- a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs @@ -219,7 +219,7 @@ public async Task Handle_TimedTokyoEvent_ProjectsStartIntoSaoPauloTimezone() "08:00", "09:00", true, - "RRULE:FREQ=WEEKLY;BYDAY=WE", + "RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;WKST=SU", [], StartUtc: new DateTime(2026, 4, 14, 23, 0, 0, DateTimeKind.Utc), EndUtc: new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc))); @@ -231,11 +231,11 @@ public async Task Handle_TimedTokyoEvent_ProjectsStartIntoSaoPauloTimezone() result.Value[0].StartDate.Should().Be("2026-04-14"); result.Value[0].StartTime.Should().Be("20:00"); result.Value[0].EndTime.Should().Be("21:00"); - result.Value[0].RecurrenceRule.Should().BeNull(); + result.Value[0].RecurrenceRule.Should().Be("RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=SU,TU;WKST=SU"); } [Fact] - public async Task Handle_RecurringEventWithSeasonalDateShift_OmitsRecurrenceRule() + public async Task Handle_RecurringEventOnSameLocalDate_KeepsRecurrenceRuleUnchanged() { var user = CreateTestUser(); user.SetTimeZone("America/Sao_Paulo").IsSuccess.Should().BeTrue(); @@ -252,8 +252,7 @@ public async Task Handle_RecurringEventWithSeasonalDateShift_OmitsRecurrenceRule "RRULE:FREQ=WEEKLY;BYDAY=TH", [], StartUtc: new DateTime(2026, 1, 15, 3, 30, 0, DateTimeKind.Utc), - EndUtc: new DateTime(2026, 1, 15, 4, 30, 0, DateTimeKind.Utc), - StartTimeZone: "Europe/Lisbon")); + EndUtc: new DateTime(2026, 1, 15, 4, 30, 0, DateTimeKind.Utc))); var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); @@ -261,37 +260,117 @@ public async Task Handle_RecurringEventWithSeasonalDateShift_OmitsRecurrenceRule result.Value.Should().ContainSingle(); result.Value[0].StartDate.Should().Be("2026-01-15"); result.Value[0].StartTime.Should().Be("00:30"); - result.Value[0].RecurrenceRule.Should().BeNull(); + result.Value[0].RecurrenceRule.Should().Be("RRULE:FREQ=WEEKLY;BYDAY=TH"); } [Fact] - public async Task Handle_RecurringEventWithStableSeasonalDate_KeepsRecurrenceRule() + public async Task Handle_RecurringEventWithOrdinalDay_ShiftsDayAndKeepsOrdinal() { var user = CreateTestUser(); user.SetTimeZone("America/Sao_Paulo").IsSuccess.Should().BeTrue(); StubSuccessfulFetch( user, new CalendarEventItem( - "evt_lisbon_stable", - "Lisbon afternoon meeting", + "evt_tokyo_ordinal", + "Tokyo monthly meeting", null, - "2026-01-15", - "15:00", - "16:00", + "2026-04-15", + "08:00", + "09:00", true, - "RRULE:FREQ=WEEKLY;BYDAY=TH", + "RRULE:FREQ=MONTHLY;BYDAY=2TU", [], - StartUtc: new DateTime(2026, 1, 15, 15, 0, 0, DateTimeKind.Utc), - EndUtc: new DateTime(2026, 1, 15, 16, 0, 0, DateTimeKind.Utc), - StartTimeZone: "Europe/Lisbon")); + StartUtc: new DateTime(2026, 4, 14, 23, 0, 0, DateTimeKind.Utc), + EndUtc: new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc))); var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); result.IsSuccess.Should().BeTrue(); result.Value.Should().ContainSingle(); - result.Value[0].StartDate.Should().Be("2026-01-15"); - result.Value[0].StartTime.Should().Be("12:00"); - result.Value[0].RecurrenceRule.Should().Be("RRULE:FREQ=WEEKLY;BYDAY=TH"); + result.Value[0].RecurrenceRule.Should().Be("RRULE:FREQ=MONTHLY;BYDAY=2MO"); + } + + [Fact] + public async Task Handle_RecurringEventWithoutByDay_KeepsRecurrenceRuleUnchanged() + { + var user = CreateTestUser(); + user.SetTimeZone("America/Sao_Paulo").IsSuccess.Should().BeTrue(); + StubSuccessfulFetch( + user, + new CalendarEventItem( + "evt_tokyo_without_byday", + "Tokyo monthly meeting", + null, + "2026-04-15", + "08:00", + "09:00", + true, + "RRULE:FREQ=MONTHLY;BYMONTHDAY=15", + [], + StartUtc: new DateTime(2026, 4, 14, 23, 0, 0, DateTimeKind.Utc), + EndUtc: new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc))); + + var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().ContainSingle(); + result.Value[0].RecurrenceRule.Should().Be("RRULE:FREQ=MONTHLY;BYMONTHDAY=15"); + } + + [Fact] + public async Task Handle_DenverRecurringEventProjectedToPhoenix_KeepsShiftedRecurrenceRule() + { + var user = CreateTestUser(); + user.SetTimeZone("America/Phoenix").IsSuccess.Should().BeTrue(); + StubSuccessfulFetch( + user, + new CalendarEventItem( + "evt_denver", + "Denver midnight meeting", + null, + "2026-07-15", + "00:30", + "01:30", + true, + "RRULE:FREQ=WEEKLY;BYDAY=WE", + [], + StartUtc: new DateTime(2026, 7, 15, 6, 30, 0, DateTimeKind.Utc), + EndUtc: new DateTime(2026, 7, 15, 7, 30, 0, DateTimeKind.Utc))); + + var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().ContainSingle(); + result.Value[0].StartDate.Should().Be("2026-07-14"); + result.Value[0].RecurrenceRule.Should().Be("RRULE:FREQ=WEEKLY;BYDAY=TU"); + } + + [Fact] + public async Task Handle_LisbonRecurringEventProjectedToSaoPaulo_NeverDropsRecurrenceRule() + { + var user = CreateTestUser(); + user.SetTimeZone("America/Sao_Paulo").IsSuccess.Should().BeTrue(); + StubSuccessfulFetch( + user, + new CalendarEventItem( + "evt_lisbon", + "Lisbon midnight meeting", + null, + "2026-07-15", + "00:30", + "01:30", + true, + "RRULE:FREQ=WEEKLY;BYDAY=WE", + [], + StartUtc: new DateTime(2026, 7, 14, 23, 30, 0, DateTimeKind.Utc), + EndUtc: new DateTime(2026, 7, 15, 0, 30, 0, DateTimeKind.Utc))); + + var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().ContainSingle(); + result.Value[0].StartDate.Should().Be("2026-07-14"); + result.Value[0].RecurrenceRule.Should().Be("RRULE:FREQ=WEEKLY;BYDAY=TU"); } [Fact] diff --git a/tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs b/tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs index 3197125e4..2d65ad0eb 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs @@ -136,7 +136,6 @@ public async Task FetchAsync_TimedEvent_PreservesStartAndEndUtcInstants() result.Should().ContainSingle(); result[0].StartUtc.Should().Be(new DateTime(2026, 4, 14, 23, 0, 0, DateTimeKind.Utc)); result[0].EndUtc.Should().Be(new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc)); - result[0].StartTimeZone.Should().Be("Asia/Tokyo"); } [Fact] From 82134581f06534b6454126c7dc730b10dcbaac45 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Wed, 16 Sep 2026 17:46:04 -0300 Subject: [PATCH 09/15] Keep calendar recurrence unchanged --- .../Queries/GetCalendarEventsQuery.cs | 54 +----------- .../GetCalendarEventsQueryHandlerTests.cs | 85 +------------------ 2 files changed, 3 insertions(+), 136 deletions(-) diff --git a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs index 0edd18187..1715ccd7e 100644 --- a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs +++ b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs @@ -1,5 +1,4 @@ using System.Globalization; -using System.Text.RegularExpressions; using MediatR; using Microsoft.Extensions.Logging; using Orbit.Application.Behaviors; @@ -11,7 +10,7 @@ namespace Orbit.Application.Calendar.Queries; -public partial record CalendarEventItem( +public record CalendarEventItem( string Id, string Title, string? Description, @@ -49,58 +48,9 @@ internal CalendarEventItem ProjectTo(TimeZoneInfo timeZone) && sameDayEnd.Date == localStart.Date && string.CompareOrdinal(projectedEndTime, projectedStartTime) > 0 ? projectedEndTime - : null, - RecurrenceRule = ProjectRecurrenceRule(RecurrenceRule, StartDate, localStart) + : null }; } - - private static string? ProjectRecurrenceRule( - string? recurrenceRule, - string? sourceStartDate, - DateTime localStart) - { - if (recurrenceRule is null - || !DateOnly.TryParseExact( - sourceStartDate, - "yyyy-MM-dd", - CultureInfo.InvariantCulture, - DateTimeStyles.None, - out var sourceStart)) - { - return recurrenceRule; - } - - var dayShift = ((int)localStart.DayOfWeek - (int)sourceStart.DayOfWeek + 7) % 7; - if (dayShift == 0) - return recurrenceRule; - - return ByDayTermPattern().Replace(recurrenceRule, match => - { - var shiftedDays = match.Groups[3].Value - .Split(',') - .Select(token => ShiftByDayToken(token, dayShift)); - return $"{match.Groups[1].Value}{match.Groups[2].Value}{string.Join(',', shiftedDays)}"; - }); - } - - private static string ShiftByDayToken(string token, int dayShift) - { - var match = ByDayTokenPattern().Match(token); - if (!match.Success) - return token; - - var sourceDay = Array.IndexOf(RecurrenceWeekdays, match.Groups[3].Value.ToUpperInvariant()); - var projectedDay = RecurrenceWeekdays[(sourceDay + dayShift) % 7]; - return $"{match.Groups[1].Value}{match.Groups[2].Value}{projectedDay}{match.Groups[4].Value}"; - } - - private static readonly string[] RecurrenceWeekdays = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"]; - - [GeneratedRegex(@"(^|;|RRULE:)(BYDAY=)([^;]*)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] - private static partial Regex ByDayTermPattern(); - - [GeneratedRegex(@"^(\s*)([+-]?\d+)?(SU|MO|TU|WE|TH|FR|SA)(\s*)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] - private static partial Regex ByDayTokenPattern(); } public record GetCalendarEventsQuery(Guid UserId) : IRequest>>, IConcurrencyRetryable; diff --git a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs index 1d6c443ca..b81a86b0e 100644 --- a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs @@ -231,7 +231,7 @@ public async Task Handle_TimedTokyoEvent_ProjectsStartIntoSaoPauloTimezone() result.Value[0].StartDate.Should().Be("2026-04-14"); result.Value[0].StartTime.Should().Be("20:00"); result.Value[0].EndTime.Should().Be("21:00"); - result.Value[0].RecurrenceRule.Should().Be("RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=SU,TU;WKST=SU"); + result.Value[0].RecurrenceRule.Should().Be("RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;WKST=SU"); } [Fact] @@ -263,33 +263,6 @@ public async Task Handle_RecurringEventOnSameLocalDate_KeepsRecurrenceRuleUnchan result.Value[0].RecurrenceRule.Should().Be("RRULE:FREQ=WEEKLY;BYDAY=TH"); } - [Fact] - public async Task Handle_RecurringEventWithOrdinalDay_ShiftsDayAndKeepsOrdinal() - { - var user = CreateTestUser(); - user.SetTimeZone("America/Sao_Paulo").IsSuccess.Should().BeTrue(); - StubSuccessfulFetch( - user, - new CalendarEventItem( - "evt_tokyo_ordinal", - "Tokyo monthly meeting", - null, - "2026-04-15", - "08:00", - "09:00", - true, - "RRULE:FREQ=MONTHLY;BYDAY=2TU", - [], - StartUtc: new DateTime(2026, 4, 14, 23, 0, 0, DateTimeKind.Utc), - EndUtc: new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc))); - - var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().ContainSingle(); - result.Value[0].RecurrenceRule.Should().Be("RRULE:FREQ=MONTHLY;BYDAY=2MO"); - } - [Fact] public async Task Handle_RecurringEventWithoutByDay_KeepsRecurrenceRuleUnchanged() { @@ -317,62 +290,6 @@ public async Task Handle_RecurringEventWithoutByDay_KeepsRecurrenceRuleUnchanged result.Value[0].RecurrenceRule.Should().Be("RRULE:FREQ=MONTHLY;BYMONTHDAY=15"); } - [Fact] - public async Task Handle_DenverRecurringEventProjectedToPhoenix_KeepsShiftedRecurrenceRule() - { - var user = CreateTestUser(); - user.SetTimeZone("America/Phoenix").IsSuccess.Should().BeTrue(); - StubSuccessfulFetch( - user, - new CalendarEventItem( - "evt_denver", - "Denver midnight meeting", - null, - "2026-07-15", - "00:30", - "01:30", - true, - "RRULE:FREQ=WEEKLY;BYDAY=WE", - [], - StartUtc: new DateTime(2026, 7, 15, 6, 30, 0, DateTimeKind.Utc), - EndUtc: new DateTime(2026, 7, 15, 7, 30, 0, DateTimeKind.Utc))); - - var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().ContainSingle(); - result.Value[0].StartDate.Should().Be("2026-07-14"); - result.Value[0].RecurrenceRule.Should().Be("RRULE:FREQ=WEEKLY;BYDAY=TU"); - } - - [Fact] - public async Task Handle_LisbonRecurringEventProjectedToSaoPaulo_NeverDropsRecurrenceRule() - { - var user = CreateTestUser(); - user.SetTimeZone("America/Sao_Paulo").IsSuccess.Should().BeTrue(); - StubSuccessfulFetch( - user, - new CalendarEventItem( - "evt_lisbon", - "Lisbon midnight meeting", - null, - "2026-07-15", - "00:30", - "01:30", - true, - "RRULE:FREQ=WEEKLY;BYDAY=WE", - [], - StartUtc: new DateTime(2026, 7, 14, 23, 30, 0, DateTimeKind.Utc), - EndUtc: new DateTime(2026, 7, 15, 0, 30, 0, DateTimeKind.Utc))); - - var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); - - result.IsSuccess.Should().BeTrue(); - result.Value.Should().ContainSingle(); - result.Value[0].StartDate.Should().Be("2026-07-14"); - result.Value[0].RecurrenceRule.Should().Be("RRULE:FREQ=WEEKLY;BYDAY=TU"); - } - [Fact] public async Task Handle_TimedEventWithoutEndUtc_ProjectsStartAndOmitsEndTime() { From 44611e5fbb18a26d66437b6e6255034cbee806b4 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Thu, 17 Sep 2026 00:14:39 -0300 Subject: [PATCH 10/15] Omit unrepresentable recurring calendar events --- .../Queries/GetCalendarEventsQuery.cs | 18 ++++++++- .../GetCalendarSyncSuggestionsQuery.cs | 7 ++-- .../GetCalendarEventsQueryHandlerTests.cs | 8 +--- ...alendarSyncSuggestionsQueryHandlerTests.cs | 39 +++++++++++++++++++ 4 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs index 1715ccd7e..d4fd07c7d 100644 --- a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs +++ b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs @@ -51,6 +51,20 @@ internal CalendarEventItem ProjectTo(TimeZoneInfo timeZone) : null }; } + + internal bool HasUnrepresentableRecurrenceAfterProjection(CalendarEventItem projected) + { + if (RecurrenceRule is null + || string.Equals(StartDate, projected.StartDate, StringComparison.Ordinal)) + { + return false; + } + + var ruleBody = RecurrenceRule.StartsWith("RRULE:", StringComparison.OrdinalIgnoreCase) + ? RecurrenceRule["RRULE:".Length..] + : RecurrenceRule; + return ruleBody.Split(';').Any(term => term.StartsWith("BYDAY=", StringComparison.OrdinalIgnoreCase)); + } } public record GetCalendarEventsQuery(Guid UserId) : IRequest>>, IConcurrencyRetryable; @@ -92,7 +106,9 @@ public async Task>> Handle(GetCalendarEventsQuery var timeZone = TimeZoneHelper.FindTimeZone(user.TimeZone); var items = fetched .Where(item => !importedEventIds.Contains(item.Id)) - .Select(item => item.ProjectTo(timeZone)) + .Select(item => (Source: item, Projected: item.ProjectTo(timeZone))) + .Where(item => !item.Source.HasUnrepresentableRecurrenceAfterProjection(item.Projected)) + .Select(item => item.Projected) .ToList(); return Result.Success(items); diff --git a/src/Orbit.Application/Calendar/Queries/GetCalendarSyncSuggestionsQuery.cs b/src/Orbit.Application/Calendar/Queries/GetCalendarSyncSuggestionsQuery.cs index caa5f981b..08a6f36df 100644 --- a/src/Orbit.Application/Calendar/Queries/GetCalendarSyncSuggestionsQuery.cs +++ b/src/Orbit.Application/Calendar/Queries/GetCalendarSyncSuggestionsQuery.cs @@ -87,9 +87,10 @@ public async Task>> Handle(GetCalendarSy { if (importedEventIds.Contains(suggestion.GoogleEventId)) return null; - var eventItem = DeserializeEvent(suggestion); - if (eventItem is null) return null; - eventItem = eventItem.ProjectTo(timeZone); + var sourceEvent = DeserializeEvent(suggestion); + if (sourceEvent is null) return null; + var eventItem = sourceEvent.ProjectTo(timeZone); + if (sourceEvent.HasUnrepresentableRecurrenceAfterProjection(eventItem)) return null; if (ResolveStartDate(eventItem, suggestion.StartDateUtc, timeZone) < userToday) return null; if (selectedCalendars is not null && !string.IsNullOrEmpty(eventItem.CalendarId) diff --git a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs index b81a86b0e..5503eb10e 100644 --- a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs @@ -205,7 +205,7 @@ public async Task Handle_FiltersOutAlreadyImportedHabitsByGoogleEventId() } [Fact] - public async Task Handle_TimedTokyoEvent_ProjectsStartIntoSaoPauloTimezone() + public async Task Handle_RecurringByDayEventCrossingAccountDate_OmitsEvent() { var user = CreateTestUser(); user.SetTimeZone("America/Sao_Paulo").IsSuccess.Should().BeTrue(); @@ -227,11 +227,7 @@ public async Task Handle_TimedTokyoEvent_ProjectsStartIntoSaoPauloTimezone() var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); result.IsSuccess.Should().BeTrue(); - result.Value.Should().ContainSingle(); - result.Value[0].StartDate.Should().Be("2026-04-14"); - result.Value[0].StartTime.Should().Be("20:00"); - result.Value[0].EndTime.Should().Be("21:00"); - result.Value[0].RecurrenceRule.Should().Be("RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;WKST=SU"); + result.Value.Should().BeEmpty(); } [Fact] diff --git a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarSyncSuggestionsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarSyncSuggestionsQueryHandlerTests.cs index be52d2351..7961dac6b 100644 --- a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarSyncSuggestionsQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarSyncSuggestionsQueryHandlerTests.cs @@ -202,6 +202,45 @@ public async Task Handle_TimedSuggestion_ProjectsBeforeComparingWithUserToday() result.Value[0].Event.EndTime.Should().Be("01:00"); } + [Fact] + public async Task Handle_StoredByDaySuggestionCrossingAccountDate_OmitsEvent() + { + var user = User.Create("Test", "test@example.com").Value; + user.SetTimeZone("America/Sao_Paulo").IsSuccess.Should().BeTrue(); + _userRepo.GetByIdAsync(Arg.Any(), Arg.Any()).Returns(user); + + var startUtc = new DateTime(2026, 4, 14, 23, 0, 0, DateTimeKind.Utc); + var eventItem = new CalendarEventItem( + "event-stored-recurring", + "Tokyo breakfast", + null, + "2026-04-15", + "08:00", + "09:00", + true, + "RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;WKST=SU", + [], + StartUtc: startUtc, + EndUtc: startUtc.AddHours(1)); + var suggestion = GoogleCalendarSyncSuggestion.Create( + UserId, + "gcal-stored-recurring", + eventItem.Title, + startUtc, + JsonSerializer.Serialize(eventItem), + startUtc); + + _suggestionRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(new List { suggestion }.AsReadOnly()); + + var result = await _handler.Handle(new GetCalendarSyncSuggestionsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeEmpty(); + } + [Fact] public async Task Handle_LegacyTimedSuggestionWithoutEndUtc_MatchesEventsQueryProjection() { From 88c3de5211a9ab12317dce021af75861c6db2508 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Fri, 18 Sep 2026 13:39:07 -0300 Subject: [PATCH 11/15] Gate projected calendar recurrence on the whole fetch window Closes the six review findings on pull request 521. 1. The weekday gate now walks every expanded occurrence Google returned for a recurring master, not the one sampled instance. The fetcher collects each instance start with the source calendar's own offset into a JsonIgnore'd ExpandedOccurrences list, so a Lisbon BYDAY=TH series that is stable in January and shifts to Wednesday in July is refused. The empty list still falls back to the sampled instance for an all-day series and for a suggestion row read back from the database. 2. The legacy title plus date plus time key only excludes a suggestion when exactly one candidate carries it. Inside a fall-back repeated hour two events project to the same local time, so the key proves nothing and both stay. This mirrors the group.Count() == 1 guard the auto sync reconciler already applies to the same key. 3. An omitted end time now logs its reason at Debug with the event id and the user id. EndUtc already ships the real duration, so the client keeps it. 4. The refusal here and the clamp in HabitScheduleService.IsMonthlyMatch are reconciled in a doc comment: Orbit clamps a rule it owns and refuses a rule it imports and cannot re-express without inventing a weekday. 5. Both new call sites pass the logger and the user id to FindTimeZone, which also catches InvalidTimeZoneException so a corrupt zone stops escaping as a 500. User.SetTimeZone trims at the boundary and rejects a blank id. 6. Asia/Kathmandu at plus 05:45 and Pacific/Chatham at plus 12:45 cover the sub hour offset gap. Co-Authored-By: Claude Opus 5 --- .../Queries/GetCalendarEventsQuery.cs | 97 +++++-- .../GetCalendarSyncSuggestionsQuery.cs | 50 +++- .../Common/TimeZoneHelper.cs | 4 +- src/Orbit.Domain/Entities/User.cs | 18 +- .../Services/GoogleCalendarEventFetcher.cs | 37 ++- .../Common/TimeZoneHelperTests.cs | 19 ++ .../GetCalendarEventsQueryHandlerTests.cs | 258 +++++++++++++++++- ...alendarSyncSuggestionsQueryHandlerTests.cs | 135 ++++++++- .../Orbit.Domain.Tests/Entities/UserTests.cs | 26 ++ .../GoogleCalendarEventFetcherTests.cs | 43 +++ 10 files changed, 649 insertions(+), 38 deletions(-) diff --git a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs index d4fd07c7d..03be072ff 100644 --- a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs +++ b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text.Json.Serialization; using MediatR; using Microsoft.Extensions.Logging; using Orbit.Application.Behaviors; @@ -25,6 +26,16 @@ public record CalendarEventItem( string CalendarName = "", DateTime? EndUtc = null) { + /// + /// Every expanded instance Google returned for this recurring master inside the fetch window, + /// each carrying the source zone's own offset at that instant. Server-only: the + /// keeps it out of the GET /calendar/events response + /// body and out of the stored suggestion JSON, so no client contract changes. Empty for a single + /// event, for an all-day series, and for a suggestion row read back from the database. + /// + [JsonIgnore] + public IReadOnlyList ExpandedOccurrences { get; init; } = []; + internal CalendarEventItem ProjectTo(TimeZoneInfo timeZone) { if (StartTime is null || StartUtc is null) @@ -52,17 +63,59 @@ internal CalendarEventItem ProjectTo(TimeZoneInfo timeZone) }; } - internal bool HasUnrepresentableRecurrenceAfterProjection(CalendarEventItem projected) + /// + /// True when a BYDAY rule names one weekday in the source calendar but names another + /// weekday for at least one occurrence once projected into . + /// + /// + /// + /// The gate walks , not the single sampled instance, because a + /// series can be stable at its first occurrence and shift at a later one: an + /// America/Sao_Paulo account reading a 03:30 Europe/Lisbon BYDAY=TH series + /// sees Thursday in January and Wednesday from the Lisbon transition onward. Sampling January + /// alone would ship a rule that is wrong for half the year. When the list is empty the check + /// falls back to the sampled instance: an all-day series carries no instant, and a suggestion + /// read back from the database drops the list because it is d. + /// + /// + /// Refusal here and the clamp in HabitScheduleService.IsMonthlyMatch answer two different + /// questions on purpose. The scheduler clamps a rule Orbit OWNS, already expressed in the user's + /// own timezone, so "monthly on the 31st" firing on 28 February keeps the user's stated intent. + /// This gate judges a rule Orbit IMPORTS and does not own. A Lisbon BYDAY=TH rule cannot be + /// re-expressed in Sao Paulo without picking a weekday the source never named, and either choice + /// is wrong for half the year, so Orbit refuses rather than invents. The two compose: refuse at + /// the import boundary, then apply best effort inside, because an event that passes this gate + /// becomes an Orbit-owned habit whose rule the scheduler may then clamp. + /// + /// + internal bool HasUnrepresentableRecurrenceAfterProjection(CalendarEventItem projected, TimeZoneInfo timeZone) { - if (RecurrenceRule is null - || string.Equals(StartDate, projected.StartDate, StringComparison.Ordinal)) - { + if (RecurrenceRule is null || !NamesAWeekday(RecurrenceRule)) return false; - } - var ruleBody = RecurrenceRule.StartsWith("RRULE:", StringComparison.OrdinalIgnoreCase) - ? RecurrenceRule["RRULE:".Length..] - : RecurrenceRule; + if (ExpandedOccurrences.Count == 0) + return !string.Equals(StartDate, projected.StartDate, StringComparison.Ordinal); + + return ExpandedOccurrences.Any(occurrence => + TimeZoneInfo.ConvertTimeFromUtc(occurrence.UtcDateTime, timeZone).Date != occurrence.Date); + } + + /// + /// True when the projection dropped a real end time, so the caller can log the reason. The source + /// carried both instants, yet the projected end no longer follows the projected start on the + /// projected start date. still carries the real duration for the client. + /// + internal bool DropsEndTimeAfterProjection(CalendarEventItem projected) + => StartTime is not null + && StartUtc is not null + && EndUtc is not null + && projected.EndTime is null; + + private static bool NamesAWeekday(string recurrenceRule) + { + var ruleBody = recurrenceRule.StartsWith("RRULE:", StringComparison.OrdinalIgnoreCase) + ? recurrenceRule["RRULE:".Length..] + : recurrenceRule; return ruleBody.Split(';').Any(term => term.StartsWith("BYDAY=", StringComparison.OrdinalIgnoreCase)); } } @@ -103,13 +156,22 @@ public async Task>> Handle(GetCalendarEventsQuery accessToken, user.GetSelectedCalendarIds(), updatedMin: null, cancellationToken); var importedEventIds = await BuildImportedEventIdSet(request.UserId, cancellationToken); - var timeZone = TimeZoneHelper.FindTimeZone(user.TimeZone); - var items = fetched - .Where(item => !importedEventIds.Contains(item.Id)) - .Select(item => (Source: item, Projected: item.ProjectTo(timeZone))) - .Where(item => !item.Source.HasUnrepresentableRecurrenceAfterProjection(item.Projected)) - .Select(item => item.Projected) - .ToList(); + var timeZone = TimeZoneHelper.FindTimeZone(user.TimeZone, logger, request.UserId); + var items = new List(); + foreach (var source in fetched) + { + if (importedEventIds.Contains(source.Id)) + continue; + + var projected = source.ProjectTo(timeZone); + if (source.HasUnrepresentableRecurrenceAfterProjection(projected, timeZone)) + continue; + + if (source.DropsEndTimeAfterProjection(projected)) + LogProjectedEndTimeOmitted(logger, source.Id, request.UserId); + + items.Add(projected); + } return Result.Success(items); } @@ -154,7 +216,7 @@ public async Task>> Handle(GetCalendarEventsQuery if (accessToken is null) return null; - await unitOfWork.SaveChangesAsync(cancellationToken); return accessToken; + await unitOfWork.SaveChangesAsync(cancellationToken); return accessToken; } private async Task> BuildImportedEventIdSet(Guid userId, CancellationToken ct) @@ -180,4 +242,7 @@ private async Task> BuildImportedEventIdSet(Guid userId, Cancell [LoggerMessage(EventId = 3, Level = LogLevel.Debug, Message = "Google Calendar reconnect required for user {UserId} (code: {ErrorCode})")] private static partial void LogGoogleCalendarReconnectRequired(ILogger logger, Guid userId, string? errorCode); + + [LoggerMessage(EventId = 4, Level = LogLevel.Debug, Message = "Omitted the end time of calendar event {EventId} for user {UserId}: the projected end does not follow the projected start on the projected start date. EndUtc still carries the duration")] + private static partial void LogProjectedEndTimeOmitted(ILogger logger, string eventId, Guid userId); } diff --git a/src/Orbit.Application/Calendar/Queries/GetCalendarSyncSuggestionsQuery.cs b/src/Orbit.Application/Calendar/Queries/GetCalendarSyncSuggestionsQuery.cs index 08a6f36df..461ac6d0e 100644 --- a/src/Orbit.Application/Calendar/Queries/GetCalendarSyncSuggestionsQuery.cs +++ b/src/Orbit.Application/Calendar/Queries/GetCalendarSyncSuggestionsQuery.cs @@ -64,16 +64,30 @@ public async Task>> Handle(GetCalendarSy h.DueTime?.ToString("HH:mm", CultureInfo.InvariantCulture))) .ToHashSet(StringComparer.Ordinal); - var items = new List(); - var timeZone = TimeZoneHelper.FindTimeZone(user.TimeZone); + var timeZone = TimeZoneHelper.FindTimeZone(user.TimeZone, logger, request.UserId); + var candidates = new List<(CalendarSyncSuggestionItem Item, string LegacyKey)>(); foreach (var suggestion in suggestions.OrderBy(s => s.StartDateUtc)) { var item = TryBuildSuggestionItem( - suggestion, userToday, importedEventIds, importedLegacyKeys, selectedCalendars, timeZone); + suggestion, userToday, importedEventIds, selectedCalendars, timeZone, request.UserId); if (item is not null) - items.Add(item); + { + candidates.Add(( + item, + BuildLegacyMatchKey(item.Event.Title, item.Event.StartDate, item.Event.StartTime))); + } } + var candidatesPerLegacyKey = candidates + .GroupBy(candidate => candidate.LegacyKey, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); + + var items = candidates + .Where(candidate => candidatesPerLegacyKey[candidate.LegacyKey] > 1 + || !importedLegacyKeys.Contains(candidate.LegacyKey)) + .Select(candidate => candidate.Item) + .ToList(); + return Result.Success(items); } @@ -81,24 +95,23 @@ public async Task>> Handle(GetCalendarSy GoogleCalendarSyncSuggestion suggestion, DateOnly userToday, HashSet importedEventIds, - HashSet importedLegacyKeys, HashSet? selectedCalendars, - TimeZoneInfo timeZone) + TimeZoneInfo timeZone, + Guid userId) { if (importedEventIds.Contains(suggestion.GoogleEventId)) return null; var sourceEvent = DeserializeEvent(suggestion); if (sourceEvent is null) return null; var eventItem = sourceEvent.ProjectTo(timeZone); - if (sourceEvent.HasUnrepresentableRecurrenceAfterProjection(eventItem)) return null; + if (sourceEvent.HasUnrepresentableRecurrenceAfterProjection(eventItem, timeZone)) return null; if (ResolveStartDate(eventItem, suggestion.StartDateUtc, timeZone) < userToday) return null; if (selectedCalendars is not null && !string.IsNullOrEmpty(eventItem.CalendarId) && !selectedCalendars.Contains(eventItem.CalendarId)) return null; - if (importedLegacyKeys.Contains(BuildLegacyMatchKey( - eventItem.Title, - eventItem.StartDate, - eventItem.StartTime))) return null; + + if (sourceEvent.DropsEndTimeAfterProjection(eventItem)) + LogProjectedEndTimeOmitted(logger, suggestion.GoogleEventId, userId); return new CalendarSyncSuggestionItem( suggestion.Id, @@ -140,6 +153,18 @@ private static DateOnly ResolveStartDate( } } + /// + /// Title plus projected local date plus projected local time, so a habit imported before + /// GoogleEventId existed can still be matched to its event. + /// + /// + /// The key only proves a prior import when exactly one candidate carries it. Two candidates share + /// it legitimately inside the repeated hour of a fall-back transition, where 05:30 and 06:30 UTC + /// both project to 01:30 local, and at most one of them is the habit the user already imported. + /// Excluding on an ambiguous key hides a real event with no way back, so an ambiguous key excludes + /// nothing and the user dismisses the duplicate instead. This mirrors the group.Count() == 1 + /// guard the auto-sync reconciler already applies to the same key. + /// private static string BuildLegacyMatchKey(string title, string? startDate, string? startTime) { return $"{title.Trim().ToLowerInvariant()}|{startDate ?? ""}|{startTime ?? ""}"; @@ -147,4 +172,7 @@ private static string BuildLegacyMatchKey(string title, string? startDate, strin [LoggerMessage(EventId = 1, Level = LogLevel.Warning, Message = "Failed to deserialize sync suggestion {SuggestionId}")] private static partial void LogDeserializeSuggestionFailed(ILogger logger, Exception ex, Guid suggestionId); + + [LoggerMessage(EventId = 2, Level = LogLevel.Debug, Message = "Omitted the end time of calendar event {EventId} for user {UserId}: the projected end does not follow the projected start on the projected start date. EndUtc still carries the duration")] + private static partial void LogProjectedEndTimeOmitted(ILogger logger, string eventId, Guid userId); } diff --git a/src/Orbit.Application/Common/TimeZoneHelper.cs b/src/Orbit.Application/Common/TimeZoneHelper.cs index 3b61ff0b3..a6b5bbb52 100644 --- a/src/Orbit.Application/Common/TimeZoneHelper.cs +++ b/src/Orbit.Application/Common/TimeZoneHelper.cs @@ -37,9 +37,9 @@ public static TimeZoneInfo FindTimeZone(string? timeZoneId, ILogger? logger = nu { return TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); } - catch (TimeZoneNotFoundException ex) + catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException) { - logger?.LogWarning(ex, "Unknown timezone {TimeZone} for user {UserId}, falling back to UTC", + logger?.LogWarning(ex, "Unusable timezone {TimeZone} for user {UserId}, falling back to UTC", timeZoneId, userId); return TimeZoneInfo.Utc; } diff --git a/src/Orbit.Domain/Entities/User.cs b/src/Orbit.Domain/Entities/User.cs index 1d7566daa..52f92ed5f 100644 --- a/src/Orbit.Domain/Entities/User.cs +++ b/src/Orbit.Domain/Entities/User.cs @@ -138,17 +138,27 @@ public Result SetName(string name) return Result.Success(); } + /// + /// Stores the IANA zone id after trimming it. Trimming here is the boundary fix for + /// "America/Sao_Paulo ": every reader calls TimeZoneHelper.FindTimeZone, which stays + /// strict on purpose so a malformed stored id is logged rather than silently repaired. + /// A blank id is rejected instead of stored, so no reader ever falls back to UTC over whitespace. + /// public Result SetTimeZone(string ianaTimeZoneId) { + var trimmedTimeZoneId = ianaTimeZoneId?.Trim(); + if (string.IsNullOrEmpty(trimmedTimeZoneId)) + return Result.Failure(DomainErrors.InvalidTimezone.Format(ianaTimeZoneId ?? string.Empty)); + try { - TimeZoneInfo.FindSystemTimeZoneById(ianaTimeZoneId); - TimeZone = ianaTimeZoneId; + TimeZoneInfo.FindSystemTimeZoneById(trimmedTimeZoneId); + TimeZone = trimmedTimeZoneId; return Result.Success(); } - catch (TimeZoneNotFoundException) + catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException) { - return Result.Failure(DomainErrors.InvalidTimezone.Format(ianaTimeZoneId)); + return Result.Failure(DomainErrors.InvalidTimezone.Format(trimmedTimeZoneId)); } } diff --git a/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs b/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs index 03e49febc..d061dd3be 100644 --- a/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs +++ b/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs @@ -103,22 +103,55 @@ private async Task> MapCalendarEvents( string accessToken, string calendarId, string calendarName, IReadOnlyList events, CancellationToken ct) { var items = new List(); - var seenRecurringMasterIds = new HashSet(StringComparer.Ordinal); + var slotPerRecurringMaster = new Dictionary(StringComparer.Ordinal); + var occurrencesPerRecurringMaster = new Dictionary>(StringComparer.Ordinal); var masterRRuleCache = new Dictionary(StringComparer.Ordinal); foreach (var ev in events) { if (string.IsNullOrWhiteSpace(ev.Summary)) continue; if (string.Equals(ev.Status, "cancelled", StringComparison.OrdinalIgnoreCase)) continue; - if (ev.RecurringEventId is not null && !seenRecurringMasterIds.Add(ev.RecurringEventId)) continue; + + if (ev.RecurringEventId is { } masterId) + { + CollectOccurrence(occurrencesPerRecurringMaster, masterId, ev); + if (slotPerRecurringMaster.ContainsKey(masterId)) continue; + slotPerRecurringMaster[masterId] = items.Count; + } var rrule = await ResolveRRule(accessToken, calendarId, ev, masterRRuleCache, ct); items.Add(MapEvent(ev, calendarId, calendarName, rrule)); } + foreach (var (masterId, slot) in slotPerRecurringMaster) + { + if (occurrencesPerRecurringMaster.TryGetValue(masterId, out var occurrences)) + items[slot] = items[slot] with { ExpandedOccurrences = occurrences }; + } + return items; } + /// + /// Records every expanded instance of a recurring master, each with the source calendar's own + /// offset at that instant, so the projection gate can test the whole fetch window instead of the + /// one instance kept in the list. An all-day instance carries no instant and is skipped. + /// + private static void CollectOccurrence( + Dictionary> occurrencesPerRecurringMaster, string masterId, Event ev) + { + if (ev.Start?.DateTimeDateTimeOffset is not { } instanceStart) + return; + + if (!occurrencesPerRecurringMaster.TryGetValue(masterId, out var occurrences)) + { + occurrences = []; + occurrencesPerRecurringMaster[masterId] = occurrences; + } + + occurrences.Add(instanceStart); + } + private static CalendarEventItem MapEvent(Event ev, string calendarId, string calendarName, string? rrule) { var startTime = ev.Start?.DateTimeDateTimeOffset?.ToString("HH:mm"); diff --git a/tests/Orbit.Application.Tests/Common/TimeZoneHelperTests.cs b/tests/Orbit.Application.Tests/Common/TimeZoneHelperTests.cs index bb0fd83f6..1e61b321c 100644 --- a/tests/Orbit.Application.Tests/Common/TimeZoneHelperTests.cs +++ b/tests/Orbit.Application.Tests/Common/TimeZoneHelperTests.cs @@ -136,6 +136,25 @@ public void FindTimeZone_NullLogger_InvalidTimezone_ReturnsUtc() result.Should().Be(TimeZoneInfo.Utc); } + [Theory] + [InlineData(" ")] + [InlineData("Not/AZone")] + [InlineData("America/Sao_Paulo ")] + public void FindTimeZone_UnusableId_ReturnsUtcAndNamesTheIdAndTheUser(string timeZoneId) + { + var userId = Guid.NewGuid(); + + var result = TimeZoneHelper.FindTimeZone(timeZoneId, _logger, userId); + + result.Should().Be(TimeZoneInfo.Utc); + _logger.ReceivedWithAnyArgs().Log( + LogLevel.Warning, + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>()); + } + [Fact] public void FindTimeZone_SameInput_ReturnsSameTimezone() { diff --git a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs index 5503eb10e..879fbd67f 100644 --- a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs @@ -22,7 +22,7 @@ public class GetCalendarEventsQueryHandlerTests private readonly IGoogleTokenService _googleTokenService = Substitute.For(); private readonly ICalendarEventFetcher _eventFetcher = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); - private readonly ILogger _logger = Substitute.For>(); + private readonly RecordingLogger _logger = new(); private readonly GetCalendarEventsQueryHandler _handler; private static readonly Guid UserId = Guid.NewGuid(); @@ -40,6 +40,23 @@ private static User CreateTestUser() return User.Create("Test User", "test@example.com").Value; } + private sealed class RecordingLogger : ILogger + { + public List<(LogLevel Level, int EventId, string Message)> Entries { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + => Entries.Add((logLevel, eventId.Id, formatter(state, exception))); + } + private void StubSuccessfulFetch(User user, params CalendarEventItem[] items) { _userRepo.GetByIdAsync(UserId, Arg.Any()).Returns(user); @@ -286,6 +303,245 @@ public async Task Handle_RecurringEventWithoutByDay_KeepsRecurrenceRuleUnchanged result.Value[0].RecurrenceRule.Should().Be("RRULE:FREQ=MONTHLY;BYMONTHDAY=15"); } + [Fact] + public async Task Handle_ByDaySeriesStableAtItsFirstOccurrenceButShiftedLater_OmitsEvent() + { + var user = CreateTestUser(); + user.SetTimeZone("America/Sao_Paulo").IsSuccess.Should().BeTrue(); + StubSuccessfulFetch( + user, + new CalendarEventItem( + "evt_lisbon_daily", + "Lisbon stand-up", + null, + "2027-01-07", + "03:30", + "04:00", + true, + "RRULE:FREQ=DAILY;BYDAY=TH", + [], + StartUtc: new DateTime(2027, 1, 7, 3, 30, 0, DateTimeKind.Utc), + EndUtc: new DateTime(2027, 1, 7, 4, 0, 0, DateTimeKind.Utc)) + { + ExpandedOccurrences = + [ + new DateTimeOffset(2027, 1, 7, 3, 30, 0, TimeSpan.Zero), + new DateTimeOffset(2027, 7, 8, 3, 30, 0, TimeSpan.FromHours(1)) + ] + }); + + var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeEmpty(); + } + + [Fact] + public async Task Handle_ByDaySeriesStableAcrossEveryOccurrence_KeepsRecurrenceRuleUnchanged() + { + var user = CreateTestUser(); + user.SetTimeZone("America/Sao_Paulo").IsSuccess.Should().BeTrue(); + StubSuccessfulFetch( + user, + new CalendarEventItem( + "evt_lisbon_afternoon", + "Lisbon afternoon review", + null, + "2027-01-07", + "15:00", + "16:00", + true, + "RRULE:FREQ=DAILY;BYDAY=TH", + [], + StartUtc: new DateTime(2027, 1, 7, 15, 0, 0, DateTimeKind.Utc), + EndUtc: new DateTime(2027, 1, 7, 16, 0, 0, DateTimeKind.Utc)) + { + ExpandedOccurrences = + [ + new DateTimeOffset(2027, 1, 7, 15, 0, 0, TimeSpan.Zero), + new DateTimeOffset(2027, 7, 8, 15, 0, 0, TimeSpan.FromHours(1)) + ] + }); + + var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().ContainSingle(); + result.Value[0].StartDate.Should().Be("2027-01-07"); + result.Value[0].StartTime.Should().Be("12:00"); + result.Value[0].RecurrenceRule.Should().Be("RRULE:FREQ=DAILY;BYDAY=TH"); + } + + [Fact] + public async Task Handle_EventCrossingLocalMidnight_OmitsEndTimeKeepsEndUtcAndLogsTheReason() + { + var user = CreateTestUser(); + user.SetTimeZone("Asia/Kathmandu").IsSuccess.Should().BeTrue(); + StubSuccessfulFetch( + user, + new CalendarEventItem( + "evt_kathmandu_late", + "Late review", + null, + "2026-09-20", + "18:05", + "18:35", + false, + null, + [], + StartUtc: new DateTime(2026, 9, 20, 18, 5, 0, DateTimeKind.Utc), + EndUtc: new DateTime(2026, 9, 20, 18, 35, 0, DateTimeKind.Utc))); + + var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().ContainSingle(); + result.Value[0].StartDate.Should().Be("2026-09-20"); + result.Value[0].StartTime.Should().Be("23:50"); + result.Value[0].EndTime.Should().BeNull(); + result.Value[0].EndUtc.Should().Be(new DateTime(2026, 9, 20, 18, 35, 0, DateTimeKind.Utc)); + _logger.Entries.Should().ContainSingle(entry => + entry.Level == LogLevel.Debug && entry.Message.Contains("end time", StringComparison.Ordinal)); + } + + [Fact] + public async Task Handle_EventInsideRepeatedHour_OmitsEndTimeKeepsEndUtcAndLogsTheReason() + { + var user = CreateTestUser(); + user.SetTimeZone("America/New_York").IsSuccess.Should().BeTrue(); + StubSuccessfulFetch( + user, + new CalendarEventItem( + "evt_new_york_fold", + "Night shift handover", + null, + "2026-11-01", + "01:30", + "02:15", + false, + null, + [], + StartUtc: new DateTime(2026, 11, 1, 5, 30, 0, DateTimeKind.Utc), + EndUtc: new DateTime(2026, 11, 1, 6, 15, 0, DateTimeKind.Utc))); + + var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().ContainSingle(); + result.Value[0].StartDate.Should().Be("2026-11-01"); + result.Value[0].StartTime.Should().Be("01:30"); + result.Value[0].EndTime.Should().BeNull(); + result.Value[0].EndUtc.Should().Be(new DateTime(2026, 11, 1, 6, 15, 0, DateTimeKind.Utc)); + _logger.Entries.Should().ContainSingle(entry => + entry.Level == LogLevel.Debug && entry.Message.Contains("end time", StringComparison.Ordinal)); + } + + [Fact] + public async Task Handle_EventKeepingItsLocalDate_LogsNoOmittedEndTime() + { + var user = CreateTestUser(); + user.SetTimeZone("Asia/Kathmandu").IsSuccess.Should().BeTrue(); + StubSuccessfulFetch( + user, + new CalendarEventItem( + "evt_kathmandu_morning", + "Morning review", + null, + "2026-09-20", + "03:00", + "03:30", + false, + null, + [], + StartUtc: new DateTime(2026, 9, 20, 3, 0, 0, DateTimeKind.Utc), + EndUtc: new DateTime(2026, 9, 20, 3, 30, 0, DateTimeKind.Utc))); + + var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value[0].StartTime.Should().Be("08:45"); + result.Value[0].EndTime.Should().Be("09:15"); + _logger.Entries.Should().NotContain(entry => entry.Level == LogLevel.Debug); + } + + [Theory] + [InlineData("Asia/Kathmandu", "2026-04-15", "04:45", "05:45")] + [InlineData("Pacific/Chatham", "2026-04-15", "11:45", "12:45")] + public async Task Handle_SubHourOffsetTimezone_ProjectsStartAndEndToTheQuarterHour( + string timeZone, string expectedDate, string expectedStart, string expectedEnd) + { + var user = CreateTestUser(); + user.SetTimeZone(timeZone).IsSuccess.Should().BeTrue(); + StubSuccessfulFetch( + user, + new CalendarEventItem( + "evt_tokyo_breakfast", + "Tokyo breakfast", + null, + "2026-04-15", + "08:00", + "09:00", + false, + null, + [], + StartUtc: new DateTime(2026, 4, 14, 23, 0, 0, DateTimeKind.Utc), + EndUtc: new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc))); + + var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().ContainSingle(); + result.Value[0].StartDate.Should().Be(expectedDate); + result.Value[0].StartTime.Should().Be(expectedStart); + result.Value[0].EndTime.Should().Be(expectedEnd); + } + + [Theory] + [InlineData(" ")] + [InlineData("Not/AZone")] + [InlineData("America/Sao_Paulo ")] + public async Task Handle_StoredTimeZoneTheSystemCannotResolve_ProjectsIntoUtcAndLogsAWarning(string storedTimeZone) + { + var user = CreateTestUser(); + ForceStoredTimeZone(user, storedTimeZone); + StubSuccessfulFetch( + user, + new CalendarEventItem( + "evt_unresolvable_zone", + "Team sync", + null, + "2026-09-20", + "18:05", + "18:35", + false, + null, + [], + StartUtc: new DateTime(2026, 9, 20, 18, 5, 0, DateTimeKind.Utc), + EndUtc: new DateTime(2026, 9, 20, 18, 35, 0, DateTimeKind.Utc))); + + var result = await _handler.Handle(new GetCalendarEventsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value[0].StartTime.Should().Be("18:05"); + _logger.Entries.Should().ContainSingle(entry => + entry.Level == LogLevel.Warning + && entry.Message.Contains(storedTimeZone, StringComparison.Ordinal) + && entry.Message.Contains(UserId.ToString(), StringComparison.Ordinal)); + } + + /// + /// Writes the column the way EF materializes it, around . That is the + /// only way a row reaches the reader with an id the system cannot resolve, and it is exactly what a + /// row written before the boundary guard looks like. + /// + private static void ForceStoredTimeZone(User user, string timeZone) + { + typeof(User) + .GetProperty(nameof(User.TimeZone))! + .GetSetMethod(nonPublic: true)! + .Invoke(user, [timeZone]); + } + [Fact] public async Task Handle_TimedEventWithoutEndUtc_ProjectsStartAndOmitsEndTime() { diff --git a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarSyncSuggestionsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarSyncSuggestionsQueryHandlerTests.cs index 7961dac6b..e82596afc 100644 --- a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarSyncSuggestionsQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarSyncSuggestionsQueryHandlerTests.cs @@ -3,7 +3,6 @@ using System.Text.Json.Nodes; using FluentAssertions; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using Orbit.Application.Calendar.Queries; using Orbit.Domain.Common; @@ -24,8 +23,26 @@ public class GetCalendarSyncSuggestionsQueryHandlerTests private readonly IUserDateService _userDateService = Substitute.For(); private readonly IPayGateService _payGate = Substitute.For(); + private readonly RecordingLogger _logger = new(); private readonly GetCalendarSyncSuggestionsQueryHandler _handler; + private sealed class RecordingLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + => Entries.Add((logLevel, formatter(state, exception))); + } + private static readonly Guid UserId = Guid.NewGuid(); private static readonly DateOnly Today = new(2026, 4, 10); @@ -39,7 +56,7 @@ public GetCalendarSyncSuggestionsQueryHandlerTests() _userRepo, _userDateService, _payGate, - NullLogger.Instance); + _logger); _userRepo.GetByIdAsync(Arg.Any(), Arg.Any()) .Returns(User.Create("Test", "test@example.com").Value); _userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()) @@ -364,6 +381,120 @@ public async Task Handle_LegacyMatchedHabit_ExcludesMatchingSuggestion() result.Value.Should().BeEmpty(); } + [Fact] + public async Task Handle_TwoSuggestionsSharingOneRepeatedHourKey_KeepsBothDespiteTheLegacyHabit() + { + var user = User.Create("Test", "test@example.com").Value; + user.SetTimeZone("America/New_York").IsSuccess.Should().BeTrue(); + _userRepo.GetByIdAsync(Arg.Any(), Arg.Any()).Returns(user); + _userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()) + .Returns(new DateOnly(2026, 10, 1)); + + var earlier = CreateFoldSuggestion("gcal-fold-early", new DateTime(2026, 11, 1, 5, 30, 0, DateTimeKind.Utc)); + var later = CreateFoldSuggestion("gcal-fold-late", new DateTime(2026, 11, 1, 6, 30, 0, DateTimeKind.Utc)); + var legacyHabit = CreateHabit("Night shift handover", new DateOnly(2026, 11, 1), new TimeOnly(1, 30)); + + _suggestionRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(new List { earlier, later }.AsReadOnly()); + _habitRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(new List { legacyHabit }.AsReadOnly()); + + var result = await _handler.Handle(new GetCalendarSyncSuggestionsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().HaveCount(2); + result.Value.Select(item => item.GoogleEventId).Should() + .BeEquivalentTo(new[] { "gcal-fold-early", "gcal-fold-late" }); + result.Value.Should().OnlyContain(item => item.Event.StartTime == "01:30"); + result.Value.Select(item => item.Event.StartUtc).Should().OnlyHaveUniqueItems(); + } + + [Fact] + public async Task Handle_SingleSuggestionMatchingALegacyHabitKey_IsStillExcluded() + { + var user = User.Create("Test", "test@example.com").Value; + user.SetTimeZone("America/New_York").IsSuccess.Should().BeTrue(); + _userRepo.GetByIdAsync(Arg.Any(), Arg.Any()).Returns(user); + _userDateService.GetUserTodayAsync(Arg.Any(), Arg.Any()) + .Returns(new DateOnly(2026, 10, 1)); + + var only = CreateFoldSuggestion("gcal-fold-only", new DateTime(2026, 11, 1, 5, 30, 0, DateTimeKind.Utc)); + var legacyHabit = CreateHabit("Night shift handover", new DateOnly(2026, 11, 1), new TimeOnly(1, 30)); + + _suggestionRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(new List { only }.AsReadOnly()); + _habitRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(new List { legacyHabit }.AsReadOnly()); + + var result = await _handler.Handle(new GetCalendarSyncSuggestionsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeEmpty(); + } + + [Theory] + [InlineData(" ")] + [InlineData("Not/AZone")] + [InlineData("America/Sao_Paulo ")] + public async Task Handle_StoredTimeZoneTheSystemCannotResolve_ProjectsIntoUtcAndLogsAWarning(string storedTimeZone) + { + var user = User.Create("Test", "test@example.com").Value; + typeof(User) + .GetProperty(nameof(User.TimeZone))! + .GetSetMethod(nonPublic: true)! + .Invoke(user, [storedTimeZone]); + _userRepo.GetByIdAsync(Arg.Any(), Arg.Any()).Returns(user); + + var suggestion = CreateFoldSuggestion( + "gcal-unresolvable-zone", new DateTime(2026, 11, 1, 5, 30, 0, DateTimeKind.Utc)); + _suggestionRepo.FindAsync( + Arg.Any>>(), + Arg.Any()) + .Returns(new List { suggestion }.AsReadOnly()); + + var result = await _handler.Handle(new GetCalendarSyncSuggestionsQuery(UserId), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().ContainSingle(); + result.Value[0].Event.StartTime.Should().Be("05:30"); + _logger.Entries.Should().ContainSingle(entry => + entry.Level == LogLevel.Warning + && entry.Message.Contains(storedTimeZone, StringComparison.Ordinal) + && entry.Message.Contains(UserId.ToString(), StringComparison.Ordinal)); + } + + private static GoogleCalendarSyncSuggestion CreateFoldSuggestion(string googleEventId, DateTime startUtc) + { + var eventItem = new CalendarEventItem( + googleEventId, + "Night shift handover", + null, + "2026-11-01", + "01:30", + "02:15", + false, + null, + [], + StartUtc: startUtc, + EndUtc: startUtc.AddMinutes(45)); + + return GoogleCalendarSyncSuggestion.Create( + UserId, + googleEventId, + eventItem.Title, + startUtc, + JsonSerializer.Serialize(eventItem), + DateTime.SpecifyKind(new DateTime(2026, 10, 10, 12, 0, 0), DateTimeKind.Utc)); + } + [Fact] public async Task Handle_DeselectedCalendar_ExcludesItsSuggestions() { diff --git a/tests/Orbit.Domain.Tests/Entities/UserTests.cs b/tests/Orbit.Domain.Tests/Entities/UserTests.cs index 27ee14550..f14e467c3 100644 --- a/tests/Orbit.Domain.Tests/Entities/UserTests.cs +++ b/tests/Orbit.Domain.Tests/Entities/UserTests.cs @@ -182,6 +182,32 @@ public void SetTimeZone_Invalid_ReturnsFailure() result.Error.Should().Contain("Invalid timezone"); } + [Fact] + public void SetTimeZone_SurroundingWhitespace_StoresTheTrimmedId() + { + var user = CreateValidUser(); + + var result = user.SetTimeZone(" America/Sao_Paulo "); + + result.IsSuccess.Should().BeTrue(); + user.TimeZone.Should().Be("America/Sao_Paulo"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void SetTimeZone_BlankId_ReturnsFailureAndKeepsThePreviousZone(string timeZoneId) + { + var user = CreateValidUser(); + user.SetTimeZone("America/Sao_Paulo").IsSuccess.Should().BeTrue(); + + var result = user.SetTimeZone(timeZoneId); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Contain("Invalid timezone"); + user.TimeZone.Should().Be("America/Sao_Paulo"); + } + [Fact] public void SetName_ValidName_UpdatesName() { diff --git a/tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs b/tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs index 2d65ad0eb..96a1600de 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs @@ -206,6 +206,49 @@ public async Task FetchAsync_RecurringMasterDedup_IsPerCalendar() result.Select(i => i.CalendarId).Should().BeEquivalentTo(ExpectedCalendarIds); } + [Fact] + public async Task FetchAsync_RecurringMaster_KeepsEveryExpandedOccurrenceInTheWindow() + { + StubCalendars(Calendar("a", "owner")); + StubEvents( + "a", + LisbonInstance("inst-jan", new DateTimeOffset(2027, 1, 7, 3, 30, 0, TimeSpan.Zero)), + LisbonInstance("inst-jul", new DateTimeOffset(2027, 7, 8, 3, 30, 0, TimeSpan.FromHours(1)))); + _api.GetEventAsync(Token, "a", "master-lisbon", Arg.Any()) + .Returns(new Event { Recurrence = ["RRULE:FREQ=DAILY;BYDAY=TH"] }); + + var result = await _fetcher.FetchAsync(Token, null, null, CancellationToken.None); + + result.Should().ContainSingle(); + result[0].Id.Should().Be("master-lisbon"); + result[0].StartUtc.Should().Be(new DateTime(2027, 1, 7, 3, 30, 0, DateTimeKind.Utc)); + result[0].ExpandedOccurrences.Should().Equal( + new DateTimeOffset(2027, 1, 7, 3, 30, 0, TimeSpan.Zero), + new DateTimeOffset(2027, 7, 8, 3, 30, 0, TimeSpan.FromHours(1))); + } + + [Fact] + public async Task FetchAsync_SingleEvent_HasNoExpandedOccurrences() + { + StubCalendars(Calendar("a", "owner")); + StubEvents("a", TimedEvent("solo", "One off")); + + var result = await _fetcher.FetchAsync(Token, null, null, CancellationToken.None); + + result.Should().ContainSingle(); + result[0].ExpandedOccurrences.Should().BeEmpty(); + } + + private static Event LisbonInstance(string id, DateTimeOffset start) + => new() + { + Id = id, + Summary = "Lisbon stand-up", + RecurringEventId = "master-lisbon", + Start = new EventDateTime { DateTimeDateTimeOffset = start }, + End = new EventDateTime { DateTimeDateTimeOffset = start.AddMinutes(30) } + }; + [Fact] public async Task FetchAsync_FailingCalendar_IsSkippedNotFatal() { From e8672fe84371a2aa714335154a47c67a2a44789b Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Fri, 18 Sep 2026 19:08:28 -0300 Subject: [PATCH 12/15] Prove calendar recurrence stability from both zones Round 6 gated a BYDAY series on the expanded instances Google returned, and GoogleCalendarApi only asks for sixty days, so a series whose transition falls outside that window was admitted and a stored suggestion row never carried the instances at all. The gate now reads the source calendar's own timezone from the recurring master the fetcher already fetches, and walks a year of dates at the occurrence's source wall clock through both zones' rules. A series it cannot prove stable is withheld from both feeds. StoredCalendarEventJson carries the source zone beside the stored suggestion, so the suggestion feed judges a row on the same evidence the events feed had. Also in scope: the auto-sync reconciler projects a fetched event into the account timezone before matching a legacy habit, the end-time omission logs whatever EndUtc holds, and an all-day event no longer reports Google's exclusive end date as an end instant. Co-Authored-By: Claude Opus 5 --- architecture.html | 2 +- architecture.json | 31 ++- .../Commands/RunCalendarAutoSyncCommand.cs | 33 +-- .../Queries/GetCalendarEventsQuery.cs | 136 +++++++++--- .../GetCalendarSyncSuggestionsQuery.cs | 20 +- .../Calendar/StoredCalendarEventJson.cs | 42 ++++ .../Services/GoogleCalendarEventFetcher.cs | 95 ++++---- .../RunCalendarAutoSyncCommandHandlerTests.cs | 30 +++ .../Calendar/CalendarFeedAgreementTests.cs | 204 ++++++++++++++++++ .../GetCalendarEventsQueryHandlerTests.cs | 128 +++++++++-- ...alendarSyncSuggestionsQueryHandlerTests.cs | 8 +- .../CalendarProjectionEndToEndTests.cs | 140 ++++++++++++ .../GoogleCalendarEventFetcherTests.cs | 46 +++- 13 files changed, 777 insertions(+), 138 deletions(-) create mode 100644 src/Orbit.Application/Calendar/StoredCalendarEventJson.cs create mode 100644 tests/Orbit.Application.Tests/Queries/Calendar/CalendarFeedAgreementTests.cs create mode 100644 tests/Orbit.Infrastructure.Tests/Services/CalendarProjectionEndToEndTests.cs diff --git a/architecture.html b/architecture.html index 801231fff..ec56f4b0f 100644 --- a/architecture.html +++ b/architecture.html @@ -47,7 +47,7 @@

Handlers with no endpoint

RequestHandler file

Entities

EntityDomain file
- + +