diff --git a/src/common/datetime/format_date.ts b/src/common/datetime/format_date.ts index c7c02d38fe..fa82187a46 100644 --- a/src/common/datetime/format_date.ts +++ b/src/common/datetime/format_date.ts @@ -1,21 +1,32 @@ import { format } from "fecha"; -import { FrontendTranslationData } from "../../data/translation"; +import memoizeOne from "memoize-one"; +import { FrontendLocaleData } from "../../data/translation"; import { toLocaleDateStringSupportsOptions } from "./check_options_support"; +const formatDateMem = memoizeOne( + (locale: FrontendLocaleData) => + new Intl.DateTimeFormat(locale.language, { + year: "numeric", + month: "long", + day: "numeric", + }) +); + export const formatDate = toLocaleDateStringSupportsOptions - ? (dateObj: Date, locales: FrontendTranslationData) => - dateObj.toLocaleDateString(locales.language, { - year: "numeric", - month: "long", - day: "numeric", - }) + ? (dateObj: Date, locale: FrontendLocaleData) => + formatDateMem(locale).format(dateObj) : (dateObj: Date) => format(dateObj, "longDate"); +const formatDateWeekdayMem = memoizeOne( + (locale: FrontendLocaleData) => + new Intl.DateTimeFormat(locale.language, { + weekday: "long", + month: "long", + day: "numeric", + }) +); + export const formatDateWeekday = toLocaleDateStringSupportsOptions - ? (dateObj: Date, locales: FrontendTranslationData) => - dateObj.toLocaleDateString(locales.language, { - weekday: "long", - month: "short", - day: "numeric", - }) + ? (dateObj: Date, locale: FrontendLocaleData) => + formatDateWeekdayMem(locale).format(dateObj) : (dateObj: Date) => format(dateObj, "dddd, MMM D"); diff --git a/src/common/datetime/format_date_time.ts b/src/common/datetime/format_date_time.ts index d4b76a1da6..850e82b185 100644 --- a/src/common/datetime/format_date_time.ts +++ b/src/common/datetime/format_date_time.ts @@ -1,26 +1,42 @@ import { format } from "fecha"; -import { FrontendTranslationData } from "../../data/translation"; +import memoizeOne from "memoize-one"; +import { FrontendLocaleData } from "../../data/translation"; import { toLocaleStringSupportsOptions } from "./check_options_support"; +import { useAmPm } from "./use_am_pm"; + +const formatDateTimeMem = memoizeOne( + (locale: FrontendLocaleData) => + new Intl.DateTimeFormat(locale.language, { + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "2-digit", + hour12: useAmPm(locale), + }) +); export const formatDateTime = toLocaleStringSupportsOptions - ? (dateObj: Date, locales: FrontendTranslationData) => - dateObj.toLocaleString(locales.language, { - year: "numeric", - month: "long", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }) - : (dateObj: Date) => format(dateObj, "MMMM D, YYYY, HH:mm"); + ? (dateObj: Date, locale: FrontendLocaleData) => + formatDateTimeMem(locale).format(dateObj) + : (dateObj: Date, locale: FrontendLocaleData) => + format(dateObj, "MMMM D, YYYY, HH:mm" + useAmPm(locale) ? " A" : ""); + +const formatDateTimeWithSecondsMem = memoizeOne( + (locale: FrontendLocaleData) => + new Intl.DateTimeFormat(locale.language, { + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + hour12: useAmPm(locale), + }) +); export const formatDateTimeWithSeconds = toLocaleStringSupportsOptions - ? (dateObj: Date, locales: FrontendTranslationData) => - dateObj.toLocaleString(locales.language, { - year: "numeric", - month: "long", - day: "numeric", - hour: "numeric", - minute: "2-digit", - second: "2-digit", - }) - : (dateObj: Date) => format(dateObj, "MMMM D, YYYY, HH:mm:ss"); + ? (dateObj: Date, locale: FrontendLocaleData) => + formatDateTimeWithSecondsMem(locale).format(dateObj) + : (dateObj: Date, locale: FrontendLocaleData) => + format(dateObj, "MMMM D, YYYY, HH:mm:ss" + useAmPm(locale) ? " A" : ""); diff --git a/src/common/datetime/format_time.ts b/src/common/datetime/format_time.ts index d2068f7638..14bbdf8e53 100644 --- a/src/common/datetime/format_time.ts +++ b/src/common/datetime/format_time.ts @@ -1,29 +1,52 @@ import { format } from "fecha"; -import { FrontendTranslationData } from "../../data/translation"; +import memoizeOne from "memoize-one"; +import { FrontendLocaleData } from "../../data/translation"; import { toLocaleTimeStringSupportsOptions } from "./check_options_support"; +import { useAmPm } from "./use_am_pm"; + +const formatTimeMem = memoizeOne( + (locale: FrontendLocaleData) => + new Intl.DateTimeFormat(locale.language, { + hour: "numeric", + minute: "2-digit", + hour12: useAmPm(locale), + }) +); export const formatTime = toLocaleTimeStringSupportsOptions - ? (dateObj: Date, locales: FrontendTranslationData) => - dateObj.toLocaleTimeString(locales.language, { - hour: "numeric", - minute: "2-digit", - }) - : (dateObj: Date) => format(dateObj, "shortTime"); + ? (dateObj: Date, locale: FrontendLocaleData) => + formatTimeMem(locale).format(dateObj) + : (dateObj: Date, locale: FrontendLocaleData) => + format(dateObj, "shortTime" + useAmPm(locale) ? " A" : ""); + +const formatTimeWithSecondsMem = memoizeOne( + (locale: FrontendLocaleData) => + new Intl.DateTimeFormat(locale.language, { + hour: "numeric", + minute: "2-digit", + second: "2-digit", + hour12: useAmPm(locale), + }) +); export const formatTimeWithSeconds = toLocaleTimeStringSupportsOptions - ? (dateObj: Date, locales: FrontendTranslationData) => - dateObj.toLocaleTimeString(locales.language, { - hour: "numeric", - minute: "2-digit", - second: "2-digit", - }) - : (dateObj: Date) => format(dateObj, "mediumTime"); + ? (dateObj: Date, locale: FrontendLocaleData) => + formatTimeWithSecondsMem(locale).format(dateObj) + : (dateObj: Date, locale: FrontendLocaleData) => + format(dateObj, "mediumTime" + useAmPm(locale) ? " A" : ""); + +const formatTimeWeekdayMem = memoizeOne( + (locale: FrontendLocaleData) => + new Intl.DateTimeFormat(locale.language, { + weekday: "long", + hour: "numeric", + minute: "2-digit", + hour12: useAmPm(locale), + }) +); export const formatTimeWeekday = toLocaleTimeStringSupportsOptions - ? (dateObj: Date, locales: FrontendTranslationData) => - dateObj.toLocaleTimeString(locales.language, { - weekday: "long", - hour: "numeric", - minute: "2-digit", - }) - : (dateObj: Date) => format(dateObj, "dddd, HH:mm"); + ? (dateObj: Date, locale: FrontendLocaleData) => + formatTimeWeekdayMem(locale).format(dateObj) + : (dateObj: Date, locale: FrontendLocaleData) => + format(dateObj, "dddd, HH:mm" + useAmPm(locale) ? " A" : ""); diff --git a/src/common/datetime/use_am_pm.ts b/src/common/datetime/use_am_pm.ts new file mode 100644 index 0000000000..cf5b2c124e --- /dev/null +++ b/src/common/datetime/use_am_pm.ts @@ -0,0 +1,15 @@ +import { FrontendLocaleData, TimeFormat } from "../../data/translation"; + +export const useAmPm = (locale: FrontendLocaleData): boolean => { + if ( + locale.time_format === TimeFormat.language || + locale.time_format === TimeFormat.system + ) { + const testLanguage = + locale.time_format === TimeFormat.language ? locale.language : undefined; + const test = new Date().toLocaleString(testLanguage); + return test.includes("AM") || test.includes("PM"); + } + + return locale.time_format === TimeFormat.am_pm; +}; diff --git a/src/common/entity/compute_state_display.ts b/src/common/entity/compute_state_display.ts index d4e77ce4e9..8165f6daf7 100644 --- a/src/common/entity/compute_state_display.ts +++ b/src/common/entity/compute_state_display.ts @@ -1,6 +1,6 @@ import { HassEntity } from "home-assistant-js-websocket"; import { UNAVAILABLE, UNKNOWN } from "../../data/entity"; -import { FrontendTranslationData } from "../../data/translation"; +import { FrontendLocaleData } from "../../data/translation"; import { formatDate } from "../datetime/format_date"; import { formatDateTime } from "../datetime/format_date_time"; import { formatTime } from "../datetime/format_time"; @@ -11,7 +11,7 @@ import { computeStateDomain } from "./compute_state_domain"; export const computeStateDisplay = ( localize: LocalizeFunc, stateObj: HassEntity, - locale: FrontendTranslationData, + locale: FrontendLocaleData, state?: string ): string => { const compareState = state !== undefined ? state : stateObj.state; diff --git a/src/common/string/format_number.ts b/src/common/string/format_number.ts index da8af2798a..2cfa22458b 100644 --- a/src/common/string/format_number.ts +++ b/src/common/string/format_number.ts @@ -1,4 +1,4 @@ -import { FrontendTranslationData, NumberFormat } from "../../data/translation"; +import { FrontendLocaleData, NumberFormat } from "../../data/translation"; /** * Formats a number based on the user's preference with thousands separator(s) and decimal character for better legibility. @@ -9,7 +9,7 @@ import { FrontendTranslationData, NumberFormat } from "../../data/translation"; */ export const formatNumber = ( num: string | number, - locale?: FrontendTranslationData, + locale?: FrontendLocaleData, options?: Intl.NumberFormatOptions ): string => { let format: string | string[] | undefined; diff --git a/src/components/ha-date-range-picker.ts b/src/components/ha-date-range-picker.ts index 15d8df0d79..2940c3a883 100644 --- a/src/components/ha-date-range-picker.ts +++ b/src/components/ha-date-range-picker.ts @@ -14,6 +14,7 @@ import { } from "lit"; import { customElement, property } from "lit/decorators"; import { formatDateTime } from "../common/datetime/format_date_time"; +import { useAmPm } from "../common/datetime/use_am_pm"; import { computeRTLDirection } from "../common/util/compute_rtl"; import { HomeAssistant } from "../types"; import "./date-range-picker"; @@ -43,7 +44,7 @@ export class HaDateRangePicker extends LitElement { if (changedProps.has("hass")) { const oldHass = changedProps.get("hass") as HomeAssistant | undefined; if (!oldHass || oldHass.locale !== this.hass.locale) { - this._hour24format = this._compute24hourFormat(); + this._hour24format = !useAmPm(this.hass.locale); this._rtlDirection = computeRTLDirection(this.hass); } } @@ -106,16 +107,6 @@ export class HaDateRangePicker extends LitElement { `; } - private _compute24hourFormat() { - return ( - new Intl.DateTimeFormat(this.hass.language, { - hour: "numeric", - }) - .formatToParts(new Date(2020, 0, 1, 13)) - .find((part) => part.type === "hour")!.value.length === 2 - ); - } - private _setDateRange(ev: CustomEvent) { const dateRange = Object.values(this.ranges!)[ev.detail.index]; const dateRangePicker = this._dateRangePicker; diff --git a/src/components/ha-gauge.ts b/src/components/ha-gauge.ts index 19fb9a4572..26b4ae52aa 100644 --- a/src/components/ha-gauge.ts +++ b/src/components/ha-gauge.ts @@ -4,7 +4,7 @@ import { ifDefined } from "lit/directives/if-defined"; import { styleMap } from "lit/directives/style-map"; import { formatNumber } from "../common/string/format_number"; import { afterNextRender } from "../common/util/render-status"; -import { FrontendTranslationData } from "../data/translation"; +import { FrontendLocaleData } from "../data/translation"; import { getValueInPercentage, normalize } from "../util/calculate"; const getAngle = (value: number, min: number, max: number) => { @@ -23,7 +23,7 @@ export class Gauge extends LitElement { @property({ type: Number }) public value = 0; - @property() public locale!: FrontendTranslationData; + @property() public locale!: FrontendLocaleData; @property() public label = ""; diff --git a/src/components/ha-selector/ha-selector-time.ts b/src/components/ha-selector/ha-selector-time.ts index 48b57def22..84ab97c8da 100644 --- a/src/components/ha-selector/ha-selector-time.ts +++ b/src/components/ha-selector/ha-selector-time.ts @@ -1,10 +1,13 @@ import { html, LitElement } from "lit"; import { customElement, property } from "lit/decorators"; import memoizeOne from "memoize-one"; +import { useAmPm } from "../../common/datetime/use_am_pm"; import { fireEvent } from "../../common/dom/fire_event"; import { TimeSelector } from "../../data/selector"; +import { FrontendLocaleData } from "../../data/translation"; import { HomeAssistant } from "../../types"; import "../paper-time-input"; + @customElement("ha-selector-time") export class HaTimeSelector extends LitElement { @property() public hass!: HomeAssistant; @@ -17,13 +20,12 @@ export class HaTimeSelector extends LitElement { @property({ type: Boolean }) public disabled = false; - private _useAmPm = memoizeOne((language: string) => { - const test = new Date().toLocaleString(language); - return test.includes("AM") || test.includes("PM"); - }); + private _useAmPmMem = memoizeOne((locale: FrontendLocaleData): boolean => + useAmPm(locale) + ); protected render() { - const useAMPM = this._useAmPm(this.hass.locale.language); + const useAMPM = this._useAmPmMem(this.hass.locale); const parts = this.value?.split(":") || []; const hours = parts[0]; @@ -48,7 +50,7 @@ export class HaTimeSelector extends LitElement { private _timeChanged(ev) { let value = ev.target.value; - const useAMPM = this._useAmPm(this.hass.locale.language); + const useAMPM = this._useAmPmMem(this.hass.locale); let hours = Number(ev.target.hour || 0); if (value && useAMPM) { if (ev.target.amPm === "PM") { diff --git a/src/data/history.ts b/src/data/history.ts index 92c4a2caf8..636698caf7 100644 --- a/src/data/history.ts +++ b/src/data/history.ts @@ -4,7 +4,7 @@ import { computeStateDomain } from "../common/entity/compute_state_domain"; import { computeStateName } from "../common/entity/compute_state_name"; import { LocalizeFunc } from "../common/translations/localize"; import { HomeAssistant } from "../types"; -import { FrontendTranslationData } from "./translation"; +import { FrontendLocaleData } from "./translation"; const DOMAINS_USE_LAST_UPDATED = ["climate", "humidifier", "water_heater"]; const LINE_ATTRIBUTES_TO_KEEP = [ @@ -109,7 +109,7 @@ const equalState = (obj1: LineChartState, obj2: LineChartState) => const processTimelineEntity = ( localize: LocalizeFunc, - language: FrontendTranslationData, + language: FrontendLocaleData, states: HassEntity[] ): TimelineEntity => { const data: TimelineState[] = []; diff --git a/src/data/translation.ts b/src/data/translation.ts index d2fc74e560..65f98a059d 100644 --- a/src/data/translation.ts +++ b/src/data/translation.ts @@ -10,14 +10,22 @@ export enum NumberFormat { none = "none", } -export interface FrontendTranslationData { +export enum TimeFormat { + language = "language", + system = "system", + am_pm = "12", + twenty_four = "24", +} + +export interface FrontendLocaleData { language: string; number_format: NumberFormat; + time_format: TimeFormat; } declare global { interface FrontendUserData { - language: FrontendTranslationData; + language: FrontendLocaleData; } } @@ -36,7 +44,7 @@ export const fetchTranslationPreferences = (hass: HomeAssistant) => export const saveTranslationPreferences = ( hass: HomeAssistant, - data: FrontendTranslationData + data: FrontendLocaleData ) => saveFrontendUserData(hass.connection, "language", data); export const getHassTranslations = async ( diff --git a/src/fake_data/provide_hass.ts b/src/fake_data/provide_hass.ts index 6249f441f5..d537ffa80e 100644 --- a/src/fake_data/provide_hass.ts +++ b/src/fake_data/provide_hass.ts @@ -5,7 +5,7 @@ import { } from "../common/dom/apply_themes_on_element"; import { computeLocalize } from "../common/translations/localize"; import { DEFAULT_PANEL } from "../data/panel"; -import { NumberFormat } from "../data/translation"; +import { NumberFormat, TimeFormat } from "../data/translation"; import { translationMetadata } from "../resources/translations-metadata"; import { HomeAssistant } from "../types"; import { getLocalLanguage, getTranslation } from "../util/hass-translation"; @@ -215,6 +215,7 @@ export const provideHass = ( locale: { language: localLanguage, number_format: NumberFormat.language, + time_format: TimeFormat.language, }, resources: null as any, localize: () => "", diff --git a/src/panels/config/logs/util.ts b/src/panels/config/logs/util.ts index ebe74c2e06..3eb92fcd96 100644 --- a/src/panels/config/logs/util.ts +++ b/src/panels/config/logs/util.ts @@ -1,8 +1,8 @@ import { formatDateTimeWithSeconds } from "../../../common/datetime/format_date_time"; import { formatTimeWithSeconds } from "../../../common/datetime/format_time"; -import { FrontendTranslationData } from "../../../data/translation"; +import { FrontendLocaleData } from "../../../data/translation"; -export const formatSystemLogTime = (date, locale: FrontendTranslationData) => { +export const formatSystemLogTime = (date, locale: FrontendLocaleData) => { const today = new Date().setHours(0, 0, 0, 0); const dateTime = new Date(date * 1000); const dateTimeDay = new Date(date * 1000).setHours(0, 0, 0, 0); diff --git a/src/panels/lovelace/components/hui-timestamp-display.ts b/src/panels/lovelace/components/hui-timestamp-display.ts index 40b9abf17b..086ba6b13d 100644 --- a/src/panels/lovelace/components/hui-timestamp-display.ts +++ b/src/panels/lovelace/components/hui-timestamp-display.ts @@ -4,12 +4,12 @@ import { formatDate } from "../../../common/datetime/format_date"; import { formatDateTime } from "../../../common/datetime/format_date_time"; import { formatTime } from "../../../common/datetime/format_time"; import relativeTime from "../../../common/datetime/relative_time"; -import { FrontendTranslationData } from "../../../data/translation"; +import { FrontendLocaleData } from "../../../data/translation"; import { HomeAssistant } from "../../../types"; import { TimestampRenderingFormats } from "./types"; const FORMATS: { - [key: string]: (ts: Date, lang: FrontendTranslationData) => string; + [key: string]: (ts: Date, lang: FrontendLocaleData) => string; } = { date: formatDate, datetime: formatDateTime, diff --git a/src/panels/lovelace/types.ts b/src/panels/lovelace/types.ts index f9763be955..070c5ef761 100644 --- a/src/panels/lovelace/types.ts +++ b/src/panels/lovelace/types.ts @@ -3,7 +3,7 @@ import { LovelaceCardConfig, LovelaceConfig, } from "../../data/lovelace"; -import { FrontendTranslationData } from "../../data/translation"; +import { FrontendLocaleData } from "../../data/translation"; import { Constructor, HomeAssistant } from "../../types"; import { LovelaceRow, LovelaceRowConfig } from "./entity-rows/types"; import { LovelaceHeaderFooterConfig } from "./header-footer/types"; @@ -23,7 +23,7 @@ export interface Lovelace { editMode: boolean; urlPath: string | null; mode: "generated" | "yaml" | "storage"; - locale: FrontendTranslationData; + locale: FrontendLocaleData; enableFullEditMode: () => void; setEditMode: (editMode: boolean) => void; saveConfig: (newConfig: LovelaceConfig) => Promise; diff --git a/src/panels/profile/ha-panel-profile.ts b/src/panels/profile/ha-panel-profile.ts index 7484c4ba3b..4d9b82c42c 100644 --- a/src/panels/profile/ha-panel-profile.ts +++ b/src/panels/profile/ha-panel-profile.ts @@ -29,6 +29,7 @@ import "./ha-pick-dashboard-row"; import "./ha-pick-language-row"; import "./ha-pick-number-format-row"; import "./ha-pick-theme-row"; +import "./ha-pick-time-format-row"; import "./ha-push-notifications-row"; import "./ha-refresh-tokens-card"; import "./ha-set-suspend-row"; @@ -96,6 +97,10 @@ class HaPanelProfile extends LitElement { .narrow=${this.narrow} .hass=${this.hass} > + ${Object.values(NumberFormat).map((format) => { const formattedNumber = formatNumber(1234567.89, { - language: this.hass.locale.language, + ...this.hass.locale, number_format: format, }); const value = this.hass.localize( @@ -48,7 +48,7 @@ class NumberFormatRow extends LitElement { ); const twoLine = value.slice(value.length - 2) !== "89"; // Display explicit number formats on one line return html` - +
${value}
${twoLine diff --git a/src/panels/profile/ha-pick-time-format-row.ts b/src/panels/profile/ha-pick-time-format-row.ts new file mode 100644 index 0000000000..686c186d17 --- /dev/null +++ b/src/panels/profile/ha-pick-time-format-row.ts @@ -0,0 +1,72 @@ +import "@polymer/paper-item/paper-item"; +import "@polymer/paper-listbox/paper-listbox"; +import { html, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators"; +import { formatTime } from "../../common/datetime/format_time"; +import { fireEvent } from "../../common/dom/fire_event"; +import "../../components/ha-card"; +import "../../components/ha-paper-dropdown-menu"; +import "../../components/ha-settings-row"; +import { TimeFormat } from "../../data/translation"; +import { HomeAssistant } from "../../types"; + +@customElement("ha-pick-time-format-row") +class TimeFormatRow extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public narrow!: boolean; + + protected render(): TemplateResult { + const date = new Date(); + return html` + + + ${this.hass.localize("ui.panel.profile.time_format.header")} + + + ${this.hass.localize("ui.panel.profile.time_format.description")} + + + + ${Object.values(TimeFormat).map((format) => { + const formattedTime = formatTime(date, { + ...this.hass.locale, + time_format: format, + }); + const value = this.hass.localize( + `ui.panel.profile.time_format.formats.${format}` + ); + return html` + +
${value}
+
${formattedTime}
+
+
`; + })} +
+
+
+ `; + } + + private async _handleFormatSelection(ev: CustomEvent) { + fireEvent(this, "hass-time-format-select", ev.detail.item.format); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-pick-time-format-row": TimeFormatRow; + } +} diff --git a/src/state/connection-mixin.ts b/src/state/connection-mixin.ts index 86ea836210..7b54f8ffbb 100644 --- a/src/state/connection-mixin.ts +++ b/src/state/connection-mixin.ts @@ -15,7 +15,7 @@ import { subscribeFrontendUserData } from "../data/frontend"; import { forwardHaptic } from "../data/haptics"; import { DEFAULT_PANEL } from "../data/panel"; import { serviceCallWillDisconnect } from "../data/service"; -import { NumberFormat } from "../data/translation"; +import { NumberFormat, TimeFormat } from "../data/translation"; import { subscribePanels } from "../data/ws-panels"; import { translationMetadata } from "../resources/translations-metadata"; import { Constructor, ServiceCallResponse } from "../types"; @@ -49,6 +49,7 @@ export const connectionMixin = >( locale: { language, number_format: NumberFormat.language, + time_format: TimeFormat.language, }, resources: null as any, localize: () => "", diff --git a/src/state/translations-mixin.ts b/src/state/translations-mixin.ts index f22d4149c1..7e0ee332f2 100644 --- a/src/state/translations-mixin.ts +++ b/src/state/translations-mixin.ts @@ -7,6 +7,7 @@ import { getHassTranslationsPre109, NumberFormat, saveTranslationPreferences, + TimeFormat, TranslationCategory, } from "../data/translation"; import { translationMetadata } from "../resources/translations-metadata"; @@ -28,6 +29,9 @@ declare global { "hass-number-format-select": { number_format: NumberFormat; }; + "hass-time-format-select": { + time_format: TimeFormat; + }; } } @@ -64,6 +68,9 @@ export default >(superClass: T) => this.addEventListener("hass-number-format-select", (e) => { this._selectNumberFormat((e as CustomEvent).detail, true); }); + this.addEventListener("hass-time-format-select", (e) => { + this._selectTimeFormat((e as CustomEvent).detail, true); + }); this._loadCoreTranslations(getLocalLanguage()); } @@ -95,6 +102,13 @@ export default >(superClass: T) => // We just got number_format from backend, no need to save back this._selectNumberFormat(locale.number_format, false); } + if ( + locale?.time_format && + this.hass!.locale.time_format !== locale.time_format + ) { + // We just got time_format from backend, no need to save back + this._selectTimeFormat(locale.time_format, false); + } }); this.hass!.connection.subscribeEvents( @@ -133,6 +147,15 @@ export default >(superClass: T) => } } + private _selectTimeFormat(time_format: TimeFormat, saveToBackend: boolean) { + this._updateHass({ + locale: { ...this.hass!.locale, time_format: time_format }, + }); + if (saveToBackend) { + saveTranslationPreferences(this.hass!, this.hass!.locale); + } + } + private _selectLanguage(language: string, saveToBackend: boolean) { if (!this.hass) { // should not happen, do it to avoid use this.hass! diff --git a/src/translations/en.json b/src/translations/en.json index 670cff6250..739a5e9b83 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -3213,6 +3213,17 @@ "none": "None" } }, + "time_format": { + "header": "Time Format", + "dropdown_label": "Time format", + "description": "Choose how times are formatted.", + "formats": { + "language": "Auto (use language setting)", + "system": "Use system locale", + "12": "12 hours (AM/PM)", + "24": "24 hours" + } + }, "themes": { "header": "Theme", "error_no_theme": "No themes available.", diff --git a/src/types.ts b/src/types.ts index 102cdd31c1..7d9f88bf6c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,10 +9,7 @@ import { } from "home-assistant-js-websocket"; import { LocalizeFunc } from "./common/translations/localize"; import { CoreFrontendUserData } from "./data/frontend"; -import { - FrontendTranslationData, - getHassTranslations, -} from "./data/translation"; +import { FrontendLocaleData, getHassTranslations } from "./data/translation"; import { Themes } from "./data/ws-themes"; import { ExternalMessaging } from "./external_app/external_messaging"; @@ -198,14 +195,14 @@ export interface HomeAssistant { panelUrl: string; // i18n // current effective language in that order: - // - backend saved user selected lanugage - // - language in local appstorage + // - backend saved user selected language + // - language in local app storage // - browser language // - english (en) language: string; - // local stored language, keep that name for backward compability + // local stored language, keep that name for backward compatibility selectedLanguage: string | null; - locale: FrontendTranslationData; + locale: FrontendLocaleData; resources: Resources; localize: LocalizeFunc; translationMetadata: TranslationMetadata; diff --git a/src/util/hass-translation.ts b/src/util/hass-translation.ts index a203ec894e..fcf8767d03 100644 --- a/src/util/hass-translation.ts +++ b/src/util/hass-translation.ts @@ -1,6 +1,6 @@ import { fetchTranslationPreferences, - FrontendTranslationData, + FrontendLocaleData, } from "../data/translation"; import { translationMetadata } from "../resources/translations-metadata"; import { HomeAssistant } from "../types"; @@ -55,21 +55,24 @@ export function findAvailableLanguage(language: string) { */ export async function getUserLocale( hass: HomeAssistant -): Promise> { +): Promise> { const result = await fetchTranslationPreferences(hass); const language = result?.language; const number_format = result?.number_format; + const time_format = result?.time_format; if (language) { const availableLanguage = findAvailableLanguage(language); if (availableLanguage) { return { language: availableLanguage, number_format, + time_format, }; } } return { number_format, + time_format, }; } diff --git a/test-mocha/common/datetime/format_date.ts b/test-mocha/common/datetime/format_date.ts index 851c18f99d..f81a4d1a5d 100644 --- a/test-mocha/common/datetime/format_date.ts +++ b/test-mocha/common/datetime/format_date.ts @@ -1,7 +1,7 @@ import { assert } from "chai"; import { formatDate } from "../../../src/common/datetime/format_date"; -import { NumberFormat } from "../../../src/data/translation"; +import { NumberFormat, TimeFormat } from "../../../src/data/translation"; describe("formatDate", () => { const dateObj = new Date(2017, 10, 18, 11, 12, 13, 1400); @@ -11,6 +11,7 @@ describe("formatDate", () => { formatDate(dateObj, { language: "en", number_format: NumberFormat.language, + time_format: TimeFormat.language, }), "November 18, 2017" ); diff --git a/test-mocha/common/datetime/format_date_time.ts b/test-mocha/common/datetime/format_date_time.ts index beead0d442..cd3a116e3f 100644 --- a/test-mocha/common/datetime/format_date_time.ts +++ b/test-mocha/common/datetime/format_date_time.ts @@ -4,32 +4,50 @@ import { formatDateTime, formatDateTimeWithSeconds, } from "../../../src/common/datetime/format_date_time"; -import { NumberFormat } from "../../../src/data/translation"; +import { NumberFormat, TimeFormat } from "../../../src/data/translation"; describe("formatDateTime", () => { - const dateObj = new Date(2017, 10, 18, 11, 12, 13, 400); + const dateObj = new Date(2017, 10, 18, 23, 12, 13, 400); it("Formats English date times", () => { assert.strictEqual( formatDateTime(dateObj, { language: "en", number_format: NumberFormat.language, + time_format: TimeFormat.am_pm, }), - "November 18, 2017, 11:12 AM" + "November 18, 2017, 11:12 PM" + ); + assert.strictEqual( + formatDateTime(dateObj, { + language: "en", + number_format: NumberFormat.language, + time_format: TimeFormat.twenty_four, + }), + "November 18, 2017, 23:12" ); }); }); describe("formatDateTimeWithSeconds", () => { - const dateObj = new Date(2017, 10, 18, 11, 12, 13, 400); + const dateObj = new Date(2017, 10, 18, 23, 12, 13, 400); it("Formats English date times with seconds", () => { assert.strictEqual( formatDateTimeWithSeconds(dateObj, { language: "en", number_format: NumberFormat.language, + time_format: TimeFormat.am_pm, }), - "November 18, 2017, 11:12:13 AM" + "November 18, 2017, 11:12:13 PM" + ); + assert.strictEqual( + formatDateTimeWithSeconds(dateObj, { + language: "en", + number_format: NumberFormat.language, + time_format: TimeFormat.twenty_four, + }), + "November 18, 2017, 23:12:13" ); }); }); diff --git a/test-mocha/common/datetime/format_time.ts b/test-mocha/common/datetime/format_time.ts index 2b1ac03aba..827f278278 100644 --- a/test-mocha/common/datetime/format_time.ts +++ b/test-mocha/common/datetime/format_time.ts @@ -4,32 +4,50 @@ import { formatTime, formatTimeWithSeconds, } from "../../../src/common/datetime/format_time"; -import { NumberFormat } from "../../../src/data/translation"; +import { NumberFormat, TimeFormat } from "../../../src/data/translation"; describe("formatTime", () => { - const dateObj = new Date(2017, 10, 18, 11, 12, 13, 1400); + const dateObj = new Date(2017, 10, 18, 23, 12, 13, 1400); it("Formats English times", () => { assert.strictEqual( formatTime(dateObj, { language: "en", number_format: NumberFormat.language, + time_format: TimeFormat.am_pm, }), - "11:12 AM" + "11:12 PM" + ); + assert.strictEqual( + formatTime(dateObj, { + language: "en", + number_format: NumberFormat.language, + time_format: TimeFormat.twenty_four, + }), + "23:12" ); }); }); describe("formatTimeWithSeconds", () => { - const dateObj = new Date(2017, 10, 18, 11, 12, 13, 400); + const dateObj = new Date(2017, 10, 18, 23, 12, 13, 400); it("Formats English times with seconds", () => { assert.strictEqual( formatTimeWithSeconds(dateObj, { language: "en", number_format: NumberFormat.language, + time_format: TimeFormat.am_pm, }), - "11:12:13 AM" + "11:12:13 PM" + ); + assert.strictEqual( + formatTimeWithSeconds(dateObj, { + language: "en", + number_format: NumberFormat.language, + time_format: TimeFormat.twenty_four, + }), + "23:12:13" ); }); }); diff --git a/test-mocha/common/entity/compute_state_display.ts b/test-mocha/common/entity/compute_state_display.ts index eb0157956f..01246ec186 100644 --- a/test-mocha/common/entity/compute_state_display.ts +++ b/test-mocha/common/entity/compute_state_display.ts @@ -2,20 +2,26 @@ import { assert } from "chai"; import { computeStateDisplay } from "../../../src/common/entity/compute_state_display"; import { UNKNOWN } from "../../../src/data/entity"; import { - FrontendTranslationData, + FrontendLocaleData, NumberFormat, + TimeFormat, } from "../../../src/data/translation"; -const localeData: FrontendTranslationData = { - language: "en", - number_format: NumberFormat.comma_decimal, -}; +let localeData: FrontendLocaleData; describe("computeStateDisplay", () => { // Mock Localize function for testing const localize = (message, ...args) => message + (args.length ? ": " + args.join(",") : ""); + beforeEach(() => { + localeData = { + language: "en", + number_format: NumberFormat.comma_decimal, + time_format: TimeFormat.am_pm, + }; + }); + it("Localizes binary sensor defaults", () => { const stateObj: any = { entity_id: "binary_sensor.test", @@ -148,7 +154,7 @@ describe("computeStateDisplay", () => { ); }); - it("Localizes input_datetime with full date time", () => { + describe("Localizes input_datetime with full date time", () => { const stateObj: any = { entity_id: "input_datetime.test", state: "123", @@ -158,15 +164,24 @@ describe("computeStateDisplay", () => { year: 2017, month: 11, day: 18, - hour: 11, + hour: 23, minute: 12, second: 13, }, }; - assert.strictEqual( - computeStateDisplay(localize, stateObj, localeData), - "November 18, 2017, 11:12 AM" - ); + it("Uses am/pm time format", () => { + assert.strictEqual( + computeStateDisplay(localize, stateObj, localeData), + "November 18, 2017, 11:12 PM" + ); + }); + it("Uses 24h time format", () => { + localeData.time_format = TimeFormat.twenty_four; + assert.strictEqual( + computeStateDisplay(localize, stateObj, localeData), + "November 18, 2017, 23:12" + ); + }); }); it("Localizes input_datetime with date", () => { @@ -179,7 +194,7 @@ describe("computeStateDisplay", () => { year: 2017, month: 11, day: 18, - hour: 11, + hour: 23, minute: 12, second: 13, }, @@ -190,7 +205,7 @@ describe("computeStateDisplay", () => { ); }); - it("Localizes input_datetime with time", () => { + describe("Localizes input_datetime with time", () => { const stateObj: any = { entity_id: "input_datetime.test", state: "123", @@ -200,15 +215,25 @@ describe("computeStateDisplay", () => { year: 2017, month: 11, day: 18, - hour: 11, + hour: 23, minute: 12, second: 13, }, }; - assert.strictEqual( - computeStateDisplay(localize, stateObj, localeData), - "11:12 AM" - ); + it("Uses am/pm time format", () => { + localeData.time_format = TimeFormat.am_pm; + assert.strictEqual( + computeStateDisplay(localize, stateObj, localeData), + "11:12 PM" + ); + }); + it("Uses 24h time format", () => { + localeData.time_format = TimeFormat.twenty_four; + assert.strictEqual( + computeStateDisplay(localize, stateObj, localeData), + "23:12" + ); + }); }); it("Localizes unavailable", () => { @@ -230,10 +255,9 @@ describe("computeStateDisplay", () => { }); it("Localizes custom state", () => { - const altLocalize = () => { + const altLocalize = () => // No matches can be found - return ""; - }; + ""; const stateObj: any = { entity_id: "sensor.test", state: "My Custom State", diff --git a/test-mocha/common/string/format_number.ts b/test-mocha/common/string/format_number.ts index 903be54cb0..dafa75575c 100644 --- a/test-mocha/common/string/format_number.ts +++ b/test-mocha/common/string/format_number.ts @@ -1,23 +1,29 @@ import { assert } from "chai"; import { formatNumber } from "../../../src/common/string/format_number"; -import { NumberFormat } from "../../../src/data/translation"; +import { + FrontendLocaleData, + NumberFormat, + TimeFormat, +} from "../../../src/data/translation"; describe("formatNumber", () => { + // Create default to not have to specify a not relevant TimeFormat over and over again. + const defaultLocale: FrontendLocaleData = { + language: "en", + number_format: NumberFormat.language, + time_format: TimeFormat.language, + }; + // Node only ships with English support for `Intl`, so we can not test for other number formats here. it("Formats English numbers", () => { - assert.strictEqual( - formatNumber(1234.5, { - language: "en", - number_format: NumberFormat.language, - }), - "1,234.5" - ); + assert.strictEqual(formatNumber(1234.5, defaultLocale), "1,234.5"); }); it("Test format 'none' (keep dot despite language 'de')", () => { assert.strictEqual( formatNumber(1.23, { + ...defaultLocale, language: "de", number_format: NumberFormat.none, }), @@ -26,57 +32,32 @@ describe("formatNumber", () => { }); it("Ensure zero is kept for format 'language'", () => { - assert.strictEqual( - formatNumber(0, { - language: "en", - number_format: NumberFormat.language, - }), - "0" - ); + assert.strictEqual(formatNumber(0, defaultLocale), "0"); }); it("Ensure zero is kept for format 'none'", () => { assert.strictEqual( - formatNumber(0, { - language: "en", - number_format: NumberFormat.none, - }), + formatNumber(0, { ...defaultLocale, number_format: NumberFormat.none }), "0" ); }); it("Test empty string input for format 'none'", () => { assert.strictEqual( - formatNumber("", { - language: "en", - number_format: NumberFormat.none, - }), + formatNumber("", { ...defaultLocale, number_format: NumberFormat.none }), "" ); }); it("Test empty string input for format 'language'", () => { - assert.strictEqual( - formatNumber("", { - language: "en", - number_format: NumberFormat.language, - }), - "0" - ); + assert.strictEqual(formatNumber("", defaultLocale), "0"); }); it("Formats number with options", () => { assert.strictEqual( - formatNumber( - 1234.5, - { - language: "en", - number_format: NumberFormat.language, - }, - { - minimumFractionDigits: 2, - } - ), + formatNumber(1234.5, defaultLocale, { + minimumFractionDigits: 2, + }), "1,234.50" ); }); diff --git a/test-mocha/tsconfig.test.json b/test-mocha/tsconfig.test.json index 0e7afc6855..ea24da6ec1 100644 --- a/test-mocha/tsconfig.test.json +++ b/test-mocha/tsconfig.test.json @@ -1,6 +1,7 @@ { "extends": "../tsconfig.json", "compilerOptions": { - "module": "commonjs" + "module": "commonjs", + "esModuleInterop": true } }