From 324ac691eb436984b35faa704d2c12b9203867f4 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Thu, 22 May 2025 18:03:56 +0200 Subject: [PATCH] fix: use local date range on /insights (based on profile's timezone) (#21472) * fix: use local date range on /insights (based on profile's timezone) * update test case --- .../insights/hooks/useInsightsParameters.ts | 11 +-- .../hooks/__tests__/preserveLocalTime.test.ts | 67 +++++++++++++++++++ .../react/hooks/useUserTimePreferences.ts | 55 +++++++++++++++ .../trpc/server/routers/viewer/me/_router.tsx | 4 ++ .../me/getUserTimePreferences.handler.ts | 19 ++++++ 5 files changed, 152 insertions(+), 4 deletions(-) create mode 100644 packages/trpc/react/hooks/__tests__/preserveLocalTime.test.ts create mode 100644 packages/trpc/react/hooks/useUserTimePreferences.ts create mode 100644 packages/trpc/server/routers/viewer/me/getUserTimePreferences.handler.ts diff --git a/packages/features/insights/hooks/useInsightsParameters.ts b/packages/features/insights/hooks/useInsightsParameters.ts index 9a26bf2a73..eff54c0a75 100644 --- a/packages/features/insights/hooks/useInsightsParameters.ts +++ b/packages/features/insights/hooks/useInsightsParameters.ts @@ -13,11 +13,13 @@ import { 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[] @@ -27,13 +29,14 @@ export function useInsightsParameters() { const routingFormId = useFilterValue("formId", ZSingleSelectFilterValue)?.data as string | undefined; const createdAtRange = useFilterValue("createdAt", ZDateRangeFilterValue)?.data; const startDate = useMemo( - () => createdAtRange?.startDate ?? getDefaultStartDate().toISOString(), - [createdAtRange?.startDate] + () => preserveLocalTime(createdAtRange?.startDate ?? getDefaultStartDate().toISOString()), + [createdAtRange?.startDate, preserveLocalTime] ); const endDate = useMemo( - () => createdAtRange?.endDate ?? getDefaultEndDate().toISOString(), - [createdAtRange?.endDate] + () => preserveLocalTime(createdAtRange?.endDate ?? getDefaultEndDate().toISOString()), + [createdAtRange?.endDate, preserveLocalTime] ); + const dateRangePreset = useMemo(() => { return (createdAtRange?.preset as PresetOptionValue) ?? CUSTOM_PRESET_VALUE; }, [createdAtRange?.preset]); diff --git a/packages/trpc/react/hooks/__tests__/preserveLocalTime.test.ts b/packages/trpc/react/hooks/__tests__/preserveLocalTime.test.ts new file mode 100644 index 0000000000..e9728396d8 --- /dev/null +++ b/packages/trpc/react/hooks/__tests__/preserveLocalTime.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; + +import dayjs from "@calcom/dayjs"; + +import { preserveLocalTime } from "../useUserTimePreferences"; + +describe("preserveLocalTime", () => { + it("should preserve midnight (00:00) when converting from Paris to Seoul", () => { + // Midnight in Paris (UTC+1/+2) + const parisTime = "2024-03-15T23:00:00.000Z"; // This represents 00:00 next day in Paris + const result = preserveLocalTime(parisTime, "Europe/Paris", "Asia/Seoul"); + + const resultInSeoul = dayjs(result).tz("Asia/Seoul"); + expect(resultInSeoul.format("YYYY-MM-DD HH:mm")).toBe("2024-03-16 00:00"); + }); + + it("should preserve end of day (23:59) when converting between timezones", () => { + // 23:59 in New York + const nyTime = "2024-03-15T03:59:00.000Z"; // This represents 23:59 previous day in NY + const result = preserveLocalTime(nyTime, "America/New_York", "Asia/Tokyo"); + + const resultInTokyo = dayjs(result).tz("Asia/Tokyo"); + expect(resultInTokyo.format("HH:mm")).toBe("23:59"); + }); + + it("should handle DST transitions correctly", () => { + // Test during US DST transition (March 10, 2024 at 2:00 AM EST -> 3:00 AM EDT) + const beforeDST = "2024-03-10T06:59:00.000Z"; // 1:59 AM EST + const afterDST = "2024-03-10T07:00:00.000Z"; // 3:00 AM EDT (clock jumps forward, skipping 2:00-2:59 AM) + + const resultBeforeDST = preserveLocalTime(beforeDST, "America/New_York", "Asia/Dubai"); + const resultAfterDST = preserveLocalTime(afterDST, "America/New_York", "Asia/Dubai"); + + expect(dayjs(resultBeforeDST).tz("Asia/Dubai").format("YYYY-MM-DD HH:mm")).toBe("2024-03-10 01:59"); // still 1:59 AM + expect(dayjs(resultAfterDST).tz("Asia/Dubai").format("YYYY-MM-DD HH:mm")).toBe("2024-03-10 03:00"); // still 3:00 AM + + // Calculate the time difference between before and after DST + const diffInMinutes = dayjs(resultAfterDST).diff(dayjs(resultBeforeDST), "minute"); + expect(diffInMinutes).toBe(61); // Should be 61 minutes difference (skipping the DST hour) + }); + + it("should handle crossing the international date line", () => { + const laTime = "2024-03-15T04:00:00.000Z"; // March 14, 9 PM in LA + const result = preserveLocalTime(laTime, "America/Los_Angeles", "Pacific/Auckland"); + + const resultInAuckland = dayjs(result).tz("Pacific/Auckland"); + expect(resultInAuckland.format("YYYY-MM-DD HH:mm")).toBe("2024-03-14 21:00"); // Should still be 9 PM + }); + + it("should preserve milliseconds", () => { + const timeWithMs = "2024-03-15T12:30:45.123Z"; + const result = preserveLocalTime(timeWithMs, "UTC", "Asia/Shanghai"); + + const resultInShanghai = dayjs(result).tz("Asia/Shanghai"); + expect(resultInShanghai.millisecond()).toBe(123); + }); + + it("should handle leap years correctly", () => { + // February 29th in a leap year + const leapDay = "2024-02-29T15:30:00.000Z"; // 16:30 in Berlin + const result = preserveLocalTime(leapDay, "Europe/Berlin", "Asia/Tokyo"); + + const resultInTokyo = dayjs(result).tz("Asia/Tokyo"); + expect(resultInTokyo.format("YYYY-MM-DD")).toBe("2024-02-29"); + expect(resultInTokyo.format("HH:mm")).toBe("16:30"); // should still be 16:30 in Tokyo + }); +}); diff --git a/packages/trpc/react/hooks/useUserTimePreferences.ts b/packages/trpc/react/hooks/useUserTimePreferences.ts new file mode 100644 index 0000000000..80090b630d --- /dev/null +++ b/packages/trpc/react/hooks/useUserTimePreferences.ts @@ -0,0 +1,55 @@ +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 15e3cb9678..0d0e2f6ebb 100644 --- a/packages/trpc/server/routers/viewer/me/_router.tsx +++ b/packages/trpc/server/routers/viewer/me/_router.tsx @@ -18,6 +18,10 @@ 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 new file mode 100644 index 0000000000..0dd4f0f8a3 --- /dev/null +++ b/packages/trpc/server/routers/viewer/me/getUserTimePreferences.handler.ts @@ -0,0 +1,19 @@ +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, + }; +};