From ff4cc598c5f25bd69f0b42688b36b72997657b8a Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Thu, 24 Sep 2026 20:01:33 -0300 Subject: [PATCH 1/3] Start #591 recurrence time zone work From 1ea09f28b34afc26b64d0dbfbcc284c65b0da851 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Thu, 24 Sep 2026 20:05:46 -0300 Subject: [PATCH 2/3] Expose recurring calendar event time zone for #591 --- .../Calendar/Queries/GetCalendarEventsQuery.cs | 3 ++- .../Services/GoogleCalendarEventFetcher.cs | 5 ++++- .../Calendar/CalendarEventItemSerializationTests.cs | 7 +++++-- .../Services/CalendarProjectionEndToEndTests.cs | 1 + .../Services/GoogleCalendarEventFetcherTests.cs | 13 ++++++++++++- 5 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs index 8071addab..6a67af87e 100644 --- a/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs +++ b/src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs @@ -24,7 +24,8 @@ public record CalendarEventItem( DateTime? StartUtc = null, string CalendarId = "", string CalendarName = "", - DateTime? EndUtc = null) + DateTime? EndUtc = null, + string? RecurrenceTimeZone = null) { /// /// The IANA zone the source calendar expands this event's recurrence in, taken from the recurring diff --git a/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs b/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs index 4d31f52ab..e1fad7861 100644 --- a/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs +++ b/src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs @@ -145,7 +145,10 @@ private static CalendarEventItem MapEvent( ResolveStartUtc(ev.Start), calendarId, calendarName, - ev.End?.DateTimeDateTimeOffset?.UtcDateTime) + ev.End?.DateTimeDateTimeOffset?.UtcDateTime, + isRecurring && startTime is not null && !string.IsNullOrWhiteSpace(recurrence.TimeZone) + ? recurrence.TimeZone + : null) { SourceTimeZone = recurrence.TimeZone, RecurrenceStartUtc = !isRecurring diff --git a/tests/Orbit.Application.Tests/Queries/Calendar/CalendarEventItemSerializationTests.cs b/tests/Orbit.Application.Tests/Queries/Calendar/CalendarEventItemSerializationTests.cs index c31012b08..c0a44fef2 100644 --- a/tests/Orbit.Application.Tests/Queries/Calendar/CalendarEventItemSerializationTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Calendar/CalendarEventItemSerializationTests.cs @@ -21,11 +21,13 @@ public class CalendarEventItemSerializationTests public void ResponseBody_CarriesEndUtcAndOmitsTheSourceTimeZone() { var json = JsonSerializer.Serialize(LisbonSeries(), ResponseOptions); + using var response = JsonDocument.Parse(json); json.Should().NotContainEquivalentOf("sourceTimeZone"); json.Should().NotContainEquivalentOf("recurrenceStartUtc"); - json.Should().NotContain("Europe/Lisbon"); json.Should().Contain("endUtc"); + response.RootElement.TryGetProperty("recurrenceTimeZone", out var zone).Should().BeTrue(); + zone.GetString().Should().Be("Europe/Lisbon"); } [Fact] @@ -78,7 +80,8 @@ private static CalendarEventItem LisbonSeries() "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)) + EndUtc: new DateTime(2027, 1, 7, 4, 0, 0, DateTimeKind.Utc), + RecurrenceTimeZone: "Europe/Lisbon") { SourceTimeZone = "Europe/Lisbon", RecurrenceStartUtc = new DateTime(2027, 1, 7, 3, 30, 0, DateTimeKind.Utc) diff --git a/tests/Orbit.Infrastructure.Tests/Services/CalendarProjectionEndToEndTests.cs b/tests/Orbit.Infrastructure.Tests/Services/CalendarProjectionEndToEndTests.cs index 74dc904dd..5c7f1271f 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/CalendarProjectionEndToEndTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/CalendarProjectionEndToEndTests.cs @@ -87,6 +87,7 @@ public async Task ByDaySeriesReadFromItsOwnZone_IsOffered() result.IsSuccess.Should().BeTrue(); result.Value.Should().ContainSingle(); result.Value[0].RecurrenceRule.Should().Be("RRULE:FREQ=DAILY;BYDAY=TH"); + result.Value[0].RecurrenceTimeZone.Should().Be("Europe/Lisbon"); result.Value[0].StartDate.Should().Be("2027-01-07"); result.Value[0].StartTime.Should().Be("03:30"); } diff --git a/tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs b/tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs index 22b208a9d..d07d2cbed 100644 --- a/tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs +++ b/tests/Orbit.Infrastructure.Tests/Services/GoogleCalendarEventFetcherTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using FluentAssertions; using Google.Apis.Calendar.v3.Data; using Microsoft.Extensions.Logging; @@ -15,6 +16,7 @@ public class GoogleCalendarEventFetcherTests private readonly GoogleCalendarEventFetcher _fetcher; private const string Token = "access-token"; + private static readonly JsonSerializerOptions ResponseOptions = new(JsonSerializerDefaults.Web); private static readonly string[] SharedCalendarSelection = new[] { "shared" }; private static readonly string[] ChosenCalendarSelection = new[] { "chosen" }; @@ -148,7 +150,7 @@ public async Task FetchAsync_AllDayEvent_PreservesFloatingDateWithNullTimes() { Id = "all-day-event", Summary = "Holiday", - Start = new EventDateTime { Date = "2026-04-15" }, + Start = new EventDateTime { Date = "2026-04-15", TimeZone = "Europe/Lisbon" }, End = new EventDateTime { Date = "2026-04-16" } }); @@ -160,6 +162,9 @@ public async Task FetchAsync_AllDayEvent_PreservesFloatingDateWithNullTimes() result[0].EndTime.Should().BeNull(); result[0].StartUtc.Should().Be(new DateTime(2026, 4, 15, 0, 0, 0, DateTimeKind.Utc)); result[0].EndUtc.Should().BeNull("Google's all-day end date is exclusive, so it is not the end instant"); + var response = JsonSerializer.SerializeToElement(result[0], ResponseOptions); + response.TryGetProperty("recurrenceTimeZone", out var zone).Should().BeTrue(); + zone.ValueKind.Should().Be(JsonValueKind.Null); } [Fact] @@ -232,6 +237,9 @@ public async Task FetchAsync_RecurringInstance_TakesTheRuleAndZoneFromTheMaster( result[0].StartUtc.Should().Be(new DateTime(2027, 1, 7, 3, 30, 0, DateTimeKind.Utc)); result[0].RecurrenceRule.Should().Be("RRULE:FREQ=DAILY;BYDAY=TH"); result[0].SourceTimeZone.Should().Be("Europe/Lisbon"); + var response = JsonSerializer.SerializeToElement(result[0], ResponseOptions); + response.TryGetProperty("recurrenceTimeZone", out var zone).Should().BeTrue(); + zone.GetString().Should().Be("Europe/Lisbon"); await _api.Received(1).GetEventAsync(Token, "a", "master-lisbon", Arg.Any()); } @@ -253,6 +261,9 @@ public async Task FetchAsync_RecurringInstance_IgnoresTheInstanceTimeZone() result.Should().ContainSingle(); result[0].SourceTimeZone.Should().Be("Europe/Lisbon"); + var response = JsonSerializer.SerializeToElement(result[0], ResponseOptions); + response.TryGetProperty("recurrenceTimeZone", out var zone).Should().BeTrue(); + zone.GetString().Should().Be("Europe/Lisbon"); } [Fact] From 70ab17ae0b66f20df17e62ab6de375e2dfbfd3a7 Mon Sep 17 00:00:00 2001 From: Thomas Luizon Rodrigues Gregorio Date: Thu, 24 Sep 2026 21:02:54 -0300 Subject: [PATCH 3/3] Restore recurrence time zone in legacy suggestions for #591 --- .../Calendar/StoredCalendarEventJson.cs | 9 +++- .../CalendarEventItemSerializationTests.cs | 21 +++++++++- ...alendarSyncSuggestionsQueryHandlerTests.cs | 41 +++++++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/Orbit.Application/Calendar/StoredCalendarEventJson.cs b/src/Orbit.Application/Calendar/StoredCalendarEventJson.cs index d18f559ba..37f4a0c07 100644 --- a/src/Orbit.Application/Calendar/StoredCalendarEventJson.cs +++ b/src/Orbit.Application/Calendar/StoredCalendarEventJson.cs @@ -35,10 +35,15 @@ internal static string Serialize(CalendarEventItem item) if (item is null) return null; + var sourceTimeZone = ReadSourceTimeZone(stored); return item with { - SourceTimeZone = ReadSourceTimeZone(stored), - RecurrenceStartUtc = ReadRecurrenceStartUtc(stored) + SourceTimeZone = sourceTimeZone, + RecurrenceStartUtc = ReadRecurrenceStartUtc(stored), + RecurrenceTimeZone = item.IsRecurring && item.StartTime is not null + && !string.IsNullOrWhiteSpace(sourceTimeZone) + ? sourceTimeZone + : null }; } diff --git a/tests/Orbit.Application.Tests/Queries/Calendar/CalendarEventItemSerializationTests.cs b/tests/Orbit.Application.Tests/Queries/Calendar/CalendarEventItemSerializationTests.cs index c0a44fef2..b46824027 100644 --- a/tests/Orbit.Application.Tests/Queries/Calendar/CalendarEventItemSerializationTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Calendar/CalendarEventItemSerializationTests.cs @@ -36,16 +36,35 @@ public void StoredRow_CarriesTheSourceTimeZoneAndReadsItBack() var stored = StoredCalendarEventJson.Serialize(LisbonSeries()); stored.Should().Contain("Europe/Lisbon"); + var legacy = JsonNode.Parse(stored)!.AsObject(); + legacy.Remove(nameof(CalendarEventItem.RecurrenceTimeZone)); - var readBack = StoredCalendarEventJson.Deserialize(stored); + var readBack = StoredCalendarEventJson.Deserialize(legacy.ToJsonString()); readBack.Should().NotBeNull(); readBack!.SourceTimeZone.Should().Be("Europe/Lisbon"); + readBack.RecurrenceTimeZone.Should().Be("Europe/Lisbon"); readBack.RecurrenceStartUtc.Should().Be(new DateTime(2027, 1, 7, 3, 30, 0, DateTimeKind.Utc)); readBack.Id.Should().Be("master-lisbon"); readBack.EndUtc.Should().Be(new DateTime(2027, 1, 7, 4, 0, 0, DateTimeKind.Utc)); } + [Theory] + [InlineData(true, null)] + [InlineData(false, "03:30")] + public void StoredRow_OnlyTimedRecurrencesExposeTheSourceTimeZone(bool isRecurring, string? startTime) + { + var item = LisbonSeries() with { IsRecurring = isRecurring, StartTime = startTime }; + var legacy = JsonNode.Parse(StoredCalendarEventJson.Serialize(item))!.AsObject(); + legacy.Remove(nameof(CalendarEventItem.RecurrenceTimeZone)); + + var readBack = StoredCalendarEventJson.Deserialize(legacy.ToJsonString()); + + readBack.Should().NotBeNull(); + readBack!.SourceTimeZone.Should().Be("Europe/Lisbon"); + readBack.RecurrenceTimeZone.Should().BeNull(); + } + /// /// A row written before the key existed reads back with no source zone, which the recurrence gate /// already treats as unproved. A row whose key holds a number must read back the same way rather diff --git a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarSyncSuggestionsQueryHandlerTests.cs b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarSyncSuggestionsQueryHandlerTests.cs index bd9458a0d..300d20af6 100644 --- a/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarSyncSuggestionsQueryHandlerTests.cs +++ b/tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarSyncSuggestionsQueryHandlerTests.cs @@ -305,6 +305,47 @@ public async Task Handle_LegacyTimedSuggestionWithoutEndUtc_MatchesEventsQueryPr result.Value[0].Event.EndTime.Should().BeNull(); } + [Fact] + public async Task Handle_LegacyRecurringSuggestion_ExposesStoredSourceTimeZone() + { + 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, 15, 15, 0, 0, DateTimeKind.Utc); + var eventItem = new CalendarEventItem( + "event-legacy-recurring", + "Weekly review", + null, + "2026-04-15", + "12:00", + "13:00", + true, + "RRULE:FREQ=WEEKLY;BYDAY=WE", + [], + StartUtc: startUtc, + EndUtc: startUtc.AddHours(1)) + { + SourceTimeZone = "America/Sao_Paulo", + RecurrenceStartUtc = startUtc + }; + var legacyJson = JsonNode.Parse(StoredCalendarEventJson.Serialize(eventItem))!.AsObject(); + legacyJson.Remove(nameof(CalendarEventItem.RecurrenceTimeZone)); + var suggestion = GoogleCalendarSyncSuggestion.Create( + UserId, "gcal-legacy-recurring", 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.RecurrenceTimeZone.Should().Be("America/Sao_Paulo"); + } + [Fact] public async Task Handle_TimedSuggestionCrossingLocalMidnight_OmitsEndTime() {