Allow users to select time format for UI rendering (#9042)

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
Philip Allgaier 2021-05-20 16:23:53 +02:00 committed by GitHub
parent 87e4c209f4
commit 70a1edd1dd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 397 additions and 175 deletions

View File

@ -1,21 +1,32 @@
import { format } from "fecha"; 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"; import { toLocaleDateStringSupportsOptions } from "./check_options_support";
export const formatDate = toLocaleDateStringSupportsOptions const formatDateMem = memoizeOne(
? (dateObj: Date, locales: FrontendTranslationData) => (locale: FrontendLocaleData) =>
dateObj.toLocaleDateString(locales.language, { new Intl.DateTimeFormat(locale.language, {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
}) })
);
export const formatDate = toLocaleDateStringSupportsOptions
? (dateObj: Date, locale: FrontendLocaleData) =>
formatDateMem(locale).format(dateObj)
: (dateObj: Date) => format(dateObj, "longDate"); : (dateObj: Date) => format(dateObj, "longDate");
export const formatDateWeekday = toLocaleDateStringSupportsOptions const formatDateWeekdayMem = memoizeOne(
? (dateObj: Date, locales: FrontendTranslationData) => (locale: FrontendLocaleData) =>
dateObj.toLocaleDateString(locales.language, { new Intl.DateTimeFormat(locale.language, {
weekday: "long", weekday: "long",
month: "short", month: "long",
day: "numeric", day: "numeric",
}) })
);
export const formatDateWeekday = toLocaleDateStringSupportsOptions
? (dateObj: Date, locale: FrontendLocaleData) =>
formatDateWeekdayMem(locale).format(dateObj)
: (dateObj: Date) => format(dateObj, "dddd, MMM D"); : (dateObj: Date) => format(dateObj, "dddd, MMM D");

View File

@ -1,26 +1,42 @@
import { format } from "fecha"; 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 { toLocaleStringSupportsOptions } from "./check_options_support";
import { useAmPm } from "./use_am_pm";
export const formatDateTime = toLocaleStringSupportsOptions const formatDateTimeMem = memoizeOne(
? (dateObj: Date, locales: FrontendTranslationData) => (locale: FrontendLocaleData) =>
dateObj.toLocaleString(locales.language, { new Intl.DateTimeFormat(locale.language, {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
hour: "numeric", hour: "numeric",
minute: "2-digit", minute: "2-digit",
hour12: useAmPm(locale),
}) })
: (dateObj: Date) => format(dateObj, "MMMM D, YYYY, HH:mm"); );
export const formatDateTimeWithSeconds = toLocaleStringSupportsOptions export const formatDateTime = toLocaleStringSupportsOptions
? (dateObj: Date, locales: FrontendTranslationData) => ? (dateObj: Date, locale: FrontendLocaleData) =>
dateObj.toLocaleString(locales.language, { 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", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
hour: "numeric", hour: "numeric",
minute: "2-digit", minute: "2-digit",
second: "2-digit", second: "2-digit",
hour12: useAmPm(locale),
}) })
: (dateObj: Date) => format(dateObj, "MMMM D, YYYY, HH:mm:ss"); );
export const formatDateTimeWithSeconds = toLocaleStringSupportsOptions
? (dateObj: Date, locale: FrontendLocaleData) =>
formatDateTimeWithSecondsMem(locale).format(dateObj)
: (dateObj: Date, locale: FrontendLocaleData) =>
format(dateObj, "MMMM D, YYYY, HH:mm:ss" + useAmPm(locale) ? " A" : "");

View File

@ -1,29 +1,52 @@
import { format } from "fecha"; 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 { toLocaleTimeStringSupportsOptions } from "./check_options_support";
import { useAmPm } from "./use_am_pm";
export const formatTime = toLocaleTimeStringSupportsOptions const formatTimeMem = memoizeOne(
? (dateObj: Date, locales: FrontendTranslationData) => (locale: FrontendLocaleData) =>
dateObj.toLocaleTimeString(locales.language, { new Intl.DateTimeFormat(locale.language, {
hour: "numeric", hour: "numeric",
minute: "2-digit", minute: "2-digit",
hour12: useAmPm(locale),
}) })
: (dateObj: Date) => format(dateObj, "shortTime"); );
export const formatTimeWithSeconds = toLocaleTimeStringSupportsOptions export const formatTime = toLocaleTimeStringSupportsOptions
? (dateObj: Date, locales: FrontendTranslationData) => ? (dateObj: Date, locale: FrontendLocaleData) =>
dateObj.toLocaleTimeString(locales.language, { 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", hour: "numeric",
minute: "2-digit", minute: "2-digit",
second: "2-digit", second: "2-digit",
hour12: useAmPm(locale),
}) })
: (dateObj: Date) => format(dateObj, "mediumTime"); );
export const formatTimeWeekday = toLocaleTimeStringSupportsOptions export const formatTimeWithSeconds = toLocaleTimeStringSupportsOptions
? (dateObj: Date, locales: FrontendTranslationData) => ? (dateObj: Date, locale: FrontendLocaleData) =>
dateObj.toLocaleTimeString(locales.language, { 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", weekday: "long",
hour: "numeric", hour: "numeric",
minute: "2-digit", minute: "2-digit",
hour12: useAmPm(locale),
}) })
: (dateObj: Date) => format(dateObj, "dddd, HH:mm"); );
export const formatTimeWeekday = toLocaleTimeStringSupportsOptions
? (dateObj: Date, locale: FrontendLocaleData) =>
formatTimeWeekdayMem(locale).format(dateObj)
: (dateObj: Date, locale: FrontendLocaleData) =>
format(dateObj, "dddd, HH:mm" + useAmPm(locale) ? " A" : "");

View File

@ -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;
};

View File

@ -1,6 +1,6 @@
import { HassEntity } from "home-assistant-js-websocket"; import { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity"; import { UNAVAILABLE, UNKNOWN } from "../../data/entity";
import { FrontendTranslationData } from "../../data/translation"; import { FrontendLocaleData } from "../../data/translation";
import { formatDate } from "../datetime/format_date"; import { formatDate } from "../datetime/format_date";
import { formatDateTime } from "../datetime/format_date_time"; import { formatDateTime } from "../datetime/format_date_time";
import { formatTime } from "../datetime/format_time"; import { formatTime } from "../datetime/format_time";
@ -11,7 +11,7 @@ import { computeStateDomain } from "./compute_state_domain";
export const computeStateDisplay = ( export const computeStateDisplay = (
localize: LocalizeFunc, localize: LocalizeFunc,
stateObj: HassEntity, stateObj: HassEntity,
locale: FrontendTranslationData, locale: FrontendLocaleData,
state?: string state?: string
): string => { ): string => {
const compareState = state !== undefined ? state : stateObj.state; const compareState = state !== undefined ? state : stateObj.state;

View File

@ -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. * 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 = ( export const formatNumber = (
num: string | number, num: string | number,
locale?: FrontendTranslationData, locale?: FrontendLocaleData,
options?: Intl.NumberFormatOptions options?: Intl.NumberFormatOptions
): string => { ): string => {
let format: string | string[] | undefined; let format: string | string[] | undefined;

View File

@ -14,6 +14,7 @@ import {
} from "lit"; } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { formatDateTime } from "../common/datetime/format_date_time"; import { formatDateTime } from "../common/datetime/format_date_time";
import { useAmPm } from "../common/datetime/use_am_pm";
import { computeRTLDirection } from "../common/util/compute_rtl"; import { computeRTLDirection } from "../common/util/compute_rtl";
import { HomeAssistant } from "../types"; import { HomeAssistant } from "../types";
import "./date-range-picker"; import "./date-range-picker";
@ -43,7 +44,7 @@ export class HaDateRangePicker extends LitElement {
if (changedProps.has("hass")) { if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined; const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.locale !== this.hass.locale) { if (!oldHass || oldHass.locale !== this.hass.locale) {
this._hour24format = this._compute24hourFormat(); this._hour24format = !useAmPm(this.hass.locale);
this._rtlDirection = computeRTLDirection(this.hass); 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<ActionDetail>) { private _setDateRange(ev: CustomEvent<ActionDetail>) {
const dateRange = Object.values(this.ranges!)[ev.detail.index]; const dateRange = Object.values(this.ranges!)[ev.detail.index];
const dateRangePicker = this._dateRangePicker; const dateRangePicker = this._dateRangePicker;

View File

@ -4,7 +4,7 @@ import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import { formatNumber } from "../common/string/format_number"; import { formatNumber } from "../common/string/format_number";
import { afterNextRender } from "../common/util/render-status"; import { afterNextRender } from "../common/util/render-status";
import { FrontendTranslationData } from "../data/translation"; import { FrontendLocaleData } from "../data/translation";
import { getValueInPercentage, normalize } from "../util/calculate"; import { getValueInPercentage, normalize } from "../util/calculate";
const getAngle = (value: number, min: number, max: number) => { const getAngle = (value: number, min: number, max: number) => {
@ -23,7 +23,7 @@ export class Gauge extends LitElement {
@property({ type: Number }) public value = 0; @property({ type: Number }) public value = 0;
@property() public locale!: FrontendTranslationData; @property() public locale!: FrontendLocaleData;
@property() public label = ""; @property() public label = "";

View File

@ -1,10 +1,13 @@
import { html, LitElement } from "lit"; import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { useAmPm } from "../../common/datetime/use_am_pm";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { TimeSelector } from "../../data/selector"; import { TimeSelector } from "../../data/selector";
import { FrontendLocaleData } from "../../data/translation";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import "../paper-time-input"; import "../paper-time-input";
@customElement("ha-selector-time") @customElement("ha-selector-time")
export class HaTimeSelector extends LitElement { export class HaTimeSelector extends LitElement {
@property() public hass!: HomeAssistant; @property() public hass!: HomeAssistant;
@ -17,13 +20,12 @@ export class HaTimeSelector extends LitElement {
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
private _useAmPm = memoizeOne((language: string) => { private _useAmPmMem = memoizeOne((locale: FrontendLocaleData): boolean =>
const test = new Date().toLocaleString(language); useAmPm(locale)
return test.includes("AM") || test.includes("PM"); );
});
protected render() { protected render() {
const useAMPM = this._useAmPm(this.hass.locale.language); const useAMPM = this._useAmPmMem(this.hass.locale);
const parts = this.value?.split(":") || []; const parts = this.value?.split(":") || [];
const hours = parts[0]; const hours = parts[0];
@ -48,7 +50,7 @@ export class HaTimeSelector extends LitElement {
private _timeChanged(ev) { private _timeChanged(ev) {
let value = ev.target.value; 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); let hours = Number(ev.target.hour || 0);
if (value && useAMPM) { if (value && useAMPM) {
if (ev.target.amPm === "PM") { if (ev.target.amPm === "PM") {

View File

@ -4,7 +4,7 @@ import { computeStateDomain } from "../common/entity/compute_state_domain";
import { computeStateName } from "../common/entity/compute_state_name"; import { computeStateName } from "../common/entity/compute_state_name";
import { LocalizeFunc } from "../common/translations/localize"; import { LocalizeFunc } from "../common/translations/localize";
import { HomeAssistant } from "../types"; import { HomeAssistant } from "../types";
import { FrontendTranslationData } from "./translation"; import { FrontendLocaleData } from "./translation";
const DOMAINS_USE_LAST_UPDATED = ["climate", "humidifier", "water_heater"]; const DOMAINS_USE_LAST_UPDATED = ["climate", "humidifier", "water_heater"];
const LINE_ATTRIBUTES_TO_KEEP = [ const LINE_ATTRIBUTES_TO_KEEP = [
@ -109,7 +109,7 @@ const equalState = (obj1: LineChartState, obj2: LineChartState) =>
const processTimelineEntity = ( const processTimelineEntity = (
localize: LocalizeFunc, localize: LocalizeFunc,
language: FrontendTranslationData, language: FrontendLocaleData,
states: HassEntity[] states: HassEntity[]
): TimelineEntity => { ): TimelineEntity => {
const data: TimelineState[] = []; const data: TimelineState[] = [];

View File

@ -10,14 +10,22 @@ export enum NumberFormat {
none = "none", none = "none",
} }
export interface FrontendTranslationData { export enum TimeFormat {
language = "language",
system = "system",
am_pm = "12",
twenty_four = "24",
}
export interface FrontendLocaleData {
language: string; language: string;
number_format: NumberFormat; number_format: NumberFormat;
time_format: TimeFormat;
} }
declare global { declare global {
interface FrontendUserData { interface FrontendUserData {
language: FrontendTranslationData; language: FrontendLocaleData;
} }
} }
@ -36,7 +44,7 @@ export const fetchTranslationPreferences = (hass: HomeAssistant) =>
export const saveTranslationPreferences = ( export const saveTranslationPreferences = (
hass: HomeAssistant, hass: HomeAssistant,
data: FrontendTranslationData data: FrontendLocaleData
) => saveFrontendUserData(hass.connection, "language", data); ) => saveFrontendUserData(hass.connection, "language", data);
export const getHassTranslations = async ( export const getHassTranslations = async (

View File

@ -5,7 +5,7 @@ import {
} from "../common/dom/apply_themes_on_element"; } from "../common/dom/apply_themes_on_element";
import { computeLocalize } from "../common/translations/localize"; import { computeLocalize } from "../common/translations/localize";
import { DEFAULT_PANEL } from "../data/panel"; import { DEFAULT_PANEL } from "../data/panel";
import { NumberFormat } from "../data/translation"; import { NumberFormat, TimeFormat } from "../data/translation";
import { translationMetadata } from "../resources/translations-metadata"; import { translationMetadata } from "../resources/translations-metadata";
import { HomeAssistant } from "../types"; import { HomeAssistant } from "../types";
import { getLocalLanguage, getTranslation } from "../util/hass-translation"; import { getLocalLanguage, getTranslation } from "../util/hass-translation";
@ -215,6 +215,7 @@ export const provideHass = (
locale: { locale: {
language: localLanguage, language: localLanguage,
number_format: NumberFormat.language, number_format: NumberFormat.language,
time_format: TimeFormat.language,
}, },
resources: null as any, resources: null as any,
localize: () => "", localize: () => "",

View File

@ -1,8 +1,8 @@
import { formatDateTimeWithSeconds } from "../../../common/datetime/format_date_time"; import { formatDateTimeWithSeconds } from "../../../common/datetime/format_date_time";
import { formatTimeWithSeconds } from "../../../common/datetime/format_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 today = new Date().setHours(0, 0, 0, 0);
const dateTime = new Date(date * 1000); const dateTime = new Date(date * 1000);
const dateTimeDay = new Date(date * 1000).setHours(0, 0, 0, 0); const dateTimeDay = new Date(date * 1000).setHours(0, 0, 0, 0);

View File

@ -4,12 +4,12 @@ import { formatDate } from "../../../common/datetime/format_date";
import { formatDateTime } from "../../../common/datetime/format_date_time"; import { formatDateTime } from "../../../common/datetime/format_date_time";
import { formatTime } from "../../../common/datetime/format_time"; import { formatTime } from "../../../common/datetime/format_time";
import relativeTime from "../../../common/datetime/relative_time"; import relativeTime from "../../../common/datetime/relative_time";
import { FrontendTranslationData } from "../../../data/translation"; import { FrontendLocaleData } from "../../../data/translation";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { TimestampRenderingFormats } from "./types"; import { TimestampRenderingFormats } from "./types";
const FORMATS: { const FORMATS: {
[key: string]: (ts: Date, lang: FrontendTranslationData) => string; [key: string]: (ts: Date, lang: FrontendLocaleData) => string;
} = { } = {
date: formatDate, date: formatDate,
datetime: formatDateTime, datetime: formatDateTime,

View File

@ -3,7 +3,7 @@ import {
LovelaceCardConfig, LovelaceCardConfig,
LovelaceConfig, LovelaceConfig,
} from "../../data/lovelace"; } from "../../data/lovelace";
import { FrontendTranslationData } from "../../data/translation"; import { FrontendLocaleData } from "../../data/translation";
import { Constructor, HomeAssistant } from "../../types"; import { Constructor, HomeAssistant } from "../../types";
import { LovelaceRow, LovelaceRowConfig } from "./entity-rows/types"; import { LovelaceRow, LovelaceRowConfig } from "./entity-rows/types";
import { LovelaceHeaderFooterConfig } from "./header-footer/types"; import { LovelaceHeaderFooterConfig } from "./header-footer/types";
@ -23,7 +23,7 @@ export interface Lovelace {
editMode: boolean; editMode: boolean;
urlPath: string | null; urlPath: string | null;
mode: "generated" | "yaml" | "storage"; mode: "generated" | "yaml" | "storage";
locale: FrontendTranslationData; locale: FrontendLocaleData;
enableFullEditMode: () => void; enableFullEditMode: () => void;
setEditMode: (editMode: boolean) => void; setEditMode: (editMode: boolean) => void;
saveConfig: (newConfig: LovelaceConfig) => Promise<void>; saveConfig: (newConfig: LovelaceConfig) => Promise<void>;

View File

@ -29,6 +29,7 @@ import "./ha-pick-dashboard-row";
import "./ha-pick-language-row"; import "./ha-pick-language-row";
import "./ha-pick-number-format-row"; import "./ha-pick-number-format-row";
import "./ha-pick-theme-row"; import "./ha-pick-theme-row";
import "./ha-pick-time-format-row";
import "./ha-push-notifications-row"; import "./ha-push-notifications-row";
import "./ha-refresh-tokens-card"; import "./ha-refresh-tokens-card";
import "./ha-set-suspend-row"; import "./ha-set-suspend-row";
@ -96,6 +97,10 @@ class HaPanelProfile extends LitElement {
.narrow=${this.narrow} .narrow=${this.narrow}
.hass=${this.hass} .hass=${this.hass}
></ha-pick-number-format-row> ></ha-pick-number-format-row>
<ha-pick-time-format-row
.narrow=${this.narrow}
.hass=${this.hass}
></ha-pick-time-format-row>
<ha-pick-theme-row <ha-pick-theme-row
.narrow=${this.narrow} .narrow=${this.narrow}
.hass=${this.hass} .hass=${this.hass}

View File

@ -40,7 +40,7 @@ class NumberFormatRow extends LitElement {
> >
${Object.values(NumberFormat).map((format) => { ${Object.values(NumberFormat).map((format) => {
const formattedNumber = formatNumber(1234567.89, { const formattedNumber = formatNumber(1234567.89, {
language: this.hass.locale.language, ...this.hass.locale,
number_format: format, number_format: format,
}); });
const value = this.hass.localize( 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 const twoLine = value.slice(value.length - 2) !== "89"; // Display explicit number formats on one line
return html` return html`
<paper-item .format=${format}> <paper-item .format=${format} .label=${value}>
<paper-item-body ?two-line=${twoLine}> <paper-item-body ?two-line=${twoLine}>
<div>${value}</div> <div>${value}</div>
${twoLine ${twoLine

View File

@ -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`
<ha-settings-row .narrow=${this.narrow}>
<span slot="heading">
${this.hass.localize("ui.panel.profile.time_format.header")}
</span>
<span slot="description">
${this.hass.localize("ui.panel.profile.time_format.description")}
</span>
<ha-paper-dropdown-menu
.label=${this.hass.localize(
"ui.panel.profile.time_format.dropdown_label"
)}
dynamic-align
.disabled=${this.hass.locale === undefined}
>
<paper-listbox
slot="dropdown-content"
.selected=${this.hass.locale.time_format}
@iron-select=${this._handleFormatSelection}
attr-for-selected="format"
>
${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` <paper-item .format=${format} .label=${value}>
<paper-item-body two-line>
<div>${value}</div>
<div secondary>${formattedTime}</div>
</paper-item-body>
</paper-item>`;
})}
</paper-listbox>
</ha-paper-dropdown-menu>
</ha-settings-row>
`;
}
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;
}
}

View File

@ -15,7 +15,7 @@ import { subscribeFrontendUserData } from "../data/frontend";
import { forwardHaptic } from "../data/haptics"; import { forwardHaptic } from "../data/haptics";
import { DEFAULT_PANEL } from "../data/panel"; import { DEFAULT_PANEL } from "../data/panel";
import { serviceCallWillDisconnect } from "../data/service"; import { serviceCallWillDisconnect } from "../data/service";
import { NumberFormat } from "../data/translation"; import { NumberFormat, TimeFormat } from "../data/translation";
import { subscribePanels } from "../data/ws-panels"; import { subscribePanels } from "../data/ws-panels";
import { translationMetadata } from "../resources/translations-metadata"; import { translationMetadata } from "../resources/translations-metadata";
import { Constructor, ServiceCallResponse } from "../types"; import { Constructor, ServiceCallResponse } from "../types";
@ -49,6 +49,7 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
locale: { locale: {
language, language,
number_format: NumberFormat.language, number_format: NumberFormat.language,
time_format: TimeFormat.language,
}, },
resources: null as any, resources: null as any,
localize: () => "", localize: () => "",

View File

@ -7,6 +7,7 @@ import {
getHassTranslationsPre109, getHassTranslationsPre109,
NumberFormat, NumberFormat,
saveTranslationPreferences, saveTranslationPreferences,
TimeFormat,
TranslationCategory, TranslationCategory,
} from "../data/translation"; } from "../data/translation";
import { translationMetadata } from "../resources/translations-metadata"; import { translationMetadata } from "../resources/translations-metadata";
@ -28,6 +29,9 @@ declare global {
"hass-number-format-select": { "hass-number-format-select": {
number_format: NumberFormat; number_format: NumberFormat;
}; };
"hass-time-format-select": {
time_format: TimeFormat;
};
} }
} }
@ -64,6 +68,9 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
this.addEventListener("hass-number-format-select", (e) => { this.addEventListener("hass-number-format-select", (e) => {
this._selectNumberFormat((e as CustomEvent).detail, true); this._selectNumberFormat((e as CustomEvent).detail, true);
}); });
this.addEventListener("hass-time-format-select", (e) => {
this._selectTimeFormat((e as CustomEvent).detail, true);
});
this._loadCoreTranslations(getLocalLanguage()); this._loadCoreTranslations(getLocalLanguage());
} }
@ -95,6 +102,13 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
// We just got number_format from backend, no need to save back // We just got number_format from backend, no need to save back
this._selectNumberFormat(locale.number_format, false); 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( this.hass!.connection.subscribeEvents(
@ -133,6 +147,15 @@ export default <T extends Constructor<HassBaseEl>>(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) { private _selectLanguage(language: string, saveToBackend: boolean) {
if (!this.hass) { if (!this.hass) {
// should not happen, do it to avoid use this.hass! // should not happen, do it to avoid use this.hass!

View File

@ -3213,6 +3213,17 @@
"none": "None" "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": { "themes": {
"header": "Theme", "header": "Theme",
"error_no_theme": "No themes available.", "error_no_theme": "No themes available.",

View File

@ -9,10 +9,7 @@ import {
} from "home-assistant-js-websocket"; } from "home-assistant-js-websocket";
import { LocalizeFunc } from "./common/translations/localize"; import { LocalizeFunc } from "./common/translations/localize";
import { CoreFrontendUserData } from "./data/frontend"; import { CoreFrontendUserData } from "./data/frontend";
import { import { FrontendLocaleData, getHassTranslations } from "./data/translation";
FrontendTranslationData,
getHassTranslations,
} from "./data/translation";
import { Themes } from "./data/ws-themes"; import { Themes } from "./data/ws-themes";
import { ExternalMessaging } from "./external_app/external_messaging"; import { ExternalMessaging } from "./external_app/external_messaging";
@ -198,14 +195,14 @@ export interface HomeAssistant {
panelUrl: string; panelUrl: string;
// i18n // i18n
// current effective language in that order: // current effective language in that order:
// - backend saved user selected lanugage // - backend saved user selected language
// - language in local app storage // - language in local app storage
// - browser language // - browser language
// - english (en) // - english (en)
language: string; language: string;
// local stored language, keep that name for backward compability // local stored language, keep that name for backward compatibility
selectedLanguage: string | null; selectedLanguage: string | null;
locale: FrontendTranslationData; locale: FrontendLocaleData;
resources: Resources; resources: Resources;
localize: LocalizeFunc; localize: LocalizeFunc;
translationMetadata: TranslationMetadata; translationMetadata: TranslationMetadata;

View File

@ -1,6 +1,6 @@
import { import {
fetchTranslationPreferences, fetchTranslationPreferences,
FrontendTranslationData, FrontendLocaleData,
} from "../data/translation"; } from "../data/translation";
import { translationMetadata } from "../resources/translations-metadata"; import { translationMetadata } from "../resources/translations-metadata";
import { HomeAssistant } from "../types"; import { HomeAssistant } from "../types";
@ -55,21 +55,24 @@ export function findAvailableLanguage(language: string) {
*/ */
export async function getUserLocale( export async function getUserLocale(
hass: HomeAssistant hass: HomeAssistant
): Promise<Partial<FrontendTranslationData>> { ): Promise<Partial<FrontendLocaleData>> {
const result = await fetchTranslationPreferences(hass); const result = await fetchTranslationPreferences(hass);
const language = result?.language; const language = result?.language;
const number_format = result?.number_format; const number_format = result?.number_format;
const time_format = result?.time_format;
if (language) { if (language) {
const availableLanguage = findAvailableLanguage(language); const availableLanguage = findAvailableLanguage(language);
if (availableLanguage) { if (availableLanguage) {
return { return {
language: availableLanguage, language: availableLanguage,
number_format, number_format,
time_format,
}; };
} }
} }
return { return {
number_format, number_format,
time_format,
}; };
} }

View File

@ -1,7 +1,7 @@
import { assert } from "chai"; import { assert } from "chai";
import { formatDate } from "../../../src/common/datetime/format_date"; import { formatDate } from "../../../src/common/datetime/format_date";
import { NumberFormat } from "../../../src/data/translation"; import { NumberFormat, TimeFormat } from "../../../src/data/translation";
describe("formatDate", () => { describe("formatDate", () => {
const dateObj = new Date(2017, 10, 18, 11, 12, 13, 1400); const dateObj = new Date(2017, 10, 18, 11, 12, 13, 1400);
@ -11,6 +11,7 @@ describe("formatDate", () => {
formatDate(dateObj, { formatDate(dateObj, {
language: "en", language: "en",
number_format: NumberFormat.language, number_format: NumberFormat.language,
time_format: TimeFormat.language,
}), }),
"November 18, 2017" "November 18, 2017"
); );

View File

@ -4,32 +4,50 @@ import {
formatDateTime, formatDateTime,
formatDateTimeWithSeconds, formatDateTimeWithSeconds,
} from "../../../src/common/datetime/format_date_time"; } from "../../../src/common/datetime/format_date_time";
import { NumberFormat } from "../../../src/data/translation"; import { NumberFormat, TimeFormat } from "../../../src/data/translation";
describe("formatDateTime", () => { 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", () => { it("Formats English date times", () => {
assert.strictEqual( assert.strictEqual(
formatDateTime(dateObj, { formatDateTime(dateObj, {
language: "en", language: "en",
number_format: NumberFormat.language, 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", () => { 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", () => { it("Formats English date times with seconds", () => {
assert.strictEqual( assert.strictEqual(
formatDateTimeWithSeconds(dateObj, { formatDateTimeWithSeconds(dateObj, {
language: "en", language: "en",
number_format: NumberFormat.language, 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"
); );
}); });
}); });

View File

@ -4,32 +4,50 @@ import {
formatTime, formatTime,
formatTimeWithSeconds, formatTimeWithSeconds,
} from "../../../src/common/datetime/format_time"; } from "../../../src/common/datetime/format_time";
import { NumberFormat } from "../../../src/data/translation"; import { NumberFormat, TimeFormat } from "../../../src/data/translation";
describe("formatTime", () => { 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", () => { it("Formats English times", () => {
assert.strictEqual( assert.strictEqual(
formatTime(dateObj, { formatTime(dateObj, {
language: "en", language: "en",
number_format: NumberFormat.language, 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", () => { 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", () => { it("Formats English times with seconds", () => {
assert.strictEqual( assert.strictEqual(
formatTimeWithSeconds(dateObj, { formatTimeWithSeconds(dateObj, {
language: "en", language: "en",
number_format: NumberFormat.language, 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"
); );
}); });
}); });

View File

@ -2,20 +2,26 @@ import { assert } from "chai";
import { computeStateDisplay } from "../../../src/common/entity/compute_state_display"; import { computeStateDisplay } from "../../../src/common/entity/compute_state_display";
import { UNKNOWN } from "../../../src/data/entity"; import { UNKNOWN } from "../../../src/data/entity";
import { import {
FrontendTranslationData, FrontendLocaleData,
NumberFormat, NumberFormat,
TimeFormat,
} from "../../../src/data/translation"; } from "../../../src/data/translation";
const localeData: FrontendTranslationData = { let localeData: FrontendLocaleData;
language: "en",
number_format: NumberFormat.comma_decimal,
};
describe("computeStateDisplay", () => { describe("computeStateDisplay", () => {
// Mock Localize function for testing // Mock Localize function for testing
const localize = (message, ...args) => const localize = (message, ...args) =>
message + (args.length ? ": " + args.join(",") : ""); message + (args.length ? ": " + args.join(",") : "");
beforeEach(() => {
localeData = {
language: "en",
number_format: NumberFormat.comma_decimal,
time_format: TimeFormat.am_pm,
};
});
it("Localizes binary sensor defaults", () => { it("Localizes binary sensor defaults", () => {
const stateObj: any = { const stateObj: any = {
entity_id: "binary_sensor.test", 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 = { const stateObj: any = {
entity_id: "input_datetime.test", entity_id: "input_datetime.test",
state: "123", state: "123",
@ -158,16 +164,25 @@ describe("computeStateDisplay", () => {
year: 2017, year: 2017,
month: 11, month: 11,
day: 18, day: 18,
hour: 11, hour: 23,
minute: 12, minute: 12,
second: 13, second: 13,
}, },
}; };
it("Uses am/pm time format", () => {
assert.strictEqual( assert.strictEqual(
computeStateDisplay(localize, stateObj, localeData), computeStateDisplay(localize, stateObj, localeData),
"November 18, 2017, 11:12 AM" "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", () => { it("Localizes input_datetime with date", () => {
const stateObj: any = { const stateObj: any = {
@ -179,7 +194,7 @@ describe("computeStateDisplay", () => {
year: 2017, year: 2017,
month: 11, month: 11,
day: 18, day: 18,
hour: 11, hour: 23,
minute: 12, minute: 12,
second: 13, second: 13,
}, },
@ -190,7 +205,7 @@ describe("computeStateDisplay", () => {
); );
}); });
it("Localizes input_datetime with time", () => { describe("Localizes input_datetime with time", () => {
const stateObj: any = { const stateObj: any = {
entity_id: "input_datetime.test", entity_id: "input_datetime.test",
state: "123", state: "123",
@ -200,16 +215,26 @@ describe("computeStateDisplay", () => {
year: 2017, year: 2017,
month: 11, month: 11,
day: 18, day: 18,
hour: 11, hour: 23,
minute: 12, minute: 12,
second: 13, second: 13,
}, },
}; };
it("Uses am/pm time format", () => {
localeData.time_format = TimeFormat.am_pm;
assert.strictEqual( assert.strictEqual(
computeStateDisplay(localize, stateObj, localeData), computeStateDisplay(localize, stateObj, localeData),
"11:12 AM" "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", () => { it("Localizes unavailable", () => {
const altLocalize = (message, ...args) => { const altLocalize = (message, ...args) => {
@ -230,10 +255,9 @@ describe("computeStateDisplay", () => {
}); });
it("Localizes custom state", () => { it("Localizes custom state", () => {
const altLocalize = () => { const altLocalize = () =>
// No matches can be found // No matches can be found
return ""; "";
};
const stateObj: any = { const stateObj: any = {
entity_id: "sensor.test", entity_id: "sensor.test",
state: "My Custom State", state: "My Custom State",

View File

@ -1,23 +1,29 @@
import { assert } from "chai"; import { assert } from "chai";
import { formatNumber } from "../../../src/common/string/format_number"; import { formatNumber } from "../../../src/common/string/format_number";
import { NumberFormat } from "../../../src/data/translation"; import {
FrontendLocaleData,
NumberFormat,
TimeFormat,
} from "../../../src/data/translation";
describe("formatNumber", () => { describe("formatNumber", () => {
// Node only ships with English support for `Intl`, so we can not test for other number formats here. // Create default to not have to specify a not relevant TimeFormat over and over again.
it("Formats English numbers", () => { const defaultLocale: FrontendLocaleData = {
assert.strictEqual(
formatNumber(1234.5, {
language: "en", language: "en",
number_format: NumberFormat.language, number_format: NumberFormat.language,
}), time_format: TimeFormat.language,
"1,234.5" };
);
// 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, defaultLocale), "1,234.5");
}); });
it("Test format 'none' (keep dot despite language 'de')", () => { it("Test format 'none' (keep dot despite language 'de')", () => {
assert.strictEqual( assert.strictEqual(
formatNumber(1.23, { formatNumber(1.23, {
...defaultLocale,
language: "de", language: "de",
number_format: NumberFormat.none, number_format: NumberFormat.none,
}), }),
@ -26,57 +32,32 @@ describe("formatNumber", () => {
}); });
it("Ensure zero is kept for format 'language'", () => { it("Ensure zero is kept for format 'language'", () => {
assert.strictEqual( assert.strictEqual(formatNumber(0, defaultLocale), "0");
formatNumber(0, {
language: "en",
number_format: NumberFormat.language,
}),
"0"
);
}); });
it("Ensure zero is kept for format 'none'", () => { it("Ensure zero is kept for format 'none'", () => {
assert.strictEqual( assert.strictEqual(
formatNumber(0, { formatNumber(0, { ...defaultLocale, number_format: NumberFormat.none }),
language: "en",
number_format: NumberFormat.none,
}),
"0" "0"
); );
}); });
it("Test empty string input for format 'none'", () => { it("Test empty string input for format 'none'", () => {
assert.strictEqual( assert.strictEqual(
formatNumber("", { formatNumber("", { ...defaultLocale, number_format: NumberFormat.none }),
language: "en",
number_format: NumberFormat.none,
}),
"" ""
); );
}); });
it("Test empty string input for format 'language'", () => { it("Test empty string input for format 'language'", () => {
assert.strictEqual( assert.strictEqual(formatNumber("", defaultLocale), "0");
formatNumber("", {
language: "en",
number_format: NumberFormat.language,
}),
"0"
);
}); });
it("Formats number with options", () => { it("Formats number with options", () => {
assert.strictEqual( assert.strictEqual(
formatNumber( formatNumber(1234.5, defaultLocale, {
1234.5,
{
language: "en",
number_format: NumberFormat.language,
},
{
minimumFractionDigits: 2, minimumFractionDigits: 2,
} }),
),
"1,234.50" "1,234.50"
); );
}); });

View File

@ -1,6 +1,7 @@
{ {
"extends": "../tsconfig.json", "extends": "../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"module": "commonjs" "module": "commonjs",
"esModuleInterop": true
} }
} }