From a2a0f496eece86da467d2f9683328a7b94a1fe32 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 12 Nov 2026 04:30:35 +0000 Subject: [PATCH 1/5] chore(macos): track day column UTC-boundary parity gap Document follow-up to align native isTimedEventOnDay with web query-window overlap. Co-authored-by: Tyler Dane --- .../CompassKitTests/DayCalendarColumnsTests.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/calendar-macos/CompassKitTests/DayCalendarColumnsTests.swift b/apps/calendar-macos/CompassKitTests/DayCalendarColumnsTests.swift index 07915e9fcf..1c44f2a6cc 100644 --- a/apps/calendar-macos/CompassKitTests/DayCalendarColumnsTests.swift +++ b/apps/calendar-macos/CompassKitTests/DayCalendarColumnsTests.swift @@ -78,4 +78,10 @@ final class DayCalendarColumnsTests: XCTestCase { ) XCTAssertEqual(columns.map(\.id), [personal.id]) } + + /// Web #5229 fixed the same shape: civil-day matching in UTC drops secondary columns + /// when Denver-timed events sit on the prior local day. Mac still uses `isTimedEventOnDay`. + func testDayViewTimedColumnsNeedQueryWindowParityAtUTCBoundary() throws { + throw XCTSkip("Follow-up: align `isTimedEventOnDay` with day query-window overlap (see web #5229).") + } } From 098a881c382ce62d397a71dd66cd37ae298d261b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 12 Nov 2026 05:34:24 +0000 Subject: [PATCH 2/5] fix(macos): day columns use query-window overlap for timed events Match web #5229 by overlapping timed events with dayEventQueryRange instead of civil-day start matching; add boundary XCTest and optional anchor timezone. Co-authored-by: Tyler Dane --- .../Calendar/CalendarWindowMath.swift | 16 +++++- .../Calendar/DayCalendarColumns.swift | 51 ++++++++++++++++--- .../DayCalendarColumnsTests.swift | 43 ++++++++++++++-- 3 files changed, 98 insertions(+), 12 deletions(-) diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/CalendarWindowMath.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/CalendarWindowMath.swift index 8b8ee28aa9..231e3c4bb9 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/CalendarWindowMath.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/CalendarWindowMath.swift @@ -29,8 +29,20 @@ public enum CalendarWindowMath { } /// One day's fetch window as `[startDate, endDate)` with exclusive upper bound. - public static func dayEventQueryRange(date: Date) -> (startDate: String, endDate: String) { - let calendar = EffectiveTimeZone.calendar + public static func dayEventQueryRange( + date: Date, + timeZoneIdentifier: String? = nil + ) -> (startDate: String, endDate: String) { + let calendar: Calendar + if let timeZoneIdentifier, + let zone = TimeZone(identifier: timeZoneIdentifier) + { + var localized = Calendar(identifier: .gregorian) + localized.timeZone = zone + calendar = localized + } else { + calendar = EffectiveTimeZone.calendar + } let startOfDay = calendar.startOfDay(for: date) let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) ?? startOfDay return ( diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DayCalendarColumns.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DayCalendarColumns.swift index c3a295b640..50f584b243 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DayCalendarColumns.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DayCalendarColumns.swift @@ -9,11 +9,19 @@ public enum DayCalendarColumns { allDayEvents: [GridLayoutAllDayEventInput], hiddenEventIds: Set, pinnedCalendarIds: Set = [], - defaultTargetCalendarId: String? = nil + defaultTargetCalendarId: String? = nil, + viewDay: Date? = nil, + viewDayTimeZone: String? = nil ) -> [CompassCalendar] { let ordered = eligibleCalendarsInOrder(calendars) + let viewAnchor = + viewDay + ?? CompassDateParsing.calendarDateInEffectiveTimeZone(dayKey) + ?? .distantPast let eventCalendarIds = calendarIdsWithVisibleEventsOnDay( dayKey: dayKey, + viewDay: viewAnchor, + viewDayTimeZone: viewDayTimeZone, timedEvents: timedEvents, allDayEvents: allDayEvents, hiddenEventIds: hiddenEventIds @@ -61,6 +69,8 @@ public enum DayCalendarColumns { private static func calendarIdsWithVisibleEventsOnDay( dayKey: String, + viewDay: Date, + viewDayTimeZone: String?, timedEvents: [GridLayoutTimedEventInput], allDayEvents: [GridLayoutAllDayEventInput], hiddenEventIds: Set @@ -69,7 +79,11 @@ public enum DayCalendarColumns { for timed in timedEvents { guard let calendarId = timed.calendarId, !hiddenEventIds.contains(timed.eventId), - isTimedEventOnDay(startDate: timed.startDate, dayKey: dayKey) + timedEventOverlapsViewDay( + timed, + viewDay: viewDay, + viewDayTimeZone: viewDayTimeZone + ) else { continue } ids.insert(calendarId) } @@ -87,11 +101,33 @@ public enum DayCalendarColumns { return ids } - private static func isTimedEventOnDay(startDate: String, dayKey: String) -> Bool { - guard let start = CompassDateParsing.parseInEffectiveTimeZone(startDate) else { + private static func timedEventOverlapsViewDay( + _ timed: GridLayoutTimedEventInput, + viewDay: Date, + viewDayTimeZone: String? + ) -> Bool { + let range = CalendarWindowMath.dayEventQueryRange( + date: viewDay, + timeZoneIdentifier: viewDayTimeZone + ) + guard let rangeStart = CompassDateParsing.parseInEffectiveTimeZone(range.startDate), + let rangeEnd = CompassDateParsing.parseInEffectiveTimeZone(range.endDate) + else { + return false + } + let eventZone = timed.timeZone ?? EffectiveTimeZone.identifier + guard let eventStart = CompassDateParsing.parseGridDateTime( + timed.startDate, + timeZoneIdentifier: eventZone + ), + let eventEnd = CompassDateParsing.parseGridDateTime( + timed.endDate, + timeZoneIdentifier: eventZone + ) + else { return false } - return CompassDateParsing.formatCalendarDay(start) == dayKey + return eventStart < rangeEnd && eventEnd > rangeStart } private static func isAllDayEventOnDay(startDate: String, endDate: String, dayKey: String) -> Bool { @@ -112,6 +148,8 @@ extension GridLayoutScenario { guard layoutMode == .day, let dayKey = visibleDateKeys.first else { return DayCalendarColumns.dayViewCalendars(calendars) } + let viewDay = + CompassDateParsing.calendarDateInEffectiveTimeZone(dayKey) ?? referenceNow return DayCalendarColumns.dayViewCalendars( calendars, dayKey: dayKey, @@ -119,7 +157,8 @@ extension GridLayoutScenario { allDayEvents: allDayEvents, hiddenEventIds: hiddenEventIds, pinnedCalendarIds: pinnedDayColumnCalendarIds, - defaultTargetCalendarId: defaultTargetCalendarId + defaultTargetCalendarId: defaultTargetCalendarId, + viewDay: viewDay ) } } diff --git a/apps/calendar-macos/CompassKitTests/DayCalendarColumnsTests.swift b/apps/calendar-macos/CompassKitTests/DayCalendarColumnsTests.swift index 1c44f2a6cc..0099341dfc 100644 --- a/apps/calendar-macos/CompassKitTests/DayCalendarColumnsTests.swift +++ b/apps/calendar-macos/CompassKitTests/DayCalendarColumnsTests.swift @@ -79,9 +79,44 @@ final class DayCalendarColumnsTests: XCTestCase { XCTAssertEqual(columns.map(\.id), [personal.id]) } - /// Web #5229 fixed the same shape: civil-day matching in UTC drops secondary columns - /// when Denver-timed events sit on the prior local day. Mac still uses `isTimedEventOnDay`. - func testDayViewTimedColumnsNeedQueryWindowParityAtUTCBoundary() throws { - throw XCTSkip("Follow-up: align `isTimedEventOnDay` with day query-window overlap (see web #5229).") + func testDayViewKeepsBothColumnsAtMergeQueueUTCBoundary() throws { + let primary = GridLayoutSnapshotFixtures.parityCalendars[0] + let secondary = GridLayoutSnapshotFixtures.parityCalendars[1] + let viewDay = try XCTUnwrap( + CompassDateParsing.parseGridDateTime( + "2026-11-11T17:23:00", + timeZoneIdentifier: "America/Denver" + ) + ) + let timed = [ + GridLayoutTimedEventInput( + calendarId: primary.id, + endDate: "2026-11-11 10:30:00.000", + eventId: "boundary-a", + startDate: "2026-11-11 09:00:00.000", + timeZone: "America/Denver", + title: "Morning" + ), + GridLayoutTimedEventInput( + calendarId: secondary.id, + endDate: "2026-11-11 14:30:00.000", + eventId: "boundary-b", + startDate: "2026-11-11 13:00:00.000", + timeZone: "America/Denver", + title: "Afternoon" + ), + ] + + let columns = DayCalendarColumns.dayViewCalendars( + [primary, secondary], + dayKey: "2026-11-11", + timedEvents: timed, + allDayEvents: [], + hiddenEventIds: [], + viewDay: viewDay, + viewDayTimeZone: "America/Denver" + ) + + XCTAssertEqual(columns.map(\.name), ["Work", "Personal"]) } } From c0b786507b3fa0122da89cc25953ff40e4b9c174 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 12 Nov 2026 05:46:34 +0000 Subject: [PATCH 3/5] fix(macos): compare day-column overlap on Date bounds Round-tripping query-window strings through parseInEffectiveTimeZone dropped Denver-boundary matches; use dayEventQueryBounds directly. Co-authored-by: Tyler Dane --- .../Calendar/CalendarWindowMath.swift | 18 +++++++++++++----- .../Calendar/DayCalendarColumns.swift | 9 +++------ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/CalendarWindowMath.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/CalendarWindowMath.swift index 231e3c4bb9..6845603a95 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/CalendarWindowMath.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/CalendarWindowMath.swift @@ -28,11 +28,10 @@ public enum CalendarWindowMath { return (window.startISO, window.endISO) } - /// One day's fetch window as `[startDate, endDate)` with exclusive upper bound. - public static func dayEventQueryRange( + public static func dayEventQueryBounds( date: Date, timeZoneIdentifier: String? = nil - ) -> (startDate: String, endDate: String) { + ) -> (start: Date, end: Date) { let calendar: Calendar if let timeZoneIdentifier, let zone = TimeZone(identifier: timeZoneIdentifier) @@ -45,9 +44,18 @@ public enum CalendarWindowMath { } let startOfDay = calendar.startOfDay(for: date) let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) ?? startOfDay + return (startOfDay, endOfDay) + } + + /// One day's fetch window as `[startDate, endDate)` with exclusive upper bound. + public static func dayEventQueryRange( + date: Date, + timeZoneIdentifier: String? = nil + ) -> (startDate: String, endDate: String) { + let bounds = dayEventQueryBounds(date: date, timeZoneIdentifier: timeZoneIdentifier) return ( - CompassDateParsing.formatLikeDayjs(startOfDay), - CompassDateParsing.formatLikeDayjs(endOfDay) + CompassDateParsing.formatLikeDayjs(bounds.start), + CompassDateParsing.formatLikeDayjs(bounds.end) ) } diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DayCalendarColumns.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DayCalendarColumns.swift index 50f584b243..a0c9dedcc9 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DayCalendarColumns.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DayCalendarColumns.swift @@ -106,15 +106,12 @@ public enum DayCalendarColumns { viewDay: Date, viewDayTimeZone: String? ) -> Bool { - let range = CalendarWindowMath.dayEventQueryRange( + let range = CalendarWindowMath.dayEventQueryBounds( date: viewDay, timeZoneIdentifier: viewDayTimeZone ) - guard let rangeStart = CompassDateParsing.parseInEffectiveTimeZone(range.startDate), - let rangeEnd = CompassDateParsing.parseInEffectiveTimeZone(range.endDate) - else { - return false - } + let rangeStart = range.start + let rangeEnd = range.end let eventZone = timed.timeZone ?? EffectiveTimeZone.identifier guard let eventStart = CompassDateParsing.parseGridDateTime( timed.startDate, From 189aa309c6e3ac5fe3b158cdb9394d5de1d2c663 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 12 Nov 2026 05:48:07 +0000 Subject: [PATCH 4/5] fix(macos): calendar timezone hint for day column overlap Pick provider zones when effective is UTC-like, wire hint in rebuildPresentation, and regression-test via dayViewCalendarsForLayout at the Nov 12 UTC boundary. Co-authored-by: Tyler Dane --- .../CompassData/NativeCalendarRootModel.swift | 2 + .../Calendar/CalendarTimeZoneHint.swift | 30 +++++++ .../Calendar/DayCalendarColumns.swift | 36 +++++---- .../Calendar/EffectiveTimeZone.swift | 7 ++ .../DayCalendarColumnsTests.swift | 81 +++++++++++-------- 5 files changed, 109 insertions(+), 47 deletions(-) create mode 100644 apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/CalendarTimeZoneHint.swift diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift index e176cf2e67..dce2bd3213 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift @@ -786,6 +786,8 @@ public final class NativeCalendarRootModel { headerTitle = "Life" return } + EffectiveTimeZone.calendarTimeZoneHint = + CalendarTimeZoneHint.pick(from: visibleCalendars()) headerTitle = CalendarHeadingLabel.format(start: startOfView, end: endOfView, now: referenceNow) let hiddenIds = gridHiddenEventIdStrings let demoIds = demoEventIds diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/CalendarTimeZoneHint.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/CalendarTimeZoneHint.swift new file mode 100644 index 0000000000..30f95eccfe --- /dev/null +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/CalendarTimeZoneHint.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Best IANA zone from connected calendars (web `pickCalendarTimeZoneHint`). +public enum CalendarTimeZoneHint { + public static func pick(from calendars: [CompassCalendar]) -> String? { + if let primary = calendars.first(where: { $0.isPrimary }), + let zone = primary.timeZone, + TimeZoneFormatting.isValidTimeZone(zone) + { + return zone + } + + for calendar in calendars { + guard let zone = calendar.timeZone, + TimeZoneFormatting.isValidTimeZone(zone), + !TimeZoneFormatting.isUtcLikeTimeZone(zone) + else { continue } + return zone + } + + for calendar in calendars { + guard let zone = calendar.timeZone, + TimeZoneFormatting.isValidTimeZone(zone) + else { continue } + return zone + } + + return nil + } +} diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DayCalendarColumns.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DayCalendarColumns.swift index a0c9dedcc9..714661cb7f 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DayCalendarColumns.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DayCalendarColumns.swift @@ -9,19 +9,16 @@ public enum DayCalendarColumns { allDayEvents: [GridLayoutAllDayEventInput], hiddenEventIds: Set, pinnedCalendarIds: Set = [], - defaultTargetCalendarId: String? = nil, - viewDay: Date? = nil, - viewDayTimeZone: String? = nil + defaultTargetCalendarId: String? = nil ) -> [CompassCalendar] { let ordered = eligibleCalendarsInOrder(calendars) let viewAnchor = - viewDay - ?? CompassDateParsing.calendarDateInEffectiveTimeZone(dayKey) - ?? .distantPast + CompassDateParsing.calendarDateInEffectiveTimeZone(dayKey) ?? .distantPast + let queryWindowTimeZone = timedEventsQueryWindowTimeZone(calendars: calendars) let eventCalendarIds = calendarIdsWithVisibleEventsOnDay( dayKey: dayKey, viewDay: viewAnchor, - viewDayTimeZone: viewDayTimeZone, + queryWindowTimeZone: queryWindowTimeZone, timedEvents: timedEvents, allDayEvents: allDayEvents, hiddenEventIds: hiddenEventIds @@ -67,10 +64,22 @@ public enum DayCalendarColumns { return [] } + private static func timedEventsQueryWindowTimeZone( + calendars: [CompassCalendar] + ) -> String? { + guard TimeZoneFormatting.isUtcLikeTimeZone(EffectiveTimeZone.identifier) else { + return nil + } + if let hint = EffectiveTimeZone.calendarTimeZoneHint { + return hint + } + return CalendarTimeZoneHint.pick(from: calendars) + } + private static func calendarIdsWithVisibleEventsOnDay( dayKey: String, viewDay: Date, - viewDayTimeZone: String?, + queryWindowTimeZone: String?, timedEvents: [GridLayoutTimedEventInput], allDayEvents: [GridLayoutAllDayEventInput], hiddenEventIds: Set @@ -82,7 +91,7 @@ public enum DayCalendarColumns { timedEventOverlapsViewDay( timed, viewDay: viewDay, - viewDayTimeZone: viewDayTimeZone + queryWindowTimeZone: queryWindowTimeZone ) else { continue } ids.insert(calendarId) @@ -104,11 +113,11 @@ public enum DayCalendarColumns { private static func timedEventOverlapsViewDay( _ timed: GridLayoutTimedEventInput, viewDay: Date, - viewDayTimeZone: String? + queryWindowTimeZone: String? ) -> Bool { let range = CalendarWindowMath.dayEventQueryBounds( date: viewDay, - timeZoneIdentifier: viewDayTimeZone + timeZoneIdentifier: queryWindowTimeZone ) let rangeStart = range.start let rangeEnd = range.end @@ -145,8 +154,6 @@ extension GridLayoutScenario { guard layoutMode == .day, let dayKey = visibleDateKeys.first else { return DayCalendarColumns.dayViewCalendars(calendars) } - let viewDay = - CompassDateParsing.calendarDateInEffectiveTimeZone(dayKey) ?? referenceNow return DayCalendarColumns.dayViewCalendars( calendars, dayKey: dayKey, @@ -154,8 +161,7 @@ extension GridLayoutScenario { allDayEvents: allDayEvents, hiddenEventIds: hiddenEventIds, pinnedCalendarIds: pinnedDayColumnCalendarIds, - defaultTargetCalendarId: defaultTargetCalendarId, - viewDay: viewDay + defaultTargetCalendarId: defaultTargetCalendarId ) } } diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/EffectiveTimeZone.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/EffectiveTimeZone.swift index bd4e400846..1f404e7c58 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/EffectiveTimeZone.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/EffectiveTimeZone.swift @@ -4,12 +4,19 @@ import Foundation public enum EffectiveTimeZone: Sendable { // Matches the web pinned-timezone store: UI/config writes; layout math reads. private nonisolated(unsafe) static var identifierStorage = "UTC" + private nonisolated(unsafe) static var calendarTimeZoneHintStorage: String? public static var identifier: String { get { identifierStorage } set { identifierStorage = newValue } } + /// Provider calendar zone used for day-query overlap when the effective zone is UTC-like. + public static var calendarTimeZoneHint: String? { + get { calendarTimeZoneHintStorage } + set { calendarTimeZoneHintStorage = newValue } + } + public static var timeZone: TimeZone { TimeZone(identifier: identifier) ?? .gmt } diff --git a/apps/calendar-macos/CompassKitTests/DayCalendarColumnsTests.swift b/apps/calendar-macos/CompassKitTests/DayCalendarColumnsTests.swift index 0099341dfc..109b99487f 100644 --- a/apps/calendar-macos/CompassKitTests/DayCalendarColumnsTests.swift +++ b/apps/calendar-macos/CompassKitTests/DayCalendarColumnsTests.swift @@ -5,6 +5,7 @@ final class DayCalendarColumnsTests: XCTestCase { override func setUp() { super.setUp() EffectiveTimeZone.identifier = "UTC" + EffectiveTimeZone.calendarTimeZoneHint = nil } func testDayViewShowsOnlyCalendarsWithVisibleEvents() { @@ -80,43 +81,59 @@ final class DayCalendarColumnsTests: XCTestCase { } func testDayViewKeepsBothColumnsAtMergeQueueUTCBoundary() throws { - let primary = GridLayoutSnapshotFixtures.parityCalendars[0] - let secondary = GridLayoutSnapshotFixtures.parityCalendars[1] - let viewDay = try XCTUnwrap( + let denverCalendars = GridLayoutSnapshotFixtures.parityCalendars.map { calendar in + CompassCalendar( + access: calendar.access, + accountEmail: calendar.accountEmail, + backgroundColor: calendar.backgroundColor, + capabilities: calendar.capabilities, + conference: calendar.conference, + createsGoogleMeet: calendar.createsGoogleMeet, + description: calendar.description, + foregroundColor: calendar.foregroundColor, + id: calendar.id, + isActive: calendar.isActive, + isPrimary: calendar.isPrimary, + isVisible: calendar.isVisible, + name: calendar.name, + provider: calendar.provider, + timeZone: "America/Denver" + ) + } + let referenceNow = try XCTUnwrap( CompassDateParsing.parseGridDateTime( - "2026-11-11T17:23:00", - timeZoneIdentifier: "America/Denver" + "2026-11-12T00:23:00.000", + timeZoneIdentifier: "UTC" ) ) - let timed = [ - GridLayoutTimedEventInput( - calendarId: primary.id, - endDate: "2026-11-11 10:30:00.000", - eventId: "boundary-a", - startDate: "2026-11-11 09:00:00.000", - timeZone: "America/Denver", - title: "Morning" - ), - GridLayoutTimedEventInput( - calendarId: secondary.id, - endDate: "2026-11-11 14:30:00.000", - eventId: "boundary-b", - startDate: "2026-11-11 13:00:00.000", - timeZone: "America/Denver", - title: "Afternoon" - ), - ] - - let columns = DayCalendarColumns.dayViewCalendars( - [primary, secondary], - dayKey: "2026-11-11", - timedEvents: timed, - allDayEvents: [], - hiddenEventIds: [], - viewDay: viewDay, - viewDayTimeZone: "America/Denver" + let work = denverCalendars[0] + let personal = denverCalendars[1] + let scenario = GridLayoutScenario( + calendars: denverCalendars, + layoutMode: .day, + referenceNow: referenceNow, + timedEvents: [ + GridLayoutTimedEventInput( + calendarId: work.id, + endDate: "2026-11-11T10:30:00.000", + eventId: "boundary-a", + startDate: "2026-11-11T09:00:00.000", + timeZone: "America/Denver", + title: "Morning" + ), + GridLayoutTimedEventInput( + calendarId: personal.id, + endDate: "2026-11-11T14:30:00.000", + eventId: "boundary-b", + startDate: "2026-11-11T13:00:00.000", + timeZone: "America/Denver", + title: "Afternoon" + ), + ], + visibleDateKeys: ["2026-11-12"] ) + let columns = scenario.dayViewCalendarsForLayout() XCTAssertEqual(columns.map(\.name), ["Work", "Personal"]) } } From 4c12fccb3767a21a98c236112b051c8f79851fec Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 12 Nov 2026 06:16:56 +0000 Subject: [PATCH 5/5] fix(macos): calendar hint in ViewStore effective timezone Mirror web pin-then-hint-then-browser resolution so fetch windows, anchors, and day columns share one zone; drop the DayCalendarColumns side channel. Co-authored-by: Tyler Dane --- .../CompassData/NativeCalendarRootModel.swift | 6 +- .../CompassData/Stores/ViewStore.swift | 11 +- .../ViewStoreCalendarTimeZoneTests.swift | 102 ++++++++++++++++++ .../Calendar/CalendarTimeZoneHint.swift | 6 +- .../Calendar/DayCalendarColumns.swift | 29 +---- .../Calendar/EffectiveTimeZone.swift | 7 -- .../DayCalendarColumnsTests.swift | 21 ++-- 7 files changed, 130 insertions(+), 52 deletions(-) create mode 100644 apps/calendar-macos/CompassData/Tests/CompassDataTests/ViewStoreCalendarTimeZoneTests.swift diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift index dce2bd3213..e5a248547c 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift @@ -201,8 +201,6 @@ public final class NativeCalendarRootModel { demoPresentation == nil ? CompassDevicePreferences.readPinnedTimeZoneResolvingAccidentalUtc() : nil - EffectiveTimeZone.identifier = - demoTimeZone ?? pinnedTimeZone ?? TimeZone.current.identifier eventsStore = environment.eventsStore hiddenEventsStore = environment.hiddenEventsStore calendarVisibilityStore = CalendarVisibilityStore() @@ -782,12 +780,12 @@ public final class NativeCalendarRootModel { } func rebuildPresentation() { + viewStore.setCalendarTimeZoneHint( + CalendarTimeZoneHint.pick(from: visibleCalendars())) if viewStore.view == .life { headerTitle = "Life" return } - EffectiveTimeZone.calendarTimeZoneHint = - CalendarTimeZoneHint.pick(from: visibleCalendars()) headerTitle = CalendarHeadingLabel.format(start: startOfView, end: endOfView, now: referenceNow) let hiddenIds = gridHiddenEventIdStrings let demoIds = demoEventIds diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/Stores/ViewStore.swift b/apps/calendar-macos/CompassData/Sources/CompassData/Stores/ViewStore.swift index a5d0eab2aa..06ad4ac5be 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/Stores/ViewStore.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/Stores/ViewStore.swift @@ -18,6 +18,7 @@ public final class ViewStore { public var anchorDate: Date public var visibleDayCount: Int public private(set) var pinnedTimeZone: String? + public private(set) var calendarTimeZoneHint: String? public private(set) var browserTimeZone: String public private(set) var timeTravelTimeZone: String? @@ -39,7 +40,9 @@ public final class ViewStore { } public var effectiveTimeZone: String { - pinnedTimeZone ?? browserTimeZone + if let pinnedTimeZone { return pinnedTimeZone } + if let calendarTimeZoneHint { return calendarTimeZoneHint } + return browserTimeZone } public func setAnchorDate(_ date: Date) { @@ -60,6 +63,12 @@ public final class ViewStore { return true } + public func setCalendarTimeZoneHint(_ hint: String?) { + guard hint == nil || TimeZoneFormatting.isValidTimeZone(hint!) else { return } + calendarTimeZoneHint = hint + syncEffectiveTimeZone() + } + public func refreshBrowserTimeZone(_ timeZone: String = TimeZone.current.identifier) { browserTimeZone = timeZone if pinnedTimeZone == nil { diff --git a/apps/calendar-macos/CompassData/Tests/CompassDataTests/ViewStoreCalendarTimeZoneTests.swift b/apps/calendar-macos/CompassData/Tests/CompassDataTests/ViewStoreCalendarTimeZoneTests.swift new file mode 100644 index 0000000000..901d64e404 --- /dev/null +++ b/apps/calendar-macos/CompassData/Tests/CompassDataTests/ViewStoreCalendarTimeZoneTests.swift @@ -0,0 +1,102 @@ +import CompassData +import CompassKit +import XCTest + +@MainActor +final class ViewStoreCalendarTimeZoneTests: XCTestCase { + func testCalendarHintBetweenPinAndBrowserLikeWeb() { + let viewStore = ViewStore(browserTimeZone: "UTC") + viewStore.setCalendarTimeZoneHint("America/Denver") + XCTAssertEqual(viewStore.effectiveTimeZone, "America/Denver") + XCTAssertEqual(EffectiveTimeZone.identifier, "America/Denver") + } + + func testPinWinsOverCalendarHint() { + let viewStore = ViewStore(browserTimeZone: "UTC") + viewStore.setCalendarTimeZoneHint("America/Denver") + XCTAssertTrue(viewStore.setPinnedTimeZone("America/Chicago")) + XCTAssertEqual(viewStore.effectiveTimeZone, "America/Chicago") + XCTAssertEqual(EffectiveTimeZone.identifier, "America/Chicago") + } + + func testDayFetchWindowAndColumnsAtUtcMidnightBoundary() throws { + let viewStore = ViewStore(browserTimeZone: "UTC") + viewStore.setCalendarTimeZoneHint("America/Denver") + let referenceNow = try XCTUnwrap( + CompassDateParsing.parseGridDateTime( + "2026-11-12T00:23:00.000", + timeZoneIdentifier: "UTC" + ) + ) + let startOfView = EffectiveTimeZone.calendar.startOfDay(for: referenceNow) + XCTAssertEqual(CompassDateParsing.formatCalendarDay(startOfView), "2026-11-11") + + let queryRange = CalendarWindowMath.dayEventQueryRange(date: startOfView) + let rangeStart = try XCTUnwrap( + CompassDateParsing.parseInEffectiveTimeZone(queryRange.startDate)) + let rangeEnd = try XCTUnwrap( + CompassDateParsing.parseInEffectiveTimeZone(queryRange.endDate)) + + let denverCalendars = GridLayoutSnapshotFixtures.parityCalendars.map { calendar in + CompassCalendar( + access: calendar.access, + accountEmail: calendar.accountEmail, + backgroundColor: calendar.backgroundColor, + capabilities: calendar.capabilities, + conference: calendar.conference, + createsGoogleMeet: calendar.createsGoogleMeet, + description: calendar.description, + foregroundColor: calendar.foregroundColor, + id: calendar.id, + isActive: calendar.isActive, + isPrimary: calendar.isPrimary, + isVisible: calendar.isVisible, + name: calendar.name, + provider: calendar.provider, + timeZone: IANATimeZone(rawValue: "America/Denver") + ) + } + let work = denverCalendars[0] + let personal = denverCalendars[1] + let morningStart = try XCTUnwrap( + CompassDateParsing.parseGridDateTime( + "2026-11-11T09:00:00.000", + timeZoneIdentifier: "America/Denver" + ) + ) + let morningEnd = try XCTUnwrap( + CompassDateParsing.parseGridDateTime( + "2026-11-11T10:30:00.000", + timeZoneIdentifier: "America/Denver" + ) + ) + XCTAssertTrue(morningStart < rangeEnd && morningEnd > rangeStart) + + let scenario = GridLayoutScenario( + calendars: denverCalendars, + layoutMode: .day, + referenceNow: referenceNow, + timedEvents: [ + GridLayoutTimedEventInput( + calendarId: work.id, + endDate: "2026-11-11T10:30:00.000", + eventId: "boundary-a", + startDate: "2026-11-11T09:00:00.000", + timeZone: "America/Denver", + title: "Morning" + ), + GridLayoutTimedEventInput( + calendarId: personal.id, + endDate: "2026-11-11T14:30:00.000", + eventId: "boundary-b", + startDate: "2026-11-11T13:00:00.000", + timeZone: "America/Denver", + title: "Afternoon" + ), + ], + visibleDateKeys: ["2026-11-11"] + ) + + XCTAssertEqual(scenario.dayViewCalendarsForLayout().map(\.name), ["Work", "Personal"]) + } +} diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/CalendarTimeZoneHint.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/CalendarTimeZoneHint.swift index 30f95eccfe..9948ffe6e6 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/CalendarTimeZoneHint.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/CalendarTimeZoneHint.swift @@ -4,14 +4,14 @@ import Foundation public enum CalendarTimeZoneHint { public static func pick(from calendars: [CompassCalendar]) -> String? { if let primary = calendars.first(where: { $0.isPrimary }), - let zone = primary.timeZone, + let zone = primary.timeZone?.rawValue, TimeZoneFormatting.isValidTimeZone(zone) { return zone } for calendar in calendars { - guard let zone = calendar.timeZone, + guard let zone = calendar.timeZone?.rawValue, TimeZoneFormatting.isValidTimeZone(zone), !TimeZoneFormatting.isUtcLikeTimeZone(zone) else { continue } @@ -19,7 +19,7 @@ public enum CalendarTimeZoneHint { } for calendar in calendars { - guard let zone = calendar.timeZone, + guard let zone = calendar.timeZone?.rawValue, TimeZoneFormatting.isValidTimeZone(zone) else { continue } return zone diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DayCalendarColumns.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DayCalendarColumns.swift index 714661cb7f..bd16cf5452 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DayCalendarColumns.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DayCalendarColumns.swift @@ -14,11 +14,9 @@ public enum DayCalendarColumns { let ordered = eligibleCalendarsInOrder(calendars) let viewAnchor = CompassDateParsing.calendarDateInEffectiveTimeZone(dayKey) ?? .distantPast - let queryWindowTimeZone = timedEventsQueryWindowTimeZone(calendars: calendars) let eventCalendarIds = calendarIdsWithVisibleEventsOnDay( dayKey: dayKey, viewDay: viewAnchor, - queryWindowTimeZone: queryWindowTimeZone, timedEvents: timedEvents, allDayEvents: allDayEvents, hiddenEventIds: hiddenEventIds @@ -64,22 +62,9 @@ public enum DayCalendarColumns { return [] } - private static func timedEventsQueryWindowTimeZone( - calendars: [CompassCalendar] - ) -> String? { - guard TimeZoneFormatting.isUtcLikeTimeZone(EffectiveTimeZone.identifier) else { - return nil - } - if let hint = EffectiveTimeZone.calendarTimeZoneHint { - return hint - } - return CalendarTimeZoneHint.pick(from: calendars) - } - private static func calendarIdsWithVisibleEventsOnDay( dayKey: String, viewDay: Date, - queryWindowTimeZone: String?, timedEvents: [GridLayoutTimedEventInput], allDayEvents: [GridLayoutAllDayEventInput], hiddenEventIds: Set @@ -88,11 +73,7 @@ public enum DayCalendarColumns { for timed in timedEvents { guard let calendarId = timed.calendarId, !hiddenEventIds.contains(timed.eventId), - timedEventOverlapsViewDay( - timed, - viewDay: viewDay, - queryWindowTimeZone: queryWindowTimeZone - ) + timedEventOverlapsViewDay(timed, viewDay: viewDay) else { continue } ids.insert(calendarId) } @@ -112,13 +93,9 @@ public enum DayCalendarColumns { private static func timedEventOverlapsViewDay( _ timed: GridLayoutTimedEventInput, - viewDay: Date, - queryWindowTimeZone: String? + viewDay: Date ) -> Bool { - let range = CalendarWindowMath.dayEventQueryBounds( - date: viewDay, - timeZoneIdentifier: queryWindowTimeZone - ) + let range = CalendarWindowMath.dayEventQueryBounds(date: viewDay) let rangeStart = range.start let rangeEnd = range.end let eventZone = timed.timeZone ?? EffectiveTimeZone.identifier diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/EffectiveTimeZone.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/EffectiveTimeZone.swift index 1f404e7c58..bd4e400846 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/EffectiveTimeZone.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/EffectiveTimeZone.swift @@ -4,19 +4,12 @@ import Foundation public enum EffectiveTimeZone: Sendable { // Matches the web pinned-timezone store: UI/config writes; layout math reads. private nonisolated(unsafe) static var identifierStorage = "UTC" - private nonisolated(unsafe) static var calendarTimeZoneHintStorage: String? public static var identifier: String { get { identifierStorage } set { identifierStorage = newValue } } - /// Provider calendar zone used for day-query overlap when the effective zone is UTC-like. - public static var calendarTimeZoneHint: String? { - get { calendarTimeZoneHintStorage } - set { calendarTimeZoneHintStorage = newValue } - } - public static var timeZone: TimeZone { TimeZone(identifier: identifier) ?? .gmt } diff --git a/apps/calendar-macos/CompassKitTests/DayCalendarColumnsTests.swift b/apps/calendar-macos/CompassKitTests/DayCalendarColumnsTests.swift index 109b99487f..2c1db883df 100644 --- a/apps/calendar-macos/CompassKitTests/DayCalendarColumnsTests.swift +++ b/apps/calendar-macos/CompassKitTests/DayCalendarColumnsTests.swift @@ -5,7 +5,6 @@ final class DayCalendarColumnsTests: XCTestCase { override func setUp() { super.setUp() EffectiveTimeZone.identifier = "UTC" - EffectiveTimeZone.calendarTimeZoneHint = nil } func testDayViewShowsOnlyCalendarsWithVisibleEvents() { @@ -80,7 +79,8 @@ final class DayCalendarColumnsTests: XCTestCase { XCTAssertEqual(columns.map(\.id), [personal.id]) } - func testDayViewKeepsBothColumnsAtMergeQueueUTCBoundary() throws { + func testDayViewTimedColumnsUseEffectiveQueryWindowOverlap() throws { + EffectiveTimeZone.identifier = "America/Denver" let denverCalendars = GridLayoutSnapshotFixtures.parityCalendars.map { calendar in CompassCalendar( access: calendar.access, @@ -97,21 +97,20 @@ final class DayCalendarColumnsTests: XCTestCase { isVisible: calendar.isVisible, name: calendar.name, provider: calendar.provider, - timeZone: "America/Denver" + timeZone: IANATimeZone(rawValue: "America/Denver") ) } - let referenceNow = try XCTUnwrap( - CompassDateParsing.parseGridDateTime( - "2026-11-12T00:23:00.000", - timeZoneIdentifier: "UTC" - ) - ) let work = denverCalendars[0] let personal = denverCalendars[1] let scenario = GridLayoutScenario( calendars: denverCalendars, layoutMode: .day, - referenceNow: referenceNow, + referenceNow: try XCTUnwrap( + CompassDateParsing.parseGridDateTime( + "2026-11-12T00:23:00.000", + timeZoneIdentifier: "UTC" + ) + ), timedEvents: [ GridLayoutTimedEventInput( calendarId: work.id, @@ -130,7 +129,7 @@ final class DayCalendarColumnsTests: XCTestCase { title: "Afternoon" ), ], - visibleDateKeys: ["2026-11-12"] + visibleDateKeys: ["2026-11-11"] ) let columns = scenario.dayViewCalendarsForLayout()