diff --git a/apps/web/core/components/home/user-greetings.tsx b/apps/web/core/components/home/user-greetings.tsx index 8d5d6cc1e86..687fcc80263 100644 --- a/apps/web/core/components/home/user-greetings.tsx +++ b/apps/web/core/components/home/user-greetings.tsx @@ -4,40 +4,49 @@ * See the LICENSE file for details. */ +import { observer } from "mobx-react"; // plane types import { useTranslation } from "@plane/i18n"; import type { IUser } from "@plane/types"; // plane ui // hooks import { useCurrentTime } from "@/hooks/use-current-time"; +import { useDisplayTimezone } from "@/hooks/use-display-timezone"; export interface IUserGreetingsView { user: IUser; } -export function UserGreetingsView(props: IUserGreetingsView) { +export const UserGreetingsView = observer(function UserGreetingsView(props: IUserGreetingsView) { const { user } = props; // current time hook const { currentTime } = useCurrentTime(); // store hooks const { t } = useTranslation(); + // resolved display timezone: user preference (UTC treated as unset) -> workspace timezone -> browser local + const timeZone = useDisplayTimezone(user?.user_timezone); + // all fields below must use the same timezone, otherwise the greeting and + // the displayed time can belong to different timezones const hour = new Intl.DateTimeFormat("en-US", { + timeZone, hour12: false, hour: "numeric", }).format(currentTime); const date = new Intl.DateTimeFormat("en-US", { + timeZone, month: "short", day: "numeric", }).format(currentTime); const weekDay = new Intl.DateTimeFormat("en-US", { + timeZone, weekday: "long", }).format(currentTime); const timeString = new Intl.DateTimeFormat("en-US", { - timeZone: user?.user_timezone, + timeZone, hour12: false, // Use 24-hour format hour: "2-digit", minute: "2-digit", @@ -58,4 +67,4 @@ export function UserGreetingsView(props: IUserGreetingsView) { ); -} +}); diff --git a/apps/web/core/components/profile/time.tsx b/apps/web/core/components/profile/time.tsx index 5265e2b2e3c..e62a972c0bd 100644 --- a/apps/web/core/components/profile/time.tsx +++ b/apps/web/core/components/profile/time.tsx @@ -4,21 +4,27 @@ * See the LICENSE file for details. */ +import { observer } from "mobx-react"; // hooks import { useCurrentTime } from "@/hooks/use-current-time"; +import { useDisplayTimezone } from "@/hooks/use-display-timezone"; type Props = { timeZone: string | undefined; }; -export function ProfileSidebarTime(props: Props) { +export const ProfileSidebarTime = observer(function ProfileSidebarTime(props: Props) { const { timeZone } = props; // current time hook const { currentTime } = useCurrentTime(); + // resolved display timezone: user preference (UTC treated as unset) -> workspace timezone -> browser local + const resolvedTimeZone = useDisplayTimezone(timeZone); + // when both are unset the browser's local timezone is used; resolve its name for display + const displayTimeZone = resolvedTimeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone; // Create a date object for the current time in the specified timezone const formatter = new Intl.DateTimeFormat("en-US", { - timeZone: timeZone, + timeZone: displayTimeZone, hour12: false, // Use 24-hour format hour: "2-digit", minute: "2-digit", @@ -27,7 +33,7 @@ export function ProfileSidebarTime(props: Props) { return ( - {timeString} {timeZone} + {timeString} {displayTimeZone} ); -} +}); diff --git a/apps/web/core/components/user/user-greetings.tsx b/apps/web/core/components/user/user-greetings.tsx index ff00a23ca93..3e813150f70 100644 --- a/apps/web/core/components/user/user-greetings.tsx +++ b/apps/web/core/components/user/user-greetings.tsx @@ -4,40 +4,49 @@ * See the LICENSE file for details. */ +import { observer } from "mobx-react"; // plane types import { useTranslation } from "@plane/i18n"; // hooks import type { IUser } from "@plane/types"; import { useCurrentTime } from "@/hooks/use-current-time"; +import { useDisplayTimezone } from "@/hooks/use-display-timezone"; // types export interface IUserGreetingsView { user: IUser; } -export function UserGreetingsView(props: IUserGreetingsView) { +export const UserGreetingsView = observer(function UserGreetingsView(props: IUserGreetingsView) { const { user } = props; // current time hook const { currentTime } = useCurrentTime(); // store hooks const { t } = useTranslation(); + // resolved display timezone: user preference (UTC treated as unset) -> workspace timezone -> browser local + const timeZone = useDisplayTimezone(user?.user_timezone); + // all fields below must use the same timezone, otherwise the greeting and + // the displayed time can belong to different timezones const hour = new Intl.DateTimeFormat("en-US", { + timeZone, hour12: false, hour: "numeric", }).format(currentTime); const date = new Intl.DateTimeFormat("en-US", { + timeZone, month: "short", day: "numeric", }).format(currentTime); const weekDay = new Intl.DateTimeFormat("en-US", { + timeZone, weekday: "long", }).format(currentTime); const timeString = new Intl.DateTimeFormat("en-US", { - timeZone: user?.user_timezone, + timeZone, hour12: false, // Use 24-hour format hour: "2-digit", minute: "2-digit", @@ -58,4 +67,4 @@ export function UserGreetingsView(props: IUserGreetingsView) { ); -} +}); diff --git a/apps/web/core/hooks/use-display-timezone.ts b/apps/web/core/hooks/use-display-timezone.ts new file mode 100644 index 00000000000..262607caa03 --- /dev/null +++ b/apps/web/core/hooks/use-display-timezone.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +// hooks +import { useWorkspace } from "@/hooks/store/use-workspace"; + +/** + * Resolve the timezone to use when displaying times in the UI. + * + * Resolution order: + * 1. The user's preferred timezone (`user_timezone`). `"UTC"` is treated as + * "not set": it is the database default for users who never picked a + * timezone, and we cannot distinguish that from a user who deliberately + * chose UTC. The workspace timezone is the better fallback for those users. + * 2. The current workspace's timezone, which the organization has explicitly + * declared in workspace settings. + * 3. `undefined`, which makes the Intl APIs fall back to the browser's local + * timezone. + */ +export const resolveDisplayTimezone = ( + userTimezone: string | undefined, + workspaceTimezone: string | undefined +): string | undefined => { + if (userTimezone && userTimezone !== "UTC") return userTimezone; + if (workspaceTimezone) return workspaceTimezone; + return undefined; +}; + +/** + * Hook variant of `resolveDisplayTimezone` that reads the current workspace + * from the store. + */ +export const useDisplayTimezone = (userTimezone: string | undefined): string | undefined => { + const { currentWorkspace } = useWorkspace(); + return resolveDisplayTimezone(userTimezone, currentWorkspace?.timezone); +};