Added Alexa Music

This commit is contained in:
2026-07-17 10:12:15 -04:00
parent 92c5268dc8
commit 28a8cb98f6
757 changed files with 151171 additions and 85450 deletions
@@ -0,0 +1,58 @@
/** Unit tests for formatDate/formatDateTime honouring HA's per-user profile
* date/time format (issue #97 — dates followed only the UI language, so an
* English UI always showed mm/dd/yyyy even with dd/mm/yyyy configured). */
import { expect } from "@open-wc/testing";
import { formatDate, formatDateTime, setDateTimePrefs } from "../styles";
const reset = () => setDateTimePrefs({ date_format: undefined, time_format: undefined });
describe("formatDate with HA profile date_format (#97)", () => {
afterEach(reset);
it("DMY renders dd/mm/yyyy regardless of language", () => {
setDateTimePrefs({ date_format: "DMY" });
expect(formatDate("2026-08-10", "en")).to.equal("10/08/2026");
expect(formatDate("2026-08-10", "de")).to.equal("10/08/2026");
});
it("MDY renders mm/dd/yyyy regardless of language", () => {
setDateTimePrefs({ date_format: "MDY" });
expect(formatDate("2026-08-10", "de")).to.equal("08/10/2026");
});
it("YMD renders yyyy-mm-dd", () => {
setDateTimePrefs({ date_format: "YMD" });
expect(formatDate("2026-08-10", "en")).to.equal("2026-08-10");
});
it('"language" / unset keeps the language-derived format (backwards compatible)', () => {
setDateTimePrefs({ date_format: "language" });
expect(formatDate("2026-08-10", "en")).to.equal("08/10/2026"); // en → en-US
expect(formatDate("2026-08-10", "de")).to.equal("10.08.2026"); // de → de-DE
reset();
expect(formatDate("2026-08-10", "de")).to.equal("10.08.2026");
});
it("null/invalid input unchanged by prefs", () => {
setDateTimePrefs({ date_format: "DMY" });
expect(formatDate(null, "en")).to.equal("—");
});
});
describe("formatDateTime with HA profile time_format (#97)", () => {
afterEach(reset);
it("24 forces 24h clock even for en", () => {
setDateTimePrefs({ date_format: "DMY", time_format: "24" });
expect(formatDateTime("2026-08-10T14:30:00", "en")).to.equal("10/08/2026 14:30");
});
it("12 forces a 12h clock even for de", () => {
setDateTimePrefs({ date_format: "DMY", time_format: "12" });
const out = formatDateTime("2026-08-10T14:30:00", "de");
expect(out.startsWith("10/08/2026 ")).to.be.true;
expect(out).to.match(/2:30/);
expect(out.toLowerCase()).to.match(/pm|nachm/);
});
});
@@ -259,13 +259,13 @@ export class MaintenanceSettingsView extends LitElement {
${this._renderPanelAccess(L)}
${this._renderGeneral(L)}
${this._renderObjectsColumns(L)}
${this._renderTemplateToggles(L)}
${this._settings.general.notifications_enabled ? this._renderNotifications(L) : nothing}
${this.features.budget ? this._renderBudget(L) : nothing}
${this._renderArchive(L)}
${this._renderVacation(L)}
${this._renderPrintQr(L)}
${this._renderImportExport(L)}
${this._renderTemplateToggles(L)}
${this._toast ? html`<div class="settings-toast">${this._toast}</div>` : nothing}
`;
}
@@ -400,23 +400,52 @@ export class MaintenanceSettingsView extends LitElement {
private _renderTemplateToggles(L: string) {
this._loadTemplates();
const hidden = new Set(this._settings!.disabled_template_ids || []);
// v2.27: cluster by category (declaration order = display order) so the
// growing catalog stays scannable — each group gets a header with its
// icon, an enabled/total count and a toggle-all checkbox.
const byCat = new Map<string, typeof this._allTemplates>();
for (const catId of Object.keys(this._templateCategories)) byCat.set(catId, []);
for (const tpl of this._allTemplates) {
if (!byCat.has(tpl.category)) byCat.set(tpl.category, []);
byCat.get(tpl.category)!.push(tpl);
}
const catName = (catId: string) =>
(this._templateCategories[catId]?.["name_" + L] as string)
|| (this._templateCategories[catId]?.name_en as string)
|| catId;
return html`
<div class="settings-section" data-section="templates">
<h3>${t("settings_templates_label", L)}</h3>
<p class="section-desc">${t("settings_templates_hint", L)}</p>
${this._allTemplates.map((tpl) => html`
<label class="setting-row">
<span>
<span class="setting-label">${tpl.name}</span>
<span class="setting-desc">${this._templateCategories[tpl.category]?.["name_" + L] || this._templateCategories[tpl.category]?.name_en || tpl.category}</span>
</span>
<input
type="checkbox"
.checked=${!hidden.has(tpl.id)}
@change=${(e: Event) => this._toggleTemplate(tpl.id, (e.target as HTMLInputElement).checked)}
/>
</label>
`)}
${[...byCat.entries()].filter(([, tpls]) => tpls.length > 0).map(([catId, tpls]) => {
const enabled = tpls.filter((tpl) => !hidden.has(tpl.id)).length;
return html`
<div class="tpl-group">
<label class="tpl-group-head">
<ha-icon icon=${(this._templateCategories[catId]?.icon as string) || "mdi:folder-outline"}></ha-icon>
<span class="tpl-group-name">${catName(catId)}</span>
<span class="tpl-group-count">${enabled}/${tpls.length}</span>
<input
type="checkbox"
title=${t("settings_templates_toggle_group", L)}
.checked=${enabled === tpls.length}
@change=${(e: Event) =>
this._toggleTemplateGroup(tpls.map((tpl) => tpl.id), (e.target as HTMLInputElement).checked)}
/>
</label>
${tpls.map((tpl) => html`
<label class="setting-row tpl-row">
<span class="setting-label">${tpl.name}</span>
<input
type="checkbox"
.checked=${!hidden.has(tpl.id)}
@change=${(e: Event) => this._toggleTemplate(tpl.id, (e.target as HTMLInputElement).checked)}
/>
</label>
`)}
</div>
`;
})}
</div>
`;
}
@@ -428,6 +457,16 @@ export class MaintenanceSettingsView extends LitElement {
this._updateSetting("disabled_template_ids", [...hidden]);
}
/** Toggle-all for one category group in the template gallery. */
private _toggleTemplateGroup(ids: string[], visible: boolean): void {
const hidden = new Set(this._settings!.disabled_template_ids || []);
for (const id of ids) {
if (visible) hidden.delete(id);
else hidden.add(id);
}
this._updateSetting("disabled_template_ids", [...hidden]);
}
private _renderObjectsColumns(L: string) {
const selected = sanitizeColumns(this._settings!.objects_table_columns);
return html`
@@ -1539,6 +1578,25 @@ export class MaintenanceSettingsView extends LitElement {
cursor: pointer;
gap: 12px;
}
/* v2.27: template gallery clustered by category */
.tpl-group { margin-top: 14px; }
.tpl-group-head {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0 6px;
border-bottom: 2px solid var(--divider-color, #e0e0e0);
cursor: pointer;
font-weight: 600;
}
.tpl-group-head ha-icon { --mdc-icon-size: 18px; color: var(--primary-color); }
.tpl-group-name { flex: 1; }
.tpl-group-count {
font-size: 12px;
color: var(--secondary-text-color);
font-weight: 400;
}
.tpl-row { padding-left: 26px; }
.setting-row:last-child { border-bottom: none; }
.setting-row.sub-row {
padding-left: 16px;
@@ -766,5 +766,6 @@
"card_saved_view": "Uložené zobrazení",
"card_saved_view_none": "Žádné",
"card_saved_view_help": "Použije filtry stavu, uživatele a štítku zobrazení navíc k výše uvedeným filtrům. Řazení a seskupení zobrazení jsou nastavení panelu a na kartě se nepoužijí.",
"doc_part_none": "K tomuto dílu nejsou připojeny žádné dokumenty."
"doc_part_none": "K tomuto dílu nejsou připojeny žádné dokumenty.",
"settings_templates_toggle_group": "Zapnout nebo vypnout všechny šablony v této skupině"
}
@@ -766,5 +766,6 @@
"card_saved_view": "Gemt visning",
"card_saved_view_none": "Ingen",
"card_saved_view_help": "Anvender visningens status-, bruger- og etiketfiltre oven på filtrene ovenfor. Visningens sortering og gruppering er panelvisningsindstillinger og anvendes ikke på kortet.",
"doc_part_none": "Ingen dokumenter knyttet til denne del."
"doc_part_none": "Ingen dokumenter knyttet til denne del.",
"settings_templates_toggle_group": "Slå alle skabeloner i denne gruppe til eller fra"
}
@@ -766,5 +766,6 @@
"card_saved_view": "Gespeicherte Ansicht",
"card_saved_view_none": "Keine",
"card_saved_view_help": "Wendet die Status-, Nutzer- und Label-Filter der Ansicht zusätzlich zu den obigen Filtern an. Sortierung und Gruppierung der Ansicht sind Panel-Darstellung und werden auf der Karte nicht angewendet.",
"doc_part_none": "Keine Dokumente mit diesem Ersatzteil verknüpft."
"doc_part_none": "Keine Dokumente mit diesem Ersatzteil verknüpft.",
"settings_templates_toggle_group": "Alle Vorlagen dieser Gruppe ein- oder ausschalten"
}
@@ -766,5 +766,6 @@
"card_saved_view": "Saved view",
"card_saved_view_none": "None",
"card_saved_view_help": "Applies the view's status, user and label filters on top of the filters above. The view's sorting and grouping are panel display settings and are not applied on the card.",
"doc_part_none": "No documents linked to this part."
"doc_part_none": "No documents linked to this part.",
"settings_templates_toggle_group": "Enable or disable all templates in this group"
}
@@ -766,5 +766,6 @@
"card_saved_view": "Vista guardada",
"card_saved_view_none": "Ninguna",
"card_saved_view_help": "Aplica los filtros de estado, usuario y etiqueta de la vista además de los filtros anteriores. El orden y la agrupación de la vista son ajustes de visualización del panel y no se aplican en la tarjeta.",
"doc_part_none": "No hay documentos vinculados a esta pieza."
"doc_part_none": "No hay documentos vinculados a esta pieza.",
"settings_templates_toggle_group": "Activar o desactivar todas las plantillas de este grupo"
}
@@ -766,5 +766,6 @@
"card_saved_view": "Tallennettu näkymä",
"card_saved_view_none": "Ei mitään",
"card_saved_view_help": "Soveltaa näkymän tila-, käyttäjä- ja tunnistesuodattimet yllä olevien suodattimien lisäksi. Näkymän lajittelu ja ryhmittely ovat paneelin näyttöasetuksia eivätkä vaikuta korttiin.",
"doc_part_none": "Tähän osaan ei ole linkitetty asiakirjoja."
"doc_part_none": "Tähän osaan ei ole linkitetty asiakirjoja.",
"settings_templates_toggle_group": "Ota kaikki tämän ryhmän mallit käyttöön tai pois"
}
@@ -766,5 +766,6 @@
"card_saved_view": "Vue enregistrée",
"card_saved_view_none": "Aucune",
"card_saved_view_help": "Applique les filtres statut, utilisateur et étiquette de la vue en plus des filtres ci-dessus. Le tri et le regroupement de la vue sont des réglages d'affichage du panneau et ne s'appliquent pas à la carte.",
"doc_part_none": "Aucun document lié à cette pièce."
"doc_part_none": "Aucun document lié à cette pièce.",
"settings_templates_toggle_group": "Activer ou désactiver tous les modèles de ce groupe"
}
@@ -766,5 +766,6 @@
"card_saved_view": "सहेजा गया व्यू",
"card_saved_view_none": "कोई नहीं",
"card_saved_view_help": "ऊपर दिए फ़िल्टरों के अतिरिक्त व्यू के स्थिति, उपयोगकर्ता और लेबल फ़िल्टर लागू करता है। व्यू का क्रम और समूहन पैनल की प्रदर्शन सेटिंग हैं और कार्ड पर लागू नहीं होते।",
"doc_part_none": "इस पुर्ज़े से कोई दस्तावेज़ जुड़ा नहीं है।"
"doc_part_none": "इस पुर्ज़े से कोई दस्तावेज़ जुड़ा नहीं है।",
"settings_templates_toggle_group": "इस समूह के सभी टेम्पलेट चालू या बंद करें"
}
@@ -766,5 +766,6 @@
"card_saved_view": "Vista salvata",
"card_saved_view_none": "Nessuna",
"card_saved_view_help": "Applica i filtri stato, utente ed etichetta della vista in aggiunta ai filtri sopra. Ordinamento e raggruppamento della vista sono impostazioni di visualizzazione del pannello e non si applicano alla scheda.",
"doc_part_none": "Nessun documento collegato a questo ricambio."
"doc_part_none": "Nessun documento collegato a questo ricambio.",
"settings_templates_toggle_group": "Attiva o disattiva tutti i modelli di questo gruppo"
}
@@ -766,5 +766,6 @@
"card_saved_view": "保存済みビュー",
"card_saved_view_none": "なし",
"card_saved_view_help": "上記のフィルターに加えて、ビューのステータス・ユーザー・ラベルのフィルターを適用します。ビューの並び順とグループ化はパネルの表示設定であり、カードには適用されません。",
"doc_part_none": "この部品にリンクされた文書はありません。"
"doc_part_none": "この部品にリンクされた文書はありません。",
"settings_templates_toggle_group": "このグループのテンプレートをすべて有効/無効にする"
}
@@ -766,5 +766,6 @@
"card_saved_view": "Lagret visning",
"card_saved_view_none": "Ingen",
"card_saved_view_help": "Bruker visningens status-, bruker- og etikettfiltre i tillegg til filtrene over. Visningens sortering og gruppering er panelvisningsinnstillinger og brukes ikke på kortet.",
"doc_part_none": "Ingen dokumenter knyttet til denne delen."
"doc_part_none": "Ingen dokumenter knyttet til denne delen.",
"settings_templates_toggle_group": "Slå alle maler i denne gruppen på eller av"
}
@@ -766,5 +766,6 @@
"card_saved_view": "Opgeslagen weergave",
"card_saved_view_none": "Geen",
"card_saved_view_help": "Past de status-, gebruikers- en labelfilters van de weergave toe bovenop de bovenstaande filters. Sortering en groepering van de weergave zijn paneelweergave-instellingen en gelden niet voor de kaart.",
"doc_part_none": "Geen documenten gekoppeld aan dit onderdeel."
"doc_part_none": "Geen documenten gekoppeld aan dit onderdeel.",
"settings_templates_toggle_group": "Alle sjablonen in deze groep in- of uitschakelen"
}
@@ -766,5 +766,6 @@
"card_saved_view": "Zapisany widok",
"card_saved_view_none": "Brak",
"card_saved_view_help": "Stosuje filtry statusu, użytkownika i etykiety widoku oprócz powyższych filtrów. Sortowanie i grupowanie widoku to ustawienia wyświetlania panelu i nie są stosowane na karcie.",
"doc_part_none": "Brak dokumentów powiązanych z tą częścią."
"doc_part_none": "Brak dokumentów powiązanych z tą częścią.",
"settings_templates_toggle_group": "Włącz lub wyłącz wszystkie szablony w tej grupie"
}
@@ -766,5 +766,6 @@
"card_saved_view": "Vista guardada",
"card_saved_view_none": "Nenhuma",
"card_saved_view_help": "Aplica os filtros de estado, utilizador e etiqueta da vista além dos filtros acima. A ordenação e o agrupamento da vista são definições de apresentação do painel e não se aplicam ao cartão.",
"doc_part_none": "Nenhum documento associado a esta peça."
"doc_part_none": "Nenhum documento associado a esta peça.",
"settings_templates_toggle_group": "Ativar ou desativar todos os modelos deste grupo"
}
@@ -766,5 +766,6 @@
"card_saved_view": "Сохранённое представление",
"card_saved_view_none": "Нет",
"card_saved_view_help": "Применяет фильтры статуса, пользователя и метки представления в дополнение к фильтрам выше. Сортировка и группировка представления — настройки отображения панели и на карточке не применяются.",
"doc_part_none": "К этой запчасти не привязаны документы."
"doc_part_none": "К этой запчасти не привязаны документы.",
"settings_templates_toggle_group": "Включить или выключить все шаблоны этой группы"
}
@@ -766,5 +766,6 @@
"card_saved_view": "Sparad vy",
"card_saved_view_none": "Ingen",
"card_saved_view_help": "Tillämpar vyns status-, användar- och etikettfilter utöver filtren ovan. Vyns sortering och gruppering är panelvisningsinställningar och tillämpas inte på kortet.",
"doc_part_none": "Inga dokument länkade till denna del."
"doc_part_none": "Inga dokument länkade till denna del.",
"settings_templates_toggle_group": "Aktivera eller inaktivera alla mallar i denna grupp"
}
@@ -766,5 +766,6 @@
"card_saved_view": "Збережене подання",
"card_saved_view_none": "Немає",
"card_saved_view_help": "Застосовує фільтри статусу, користувача та мітки подання додатково до фільтрів вище. Сортування та групування подання — налаштування відображення панелі й на картці не застосовуються.",
"doc_part_none": "До цієї запчастини не прив'язано документів."
"doc_part_none": "До цієї запчастини не прив'язано документів.",
"settings_templates_toggle_group": "Увімкнути або вимкнути всі шаблони цієї групи"
}
@@ -766,5 +766,6 @@
"card_saved_view": "已保存的视图",
"card_saved_view_none": "无",
"card_saved_view_help": "在上述筛选之外再应用该视图的状态、用户和标签筛选。视图的排序和分组属于面板显示设置,不会应用到卡片上。",
"doc_part_none": "此部件没有关联的文档。"
"doc_part_none": "此部件没有关联的文档。",
"settings_templates_toggle_group": "启用或禁用此组中的所有模板"
}
@@ -30,7 +30,7 @@ import {
type CalendarEvent,
} from "./helpers/calendar-bucket";
import { calendarStyles } from "./calendar-styles";
import { sharedStyles, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded } from "./styles";
import { sharedStyles, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, setDateTimePrefs } from "./styles";
import type {
HomeAssistant,
MaintenanceObjectResponse,
@@ -114,6 +114,8 @@ export class MaintenanceCalendarCard extends LitElement {
updated(changedProps: Map<string, unknown>): void {
super.updated(changedProps);
// Dates/times follow the HA profile format, not just the language (#97).
if (changedProps.has("hass")) setDateTimePrefs(this.hass?.locale);
const lang = this.hass?.language;
if (lang && !isLocaleLoaded(lang)) {
ensureLocale(lang).then(() => this.requestUpdate());
@@ -2,7 +2,7 @@
import { LitElement, html, css, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded } from "./styles";
import { sharedStyles, STATUS_COLORS, t, ensureLocale, isLocaleLoaded, setDateTimePrefs } from "./styles";
import type {
HomeAssistant,
MaintenanceObjectResponse,
@@ -89,6 +89,8 @@ export class MaintenanceSupporterCard extends LitElement {
updated(changedProps: Map<string, unknown>): void {
super.updated(changedProps);
// Dates/times follow the HA profile format, not just the language (#97).
if (changedProps.has("hass")) setDateTimePrefs(this.hass?.locale);
const lang = this.hass?.language;
if (lang && !isLocaleLoaded(lang)) {
ensureLocale(lang).then(() => this.requestUpdate());
@@ -3,7 +3,7 @@
import { LitElement, html, nothing } from "lit";
import { isSafeHttpUrl } from "./helpers/url";
import { customElement, property, state } from "lit/decorators.js";
import { sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence } from "./styles";
import { sharedStyles, STATUS_COLORS, STATUS_ICONS, DEFAULT_CURRENCY_SYMBOL, t, ensureLocale, isLocaleLoaded, formatDate, formatDueDays, formatInterval, formatRecurrence, setDateTimePrefs } from "./styles";
import { LS_KEYS } from "./helpers/storage-keys";
import { daysProgress } from "./helpers/interval";
import { buildObjectReportHtml, type ReportLabels } from "./helpers/report";
@@ -269,6 +269,8 @@ export class MaintenanceSupporterPanel extends LitElement {
updated(changedProps: Map<string, unknown>): void {
super.updated(changedProps);
// Dates/times follow the HA profile format, not just the language (#97).
if (changedProps.has("hass")) setDateTimePrefs(this.hass?.locale);
// Lazy-load the user's UI language (non-EN tables aren't bundled) and
// re-render once it arrives. EN is bundled, so strings read in English
// until then rather than as raw keys.
@@ -45,6 +45,10 @@ export const panelStyles = css`
.filter-bar {
display: flex;
/* Wrap at EVERY width: with six filter dropdowns + action buttons the
bar doesn't fit one line even on wide tablets, and unwrapped flex
compressed the selects into unreadable stubs ("— No v", "Al"). */
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-end;
padding: 8px 0;
@@ -72,6 +76,9 @@ export const panelStyles = css`
border-radius: 4px;
background: var(--card-background-color, #fff);
color: var(--primary-text-color);
/* Readability floor selects wrap to the next line instead of
shrinking their selected value into ellipsis. */
min-width: 96px;
}
/* Desktop: the LIST owns the 7-column grid and every row is a subgrid
@@ -127,29 +127,72 @@ function langToLocale(lang?: string): string {
return map[l] ?? "en-US";
}
/** Format a date string (ISO) in the user's locale.
/** HA per-user date/time format prefs (issue #97).
*
* 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.
*/
interface DateTimePrefs {
date?: string;
time?: string;
}
const _w = window as unknown as { __msDateTimePrefs?: DateTimePrefs };
const DT_PREFS: DateTimePrefs = (_w.__msDateTimePrefs ??= {});
/** Feed HA's per-user date/time format into formatDate/formatDateTime. */
export function setDateTimePrefs(locale?: { date_format?: string; time_format?: string }): void {
if (!locale) return;
DT_PREFS.date = locale.date_format;
DT_PREFS.time = locale.time_format;
}
function _formatDateObj(d: Date, lang?: string): string {
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = String(d.getFullYear());
switch (DT_PREFS.date) {
case "DMY": return `${dd}/${mm}/${yyyy}`;
case "MDY": return `${mm}/${dd}/${yyyy}`;
case "YMD": return `${yyyy}-${mm}-${dd}`;
case "system":
return d.toLocaleDateString(undefined, { day: "2-digit", month: "2-digit", year: "numeric" });
default: // "language" or unset — derive from the UI language (pre-#97 behaviour)
return d.toLocaleDateString(langToLocale(lang), { day: "2-digit", month: "2-digit", year: "numeric" });
}
}
function _formatTimeObj(d: Date, lang?: string): string {
switch (DT_PREFS.time) {
case "12": return d.toLocaleTimeString(langToLocale(lang), { hour: "2-digit", minute: "2-digit", hour12: true });
case "24": return d.toLocaleTimeString(langToLocale(lang), { hour: "2-digit", minute: "2-digit", hour12: false });
case "system": return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
default: return d.toLocaleTimeString(langToLocale(lang), { hour: "2-digit", minute: "2-digit" });
}
}
/** Format a date string (ISO) honouring the HA profile date format.
* Appends T00:00:00 to date-only strings so JS parses them as local time, not UTC. */
export function formatDate(iso: string | null | undefined, lang?: string): string {
if (!iso) return "—";
try {
const local = iso.includes("T") ? iso : iso + "T00:00:00";
return new Date(local).toLocaleDateString(langToLocale(lang), { day: "2-digit", month: "2-digit", year: "numeric" });
return _formatDateObj(new Date(local), lang);
} catch {
return iso;
}
}
/** Format a datetime string (ISO) in the user's locale. */
/** Format a datetime string (ISO) honouring the HA profile date/time formats. */
export function formatDateTime(iso: string | null | undefined, lang?: string): string {
if (!iso) return "—";
try {
const locale = langToLocale(lang);
const d = new Date(iso);
return (
d.toLocaleDateString(locale, { day: "2-digit", month: "2-digit", year: "numeric" }) +
" " +
d.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" })
);
return _formatDateObj(d, lang) + " " + _formatTimeObj(d, lang);
} catch {
return iso;
}
@@ -1392,7 +1435,14 @@ export const sharedStyles = css`
.sparkline-svg { height: 100px; }
.stats-bar { gap: 8px; padding: 12px; }
.stat-item { min-width: 60px; }
/* min-width: 0 (NOT a fixed floor) a fixed min-width re-enables the
grid's auto minimum, so the 5 KPI tracks couldn't shrink below their
label text and the last KPI clipped off-screen on phones (the header
only *looked* cut .content scrolls sideways, but nothing hints so).
With 0 the tracks compress and the labels wrap to a second line. */
.stat-item { min-width: 0; }
.stat-item.clickable { padding: 4px 4px; }
.stat-item .stat-label { font-size: 11px; white-space: normal; text-align: center; line-height: 1.2; }
.stat-value { font-size: 20px; }
}
`;
@@ -464,7 +464,10 @@ export interface HomeAssistant {
}>;
}>>;
language: string;
locale?: { language: string; number_format?: string };
// date_format/time_format are HA's per-user PROFILE settings ("language" |
// "system" | "DMY" | "MDY" | "YMD" resp. "language" | "system" | "12" | "24")
// — issue #97: dates must follow them, not just the UI language.
locale?: { language: string; number_format?: string; date_format?: string; time_format?: string };
localize(key: string, ...args: unknown[]): string;
user?: { id: string; name: string; is_admin: boolean; is_owner: boolean };
/** Current access token used for authenticated `fetch()` to our HTTP