From 6cccb1725f274f765e58893f20cd4919f7caec62 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Thu, 12 Jun 2025 17:26:44 +0200 Subject: [PATCH] fix: provide timeZone to /insights from the server side (#21580) --- .../app/(use-page-wrapper)/insights/page.tsx | 23 +++++++- apps/web/modules/insights/insights-view.tsx | 4 +- .../features/data-table/DataTableProvider.tsx | 5 ++ ...useChangeTimeZoneWithPreservedLocalTime.ts | 28 ++++++++++ .../lib}/__tests__/preserveLocalTime.test.ts | 2 +- .../data-table/lib/preserveLocalTime.ts | 31 +++++++++++ .../insights/hooks/useInsightsParameters.ts | 29 +++++----- .../react/hooks/useUserTimePreferences.ts | 55 ------------------- .../trpc/server/routers/viewer/me/_router.tsx | 4 -- .../me/getUserTimePreferences.handler.ts | 19 ------- 10 files changed, 102 insertions(+), 98 deletions(-) create mode 100644 packages/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime.ts rename packages/{trpc/react/hooks => features/data-table/lib}/__tests__/preserveLocalTime.test.ts (98%) create mode 100644 packages/features/data-table/lib/preserveLocalTime.ts delete mode 100644 packages/trpc/react/hooks/useUserTimePreferences.ts delete mode 100644 packages/trpc/server/routers/viewer/me/getUserTimePreferences.handler.ts diff --git a/apps/web/app/(use-page-wrapper)/insights/page.tsx b/apps/web/app/(use-page-wrapper)/insights/page.tsx index f5180928dc..53f930568d 100644 --- a/apps/web/app/(use-page-wrapper)/insights/page.tsx +++ b/apps/web/app/(use-page-wrapper)/insights/page.tsx @@ -1,4 +1,10 @@ import { _generateMetadata } from "app/_utils"; +import { cookies, headers } from "next/headers"; + +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import prisma from "@calcom/prisma"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; import InsightsPage from "~/insights/insights-view"; @@ -11,6 +17,17 @@ export const generateMetadata = async () => "/insights" ); -export default async function Page() { - return ; -} +const ServerPage = async () => { + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); + + const { timeZone } = await prisma.user.findUniqueOrThrow({ + where: { id: session?.user.id ?? -1 }, + select: { + timeZone: true, + }, + }); + + return ; +}; + +export default ServerPage; diff --git a/apps/web/modules/insights/insights-view.tsx b/apps/web/modules/insights/insights-view.tsx index 1c6870906a..c2f4405fa2 100644 --- a/apps/web/modules/insights/insights-view.tsx +++ b/apps/web/modules/insights/insights-view.tsx @@ -28,9 +28,9 @@ import { useInsightsBookings } from "@calcom/features/insights/hooks/useInsights import { useInsightsOrgTeams } from "@calcom/features/insights/hooks/useInsightsOrgTeams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -export default function InsightsPage() { +export default function InsightsPage({ timeZone }: { timeZone: string }) { return ( - + diff --git a/packages/features/data-table/DataTableProvider.tsx b/packages/features/data-table/DataTableProvider.tsx index 34f1528c5f..ef4c0f3b06 100644 --- a/packages/features/data-table/DataTableProvider.tsx +++ b/packages/features/data-table/DataTableProvider.tsx @@ -64,6 +64,8 @@ export type DataTableContextType = { searchTerm: string; setSearchTerm: (searchTerm: string | null) => void; + + timeZone?: string; }; export const DataTableContext = createContext(null); @@ -75,6 +77,7 @@ interface DataTableProviderProps { ctaContainerClassName?: string; defaultPageSize?: number; segments?: FilterSegmentOutput[]; + timeZone?: string; preferredSegmentId?: number | null; } @@ -85,6 +88,7 @@ export function DataTableProvider({ defaultPageSize = DEFAULT_PAGE_SIZE, ctaContainerClassName = CTA_CONTAINER_CLASS_NAME, segments: providedSegments, + timeZone, preferredSegmentId, }: DataTableProviderProps) { const filterToOpen = useRef(undefined); @@ -245,6 +249,7 @@ export function DataTableProvider({ isSegmentEnabled, searchTerm, setSearchTerm: setDebouncedSearchTerm, + timeZone, }}> {children} diff --git a/packages/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime.ts b/packages/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime.ts new file mode 100644 index 0000000000..549e6192e7 --- /dev/null +++ b/packages/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime.ts @@ -0,0 +1,28 @@ +import { useMemo } from "react"; + +import dayjs from "@calcom/dayjs"; + +import { preserveLocalTime } from "../lib/preserveLocalTime"; +import { useDataTable } from "./useDataTable"; + +/** + * Converts a timestamp to maintain the same local time in a different timezone. + * + * For example, if it's midnight (00:00) in Paris time: + * - Input : "2025-05-22T22:00:00.000Z" (Midnight/00:00 in Paris) + * - Output: "2025-05-22T15:00:00.000Z" (Midnight/00:00 in Seoul) + * + * This ensures that times like midnight (00:00) or end of day (23:59) + * remain at those exact local times when converting between timezones. + * The output timestamp is based on the timezone in the user's profile settings. + */ +export function useChangeTimeZoneWithPreservedLocalTime(isoString: string) { + const { timeZone: profileTimeZone } = useDataTable(); + return useMemo(() => { + const currentTimeZone = dayjs.tz.guess(); + if (!profileTimeZone || currentTimeZone === profileTimeZone) { + return isoString; + } + return preserveLocalTime(isoString, currentTimeZone, profileTimeZone); + }, [isoString, profileTimeZone]); +} diff --git a/packages/trpc/react/hooks/__tests__/preserveLocalTime.test.ts b/packages/features/data-table/lib/__tests__/preserveLocalTime.test.ts similarity index 98% rename from packages/trpc/react/hooks/__tests__/preserveLocalTime.test.ts rename to packages/features/data-table/lib/__tests__/preserveLocalTime.test.ts index e9728396d8..a23273cd09 100644 --- a/packages/trpc/react/hooks/__tests__/preserveLocalTime.test.ts +++ b/packages/features/data-table/lib/__tests__/preserveLocalTime.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import dayjs from "@calcom/dayjs"; -import { preserveLocalTime } from "../useUserTimePreferences"; +import { preserveLocalTime } from "../preserveLocalTime"; describe("preserveLocalTime", () => { it("should preserve midnight (00:00) when converting from Paris to Seoul", () => { diff --git a/packages/features/data-table/lib/preserveLocalTime.ts b/packages/features/data-table/lib/preserveLocalTime.ts new file mode 100644 index 0000000000..783e24ac82 --- /dev/null +++ b/packages/features/data-table/lib/preserveLocalTime.ts @@ -0,0 +1,31 @@ +import dayjs from "@calcom/dayjs"; + +/** + * Converts a timestamp to maintain the same local time in a different timezone. + * + * For example, if it's midnight (00:00) in Paris time: + * - Input : "2025-05-22T22:00:00.000Z" (Midnight/00:00 in Paris) + * - Output: "2025-05-22T15:00:00.000Z" (Midnight/00:00 in Seoul) + * + * This ensures that times like midnight (00:00) or end of day (23:59) + * remain at those exact local times when converting between timezones. + * The output timestamp is based on the timezone in the user's profile settings. + */ +export const preserveLocalTime = (isoString: string, originalTimeZone: string, targetTimeZone: string) => { + // Parse the input time + const time = dayjs(isoString).tz(originalTimeZone); + // Get the wall clock time components + const hours = time.hour(); + const minutes = time.minute(); + const seconds = time.second(); + const milliseconds = time.millisecond(); + + // Create a new date in target timezone with same wall clock time + return dayjs + .tz(time.format("YYYY-MM-DD"), targetTimeZone) + .hour(hours) + .minute(minutes) + .second(seconds) + .millisecond(milliseconds) + .toISOString(); +}; diff --git a/packages/features/insights/hooks/useInsightsParameters.ts b/packages/features/insights/hooks/useInsightsParameters.ts index b287cfe7db..f82f168dfe 100644 --- a/packages/features/insights/hooks/useInsightsParameters.ts +++ b/packages/features/insights/hooks/useInsightsParameters.ts @@ -8,19 +8,18 @@ import { ZSingleSelectFilterValue, ZDateRangeFilterValue, } from "@calcom/features/data-table"; +import { useChangeTimeZoneWithPreservedLocalTime } from "@calcom/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime"; import { getDefaultStartDate, getDefaultEndDate, CUSTOM_PRESET_VALUE, type PresetOptionValue, } from "@calcom/features/data-table/lib/dateRange"; -import { useUserTimePreferences } from "@calcom/trpc/react/hooks/useUserTimePreferences"; import { useInsightsOrgTeams } from "./useInsightsOrgTeams"; export function useInsightsParameters() { const { isAll, teamId, userId } = useInsightsOrgTeams(); - const { preserveLocalTime } = useUserTimePreferences(); const memberUserIds = useFilterValue("bookingUserId", ZMultiSelectFilterValue)?.data as | number[] @@ -32,18 +31,20 @@ export function useInsightsParameters() { // TODO for future: this preserving local time & startOf & endOf should be handled // from DateRangeFilter out of the box. // When we do it, we also need to remove those timezone handling logic from the backend side at the same time. - const startDate = useMemo(() => { - const timestamp = dayjs(createdAtRange?.startDate ?? getDefaultStartDate().toISOString()) - .startOf("day") - .toISOString(); - return preserveLocalTime(timestamp); - }, [createdAtRange?.startDate, preserveLocalTime]); - const endDate = useMemo(() => { - const timestamp = dayjs(createdAtRange?.endDate ?? getDefaultEndDate().toISOString()) - .endOf("day") - .toISOString(); - return preserveLocalTime(timestamp); - }, [createdAtRange?.endDate, preserveLocalTime]); + const startDate = useChangeTimeZoneWithPreservedLocalTime( + useMemo(() => { + return dayjs(createdAtRange?.startDate ?? getDefaultStartDate().toISOString()) + .startOf("day") + .toISOString(); + }, [createdAtRange?.startDate]) + ); + const endDate = useChangeTimeZoneWithPreservedLocalTime( + useMemo(() => { + return dayjs(createdAtRange?.endDate ?? getDefaultEndDate().toISOString()) + .endOf("day") + .toISOString(); + }, [createdAtRange?.endDate]) + ); const dateRangePreset = useMemo(() => { return (createdAtRange?.preset as PresetOptionValue) ?? CUSTOM_PRESET_VALUE; diff --git a/packages/trpc/react/hooks/useUserTimePreferences.ts b/packages/trpc/react/hooks/useUserTimePreferences.ts deleted file mode 100644 index 80090b630d..0000000000 --- a/packages/trpc/react/hooks/useUserTimePreferences.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { useMemo, useCallback } from "react"; - -import dayjs from "@calcom/dayjs"; - -import { trpc } from "../trpc"; - -export const preserveLocalTime = (isoString: string, originalTimeZone: string, targetTimeZone: string) => { - // Parse the input time - const time = dayjs(isoString).tz(originalTimeZone); - // Get the wall clock time components - const hours = time.hour(); - const minutes = time.minute(); - const seconds = time.second(); - const milliseconds = time.millisecond(); - - // Create a new date in target timezone with same wall clock time - return dayjs - .tz(time.format("YYYY-MM-DD"), targetTimeZone) - .hour(hours) - .minute(minutes) - .second(seconds) - .millisecond(milliseconds) - .toISOString(); -}; - -export function useUserTimePreferences() { - const { data } = trpc.viewer.me.getUserTimePreferences.useQuery(undefined, { - staleTime: 5 * 60 * 1000, - }); - - const timeFormat = useMemo(() => data?.timeFormat ?? 12, [data?.timeFormat]); - const timeZone = useMemo(() => data?.timeZone ?? dayjs.tz.guess(), [data?.timeZone]); - - /** - * Converts a timestamp to maintain the same local time in a different timezone. - * - * For example, if it's midnight (00:00) in Paris time: - * - Input : "2025-05-22T22:00:00.000Z" (Midnight/00:00 in Paris) - * - Output: "2025-05-22T15:00:00.000Z" (Midnight/00:00 in Seoul) - * - * This ensures that times like midnight (00:00) or end of day (23:59) - * remain at those exact local times when converting between timezones. - * The output timestamp is based on the timezone in the user's profile settings. - */ - const _preserveLocalTime = useCallback( - (timestamp: string) => preserveLocalTime(timestamp, dayjs.tz.guess(), timeZone), - [timeZone] - ); - - return { - timeFormat, - timeZone, - preserveLocalTime: _preserveLocalTime, - }; -} diff --git a/packages/trpc/server/routers/viewer/me/_router.tsx b/packages/trpc/server/routers/viewer/me/_router.tsx index 0d0e2f6ebb..15e3cb9678 100644 --- a/packages/trpc/server/routers/viewer/me/_router.tsx +++ b/packages/trpc/server/routers/viewer/me/_router.tsx @@ -18,10 +18,6 @@ export const meRouter = router({ return handler({ ctx }); }), get, - getUserTimePreferences: authedProcedure.query(async ({ ctx }) => { - const handler = (await import("./getUserTimePreferences.handler")).getUserTimePreferencesHandler; - return handler({ ctx }); - }), getUserTopBanners: authedProcedure.query(async ({ ctx }) => { const handler = (await import("./getUserTopBanners.handler")).getUserTopBannersHandler; return handler({ ctx }); diff --git a/packages/trpc/server/routers/viewer/me/getUserTimePreferences.handler.ts b/packages/trpc/server/routers/viewer/me/getUserTimePreferences.handler.ts deleted file mode 100644 index 0dd4f0f8a3..0000000000 --- a/packages/trpc/server/routers/viewer/me/getUserTimePreferences.handler.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Session } from "next-auth"; - -import type { TrpcSessionUser } from "@calcom/trpc/server/types"; - -type GetUserTimePreferencesOptions = { - ctx: { - user: NonNullable; - session: Session; - }; -}; - -export const getUserTimePreferencesHandler = async ({ ctx }: GetUserTimePreferencesOptions) => { - const { user } = ctx; - - return { - timeFormat: user.timeFormat ?? 12, - timeZone: user.timeZone, - }; -};