187 files

This commit is contained in:
Home Assistant Version Control committed 2026-09-03 17:54:35 +00:00
1 parent 16c9b4d7f4
commit d6413e19b5
187 files changed
+15542 -2765

No files matched your search

@@ -106,7 +106,7 @@ export function syncLocaleFromHass(
changedProps: Map<string, unknown>,
): void {
if (changedProps.has("hass")) {
setDateTimePrefs(host.hass?.locale as Parameters<typeof setDateTimePrefs>[0], host.hass?.config?.country);
setProfilePrefs(host.hass?.locale as Parameters<typeof setProfilePrefs>[0], host.hass?.config?.country);
}
const lang = host.hass?.language;
if (lang && !isLocaleLoaded(lang)) {
@@ -188,38 +188,88 @@ function langToLocale(lang?: string): string {
return map[l] ?? "en-US";
}
/** HA per-user date/time format prefs (issue #97).
/** HA per-user format prefs (issue #97, #163).
*
* hass.locale.date_format ("language"|"system"|"DMY"|"MDY"|"YMD") and
* time_format ("language"|"system"|"12"|"24") are frontend PROFILE settings —
* deriving the format from the UI language alone showed en users mm/dd/yyyy
* even with dd/mm/yyyy configured. Stored on a window singleton because each
* bundle (panel / cards / dialog-mount) gets its own module scope; every
* surface calls setDateTimePrefs() on hass updates and whichever runs first
* wins for all of them.
* hass.locale.date_format ("language"|"system"|"DMY"|"MDY"|"YMD"),
* time_format ("language"|"system"|"12"|"24") and number_format ("language"|
* "system"|"comma_decimal"|"decimal_comma"|"space_comma"|"none") are frontend
* PROFILE settings — deriving the format from the UI language alone showed
* en users mm/dd/yyyy even with dd/mm/yyyy configured. Stored on a window
* singleton because each bundle (panel / cards / dialog-mount) gets its own
* module scope; every surface calls setProfilePrefs() on hass updates and
* whichever runs first wins for all of them. (The window key keeps its
* original name so a stale cached bundle still shares the object.)
*/
interface DateTimePrefs {
interface ProfilePrefs {
date?: string;
time?: string;
number?: string;
country?: string;
}
const _w = window as unknown as { __msDateTimePrefs?: DateTimePrefs };
const DT_PREFS: DateTimePrefs = (_w.__msDateTimePrefs ??= {});
const _w = window as unknown as { __msDateTimePrefs?: ProfilePrefs };
const DT_PREFS: ProfilePrefs = (_w.__msDateTimePrefs ??= {});
/** Feed HA's per-user date/time format into formatDate/formatDateTime.
/** Feed HA's per-user date/time/number formats into the formatters below.
* `country` is the SERVER's configured country (#140): with the profile
* format left on "language" it regionalizes the language default —
* date format left on "language" it regionalizes the language default —
* "en" + AU renders DD/MM/YYYY instead of the hard en-US mapping. */
export function setDateTimePrefs(
locale?: { date_format?: string; time_format?: string },
export function setProfilePrefs(
locale?: { date_format?: string; time_format?: string; number_format?: string },
country?: string | null,
): void {
if (!locale) return;
DT_PREFS.date = locale.date_format;
DT_PREFS.time = locale.time_format;
DT_PREFS.number = locale.number_format;
if (country !== undefined) DT_PREFS.country = country || undefined;
}
/** Locale for the profile number format — mirrors HA's own
* numberFormatToLocale so our figures match the entity cards next to them:
* the three explicit styles pin a representative locale, "system" is the
* browser, and the "language" default is the UI language WITHOUT the
* server-country regionalization dates get (#140) — HA formats "de" as
* 1.234,56 whatever the country. */
function numberLocale(lang?: string): string | string[] | undefined {
switch (DT_PREFS.number) {
case "comma_decimal": return ["en-US", "en"];
case "decimal_comma": return ["de", "es", "it"];
case "space_comma": return ["fr", "sv", "cs"];
case "system": return undefined;
default: return langToLocale(lang);
}
}
/** Format a number for display honouring the HA profile number format.
* `digits` pins the fraction digits (a number = exactly that many, like
* toFixed) or passes raw Intl options; the default is at most 2, no trailing
* zeros. "none" mirrors HA: plain digits, no grouping, trailing zeros
* dropped. Every human-facing number in the UI goes through here — SVG
* coordinates use chart-utils px() instead, which must stay locale-free. */
export function formatNumber(n: number, lang?: string, digits?: number | Intl.NumberFormatOptions): string {
if (!Number.isFinite(n)) return String(n);
const opts: Intl.NumberFormatOptions =
typeof digits === "number"
? { minimumFractionDigits: digits, maximumFractionDigits: digits }
: { maximumFractionDigits: 2, ...digits };
if (DT_PREFS.number === "none") {
const f = Math.pow(10, opts.maximumFractionDigits ?? 2);
return String(Math.round(n * f) / f);
}
try {
return new Intl.NumberFormat(numberLocale(lang), opts).format(n);
} catch {
return new Intl.NumberFormat(undefined, opts).format(n);
}
}
/** Amount + currency symbol ("12.50 €"; the symbol is optional) — every cost
* figure in the UI and the printables goes through here. */
export function formatCost(amount: number, symbol?: string, lang?: string, digits = 2): string {
const num = formatNumber(amount, lang, digits);
return symbol ? `${num} ${symbol}` : num;
}
/** BCP-47 locale for the "language" default: the UI language regionalized
* by the server country when Intl knows the combination (#140). Explicit
* profile formats (DMY/MDY/YMD/system) never reach this path. */
@@ -253,7 +303,10 @@ function _formatDateObj(d: Date, lang?: string): string {
}
}
function _formatTimeObj(d: Date, lang?: string): string {
/** Time of day honouring the HA profile time format (12/24h). Every
* clock-time string in the UI goes through here — chart crosshairs included
* (#163: a bare toLocaleDateString({hour, minute}) ignored the profile). */
export function formatTimeOfDay(d: Date, lang?: string): string {
switch (DT_PREFS.time) {
case "12": return d.toLocaleTimeString(resolveLocale(lang), { hour: "2-digit", minute: "2-digit", hour12: true });
case "24": return d.toLocaleTimeString(resolveLocale(lang), { hour: "2-digit", minute: "2-digit", hour12: false });
@@ -279,7 +332,7 @@ export function formatDateTime(iso: string | null | undefined, lang?: string): s
if (!iso) return "—";
try {
const d = new Date(iso);
return _formatDateObj(d, lang) + " " + _formatTimeObj(d, lang);
return _formatDateObj(d, lang) + " " + formatTimeOfDay(d, lang);
} catch {
return iso;
}
@@ -306,9 +359,37 @@ export function formatInterval(
}
/** Localized weekday name (0=Mon … 6=Sun) via Intl — 2024-01-01 is a Monday.
* Single source for the dialog selectors AND formatRecurrence (DRY). */
* Single source for the dialog selectors AND formatRecurrence (DRY).
*
* Name-only helpers (weekday / month / "Jul 3") follow the UI language like
* HA's own formatDateShort does — the profile date_format only orders
* NUMERIC dates. They still live here, not in the consumers: styles.ts is
* the only module allowed to call Intl for dates and numbers (see
* __tests__/profile-format-single-source.test.ts, #163). */
export function weekdayName(i: number, lang?: string, style: "long" | "short" = "long"): string {
return new Date(Date.UTC(2024, 0, 1 + i)).toLocaleDateString(langToLocale(lang), { weekday: style, timeZone: "UTC" });
return new Date(Date.UTC(2024, 0, 1 + i)).toLocaleDateString(resolveLocale(lang), { weekday: style, timeZone: "UTC" });
}
/** Localized month name (0=Jan … 11=Dec). */
export function monthName(i: number, lang?: string, style: "long" | "short" = "long"): string {
return new Date(Date.UTC(2024, i, 1)).toLocaleDateString(resolveLocale(lang), { month: style, timeZone: "UTC" });
}
/** Weekday / month name of a local Date (calendar day rows). */
export function formatWeekday(d: Date, lang?: string, style: "long" | "short" = "long"): string {
return weekdayName((d.getDay() + 6) % 7, lang, style);
}
export function formatMonth(d: Date, lang?: string, style: "long" | "short" = "long"): string {
return monthName(d.getMonth(), lang, style);
}
/** Short day+month ("Jul 3" / "3. Juli"), with a 2-digit year when the
* caller's range needs disambiguating — chart axis ticks and tooltips. */
export function formatDateShort(d: Date, lang?: string, withYear = false): string {
return d.toLocaleDateString(
resolveLocale(lang),
withYear ? { month: "short", day: "numeric", year: "2-digit" } : { month: "short", day: "numeric" },
);
}
/** Recurrence shape carried on the WS payload (see types.TaskSchedule). */