From 2cac7d6fce10f093b4ca1d02203962271f368c17 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Thu, 28 May 2026 15:38:34 +0100 Subject: [PATCH] date localization --- .../public/locales/en-US/translation.json | 1 + .../ee/api-key/components/api-key-table.tsx | 5 +- .../components/create-api-key-modal.tsx | 4 +- .../ee/billing/components/billing-details.tsx | 10 ++- .../ee/licence/components/license-details.tsx | 11 +++- .../components/expiration-fields.tsx | 5 +- .../components/manage-verification-form.tsx | 14 +++-- .../components/page-verification-modal.tsx | 3 +- .../components/verification-list-table.tsx | 14 +++-- .../ee/scim/components/scim-token-table.tsx | 5 +- .../groups/more-inserts-group.tsx | 4 +- .../components/slash-menu/menu-items.ts | 3 +- .../features/label/utils/format-label-date.ts | 22 +++++-- .../notification/notification.utils.ts | 7 ++- .../features/share/components/share-list.tsx | 10 ++- apps/client/src/lib/date-locale.ts | 62 +++++++++++++++++++ apps/client/src/lib/time.ts | 20 ++++-- 17 files changed, 158 insertions(+), 42 deletions(-) create mode 100644 apps/client/src/lib/date-locale.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index ec40a1967..278021657 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -424,6 +424,7 @@ "Names do not match": "Names do not match", "Today, {{time}}": "Today, {{time}}", "Yesterday, {{time}}": "Yesterday, {{time}}", + "now": "now", "Space created successfully": "Space created successfully", "Space updated successfully": "Space updated successfully", "Space deleted successfully": "Space deleted successfully", diff --git a/apps/client/src/ee/api-key/components/api-key-table.tsx b/apps/client/src/ee/api-key/components/api-key-table.tsx index f17b3d8d1..efb774484 100644 --- a/apps/client/src/ee/api-key/components/api-key-table.tsx +++ b/apps/client/src/ee/api-key/components/api-key-table.tsx @@ -1,11 +1,11 @@ import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core"; import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react"; -import { format } from "date-fns"; import { useTranslation } from "react-i18next"; import { IApiKey } from "@/ee/api-key"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import React from "react"; import NoTableResults from "@/components/common/no-table-results"; +import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts"; interface ApiKeyTableProps { apiKeys: IApiKey[]; @@ -23,10 +23,11 @@ export function ApiKeyTable({ onRevoke, }: ApiKeyTableProps) { const { t } = useTranslation(); + const locale = useDateFnsLocale(); const formatDate = (date: Date | string | null) => { if (!date) return t("Never"); - return format(new Date(date), "MMM dd, yyyy"); + return formatLocalized(date, "MMM dd, yyyy", "PP", locale); }; const isExpired = (expiresAt: string | null) => { diff --git a/apps/client/src/ee/api-key/components/create-api-key-modal.tsx b/apps/client/src/ee/api-key/components/create-api-key-modal.tsx index 0f639bf45..53341a614 100644 --- a/apps/client/src/ee/api-key/components/create-api-key-modal.tsx +++ b/apps/client/src/ee/api-key/components/create-api-key-modal.tsx @@ -31,7 +31,7 @@ export function CreateApiKeyModal({ onClose, onSuccess, }: CreateApiKeyModalProps) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [expirationOption, setExpirationOption] = useState("30"); const createApiKeyMutation = useCreateApiKeyMutation(); @@ -59,7 +59,7 @@ export function CreateApiKeyModal({ const getExpirationLabel = (days: number) => { const date = new Date(); date.setDate(date.getDate() + days); - const formatted = date.toLocaleDateString("en-US", { + const formatted = date.toLocaleDateString(i18n.language, { month: "short", day: "2-digit", year: "numeric", diff --git a/apps/client/src/ee/billing/components/billing-details.tsx b/apps/client/src/ee/billing/components/billing-details.tsx index 0fb061471..e4a5e5f82 100644 --- a/apps/client/src/ee/billing/components/billing-details.tsx +++ b/apps/client/src/ee/billing/components/billing-details.tsx @@ -4,12 +4,13 @@ import { } from "@/ee/billing/queries/billing-query.ts"; import { Group, Text, SimpleGrid, Paper } from "@mantine/core"; import classes from "./billing.module.css"; -import { format } from "date-fns"; import { formatInterval } from "@/ee/billing/utils.ts"; +import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts"; export default function BillingDetails() { const { data: billing } = useBillingQuery(); const { data: plans } = useBillingPlans(); + const locale = useDateFnsLocale(); if (!billing || !plans) { return null; @@ -75,7 +76,12 @@ export default function BillingDetails() { : "Renewal date"} - {format(billing.periodEndAt, "dd MMM, yyyy")} + {formatLocalized( + billing.periodEndAt, + "dd MMM, yyyy", + "PP", + locale, + )} diff --git a/apps/client/src/ee/licence/components/license-details.tsx b/apps/client/src/ee/licence/components/license-details.tsx index d3a936329..0a805de91 100644 --- a/apps/client/src/ee/licence/components/license-details.tsx +++ b/apps/client/src/ee/licence/components/license-details.tsx @@ -1,13 +1,14 @@ import { Badge, Table } from "@mantine/core"; -import { format } from "date-fns"; import { useLicenseInfo } from "@/ee/licence/queries/license-query.ts"; import { isLicenseExpired } from "@/ee/licence/license.utils.ts"; import { useAtom } from "jotai"; import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts"; +import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts"; export default function LicenseDetails() { const { data: license, isError } = useLicenseInfo(); const [workspace] = useAtom(workspaceAtom); + const locale = useDateFnsLocale(); if (!license) { return null; @@ -50,12 +51,16 @@ export default function LicenseDetails() { Issued at - {format(license.issuedAt, "dd MMMM, yyyy")} + + {formatLocalized(license.issuedAt, "dd MMMM, yyyy", "PPP", locale)} + Expires at - {format(license.expiresAt, "dd MMMM, yyyy")} + + {formatLocalized(license.expiresAt, "dd MMMM, yyyy", "PPP", locale)} + License ID diff --git a/apps/client/src/ee/page-verification/components/expiration-fields.tsx b/apps/client/src/ee/page-verification/components/expiration-fields.tsx index 9dc7a96fe..ad2102f9f 100644 --- a/apps/client/src/ee/page-verification/components/expiration-fields.tsx +++ b/apps/client/src/ee/page-verification/components/expiration-fields.tsx @@ -1,6 +1,7 @@ import { Group, NumberInput, Select, Text } from "@mantine/core"; import { DateInput } from "@mantine/dates"; import { useTranslation } from "react-i18next"; +import i18n from "@/i18n.ts"; import { ExpirationMode, PeriodUnit, @@ -30,7 +31,7 @@ export function addDays(days: number, from?: Date): Date { function formatShortDate(date: Date): string { const crossesYear = date.getFullYear() !== new Date().getFullYear(); - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString(i18n.language, { month: "short", day: "numeric", ...(crossesYear && { year: "numeric" }), @@ -38,7 +39,7 @@ function formatShortDate(date: Date): string { } function formatLongDate(date: Date): string { - return date.toLocaleDateString(undefined, { + return date.toLocaleDateString(i18n.language, { month: "long", day: "numeric", year: "numeric", diff --git a/apps/client/src/ee/page-verification/components/manage-verification-form.tsx b/apps/client/src/ee/page-verification/components/manage-verification-form.tsx index f2fda1987..9d5214f7b 100644 --- a/apps/client/src/ee/page-verification/components/manage-verification-form.tsx +++ b/apps/client/src/ee/page-verification/components/manage-verification-form.tsx @@ -12,6 +12,7 @@ import { } from "@mantine/core"; import { modals } from "@mantine/modals"; import { useTranslation } from "react-i18next"; +import i18n from "@/i18n.ts"; import { useMarkObsoleteMutation, usePageVerificationInfoQuery, @@ -197,11 +198,14 @@ function ExpiringManageContent({ pageId, info, onClose }: ManageContentProps) { {info.expiresAt && ( {t(status === "expired" ? "Expired {{date}}" : "Expires {{date}}", { - date: new Date(info.expiresAt).toLocaleDateString(undefined, { - month: "long", - day: "numeric", - year: "numeric", - }), + date: new Date(info.expiresAt).toLocaleDateString( + i18n.language, + { + month: "long", + day: "numeric", + year: "numeric", + }, + ), })} )} diff --git a/apps/client/src/ee/page-verification/components/page-verification-modal.tsx b/apps/client/src/ee/page-verification/components/page-verification-modal.tsx index b8db0d8ce..a27d3a295 100644 --- a/apps/client/src/ee/page-verification/components/page-verification-modal.tsx +++ b/apps/client/src/ee/page-verification/components/page-verification-modal.tsx @@ -13,6 +13,7 @@ import { IconShieldCheck, } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; +import i18n from "@/i18n.ts"; import { useParams } from "react-router-dom"; import { extractPageSlugId } from "@/lib"; import { usePageQuery } from "@/features/page/queries/page-query"; @@ -127,7 +128,7 @@ export function PageVerificationBadge({ status === "verified" && verificationInfo?.expiresAt ? t("Verified until {{date}}", { date: new Date(verificationInfo.expiresAt).toLocaleDateString( - undefined, + i18n.language, { month: "long", day: "numeric", year: "numeric" }, ), }) diff --git a/apps/client/src/ee/page-verification/components/verification-list-table.tsx b/apps/client/src/ee/page-verification/components/verification-list-table.tsx index 675e05c98..832d7a4f7 100644 --- a/apps/client/src/ee/page-verification/components/verification-list-table.tsx +++ b/apps/client/src/ee/page-verification/components/verification-list-table.tsx @@ -16,9 +16,10 @@ import { } from "@/ee/page-verification/types/page-verification.types"; import { CustomAvatar } from "@/components/ui/custom-avatar"; import { buildPageUrl } from "@/features/page/page.utils"; -import { format } from "date-fns"; import NoTableResults from "@/components/common/no-table-results"; import rowClasses from "@/components/ui/clickable-table-row.module.css"; +import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts"; +import type { Locale } from "date-fns"; const MAX_VISIBLE_VERIFIERS = 5; @@ -48,7 +49,11 @@ function statusBadge(status: VerificationStatus | null, t: (s: string) => string } } -function verifiedUntilText(item: IVerificationListItem, t: (s: string) => string): string { +function verifiedUntilText( + item: IVerificationListItem, + t: (s: string) => string, + locale: Locale, +): string { if (item.type === "qms") { if (item.status === "approved") return t("Indefinitely"); return "—"; @@ -60,7 +65,7 @@ function verifiedUntilText(item: IVerificationListItem, t: (s: string) => string const now = new Date(); if (expires <= now) return t("Expired"); - return format(expires, "MMM d, yyyy"); + return formatLocalized(expires, "MMM d, yyyy", "PP", locale); } function TableSkeleton() { @@ -98,6 +103,7 @@ export default function VerificationListTable({ isLoading, }: VerificationListTableProps) { const { t } = useTranslation(); + const locale = useDateFnsLocale(); return ( @@ -200,7 +206,7 @@ export default function VerificationListTable({ - {verifiedUntilText(item, t)} + {verifiedUntilText(item, t, locale)} diff --git a/apps/client/src/ee/scim/components/scim-token-table.tsx b/apps/client/src/ee/scim/components/scim-token-table.tsx index eb36f4096..90572be3a 100644 --- a/apps/client/src/ee/scim/components/scim-token-table.tsx +++ b/apps/client/src/ee/scim/components/scim-token-table.tsx @@ -1,11 +1,11 @@ import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core"; import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react"; -import { format } from "date-fns"; import { useTranslation } from "react-i18next"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import React from "react"; import NoTableResults from "@/components/common/no-table-results"; import { IScimToken } from "@/ee/scim/types/scim-token.types"; +import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts"; interface ScimTokenTableProps { tokens: IScimToken[]; @@ -21,10 +21,11 @@ export function ScimTokenTable({ onRevoke, }: ScimTokenTableProps) { const { t } = useTranslation(); + const locale = useDateFnsLocale(); const formatDate = (date: Date | string | null) => { if (!date) return t("Never"); - return format(new Date(date), "MMM dd, yyyy"); + return formatLocalized(date, "MMM dd, yyyy", "PP", locale); }; return ( diff --git a/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx b/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx index ce23b7b4f..0b762be1c 100644 --- a/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx +++ b/apps/client/src/features/editor/components/fixed-toolbar/groups/more-inserts-group.tsx @@ -36,13 +36,13 @@ interface Props { } export const MoreInsertsGroup: FC = ({ editor, templateMode }) => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const setEmbed = (provider: string) => editor.chain().focus().setEmbed({ provider }).run(); const insertDate = () => { - const currentDate = new Date().toLocaleDateString("en-US", { + const currentDate = new Date().toLocaleDateString(i18n.language, { year: "numeric", month: "long", day: "numeric", diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts index cddddc35f..7f8567558 100644 --- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts +++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts @@ -43,6 +43,7 @@ import IconMermaid from "@/components/icons/icon-mermaid"; import IconDrawio from "@/components/icons/icon-drawio"; import { IconColumns4 } from "@/components/icons/icon-columns-4"; import { IconColumns5 } from "@/components/icons/icon-columns-5"; +import i18n from "@/i18n.ts"; import { AirtableIcon, FigmaIcon, @@ -459,7 +460,7 @@ const CommandGroups: SlashMenuGroupedItemsType = { searchTerms: ["date", "today"], icon: IconCalendar, command: ({ editor, range }: CommandProps) => { - const currentDate = new Date().toLocaleDateString("en-US", { + const currentDate = new Date().toLocaleDateString(i18n.language, { year: "numeric", month: "long", day: "numeric", diff --git a/apps/client/src/features/label/utils/format-label-date.ts b/apps/client/src/features/label/utils/format-label-date.ts index 1221c8ad8..af26fac91 100644 --- a/apps/client/src/features/label/utils/format-label-date.ts +++ b/apps/client/src/features/label/utils/format-label-date.ts @@ -1,15 +1,27 @@ -import { format, isThisYear, isToday, isYesterday } from "date-fns"; +import { isThisYear, isToday, isYesterday } from "date-fns"; import i18n from "@/i18n.ts"; +import { formatLocalized, getDateFnsLocale } from "@/lib/date-locale.ts"; export function formatLabelListDate(date: Date): string { + const locale = getDateFnsLocale(); if (isToday(date)) { - return i18n.t("Today, {{time}}", { time: format(date, "h:mma") }); + return i18n.t("Today, {{time}}", { + time: formatLocalized(date, "h:mma", "p", locale), + }); } if (isYesterday(date)) { - return i18n.t("Yesterday, {{time}}", { time: format(date, "h:mma") }); + return i18n.t("Yesterday, {{time}}", { + time: formatLocalized(date, "h:mma", "p", locale), + }); } if (isThisYear(date)) { - return format(date, "MMM dd"); + if (locale.code?.startsWith("en")) { + return formatLocalized(date, "MMM dd", "MMM dd", locale); + } + return new Intl.DateTimeFormat(i18n.language, { + month: "short", + day: "numeric", + }).format(date); } - return format(date, "MMM dd, yyyy"); + return formatLocalized(date, "MMM dd, yyyy", "PP", locale); } diff --git a/apps/client/src/features/notification/notification.utils.ts b/apps/client/src/features/notification/notification.utils.ts index 266bfc278..83b1b2891 100644 --- a/apps/client/src/features/notification/notification.utils.ts +++ b/apps/client/src/features/notification/notification.utils.ts @@ -1,3 +1,4 @@ +import i18n from "@/i18n.ts"; import { INotification } from "./types/notification.types"; export function formatRelativeTime(dateStr: string): string { @@ -8,15 +9,15 @@ export function formatRelativeTime(dateStr: string): string { const diffHours = Math.floor(diffMs / 3_600_000); const diffDays = Math.floor(diffMs / 86_400_000); - if (diffMin < 1) return "now"; + if (diffMin < 1) return i18n.t("now"); if (diffMin < 60) return `${diffMin}m`; if (diffHours < 24) return `${diffHours}h`; if (diffDays < 7) return `${diffDays}d`; - return date.toLocaleDateString(undefined, { + return new Intl.DateTimeFormat(i18n.language, { month: "short", day: "numeric", - }); + }).format(date); } type TimeGroup = "today" | "yesterday" | "this_week" | "older"; diff --git a/apps/client/src/features/share/components/share-list.tsx b/apps/client/src/features/share/components/share-list.tsx index 37ea4abf6..aab214cdd 100644 --- a/apps/client/src/features/share/components/share-list.tsx +++ b/apps/client/src/features/share/components/share-list.tsx @@ -7,8 +7,8 @@ import Paginate from "@/components/common/paginate.tsx"; import { useCursorPaginate } from "@/hooks/use-cursor-paginate"; import { useGetSharesQuery } from "@/features/share/queries/share-query.ts"; import { ISharedItem } from "@/features/share/types/share.types.ts"; -import { format } from "date-fns"; import ShareActionMenu from "@/features/share/components/share-action-menu.tsx"; +import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale.ts"; import { buildSharedPageUrl } from "@/features/page/page.utils.ts"; import { getPageIcon } from "@/lib"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; @@ -20,6 +20,7 @@ export default function ShareList() { const { t } = useTranslation(); const { cursor, goNext, goPrev } = useCursorPaginate(); const { data, isLoading } = useGetSharesQuery({ cursor }); + const locale = useDateFnsLocale(); if (!isLoading && data?.items.length === 0) { return ; @@ -81,7 +82,12 @@ export default function ShareList() { - {format(new Date(share.createdAt), "MMM dd, yyyy")} + {formatLocalized( + share.createdAt, + "MMM dd, yyyy", + "PP", + locale, + )} diff --git a/apps/client/src/lib/date-locale.ts b/apps/client/src/lib/date-locale.ts new file mode 100644 index 000000000..7d683978b --- /dev/null +++ b/apps/client/src/lib/date-locale.ts @@ -0,0 +1,62 @@ +import { format as dateFnsFormat, type Locale } from "date-fns"; +import { + de, + enUS, + es, + fr, + it, + ja, + ko, + nl, + ptBR, + ru, + uk, + zhCN, +} from "date-fns/locale"; +import { useTranslation } from "react-i18next"; +import i18n from "@/i18n.ts"; + +const LOCALE_MAP: Record = { + "de-DE": de, + "en-US": enUS, + "es-ES": es, + "fr-FR": fr, + "it-IT": it, + "ja-JP": ja, + "ko-KR": ko, + "nl-NL": nl, + "pt-BR": ptBR, + "ru-RU": ru, + "uk-UA": uk, + "zh-CN": zhCN, +}; + +export function getDateFnsLocale(language?: string): Locale { + const lang = language ?? i18n.language ?? "en-US"; + return LOCALE_MAP[lang] ?? LOCALE_MAP[lang.split("-")[0]] ?? enUS; +} + +export function useDateFnsLocale(): Locale { + const { i18n: instance } = useTranslation(); + return getDateFnsLocale(instance.language); +} + +function isEnglishLocale(locale: Locale): boolean { + return locale.code === "en-US" || locale.code?.startsWith("en") === true; +} + +/** + * Picks `enUSPattern` for the English locale and `localizedPattern` for every + * other locale. Keeps existing en-US output byte-identical while letting other + * languages use date-fns localized format tokens (P, PP, p, PPp, …). + */ +export function formatLocalized( + date: Date | number | string, + enUSPattern: string, + localizedPattern: string, + locale?: Locale, +): string { + const effective = locale ?? getDateFnsLocale(); + const pattern = isEnglishLocale(effective) ? enUSPattern : localizedPattern; + return dateFnsFormat(new Date(date), pattern, { locale: effective }); +} diff --git a/apps/client/src/lib/time.ts b/apps/client/src/lib/time.ts index 0e320c1fa..a6056dd32 100644 --- a/apps/client/src/lib/time.ts +++ b/apps/client/src/lib/time.ts @@ -1,17 +1,25 @@ -import { formatDistanceStrict } from "date-fns"; -import { format, isToday, isYesterday } from "date-fns"; +import { formatDistanceStrict, isToday, isYesterday } from "date-fns"; import i18n from "@/i18n.ts"; +import { formatLocalized, getDateFnsLocale } from "@/lib/date-locale.ts"; export function timeAgo(date: Date) { - return formatDistanceStrict(new Date(date), new Date(), { addSuffix: true }); + return formatDistanceStrict(new Date(date), new Date(), { + addSuffix: true, + locale: getDateFnsLocale(), + }); } export function formattedDate(date: Date) { + const locale = getDateFnsLocale(); if (isToday(date)) { - return i18n.t("Today, {{time}}", { time: format(date, "h:mma") }); + return i18n.t("Today, {{time}}", { + time: formatLocalized(date, "h:mma", "p", locale), + }); } else if (isYesterday(date)) { - return i18n.t("Yesterday, {{time}}", { time: format(date, "h:mma") }); + return i18n.t("Yesterday, {{time}}", { + time: formatLocalized(date, "h:mma", "p", locale), + }); } else { - return format(date, "MMM dd, yyyy, h:mma"); + return formatLocalized(date, "MMM dd, yyyy, h:mma", "PPp", locale); } }