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
This commit is contained in:
@@ -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<PresetOptionValue>(() => {
|
||||
return (createdAtRange?.preset as PresetOptionValue) ?? CUSTOM_PRESET_VALUE;
|
||||
}, [createdAtRange?.preset]);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Session } from "next-auth";
|
||||
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
type GetUserTimePreferencesOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
session: Session;
|
||||
};
|
||||
};
|
||||
|
||||
export const getUserTimePreferencesHandler = async ({ ctx }: GetUserTimePreferencesOptions) => {
|
||||
const { user } = ctx;
|
||||
|
||||
return {
|
||||
timeFormat: user.timeFormat ?? 12,
|
||||
timeZone: user.timeZone,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user