Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -782,6 +780,8 @@ public final class NativeCalendarRootModel {
}

func rebuildPresentation() {
viewStore.setCalendarTimeZoneHint(
CalendarTimeZoneHint.pick(from: visibleCalendars()))
if viewStore.view == .life {
headerTitle = "Life"
return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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) {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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"])
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -61,6 +64,7 @@ public enum DayCalendarColumns {

private static func calendarIdsWithVisibleEventsOnDay(
dayKey: String,
viewDay: Date,
timedEvents: [GridLayoutTimedEventInput],
allDayEvents: [GridLayoutAllDayEventInput],
hiddenEventIds: Set<String>
Expand All @@ -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)
}
Expand All @@ -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 {
Expand Down
57 changes: 57 additions & 0 deletions apps/calendar-macos/CompassKitTests/DayCalendarColumnsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
}
}
Loading