From 01319489ff513151491e4711ab4c33959a8ba079 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:02:57 +0000 Subject: [PATCH 1/5] fix: normalize "original" locale to "en" for datetime formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When users select "Untranslated" mode, the locale is set to "original". This is not a valid BCP 47 locale tag, so browser Intl APIs fall back to the OS language, causing mixed-language strings like "Revealed el próximo mes". Add a normalizeIntlLocale() utility that maps "original" to "en" and apply it consistently across all components that pass locale to intlFormat, intlFormatDistance, or the element. Fixes #4122 Co-authored-by: leonardthethird --- .../components/consumer_post_card/upcoming_cp.tsx | 3 ++- .../components/cp_reveal_time/cp_reveal_time.tsx | 3 ++- front_end/src/components/ui/local_daytime.tsx | 11 ++--------- front_end/src/utils/formatters/date.ts | 15 ++++++++++++--- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/front_end/src/components/consumer_post_card/upcoming_cp.tsx b/front_end/src/components/consumer_post_card/upcoming_cp.tsx index 6d4e5d8b75..764a04304f 100644 --- a/front_end/src/components/consumer_post_card/upcoming_cp.tsx +++ b/front_end/src/components/consumer_post_card/upcoming_cp.tsx @@ -4,6 +4,7 @@ import { FC } from "react"; import RelativeTime from "@/components/ui/relative_time"; import cn from "@/utils/core/cn"; +import { normalizeIntlLocale } from "@/utils/formatters/date"; type Props = { cpRevealsOn: string; @@ -12,7 +13,7 @@ type Props = { const UpcomingCP: FC = ({ cpRevealsOn, className }) => { const t = useTranslations(); - const locale = useLocale(); + const locale = normalizeIntlLocale(useLocale()); return (
{t("cpRevealed")} diff --git a/front_end/src/components/cp_reveal_time/cp_reveal_time.tsx b/front_end/src/components/cp_reveal_time/cp_reveal_time.tsx index ff1a4d0c35..f142413af0 100644 --- a/front_end/src/components/cp_reveal_time/cp_reveal_time.tsx +++ b/front_end/src/components/cp_reveal_time/cp_reveal_time.tsx @@ -3,6 +3,7 @@ import { useLocale, useTranslations } from "next-intl"; import React, { FC } from "react"; import RelativeTime from "@/components/ui/relative_time"; +import { normalizeIntlLocale } from "@/utils/formatters/date"; type Props = { cpRevealTime: string; @@ -16,7 +17,7 @@ const CPRevealTime: FC = ({ className = "", }) => { const t = useTranslations(); - const locale = useLocale(); + const locale = normalizeIntlLocale(useLocale()); if (hiddenUntilView) { const revealDate = new Date(cpRevealTime); diff --git a/front_end/src/components/ui/local_daytime.tsx b/front_end/src/components/ui/local_daytime.tsx index 4c8df72b13..c6c81ae535 100644 --- a/front_end/src/components/ui/local_daytime.tsx +++ b/front_end/src/components/ui/local_daytime.tsx @@ -2,21 +2,14 @@ import { useLocale } from "next-intl"; import { FC } from "react"; import RelativeTime from "@/components/ui/relative_time"; -import { formatDate } from "@/utils/formatters/date"; +import { formatDate, normalizeIntlLocale } from "@/utils/formatters/date"; type Props = { date?: string; }; const LocalDaytime: FC = ({ date }) => { - let locale = useLocale(); - if (locale === "original") { - // For some reason, when the locale is "original" (Untranslated), the the server and client - // endup with different values for the localValue variable. This is a workaround to - // make sure the dates render correctly on both and default to English locale when - // in Untranslated mode - locale = "en"; - } + const locale = normalizeIntlLocale(useLocale()); const localValue = date ? formatDate(locale, new Date(date)) : ""; return ( diff --git a/front_end/src/utils/formatters/date.ts b/front_end/src/utils/formatters/date.ts index 46f7d09ce7..907c330458 100644 --- a/front_end/src/utils/formatters/date.ts +++ b/front_end/src/utils/formatters/date.ts @@ -92,7 +92,7 @@ export function formatDate(locale: string, date: Date) { return intlFormat( new Date(date), { year: "numeric", month: "short", day: "numeric" }, - { locale } + { locale: normalizeIntlLocale(locale) } ); } @@ -106,7 +106,7 @@ export function formatDatetime(locale: string, date: Date) { hour: "numeric", minute: "numeric", }, - { locale } + { locale: normalizeIntlLocale(locale) } ); } @@ -133,7 +133,7 @@ export function formatRelativeDate( let dateStr: string = ""; if (Math.abs(delta) < relCutoff) { dateStr = intlFormatDistance(date, now, { - locale, + locale: normalizeIntlLocale(locale), numeric: "always", style: short ? "short" : "long", }); @@ -189,6 +189,15 @@ export function formatDurationToShortStr(duration: Duration): string { return str; } +/** + * Normalizes the app locale for use with the browser's Intl API. + * The "original" locale (Untranslated mode) is not a valid BCP 47 tag, + * so it falls back to the OS language. This maps it to "en" instead. + */ +export function normalizeIntlLocale(locale: string): string { + return locale === "original" ? "en" : locale; +} + export const getDateFnsLocale = (locale: string) => { switch (locale) { case "es": From 6d0303cd6adea45e66320ac39cb89b87ad467be5 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:23:51 +0000 Subject: [PATCH 2/5] refactor: wrap direct date-fns Intl calls to centralize locale normalization Adds `formatIntlDate` / `formatIntlDistance` in @/utils/formatters/date so `normalizeIntlLocale` is applied inside the wrapper, and callers stop needing to normalize the "original" locale at every site. Also normalizes the `lang` prop inside RelativeTime, since the underlying web component uses it for Intl formatting and would otherwise hit the same OS-language-fallback bug. An ESLint `no-restricted-imports` rule now blocks direct imports of `intlFormat` / `intlFormatDistance` from date-fns so a future contributor gets a lint error instead of a subtle locale bug in Untranslated mode. Co-authored-by: Nikita <93587872+ncarazon@users.noreply.github.com> --- front_end/eslint.config.mjs | 19 ++++++++++ .../consumer_post_card/upcoming_cp.tsx | 9 ++--- .../cp_reveal_time/cp_reveal_time.tsx | 14 ++++---- front_end/src/components/ui/local_daytime.tsx | 4 +-- front_end/src/components/ui/relative_time.tsx | 7 ++-- front_end/src/utils/formatters/date.ts | 35 +++++++++++++++++++ 6 files changed, 71 insertions(+), 17 deletions(-) diff --git a/front_end/eslint.config.mjs b/front_end/eslint.config.mjs index 8452af75cc..e1888eed0c 100644 --- a/front_end/eslint.config.mjs +++ b/front_end/eslint.config.mjs @@ -64,6 +64,25 @@ const eslintConfig = [ }, }, ], + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "date-fns", + importNames: ["intlFormat", "intlFormatDistance"], + message: + "Use formatIntlDate / formatIntlDistance from @/utils/formatters/date so the 'original' locale (Untranslated mode) is normalized for Intl APIs.", + }, + ], + }, + ], + }, + }, + { + files: ["src/utils/formatters/date.ts"], + rules: { + "no-restricted-imports": "off", }, }, { diff --git a/front_end/src/components/consumer_post_card/upcoming_cp.tsx b/front_end/src/components/consumer_post_card/upcoming_cp.tsx index 764a04304f..2a98900f03 100644 --- a/front_end/src/components/consumer_post_card/upcoming_cp.tsx +++ b/front_end/src/components/consumer_post_card/upcoming_cp.tsx @@ -1,10 +1,9 @@ -import { intlFormatDistance } from "date-fns"; import { useLocale, useTranslations } from "next-intl"; import { FC } from "react"; import RelativeTime from "@/components/ui/relative_time"; import cn from "@/utils/core/cn"; -import { normalizeIntlLocale } from "@/utils/formatters/date"; +import { formatIntlDistance } from "@/utils/formatters/date"; type Props = { cpRevealsOn: string; @@ -13,14 +12,12 @@ type Props = { const UpcomingCP: FC = ({ cpRevealsOn, className }) => { const t = useTranslations(); - const locale = normalizeIntlLocale(useLocale()); + const locale = useLocale(); return (
{t("cpRevealed")} - {intlFormatDistance(cpRevealsOn, new Date(), { - locale, - })} + {formatIntlDistance(locale, cpRevealsOn, new Date())}
); diff --git a/front_end/src/components/cp_reveal_time/cp_reveal_time.tsx b/front_end/src/components/cp_reveal_time/cp_reveal_time.tsx index f142413af0..01e21d2b91 100644 --- a/front_end/src/components/cp_reveal_time/cp_reveal_time.tsx +++ b/front_end/src/components/cp_reveal_time/cp_reveal_time.tsx @@ -1,9 +1,9 @@ -import { differenceInHours, intlFormat, intlFormatDistance } from "date-fns"; +import { differenceInHours } from "date-fns"; import { useLocale, useTranslations } from "next-intl"; import React, { FC } from "react"; import RelativeTime from "@/components/ui/relative_time"; -import { normalizeIntlLocale } from "@/utils/formatters/date"; +import { formatIntlDate, formatIntlDistance } from "@/utils/formatters/date"; type Props = { cpRevealTime: string; @@ -17,7 +17,7 @@ const CPRevealTime: FC = ({ className = "", }) => { const t = useTranslations(); - const locale = normalizeIntlLocale(useLocale()); + const locale = useLocale(); if (hiddenUntilView) { const revealDate = new Date(cpRevealTime); @@ -28,7 +28,8 @@ const CPRevealTime: FC = ({ {t("hiddenUntil")}
- {intlFormat( + {formatIntlDate( + locale, revealDate, hoursUntilReveal < 24 ? { @@ -39,8 +40,7 @@ const CPRevealTime: FC = ({ year: "numeric", month: "long", day: "numeric", - }, - { locale } + } )}
); @@ -50,7 +50,7 @@ const CPRevealTime: FC = ({ {t("cpRevealed")}{" "} - {intlFormatDistance(cpRevealTime, new Date(), { locale })} + {formatIntlDistance(locale, cpRevealTime, new Date())} ); diff --git a/front_end/src/components/ui/local_daytime.tsx b/front_end/src/components/ui/local_daytime.tsx index c6c81ae535..7d10ba0905 100644 --- a/front_end/src/components/ui/local_daytime.tsx +++ b/front_end/src/components/ui/local_daytime.tsx @@ -2,14 +2,14 @@ import { useLocale } from "next-intl"; import { FC } from "react"; import RelativeTime from "@/components/ui/relative_time"; -import { formatDate, normalizeIntlLocale } from "@/utils/formatters/date"; +import { formatDate } from "@/utils/formatters/date"; type Props = { date?: string; }; const LocalDaytime: FC = ({ date }) => { - const locale = normalizeIntlLocale(useLocale()); + const locale = useLocale(); const localValue = date ? formatDate(locale, new Date(date)) : ""; return ( diff --git a/front_end/src/components/ui/relative_time.tsx b/front_end/src/components/ui/relative_time.tsx index 8ac3c8e4a6..ae0cce51ca 100644 --- a/front_end/src/components/ui/relative_time.tsx +++ b/front_end/src/components/ui/relative_time.tsx @@ -2,6 +2,8 @@ import type { RelativeTimeElement } from "@github/relative-time-element"; import { HTMLAttributes, ReactNode } from "react"; import "@github/relative-time-element"; +import { normalizeIntlLocale } from "@/utils/formatters/date"; + // Extend the native RelativeTimeElement properties for React interface RelativeTimeProps extends HTMLAttributes, @@ -9,10 +11,11 @@ interface RelativeTimeProps children?: ReactNode; } -const RelativeTime = ({ children, ...props }: RelativeTimeProps) => { +const RelativeTime = ({ children, lang, ...props }: RelativeTimeProps) => { + const normalizedLang = lang ? normalizeIntlLocale(lang) : lang; return ( // @ts-expect-error relative-time-element lacks TS compatibility with React 19, tracked here: https://github.com/github/relative-time-element/issues/304 - + {children} {/* @ts-expect-error relative-time-element lacks TS compatibility with React 19, tracked here: https://github.com/github/relative-time-element/issues/304 */} diff --git a/front_end/src/utils/formatters/date.ts b/front_end/src/utils/formatters/date.ts index 907c330458..846cd0a163 100644 --- a/front_end/src/utils/formatters/date.ts +++ b/front_end/src/utils/formatters/date.ts @@ -3,6 +3,7 @@ import { Duration, intlFormat, intlFormatDistance, + type IntlFormatDistanceOptions, } from "date-fns"; import { es, cs, pt, zhTW, zhCN, enUS } from "date-fns/locale"; @@ -198,6 +199,40 @@ export function normalizeIntlLocale(locale: string): string { return locale === "original" ? "en" : locale; } +/** + * Wrapper around date-fns `intlFormat` that normalizes the locale. + * Prefer this over calling `intlFormat` directly so Untranslated mode + * (`"original"` locale) is handled consistently. + */ +export function formatIntlDate( + locale: string, + date: Date | number | string, + formatOptions?: Intl.DateTimeFormatOptions +): string { + return intlFormat( + date, + formatOptions ?? {}, + { locale: normalizeIntlLocale(locale) } + ); +} + +/** + * Wrapper around date-fns `intlFormatDistance` that normalizes the locale. + * Prefer this over calling `intlFormatDistance` directly so Untranslated mode + * (`"original"` locale) is handled consistently. + */ +export function formatIntlDistance( + locale: string, + date: Date | number | string, + baseDate: Date | number | string, + options?: Omit +): string { + return intlFormatDistance(date, baseDate, { + ...options, + locale: normalizeIntlLocale(locale), + }); +} + export const getDateFnsLocale = (locale: string) => { switch (locale) { case "es": From 14b5210bbb3c63cd90014f96fbea87f76c0bb66b Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:00:57 +0000 Subject: [PATCH 3/5] fix: restore explicit "original" -> "en" fallback in LocalDaytime Restore the pre-existing workaround (and its comment) that defaults the locale to "en" when it is "original", to avoid the server/client hydration mismatch on `localValue`. The centralized `normalizeIntlLocale` covers Intl call sites, but this component-level guard is what keeps SSR and CSR producing the same string here. Co-authored-by: Nikita <93587872+ncarazon@users.noreply.github.com> --- front_end/src/components/ui/local_daytime.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/front_end/src/components/ui/local_daytime.tsx b/front_end/src/components/ui/local_daytime.tsx index 7d10ba0905..4c8df72b13 100644 --- a/front_end/src/components/ui/local_daytime.tsx +++ b/front_end/src/components/ui/local_daytime.tsx @@ -9,7 +9,14 @@ type Props = { }; const LocalDaytime: FC = ({ date }) => { - const locale = useLocale(); + let locale = useLocale(); + if (locale === "original") { + // For some reason, when the locale is "original" (Untranslated), the the server and client + // endup with different values for the localValue variable. This is a workaround to + // make sure the dates render correctly on both and default to English locale when + // in Untranslated mode + locale = "en"; + } const localValue = date ? formatDate(locale, new Date(date)) : ""; return ( From 8781c8fa2b191a2aa55e2503e9ce51836b4fae78 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:56:36 +0000 Subject: [PATCH 4/5] style: apply prettier formatting to formatIntlDate Co-authored-by: Nikita <93587872+ncarazon@users.noreply.github.com> --- front_end/src/utils/formatters/date.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/front_end/src/utils/formatters/date.ts b/front_end/src/utils/formatters/date.ts index 846cd0a163..61e4a629fb 100644 --- a/front_end/src/utils/formatters/date.ts +++ b/front_end/src/utils/formatters/date.ts @@ -209,11 +209,9 @@ export function formatIntlDate( date: Date | number | string, formatOptions?: Intl.DateTimeFormatOptions ): string { - return intlFormat( - date, - formatOptions ?? {}, - { locale: normalizeIntlLocale(locale) } - ); + return intlFormat(date, formatOptions ?? {}, { + locale: normalizeIntlLocale(locale), + }); } /** From 968efc26f522d00a49acaff51aac293f6d62ea2d Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:01:32 +0000 Subject: [PATCH 5/5] style: prettier formatting for formatIntlDate Hug the last object argument in the intlFormat call to satisfy prettier's print-width rule. Co-authored-by: Nikita <93587872+ncarazon@users.noreply.github.com>