diff --git a/src/common/datetime/format_date.ts b/src/common/datetime/format_date.ts index d09c408924b1..02172985caa5 100644 --- a/src/common/datetime/format_date.ts +++ b/src/common/datetime/format_date.ts @@ -1,59 +1,88 @@ import type { HassConfig } from "home-assistant-js-websocket"; -import memoizeOne from "memoize-one"; +import { format } from "date-fns"; +import { TZDate } from "@date-fns/tz"; import type { FrontendLocaleData } from "../../data/translation"; import { DateFormat } from "../../data/translation"; import { resolveTimeZone } from "./resolve-time-zone"; +// Helper to get date in target timezone +const toTimeZone = (date: Date, timeZone: string): Date => { + try { + return new TZDate(date, timeZone); + } catch { + return date; + } +}; + +// Helper to get format string based on date preference +const formatForDatePreference = ( + template: { DMY: string; MDY: string; YMD: string }, + locale: FrontendLocaleData +): string => { + if ( + locale.date_format === DateFormat.language || + locale.date_format === DateFormat.system + ) { + return template.MDY; // Default to MDY for browser locale + } + + const pattern = + template[locale.date_format as unknown as keyof typeof template]; + return pattern || template.MDY; // Fallback to MDY if not found +}; + // Tuesday, August 10 export const formatDateWeekdayDay = ( dateObj: Date, locale: FrontendLocaleData, config: HassConfig -) => formatDateWeekdayDayMem(locale, config.time_zone).format(dateObj); - -const formatDateWeekdayDayMem = memoizeOne( - (locale: FrontendLocaleData, serverTimeZone: string) => - new Intl.DateTimeFormat(locale.language, { - weekday: "long", - month: "long", - day: "numeric", - timeZone: resolveTimeZone(locale.time_zone, serverTimeZone), - }) -); +) => { + const timeZone = resolveTimeZone(locale.time_zone, config.time_zone); + const zonedDate = toTimeZone(dateObj, timeZone); + const pattern = formatForDatePreference( + { DMY: "EEEE, d MMMM", MDY: "EEEE, MMMM d", YMD: "EEEE, MMMM d" }, + locale + ); + return format(zonedDate, pattern); +}; // August 10, 2021 export const formatDate = ( dateObj: Date, locale: FrontendLocaleData, config: HassConfig -) => formatDateMem(locale, config.time_zone).format(dateObj); - -const formatDateMem = memoizeOne( - (locale: FrontendLocaleData, serverTimeZone: string) => - new Intl.DateTimeFormat(locale.language, { - year: "numeric", - month: "long", - day: "numeric", - timeZone: resolveTimeZone(locale.time_zone, serverTimeZone), - }) -); +) => { + const timeZone = resolveTimeZone(locale.time_zone, config.time_zone); + const zonedDate = toTimeZone(dateObj, timeZone); + const pattern = formatForDatePreference( + { + DMY: "d MMMM, yyyy", + MDY: "MMMM d, yyyy", + YMD: "yyyy, MMMM d", + }, + locale + ); + return format(zonedDate, pattern); +}; // Aug 10, 2021 export const formatDateShort = ( dateObj: Date, locale: FrontendLocaleData, config: HassConfig -) => formatDateShortMem(locale, config.time_zone).format(dateObj); - -const formatDateShortMem = memoizeOne( - (locale: FrontendLocaleData, serverTimeZone: string) => - new Intl.DateTimeFormat(locale.language, { - year: "numeric", - month: "short", - day: "numeric", - timeZone: resolveTimeZone(locale.time_zone, serverTimeZone), - }) -); +) => { + const timeZone = resolveTimeZone(locale.time_zone, config.time_zone); + const zonedDate = toTimeZone(dateObj, timeZone); + const pattern = formatForDatePreference( + { + DMY: "d MMM, yyyy", + MDY: "MMM d, yyyy", + YMD: "yyyy, MMM d", + }, + locale + ); + return format(zonedDate, pattern); +}; // 10/08/2021 export const formatDateNumeric = ( @@ -61,7 +90,7 @@ export const formatDateNumeric = ( locale: FrontendLocaleData, config: HassConfig ) => { - const formatter = formatDateNumericMem(locale, config.time_zone); + const formatter = createDateNumericFormatter(locale, config.time_zone); if ( locale.date_format === DateFormat.language || @@ -93,179 +122,133 @@ export const formatDateNumeric = ( return formats[locale.date_format]; }; -const formatDateNumericMem = memoizeOne( - (locale: FrontendLocaleData, serverTimeZone: string) => { - const localeString = - locale.date_format === DateFormat.system ? undefined : locale.language; - - if ( - locale.date_format === DateFormat.language || - locale.date_format === DateFormat.system - ) { - return new Intl.DateTimeFormat(localeString, { - year: "numeric", - month: "numeric", - day: "numeric", - timeZone: resolveTimeZone(locale.time_zone, serverTimeZone), - }); - } - - return new Intl.DateTimeFormat(localeString, { - year: "numeric", - month: "numeric", - day: "numeric", - timeZone: resolveTimeZone(locale.time_zone, serverTimeZone), - }); - } -); +const createDateNumericFormatter = ( + locale: FrontendLocaleData, + serverTimeZone: string +) => { + const localeString = + locale.date_format === DateFormat.system ? undefined : locale.language; + return new Intl.DateTimeFormat(localeString, { + year: "numeric", + month: "numeric", + day: "numeric", + timeZone: resolveTimeZone(locale.time_zone, serverTimeZone), + }); +}; // Aug 10 export const formatDateVeryShort = ( dateObj: Date, locale: FrontendLocaleData, config: HassConfig -) => formatDateVeryShortMem(locale, config.time_zone).format(dateObj); - -const formatDateVeryShortMem = memoizeOne( - (locale: FrontendLocaleData, serverTimeZone: string) => - new Intl.DateTimeFormat(locale.language, { - day: "numeric", - month: "short", - timeZone: resolveTimeZone(locale.time_zone, serverTimeZone), - }) -); +) => { + const timeZone = resolveTimeZone(locale.time_zone, config.time_zone); + const zonedDate = toTimeZone(dateObj, timeZone); + const pattern = formatForDatePreference( + { DMY: "d MMM", MDY: "MMM d", YMD: "MMM d" }, + locale + ); + return format(zonedDate, pattern); +}; // August 2021 export const formatDateMonthYear = ( dateObj: Date, locale: FrontendLocaleData, config: HassConfig -) => formatDateMonthYearMem(locale, config.time_zone).format(dateObj); - -const formatDateMonthYearMem = memoizeOne( - (locale: FrontendLocaleData, serverTimeZone: string) => - new Intl.DateTimeFormat(locale.language, { - month: "long", - year: "numeric", - timeZone: resolveTimeZone(locale.time_zone, serverTimeZone), - }) -); +) => { + const timeZone = resolveTimeZone(locale.time_zone, config.time_zone); + const zonedDate = toTimeZone(dateObj, timeZone); + const pattern = formatForDatePreference( + { + DMY: "MMMM yyyy", + MDY: "MMMM yyyy", + YMD: "yyyy MMMM", + }, + locale + ); + return format(zonedDate, pattern); +}; // August export const formatDateMonth = ( dateObj: Date, locale: FrontendLocaleData, config: HassConfig -) => formatDateMonthMem(locale, config.time_zone).format(dateObj); - -const formatDateMonthMem = memoizeOne( - (locale: FrontendLocaleData, serverTimeZone: string) => - new Intl.DateTimeFormat(locale.language, { - month: "long", - timeZone: resolveTimeZone(locale.time_zone, serverTimeZone), - }) -); +) => { + const timeZone = resolveTimeZone(locale.time_zone, config.time_zone); + const zonedDate = toTimeZone(dateObj, timeZone); + return format(zonedDate, "MMMM"); +}; // Aug export const formatDateMonthShort = ( dateObj: Date, locale: FrontendLocaleData, config: HassConfig -) => formatDateMonthShortMem(locale, config.time_zone).format(dateObj); - -const formatDateMonthShortMem = memoizeOne( - (locale: FrontendLocaleData, serverTimeZone: string) => - new Intl.DateTimeFormat(locale.language, { - month: "short", - timeZone: resolveTimeZone(locale.time_zone, serverTimeZone), - }) -); +) => { + const timeZone = resolveTimeZone(locale.time_zone, config.time_zone); + const zonedDate = toTimeZone(dateObj, timeZone); + return format(zonedDate, "MMM"); +}; // 2021 export const formatDateYear = ( dateObj: Date, locale: FrontendLocaleData, config: HassConfig -) => formatDateYearMem(locale, config.time_zone).format(dateObj); - -const formatDateYearMem = memoizeOne( - (locale: FrontendLocaleData, serverTimeZone: string) => - new Intl.DateTimeFormat(locale.language, { - year: "numeric", - timeZone: resolveTimeZone(locale.time_zone, serverTimeZone), - }) -); +) => { + const timeZone = resolveTimeZone(locale.time_zone, config.time_zone); + const zonedDate = toTimeZone(dateObj, timeZone); + return format(zonedDate, "yyyy"); +}; // Monday export const formatDateWeekday = ( dateObj: Date, locale: FrontendLocaleData, config: HassConfig -) => formatDateWeekdayMem(locale, config.time_zone).format(dateObj); - -const formatDateWeekdayMem = memoizeOne( - (locale: FrontendLocaleData, serverTimeZone: string) => - new Intl.DateTimeFormat(locale.language, { - weekday: "long", - timeZone: resolveTimeZone(locale.time_zone, serverTimeZone), - }) -); +) => { + const timeZone = resolveTimeZone(locale.time_zone, config.time_zone); + const zonedDate = toTimeZone(dateObj, timeZone); + return format(zonedDate, "EEEE"); +}; // Mon export const formatDateWeekdayShort = ( dateObj: Date, locale: FrontendLocaleData, config: HassConfig -) => formatDateWeekdayShortMem(locale, config.time_zone).format(dateObj); - -const formatDateWeekdayShortMem = memoizeOne( - (locale: FrontendLocaleData, serverTimeZone: string) => - new Intl.DateTimeFormat(locale.language, { - weekday: "short", - timeZone: resolveTimeZone(locale.time_zone, serverTimeZone), - }) -); +) => { + const timeZone = resolveTimeZone(locale.time_zone, config.time_zone); + const zonedDate = toTimeZone(dateObj, timeZone); + return format(zonedDate, "EEE"); +}; // Mon, Aug 10 export const formatDateWeekdayVeryShortDate = ( dateObj: Date, locale: FrontendLocaleData, config: HassConfig -) => - formatDateWeekdayVeryShortDateMem(locale, config.time_zone).format(dateObj); - -const formatDateWeekdayVeryShortDateMem = memoizeOne( - (locale: FrontendLocaleData, serverTimeZone: string) => - new Intl.DateTimeFormat(locale.language, { - weekday: "short", - month: "short", - day: "numeric", - timeZone: resolveTimeZone(locale.time_zone, serverTimeZone), - }) -); +) => { + const timeZone = resolveTimeZone(locale.time_zone, config.time_zone); + const zonedDate = toTimeZone(dateObj, timeZone); + return format(zonedDate, "EEE, MMM d"); +}; // Mon, Aug 10, 2021 export const formatDateWeekdayShortDate = ( dateObj: Date, locale: FrontendLocaleData, config: HassConfig -) => formatDateWeekdayShortDateMem(locale, config.time_zone).format(dateObj); - -const formatDateWeekdayShortDateMem = memoizeOne( - (locale: FrontendLocaleData, serverTimeZone: string) => - new Intl.DateTimeFormat(locale.language, { - weekday: "short", - month: "short", - day: "numeric", - year: "numeric", - timeZone: resolveTimeZone(locale.time_zone, serverTimeZone), - }) -); +) => { + const timeZone = resolveTimeZone(locale.time_zone, config.time_zone); + const zonedDate = toTimeZone(dateObj, timeZone); + return format(zonedDate, "EEE, MMM d, yyyy"); +}; /** - * Format a date as YYYY-MM-DD. Uses "en-CA" because it's the only - * Intl locale that natively outputs ISO 8601 date format. - * Locale/config are only used to resolve the time zone. + * Format a date as YYYY-MM-DD */ export const formatISODateOnly = ( dateObj: Date, @@ -273,13 +256,8 @@ export const formatISODateOnly = ( config: HassConfig ) => { const timeZone = resolveTimeZone(locale.time_zone, config.time_zone); - const formatter = new Intl.DateTimeFormat("en-CA", { - year: "numeric", - month: "2-digit", - day: "2-digit", - timeZone, - }); - return formatter.format(dateObj); + const zonedDate = toTimeZone(dateObj, timeZone); + return format(zonedDate, "yyyy-MM-dd"); }; // 2026-08-10/2026-08-15 diff --git a/src/common/datetime/format_date_time.ts b/src/common/datetime/format_date_time.ts index b0d6a5c68e3f..f8cb8c60d338 100644 --- a/src/common/datetime/format_date_time.ts +++ b/src/common/datetime/format_date_time.ts @@ -1,83 +1,106 @@ import type { HassConfig } from "home-assistant-js-websocket"; -import memoizeOne from "memoize-one"; -import type { FrontendLocaleData } from "../../data/translation"; +import { format } from "date-fns"; +import { TZDate } from "@date-fns/tz"; +import { DateFormat, type FrontendLocaleData } from "../../data/translation"; import { formatDateNumeric } from "./format_date"; import { formatTime } from "./format_time"; import { resolveTimeZone } from "./resolve-time-zone"; import { useAmPm } from "./use_am_pm"; +// Helper to get date in target timezone +const toTimeZone = (date: Date, timeZone: string): Date => { + try { + return new TZDate(date, timeZone); + } catch { + return date; + } +}; + +// Helper to get format string based on date preference +const formatForDatePreference = ( + template: { DMY: string; MDY: string; YMD: string }, + locale: FrontendLocaleData +): string => { + if ( + locale.date_format === DateFormat.language || + locale.date_format === DateFormat.system + ) { + return template.MDY; // Default to MDY for browser locale + } + + const pattern = + template[locale.date_format as unknown as keyof typeof template]; + return pattern || template.MDY; // Fallback to MDY if not found +}; + // August 9, 2021, 8:23 AM export const formatDateTime = ( dateObj: Date, locale: FrontendLocaleData, config: HassConfig -) => formatDateTimeMem(locale, config.time_zone).format(dateObj); - -const formatDateTimeMem = memoizeOne( - (locale: FrontendLocaleData, serverTimeZone: string) => - new Intl.DateTimeFormat(locale.language, { - year: "numeric", - month: "long", - day: "numeric", - hour: useAmPm(locale) ? "numeric" : "2-digit", - minute: "2-digit", - hourCycle: useAmPm(locale) ? "h12" : "h23", - timeZone: resolveTimeZone(locale.time_zone, serverTimeZone), - }) -); +) => { + const timeZone = resolveTimeZone(locale.time_zone, config.time_zone); + const zonedDate = toTimeZone(dateObj, timeZone); + const isAmPm = useAmPm(locale); + const pattern = formatForDatePreference( + { + DMY: `d MMMM, yyyy 'at' ${isAmPm ? "h:mm a" : "HH:mm"}`, + MDY: `MMMM d, yyyy 'at' ${isAmPm ? "h:mm a" : "HH:mm"}`, + YMD: `yyyy, MMMM d 'at' ${isAmPm ? "h:mm a" : "HH:mm"}`, + }, + locale + ); + return format(zonedDate, pattern); +}; export const formatDateTimeWithBrowserDefaults = (dateObj: Date) => - formatDateTimeWithBrowserDefaultsMem().format(dateObj); - -const formatDateTimeWithBrowserDefaultsMem = memoizeOne( - () => - new Intl.DateTimeFormat(undefined, { - year: "numeric", - month: "long", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }) -); + new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(dateObj); // Aug 9, 2021, 8:23 AM export const formatShortDateTimeWithYear = ( dateObj: Date, locale: FrontendLocaleData, config: HassConfig -) => formatShortDateTimeWithYearMem(locale, config.time_zone).format(dateObj); - -const formatShortDateTimeWithYearMem = memoizeOne( - (locale: FrontendLocaleData, serverTimeZone: string) => - new Intl.DateTimeFormat(locale.language, { - year: "numeric", - month: "short", - day: "numeric", - hour: useAmPm(locale) ? "numeric" : "2-digit", - minute: "2-digit", - hourCycle: useAmPm(locale) ? "h12" : "h23", - timeZone: resolveTimeZone(locale.time_zone, serverTimeZone), - }) -); +) => { + const timeZone = resolveTimeZone(locale.time_zone, config.time_zone); + const zonedDate = toTimeZone(dateObj, timeZone); + const isAmPm = useAmPm(locale); + const pattern = formatForDatePreference( + { + DMY: `d MMM, yyyy, ${isAmPm ? "h:mm a" : "HH:mm"}`, + MDY: `MMM d, yyyy, ${isAmPm ? "h:mm a" : "HH:mm"}`, + YMD: `yyyy, MMM d, ${isAmPm ? "h:mm a" : "HH:mm"}`, + }, + locale + ); + return format(zonedDate, pattern); +}; // Aug 9, 8:23 AM export const formatShortDateTime = ( dateObj: Date, locale: FrontendLocaleData, config: HassConfig -) => formatShortDateTimeMem(locale, config.time_zone).format(dateObj); - -const formatShortDateTimeMem = memoizeOne( - (locale: FrontendLocaleData, serverTimeZone: string) => - new Intl.DateTimeFormat(locale.language, { - month: "short", - day: "numeric", - hour: useAmPm(locale) ? "numeric" : "2-digit", - minute: "2-digit", - hourCycle: useAmPm(locale) ? "h12" : "h23", - timeZone: resolveTimeZone(locale.time_zone, serverTimeZone), - }) -); +) => { + const timeZone = resolveTimeZone(locale.time_zone, config.time_zone); + const zonedDate = toTimeZone(dateObj, timeZone); + const isAmPm = useAmPm(locale); + const pattern = formatForDatePreference( + { + DMY: `d MMM, ${isAmPm ? "h:mm a" : "HH:mm"}`, + MDY: `MMM d, ${isAmPm ? "h:mm a" : "HH:mm"}`, + YMD: `MMM d, ${isAmPm ? "h:mm a" : "HH:mm"}`, + }, + locale + ); + return format(zonedDate, pattern); +}; export const formatShortDateTimeWithConditionalYear = ( dateObj: Date, @@ -96,21 +119,20 @@ export const formatDateTimeWithSeconds = ( dateObj: Date, locale: FrontendLocaleData, config: HassConfig -) => formatDateTimeWithSecondsMem(locale, config.time_zone).format(dateObj); - -const formatDateTimeWithSecondsMem = memoizeOne( - (locale: FrontendLocaleData, serverTimeZone: string) => - new Intl.DateTimeFormat(locale.language, { - year: "numeric", - month: "long", - day: "numeric", - hour: useAmPm(locale) ? "numeric" : "2-digit", - minute: "2-digit", - second: "2-digit", - hourCycle: useAmPm(locale) ? "h12" : "h23", - timeZone: resolveTimeZone(locale.time_zone, serverTimeZone), - }) -); +) => { + const timeZone = resolveTimeZone(locale.time_zone, config.time_zone); + const zonedDate = toTimeZone(dateObj, timeZone); + const isAmPm = useAmPm(locale); + const pattern = formatForDatePreference( + { + DMY: `d MMMM, yyyy 'at' ${isAmPm ? "h:mm:ss a" : "HH:mm:ss"}`, + MDY: `MMMM d, yyyy 'at' ${isAmPm ? "h:mm:ss a" : "HH:mm:ss"}`, + YMD: `yyyy, MMMM d 'at' ${isAmPm ? "h:mm:ss a" : "HH:mm:ss"}`, + }, + locale + ); + return format(zonedDate, pattern); +}; // 9/8/2021, 8:23 AM export const formatDateTimeNumeric = ( diff --git a/test/common/datetime/absolute_time.test.ts b/test/common/datetime/absolute_time.test.ts index 1b6cb4b5fadc..adef32f67ff7 100644 --- a/test/common/datetime/absolute_time.test.ts +++ b/test/common/datetime/absolute_time.test.ts @@ -37,7 +37,9 @@ describe("absoluteTime", () => { }); it("should format date with year correctly", () => { - const from = new Date(2024, 1, 29, 13, 23); + const from = new Date(); + from.setUTCFullYear(2024, 1, 29); + from.setUTCHours(13, 23); const result = absoluteTime(from, locale, config); expect(result).toBe("Feb 29, 2024, 13:23"); }); diff --git a/test/common/entity/compute_state_display.test.ts b/test/common/entity/compute_state_display.test.ts index 8e3824349c3a..55a0e0c2d7fd 100644 --- a/test/common/entity/compute_state_display.test.ts +++ b/test/common/entity/compute_state_display.test.ts @@ -719,9 +719,14 @@ describe("computeStateDisplayFromEntityAttributes datetime device calss", () => (() => {}) as any, { language: "en", + number_format: NumberFormat.comma_decimal, + time_format: TimeFormat.twenty_four, + date_format: DateFormat.language, + time_zone: TimeZone.server, + first_weekday: FirstWeekday.language, } as FrontendLocaleData, [], - {} as HassConfig, + { ...demoConfig, time_zone: "UTC" }, undefined, "button.test", {},