From 01338eeb718181659f16b033dd57a474105d7e7a Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Sat, 12 Sep 2026 12:03:41 +0530 Subject: [PATCH 1/4] Fix time display format by using localized format --- config/eslint/eslint.config.mjs | 2 +- ...equire-locale-for-localized-date-format.js | 21 ++-- src/CONST/index.ts | 8 +- src/components/AutoUpdateTime.tsx | 4 +- src/components/LocaleContextProvider.tsx | 2 +- .../sections/PerDiemFields.tsx | 4 +- .../OnboardingHelpDropdownButton.tsx | 5 +- .../ChronosOOOListActions.tsx | 2 +- src/components/TimeModalPicker.tsx | 6 +- src/languages/IntlStore.ts | 4 +- src/libs/DateUtils.ts | 111 ++++++------------ src/libs/PerDiemRequestUtils.ts | 10 +- .../ScheduleCallConfirmationPage.tsx | 5 +- src/pages/ScheduleCall/ScheduleCallPage.tsx | 4 +- src/pages/Travel/CarTripDetails.tsx | 6 +- src/pages/Travel/FlightTripDetails.tsx | 4 +- src/pages/Travel/HotelTripDetails.tsx | 6 +- src/pages/Travel/TrainTripDetails.tsx | 4 +- .../inbox/report/ParticipantLocalTime.tsx | 17 +-- .../CustomStatus/StatusClearAfterPage.tsx | 2 +- tests/unit/DateUtilsTest.ts | 75 +++--------- 21 files changed, 111 insertions(+), 191 deletions(-) diff --git a/config/eslint/eslint.config.mjs b/config/eslint/eslint.config.mjs index bcb7ee6d37d7..e4336a23cf76 100644 --- a/config/eslint/eslint.config.mjs +++ b/config/eslint/eslint.config.mjs @@ -119,7 +119,7 @@ const restrictedImportPaths = [ }, { name: 'date-fns/locale', - message: "Do not import 'date-fns/locale' directly. Please use the submodule import instead, like 'date-fns/locale/en-GB'.", + message: "Do not import 'date-fns/locale' directly. Please use the submodule import instead, like 'date-fns/locale/en-US'.", }, { name: 'expensify-common', diff --git a/eslint-plugin-local-rules/require-locale-for-localized-date-format.js b/eslint-plugin-local-rules/require-locale-for-localized-date-format.js index 6313f1cfa7f5..23deccaac0f1 100644 --- a/eslint-plugin-local-rules/require-locale-for-localized-date-format.js +++ b/eslint-plugin-local-rules/require-locale-for-localized-date-format.js @@ -33,6 +33,16 @@ const LOCALIZED_TOKENS = [ {token: 'eeee', label: 'eeee (weekday name)'}, {token: 'eee', label: 'eee (short weekday)'}, {token: 'do', label: 'do (ordinal day)'}, + // date-fns' localized date/time formats. These exist precisely to defer the clock convention, ordering and + // separators to the locale, so they are meaningless without one. + {token: 'PPPP', label: 'PPPP (localized long date with weekday)'}, + {token: 'PPP', label: 'PPP (localized long date)'}, + {token: 'PP', label: 'PP (localized medium date)'}, + {token: 'P', label: 'P (localized short date)'}, + {token: 'pppp', label: 'pppp (localized full time)'}, + {token: 'ppp', label: 'ppp (localized long time)'}, + {token: 'pp', label: 'pp (localized medium time)'}, + {token: 'p', label: 'p (localized short time)'}, {token: 'aaaa', label: 'aaaa (AM/PM)'}, {token: 'aaa', label: 'aaa (AM/PM)'}, {token: 'aa', label: 'aa (AM/PM)'}, @@ -55,16 +65,7 @@ const DATE_FNS_MODULES = new Set(['date-fns', 'date-fns-tz']); * `CONST.DATE.*` formats with no language-dependent tokens. Anything else in `CONST.DATE` is treated as localized, so a * newly added format is guarded by default rather than silently escaping this rule. */ -const MACHINE_DATE_CONSTANTS = new Set([ - 'FNS_FORMAT_STRING', - 'FNS_DATE_TIME_FORMAT_STRING', - 'FNS_DB_FORMAT_STRING', - 'FNS_TIMEZONE_FORMAT_STRING', - 'YEAR_MONTH_FORMAT', - 'SHORT_DATE_FORMAT', - 'LOCAL_TIME_FORMAT_WITHOUT_PERIOD', - 'TIME_FORMAT_WITHOUT_PERIOD', -]); +const MACHINE_DATE_CONSTANTS = new Set(['FNS_FORMAT_STRING', 'FNS_DATE_TIME_FORMAT_STRING', 'FNS_DB_FORMAT_STRING', 'FNS_TIMEZONE_FORMAT_STRING', 'YEAR_MONTH_FORMAT', 'SHORT_DATE_FORMAT']); /** * Strips the single-quoted escaped literals date-fns supports (e.g. the "T" in `yyyy-MM-dd'T'HH:mm`) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 761ff41ee257..90b33eb4f698 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -692,9 +692,11 @@ const CONST = { DATE: { FNS_FORMAT_STRING: 'yyyy-MM-dd', FNS_DATE_TIME_FORMAT_STRING: 'yyyy-MM-dd HH:mm:ss', - LOCAL_TIME_FORMAT: 'h:mm a', - LOCAL_TIME_FORMAT_WITHOUT_PERIOD: 'h:mm', - TIME_FORMAT_WITHOUT_PERIOD: 'hh:mm', + // `p` is date-fns' localized time: it resolves each locale's own clock convention rather than fixing the + // US 12-hour one. Ten of the eleven shipped locales use a 24-hour clock, so `h:mm a` was wrong for them + // and the translated AM/PM marker only made a wrong convention read as deliberate. Greek keeps its + // 12-hour clock and its own `μ.μ.` marker. `p` is a date-fns extension, and still needs `{locale}`. + LOCAL_TIME_FORMAT: 'p', YEAR_MONTH_FORMAT: 'yyyyMM', MONTH_FORMAT: 'MMMM', WEEKDAY_TIME_FORMAT: 'eeee', diff --git a/src/components/AutoUpdateTime.tsx b/src/components/AutoUpdateTime.tsx index 9a1e41a50803..6b81e33b1e24 100644 --- a/src/components/AutoUpdateTime.tsx +++ b/src/components/AutoUpdateTime.tsx @@ -20,7 +20,7 @@ type AutoUpdateTimeProps = { }; function AutoUpdateTime({timezone}: AutoUpdateTimeProps) { - const {translate, getLocalDateFromDatetime} = useLocalize(); + const {translate, getLocalDateFromDatetime, dateFnsLocale} = useLocalize(); const styles = useThemeStyles(); const [, setTick] = useState(0); @@ -43,7 +43,7 @@ function AutoUpdateTime({timezone}: AutoUpdateTimeProps) { diff --git a/src/components/LocaleContextProvider.tsx b/src/components/LocaleContextProvider.tsx index b9023fe30d74..dfaf918ccb37 100644 --- a/src/components/LocaleContextProvider.tsx +++ b/src/components/LocaleContextProvider.tsx @@ -176,7 +176,7 @@ function LocaleContextProvider({children}: LocaleContextProviderProps) { const formatTravelDate: LocaleContextProps['formatTravelDate'] = (datetime) => { const date = new Date(datetime); const formattedDate = formatDate(date, CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT, {locale: dateFnsLocale}); - const formattedHour = DateUtils.formatTimeWithPeriod(translate, date); + const formattedHour = formatDate(date, CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale}); const at = translateLocalize(currentLocale, 'common.conjunctionAt'); return `${formattedDate} ${at} ${formattedHour}`; }; diff --git a/src/components/MoneyRequestConfirmationList/sections/PerDiemFields.tsx b/src/components/MoneyRequestConfirmationList/sections/PerDiemFields.tsx index 7ce6aae8f946..6aeb55dfbcbc 100644 --- a/src/components/MoneyRequestConfirmationList/sections/PerDiemFields.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/PerDiemFields.tsx @@ -31,7 +31,7 @@ type PerDiemFieldsProps = { function PerDiemFields({perDiemCustomUnit, transaction, isReadOnly, didConfirm, transactionID, shouldDisplayFieldError, formError}: PerDiemFieldsProps) { const styles = useThemeStyles(); - const {translate} = useLocalize(); + const {translate, dateFnsLocale} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['Stopwatch', 'CalendarSolid']); const subRates = getSubratesFields(perDiemCustomUnit, transaction); @@ -114,7 +114,7 @@ function PerDiemFields({perDiemCustomUnit, transaction, isReadOnly, didConfirm, diff --git a/src/components/TimeModalPicker.tsx b/src/components/TimeModalPicker.tsx index 2469f74a23b6..5fdee04745a7 100644 --- a/src/components/TimeModalPicker.tsx +++ b/src/components/TimeModalPicker.tsx @@ -31,9 +31,11 @@ type TimeModalPickerProps = { function TimeModalPicker({value, errorText, label, onInputChange = () => {}, ref}: TimeModalPickerProps) { const styles = useThemeStyles(); - const {translate} = useLocalize(); + const {dateFnsLocale} = useLocalize(); const [isPickerVisible, setIsPickerVisible] = useState(false); - const currentTime = value ? DateUtils.getTime12HourWithTranslatedPeriod(translate, value) : undefined; + // The row shows a localized time, while `TimePicker` still reads `value` through `extractTime12Hour` — that one is + // the picker's English wire format, so rendering it here would put an AM/PM clock next to 24-hour times elsewhere. + const currentTime = value ? DateUtils.formatToLocalTime(value, dateFnsLocale) : undefined; const hidePickerModal = () => { setIsPickerVisible(false); diff --git a/src/languages/IntlStore.ts b/src/languages/IntlStore.ts index efcae648e373..72f136fd8e54 100644 --- a/src/languages/IntlStore.ts +++ b/src/languages/IntlStore.ts @@ -94,8 +94,8 @@ class IntlStore { import('./en').then((module: DynamicModule) => { this.cache.set(LOCALES.EN, flattenObject(extractModuleDefaultExport(module))); }), - import('date-fns/locale/en-GB').then((module) => { - this.dateUtilsCache.set(LOCALES.EN, module.enGB); + import('date-fns/locale/en-US').then((module) => { + this.dateUtilsCache.set(LOCALES.EN, module.enUS); }), shouldPolyfillNumberFormat(LOCALES.EN) ? import('@formatjs/intl-numberformat/locale-data/en') : Promise.resolve(), shouldPolyfillListFormat(LOCALES.EN) ? import('@formatjs/intl-listformat/locale-data/en') : Promise.resolve(), diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index c6897c630cb8..5e902ae9c3ba 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -42,7 +42,7 @@ import { subMinutes, } from 'date-fns'; import {formatInTimeZone, fromZonedTime, toDate, toZonedTime, format as tzFormat} from 'date-fns-tz'; -import {enGB} from 'date-fns/locale/en-GB'; +import {enUS} from 'date-fns/locale/en-US'; import throttle from 'lodash/throttle'; import {setCurrentDate} from './actions/CurrentDate'; @@ -185,7 +185,6 @@ function datetimeToCalendarTime(locale: Locale | undefined, datetime: string, cu let tomorrowAt = translateLocalize(locale, 'common.tomorrowAt'); let yesterdayAt = translateLocalize(locale, 'common.yesterdayAt'); const at = translateLocalize(locale, 'common.conjunctionAt'); - const translate: LocalizedTranslate = (path, ...params) => translateLocalize(locale, path, ...params); const weekStartsOn = getWeekStartsOn(); const startOfCurrentWeek = startOfWeek(new Date(), {weekStartsOn}); @@ -198,18 +197,18 @@ function datetimeToCalendarTime(locale: Locale | undefined, datetime: string, cu } if (isToday(date, currentSelectedTimezone)) { - return `${todayAt} ${formatTimeWithPeriod(translate, date)}${tz}`; + return `${todayAt} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale})}${tz}`; } if (isTomorrow(date, currentSelectedTimezone)) { - return `${tomorrowAt} ${formatTimeWithPeriod(translate, date)}${tz}`; + return `${tomorrowAt} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale})}${tz}`; } if (isYesterday(date, currentSelectedTimezone)) { - return `${yesterdayAt} ${formatTimeWithPeriod(translate, date)}${tz}`; + return `${yesterdayAt} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale})}${tz}`; } if (date >= startOfCurrentWeek && date <= endOfCurrentWeek) { - return `${format(date, CONST.DATE.MONTH_DAY_ABBR_FORMAT, {locale: dateFnsLocale})} ${at} ${formatTimeWithPeriod(translate, date)}${tz}`; + return `${format(date, CONST.DATE.MONTH_DAY_ABBR_FORMAT, {locale: dateFnsLocale})} ${at} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale})}${tz}`; } - return `${format(date, CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT, {locale: dateFnsLocale})} ${at} ${formatTimeWithPeriod(translate, date)}${tz}`; + return `${format(date, CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT, {locale: dateFnsLocale})} ${at} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale})}${tz}`; } /** @@ -299,8 +298,8 @@ function formatToDayOfWeek(datetime: Date, dateFnsLocale: DateFnsLocale | undefi * * @returns 2:30 PM */ -function formatToLocalTime(translate: LocalizedTranslate, datetime: string | Date): string { - return formatTimeWithPeriod(translate, new Date(datetime)); +function formatToLocalTime(datetime: string | Date, dateFnsLocale: DateFnsLocale | undefined): string { + return format(new Date(datetime), CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale}); } const THREE_HOURS = 1000 * 60 * 60 * 3; @@ -492,49 +491,24 @@ function extractTime12Hour(dateTimeString: string, isFullFormat = false): string return ''; } const date = new Date(dateTimeString); - // get12HourTimeObjectFromDate parses this back with the same default locale, so the format and the parse stay - // in step in every language. - // eslint-disable-next-line rulesdir/require-locale-for-localized-date-format -- see above - return format(date, isFullFormat ? 'hh:mm:ss.SSS a' : 'hh:mm a'); -} - -/** - * param {number} hours - 0-23, in the timezone the time is shown in - * returns {string} example: PM - */ -function getTimePeriodLabel(translate: LocalizedTranslate, hours: number): string { - return translate(hours >= 12 ? 'common.pm' : 'common.am'); -} - -/** - * param {string} timeFormat - a date-fns pattern with no meridiem token - * returns {string} example: 11:10 PM - */ -function formatTimeWithPeriod(translate: LocalizedTranslate, date: Date, timeFormat: string = CONST.DATE.LOCAL_TIME_FORMAT_WITHOUT_PERIOD): string { - return `${format(date, timeFormat)} ${getTimePeriodLabel(translate, date.getHours())}`; -} - -/** - * param {string} dateTimeString - * returns {string} example: 11:10 PM - */ -function getTime12HourWithTranslatedPeriod(translate: LocalizedTranslate, dateTimeString: string): string { - if (!dateTimeString || dateTimeString === 'never') { - return ''; - } - return formatTimeWithPeriod(translate, new Date(dateTimeString), CONST.DATE.TIME_FORMAT_WITHOUT_PERIOD); + // Pinned to English, not the active language. This value is the TimePicker's wire format: it is parsed back by + // `get12HourTimeObjectFromDate`, and the period it yields is compared against the English `CONST.TIME_PERIOD` that + // the AM/PM buttons write and `combineDateAndTime` parses. Both ends of that round trip have to agree on one + // language, and English is the one the rest of the protocol already uses. Relying on the active locale instead + // worked only because the parse omitted a locale too, so the pair silently depended on a mutable global. + return format(date, isFullFormat ? 'hh:mm:ss.SSS a' : 'hh:mm a', {locale: enUS}); } /** * param {string} dateTimeString * returns {string} example: 2023-05-16 11:10 PM */ -function formatDateTimeTo12Hour(translate: LocalizedTranslate, dateTimeString: string): string { +function formatDateTimeTo12Hour(dateTimeString: string, dateFnsLocale: DateFnsLocale | undefined): string { if (!dateTimeString) { return ''; } const date = new Date(dateTimeString); - return `${format(date, CONST.DATE.FNS_FORMAT_STRING)} ${formatTimeWithPeriod(translate, date, CONST.DATE.TIME_FORMAT_WITHOUT_PERIOD)}`; + return format(date, `${CONST.DATE.FNS_FORMAT_STRING} ${CONST.DATE.LOCAL_TIME_FORMAT}`, {locale: dateFnsLocale}); } /** @@ -570,7 +544,7 @@ function getLocalizedTimePeriodDescription(translate: LocalizedTranslate, dateFn case '': return translate('statusPage.timePeriods.never'); default: - return formatDateTimeTo12Hour(translate, data); + return formatDateTimeTo12Hour(data, dateFnsLocale); } } @@ -604,16 +578,16 @@ function getStatusUntilDate( // If it's a time on the same date if (isSameDay(input, now)) { - return translate('statusPage.untilTime', formatTimeWithPeriod(translate, input)); + return translate('statusPage.untilTime', format(input, CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale})); } // If it's further in the future than tomorrow but within the same year if (isAfter(input, now) && isSameYear(input, now)) { - return translate('statusPage.untilTime', `${format(input, CONST.DATE.SHORT_DATE_FORMAT)} ${formatTimeWithPeriod(translate, input)}`); + return translate('statusPage.untilTime', format(input, `${CONST.DATE.SHORT_DATE_FORMAT} ${CONST.DATE.LOCAL_TIME_FORMAT}`, {locale: dateFnsLocale})); } // If it's in another year - return translate('statusPage.untilTime', `${format(input, CONST.DATE.FNS_FORMAT_STRING)} ${formatTimeWithPeriod(translate, input)}`); + return translate('statusPage.untilTime', format(input, `${CONST.DATE.FNS_FORMAT_STRING} ${CONST.DATE.LOCAL_TIME_FORMAT}`, {locale: dateFnsLocale})); } /** @@ -638,7 +612,7 @@ const combineDateAndTime = (updatedTime: string, inputDateTime: string): string } else if (updatedTime.includes(':')) { // it's in "hh:mm a" format // The picker always submits English AM/PM markers, which the app's active date-fns locale may not parse. - const tempTime = parse(updatedTime, 'hh:mm a', new Date(), {locale: enGB}); + const tempTime = parse(updatedTime, 'hh:mm a', new Date(), {locale: enUS}); if (isValid(tempTime)) { parsedTime = tempTime; } @@ -690,7 +664,10 @@ function get12HourTimeObjectFromDate(dateTime: string, isFullFormat = false): {h period: 'PM', }; } - const parsedTime = parse(dateTime, isFullFormat ? 'hh:mm:ss.SSS a' : 'hh:mm a', new Date()); + // The counterpart to `extractTime12Hour`'s format: same pattern, same pinned locale. Passing a locale to only one + // of the two would make this parse return an invalid date, and `getHours()` would then be NaN — so `period` would + // quietly come back as AM for every time of day. + const parsedTime = parse(dateTime, isFullFormat ? 'hh:mm:ss.SSS a' : 'hh:mm a', new Date(), {locale: enUS}); return { hour: format(parsedTime, 'hh'), minute: format(parsedTime, 'mm'), @@ -915,11 +892,10 @@ function getFormattedReservationRangeDate(translate: LocalizedTranslate, dateFns * 2. When the date refers not to the current year: Departs on Wednesday, Mar 17, 2023 at 8:00. */ function getFormattedTransportDate(translate: LocalizedTranslate, dateFnsLocale: DateFnsLocale | undefined, date: Date): string { - const time = formatTimeWithPeriod(translate, date, CONST.DATE.TIME_FORMAT_WITHOUT_PERIOD); if (isThisYear(date)) { - return `${translate('travel.departs')} ${format(date, 'EEEE, MMM d', {locale: dateFnsLocale})} ${translate('common.conjunctionAt')} ${time}`; + return `${translate('travel.departs')} ${format(date, 'EEEE, MMM d', {locale: dateFnsLocale})} ${translate('common.conjunctionAt')} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale})}`; } - return `${translate('travel.departs')} ${format(date, 'EEEE, MMM d, yyyy', {locale: dateFnsLocale})} ${translate('common.conjunctionAt')} ${time}`; + return `${translate('travel.departs')} ${format(date, 'EEEE, MMM d, yyyy', {locale: dateFnsLocale})} ${translate('common.conjunctionAt')} ${format(date, CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale})}`; } /** @@ -928,17 +904,16 @@ function getFormattedTransportDate(translate: LocalizedTranslate, dateFnsLocale: * 1. When the date refers to the current year: Wednesday, Mar 17 8:00 AM * 2. When the date refers not to the current year: Wednesday, Mar 17, 2023 8:00 AM */ -function getFormattedTransportDateAndHour(translate: LocalizedTranslate, dateFnsLocale: DateFnsLocale | undefined, date: Date): {date: string; hour: string} { - const hour = formatTimeWithPeriod(translate, date); +function getFormattedTransportDateAndHour(date: Date, dateFnsLocale: DateFnsLocale | undefined): {date: string; hour: string} { if (isThisYear(date)) { return { date: format(date, 'EEEE, MMM d', {locale: dateFnsLocale}), - hour, + hour: format(date, CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale}), }; } return { date: format(date, 'EEEE, MMM d, yyyy', {locale: dateFnsLocale}), - hour, + hour: format(date, CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale}), }; } @@ -965,19 +940,17 @@ function getCancellationDateTimezoneLabel(venueTimezone: string): string { * 1. When the date refers to the current year: Wednesday, Mar 17 8:00 AM, GMT+7 * 2. When the date refers not to the current year: Wednesday, Mar 17, 2023 8:00 AM, GMT+7 */ -function getFormattedCancellationDate(translate: LocalizedTranslate, dateFnsLocale: DateFnsLocale | undefined, isoDateString: string): string { +function getFormattedCancellationDate(isoDateString: string, dateFnsLocale: DateFnsLocale | undefined): string { if (!isoDateString) { return ''; } const offsetMatch = isoDateString.match(/([+-]\d{2}:\d{2})$/); const venueTimezone = offsetMatch ? offsetMatch[1] : 'UTC'; const date = new Date(isoDateString); - const datePattern = isThisYear(date) ? 'EEEE, MMM d' : 'EEEE, MMM d, yyyy'; - // The marker follows the venue's own hour, not the reader's. - const time = `${formatInTimeZone(date, venueTimezone, CONST.DATE.LOCAL_TIME_FORMAT_WITHOUT_PERIOD)} ${getTimePeriodLabel(translate, Number(formatInTimeZone(date, venueTimezone, 'H')))}`; + const pattern = isThisYear(date) ? `EEEE, MMM d ${CONST.DATE.LOCAL_TIME_FORMAT}` : `EEEE, MMM d, yyyy ${CONST.DATE.LOCAL_TIME_FORMAT}`; // `formatInTimeZone`'s `zzz` token relies on `Intl.DateTimeFormat`, which rejects raw offset strings like // `+07:00`, so the timezone label is derived from the offset and appended manually. - return `${formatInTimeZone(date, venueTimezone, datePattern, {locale: dateFnsLocale})} ${time}, ${getCancellationDateTimezoneLabel(venueTimezone)}`; + return `${formatInTimeZone(date, venueTimezone, pattern, {locale: dateFnsLocale})}, ${getCancellationDateTimezoneLabel(venueTimezone)}`; } /** @@ -1087,20 +1060,6 @@ const formatInTimeZoneWithFallback: typeof formatInTimeZone = (date, timeZone, f } }; -/** - * param {SelectedTimezone} timeZone - also decides the marker - * returns {string} example: 11:10 PM - */ -function formatTimeInTimeZoneWithPeriod( - translate: LocalizedTranslate, - date: string | Date, - timeZone: SelectedTimezone, - timeFormat: string = CONST.DATE.LOCAL_TIME_FORMAT_WITHOUT_PERIOD, -): string { - const hours = Number(formatInTimeZoneWithFallback(date, timeZone, 'H')); - return `${formatInTimeZoneWithFallback(date, timeZone, timeFormat)} ${getTimePeriodLabel(translate, hours)}`; -} - /** * Converts a UTC datetime string to a date string (yyyy-MM-dd) in the target timezone. * @param utcDateTime - Datetime string in UTC format (yyyy-MM-dd HH:mm:ss or yyyy-MM-dd HH:mm:ss.SSS) @@ -1324,10 +1283,6 @@ const DateUtils = { extractDate, getStatusUntilDate, extractTime12Hour, - getTime12HourWithTranslatedPeriod, - getTimePeriodLabel, - formatTimeWithPeriod, - formatTimeInTimeZoneWithPeriod, formatDateTimeTo12Hour, get12HourTimeObjectFromDate, getLocalizedTimePeriodDescription, diff --git a/src/libs/PerDiemRequestUtils.ts b/src/libs/PerDiemRequestUtils.ts index ee9e17ad18b2..e3c5cceb666d 100644 --- a/src/libs/PerDiemRequestUtils.ts +++ b/src/libs/PerDiemRequestUtils.ts @@ -5,6 +5,7 @@ import CONST from '@src/CONST'; import type {Policy, Report, Transaction} from '@src/types/onyx'; import type {CustomUnit, Rate} from '@src/types/onyx/Policy'; +import type {Locale as DateFnsLocale} from 'date-fns'; import type {OnyxEntry} from 'react-native-onyx'; import {addDays, differenceInDays, differenceInMinutes, format, isSameDay, startOfDay} from 'date-fns'; @@ -12,7 +13,6 @@ import lodashSortBy from 'lodash/sortBy'; import type {OptionTree} from './OptionsListUtils'; -import DateUtils from './DateUtils'; import {isPolicyExpenseChat} from './ReportUtils'; import tokenizedSearch from './tokenizedSearch'; @@ -221,17 +221,17 @@ function getSubratesForDisplay(subrate: Subrate | undefined, qtyText: string) { * param {string} dateTimeString * returns {string} example: 2023-05-16 11:10 PM */ -function formatDateTimeTo12Hour(translate: LocalizedTranslate, dateTimeString: string): string { +function formatDateTimeTo12Hour(dateTimeString: string, dateFnsLocale: DateFnsLocale | undefined): string { if (!dateTimeString) { return ''; } const date = new Date(dateTimeString); - return `${DateUtils.formatTimeWithPeriod(translate, date, CONST.DATE.TIME_FORMAT_WITHOUT_PERIOD)}, ${format(date, CONST.DATE.FNS_FORMAT_STRING)}`; + return format(date, `${CONST.DATE.LOCAL_TIME_FORMAT}, ${CONST.DATE.FNS_FORMAT_STRING}`, {locale: dateFnsLocale}); } -function getTimeForDisplay(transaction: OnyxEntry, translate: LocalizedTranslate) { +function getTimeForDisplay(transaction: OnyxEntry, dateFnsLocale: DateFnsLocale | undefined) { const customUnitRateDate = transaction?.comment?.customUnit?.attributes?.dates ?? {start: '', end: ''}; - return `${formatDateTimeTo12Hour(translate, customUnitRateDate.start)} - ${formatDateTimeTo12Hour(translate, customUnitRateDate.end)}`; + return `${formatDateTimeTo12Hour(customUnitRateDate.start, dateFnsLocale)} - ${formatDateTimeTo12Hour(customUnitRateDate.end, dateFnsLocale)}`; } function getTimeDifferenceIntervals(transaction: OnyxEntry) { diff --git a/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx b/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx index 3a1a6ded6c15..f5d2163fc9cd 100644 --- a/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx +++ b/src/pages/ScheduleCall/ScheduleCallConfirmationPage.tsx @@ -76,10 +76,11 @@ function ScheduleCallConfirmationPage() { let dateTimeString = ''; if (scheduleCallDraft?.timeSlot && scheduleCallDraft.date) { const dateString = DateUtils.formatInTimeZoneWithFallback(scheduleCallDraft.date, userTimezone, CONST.DATE.MONTH_DAY_YEAR_FORMAT, {locale: dateFnsLocale}); - const timeString = `${DateUtils.formatTimeInTimeZoneWithPeriod(translate, scheduleCallDraft?.timeSlot, userTimezone)} - ${DateUtils.formatTimeInTimeZoneWithPeriod( - translate, + const timeString = `${DateUtils.formatInTimeZoneWithFallback(scheduleCallDraft?.timeSlot, userTimezone, CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale})} - ${DateUtils.formatInTimeZoneWithFallback( addMinutes(scheduleCallDraft?.timeSlot, 30), userTimezone, + CONST.DATE.LOCAL_TIME_FORMAT, + {locale: dateFnsLocale}, )}`; const timezoneString = DateUtils.getZoneAbbreviation(new Date(scheduleCallDraft?.timeSlot), userTimezone); diff --git a/src/pages/ScheduleCall/ScheduleCallPage.tsx b/src/pages/ScheduleCall/ScheduleCallPage.tsx index 0bfe8247c4e0..726ce74ac463 100644 --- a/src/pages/ScheduleCall/ScheduleCallPage.tsx +++ b/src/pages/ScheduleCall/ScheduleCallPage.tsx @@ -242,7 +242,9 @@ function ScheduleCallPage() { enableHapticFeedback style={styles.twoColumnLayoutCol} > - {DateUtils.formatTimeInTimeZoneWithPeriod(translate, timeSlot.startTime, userTimezone)} + + {DateUtils.formatInTimeZoneWithFallback(timeSlot.startTime, userTimezone, CONST.DATE.LOCAL_TIME_FORMAT, {locale: dateFnsLocale})} + ))} {timeFillerItem} diff --git a/src/pages/Travel/CarTripDetails.tsx b/src/pages/Travel/CarTripDetails.tsx index 175d2b908d79..cd0579170096 100644 --- a/src/pages/Travel/CarTripDetails.tsx +++ b/src/pages/Travel/CarTripDetails.tsx @@ -25,12 +25,12 @@ function CarTripDetails({reservation, personalDetails}: CarTripDetailsProps) { const styles = useThemeStyles(); const {translate, dateFnsLocale} = useLocalize(); - const pickUpDate = DateUtils.getFormattedTransportDateAndHour(translate, dateFnsLocale, new Date(reservation.start.date)); - const dropOffDate = DateUtils.getFormattedTransportDateAndHour(translate, dateFnsLocale, new Date(reservation.end.date)); + const pickUpDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.start.date), dateFnsLocale); + const dropOffDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.end.date), dateFnsLocale); let cancellationText = reservation.cancellationPolicy; if (reservation.cancellationDeadline) { - cancellationText = `${translate('travel.carDetails.cancellationUntil')} ${DateUtils.getFormattedCancellationDate(translate, dateFnsLocale, reservation.cancellationDeadline)}`; + cancellationText = `${translate('travel.carDetails.cancellationUntil')} ${DateUtils.getFormattedCancellationDate(reservation.cancellationDeadline, dateFnsLocale)}`; } if (reservation.cancellationPolicy === null && reservation.cancellationDeadline === null) { diff --git a/src/pages/Travel/FlightTripDetails.tsx b/src/pages/Travel/FlightTripDetails.tsx index 42485b9a8d86..aae10ad279f9 100644 --- a/src/pages/Travel/FlightTripDetails.tsx +++ b/src/pages/Travel/FlightTripDetails.tsx @@ -42,8 +42,8 @@ function FlightTripDetails({reservation, prevReservation, personalDetails}: Flig FIRST: translate('travel.flightDetails.cabinClasses.first'), }; - const startDate = DateUtils.getFormattedTransportDateAndHour(translate, dateFnsLocale, new Date(reservation.start.date)); - const endDate = DateUtils.getFormattedTransportDateAndHour(translate, dateFnsLocale, new Date(reservation.end.date)); + const startDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.start.date), dateFnsLocale); + const endDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.end.date), dateFnsLocale); const prevFlightEndDate = prevReservation?.end.date; const layover = prevFlightEndDate && DateUtils.getFormattedDurationBetweenDates(translate, new Date(prevFlightEndDate), new Date(reservation.start.date)); diff --git a/src/pages/Travel/HotelTripDetails.tsx b/src/pages/Travel/HotelTripDetails.tsx index 3bf887816228..083ea9e377ee 100644 --- a/src/pages/Travel/HotelTripDetails.tsx +++ b/src/pages/Travel/HotelTripDetails.tsx @@ -34,10 +34,10 @@ function HotelTripDetails({reservation, personalDetails}: HotelTripDetailsProps) [CONST.CANCELLATION_POLICY.PARTIALLY_REFUNDABLE]: translate('travel.hotelDetails.cancellationPolicies.partiallyRefundable'), }; - const checkInDate = DateUtils.getFormattedTransportDateAndHour(translate, dateFnsLocale, new Date(reservation.start.date)); - const checkOutDate = DateUtils.getFormattedTransportDateAndHour(translate, dateFnsLocale, new Date(reservation.end.date)); + const checkInDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.start.date), dateFnsLocale); + const checkOutDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.end.date), dateFnsLocale); const cancellationText = reservation.cancellationDeadline - ? `${translate('travel.hotelDetails.cancellationUntil')} ${DateUtils.getFormattedCancellationDate(translate, dateFnsLocale, reservation.cancellationDeadline)}` + ? `${translate('travel.hotelDetails.cancellationUntil')} ${DateUtils.getFormattedCancellationDate(reservation.cancellationDeadline, dateFnsLocale)}` : cancellationMapping[reservation.cancellationPolicy ?? CONST.CANCELLATION_POLICY.UNKNOWN]; const displayName = personalDetails?.displayName ?? reservation.travelerPersonalInfo?.name; diff --git a/src/pages/Travel/TrainTripDetails.tsx b/src/pages/Travel/TrainTripDetails.tsx index 9560c480924e..4fdee0514f47 100644 --- a/src/pages/Travel/TrainTripDetails.tsx +++ b/src/pages/Travel/TrainTripDetails.tsx @@ -27,8 +27,8 @@ function TrainTripDetails({reservation, personalDetails}: TrainTripDetailsProps) const styles = useThemeStyles(); const {translate, dateFnsLocale} = useLocalize(); - const startDate = DateUtils.getFormattedTransportDateAndHour(translate, dateFnsLocale, new Date(reservation.start.date)); - const endDate = DateUtils.getFormattedTransportDateAndHour(translate, dateFnsLocale, new Date(reservation.end.date)); + const startDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.start.date), dateFnsLocale); + const endDate = DateUtils.getFormattedTransportDateAndHour(new Date(reservation.end.date), dateFnsLocale); const trainRouteDescription = `${formatTransitLocationLabel(reservation.start)} ${translate('common.conjunctionTo')} ${formatTransitLocationLabel(reservation.end)}`; const trainDuration = DateUtils.getFormattedDurationBetweenDates(translate, new Date(reservation.start.date), new Date(reservation.end.date)); diff --git a/src/pages/inbox/report/ParticipantLocalTime.tsx b/src/pages/inbox/report/ParticipantLocalTime.tsx index 5d8b5c9f93ee..492cd0e68db1 100644 --- a/src/pages/inbox/report/ParticipantLocalTime.tsx +++ b/src/pages/inbox/report/ParticipantLocalTime.tsx @@ -17,12 +17,7 @@ type ParticipantLocalTimeProps = { participant: PersonalDetails; }; -function getParticipantLocalTime( - participant: PersonalDetails, - translate: LocaleContextProps['translate'], - getLocalDateFromDatetime: LocaleContextProps['getLocalDateFromDatetime'], - dateFnsLocale: LocaleContextProps['dateFnsLocale'], -) { +function getParticipantLocalTime(participant: PersonalDetails, getLocalDateFromDatetime: LocaleContextProps['getLocalDateFromDatetime'], dateFnsLocale: LocaleContextProps['dateFnsLocale']) { // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Disabling this line for safeness as nullish coalescing works only if the value is undefined or null const reportRecipientTimezone = participant.timezone || CONST.DEFAULT_TIME_ZONE; const reportTimezone = getLocalDateFromDatetime(undefined, reportRecipientTimezone.selected); @@ -30,26 +25,26 @@ function getParticipantLocalTime( const reportRecipientDay = DateUtils.formatToDayOfWeek(reportTimezone, dateFnsLocale); const currentUserDay = DateUtils.formatToDayOfWeek(currentTimezone, dateFnsLocale); if (reportRecipientDay !== currentUserDay) { - return `${DateUtils.formatToLocalTime(translate, reportTimezone)} ${reportRecipientDay}`; + return `${DateUtils.formatToLocalTime(reportTimezone, dateFnsLocale)} ${reportRecipientDay}`; } - return `${DateUtils.formatToLocalTime(translate, reportTimezone)}`; + return `${DateUtils.formatToLocalTime(reportTimezone, dateFnsLocale)}`; } function ParticipantLocalTime({participant}: ParticipantLocalTimeProps) { const {translate, getLocalDateFromDatetime, dateFnsLocale} = useLocalize(); const styles = useThemeStyles(); - const [localTime, setLocalTime] = useState(() => getParticipantLocalTime(participant, translate, getLocalDateFromDatetime, dateFnsLocale)); + const [localTime, setLocalTime] = useState(() => getParticipantLocalTime(participant, getLocalDateFromDatetime, dateFnsLocale)); useEffect(() => { const timer = Timers.register( setInterval(() => { - setLocalTime(getParticipantLocalTime(participant, translate, getLocalDateFromDatetime, dateFnsLocale)); + setLocalTime(getParticipantLocalTime(participant, getLocalDateFromDatetime, dateFnsLocale)); }, 1000), ); return () => { clearInterval(timer); }; - }, [participant, translate, getLocalDateFromDatetime, dateFnsLocale]); + }, [participant, getLocalDateFromDatetime, dateFnsLocale]); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Disabling this line for safeness as nullish coalescing works only if the value is undefined or null const reportRecipientDisplayName = participant.firstName || participant.displayName; diff --git a/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.tsx b/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.tsx index d2f05d1ecebf..ab8892f24a7f 100644 --- a/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.tsx +++ b/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.tsx @@ -128,7 +128,7 @@ function StatusClearAfterPage() { }, []); const customStatusDate = DateUtils.extractDate(statusDraftCustomClearAfterDate ?? ''); - const customStatusTime = DateUtils.getTime12HourWithTranslatedPeriod(translate, statusDraftCustomClearAfterDate ?? ''); + const customStatusTime = DateUtils.extractTime12Hour(statusDraftCustomClearAfterDate ?? ''); const listFooterContent = useMemo(() => { if (draftPeriod !== CONST.CUSTOM_STATUS_TYPES.CUSTOM) { diff --git a/tests/unit/DateUtilsTest.ts b/tests/unit/DateUtilsTest.ts index 4f9485cee0c8..2eb11c4e1cb0 100644 --- a/tests/unit/DateUtilsTest.ts +++ b/tests/unit/DateUtilsTest.ts @@ -7,7 +7,6 @@ import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; import type {TranslationParameters, TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import type Locale from '@src/types/onyx/Locale'; import type {SelectedTimezone} from '@src/types/onyx/PersonalDetails'; /* eslint-disable @typescript-eslint/naming-convention */ @@ -23,10 +22,6 @@ jest.mock('@src/libs/Log'); const LOCALE = CONST.LOCALES.EN; const UTC = 'UTC'; -const getTranslateFn = - (locale: Locale): LocaleContextProps['translate'] => - (path, ...params) => - translate(locale, path, ...params); describe('DateUtils', () => { beforeAll(() => { Onyx.init({ @@ -81,7 +76,7 @@ describe('DateUtils', () => { expect(weekDay).toBe('Monday'); }); it('formatToLocalTime should return a date in a local format', () => { - const localTime = DateUtils.formatToLocalTime(translateLocal, datetime); + const localTime = DateUtils.formatToLocalTime(datetime, undefined); expect(localTime).toBe('12:00 AM'); }); @@ -684,7 +679,7 @@ describe('DateUtils', () => { jest.useFakeTimers(); jest.setSystemTime(new Date('2025-01-01T00:00:00Z')); // 2026-04-19T15:00:00+07:00 — venue is UTC+7, device timezone is UTC - const result = DateUtils.getFormattedCancellationDate(translateLocal, undefined, '2026-04-19T15:00:00+07:00'); + const result = DateUtils.getFormattedCancellationDate('2026-04-19T15:00:00+07:00', undefined); // Should display 3:00 PM in the venue's +07:00 timezone, not converted to device-local time expect(result).toBe('Sunday, Apr 19, 2026 3:00 PM, GMT+7'); }); @@ -693,19 +688,19 @@ describe('DateUtils', () => { // Pin "now" to 2026 so the 2026 date is treated as the current year and the year is omitted. jest.useFakeTimers(); jest.setSystemTime(new Date('2026-06-01T00:00:00Z')); - const result = DateUtils.getFormattedCancellationDate(translateLocal, undefined, '2026-06-15T10:30:00+00:00'); + const result = DateUtils.getFormattedCancellationDate('2026-06-15T10:30:00+00:00', undefined); expect(result).toBe('Monday, Jun 15 10:30 AM, UTC'); }); it('should return empty string for falsy input', () => { - expect(DateUtils.getFormattedCancellationDate(translateLocal, undefined, '')).toBe(''); + expect(DateUtils.getFormattedCancellationDate('', undefined)).toBe(''); }); it('should fall back to UTC when no timezone offset is present in the ISO string', () => { // Pin "now" before 2026 so the 2026 date is treated as a non-current year and the year is shown. jest.useFakeTimers(); jest.setSystemTime(new Date('2025-01-01T00:00:00Z')); - const result = DateUtils.getFormattedCancellationDate(translateLocal, undefined, '2026-04-19T15:00:00'); + const result = DateUtils.getFormattedCancellationDate('2026-04-19T15:00:00', undefined); expect(result).toBe('Sunday, Apr 19, 2026 3:00 PM, UTC'); }); }); @@ -765,11 +760,19 @@ describe('DateUtils', () => { expect(DateUtils.combineDateAndTime('08:00 AM', '2026-08-04 00:00:00')).toBe('2026-08-04 08:00:00'); }); - it('get12HourTimeObjectFromDate returns the AM/PM period for a localized time string', () => { - const localizedNoon = DateUtils.extractTime12Hour('2026-08-04 12:00:00'); - expect(DateUtils.get12HourTimeObjectFromDate(localizedNoon).period).toBe(CONST.TIME_PERIOD.PM); - const localizedMorning = DateUtils.extractTime12Hour('2026-08-04 08:00:00'); - expect(DateUtils.get12HourTimeObjectFromDate(localizedMorning)).toEqual({hour: '08', minute: '00', seconds: '00', milliseconds: '000', period: CONST.TIME_PERIOD.AM}); + it('extractTime12Hour emits an English AM/PM marker whatever the active language', () => { + // This value is the picker's wire format, not display text, so it stays English for the same reason + // `combineDateAndTime` parses English: the period is compared against `CONST.TIME_PERIOD`. + expect(DateUtils.extractTime12Hour('2026-08-04 12:00:00')).toBe('12:00 PM'); + expect(DateUtils.extractTime12Hour('2026-08-04 08:00:00')).toBe('08:00 AM'); + expect(DateUtils.extractTime12Hour('2026-08-04 12:00:00.500', true)).toBe('12:00:00.500 PM'); + }); + + it('get12HourTimeObjectFromDate reads back what extractTime12Hour wrote', () => { + const noon = DateUtils.extractTime12Hour('2026-08-04 12:00:00'); + expect(DateUtils.get12HourTimeObjectFromDate(noon).period).toBe(CONST.TIME_PERIOD.PM); + const morning = DateUtils.extractTime12Hour('2026-08-04 08:00:00'); + expect(DateUtils.get12HourTimeObjectFromDate(morning)).toEqual({hour: '08', minute: '00', seconds: '00', milliseconds: '000', period: CONST.TIME_PERIOD.AM}); }); it('per diem start/end range built from picker values validates', () => { @@ -777,47 +780,5 @@ describe('DateUtils', () => { const newEnd = DateUtils.combineDateAndTime('02:00 PM', '2026-08-04'); expect(DateUtils.isValidStartEndTimeRange({startTime: newStart, endTime: newEnd})).toBe(true); }); - - it('getTime12HourWithTranslatedPeriod shows the same period the picker offers', () => { - const translateDE = getTranslateFn(CONST.LOCALES.DE); - expect(DateUtils.getTime12HourWithTranslatedPeriod(translateDE, '2026-08-04 00:00:00')).toBe('12:00 AM'); - expect(DateUtils.getTime12HourWithTranslatedPeriod(translateDE, '2026-08-04 12:00:00')).toBe('12:00 PM'); - expect(DateUtils.getTime12HourWithTranslatedPeriod(translateDE, '2026-08-04 08:30:00')).toBe('08:30 AM'); - }); - - it('getTime12HourWithTranslatedPeriod returns an empty string when there is no date', () => { - expect(DateUtils.getTime12HourWithTranslatedPeriod(getTranslateFn(CONST.LOCALES.DE), '')).toBe(''); - }); - - it('formatDateTimeTo12Hour and the Until label use the same period as the row', () => { - const translateDE = getTranslateFn(CONST.LOCALES.DE); - expect(DateUtils.formatDateTimeTo12Hour(translateDE, '2026-08-04 14:30:00')).toBe('2026-08-04 02:30 PM'); - expect(DateUtils.getLocalizedTimePeriodDescription(translateDE, undefined, '2026-08-04 14:30:00')).toBe('2026-08-04 02:30 PM'); - }); - - it('datetimeToCalendarTime uses the same period as the row', () => { - // Pinned so the date lands outside the current week and the branch under test is stable. - jest.useFakeTimers(); - jest.setSystemTime(new Date('2026-08-25T00:00:00Z')); - expect(DateUtils.datetimeToCalendarTime(CONST.LOCALES.DE, '2026-08-04 14:30:00', timezone)).toBe('Aug. 4, 2026 um 2:30 PM'); - jest.useRealTimers(); - }); - - it('a time in the hour skipped by the local DST change keeps its own hour', () => { - jest.useFakeTimers(); - jest.setSystemTime(new Date(2026, 2, 8, 0, 30)); - expect(DateUtils.getTime12HourWithTranslatedPeriod(getTranslateFn(CONST.LOCALES.DE), '2026-08-04 02:30:00')).toBe('02:30 AM'); - jest.useRealTimers(); - }); - }); - - describe('getTime12HourWithTranslatedPeriod in a locale that translates the period', () => { - beforeEach(() => IntlStore.load(CONST.LOCALES.JA)); - - it('keeps the translated marker', () => { - const translateJA = getTranslateFn(CONST.LOCALES.JA); - expect(DateUtils.getTime12HourWithTranslatedPeriod(translateJA, '2026-08-04 14:00:00')).toBe('02:00 午後'); - expect(DateUtils.getTime12HourWithTranslatedPeriod(translateJA, '2026-08-04 08:00:00')).toBe('08:00 午前'); - }); }); }); From c331a28803425c3dd5ff9f865b2b3261e6f43985 Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Sat, 12 Sep 2026 12:26:33 +0530 Subject: [PATCH 2/4] Address suggestions --- .../Profile/CustomStatus/StatusClearAfterPage.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.tsx b/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.tsx index ab8892f24a7f..b8909da40362 100644 --- a/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.tsx +++ b/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.tsx @@ -79,7 +79,7 @@ const useValidateCustomDate = (translate: LocalizedTranslate, data: string) => { function StatusClearAfterPage() { const styles = useThemeStyles(); - const {translate} = useLocalize(); + const {translate, dateFnsLocale} = useLocalize(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const clearAfter = currentUserPersonalDetails.status?.clearAfter ?? ''; const [customStatus] = useOnyx(ONYXKEYS.CUSTOM_STATUS_DRAFT); @@ -128,7 +128,12 @@ function StatusClearAfterPage() { }, []); const customStatusDate = DateUtils.extractDate(statusDraftCustomClearAfterDate ?? ''); - const customStatusTime = DateUtils.extractTime12Hour(statusDraftCustomClearAfterDate ?? ''); + // The sentinel and empty cases are guarded here because, unlike `extractTime12Hour`, the display formatter parses + // whatever it is handed. + const customStatusTime = + statusDraftCustomClearAfterDate && statusDraftCustomClearAfterDate !== CONST.CUSTOM_STATUS_TYPES.NEVER + ? DateUtils.formatToLocalTime(statusDraftCustomClearAfterDate, dateFnsLocale) + : ''; const listFooterContent = useMemo(() => { if (draftPeriod !== CONST.CUSTOM_STATUS_TYPES.CUSTOM) { From 3d30cabdaccbd36bcfe85c6e1c9bf2aa2a372b29 Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Thu, 17 Sep 2026 10:56:13 +0530 Subject: [PATCH 3/4] Add tests --- tests/unit/DateUtilsTest.ts | 74 +++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/unit/DateUtilsTest.ts b/tests/unit/DateUtilsTest.ts index 2eb11c4e1cb0..aab0c6f390d1 100644 --- a/tests/unit/DateUtilsTest.ts +++ b/tests/unit/DateUtilsTest.ts @@ -12,7 +12,9 @@ import type {SelectedTimezone} from '@src/types/onyx/PersonalDetails'; /* eslint-disable @typescript-eslint/naming-convention */ import {addDays, addMinutes, endOfDay, format, set, setHours, setMinutes, startOfDay, subDays, subHours, subMinutes, subSeconds} from 'date-fns'; import {fromZonedTime, toZonedTime, format as tzFormat} from 'date-fns-tz'; +import {de} from 'date-fns/locale/de'; import {el} from 'date-fns/locale/el'; +import {ja} from 'date-fns/locale/ja'; import Onyx from 'react-native-onyx'; import {translateLocal} from '../utils/TestHelper'; @@ -71,6 +73,14 @@ describe('DateUtils', () => { expect(formattedDate).toBe('Monday, November 7, 2022'); }); + it('formatToLongDateWithWeekday should translate the weekday and month names', () => { + // Only the words follow the locale here. `LONG_DATE_FORMAT_WITH_WEEKDAY` is still the hand-written + // `eeee, MMMM d, yyyy`, so the component order stays US — German would otherwise read `Montag, 7. November + // 2022` and Japanese `2022年11月7日月曜日`. Localizing that ordering is a separate change to `CONST.DATE`. + expect(DateUtils.formatToLongDateWithWeekday(datetime, de)).toBe('Montag, November 7, 2022'); + expect(DateUtils.formatToLongDateWithWeekday(datetime, ja)).toBe('月曜日, 11月 7, 2022'); + }); + it('formatToDayOfWeek should return a weekday', () => { const weekDay = DateUtils.formatToDayOfWeek(new Date(datetime), undefined); expect(weekDay).toBe('Monday'); @@ -80,6 +90,13 @@ describe('DateUtils', () => { expect(localTime).toBe('12:00 AM'); }); + it('formatToLocalTime should follow the given locale clock convention', () => { + // `LOCAL_TIME_FORMAT` is now `p`, so the clock comes from the locale rather than a fixed 12-hour pattern. + expect(DateUtils.formatToLocalTime(datetime, de)).toBe('00:00'); + expect(DateUtils.formatToLocalTime(datetime, ja)).toBe('0:00'); + expect(DateUtils.formatToLocalTime(datetime, el)).toBe('12:00 π.μ.'); + }); + it('should return a date object with the formatted datetime when calling getLocalDateFromDatetime', () => { const localDate = DateUtils.getLocalDateFromDatetime(LOCALE, timezone, datetime); expect(tzFormat(localDate, CONST.DATE.FNS_TIMEZONE_FORMAT_STRING, {timeZone: timezone})).toEqual('2022-11-07T00:00:00Z'); @@ -321,6 +338,40 @@ describe('DateUtils', () => { }); }); + describe('travel date formatters', () => { + // Current year and a past year, to exercise both branches. `translate` stays English throughout, so the + // assertions isolate `dateFnsLocale`: it drives the weekday, the month and the clock convention. + const thisYear = new Date(2026, 2, 17, 8, 0); + const pastYear = new Date(2023, 2, 17, 20, 30); + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(2026, 5, 1)); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('getFormattedTransportDate uses the locale weekday, month and clock', () => { + expect(DateUtils.getFormattedTransportDate(translateLocal, undefined, thisYear)).toBe('Departs Tuesday, Mar 17 at 8:00 AM'); + expect(DateUtils.getFormattedTransportDate(translateLocal, de, thisYear)).toBe('Departs Dienstag, März 17 at 08:00'); + expect(DateUtils.getFormattedTransportDate(translateLocal, ja, thisYear)).toBe('Departs 火曜日, 3月 17 at 8:00'); + // Greek is the one shipped locale that keeps a 12-hour clock. + expect(DateUtils.getFormattedTransportDate(translateLocal, el, thisYear)).toBe('Departs Τρίτη, Μαρ 17 at 8:00 π.μ.'); + }); + + it('getFormattedTransportDate adds the year outside the current year', () => { + expect(DateUtils.getFormattedTransportDate(translateLocal, de, pastYear)).toBe('Departs Freitag, März 17, 2023 at 20:30'); + }); + + it('getFormattedTransportDateAndHour returns the date and hour separately, both localized', () => { + expect(DateUtils.getFormattedTransportDateAndHour(thisYear, undefined)).toEqual({date: 'Tuesday, Mar 17', hour: '8:00 AM'}); + expect(DateUtils.getFormattedTransportDateAndHour(thisYear, de)).toEqual({date: 'Dienstag, März 17', hour: '08:00'}); + expect(DateUtils.getFormattedTransportDateAndHour(pastYear, de)).toEqual({date: 'Freitag, März 17, 2023', hour: '20:30'}); + }); + }); + describe('getStatusUntilDate', () => { const currentTimeZone = 'America/Los_Angeles' as SelectedTimezone; const inputTimeZoneNY = 'America/New_York' as SelectedTimezone; @@ -336,6 +387,19 @@ describe('DateUtils', () => { jest.useRealTimers(); }); + it('formats every branch with the given locale clock', () => { + // `translate` stays English so the assertion isolates what `dateFnsLocale` controls: German uses a + // 24-hour clock, so each branch loses its meridiem while the surrounding copy is untouched. + const sameDay = tzFormat(toZonedTime(new Date('2025-10-19T22:34:00Z'), currentTimeZone), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING, {timeZone: currentTimeZone}); + expect(DateUtils.getStatusUntilDate(translateLocal, de, sameDay, currentTimeZone, currentTimeZone)).toBe('Until 15:34'); + + const sameYear = tzFormat(toZonedTime(new Date('2025-12-02T20:15:00Z'), currentTimeZone), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING, {timeZone: currentTimeZone}); + expect(DateUtils.getStatusUntilDate(translateLocal, de, sameYear, currentTimeZone, currentTimeZone)).toBe('Until 12-02 12:15'); + + const otherYear = tzFormat(toZonedTime(new Date('2026-03-02T20:15:00Z'), currentTimeZone), CONST.DATE.FNS_DATE_TIME_FORMAT_STRING, {timeZone: currentTimeZone}); + expect(DateUtils.getStatusUntilDate(translateLocal, de, otherYear, currentTimeZone, currentTimeZone)).toBe('Until 2026-03-02 12:15'); + }); + it('returns empty string when input date is empty', () => { expect(DateUtils.getStatusUntilDate(translateLocal, undefined, '', inputTimeZoneNY, currentTimeZone)).toBe(''); }); @@ -703,6 +767,16 @@ describe('DateUtils', () => { const result = DateUtils.getFormattedCancellationDate('2026-04-19T15:00:00', undefined); expect(result).toBe('Sunday, Apr 19, 2026 3:00 PM, UTC'); }); + + it('should use the given locale for the weekday, the month and the clock', () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-01-01T00:00:00Z')); + // German and Japanese use a 24-hour clock, so the time loses its meridiem entirely. + expect(DateUtils.getFormattedCancellationDate('2026-04-19T15:00:00+07:00', de)).toBe('Sonntag, Apr. 19, 2026 15:00, GMT+7'); + expect(DateUtils.getFormattedCancellationDate('2026-04-19T15:00:00+07:00', ja)).toBe('日曜日, 4月 19, 2026 15:00, GMT+7'); + // Greek is the one shipped locale that keeps a 12-hour clock, with its own marker. + expect(DateUtils.getFormattedCancellationDate('2026-04-19T15:00:00+07:00', el)).toBe('Κυριακή, Απρ 19, 2026 3:00 μ.μ., GMT+7'); + }); }); describe('getRemainingSecondsInWindow', () => { From 2809511cb1951259c01d536857c2f894934709f0 Mon Sep 17 00:00:00 2001 From: Shubham Agrawal Date: Thu, 17 Sep 2026 12:23:01 +0530 Subject: [PATCH 4/4] Spell --- tests/unit/DateUtilsTest.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/DateUtilsTest.ts b/tests/unit/DateUtilsTest.ts index aab0c6f390d1..edcb532ce5cb 100644 --- a/tests/unit/DateUtilsTest.ts +++ b/tests/unit/DateUtilsTest.ts @@ -1,3 +1,5 @@ +// cspell:ignore Montag Dienstag Freitag Sonntag März Τρίτη Κυριακή -- German and Greek weekday and month +// names, asserted verbatim so the locale-driven formatters are covered rather than only the English path. import type {LocaleContextProps} from '@components/LocaleContextProvider'; import DateUtils from '@libs/DateUtils';