diff --git a/apps/calendar-web/src/calendars/calendar.query.ts b/apps/calendar-web/src/calendars/calendar.query.ts index 3454e47690..3b4ee451da 100644 --- a/apps/calendar-web/src/calendars/calendar.query.ts +++ b/apps/calendar-web/src/calendars/calendar.query.ts @@ -16,6 +16,7 @@ import { isSentinelCalendarList, synthesizeLocalCalendar, } from "@web/calendars/local-calendar.sentinel"; +import { syncCalendarTimeZoneHintFromCalendars } from "@web/timezone/calendar-timezone-hint"; export const calendarQueryKeys = { all: ["calendars"] as const, @@ -36,10 +37,14 @@ export function calendarsQueryOptions(authenticated: boolean) { // covers the gap before React re-renders after the session store flips, // so a still-mounted anonymous observer cannot write the sentinel back. if (!authenticated && !readAuthenticated()) { - return [synthesizeLocalCalendar(getLocalCalendarSentinelId())]; + const local = [synthesizeLocalCalendar(getLocalCalendarSentinelId())]; + syncCalendarTimeZoneHintFromCalendars(local); + return local; } - return CalendarApi.list(); + const calendars = await CalendarApi.list(); + syncCalendarTimeZoneHintFromCalendars(calendars); + return calendars; }, staleTime: 60_000, }); @@ -97,6 +102,7 @@ export async function ensureRemoteCalendars( const calendars = await client.fetchQuery(calendarsQueryOptions(true)); if (isSentinelCalendarList(calendars)) return undefined; + syncCalendarTimeZoneHintFromCalendars(calendars); return calendars; } diff --git a/apps/calendar-web/src/routers/loaders.test.ts b/apps/calendar-web/src/routers/loaders.test.ts index dd58bd0c41..a35988d779 100644 --- a/apps/calendar-web/src/routers/loaders.test.ts +++ b/apps/calendar-web/src/routers/loaders.test.ts @@ -5,6 +5,10 @@ import { createRouter, isRedirect, } from "@tanstack/react-router"; +import { TimeZoneSchema } from "@core/types/domain-primitives"; +import { createMockCalendar } from "@web/__tests__/utils/factories/calendar.factory"; +import { queryClient } from "@web/api/query-client"; +import { calendarQueryKeys } from "@web/calendars/calendar.query"; import { ROOT_ROUTES } from "@web/common/constants/routes"; import * as prefetchWeekEvents from "@web/events/queries/prefetch-week-events"; import { @@ -16,7 +20,18 @@ import { redirectToToday, validateWeekDateParam, } from "@web/routers/loaders"; -import { afterEach, describe, expect, it, spyOn } from "bun:test"; +import { + resetEffectiveTimeZoneStoreForTests, + setBrowserTimeZoneForTests, +} from "@web/timezone/effective-timezone.store"; +import { + afterEach, + describe, + expect, + it, + setSystemTime, + spyOn, +} from "bun:test"; function getRedirect(fn: () => unknown) { try { @@ -126,6 +141,28 @@ describe("router redirects", () => { }); }); +describe("loadTodayData", () => { + afterEach(() => { + resetEffectiveTimeZoneStoreForTests(); + queryClient.clear(); + setSystemTime(); + }); + + it("applies a cached calendar hint before picking today at the merge-queue boundary", () => { + setBrowserTimeZoneForTests("UTC"); + setSystemTime(new Date("2026-11-12T00:23:00.000Z")); + queryClient.setQueryData(calendarQueryKeys.all, [ + createMockCalendar({ + name: "miscellaneous", + timeZone: TimeZoneSchema.parse("America/Denver"), + isPrimary: true, + }), + ]); + + expect(loadTodayData().dateString).toBe("2026-11-11"); + }); +}); + describe("loadDateParam", () => { it("shapes the loader data for an already-valid dateString param", () => { const result = loadDateParam({ diff --git a/apps/calendar-web/src/routers/loaders.ts b/apps/calendar-web/src/routers/loaders.ts index 2ca4669557..2512166948 100644 --- a/apps/calendar-web/src/routers/loaders.ts +++ b/apps/calendar-web/src/routers/loaders.ts @@ -1,6 +1,9 @@ import { redirect } from "@tanstack/react-router"; +import { type Calendar } from "@core/types/calendar.contracts"; import { zYearMonthDayString } from "@core/types/type.utils"; import dayjs, { type Dayjs } from "@core/util/date/dayjs"; +import { queryClient } from "@web/api/query-client"; +import { calendarQueryKeys } from "@web/calendars/calendar.query"; import { DEFAULT_CALENDAR_ROUTE, ROOT_ROUTES, @@ -11,6 +14,7 @@ import { prefetchWeekEventsQuery, weekDateStringFromPathname, } from "@web/events/queries/prefetch-week-events"; +import { syncCalendarTimeZoneHintFromCalendars } from "@web/timezone/calendar-timezone-hint"; import { getEffectiveTimeZone } from "@web/timezone/effective-timezone.store"; export interface DayLoaderData { @@ -38,7 +42,15 @@ export async function loadAuthenticated() { return { authenticated }; } +function syncCachedCalendarTimeZoneHintBeforeToday(): void { + const calendars = queryClient.getQueryData(calendarQueryKeys.all); + if (calendars?.length) { + syncCalendarTimeZoneHintFromCalendars(calendars); + } +} + export function loadTodayData(): DayLoaderData { + syncCachedCalendarTimeZoneHintBeforeToday(); const dateInView = dayjs().tz(getEffectiveTimeZone()); const dateFormat = dayjs.DateFormat.YEAR_MONTH_DAY_FORMAT; diff --git a/apps/calendar-web/src/timezone/CalendarTimeZoneHintSync.tsx b/apps/calendar-web/src/timezone/CalendarTimeZoneHintSync.tsx index 89b3562eb2..6630f4ebcc 100644 --- a/apps/calendar-web/src/timezone/CalendarTimeZoneHintSync.tsx +++ b/apps/calendar-web/src/timezone/CalendarTimeZoneHintSync.tsx @@ -1,16 +1,13 @@ import { useLayoutEffect } from "react"; import { useCalendarsQuery } from "@web/calendars/calendar.query"; -import { pickCalendarTimeZoneHint } from "@web/timezone/calendar-timezone-hint"; -import { setCalendarTimeZoneHint } from "@web/timezone/effective-timezone.store"; +import { syncCalendarTimeZoneHintFromCalendars } from "@web/timezone/calendar-timezone-hint"; /** Keeps the Auto effective zone aligned with connected calendar timezones. */ export function CalendarTimeZoneHintSync() { const { data: calendars } = useCalendarsQuery(); useLayoutEffect(() => { - setCalendarTimeZoneHint( - calendars ? pickCalendarTimeZoneHint(calendars) : null, - ); + syncCalendarTimeZoneHintFromCalendars(calendars); }, [calendars]); return null; diff --git a/apps/calendar-web/src/timezone/calendar-timezone-hint.test.ts b/apps/calendar-web/src/timezone/calendar-timezone-hint.test.ts index bbd90110c2..919c813928 100644 --- a/apps/calendar-web/src/timezone/calendar-timezone-hint.test.ts +++ b/apps/calendar-web/src/timezone/calendar-timezone-hint.test.ts @@ -1,7 +1,39 @@ import { TimeZoneSchema } from "@core/types/domain-primitives"; +import dayjs from "@core/util/date/dayjs"; import { createMockCalendar } from "@web/__tests__/utils/factories/calendar.factory"; -import { pickCalendarTimeZoneHint } from "@web/timezone/calendar-timezone-hint"; -import { describe, expect, it } from "bun:test"; +import { + pickCalendarTimeZoneHint, + syncCalendarTimeZoneHintFromCalendars, +} from "@web/timezone/calendar-timezone-hint"; +import { + getEffectiveTimeZone, + resetEffectiveTimeZoneStoreForTests, + setBrowserTimeZoneForTests, +} from "@web/timezone/effective-timezone.store"; +import { afterEach, describe, expect, it, setSystemTime } from "bun:test"; + +describe("syncCalendarTimeZoneHintFromCalendars", () => { + afterEach(() => { + resetEffectiveTimeZoneStoreForTests(); + setSystemTime(); + }); + + it("applies denver today before navigation when the browser is UTC at the merge-queue boundary", () => { + setBrowserTimeZoneForTests("UTC"); + setSystemTime(new Date("2026-11-12T00:23:00.000Z")); + syncCalendarTimeZoneHintFromCalendars([ + createMockCalendar({ + name: "miscellaneous", + timeZone: TimeZoneSchema.parse("America/Denver"), + isPrimary: true, + }), + ]); + expect(getEffectiveTimeZone()).toBe("America/Denver"); + expect(dayjs().tz(getEffectiveTimeZone()).format("YYYY-MM-DD")).toBe( + "2026-11-11", + ); + }); +}); describe("pickCalendarTimeZoneHint", () => { it("prefers the primary calendar zone", () => { diff --git a/apps/calendar-web/src/timezone/calendar-timezone-hint.ts b/apps/calendar-web/src/timezone/calendar-timezone-hint.ts index 566f6baf4c..0f0261ec7c 100644 --- a/apps/calendar-web/src/timezone/calendar-timezone-hint.ts +++ b/apps/calendar-web/src/timezone/calendar-timezone-hint.ts @@ -1,5 +1,6 @@ import { type Calendar } from "@core/types/calendar.contracts"; import { isValidTimeZone } from "@web/timezone/browser-timezone"; +import { setCalendarTimeZoneHint } from "@web/timezone/effective-timezone.store"; import { isUtcLikeTimeZone } from "@web/timezone/utc-like-timezone"; /** @@ -32,3 +33,12 @@ export function pickCalendarTimeZoneHint( return null; } + +/** Keep Auto effective zone aligned with connected calendars (see CalendarTimeZoneHintSync). */ +export function syncCalendarTimeZoneHintFromCalendars( + calendars: readonly Calendar[] | null | undefined, +): void { + setCalendarTimeZoneHint( + calendars ? pickCalendarTimeZoneHint(calendars) : null, + ); +} diff --git a/apps/calendar-web/src/views/Day/components/Calendar/dayCalendarColumns.util.test.ts b/apps/calendar-web/src/views/Day/components/Calendar/dayCalendarColumns.util.test.ts index 93caeff47e..ba9b360d89 100644 --- a/apps/calendar-web/src/views/Day/components/Calendar/dayCalendarColumns.util.test.ts +++ b/apps/calendar-web/src/views/Day/components/Calendar/dayCalendarColumns.util.test.ts @@ -216,6 +216,68 @@ describe("calendarIdsWithVisibleEventsOnDay", () => { resetEffectiveTimeZoneStoreForTests(); }); + it("shows every stress calendar column when viewed day matches denver event day", () => { + setBrowserTimeZoneForTests("UTC"); + const stressCalendars = Array.from({ length: 12 }, (_, index) => + makeCalendar({ + id: `f${String(index).padStart(23, "0")}` as Calendar["id"], + name: `stress-${index}`, + isPrimary: index === 0, + }), + ); + const dateInViewDenver = dayjs.tz("2026-11-11T17:23:00", "America/Denver"); + const timed = stressCalendars.map((calendar, index) => + gridEvent({ + _id: String(50 + index), + calendarId: calendar.id, + startDate: "2026-11-11 09:00:00.000", + endDate: "2026-11-11 10:00:00.000", + timeZone: "America/Denver", + }), + ); + + expect( + getDayViewCalendars({ + allDayEvents: [], + calendars: stressCalendars, + dateInView: dateInViewDenver, + hiddenEventIds: new Set(), + timedEvents: timed, + }), + ).toHaveLength(12); + }); + + it("falls back to primary when viewed utc day mismatches denver stress events", () => { + setBrowserTimeZoneForTests("UTC"); + const stressCalendars = Array.from({ length: 12 }, (_, index) => + makeCalendar({ + id: `f${String(index).padStart(23, "0")}` as Calendar["id"], + name: `stress-${index}`, + isPrimary: index === 0, + }), + ); + const dateInViewUtc = dayjs.tz("2026-11-12T00:23:00", "UTC"); + const timed = stressCalendars.map((calendar, index) => + gridEvent({ + _id: String(50 + index), + calendarId: calendar.id, + startDate: "2026-11-11 09:00:00.000", + endDate: "2026-11-11 10:00:00.000", + timeZone: "America/Denver", + }), + ); + + expect( + getDayViewCalendars({ + allDayEvents: [], + calendars: stressCalendars, + dateInView: dateInViewUtc, + hiddenEventIds: new Set(), + timedEvents: timed, + }), + ).toHaveLength(1); + }); + it("keeps both day columns at the merge-queue UTC boundary (browser UTC, no calendar hint)", () => { setBrowserTimeZoneForTests("UTC"); const primary = makeCalendar({