diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift index e176cf2e6..e5a248547 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,6 +780,8 @@ public final class NativeCalendarRootModel { } func rebuildPresentation() { + viewStore.setCalendarTimeZoneHint( + CalendarTimeZoneHint.pick(from: visibleCalendars())) if viewStore.view == .life { headerTitle = "Life" return diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/Stores/ViewStore.swift b/apps/calendar-macos/CompassData/Sources/CompassData/Stores/ViewStore.swift index a5d0eab2a..06ad4ac5b 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 000000000..901d64e40 --- /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 new file mode 100644 index 000000000..9948ffe6e --- /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?.rawValue, + TimeZoneFormatting.isValidTimeZone(zone) + { + return zone + } + + for calendar in calendars { + guard let zone = calendar.timeZone?.rawValue, + TimeZoneFormatting.isValidTimeZone(zone), + !TimeZoneFormatting.isUtcLikeTimeZone(zone) + else { continue } + return zone + } + + for calendar in calendars { + guard let zone = calendar.timeZone?.rawValue, + TimeZoneFormatting.isValidTimeZone(zone) + else { continue } + return zone + } + + return nil + } +} diff --git a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/CalendarWindowMath.swift b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/CalendarWindowMath.swift index 8b8ee28aa..6845603a9 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/CalendarWindowMath.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/CalendarWindowMath.swift @@ -28,14 +28,34 @@ public enum CalendarWindowMath { return (window.startISO, window.endISO) } - /// 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 dayEventQueryBounds( + date: Date, + timeZoneIdentifier: String? = nil + ) -> (start: Date, end: Date) { + 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 (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 c3a295b64..bd16cf545 100644 --- a/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DayCalendarColumns.swift +++ b/apps/calendar-macos/CompassKit/Sources/CompassKit/Calendar/DayCalendarColumns.swift @@ -12,8 +12,11 @@ public enum DayCalendarColumns { defaultTargetCalendarId: String? = nil ) -> [CompassCalendar] { let ordered = eligibleCalendarsInOrder(calendars) + let viewAnchor = + CompassDateParsing.calendarDateInEffectiveTimeZone(dayKey) ?? .distantPast let eventCalendarIds = calendarIdsWithVisibleEventsOnDay( dayKey: dayKey, + viewDay: viewAnchor, timedEvents: timedEvents, allDayEvents: allDayEvents, hiddenEventIds: hiddenEventIds @@ -61,6 +64,7 @@ public enum DayCalendarColumns { private static func calendarIdsWithVisibleEventsOnDay( dayKey: String, + viewDay: Date, timedEvents: [GridLayoutTimedEventInput], allDayEvents: [GridLayoutAllDayEventInput], hiddenEventIds: Set @@ -69,7 +73,7 @@ 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) else { continue } ids.insert(calendarId) } @@ -87,11 +91,26 @@ 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 + ) -> Bool { + let range = CalendarWindowMath.dayEventQueryBounds(date: viewDay) + let rangeStart = range.start + let rangeEnd = range.end + 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 { diff --git a/apps/calendar-macos/CompassKitTests/DayCalendarColumnsTests.swift b/apps/calendar-macos/CompassKitTests/DayCalendarColumnsTests.swift index 07915e9fc..2c1db883d 100644 --- a/apps/calendar-macos/CompassKitTests/DayCalendarColumnsTests.swift +++ b/apps/calendar-macos/CompassKitTests/DayCalendarColumnsTests.swift @@ -78,4 +78,61 @@ final class DayCalendarColumnsTests: XCTestCase { ) XCTAssertEqual(columns.map(\.id), [personal.id]) } + + func testDayViewTimedColumnsUseEffectiveQueryWindowOverlap() throws { + EffectiveTimeZone.identifier = "America/Denver" + 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 scenario = GridLayoutScenario( + calendars: denverCalendars, + layoutMode: .day, + referenceNow: try XCTUnwrap( + CompassDateParsing.parseGridDateTime( + "2026-11-12T00:23:00.000", + timeZoneIdentifier: "UTC" + ) + ), + 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"] + ) + + let columns = scenario.dayViewCalendarsForLayout() + XCTAssertEqual(columns.map(\.name), ["Work", "Personal"]) + } }