diff --git a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift index 57fdb7d3e..4d2bbf688 100644 --- a/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift +++ b/apps/calendar-macos/CompassData/Sources/CompassData/NativeCalendarRootModel.swift @@ -1190,3 +1190,15 @@ public final class NativeCalendarRootModel { } } } + +extension NativeCalendarRootModel { + /// Tests drive calendar-hint sync without mocking the remote list API. + internal func applyCalendarsForPresentationTesting(_ updated: [CompassCalendar]) { + calendars = updated + rebuildPresentation() + } + + internal var testingVisibleRangeRefreshIsScheduled: Bool { + refreshTask != nil + } +} diff --git a/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelCalendarHintSyncTests.swift b/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelCalendarHintSyncTests.swift new file mode 100644 index 000000000..615e2c4a1 --- /dev/null +++ b/apps/calendar-macos/CompassData/Tests/CompassDataTests/NativeCalendarRootModelCalendarHintSyncTests.swift @@ -0,0 +1,91 @@ +import CompassData +import CompassKit +import XCTest +@testable import CompassData + +@MainActor +final class NativeCalendarRootModelCalendarHintSyncTests: XCTestCase { + func testCalendarHintChangeReanchorsWeekAndSchedulesRangeRefresh() async throws { + let priorDefault = NSTimeZone.default + defer { NSTimeZone.default = priorDefault } + NSTimeZone.default = try XCTUnwrap(TimeZone(identifier: "UTC")) + + let referenceNow = try XCTUnwrap( + CompassDateParsing.parseGridDateTime( + "2026-11-12T00:23:00.000", + timeZoneIdentifier: "UTC" + ) + ) + let environment = try NativeCalendarEnvironment(inMemoryDatabase: true) + let model = NativeCalendarRootModel(environment: environment, now: { referenceNow }) + model.viewStore.setCalendarTimeZoneHint("America/New_York") + + let nycOnly = [ + compassCalendar(id: "nyc", name: "NYC", timeZone: "America/New_York", isPrimary: true, isVisible: true), + ] + model.applyCalendarsForPresentationTesting(nycOnly) + let anchorInNewYork = model.viewStore.anchorDate + XCTAssertEqual(EffectiveTimeZone.identifier, "America/New_York") + + let hiddenDenverPrimary = [ + compassCalendar( + id: "denver-primary", name: "Primary", timeZone: "America/Denver", isPrimary: true, isVisible: false + ), + compassCalendar(id: "nyc", name: "NYC", timeZone: "America/New_York", isPrimary: false, isVisible: true), + ] + model.applyCalendarsForPresentationTesting(hiddenDenverPrimary) + + XCTAssertEqual(EffectiveTimeZone.identifier, "America/Denver") + XCTAssertNotEqual(model.viewStore.anchorDate, anchorInNewYork) + let expected = CalendarWindowMath.sundayWeekViewStart(containing: referenceNow) + XCTAssertEqual(model.viewStore.anchorDate, expected) + XCTAssertTrue(model.testingVisibleRangeRefreshIsScheduled) + } + + func testHiddenPrimaryDenverWinsOverVisibleNewYorkOnFullList() async throws { + let environment = try NativeCalendarEnvironment(inMemoryDatabase: true) + let model = NativeCalendarRootModel(environment: environment) + model.applyCalendarsForPresentationTesting([ + compassCalendar( + id: "denver-primary", name: "Primary", timeZone: "America/Denver", isPrimary: true, isVisible: false + ), + compassCalendar(id: "nyc", name: "NYC", timeZone: "America/New_York", isPrimary: false, isVisible: true), + ]) + XCTAssertEqual(EffectiveTimeZone.identifier, "America/Denver") + } + + private func compassCalendar( + id: String, + name: String, + timeZone: String, + isPrimary: Bool, + isVisible: Bool + ) -> CompassCalendar { + let capabilities = CompassCalendarCapabilities( + canInviteAttendees: false, + canManage: false, + canReadAvailability: true, + canReadDetails: true, + canWatchEvents: false, + canWrite: true, + conferenceKinds: [] + ) + return CompassCalendar( + access: .owner, + accountEmail: "a@example.com", + backgroundColor: "#ffffff", + capabilities: capabilities, + conference: nil, + createsGoogleMeet: false, + description: "", + foregroundColor: "#000000", + id: id, + isActive: true, + isPrimary: isPrimary, + isVisible: isVisible, + name: name, + provider: "google", + timeZone: IANATimeZone(rawValue: timeZone) + ) + } +} diff --git a/apps/calendar-web/src/calendars/calendar.query.test.ts b/apps/calendar-web/src/calendars/calendar.query.test.ts new file mode 100644 index 000000000..dce0d87ad --- /dev/null +++ b/apps/calendar-web/src/calendars/calendar.query.test.ts @@ -0,0 +1,74 @@ +import { type QueryFunctionContext } from "@tanstack/react-query"; +import { TimeZoneSchema } from "@core/types/domain-primitives"; +import { createMockCalendar } from "@web/__tests__/utils/factories/calendar.factory"; +import { CalendarApi } from "@web/api/calendar.api"; +import { queryClient } from "@web/api/query-client"; +import { + calendarQueryKeys, + calendarsQueryOptions, +} from "@web/calendars/calendar.query"; +import * as calendarTimeZoneHint from "@web/timezone/calendar-timezone-hint"; +import { + resetEffectiveTimeZoneStoreForTests, + setBrowserTimeZoneForTests, +} from "@web/timezone/effective-timezone.store"; +import { afterEach, describe, expect, it, spyOn } from "bun:test"; + +function calendarsQueryContext(): QueryFunctionContext< + typeof calendarQueryKeys.all +> { + return { + client: queryClient, + queryKey: calendarQueryKeys.all, + signal: new AbortController().signal, + meta: undefined, + }; +} + +describe("calendarsQueryOptions", () => { + afterEach(() => { + resetEffectiveTimeZoneStoreForTests(); + }); + + it("syncs the calendar timezone hint when the remote list resolves", async () => { + const calendars = [ + createMockCalendar({ + name: "Work", + timeZone: TimeZoneSchema.parse("America/Denver"), + isPrimary: true, + }), + ]; + const list = spyOn(CalendarApi, "list").mockResolvedValue(calendars); + const sync = spyOn( + calendarTimeZoneHint, + "syncCalendarTimeZoneHintFromCalendars", + ); + + await calendarsQueryOptions(true).queryFn!(calendarsQueryContext()); + + expect(list).toHaveBeenCalledTimes(1); + expect(sync).toHaveBeenCalledTimes(1); + expect(sync).toHaveBeenCalledWith(calendars); + + list.mockRestore(); + sync.mockRestore(); + }); + + it("syncs the calendar timezone hint for the anonymous sentinel path", async () => { + setBrowserTimeZoneForTests("UTC"); + const sync = spyOn( + calendarTimeZoneHint, + "syncCalendarTimeZoneHintFromCalendars", + ); + + await calendarsQueryOptions(false).queryFn!(calendarsQueryContext()); + + expect(sync).toHaveBeenCalledTimes(1); + const synced = sync.mock.calls[0]?.[0]; + expect(synced).toBeDefined(); + expect(synced).toHaveLength(1); + expect(synced![0]!.isVisible).toBe(true); + + sync.mockRestore(); + }); +}); diff --git a/apps/calendar-web/src/routers/loaders.test.ts b/apps/calendar-web/src/routers/loaders.test.ts index a35988d77..b0fadc492 100644 --- a/apps/calendar-web/src/routers/loaders.test.ts +++ b/apps/calendar-web/src/routers/loaders.test.ts @@ -161,6 +161,14 @@ describe("loadTodayData", () => { expect(loadTodayData().dateString).toBe("2026-11-11"); }); + + it("uses the browser timezone for today when the calendars cache is empty", () => { + setBrowserTimeZoneForTests("America/Chicago"); + setSystemTime(new Date("2026-06-01T18:00:00.000Z")); + queryClient.removeQueries({ queryKey: calendarQueryKeys.all }); + + expect(loadTodayData().dateString).toBe("2026-06-01"); + }); }); describe("loadDateParam", () => {