Compare commits

..

46 Commits

Author SHA1 Message Date
Aidan Timson
18fc990cf5 Format 2026-02-25 13:48:32 +00:00
Aidan Timson
6747dc2d90 Remove unnecessary copy
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 13:48:09 +00:00
Aidan Timson
38c09c6b98 Dont force ltr 2026-02-25 13:40:16 +00:00
Aidan Timson
46c1309bf0 Cache date formatting 2026-02-25 13:37:17 +00:00
Aidan Timson
7ce54b3ab0 Reflect property for style usage 2026-02-25 13:32:28 +00:00
Aidan Timson
b95bbcd71b Remove JS tick for analog clock 2026-02-25 12:26:32 +00:00
Aidan Timson
36c9f4ea57 Inline 2026-02-25 12:14:04 +00:00
Aidan Timson
77a043e18e Improve typing 2026-02-25 12:11:18 +00:00
Aidan Timson
361320ab60 Remove modern/legacy support diff 2026-02-25 11:56:19 +00:00
Aidan Timson
8092546b70 Fix 2026-02-25 11:53:56 +00:00
Aidan Timson
cb5daa87df Refactor 2026-02-25 11:34:45 +00:00
Aidan Timson
74042f4d17 Update description 2026-02-25 10:00:29 +00:00
Aidan Timson
b2eababfa7 Fix value render bug 2026-02-25 09:54:55 +00:00
Aidan Timson
41d27413f5 Cleanup 2026-02-25 09:43:55 +00:00
Aidan Timson
2e64f471c1 Remove conditional render on seperators 2026-02-25 09:30:40 +00:00
Aidan Timson
6561276983 Format 2026-02-25 09:13:53 +00:00
Aidan Timson
0ce57a9dfe Move section 2026-02-25 09:01:15 +00:00
Aidan Timson
5dd7ed22bd Fix 1st value as array 2026-02-25 08:59:10 +00:00
Aidan Timson
831c290158 Split date and clock to avoid layout shifts 2026-02-25 08:54:42 +00:00
Aidan Timson
5691d11e3c Margin 2026-02-25 08:54:42 +00:00
Aidan Timson
1a15711422 Reduce line height 2026-02-25 08:54:42 +00:00
Aidan Timson
f5e462b8e9 Use singular section name 2026-02-25 08:54:42 +00:00
Aidan Timson
2e47a12051 Add new line seperator 2026-02-25 08:54:42 +00:00
Aidan Timson
7e5b3ef59b Swap section in picker values 2026-02-25 08:54:42 +00:00
Aidan Timson
fe77991605 Rename 2026-02-25 08:54:42 +00:00
Aidan Timson
86108b08df Section in picker values 2026-02-25 08:54:42 +00:00
Aidan Timson
01956c69ce Preview in secondary 2026-02-25 08:54:42 +00:00
Aidan Timson
6eadae63a0 Remove section from labels 2026-02-25 08:54:42 +00:00
Aidan Timson
c427e42c68 Group sections 2026-02-25 08:54:42 +00:00
Aidan Timson
fb612be7ba Date formatter C 2026-02-25 08:54:42 +00:00
Aidan Timson
60ada7c159 Date formatter B 2026-02-25 08:54:42 +00:00
Aidan Timson
9a85a62548 Date formatter A 2026-02-25 08:54:42 +00:00
Aidan Timson
cf4890bbd6 Use local date var 2026-02-25 08:54:42 +00:00
Aidan Timson
5cdbef2362 Cleanup old translation 2026-02-25 08:54:42 +00:00
Aidan Timson
5115367d48 Scale small based on date length 2026-02-25 08:54:42 +00:00
Aidan Timson
652c6e5a0b Sizing (CSS Impl) 2026-02-25 08:54:42 +00:00
Aidan Timson
4615484fcd Sizing (JS Impl) 2026-02-25 08:54:42 +00:00
Aidan Timson
8009782759 Format 2026-02-25 08:54:42 +00:00
Aidan Timson
0a904b2cd4 Add 2026-02-25 08:54:42 +00:00
Aidan Timson
963f7086a9 Improve 2026-02-25 08:54:42 +00:00
Aidan Timson
c8a186d8d9 Type 2026-02-25 08:54:42 +00:00
Aidan Timson
8271400193 Setup 2026-02-25 08:54:42 +00:00
Aidan Timson
721323a32c Match 2026-02-25 08:54:42 +00:00
Aidan Timson
4c9c22130e Setup analog clock 2026-02-25 08:54:42 +00:00
Aidan Timson
135c913ebc Add date to digital clock 2026-02-25 08:54:42 +00:00
Aidan Timson
4e800bd3a9 Setup 2026-02-25 08:54:42 +00:00
47 changed files with 1903 additions and 1526 deletions

View File

@@ -34,10 +34,10 @@
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.6.0",
"@codemirror/state": "6.5.4",
"@codemirror/view": "6.39.15",
"@codemirror/view": "6.39.12",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.2.2",
"@formatjs/intl-datetimeformat": "7.2.1",
"@formatjs/intl-displaynames": "7.2.1",
"@formatjs/intl-durationformat": "0.10.1",
"@formatjs/intl-getcanonicallocales": "3.2.1",
@@ -118,7 +118,7 @@
"lit": "3.3.2",
"lit-html": "3.3.2",
"luxon": "3.7.2",
"marked": "17.0.3",
"marked": "17.0.1",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -148,14 +148,14 @@
"@babel/helper-define-polyfill-provider": "0.6.6",
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/preset-env": "7.29.0",
"@bundle-stats/plugin-webpack-filter": "4.21.10",
"@html-eslint/eslint-plugin": "0.56.0",
"@bundle-stats/plugin-webpack-filter": "4.21.9",
"@html-eslint/eslint-plugin": "0.55.0",
"@lokalise/node-api": "15.6.1",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/plugin-retry": "8.0.3",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.2",
"@rspack/core": "1.7.6",
"@rspack/core": "1.7.5",
"@rspack/dev-server": "1.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.25",
@@ -180,18 +180,18 @@
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.1",
"eslint": "9.39.3",
"eslint": "9.39.2",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.10",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-lit": "2.2.1",
"eslint-plugin-lit": "2.1.1",
"eslint-plugin-lit-a11y": "5.1.1",
"eslint-plugin-unused-imports": "4.4.1",
"eslint-plugin-unused-imports": "4.3.0",
"eslint-plugin-wc": "3.0.2",
"fancy-log": "2.0.0",
"fs-extra": "11.3.3",
"glob": "13.0.6",
"glob": "13.0.1",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260225.0"
version = "20260128.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"

View File

@@ -210,39 +210,3 @@ const formatDateWeekdayShortMem = memoizeOne(
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
})
);
// Mon, Aug 10
export const formatDateWeekdayVeryShortDate = (
dateObj: Date,
locale: FrontendLocaleData,
config: HassConfig
) =>
formatDateWeekdayVeryShortDateMem(locale, config.time_zone).format(dateObj);
const formatDateWeekdayVeryShortDateMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language, {
weekday: "short",
month: "short",
day: "numeric",
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
})
);
// Mon, Aug 10, 2021
export const formatDateWeekdayShortDate = (
dateObj: Date,
locale: FrontendLocaleData,
config: HassConfig
) => formatDateWeekdayShortDateMem(locale, config.time_zone).format(dateObj);
const formatDateWeekdayShortDateMem = memoizeOne(
(locale: FrontendLocaleData, serverTimeZone: string) =>
new Intl.DateTimeFormat(locale.language, {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
})
);

View File

@@ -136,6 +136,9 @@ export class HaDataTable extends LitElement {
@property({ attribute: false }) public searchLabel?: string;
@property({ type: Boolean, attribute: "no-label-float" })
public noLabelFloat? = false;
@property({ type: String }) public filter = "";
@property({ attribute: false }) public groupColumn?: string;
@@ -397,6 +400,7 @@ export class HaDataTable extends LitElement {
.hass=${this.hass}
@value-changed=${this._handleSearchChange}
.label=${this.searchLabel}
.noLabelFloat=${this.noLabelFloat}
></search-input>
</div>
`
@@ -430,9 +434,9 @@ export class HaDataTable extends LitElement {
<ha-checkbox
class="mdc-data-table__row-checkbox"
@change=${this._handleHeaderRowCheckboxClick}
.indeterminate=${!!this._checkedRows.length &&
.indeterminate=${this._checkedRows.length &&
this._checkedRows.length !== this._checkableRowsCount}
.checked=${!!this._checkedRows.length &&
.checked=${this._checkedRows.length &&
this._checkedRows.length === this._checkableRowsCount}
>
</ha-checkbox>

View File

@@ -15,7 +15,6 @@ import { iconColorCSS } from "../../common/style/icon_color_css";
import { cameraUrlWithWidthHeight } from "../../data/camera";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import type { HomeAssistant } from "../../types";
import { addBrandsAuth } from "../../util/brands-url";
import "../ha-state-icon";
@customElement("state-badge")
@@ -138,7 +137,6 @@ export class StateBadge extends LitElement {
let imageUrl =
stateObj.attributes.entity_picture_local ||
stateObj.attributes.entity_picture;
imageUrl = addBrandsAuth(imageUrl);
if (this.hass) {
imageUrl = this.hass.hassUrl(imageUrl);
}

View File

@@ -84,9 +84,6 @@ export class HaButton extends Button {
--button-color-fill-loud-hover: var(
--ha-color-fill-primary-loud-hover
);
--button-color-fill-quiet-active: var(
--ha-color-fill-primary-quiet-active
);
}
:host([variant="neutral"]) {
@@ -102,9 +99,6 @@ export class HaButton extends Button {
--button-color-fill-loud-hover: var(
--ha-color-fill-neutral-loud-hover
);
--button-color-fill-quiet-active: var(
--ha-color-fill-neutral-normal-active
);
}
:host([variant="success"]) {
@@ -120,9 +114,6 @@ export class HaButton extends Button {
--button-color-fill-loud-hover: var(
--ha-color-fill-success-loud-hover
);
--button-color-fill-quiet-active: var(
--ha-color-fill-success-quiet-active
);
}
:host([variant="warning"]) {
@@ -138,9 +129,6 @@ export class HaButton extends Button {
--button-color-fill-loud-hover: var(
--ha-color-fill-warning-loud-hover
);
--button-color-fill-quiet-active: var(
--ha-color-fill-warning-quiet-active
);
}
:host([variant="danger"]) {
@@ -156,9 +144,6 @@ export class HaButton extends Button {
--button-color-fill-loud-hover: var(
--ha-color-fill-danger-loud-hover
);
--button-color-fill-quiet-active: var(
--ha-color-fill-danger-quiet-active
);
}
:host([appearance~="plain"]) .button {
@@ -202,10 +187,6 @@ export class HaButton extends Button {
background-color: var(--ha-color-fill-disabled-normal-resting);
color: var(--ha-color-on-disabled-normal);
}
:host([appearance~="plain"])
.button:not(.disabled):not(.loading):active {
background-color: var(--button-color-fill-quiet-active);
}
:host([appearance~="accent"]) .button {
background-color: var(

View File

@@ -0,0 +1,522 @@
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array";
import { resolveTimeZone } from "../common/datetime/resolve-time-zone";
import { fireEvent } from "../common/dom/fire_event";
import {
CLOCK_CARD_DATE_PARTS,
formatClockCardDate,
} from "../panels/lovelace/cards/clock/clock-date-format";
import type { ClockCardDatePart } from "../panels/lovelace/cards/types";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./chips/ha-assist-chip";
import "./chips/ha-chip-set";
import "./chips/ha-input-chip";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-input-helper-text";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-sortable";
type ClockDatePartSection = "weekday" | "day" | "month" | "year" | "separator";
type ClockDateSeparatorPart = Extract<
ClockCardDatePart,
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
>;
const CLOCK_DATE_PART_SECTION_ORDER: readonly ClockDatePartSection[] = [
"day",
"month",
"year",
"weekday",
"separator",
];
const CLOCK_DATE_SEPARATOR_VALUES: Record<ClockDateSeparatorPart, string> = {
"separator-dash": "-",
"separator-slash": "/",
"separator-dot": ".",
"separator-new-line": "",
};
const getClockDatePartSection = (
part: ClockCardDatePart
): ClockDatePartSection => {
if (part.startsWith("weekday-")) {
return "weekday";
}
if (part.startsWith("day-")) {
return "day";
}
if (part.startsWith("month-")) {
return "month";
}
if (part.startsWith("year-")) {
return "year";
}
return "separator";
};
interface ClockDatePartSectionData {
id: ClockDatePartSection;
title: string;
items: PickerComboBoxItem[];
}
interface ClockDatePartValueItem {
key: string;
item: string;
idx: number;
}
@customElement("ha-clock-date-format-picker")
export class HaClockDateFormatPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property() public label?: string;
@property() public value?: string[] | string;
@property() public helper?: string;
@query("ha-generic-picker", true) private _picker?: HaGenericPicker;
private _editIndex?: number;
protected render() {
const value = this._value;
const valueItems = this._getValueItems(value);
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${this._getPickerValue()}
.sections=${this._getSections(this.hass.locale.language)}
.getItems=${this._getItems}
@value-changed=${this._pickerValueChanged}
>
<div slot="field" class="container">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
valueItems,
(entry: ClockDatePartValueItem) => entry.key,
({ item, idx }) => this._renderValueChip(item, idx)
)}
${this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this.hass.localize("ui.common.add")}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`}
</ha-chip-set>
</ha-sortable>
</div>
</ha-generic-picker>
${this._renderHelper()}
`;
}
private _renderHelper() {
return this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
: nothing;
}
private _getValueItems = memoizeOne(
(value: string[]): ClockDatePartValueItem[] => {
const occurrences = new Map<string, number>();
return value.map((item, idx) => {
const occurrence = occurrences.get(item) ?? 0;
occurrences.set(item, occurrence + 1);
return {
key: `${item}:${occurrence}`,
item,
idx,
};
});
}
);
private _renderValueChip(item: string, idx: number) {
const label = this._getItemLabel(item, this.hass.locale.language);
const isValid = !!label;
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label ?? item}
.selected=${!this.disabled}
.disabled=${this.disabled}
class=${!isValid ? "invalid" : ""}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</ha-input-chip>
`;
}
private async _addItem(ev: Event) {
ev.stopPropagation();
if (this.disabled) {
return;
}
this._editIndex = undefined;
await this.updateComplete;
await this._picker?.open();
}
private async _editItem(ev: Event) {
ev.stopPropagation();
if (this.disabled) {
return;
}
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
10
);
this._editIndex = idx;
await this.updateComplete;
await this._picker?.open();
}
private get _value() {
return !this.value ? [] : ensureArray(this.value);
}
private _toValue = memoizeOne((value: string[]): string[] | undefined =>
value.length === 0 ? undefined : value
);
private _buildSections = memoizeOne(
(language: string): ClockDatePartSectionData[] => {
const itemsBySection: Record<ClockDatePartSection, PickerComboBoxItem[]> =
{
weekday: [],
day: [],
month: [],
year: [],
separator: [],
};
const previewDate = new Date();
const previewTimeZone = resolveTimeZone(
this.hass.locale.time_zone,
this.hass.config.time_zone
);
CLOCK_CARD_DATE_PARTS.forEach((part) => {
const section = getClockDatePartSection(part);
const label =
this.hass.localize(
`ui.panel.lovelace.editor.card.clock.date.parts.${part}`
) ?? part;
const secondary =
section === "separator"
? CLOCK_DATE_SEPARATOR_VALUES[part as ClockDateSeparatorPart]
: formatClockCardDate(
previewDate,
{ parts: [part] },
language,
previewTimeZone
);
itemsBySection[section].push({
id: part,
primary: label,
secondary,
sorting_label: label,
});
});
return CLOCK_DATE_PART_SECTION_ORDER.map((section) => ({
id: section,
title:
this.hass.localize(
`ui.panel.lovelace.editor.card.clock.date.sections.${section}`
) ?? section,
items: itemsBySection[section],
})).filter((section) => section.items.length > 0);
}
);
private _getSections = memoizeOne(
(_language: string): { id: string; label: string }[] =>
this._buildSections(_language).map((section) => ({
id: section.id,
label: section.title,
}))
);
private _getItems = (
searchString?: string,
section?: string
): (PickerComboBoxItem | string)[] => {
const normalizedSearch = searchString?.trim().toLowerCase();
const sections = this._buildSections(this.hass.locale.language)
.map((sectionData) => {
if (!normalizedSearch) {
return sectionData;
}
return {
...sectionData,
items: sectionData.items.filter(
(item) =>
item.primary.toLowerCase().includes(normalizedSearch) ||
item.secondary?.toLowerCase().includes(normalizedSearch) ||
item.id.toLowerCase().includes(normalizedSearch)
),
};
})
.filter((sectionData) => sectionData.items.length > 0);
if (section) {
return (
sections.find((candidate) => candidate.id === section)?.items || []
);
}
const groupedItems: (PickerComboBoxItem | string)[] = [];
sections.forEach((sectionData) => {
groupedItems.push(sectionData.title, ...sectionData.items);
});
return groupedItems;
};
private _getItemLabel = memoizeOne(
(value: string, language: string): string | undefined => {
const sections = this._buildSections(language);
for (const section of sections) {
const item = section.items.find((candidate) => candidate.id === value);
if (item) {
if (section.id === "separator") {
if (value === "separator-new-line") {
return item.primary;
}
return item.secondary ?? item.primary;
}
return `${item.secondary} [${item.primary} ${section.title}]`;
}
}
return undefined;
}
);
private _getPickerValue(): string | undefined {
if (this._editIndex != null) {
return this._value[this._editIndex];
}
return undefined;
}
private async _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const value = this._value;
const newValue = value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
}
private async _removeItem(ev: Event) {
ev.preventDefault();
ev.stopPropagation();
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
10
);
if (Number.isNaN(idx)) {
return;
}
const value = [...this._value];
value.splice(idx, 1);
if (this._editIndex !== undefined) {
if (this._editIndex === idx) {
this._editIndex = undefined;
} else if (this._editIndex > idx) {
this._editIndex -= 1;
}
}
this._setValue(value);
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const value = ev.detail.value;
if (this.disabled || !value) {
return;
}
const newValue = [...this._value];
if (this._editIndex != null) {
newValue[this._editIndex] = value;
this._editIndex = undefined;
} else {
newValue.push(value);
}
this._setValue(newValue);
if (this._picker) {
this._picker.value = undefined;
}
}
private _setValue(value: string[]) {
const newValue = this._toValue(value);
this.value = newValue;
fireEvent(this, "value-changed", {
value: newValue,
});
}
static styles = css`
:host {
position: relative;
width: 100%;
}
.container {
position: relative;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: var(--ha-border-radius-sm);
border-end-end-radius: var(--ha-border-radius-square);
border-end-start-radius: var(--ha-border-radius-square);
}
.container:after {
display: block;
content: "";
position: absolute;
pointer-events: none;
bottom: 0;
left: 0;
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
transition:
height 180ms ease-in-out,
background-color 180ms ease-in-out;
}
:host([disabled]) .container:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
.container:focus-within:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
label {
display: block;
margin: 0 0 var(--ha-space-2);
}
.add {
order: 1;
}
ha-chip-set {
padding: var(--ha-space-2);
}
.invalid {
text-decoration: line-through;
}
.sortable-fallback {
display: none;
opacity: 0;
}
.sortable-ghost {
opacity: 0.4;
}
.sortable-drag {
cursor: grabbing;
}
ha-input-helper-text {
display: block;
margin: var(--ha-space-2) 0 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-clock-date-format-picker": HaClockDateFormatPicker;
}
}

View File

@@ -13,11 +13,7 @@ import {
} from "../../data/media-player";
import type { MediaSelector, MediaSelectorValue } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import {
brandsUrl,
extractDomainFromBrandUrl,
isBrandUrl,
} from "../../util/brands-url";
import { brandsUrl, extractDomainFromBrandUrl } from "../../util/brands-url";
import "../ha-alert";
import "../ha-form/ha-form";
import type { SchemaUnion } from "../ha-form/types";
@@ -76,7 +72,16 @@ export class HaMediaSelector extends LitElement {
if (thumbnail === oldThumbnail) {
return;
}
if (thumbnail && isBrandUrl(thumbnail)) {
if (thumbnail && thumbnail.startsWith("/")) {
this._thumbnailUrl = undefined;
// Thumbnails served by local API require authentication
getSignedPath(this.hass, thumbnail).then((signedPath) => {
this._thumbnailUrl = signedPath.path;
});
} else if (
thumbnail &&
thumbnail.startsWith("https://brands.home-assistant.io")
) {
// The backend is not aware of the theme used by the users,
// so we rewrite the URL to show a proper icon
this._thumbnailUrl = brandsUrl({
@@ -84,12 +89,6 @@ export class HaMediaSelector extends LitElement {
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
});
} else if (thumbnail && thumbnail.startsWith("/")) {
this._thumbnailUrl = undefined;
// Thumbnails served by local API require authentication
getSignedPath(this.hass, thumbnail).then((signedPath) => {
this._thumbnailUrl = signedPath.path;
});
} else {
this._thumbnailUrl = thumbnail;
}

View File

@@ -0,0 +1,41 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { UiClockDateFormatSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-clock-date-format-picker";
@customElement("ha-selector-ui_clock_date_format")
export class HaSelectorUiClockDateFormat extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: UiClockDateFormatSelector;
@property() public value?: string | string[];
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
protected render() {
return html`
<ha-clock-date-format-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
></ha-clock-date-format-picker>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-ui_clock_date_format": HaSelectorUiClockDateFormat;
}
}

View File

@@ -61,6 +61,7 @@ const LOAD_ELEMENTS = {
location: () => import("./ha-selector-location"),
color_temp: () => import("./ha-selector-color-temp"),
ui_action: () => import("./ha-selector-ui-action"),
ui_clock_date_format: () => import("./ha-selector-ui-clock-date-format"),
ui_color: () => import("./ha-selector-ui-color"),
ui_state_content: () => import("./ha-selector-ui-state-content"),
};

View File

@@ -144,7 +144,6 @@ export const computePanels = memoizeOne(
if (
!isDefaultPanel &&
(!panel.title ||
panel.show_in_sidebar === false ||
hiddenPanels.includes(panel.url_path) ||
(panel.default_visible === false &&
!panelsOrder.includes(panel.url_path)))

View File

@@ -765,16 +765,6 @@ export class HaMediaPlayerBrowse extends LitElement {
return "";
}
if (isBrandUrl(thumbnailUrl)) {
// The backend is not aware of the theme used by the users,
// so we rewrite the URL to show a proper icon
return brandsUrl({
domain: extractDomainFromBrandUrl(thumbnailUrl),
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
});
}
if (thumbnailUrl.startsWith("/")) {
// Thumbnails served by local API require authentication
return new Promise((resolve, reject) => {
@@ -797,6 +787,16 @@ export class HaMediaPlayerBrowse extends LitElement {
});
}
if (isBrandUrl(thumbnailUrl)) {
// The backend is not aware of the theme used by the users,
// so we rewrite the URL to show a proper icon
thumbnailUrl = brandsUrl({
domain: extractDomainFromBrandUrl(thumbnailUrl),
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
});
}
return thumbnailUrl;
}

View File

@@ -3,6 +3,7 @@ import { formatDurationDigital } from "../../common/datetime/format_duration";
import type { FrontendLocaleData } from "../translation";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
// These attributes are hidden from the more-info window for all entities.
export const STATE_ATTRIBUTES = [
"entity_id",
"assumed_state",
@@ -28,6 +29,8 @@ export const STATE_ATTRIBUTES = [
"available_tones",
];
// These attributes are hidden from the more-info window for entities of the
// matching domain and device_class.
export const STATE_ATTRIBUTES_DOMAIN_CLASS = {
sensor: {
enum: ["options"],

View File

@@ -74,6 +74,7 @@ export type Selector =
| TTSSelector
| TTSVoiceSelector
| UiActionSelector
| UiClockDateFormatSelector
| UiColorSelector
| UiStateContentSelector
| BackupLocationSelector;
@@ -505,6 +506,10 @@ export interface UiActionSelector {
} | null;
}
export interface UiClockDateFormatSelector {
ui_clock_date_format: {} | null;
}
export interface UiColorSelector {
ui_color: {
default_color?: string;

View File

@@ -0,0 +1,136 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeAttributeNameDisplay } from "../../common/entity/compute_attribute_display";
import "../../components/ha-attribute-value";
import "../../components/ha-card";
import { computeShownAttributes } from "../../data/entity/entity_attributes";
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
interface AttributesViewParams {
entityId: string;
}
@customElement("ha-more-info-attributes")
class HaMoreInfoAttributes extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entry?: ExtEntityRegistryEntry | null;
@property({ attribute: false }) public params?: AttributesViewParams;
@state() private _stateObj?: HassEntity;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("params") || changedProps.has("hass")) {
if (this.params?.entityId && this.hass) {
this._stateObj = this.hass.states[this.params.entityId];
}
}
}
protected render() {
if (!this.params || !this._stateObj) {
return nothing;
}
const attributes = computeShownAttributes(this._stateObj);
return html`
<div class="content">
<ha-card>
<div class="card-content">
${attributes.map(
(attribute) => html`
<div class="data-entry">
<div class="key">
${computeAttributeNameDisplay(
this.hass.localize,
this._stateObj!,
this.hass.entities,
attribute
)}
</div>
<div class="value">
<ha-attribute-value
.hass=${this.hass}
.attribute=${attribute}
.stateObj=${this._stateObj}
></ha-attribute-value>
</div>
</div>
`
)}
</div>
</ha-card>
${this._stateObj.attributes.attribution
? html`
<div class="attribution">
${this._stateObj.attributes.attribution}
</div>
`
: nothing}
</div>
`;
}
static styles: CSSResultGroup = css`
:host {
display: flex;
flex-direction: column;
flex: 1;
}
.content {
padding: var(--ha-space-6);
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-6));
}
ha-card {
direction: ltr;
}
.card-content {
padding: var(--ha-space-2) var(--ha-space-4);
}
.data-entry {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: var(--ha-space-2) 0;
border-bottom: 1px solid var(--divider-color);
}
.data-entry:last-of-type {
border-bottom: none;
}
.data-entry .value {
max-width: 60%;
overflow-wrap: break-word;
text-align: right;
}
.key {
flex-grow: 1;
color: var(--secondary-text-color);
}
.attribution {
color: var(--secondary-text-color);
text-align: center;
margin-top: var(--ha-space-4);
font-size: var(--ha-font-size-s);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-attributes": HaMoreInfoAttributes;
}
}

View File

@@ -1,189 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeAttributeNameDisplay } from "../../common/entity/compute_attribute_display";
import "../../components/ha-attribute-value";
import "../../components/ha-card";
import { computeShownAttributes } from "../../data/entity/entity_attributes";
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
interface DetailsViewParams {
entityId: string;
}
@customElement("ha-more-info-details")
class HaMoreInfoDetails extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entry?: ExtEntityRegistryEntry | null;
@property({ attribute: false }) public params?: DetailsViewParams;
@state() private _stateObj?: HassEntity;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("params") || changedProps.has("hass")) {
if (this.params?.entityId && this.hass) {
this._stateObj = this.hass.states[this.params.entityId];
}
}
}
protected render() {
if (!this.params || !this._stateObj) {
return nothing;
}
const translatedState = this.hass.formatEntityState(this._stateObj);
const detailsAttributes = computeShownAttributes(this._stateObj);
const detailsAttributeSet = new Set(detailsAttributes);
const builtInAttributes = Object.keys(this._stateObj.attributes).filter(
(attribute) => !detailsAttributeSet.has(attribute)
);
const allAttributes = [...detailsAttributes, ...builtInAttributes];
return html`
<div class="content">
<section class="section">
<h2 class="section-title">
${this.hass.localize(
"ui.components.entity.entity-state-picker.state"
)}
</h2>
<ha-card>
<div class="card-content">
<div class="attribute-group">
<div class="data-entry">
<div class="key">
${this.hass.localize(
"ui.dialogs.more_info_control.translated"
)}
</div>
<div class="value">${translatedState}</div>
</div>
<div class="data-entry">
<div class="key">
${this.hass.localize("ui.dialogs.more_info_control.raw")}
</div>
<div class="value">${this._stateObj.state}</div>
</div>
</div>
</div>
</ha-card>
</section>
<section class="section">
<h2 class="section-title">
${this.hass.localize("ui.dialogs.more_info_control.attributes")}
</h2>
<ha-card>
<div class="card-content">
<div class="attribute-group">
${this._renderAttributes(allAttributes)}
</div>
</div>
</ha-card>
</section>
</div>
`;
}
private _renderAttributes(attributes: string[]) {
if (attributes.length === 0) {
return html`<div class="empty">
${this.hass.localize("ui.common.none")}
</div>`;
}
return attributes.map(
(attribute) => html`
<div class="data-entry">
<div class="key">
${computeAttributeNameDisplay(
this.hass.localize,
this._stateObj!,
this.hass.entities,
attribute
)}
</div>
<div class="value">
<ha-attribute-value
.hass=${this.hass}
.attribute=${attribute}
.stateObj=${this._stateObj}
></ha-attribute-value>
</div>
</div>
`
);
}
static styles: CSSResultGroup = css`
:host {
display: flex;
flex-direction: column;
flex: 1;
}
.content {
padding: var(--ha-space-6);
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-6));
}
.section + .section {
margin-top: var(--ha-space-4);
}
.section-title {
margin: 0 0 var(--ha-space-2);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
}
ha-card {
direction: ltr;
}
.card-content {
padding: var(--ha-space-2) var(--ha-space-4);
}
.data-entry {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: var(--ha-space-2) 0;
border-bottom: 1px solid var(--divider-color);
}
.attribute-group .data-entry:last-of-type {
border-bottom: none;
}
.data-entry .value {
max-width: 60%;
overflow-wrap: break-word;
text-align: right;
}
.key {
flex-grow: 1;
color: var(--secondary-text-color);
}
.empty {
color: var(--secondary-text-color);
text-align: center;
padding: var(--ha-space-2) 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-details": HaMoreInfoDetails;
}
}

View File

@@ -44,6 +44,7 @@ import "../../components/ha-dropdown-item";
import "../../components/ha-icon-button";
import "../../components/ha-icon-button-prev";
import "../../components/ha-related-items";
import { computeShownAttributes } from "../../data/entity/entity_attributes";
import type {
EntityRegistryEntry,
ExtEntityRegistryEntry,
@@ -343,21 +344,31 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
case "info":
this._resetInitialView();
break;
case "details":
this._showDetails();
case "attributes":
this._showAttributes();
break;
}
}
private _showDetails(): void {
import("./ha-more-info-details");
private _showAttributes(): void {
import("./ha-more-info-attributes");
this._childView = {
viewTag: "ha-more-info-details",
viewTitle: this.hass.localize("ui.dialogs.more_info_control.details"),
viewTag: "ha-more-info-attributes",
viewParams: { entityId: this._entityId },
};
}
private _hasDisplayableAttributes(): boolean {
if (!this._entityId) {
return false;
}
const stateObj = this.hass.states[this._entityId];
if (!stateObj) {
return false;
}
return computeShownAttributes(stateObj).length > 0;
}
private _goToAddEntityTo(ev) {
// Only check for request-selected events (from menu items), not regular clicks (from icon button)
if (ev.type === "request-selected" && !shouldHandleRequestSelectedEvent(ev))
@@ -579,15 +590,19 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
"ui.dialogs.more_info_control.related"
)}
</ha-dropdown-item>
<ha-dropdown-item value="details">
<ha-svg-icon
slot="icon"
.path=${mdiFormatListBulletedSquare}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.details"
)}
</ha-dropdown-item>
${this._hasDisplayableAttributes()
? html`
<ha-dropdown-item value="attributes">
<ha-svg-icon
slot="icon"
.path=${mdiFormatListBulletedSquare}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.attributes"
)}
</ha-dropdown-item>
`
: nothing}
${this._shouldShowAddEntityTo()
? html`
<ha-dropdown-item value="add_to">

View File

@@ -32,28 +32,21 @@ const initRouting = () => {
new CacheFirst({ matchOptions: { ignoreSearch: true } })
);
// Cache any brand images used for 1 day
// Brands are proxied via the local API with backend caching.
// Strip the rotating access token from cache keys so token rotation
// doesn't bust the cache, while preserving other params like "placeholder".
// Cache any brand images used for 30 days
// Use revalidation so cache is always available during an extended outage
registerRoute(
({ url, request }) =>
url.pathname.startsWith("/api/brands/") &&
url.origin === "https://brands.home-assistant.io" &&
request.destination === "image",
new StaleWhileRevalidate({
cacheName: "brands",
// CORS must be forced to work for CSS images
fetchOptions: { mode: "cors", credentials: "omit" },
plugins: [
{
cacheKeyWillBeUsed: async ({ request }) => {
const url = new URL(request.url);
url.searchParams.delete("token");
return url.href;
},
},
// Add 404 so we quickly respond to domains with missing images
new CacheableResponsePlugin({ statuses: [0, 200, 404] }),
new ExpirationPlugin({
maxAgeSeconds: 60 * 60 * 24,
maxAgeSeconds: 60 * 60 * 24 * 30,
purgeOnQuotaError: true,
}),
],

View File

@@ -51,6 +51,8 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ type: Boolean }) public supervisor = false;
@property({ type: Boolean, attribute: "main-page" }) public mainPage = false;
@property({ attribute: false }) public initialCollapsedGroups: string[] = [];
@@ -320,6 +322,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
? html`
<ha-dropdown-item
.value=${id}
.clickAction=${this._handleGroupBy}
.selected=${id === this._groupColumn}
class=${classMap({ selected: id === this._groupColumn })}
>
@@ -380,6 +383,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
.route=${this.route}
.tabs=${this.tabs}
.mainPage=${this.mainPage}
.supervisor=${this.supervisor}
.pane=${showPane && this.showFilters}
@sorting-changed=${this._sortingChanged}
>
@@ -485,6 +489,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
: ""}
<ha-data-table
.hass=${this.hass}
.localize=${localize}
.narrow=${this.narrow}
.columns=${this.columns}
.data=${this.data}

View File

@@ -96,6 +96,7 @@ class HassTabsSubpage extends LitElement {
(page) => html`
<a href=${page.path} @click=${this._tabClicked}>
<ha-tab
.hass=${this.hass}
.active=${page.path === activeTab?.path}
.narrow=${this.narrow}
.name=${page.translationKey

View File

@@ -578,6 +578,7 @@ class AddIntegrationDialog extends LitElement {
}
return html`
<ha-integration-list-item
brand
.hass=${this.hass}
.integration=${integration}
tabindex="0"

View File

@@ -30,6 +30,8 @@ export class HaIntegrationListItem extends ListItemBase {
// eslint-disable-next-line lit/attribute-names
@property({ type: Boolean }) hasMeta = true;
@property({ type: Boolean }) brand = false;
// @ts-expect-error
protected override renderSingleLine() {
if (!this.integration) {
@@ -66,6 +68,7 @@ export class HaIntegrationListItem extends ListItemBase {
domain: this.integration.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
brand: this.brand,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"

View File

@@ -224,6 +224,7 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
slot="graphic"
.src=${brandsUrl({
domain: router.brand,
brand: true,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}

View File

@@ -344,7 +344,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
const item: DataTableItem = {
icon: getPanelIcon(panelInfo),
title: getPanelTitle(this.hass, panelInfo) || panelInfo.url_path,
show_in_sidebar: panelInfo.show_in_sidebar || false,
show_in_sidebar: panelInfo.title != null,
mode: "storage",
url_path: panelInfo.url_path,
filename: "",
@@ -487,7 +487,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
title: getPanelTitle(this.hass, panelInfo) || panelInfo.url_path,
icon: getPanelIcon(panelInfo),
requireAdmin: panelInfo.require_admin || false,
showInSidebar: panelInfo.show_in_sidebar || false,
showInSidebar: panelInfo.title != null,
isDefault: panelInfo.url_path === defaultPanel,
updatePanel: async (values) => {
await updatePanel(this.hass!, panelInfo.url_path, values);

View File

@@ -39,9 +39,6 @@ export class PowerViewStrategy extends ReactiveElement {
const hasPowerDevices = prefs?.device_consumption.some(
(device) => device.stat_rate
);
const hasWaterDevices = prefs?.device_consumption_water.some(
(device) => device.stat_rate
);
const hasWaterSources = prefs?.energy_sources.some(
(source) => source.type === "water" && source.stat_rate
);
@@ -67,12 +64,11 @@ export class PowerViewStrategy extends ReactiveElement {
max_columns: 2,
};
// No sources configured
// No power sources configured
if (
!prefs ||
(!hasPowerSources &&
!hasPowerDevices &&
!hasWaterDevices &&
!hasWaterSources &&
!hasGasSources)
) {
@@ -130,24 +126,6 @@ export class PowerViewStrategy extends ReactiveElement {
});
}
if (hasWaterDevices) {
const showFloorsAndAreas = shouldShowFloorsAndAreas(
prefs.device_consumption_water,
hass,
(d) => d.stat_rate
);
chartsSection.cards!.push({
title: hass.localize("ui.panel.energy.cards.water_flow_sankey_title"),
type: "water-flow-sankey",
collection_key: collectionKey,
group_by_floor: showFloorsAndAreas,
group_by_area: showFloorsAndAreas,
grid_options: {
columns: 36,
},
});
}
tiles.forEach((card) => {
tileSection.cards!.push({
...card,

View File

@@ -18,7 +18,6 @@ import "../../../components/ha-svg-icon";
import { cameraUrlWithWidthHeight } from "../../../data/camera";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import type { HomeAssistant } from "../../../types";
import { addBrandsAuth } from "../../../util/brands-url";
import { actionHandler } from "../common/directives/action-handler-directive";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
import { findEntities } from "../common/find-entities";
@@ -144,7 +143,7 @@ export class HuiEntityBadge extends LitElement implements LovelaceBadge {
if (!entityPicture) return undefined;
let imageUrl = this.hass!.hassUrl(addBrandsAuth(entityPicture));
let imageUrl = this.hass!.hassUrl(entityPicture);
if (computeStateDomain(stateObj) === "camera") {
imageUrl = cameraUrlWithWidthHeight(imageUrl, 32, 32);
}

View File

@@ -0,0 +1,221 @@
import type { ClockCardConfig, ClockCardDatePart } from "../types";
type ClockCardSeparatorPart = Extract<
ClockCardDatePart,
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
>;
type ClockCardValuePart = Exclude<ClockCardDatePart, ClockCardSeparatorPart>;
/**
* Normalized date configuration used by clock card renderers.
*/
export interface ClockCardDateConfig {
parts: ClockCardDatePart[];
}
/**
* All selectable date tokens exposed by the clock card editor.
*/
export const CLOCK_CARD_DATE_PARTS: readonly ClockCardDatePart[] = [
"weekday-short",
"weekday-long",
"day-numeric",
"day-2-digit",
"month-short",
"month-long",
"month-numeric",
"month-2-digit",
"year-2-digit",
"year-numeric",
"separator-dash",
"separator-slash",
"separator-dot",
"separator-new-line",
];
const DATE_PART_OPTIONS: Record<
ClockCardValuePart,
Pick<Intl.DateTimeFormatOptions, "weekday" | "day" | "month" | "year">
> = {
"weekday-short": { weekday: "short" },
"weekday-long": { weekday: "long" },
"day-numeric": { day: "numeric" },
"day-2-digit": { day: "2-digit" },
"month-short": { month: "short" },
"month-long": { month: "long" },
"month-numeric": { month: "numeric" },
"month-2-digit": { month: "2-digit" },
"year-2-digit": { year: "2-digit" },
"year-numeric": { year: "numeric" },
};
const DATE_SEPARATORS: Record<ClockCardSeparatorPart, string> = {
"separator-dash": "-",
"separator-slash": "/",
"separator-dot": ".",
"separator-new-line": "\n",
};
const DATE_SEPARATOR_PARTS = new Set<ClockCardSeparatorPart>([
"separator-dash",
"separator-slash",
"separator-dot",
"separator-new-line",
]);
const DATE_PART_FORMATTERS = new Map<string, Intl.DateTimeFormat>();
const isClockCardDatePart = (value: string): value is ClockCardDatePart =>
CLOCK_CARD_DATE_PARTS.includes(value as ClockCardDatePart);
const isDateSeparatorPart = (
part: ClockCardDatePart
): part is ClockCardSeparatorPart =>
DATE_SEPARATOR_PARTS.has(part as ClockCardSeparatorPart);
/**
* Returns a reusable formatter for a specific date token.
*/
const getDatePartFormatter = (
part: ClockCardValuePart,
language: string,
timeZone?: string
): Intl.DateTimeFormat => {
const cacheKey = `${language}|${timeZone || ""}|${part}`;
const cached = DATE_PART_FORMATTERS.get(cacheKey);
if (cached) {
return cached;
}
const formatter = new Intl.DateTimeFormat(language, {
...DATE_PART_OPTIONS[part],
...(timeZone ? { timeZone } : {}),
});
DATE_PART_FORMATTERS.set(cacheKey, formatter);
return formatter;
};
const formatDatePart = (
part: ClockCardValuePart,
date: Date,
language: string,
timeZone?: string
) => getDatePartFormatter(part, language, timeZone).format(date);
/**
* Applies a single date token to Intl.DateTimeFormat options.
*/
const applyDatePartOption = (
options: Intl.DateTimeFormatOptions,
part: ClockCardDatePart
) => {
if (isDateSeparatorPart(part)) {
return;
}
const partOptions = DATE_PART_OPTIONS[part];
if (partOptions.weekday) {
options.weekday = partOptions.weekday;
}
if (partOptions.day) {
options.day = partOptions.day;
}
if (partOptions.month) {
options.month = partOptions.month;
}
if (partOptions.year) {
options.year = partOptions.year;
}
};
/**
* Sanitizes configured date tokens while preserving their literal order.
*/
const normalizeDateParts = (
parts: ClockCardConfig["date_format"]
): ClockCardDatePart[] =>
parts?.filter((part): part is ClockCardDatePart =>
isClockCardDatePart(part)
) || [];
/**
* Returns a normalized date config from a card configuration object.
*/
export const getClockCardDateConfig = (
config?: Pick<ClockCardConfig, "date_format">
): ClockCardDateConfig => ({
parts: normalizeDateParts(config?.date_format),
});
/**
* Checks whether the clock configuration resolves to any visible date output.
*/
export const hasClockCardDate = (
config?: Pick<ClockCardConfig, "date_format">
): boolean => getClockCardDateConfig(config).parts.length > 0;
/**
* Converts normalized date tokens into Intl.DateTimeFormat options.
*
* Separator tokens are ignored. If multiple tokens target the same Intl field,
* the last one wins.
*/
export const getClockCardDateTimeFormatOptions = (
dateConfig: ClockCardDateConfig
): Intl.DateTimeFormatOptions => {
const options: Intl.DateTimeFormatOptions = {};
dateConfig.parts.forEach((part) => {
applyDatePartOption(options, part);
});
return options;
};
/**
* Builds the final date string from literal date tokens.
*
* Value tokens are localized through Intl.DateTimeFormat. Separator tokens are
* always rendered literally. A default space is only inserted between adjacent
* value tokens.
*/
export const formatClockCardDate = (
date: Date,
dateConfig: ClockCardDateConfig,
language: string,
timeZone?: string
): string => {
let result = "";
let previousRenderedPartWasValue = false;
dateConfig.parts.forEach((part) => {
if (isDateSeparatorPart(part)) {
result += DATE_SEPARATORS[part];
previousRenderedPartWasValue = false;
return;
}
const value = formatDatePart(part, date, language, timeZone);
if (!value) {
return;
}
if (previousRenderedPartWasValue) {
result += " ";
}
result += value;
previousRenderedPartWasValue = true;
});
return result;
};

View File

@@ -2,9 +2,16 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
import type { HomeAssistant } from "../../../../types";
import type { ClockCardConfig } from "../types";
import {
formatClockCardDate,
getClockCardDateConfig,
hasClockCardDate,
} from "./clock-date-format";
function romanize12HourClock(num: number) {
const numerals = [
@@ -26,6 +33,11 @@ function romanize12HourClock(num: number) {
return numerals[num];
}
const DATE_UPDATE_INTERVAL = 60_000;
const QUARTER_TICKS = Array.from({ length: 4 }, (_, i) => i);
const HOUR_TICKS = Array.from({ length: 12 }, (_, i) => i);
const MINUTE_TICKS = Array.from({ length: 60 }, (_, i) => i);
@customElement("hui-clock-card-analog")
export class HuiClockCardAnalog extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -40,42 +52,18 @@ export class HuiClockCardAnalog extends LitElement {
@state() private _secondOffsetSec?: number;
private _initDate() {
if (!this.config || !this.hass) {
return;
}
@state() private _date?: string;
let locale = this.hass.locale;
if (this.config.time_format) {
locale = { ...locale, time_format: this.config.time_format };
}
private _dateInterval?: number;
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h12",
timeZone:
this.config.time_zone ||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
});
private _timeZone?: string;
this._computeOffsets();
}
protected updated(changedProps: PropertyValues) {
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.locale !== this.hass?.locale) {
this._initDate();
}
}
}
private _language?: string;
public connectedCallback() {
super.connectedCallback();
document.addEventListener("visibilitychange", this._handleVisibilityChange);
this._computeOffsets();
this._initDate();
}
public disconnectedCallback() {
@@ -84,18 +72,87 @@ export class HuiClockCardAnalog extends LitElement {
"visibilitychange",
this._handleVisibilityChange
);
this._stopDateTick();
}
protected updated(changedProps: PropertyValues) {
if (changedProps.has("config") || changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (
changedProps.has("config") ||
!oldHass ||
oldHass.locale !== this.hass?.locale
) {
this._initDate();
}
}
}
private _handleVisibilityChange = () => {
if (!document.hidden) {
this._computeOffsets();
this._updateDate();
}
};
private _initDate() {
if (!this.config || !this.hass) {
this._stopDateTick();
this._date = undefined;
return;
}
let locale = this.hass.locale;
if (this.config.time_format) {
locale = { ...locale, time_format: this.config.time_format };
}
const timeZone =
this.config.time_zone ||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone);
this._language = this.hass.locale.language;
this._timeZone = timeZone;
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h12",
timeZone,
});
this._computeOffsets();
this._updateDate();
if (this.isConnected && hasClockCardDate(this.config)) {
this._startDateTick();
} else {
this._stopDateTick();
}
}
private _startDateTick() {
this._stopDateTick();
this._dateInterval = window.setInterval(
() => this._updateDate(),
DATE_UPDATE_INTERVAL
);
}
private _stopDateTick() {
if (this._dateInterval) {
clearInterval(this._dateInterval);
this._dateInterval = undefined;
}
}
private _computeOffsets() {
if (!this._dateTimeFormat) return;
const parts = this._dateTimeFormat.formatToParts();
const date = new Date();
const parts = this._dateTimeFormat.formatToParts(date);
const hourStr = parts.find((p) => p.type === "hour")?.value;
const minuteStr = parts.find((p) => p.type === "minute")?.value;
const secondStr = parts.find((p) => p.type === "second")?.value;
@@ -103,7 +160,7 @@ export class HuiClockCardAnalog extends LitElement {
const hour = hourStr ? parseInt(hourStr, 10) : 0;
const minute = minuteStr ? parseInt(minuteStr, 10) : 0;
const second = secondStr ? parseInt(secondStr, 10) : 0;
const ms = new Date().getMilliseconds();
const ms = date.getMilliseconds();
const secondsWithMs = second + ms / 1000;
const hour12 = hour % 12;
@@ -113,16 +170,44 @@ export class HuiClockCardAnalog extends LitElement {
this._hourOffsetSec = hour12 * 3600 + minute * 60 + secondsWithMs;
}
private _updateDate() {
if (!this.config || !hasClockCardDate(this.config) || !this._language) {
this._date = undefined;
return;
}
const dateConfig = getClockCardDateConfig(this.config);
this._date = formatClockCardDate(
new Date(),
dateConfig,
this._language,
this._timeZone
);
}
private _computeClock = memoizeOne((config: ClockCardConfig) => {
const faceParts = config.face_style?.split("_");
const dateConfig = getClockCardDateConfig(config);
const showDate = hasClockCardDate(config);
const isLongDate =
dateConfig.parts.includes("month-long") ||
dateConfig.parts.includes("weekday-long");
return {
sizeClass: config.clock_size ? `size-${config.clock_size}` : "",
isNumbers: faceParts?.includes("numbers") ?? false,
isRoman: faceParts?.includes("roman") ?? false,
isUpright: faceParts?.includes("upright") ?? false,
showDate,
isLongDate,
};
});
render() {
if (!this.config) return nothing;
const sizeClass = this.config.clock_size
? `size-${this.config.clock_size}`
: "";
const isNumbers = this.config?.face_style?.startsWith("numbers");
const isRoman = this.config?.face_style?.startsWith("roman");
const isUpright = this.config?.face_style?.endsWith("upright");
const { sizeClass, isNumbers, isRoman, isUpright, isLongDate, showDate } =
this._computeClock(this.config);
const indicator = (number?: number) => html`
<div
@@ -163,14 +248,14 @@ export class HuiClockCardAnalog extends LitElement {
})}
>
${this.config.ticks === "quarter"
? Array.from({ length: 4 }, (_, i) => i).map(
? QUARTER_TICKS.map(
(i) =>
// 4 ticks (12, 3, 6, 9) at 0°, 90°, 180°, 270°
html`
<div
aria-hidden="true"
class="tick hour"
style=${`--tick-rotation: ${i * 90}deg;`}
style=${styleMap({ "--tick-rotation": `${i * 90}deg` })}
>
${indicator([12, 3, 6, 9][i])}
</div>
@@ -178,28 +263,30 @@ export class HuiClockCardAnalog extends LitElement {
)
: !this.config.ticks || // Default to hour ticks
this.config.ticks === "hour"
? Array.from({ length: 12 }, (_, i) => i).map(
? HOUR_TICKS.map(
(i) =>
// 12 ticks (1-12)
html`
<div
aria-hidden="true"
class="tick hour"
style=${`--tick-rotation: ${i * 30}deg;`}
style=${styleMap({ "--tick-rotation": `${i * 30}deg` })}
>
${indicator(((i + 11) % 12) + 1)}
</div>
`
)
: this.config.ticks === "minute"
? Array.from({ length: 60 }, (_, i) => i).map(
? MINUTE_TICKS.map(
(i) =>
// 60 ticks (1-60)
html`
<div
aria-hidden="true"
class="tick ${i % 5 === 0 ? "hour" : "minute"}"
style=${`--tick-rotation: ${i * 6}deg;`}
style=${styleMap({
"--tick-rotation": `${i * 6}deg`,
})}
>
${i % 5 === 0
? indicator(((i / 5 + 11) % 12) + 1)
@@ -208,14 +295,34 @@ export class HuiClockCardAnalog extends LitElement {
`
)
: nothing}
${showDate
? html`<div
class=${classMap({
date: true,
[sizeClass]: true,
"long-date": isLongDate,
})}
>
${this._date
?.split("\n")
.map((line, index) =>
index > 0 ? html`<br />${line}` : line
)}
</div>`
: nothing}
<div class="center-dot"></div>
<div
class="hand hour"
style=${`animation-delay: -${this._hourOffsetSec ?? 0}s;`}
style=${styleMap({
"animation-delay": `-${this._hourOffsetSec ?? 0}s`,
})}
></div>
<div
class="hand minute"
style=${`animation-delay: -${this._minuteOffsetSec ?? 0}s;`}
style=${styleMap({
"animation-delay": `-${this._minuteOffsetSec ?? 0}s`,
})}
></div>
${this.config.show_seconds
? html`<div
@@ -224,11 +331,13 @@ export class HuiClockCardAnalog extends LitElement {
second: true,
step: this.config.seconds_motion === "tick",
})}
style=${`animation-delay: -${
(this.config.seconds_motion === "tick"
? Math.floor(this._secondOffsetSec ?? 0)
: (this._secondOffsetSec ?? 0)) as number
}s;`}
style=${styleMap({
"animation-delay": `-${
(this.config.seconds_motion === "tick"
? Math.floor(this._secondOffsetSec ?? 0)
: (this._secondOffsetSec ?? 0)) as number
}s`,
})}
></div>`
: nothing}
</div>
@@ -407,6 +516,36 @@ export class HuiClockCardAnalog extends LitElement {
transform: translate(-50%, 0) rotate(360deg);
}
}
.date {
position: absolute;
top: 68%;
left: 50%;
transform: translate(-50%, -50%);
display: block;
color: var(--primary-text-color);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
text-align: center;
opacity: 0.8;
max-width: 87%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.date.long-date:not(.size-medium):not(.size-large) {
font-size: var(--ha-font-size-xs);
}
.date.size-medium {
font-size: var(--ha-font-size-l);
}
.date.size-large {
font-size: var(--ha-font-size-xl);
}
`;
}

View File

@@ -5,6 +5,11 @@ import type { ClockCardConfig } from "../types";
import type { HomeAssistant } from "../../../../types";
import { useAmPm } from "../../../../common/datetime/use_am_pm";
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
import {
formatClockCardDate,
getClockCardDateConfig,
hasClockCardDate,
} from "./clock-date-format";
const INTERVAL = 1000;
@@ -24,10 +29,20 @@ export class HuiClockCardDigital extends LitElement {
@state() private _timeAmPm?: string;
@state() private _date?: string;
private _tickInterval?: undefined | number;
private _lastDateMinute?: string;
private _timeZone?: string;
private _language?: string;
private _initDate() {
if (!this.config || !this.hass) {
this._date = undefined;
this._lastDateMinute = undefined;
return;
}
@@ -37,24 +52,35 @@ export class HuiClockCardDigital extends LitElement {
locale = { ...locale, time_format: this.config.time_format };
}
const timeZone =
this.config?.time_zone ||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone);
const h12 = useAmPm(locale);
this._language = this.hass.locale.language;
this._timeZone = timeZone;
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
hour: h12 ? "numeric" : "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: h12 ? "h12" : "h23",
timeZone:
this.config?.time_zone ||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
timeZone,
});
this._lastDateMinute = undefined;
this._tick();
}
protected updated(changedProps: PropertyValues) {
if (changedProps.has("hass")) {
if (changedProps.has("config") || changedProps.has("hass")) {
const oldHass = changedProps.get("hass");
if (!oldHass || oldHass.locale !== this.hass?.locale) {
if (
changedProps.has("config") ||
!oldHass ||
oldHass.locale !== this.hass?.locale
) {
this._initDate();
}
}
@@ -71,6 +97,7 @@ export class HuiClockCardDigital extends LitElement {
}
private _startTick() {
this._stopTick();
this._tickInterval = window.setInterval(() => this._tick(), INTERVAL);
this._tick();
}
@@ -85,7 +112,8 @@ export class HuiClockCardDigital extends LitElement {
private _tick() {
if (!this._dateTimeFormat) return;
const parts = this._dateTimeFormat.formatToParts();
const date = new Date();
const parts = this._dateTimeFormat.formatToParts(date);
this._timeHour = parts.find((part) => part.type === "hour")?.value;
this._timeMinute = parts.find((part) => part.type === "minute")?.value;
@@ -93,6 +121,33 @@ export class HuiClockCardDigital extends LitElement {
? parts.find((part) => part.type === "second")?.value
: undefined;
this._timeAmPm = parts.find((part) => part.type === "dayPeriod")?.value;
this._updateDate(date);
}
private _updateDate(date: Date) {
if (!this.config || !hasClockCardDate(this.config) || !this._language) {
this._date = undefined;
this._lastDateMinute = undefined;
return;
}
if (
this._timeMinute !== undefined &&
this._timeMinute === this._lastDateMinute &&
this._date !== undefined
) {
return;
}
const dateConfig = getClockCardDateConfig(this.config);
this._date = formatClockCardDate(
date,
dateConfig,
this._language,
this._timeZone
);
this._lastDateMinute = this._timeMinute;
}
render() {
@@ -101,18 +156,30 @@ export class HuiClockCardDigital extends LitElement {
const sizeClass = this.config.clock_size
? `size-${this.config.clock_size}`
: "";
const showDate = hasClockCardDate(this.config);
return html`
<div class="time-parts ${sizeClass}">
<div class="time-part hour">${this._timeHour}</div>
<div class="time-part minute">${this._timeMinute}</div>
${this._timeSecond !== undefined
? html`<div class="time-part second">${this._timeSecond}</div>`
: nothing}
${this._timeAmPm !== undefined
? html`<div class="time-part am-pm">${this._timeAmPm}</div>`
: nothing}
<div class="clock-container">
<div class="time-parts ${sizeClass}">
<div class="time-part hour">${this._timeHour}</div>
<div class="time-part minute">${this._timeMinute}</div>
${this._timeSecond !== undefined
? html`<div class="time-part second">${this._timeSecond}</div>`
: nothing}
${this._timeAmPm !== undefined
? html`<div class="time-part am-pm">${this._timeAmPm}</div>`
: nothing}
</div>
</div>
${showDate
? html`<div class="date-container">
<div class="date ${sizeClass}">
${this._date
?.split("\n")
.map((line, index) => (index > 0 ? html`<br />${line}` : line))}
</div>
</div>`
: nothing}
`;
}
@@ -121,6 +188,17 @@ export class HuiClockCardDigital extends LitElement {
display: block;
}
.clock-container {
width: 100%;
display: flex;
justify-content: center;
}
.date-container {
width: 100%;
margin-top: var(--ha-space-1);
}
.time-parts {
align-items: center;
display: grid;
@@ -188,6 +266,21 @@ export class HuiClockCardDigital extends LitElement {
content: ":";
margin: 0 2px;
}
.date {
text-align: center;
opacity: 0.8;
font-size: var(--ha-font-size-s);
line-height: 1.1;
}
.date.size-medium {
font-size: var(--ha-font-size-l);
}
.date.size-large {
font-size: var(--ha-font-size-2xl);
}
`;
}

View File

@@ -28,8 +28,6 @@ import {
formatDateMonthYear,
formatDateShort,
formatDateVeryShort,
formatDateWeekdayShortDate,
formatDateWeekdayVeryShortDate,
} from "../../../../../common/datetime/format_date";
import { formatTime } from "../../../../../common/datetime/format_time";
import type { ECOption } from "../../../../../resources/echarts/echarts";
@@ -224,9 +222,7 @@ function formatTooltip(
if (suggestedPeriod === "month") {
period = `${formatDateMonthYear(date, locale, config)}`;
} else if (suggestedPeriod === "day") {
period = showCompareYear
? formatDateWeekdayShortDate(date, locale, config)
: formatDateWeekdayVeryShortDate(date, locale, config);
period = `${(showCompareYear ? formatDateShort : formatDateVeryShort)(date, locale, config)}`;
} else {
period = `${
compare

View File

@@ -21,7 +21,6 @@ import { cameraUrlWithWidthHeight } from "../../../data/camera";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import "../../../state-display/state-display";
import type { HomeAssistant } from "../../../types";
import { addBrandsAuth } from "../../../util/brands-url";
import "../card-features/hui-card-features";
import type { LovelaceCardFeatureContext } from "../card-features/types";
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
@@ -159,7 +158,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
if (!entityPicture) return undefined;
let imageUrl = this.hass!.hassUrl(addBrandsAuth(entityPicture));
let imageUrl = this.hass!.hassUrl(entityPicture);
if (computeDomain(entity.entity_id) === "camera") {
imageUrl = cameraUrlWithWidthHeight(imageUrl, 80, 80);
}

View File

@@ -251,14 +251,6 @@ export interface WaterSankeyCardConfig extends EnergyCardBaseConfig {
group_by_area?: boolean;
}
export interface WaterFlowSankeyCardConfig extends EnergyCardBaseConfig {
type: "water-flow-sankey";
title?: string;
layout?: "vertical" | "horizontal" | "auto";
group_by_floor?: boolean;
group_by_area?: boolean;
}
export interface PowerSourcesGraphCardConfig extends EnergyCardBaseConfig {
type: "power-sources-graph";
title?: string;
@@ -459,12 +451,29 @@ export interface ClockCardConfig extends LovelaceCardConfig {
time_format?: TimeFormat;
time_zone?: string;
no_background?: boolean;
date_format?: ClockCardDatePart[];
// Analog clock options
border?: boolean;
ticks?: "none" | "quarter" | "hour" | "minute";
face_style?: "markers" | "numbers_upright" | "roman";
}
export type ClockCardDatePart =
| "weekday-short"
| "weekday-long"
| "day-numeric"
| "day-2-digit"
| "month-short"
| "month-long"
| "month-numeric"
| "month-2-digit"
| "year-2-digit"
| "year-numeric"
| "separator-dash"
| "separator-slash"
| "separator-dot"
| "separator-new-line";
export interface MediaControlCardConfig extends LovelaceCardConfig {
entity: string;
name?: string | EntityNameItem | EntityNameItem[];

View File

@@ -1,628 +0,0 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import "../../../../components/ha-card";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { EnergyData } from "../../../../data/energy";
import {
formatFlowRateShort,
getEnergyDataCollection,
getFlowRateFromState,
} from "../../../../data/energy";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard, LovelaceGridOptions } from "../../types";
import type { WaterFlowSankeyCardConfig } from "../types";
import "../../../../components/chart/ha-sankey-chart";
import type { Link, Node } from "../../../../components/chart/ha-sankey-chart";
import { getGraphColorByIndex } from "../../../../common/color/colors";
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
const DEFAULT_CONFIG: Partial<WaterFlowSankeyCardConfig> = {
group_by_floor: true,
group_by_area: true,
};
// Minimum flow threshold as a fraction of total inflow to display a device node.
// Devices below this threshold will be grouped into an "Other" node.
const MIN_FLOW_THRESHOLD_FACTOR = 0.001; // 0.1% of total inflow
interface SmallConsumer {
statRate: string;
name: string | undefined;
value: number;
effectiveParent: string | undefined;
idx: number;
}
@customElement("hui-water-flow-sankey-card")
class HuiWaterFlowSankeyCard
extends SubscribeMixin(MobileAwareMixin(LitElement))
implements LovelaceCard
{
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public layout?: string;
@state() private _config?: WaterFlowSankeyCardConfig;
@state() private _data?: EnergyData;
private _entities = new Set<string>();
protected hassSubscribeRequiredHostProps = ["_config"];
public setConfig(config: WaterFlowSankeyCardConfig): void {
this._config = { ...DEFAULT_CONFIG, ...config };
}
public hassSubscribe(): UnsubscribeFunc[] {
return [
getEnergyDataCollection(this.hass, {
key: this._config?.collection_key,
}).subscribe((data) => {
this._data = data;
}),
];
}
public getCardSize(): Promise<number> | number {
return 5;
}
getGridOptions(): LovelaceGridOptions {
return {
columns: 12,
min_columns: 6,
rows: 6,
min_rows: 2,
};
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
if (
changedProps.has("_config") ||
changedProps.has("_data") ||
changedProps.has("_isMobileSize")
) {
return true;
}
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || !this._entities.size) {
return true;
}
for (const entityId of this._entities) {
if (oldHass.states[entityId] !== this.hass.states[entityId]) {
return true;
}
}
}
return false;
}
protected render() {
if (!this._config) {
return nothing;
}
if (!this._data) {
return html`${this.hass.localize(
"ui.panel.lovelace.cards.energy.loading"
)}`;
}
const prefs = this._data.prefs;
const computedStyle = getComputedStyle(this);
// Clear tracked entities and rebuild set
this._entities.clear();
// Collect water sources with stat_rate
const waterSources = prefs.energy_sources.filter(
(source) => source.type === "water" && source.stat_rate
);
let totalInflow = 0;
waterSources.forEach((source) => {
if (source.type === "water" && source.stat_rate) {
const value = this._getCurrentFlowRate(source.stat_rate);
if (value > 0) totalInflow += value;
}
});
// When there are no source meters, pre-compute total device flow so the
// home node has the correct value (sum of all device consumption) rather
// than 0. This avoids a broken sankey where the root node has value=0
// while its children have positive values.
let totalDeviceFlow = 0;
if (waterSources.length === 0) {
prefs.device_consumption_water.forEach((device) => {
if (device.stat_rate) {
totalDeviceFlow += this._getCurrentFlowRate(device.stat_rate);
}
});
}
const effectiveTotalInflow =
waterSources.length === 0 ? totalDeviceFlow : totalInflow;
// Calculate dynamic threshold
const minFlowThreshold = effectiveTotalInflow * MIN_FLOW_THRESHOLD_FACTOR;
const nodes: Node[] = [];
const links: Link[] = [];
const waterColor = computedStyle
.getPropertyValue("--energy-water-color")
.trim();
const primaryColor = computedStyle
.getPropertyValue("--primary-color")
.trim();
// Determine the "root" node for device links.
// - 0 sources: home node (value = sum of device values, computed later)
// - 1 source: that source node is the root (no home node)
// - >1 sources: home node aggregates all sources
const showHomeNode = waterSources.length !== 1;
let rootNodeId: string;
if (showHomeNode) {
// Add source nodes and link to home
waterSources.forEach((source) => {
if (source.type !== "water" || !source.stat_rate) return;
const value = this._getCurrentFlowRate(source.stat_rate);
if (value <= 0) return;
const sourceNodeId = `water_source_${source.stat_rate}`;
nodes.push({
id: sourceNodeId,
label:
this._getEntityLabel(source.stat_rate) ||
this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_distribution.water"
),
value,
color: waterColor,
index: 0,
entityId: source.stat_rate,
});
links.push({ source: sourceNodeId, target: "home" });
});
const homeNode: Node = {
id: "home",
label: this.hass.config.location_name,
value: Math.max(0, effectiveTotalInflow),
color: primaryColor,
index: 1,
};
nodes.push(homeNode);
rootNodeId = "home";
} else {
// Single source: that source IS the root, no home node
const source = waterSources[0];
if (source.type === "water" && source.stat_rate) {
const value = this._getCurrentFlowRate(source.stat_rate);
nodes.push({
id: source.stat_rate,
label:
this._getEntityLabel(source.stat_rate) ||
this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_distribution.water"
),
value: Math.max(0, value),
color: waterColor,
index: 0,
entityId: source.stat_rate,
});
rootNodeId = source.stat_rate;
} else {
// Fallback (shouldn't happen)
rootNodeId = "home";
}
}
// Build a map of device relationships for hierarchy resolution
const deviceMap = new Map<
string,
{ stat_rate?: string; included_in_stat?: string }
>();
prefs.device_consumption_water.forEach((device) => {
deviceMap.set(device.stat_consumption, {
stat_rate: device.stat_rate,
included_in_stat: device.included_in_stat,
});
});
// Set of stat_rate entities that will be rendered as nodes
const renderedStatRates = new Set<string>();
prefs.device_consumption_water.forEach((device) => {
if (device.stat_rate) {
const value = this._getCurrentFlowRate(device.stat_rate);
if (value >= minFlowThreshold) {
renderedStatRates.add(device.stat_rate);
}
}
});
// Find the effective parent for hierarchy
const findEffectiveParent = (
includedInStat: string | undefined
): string | undefined => {
let currentParent = includedInStat;
while (currentParent) {
const parentDevice = deviceMap.get(currentParent);
if (!parentDevice) return undefined;
if (
parentDevice.stat_rate &&
renderedStatRates.has(parentDevice.stat_rate)
) {
return parentDevice.stat_rate;
}
currentParent = parentDevice.included_in_stat;
}
return undefined;
};
let untrackedConsumption = effectiveTotalInflow;
const deviceNodes: Node[] = [];
const parentLinks: Record<string, string> = {};
const smallConsumersByParent = new Map<string, SmallConsumer[]>();
prefs.device_consumption_water.forEach((device, idx) => {
if (!device.stat_rate) return;
const value = this._getCurrentFlowRate(device.stat_rate);
const effectiveParent = findEffectiveParent(device.included_in_stat);
if (value < minFlowThreshold) {
const parentKey = effectiveParent ?? rootNodeId;
if (!smallConsumersByParent.has(parentKey)) {
smallConsumersByParent.set(parentKey, []);
}
smallConsumersByParent.get(parentKey)!.push({
statRate: device.stat_rate,
name: device.name,
value,
effectiveParent,
idx,
});
return;
}
const node = {
id: device.stat_rate,
label: device.name || this._getEntityLabel(device.stat_rate),
value,
color: getGraphColorByIndex(idx, computedStyle),
index: 4,
parent: effectiveParent,
entityId: device.stat_rate,
};
if (node.parent) {
parentLinks[node.id] = node.parent;
links.push({ source: node.parent, target: node.id });
} else {
untrackedConsumption -= value;
}
deviceNodes.push(node);
});
// Process small consumers
smallConsumersByParent.forEach((consumers, parentKey) => {
const totalValue = consumers.reduce((sum, c) => sum + c.value, 0);
if (totalValue <= 0) return;
if (consumers.length === 1) {
const consumer = consumers[0];
const node = {
id: consumer.statRate,
label: consumer.name || this._getEntityLabel(consumer.statRate),
value: consumer.value,
color: getGraphColorByIndex(consumer.idx, computedStyle),
index: 4,
parent: consumer.effectiveParent,
entityId: consumer.statRate,
};
if (node.parent) {
parentLinks[node.id] = node.parent;
links.push({ source: node.parent, target: node.id });
} else {
untrackedConsumption -= consumer.value;
}
deviceNodes.push(node);
} else {
const otherNodeId = `other_${parentKey}`;
const otherNode: Node = {
id: otherNodeId,
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.other"
),
value: Math.ceil(totalValue),
color: computedStyle
.getPropertyValue("--state-unavailable-color")
.trim(),
index: 4,
};
if (parentKey !== rootNodeId) {
parentLinks[otherNodeId] = parentKey;
links.push({ source: parentKey, target: otherNodeId });
} else {
untrackedConsumption -= totalValue;
}
deviceNodes.push(otherNode);
}
});
const devicesWithoutParent = deviceNodes.filter(
(node) => !parentLinks[node.id]
);
const { group_by_area, group_by_floor, layout, title } = this._config;
if (group_by_area || group_by_floor) {
const { areas, floors } = this._groupByFloorAndArea(devicesWithoutParent);
Object.keys(floors)
.sort(
(a, b) =>
(this.hass.floors[b]?.level ?? -Infinity) -
(this.hass.floors[a]?.level ?? -Infinity)
)
.forEach((floorId) => {
let floorNodeId = `floor_${floorId}`;
if (floorId === "no_floor" || !group_by_floor) {
floorNodeId = rootNodeId;
} else {
nodes.push({
id: floorNodeId,
label: this.hass.floors[floorId].name,
value: floors[floorId].value,
index: 2,
color: primaryColor,
});
links.push({ source: rootNodeId, target: floorNodeId });
}
floors[floorId].areas.forEach((areaId) => {
let targetNodeId: string;
if (areaId === "no_area" || !group_by_area) {
targetNodeId = floorNodeId;
} else {
const areaNodeId = `area_${areaId}`;
nodes.push({
id: areaNodeId,
label: this.hass.areas[areaId]?.name || areaId,
value: areas[areaId].value,
index: 3,
color: primaryColor,
});
links.push({
source: floorNodeId,
target: areaNodeId,
value: areas[areaId].value,
});
targetNodeId = areaNodeId;
}
areas[areaId].devices.forEach((device) => {
links.push({
source: targetNodeId,
target: device.id,
value: device.value,
});
});
});
});
} else {
devicesWithoutParent.forEach((deviceNode) => {
links.push({
source: rootNodeId,
target: deviceNode.id,
value: deviceNode.value,
});
});
}
const deviceSections = this._getDeviceSections(parentLinks, deviceNodes);
deviceSections.forEach((section, index) => {
section.forEach((node: Node) => {
nodes.push({ ...node, index: 4 + index });
});
});
// Untracked consumption (only show if > 1 L/min threshold)
if (untrackedConsumption > 1) {
nodes.push({
id: "untracked",
label: this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
),
value: untrackedConsumption,
color: computedStyle
.getPropertyValue("--state-unavailable-color")
.trim(),
index: 3 + deviceSections.length,
});
links.push({
source: rootNodeId,
target: "untracked",
value: untrackedConsumption,
});
}
const hasData = nodes.some((node) => node.value > 0);
const vertical =
layout === "vertical" || (layout !== "horizontal" && this._isMobileSize);
return html`
<ha-card
.header=${title}
class=${classMap({
"is-grid": this.layout === "grid",
"is-panel": this.layout === "panel",
"is-vertical": vertical,
})}
>
<div class="card-content">
${hasData
? html`<ha-sankey-chart
.data=${{ nodes, links }}
.vertical=${vertical}
.valueFormatter=${this._valueFormatter}
@node-click=${this._handleNodeClick}
></ha-sankey-chart>`
: html`${this.hass.localize(
"ui.panel.lovelace.cards.energy.no_data"
)}`}
</div>
</ha-card>
`;
}
private _valueFormatter = (value: number) =>
`<div style="direction:ltr; display: inline;">
${formatFlowRateShort(this.hass.locale, this.hass.config.unit_system.length, value)}
</div>`;
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
const { node } = ev.detail;
if (node.entityId) {
fireEvent(this, "hass-more-info", { entityId: node.entityId });
}
}
private _getCurrentFlowRate(entityId: string): number {
this._entities.add(entityId);
return getFlowRateFromState(this.hass.states[entityId]) ?? 0;
}
private _getEntityLabel(entityId: string): string {
const stateObj = this.hass.states[entityId];
if (!stateObj) return entityId;
return stateObj.attributes.friendly_name || entityId;
}
protected _groupByFloorAndArea(deviceNodes: Node[]) {
const areas: Record<string, { value: number; devices: Node[] }> = {
no_area: { value: 0, devices: [] },
};
const floors: Record<string, { value: number; areas: string[] }> = {
no_floor: { value: 0, areas: ["no_area"] },
};
deviceNodes.forEach((deviceNode) => {
const entity = this.hass.states[deviceNode.id];
const { area, floor } = entity
? getEntityContext(
entity,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: { area: null, floor: null };
if (area) {
if (area.area_id in areas) {
areas[area.area_id].value += deviceNode.value;
areas[area.area_id].devices.push(deviceNode);
} else {
areas[area.area_id] = {
value: deviceNode.value,
devices: [deviceNode],
};
}
if (floor) {
if (floor.floor_id in floors) {
floors[floor.floor_id].value += deviceNode.value;
if (!floors[floor.floor_id].areas.includes(area.area_id)) {
floors[floor.floor_id].areas.push(area.area_id);
}
} else {
floors[floor.floor_id] = {
value: deviceNode.value,
areas: [area.area_id],
};
}
} else {
floors.no_floor.value += deviceNode.value;
if (!floors.no_floor.areas.includes(area.area_id)) {
floors.no_floor.areas.unshift(area.area_id);
}
}
} else {
areas.no_area.value += deviceNode.value;
areas.no_area.devices.push(deviceNode);
}
});
return { areas, floors };
}
protected _getDeviceSections(
parentLinks: Record<string, string>,
deviceNodes: Node[]
): Node[][] {
const parentSection: Node[] = [];
const childSection: Node[] = [];
const parentIds = Object.values(parentLinks);
const remainingLinks: typeof parentLinks = {};
deviceNodes.forEach((deviceNode) => {
const isChild = deviceNode.id in parentLinks;
const isParent = parentIds.includes(deviceNode.id);
if (isParent && !isChild) {
parentSection.push(deviceNode);
} else {
childSection.push(deviceNode);
}
});
Object.entries(parentLinks).forEach(([child, parent]) => {
if (!parentSection.some((node) => node.id === parent)) {
remainingLinks[child] = parent;
}
});
if (parentSection.length > 0) {
return [
parentSection,
...this._getDeviceSections(remainingLinks, childSection),
];
}
return [deviceNodes];
}
static styles = css`
ha-card {
height: 400px;
display: flex;
flex-direction: column;
--chart-max-height: none;
}
ha-card.is-vertical {
height: 500px;
}
ha-card.is-grid,
ha-card.is-panel {
height: 100%;
}
.card-content {
flex: 1;
display: flex;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-water-flow-sankey-card": HuiWaterFlowSankeyCard;
}
}

View File

@@ -67,8 +67,6 @@ const LAZY_LOAD_TYPES = {
import("../cards/energy/hui-energy-usage-graph-card"),
"energy-sankey": () => import("../cards/energy/hui-energy-sankey-card"),
"water-sankey": () => import("../cards/water/hui-water-sankey-card"),
"water-flow-sankey": () =>
import("../cards/water/hui-water-flow-sankey-card"),
"power-sources-graph": () =>
import("../cards/energy/hui-power-sources-graph-card"),
"power-total": () => import("../cards/energy/hui-power-total-card"),

View File

@@ -142,6 +142,7 @@ export class HuiCreateDialogBadge
`
: html`
<hui-entity-picker-table
no-label-float
.hass=${this.hass}
.narrow=${true}
@selected-changed=${this._handleSelectedChanged}

View File

@@ -165,6 +165,7 @@ export class HuiCreateDialogCard
`
: html`
<hui-entity-picker-table
no-label-float
.hass=${this.hass}
narrow
@selected-changed=${this._handleSelectedChanged}

View File

@@ -43,6 +43,9 @@ export class HuiEntityPickerTable extends LitElement {
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, attribute: "no-label-float" })
public noLabelFloat? = false;
@property({ type: Array }) public entities?: string[];
protected firstUpdated(_changedProperties: PropertyValues): void {
@@ -112,6 +115,7 @@ export class HuiEntityPickerTable extends LitElement {
.searchLabel=${this.hass.localize(
"ui.panel.lovelace.unused_entities.search"
)}
.noLabelFloat=${this.noLabelFloat}
.noDataText=${this.hass.localize(
"ui.panel.lovelace.unused_entities.no_data"
)}

View File

@@ -3,16 +3,15 @@ import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
array,
assert,
assign,
boolean,
defaulted,
enums,
literal,
object,
optional,
string,
union,
} from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
@@ -20,57 +19,46 @@ import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { ClockCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { TimeFormat } from "../../../../data/translation";
import {
CLOCK_CARD_DATE_PARTS,
getClockCardDateConfig,
} from "../../cards/clock/clock-date-format";
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
title: optional(string()),
clock_style: optional(union([literal("digital"), literal("analog")])),
clock_size: optional(
union([literal("small"), literal("medium"), literal("large")])
),
clock_style: optional(enums(["digital", "analog"])),
clock_size: optional(enums(["small", "medium", "large"])),
time_format: optional(enums(Object.values(TimeFormat))),
time_zone: optional(enums(Object.keys(timezones))),
show_seconds: optional(boolean()),
no_background: optional(boolean()),
date_format: optional(defaulted(array(enums(CLOCK_CARD_DATE_PARTS)), [])),
// Analog clock options
border: optional(defaulted(boolean(), false)),
ticks: optional(
defaulted(
union([
literal("none"),
literal("quarter"),
literal("hour"),
literal("minute"),
]),
literal("hour")
)
defaulted(enums(["none", "quarter", "hour", "minute"]), "hour")
),
seconds_motion: optional(
defaulted(
union([literal("continuous"), literal("tick")]),
literal("continuous")
)
defaulted(enums(["continuous", "tick"]), "continuous")
),
face_style: optional(
defaulted(
union([
literal("markers"),
literal("numbers_upright"),
literal("roman"),
]),
literal("markers")
)
defaulted(enums(["markers", "numbers_upright", "roman"]), "markers")
),
})
);
type ClockCardFormData = Omit<ClockCardConfig, "time_format"> & {
time_format?: ClockCardConfig["time_format"] | "auto";
};
@customElement("hui-clock-card-editor")
export class HuiClockCardEditor
extends LitElement
@@ -93,7 +81,7 @@ export class HuiClockCardEditor
name: "clock_style",
selector: {
select: {
mode: "dropdown",
mode: "box",
options: ["digital", "analog"].map((value) => ({
value,
label: localize(
@@ -119,6 +107,13 @@ export class HuiClockCardEditor
},
{ name: "show_seconds", selector: { boolean: {} } },
{ name: "no_background", selector: { boolean: {} } },
{
name: "date_format",
required: false,
selector: {
ui_clock_date_format: {},
},
},
...(clockStyle === "digital"
? ([
{
@@ -241,18 +236,28 @@ export class HuiClockCardEditor
] as const satisfies readonly HaFormSchema[]
);
private _data = memoizeOne((config) => ({
clock_style: "digital",
clock_size: "small",
time_format: "auto",
show_seconds: false,
no_background: false,
// Analog clock options
border: false,
ticks: "hour",
face_style: "markers",
...config,
}));
private _data = memoizeOne((config: ClockCardConfig): ClockCardFormData => {
const dateConfig = getClockCardDateConfig(config);
const data: ClockCardFormData = {
...config,
clock_style: config.clock_style ?? "digital",
clock_size: config.clock_size ?? "small",
time_format: config.time_format ?? "auto",
show_seconds: config.show_seconds ?? false,
no_background: config.no_background ?? false,
// Analog clock options
border: config.border ?? false,
ticks: config.ticks ?? "hour",
face_style: config.face_style ?? "markers",
};
if (config.date_format === undefined) {
data.date_format = dateConfig.parts;
}
return data;
});
public setConfig(config: ClockCardConfig): void {
assert(config, cardConfigStruct);
@@ -270,8 +275,9 @@ export class HuiClockCardEditor
.data=${this._data(this._config)}
.schema=${this._schema(
this.hass.localize,
this._data(this._config).clock_style,
this._data(this._config).ticks,
this._data(this._config)
.clock_style as ClockCardConfig["clock_style"],
this._data(this._config).ticks as ClockCardConfig["ticks"],
this._data(this._config).show_seconds
)}
.computeLabel=${this._computeLabelCallback}
@@ -281,35 +287,40 @@ export class HuiClockCardEditor
`;
}
private _valueChanged(ev: CustomEvent): void {
if (ev.detail.value.time_format === "auto") {
delete ev.detail.value.time_format;
private _valueChanged(ev: ValueChangedEvent<ClockCardFormData>): void {
const config = ev.detail.value;
if (!config.date_format || config.date_format.length === 0) {
delete config.date_format;
}
if (ev.detail.value.clock_style === "analog") {
ev.detail.value.border = ev.detail.value.border ?? false;
ev.detail.value.ticks = ev.detail.value.ticks ?? "hour";
ev.detail.value.face_style = ev.detail.value.face_style ?? "markers";
if (ev.detail.value.show_seconds) {
ev.detail.value.seconds_motion =
ev.detail.value.seconds_motion ?? "continuous";
if (config.time_format === "auto") {
delete config.time_format;
}
if (config.clock_style === "analog") {
config.border = config.border ?? false;
config.ticks = config.ticks ?? "hour";
config.face_style = config.face_style ?? "markers";
if (config.show_seconds) {
config.seconds_motion = config.seconds_motion ?? "continuous";
} else {
delete ev.detail.value.seconds_motion;
delete config.seconds_motion;
}
} else {
delete ev.detail.value.border;
delete ev.detail.value.ticks;
delete ev.detail.value.face_style;
delete ev.detail.value.seconds_motion;
delete config.border;
delete config.ticks;
delete config.face_style;
delete config.seconds_motion;
}
if (ev.detail.value.ticks !== "none") {
ev.detail.value.face_style = ev.detail.value.face_style ?? "markers";
if (config.ticks !== "none") {
config.face_style = config.face_style ?? "markers";
} else {
delete ev.detail.value.face_style;
delete config.face_style;
}
fireEvent(this, "config-changed", { config: ev.detail.value });
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (
@@ -344,6 +355,10 @@ export class HuiClockCardEditor
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.no_background`
);
case "date_format":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.date.label`
);
case "border":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.border.label`
@@ -369,6 +384,10 @@ export class HuiClockCardEditor
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "date_format":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.date.description`
);
case "border":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.border.description`

View File

@@ -32,7 +32,7 @@ export const securityEntityFilters: EntityFilter[] = [
},
{
domain: "cover",
device_class: ["door", "garage", "gate", "window"],
device_class: ["door", "garage", "gate"],
entity_category: "none",
},
{

View File

@@ -4,7 +4,7 @@ export const waColorStyles = css`
html {
--wa-color-brand-fill-loud: var(--ha-color-fill-primary-loud-resting);
--wa-color-brand-fill-normal: var(--ha-color-fill-primary-normal-resting);
--wa-color-brand-fill-quiet: var(--ha-color-fill-primary-quiet-hover);
--wa-color-brand-fill-quiet: var(--ha-color-fill-primary-quiet-resting);
--wa-color-brand-border-loud: var(--ha-color-border-loud);
--wa-color-brand-border-normal: var(--ha-color-primary-50);
--wa-color-brand-border-quiet: var(--ha-color-border-quiet);
@@ -14,7 +14,7 @@ export const waColorStyles = css`
--wa-color-neutral-fill-loud: var(--ha-color-fill-neutral-loud-resting);
--wa-color-neutral-fill-normal: var(--ha-color-fill-neutral-normal-resting);
--wa-color-neutral-fill-quiet: var(--ha-color-fill-neutral-quiet-hover);
--wa-color-neutral-fill-quiet: var(--ha-color-fill-neutral-quiet-resting);
--wa-color-neutral-border-loud: var(--ha-color-border-neutral-loud);
--wa-color-neutral-border-normal: var(--ha-color-border-neutral-normal);
--wa-color-neutral-border-quiet: var(--ha-color-border-neutral-quiet);
@@ -24,7 +24,7 @@ export const waColorStyles = css`
--wa-color-success-fill-loud: var(--ha-color-fill-success-loud-resting);
--wa-color-success-fill-normal: var(--ha-color-fill-success-normal-resting);
--wa-color-success-fill-quiet: var(--ha-color-fill-success-quiet-hover);
--wa-color-success-fill-quiet: var(--ha-color-fill-success-quiet-resting);
--wa-color-success-border-loud: var(--ha-color-border-success-loud);
--wa-color-success-border-normal: var(--ha-color-border-success-normal);
--wa-color-success-border-quiet: var(--ha-color-border-success-quiet);
@@ -34,7 +34,7 @@ export const waColorStyles = css`
--wa-color-warning-fill-loud: var(--ha-color-fill-warning-loud-resting);
--wa-color-warning-fill-normal: var(--ha-color-fill-warning-normal-resting);
--wa-color-warning-fill-quiet: var(--ha-color-fill-warning-quiet-hover);
--wa-color-warning-fill-quiet: var(--ha-color-fill-warning-quiet-resting);
--wa-color-warning-border-loud: var(--ha-color-border-warning-loud);
--wa-color-warning-border-normal: var(--ha-color-border-warning-normal);
--wa-color-warning-border-quiet: var(--ha-color-border-warning-quiet);
@@ -44,7 +44,7 @@ export const waColorStyles = css`
--wa-color-danger-fill-loud: var(--ha-color-fill-danger-loud-resting);
--wa-color-danger-fill-normal: var(--ha-color-fill-danger-normal-resting);
--wa-color-danger-fill-quiet: var(--ha-color-fill-danger-quiet-hover);
--wa-color-danger-fill-quiet: var(--ha-color-fill-danger-quiet-resting);
--wa-color-danger-border-loud: var(--ha-color-border-danger-loud);
--wa-color-danger-border-normal: var(--ha-color-border-danger-normal);
--wa-color-danger-border-quiet: var(--ha-color-border-danger-quiet);

View File

@@ -30,10 +30,6 @@ import { subscribeEntityRegistryDisplay } from "../data/ws-entity_registry_displ
import { subscribeFloorRegistry } from "../data/ws-floor_registry";
import { subscribePanels } from "../data/ws-panels";
import { translationMetadata } from "../resources/translations-metadata";
import {
clearBrandsTokenRefresh,
fetchAndScheduleBrandsAccessToken,
} from "../util/brands-url";
import type { Constructor, HomeAssistant, ServiceCallResponse } from "../types";
import { getLocalLanguage } from "../util/common-translation";
import { fetchWithAuth } from "../util/fetch-with-auth";
@@ -323,10 +319,6 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
this._updateHass({ systemData: {} });
});
clearInterval(this.__backendPingInterval);
// Fetch the brands access token on initial connect and schedule refresh
fetchAndScheduleBrandsAccessToken(this.hass!);
this.__backendPingInterval = setInterval(() => {
if (this.hass?.connected) {
// If the backend is busy, or the connection is latent,
@@ -351,9 +343,6 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
this._updateHass({ connected: true });
broadcastConnectionStatus("connected");
// Refresh the brands access token on reconnect and restart refresh schedule
fetchAndScheduleBrandsAccessToken(this.hass!);
// on reconnect always fetch config as we might miss an update while we were disconnected
// @ts-ignore
this.hass!.callWS({ type: "get_config" }).then((config: HassConfig) => {
@@ -371,6 +360,5 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
this._updateHass({ connected: false });
broadcastConnectionStatus("disconnected");
clearInterval(this.__backendPingInterval);
clearBrandsTokenRefresh();
}
};

View File

@@ -1524,8 +1524,6 @@
"settings": "Settings",
"edit": "Edit entity",
"details": "Details",
"translated": "Translated",
"raw": "Raw",
"back_to_info": "Back to info",
"info": "Information",
"related": "Related",
@@ -9092,6 +9090,33 @@
"large": "Large"
},
"show_seconds": "Display seconds",
"date": {
"label": "Date",
"description": "Select and order the date parts. Add a separator to control punctuation.",
"sections": {
"weekday": "Weekday",
"day": "Day",
"month": "Month",
"year": "Year",
"separator": "Separator"
},
"parts": {
"weekday-short": "Short",
"weekday-long": "Long",
"day-numeric": "Numeric",
"day-2-digit": "2-digit",
"month-short": "Short",
"month-long": "Long",
"month-numeric": "Numeric",
"month-2-digit": "2-digit",
"year-2-digit": "2-digit",
"year-numeric": "Full",
"separator-dash": "Dash",
"separator-slash": "Slash",
"separator-dot": "Dot",
"separator-new-line": "New line"
}
},
"time_format": "[%key:ui::panel::profile::time_format::dropdown_label%]",
"time_formats": {
"auto": "Use user settings",
@@ -10370,7 +10395,6 @@
"water_sankey_title": "Water flow",
"energy_top_consumers_title": "Top consumers",
"power_sankey_title": "Current power flow",
"water_flow_sankey_title": "Current water flow",
"power_sources_graph_title": "Power sources"
}
},

View File

@@ -142,7 +142,6 @@ export interface PanelInfo<T = Record<string, any> | null> {
config_panel_domain?: string;
default_visible?: boolean;
require_admin?: boolean;
show_in_sidebar?: boolean;
}
export type Panels = Record<string, PanelInfo>;

View File

@@ -1,9 +1,8 @@
import type { HomeAssistant } from "../types";
export interface BrandsOptions {
domain: string;
type: "icon" | "logo" | "icon@2x" | "logo@2x";
darkOptimized?: boolean;
brand?: boolean;
}
export interface HardwareBrandsOptions {
@@ -13,94 +12,17 @@ export interface HardwareBrandsOptions {
darkOptimized?: boolean;
}
let _brandsAccessToken: string | undefined;
let _brandsRefreshInterval: ReturnType<typeof setInterval> | undefined;
// Token refreshes every 30 minutes and is valid for 1 hour.
// Re-fetch every 30 minutes to always have a valid token.
const TOKEN_REFRESH_MS = 30 * 60 * 1000;
export const fetchAndScheduleBrandsAccessToken = (
hass: HomeAssistant
): Promise<void> =>
fetchBrandsAccessToken(hass).then(
() => scheduleBrandsTokenRefresh(hass),
() => {
// Ignore failures; older backends may not support this command
}
);
export const fetchBrandsAccessToken = async (
hass: HomeAssistant
): Promise<void> => {
const result = await hass.callWS<{ token: string }>({
type: "brands/access_token",
});
_brandsAccessToken = result.token;
};
export const scheduleBrandsTokenRefresh = (hass: HomeAssistant): void => {
clearBrandsTokenRefresh();
_brandsRefreshInterval = setInterval(() => {
fetchBrandsAccessToken(hass).catch(() => {
// Ignore failures; older backends may not support this command
});
}, TOKEN_REFRESH_MS);
};
export const clearBrandsTokenRefresh = (): void => {
if (_brandsRefreshInterval) {
clearInterval(_brandsRefreshInterval);
_brandsRefreshInterval = undefined;
}
};
export const brandsUrl = (options: BrandsOptions): string => {
const base = `/api/brands/integration/${options.domain}/${
export const brandsUrl = (options: BrandsOptions): string =>
`https://brands.home-assistant.io/${options.brand ? "brands/" : ""}_/${options.domain}/${
options.darkOptimized ? "dark_" : ""
}${options.type}.png`;
if (_brandsAccessToken) {
return `${base}?token=${_brandsAccessToken}`;
}
return base;
};
export const hardwareBrandsUrl = (options: HardwareBrandsOptions): string => {
const base = `/api/brands/hardware/${options.category}/${
export const hardwareBrandsUrl = (options: HardwareBrandsOptions): string =>
`https://brands.home-assistant.io/hardware/${options.category}/${
options.darkOptimized ? "dark_" : ""
}${options.manufacturer}${options.model ? `_${options.model}` : ""}.png`;
if (_brandsAccessToken) {
return `${base}?token=${_brandsAccessToken}`;
}
return base;
};
export const addBrandsAuth = (url: string): string => {
if (!_brandsAccessToken || !url.startsWith("/api/brands/")) {
return url;
}
const fullUrl = new URL(url, location.origin);
fullUrl.searchParams.set("token", _brandsAccessToken);
return `${fullUrl.pathname}${fullUrl.search}`;
};
export const extractDomainFromBrandUrl = (url: string): string => {
// Handle both new local API paths (/api/brands/integration/{domain}/...)
// and legacy CDN URLs (https://brands.home-assistant.io/_/{domain}/...)
if (url.startsWith("/api/brands/")) {
// /api/brands/integration/{domain}/... -> ["" ,"api", "brands", "integration", "{domain}", ...]
return url.split("/")[4];
}
// https://brands.home-assistant.io/_/{domain}/... -> ["", "_", "{domain}", ...]
const parsed = new URL(url);
const segments = parsed.pathname.split("/").filter((s) => s.length > 0);
const underscoreIdx = segments.indexOf("_");
if (underscoreIdx !== -1 && underscoreIdx + 1 < segments.length) {
return segments[underscoreIdx + 1];
}
return segments[1] ?? "";
};
export const extractDomainFromBrandUrl = (url: string) => url.split("/")[4];
export const isBrandUrl = (thumbnail: string | ""): boolean =>
thumbnail.startsWith("/api/brands/") ||
thumbnail.startsWith("https://brands.home-assistant.io/");

View File

@@ -0,0 +1,261 @@
import { assert, describe, it } from "vitest";
import type { ClockCardDatePart } from "../../../../src/panels/lovelace/cards/types";
import {
formatClockCardDate,
getClockCardDateConfig,
getClockCardDateTimeFormatOptions,
hasClockCardDate,
} from "../../../../src/panels/lovelace/cards/clock/clock-date-format";
describe("clock-date-format", () => {
const date = new Date("2024-11-08T10:20:30.000Z");
it("returns an empty config when date format is missing", () => {
assert.deepEqual(getClockCardDateConfig(), { parts: [] });
});
it("preserves literal token order", () => {
const config = getClockCardDateConfig({
date_format: [
"day-numeric",
"separator-dot",
"month-short",
"month-long",
"separator-slash",
"year-2-digit",
"year-numeric",
],
});
assert.deepEqual(config.parts, [
"day-numeric",
"separator-dot",
"month-short",
"month-long",
"separator-slash",
"year-2-digit",
"year-numeric",
]);
});
it("filters invalid date tokens", () => {
const config = getClockCardDateConfig({
date_format: [
"month-short",
"invalid",
"year-2-digit",
] as unknown as ClockCardDatePart[],
});
assert.deepEqual(config.parts, ["month-short", "year-2-digit"]);
});
it("builds Intl options from selected date tokens", () => {
const options = getClockCardDateTimeFormatOptions({
parts: [
"weekday-short",
"separator-slash",
"day-2-digit",
"month-long",
"month-numeric",
"year-2-digit",
],
});
assert.deepEqual(options, {
weekday: "short",
day: "2-digit",
month: "numeric",
year: "2-digit",
});
});
it("reports whether any date part is configured", () => {
assert.equal(hasClockCardDate(), false);
assert.equal(hasClockCardDate({ date_format: [] }), false);
assert.equal(hasClockCardDate({ date_format: ["separator-dot"] }), true);
assert.equal(
hasClockCardDate({ date_format: ["separator-new-line"] }),
true
);
assert.equal(hasClockCardDate({ date_format: ["weekday-short"] }), true);
});
it("formats output in configured part order with literal separators", () => {
const result = formatClockCardDate(
date,
{
parts: [
"month-numeric",
"separator-slash",
"day-2-digit",
"separator-dash",
"year-2-digit",
],
},
"en",
"UTC"
);
assert.equal(result, "11/08-24");
});
it("uses separator only for the next gap", () => {
const result = formatClockCardDate(
date,
{
parts: [
"day-numeric",
"separator-dot",
"month-numeric",
"year-numeric",
],
},
"en",
"UTC"
);
assert.equal(result, "8.11 2024");
});
it("supports using the same separator style multiple times", () => {
const result = formatClockCardDate(
date,
{
parts: [
"month-numeric",
"separator-slash",
"day-2-digit",
"separator-slash",
"year-2-digit",
],
},
"en",
"UTC"
);
assert.equal(result, "11/08/24");
});
it("renders separators even when no value follows", () => {
const result = formatClockCardDate(
date,
{
parts: ["day-numeric", "separator-dash", "separator-dot"],
},
"en",
"UTC"
);
assert.equal(result, "8-.");
});
it("renders separators even when no value precedes", () => {
const result = formatClockCardDate(
date,
{
parts: ["separator-slash", "separator-dot", "day-numeric"],
},
"en",
"UTC"
);
assert.equal(result, "/.8");
});
it("renders all consecutive separators between values", () => {
const result = formatClockCardDate(
date,
{
parts: [
"day-numeric",
"separator-dash",
"separator-slash",
"separator-dot",
"month-numeric",
],
},
"en",
"UTC"
);
assert.equal(result, "8-/.11");
});
it("renders repeated separators without deduplication", () => {
const result = formatClockCardDate(
date,
{
parts: [
"day-numeric",
"separator-dash",
"separator-dash",
"separator-dash",
"month-numeric",
],
},
"en",
"UTC"
);
assert.equal(result, "8---11");
});
it("renders separator-only configurations", () => {
const result = formatClockCardDate(
date,
{
parts: ["separator-dash", "separator-slash", "separator-dot"],
},
"en",
"UTC"
);
assert.equal(result, "-/.");
});
it("supports inserting a new line between date values", () => {
const result = formatClockCardDate(
date,
{
parts: [
"month-numeric",
"separator-new-line",
"day-2-digit",
"year-numeric",
],
},
"en",
"UTC"
);
assert.equal(result, "11\n08 2024");
});
it("allows multiple variants for the same date part", () => {
const result = formatClockCardDate(
date,
{
parts: ["month-short", "month-long", "year-numeric"],
},
"en",
"UTC"
);
assert.equal(result, "Nov November 2024");
});
it("filters invalid tokens when formatting", () => {
const config = getClockCardDateConfig({
date_format: [
"month-numeric",
"invalid",
"year-numeric",
] as unknown as ClockCardDatePart[],
});
const result = formatClockCardDate(date, config, "en", "UTC");
assert.equal(result, "11 2024");
});
});

View File

@@ -1,144 +1,27 @@
import { assert, describe, it, vi, afterEach } from "vitest";
import type { HomeAssistant } from "../../src/types";
import {
addBrandsAuth,
brandsUrl,
clearBrandsTokenRefresh,
fetchBrandsAccessToken,
scheduleBrandsTokenRefresh,
} from "../../src/util/brands-url";
import { assert, describe, it } from "vitest";
import { brandsUrl } from "../../src/util/brands-url";
describe("Generate brands Url", () => {
it("Generate logo brands url for cloud component", () => {
assert.strictEqual(
// @ts-ignore
brandsUrl({ domain: "cloud", type: "logo" }),
"/api/brands/integration/cloud/logo.png"
"https://brands.home-assistant.io/_/cloud/logo.png"
);
});
it("Generate icon brands url for cloud component", () => {
assert.strictEqual(
// @ts-ignore
brandsUrl({ domain: "cloud", type: "icon" }),
"/api/brands/integration/cloud/icon.png"
"https://brands.home-assistant.io/_/cloud/icon.png"
);
});
it("Generate dark theme optimized logo brands url for cloud component", () => {
assert.strictEqual(
// @ts-ignore
brandsUrl({ domain: "cloud", type: "logo", darkOptimized: true }),
"/api/brands/integration/cloud/dark_logo.png"
"https://brands.home-assistant.io/_/cloud/dark_logo.png"
);
});
});
describe("addBrandsAuth", () => {
it("Returns non-brands URLs unchanged", () => {
assert.strictEqual(
addBrandsAuth("/api/camera_proxy/camera.foo?token=abc"),
"/api/camera_proxy/camera.foo?token=abc"
);
});
it("Returns brands URL unchanged when no token is available", () => {
assert.strictEqual(
addBrandsAuth("/api/brands/integration/demo/icon.png"),
"/api/brands/integration/demo/icon.png"
);
});
it("Appends token to brands URL when token is available", async () => {
const mockHass = {
callWS: async () => ({ token: "test-token-123" }),
} as unknown as HomeAssistant;
await fetchBrandsAccessToken(mockHass);
assert.strictEqual(
addBrandsAuth("/api/brands/integration/demo/icon.png"),
"/api/brands/integration/demo/icon.png?token=test-token-123"
);
});
it("Replaces existing token param instead of duplicating", async () => {
const mockHass = {
callWS: async () => ({ token: "new-token" }),
} as unknown as HomeAssistant;
await fetchBrandsAccessToken(mockHass);
assert.strictEqual(
addBrandsAuth("/api/brands/integration/demo/icon.png?token=old-token"),
"/api/brands/integration/demo/icon.png?token=new-token"
);
});
});
describe("scheduleBrandsTokenRefresh", () => {
afterEach(() => {
clearBrandsTokenRefresh();
vi.restoreAllMocks();
vi.useRealTimers();
});
it("Refreshes the token after 30 minutes", async () => {
vi.useFakeTimers();
let callCount = 0;
const mockHass = {
callWS: async () => {
callCount++;
return { token: `token-${callCount}` };
},
} as unknown as HomeAssistant;
await fetchBrandsAccessToken(mockHass);
assert.strictEqual(callCount, 1);
assert.strictEqual(
brandsUrl({ domain: "test", type: "icon" }),
"/api/brands/integration/test/icon.png?token=token-1"
);
scheduleBrandsTokenRefresh(mockHass);
// Advance 30 minutes
await vi.advanceTimersByTimeAsync(30 * 60 * 1000);
assert.strictEqual(callCount, 2);
assert.strictEqual(
brandsUrl({ domain: "test", type: "icon" }),
"/api/brands/integration/test/icon.png?token=token-2"
);
});
it("Does not refresh before 30 minutes", async () => {
vi.useFakeTimers();
let callCount = 0;
const mockHass = {
callWS: async () => {
callCount++;
return { token: `token-${callCount}` };
},
} as unknown as HomeAssistant;
await fetchBrandsAccessToken(mockHass);
scheduleBrandsTokenRefresh(mockHass);
// Advance 29 minutes — should not have refreshed
await vi.advanceTimersByTimeAsync(29 * 60 * 1000);
assert.strictEqual(callCount, 1);
});
it("clearBrandsTokenRefresh stops the interval", async () => {
vi.useFakeTimers();
let callCount = 0;
const mockHass = {
callWS: async () => {
callCount++;
return { token: `token-${callCount}` };
},
} as unknown as HomeAssistant;
await fetchBrandsAccessToken(mockHass);
scheduleBrandsTokenRefresh(mockHass);
clearBrandsTokenRefresh();
// Advance 30 minutes — should not have refreshed because we cleared
await vi.advanceTimersByTimeAsync(30 * 60 * 1000);
assert.strictEqual(callCount, 1);
});
});

356
yarn.lock
View File

@@ -1204,14 +1204,14 @@ __metadata:
languageName: node
linkType: hard
"@bundle-stats/plugin-webpack-filter@npm:4.21.10":
version: 4.21.10
resolution: "@bundle-stats/plugin-webpack-filter@npm:4.21.10"
"@bundle-stats/plugin-webpack-filter@npm:4.21.9":
version: 4.21.9
resolution: "@bundle-stats/plugin-webpack-filter@npm:4.21.9"
dependencies:
tslib: "npm:2.8.1"
peerDependencies:
core-js: ^3.0.0
checksum: 10/25655718152d351429d5a2fb78652d51039d6b444da596c24f576840d1dc9cb8070204748f3f48a010401d0da21b42e5b73c840794dbd504f2fd62bf7bdda691
checksum: 10/927bdcc8b4822d2f167f3bf53023a51579268c6aaf5cdafbe4243c30da742e776d955c8fd7a4bad866ff3c6761cdfc5e2beb5f4c76b5080d8b9509cc0e3b159e
languageName: node
linkType: hard
@@ -1282,15 +1282,15 @@ __metadata:
languageName: node
linkType: hard
"@codemirror/view@npm:6.39.15, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0, @codemirror/view@npm:^6.37.0":
version: 6.39.15
resolution: "@codemirror/view@npm:6.39.15"
"@codemirror/view@npm:6.39.12, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0, @codemirror/view@npm:^6.37.0":
version: 6.39.12
resolution: "@codemirror/view@npm:6.39.12"
dependencies:
"@codemirror/state": "npm:^6.5.0"
crelt: "npm:^1.0.6"
style-mod: "npm:^4.1.0"
w3c-keyname: "npm:^2.2.4"
checksum: 10/098889e36997cfca0699fff637f87c698ee324786d231a13973bd3ccd15c8d4efdb9b85acc509ea76aaba2f0e1f67868fabb40625ca86684e9d8c8f1abe65d9b
checksum: 10/acd476d485914095fe38009bb29c7a6ac4cf3de2d3921172e27fc40639eb5bce0534766fd6e093c94aa60f076f263ce90a670a8052e220244bf4065f95c064bb
languageName: node
linkType: hard
@@ -1627,13 +1627,13 @@ __metadata:
languageName: node
linkType: hard
"@eslint/css-tree@npm:^3.6.9":
version: 3.6.9
resolution: "@eslint/css-tree@npm:3.6.9"
"@eslint/css-tree@npm:^3.6.8":
version: 3.6.8
resolution: "@eslint/css-tree@npm:3.6.8"
dependencies:
mdn-data: "npm:2.23.0"
source-map-js: "npm:^1.0.1"
checksum: 10/f3ce66f5c8ba1957b82e1c1df79b91092cde79d3c6d3abeb9facaccb4fecc21018bf352f75e39d614b7bb2e33dd4bebaa60f2d98468167237080b9e31b35e19d
checksum: 10/dce5da0aef43b82375906b1760b1cbea29a424ec458564f3274295dd1625610809603f96dc5a7cccab85e4d943996b917347c9a3e84f2328c24912685f400053
languageName: node
linkType: hard
@@ -1654,10 +1654,10 @@ __metadata:
languageName: node
linkType: hard
"@eslint/js@npm:9.39.3":
version: 9.39.3
resolution: "@eslint/js@npm:9.39.3"
checksum: 10/91a1a1822cfeb2eb8a89aae86be5dfabad0b66b0915946516690a8485ddd80b91f43eee346789313fea1acbb7390a4958119ca7dc9a684a5c4014f12fcb3aaf3
"@eslint/js@npm:9.39.2":
version: 9.39.2
resolution: "@eslint/js@npm:9.39.2"
checksum: 10/6b7f676746f3111b5d1b23715319212ab9297868a0fa9980d483c3da8965d5841673aada2d5653e85a3f7156edee0893a7ae7035211b4efdcb2848154bb947f2
languageName: node
linkType: hard
@@ -1758,15 +1758,15 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/intl-datetimeformat@npm:7.2.2":
version: 7.2.2
resolution: "@formatjs/intl-datetimeformat@npm:7.2.2"
"@formatjs/intl-datetimeformat@npm:7.2.1":
version: 7.2.1
resolution: "@formatjs/intl-datetimeformat@npm:7.2.1"
dependencies:
"@formatjs/ecma402-abstract": "npm:3.1.1"
"@formatjs/intl-localematcher": "npm:0.8.1"
decimal.js: "npm:^10.6.0"
tslib: "npm:^2.8.1"
checksum: 10/7ab7bc95687cdb6e6e22d5b717d6770b8b692e7feb630d0b206825e12ed82b0eb6692d4b9e7b067b2e63deaf9d08305ecb9ae43bcd5b6ad426c13d32659be62b
checksum: 10/ed6664a70084780d13db121debb9f337d93b9f17b8690158fcf37684aaccbc9f74366bc3a01a34a3032302b0fc2189fd924492f049869c623d3c94f08c60675e
languageName: node
linkType: hard
@@ -1979,74 +1979,63 @@ __metadata:
languageName: node
linkType: hard
"@html-eslint/core@npm:^0.56.0":
version: 0.56.0
resolution: "@html-eslint/core@npm:0.56.0"
dependencies:
"@html-eslint/types": "npm:^0.56.0"
eslint: "npm:^9.39.1"
html-standard: "npm:^0.0.13"
checksum: 10/0722e7405e56b256c471229dbd0ccb49cf0edd3e5224a087240f1954c74af0a6f289e5be0b83957480f1fa5abf6f7cbad7212134a6872a3fd66e1dc1ad879d66
languageName: node
linkType: hard
"@html-eslint/eslint-plugin@npm:0.56.0":
version: 0.56.0
resolution: "@html-eslint/eslint-plugin@npm:0.56.0"
"@html-eslint/eslint-plugin@npm:0.55.0":
version: 0.55.0
resolution: "@html-eslint/eslint-plugin@npm:0.55.0"
dependencies:
"@eslint/plugin-kit": "npm:^0.4.1"
"@html-eslint/core": "npm:^0.56.0"
"@html-eslint/parser": "npm:^0.56.0"
"@html-eslint/template-parser": "npm:^0.56.0"
"@html-eslint/template-syntax-parser": "npm:^0.56.0"
"@html-eslint/types": "npm:^0.56.0"
"@html-eslint/parser": "npm:^0.55.0"
"@html-eslint/template-parser": "npm:^0.55.0"
"@html-eslint/template-syntax-parser": "npm:^0.55.0"
"@html-eslint/types": "npm:^0.55.0"
html-standard: "npm:^0.0.11"
peerDependencies:
eslint: ">=8.0.0 || ^10.0.0-0"
checksum: 10/16c5d638045266ca201b5dc154b3706262801668b2edde4f0905bdba9ba2e14b7175eacf9ca7d2ad5a5a03e3369843822d34ee607b6b7fbbeb020ac7f87629fb
checksum: 10/945a0f0b6f4007beade9d22b889984498d10aa7855d3087fd9fb71dc20b1da7b452a3877b55f7683ef12736b356279356d66e8a201b25ee4a332e139315f16ae
languageName: node
linkType: hard
"@html-eslint/parser@npm:^0.56.0":
version: 0.56.0
resolution: "@html-eslint/parser@npm:0.56.0"
"@html-eslint/parser@npm:^0.55.0":
version: 0.55.0
resolution: "@html-eslint/parser@npm:0.55.0"
dependencies:
"@eslint/css-tree": "npm:^3.6.9"
"@html-eslint/template-syntax-parser": "npm:^0.56.0"
"@html-eslint/types": "npm:^0.56.0"
"@eslint/css-tree": "npm:^3.6.8"
"@html-eslint/template-syntax-parser": "npm:^0.55.0"
"@html-eslint/types": "npm:^0.55.0"
css-tree: "npm:^3.1.0"
es-html-parser: "npm:0.3.1"
checksum: 10/1fead2be97481f461ee7030f4ea9274e5f969540118b4429e3eea361f62138660c1b7538668c7f8d55a8ae6985deff3056968d71bae62b91fb001d4209a24b50
checksum: 10/f763ffb6d33f7ae7b8873772fabcb93650cd0e160b8b855cb24c41e19d9b9d423781ba2eff5cbe25b3ffc628c592a4350e91c6c56af475d21429595d944a359d
languageName: node
linkType: hard
"@html-eslint/template-parser@npm:^0.56.0":
version: 0.56.0
resolution: "@html-eslint/template-parser@npm:0.56.0"
"@html-eslint/template-parser@npm:^0.55.0":
version: 0.55.0
resolution: "@html-eslint/template-parser@npm:0.55.0"
dependencies:
"@html-eslint/types": "npm:^0.56.0"
"@html-eslint/types": "npm:^0.55.0"
es-html-parser: "npm:0.3.1"
checksum: 10/45e41dc8fb46336f40d1b78ef55ee3150d7a1034d393529eb40ce37a7d0a8fa04f6ed8f9a63478ff884d9588b1359b97a799681a37cd485d237dce867b7f9d20
checksum: 10/a8243200a347319d14614496790b1954b55dca148406c3af58d62c158b28934c4aa832e62e546961a99cfced551d0468dc5bae4455b005ea500fad7435427934
languageName: node
linkType: hard
"@html-eslint/template-syntax-parser@npm:^0.56.0":
version: 0.56.0
resolution: "@html-eslint/template-syntax-parser@npm:0.56.0"
"@html-eslint/template-syntax-parser@npm:^0.55.0":
version: 0.55.0
resolution: "@html-eslint/template-syntax-parser@npm:0.55.0"
dependencies:
"@html-eslint/types": "npm:^0.56.0"
checksum: 10/2cc4f2cbfa58c6381b2542ca86e424a97429326aa0b8b6b09e0a51d27b0e26fd723a4550e4c17536e00cfd506cbd0c61d8fd3dff1c829714c4432406096b4a01
"@html-eslint/types": "npm:^0.55.0"
checksum: 10/d2207b86abb86014aefb24e10e9d1b40719b4e884c146079704fb2dd95dcd5be909df9e71e18b0bbc365d7ae7b787a9d48a4d8126999bc5d109f8ae4a0a130bb
languageName: node
linkType: hard
"@html-eslint/types@npm:^0.56.0":
version: 0.56.0
resolution: "@html-eslint/types@npm:0.56.0"
"@html-eslint/types@npm:^0.55.0":
version: 0.55.0
resolution: "@html-eslint/types@npm:0.55.0"
dependencies:
"@types/css-tree": "npm:^2.3.11"
"@types/estree": "npm:^1.0.6"
es-html-parser: "npm:0.3.1"
eslint: "npm:^9.39.1"
checksum: 10/5359ccfb8f431ccedfe7d9c0b20d883d19bbb6a24eee925c036c7774d6f2177eda021826abbc6f947b59c2364d7cf25a596591e852c1141000a3fab6ea70acc9
checksum: 10/10d6b53e0cbb3f529a20949040bc363722f6c91bd224d78c8df2d8e6ff8ac157186e23db7bff61e7c1927f110237fd669c9d910a72399aa8b9929445c288a3cb
languageName: node
linkType: hard
@@ -2081,6 +2070,22 @@ __metadata:
languageName: node
linkType: hard
"@isaacs/balanced-match@npm:^4.0.1":
version: 4.0.1
resolution: "@isaacs/balanced-match@npm:4.0.1"
checksum: 10/102fbc6d2c0d5edf8f6dbf2b3feb21695a21bc850f11bc47c4f06aa83bd8884fde3fe9d6d797d619901d96865fdcb4569ac2a54c937992c48885c5e3d9967fe8
languageName: node
linkType: hard
"@isaacs/brace-expansion@npm:^5.0.1":
version: 5.0.1
resolution: "@isaacs/brace-expansion@npm:5.0.1"
dependencies:
"@isaacs/balanced-match": "npm:^4.0.1"
checksum: 10/aec226065bc4285436a27379e08cc35bf94ef59f5098ac1c026495c9ba4ab33d851964082d3648d56d63eb90f2642867bd15a3e1b810b98beb1a8c14efce6a94
languageName: node
linkType: hard
"@isaacs/fs-minipass@npm:^4.0.0":
version: 4.0.1
resolution: "@isaacs/fs-minipass@npm:4.0.1"
@@ -3592,16 +3597,16 @@ __metadata:
languageName: node
linkType: hard
"@octokit/plugin-retry@npm:8.1.0":
version: 8.1.0
resolution: "@octokit/plugin-retry@npm:8.1.0"
"@octokit/plugin-retry@npm:8.0.3":
version: 8.0.3
resolution: "@octokit/plugin-retry@npm:8.0.3"
dependencies:
"@octokit/request-error": "npm:^7.0.2"
"@octokit/types": "npm:^16.0.0"
bottleneck: "npm:^2.15.3"
peerDependencies:
"@octokit/core": ">=7"
checksum: 10/0bccaa14ef295ac5dc3e6fa96bb7c555b8b188dfe0bf1db5ea83acb29af375bf08a43e0d44c42941608afc6ab414b6dcdfb44881a8e8346b963d7501e0aea766
checksum: 10/fbd459a1a73b286006fd09d1779c7e6fd2687ce945f4a66d87685c50466dab07de8f70a8ea0e87dbfe85924c5f56539e0c3631ea640b0b1b4dd23699298e1aa9
languageName: node
linkType: hard
@@ -4067,92 +4072,92 @@ __metadata:
languageName: node
linkType: hard
"@rspack/binding-darwin-arm64@npm:1.7.6":
version: 1.7.6
resolution: "@rspack/binding-darwin-arm64@npm:1.7.6"
"@rspack/binding-darwin-arm64@npm:1.7.5":
version: 1.7.5
resolution: "@rspack/binding-darwin-arm64@npm:1.7.5"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@rspack/binding-darwin-x64@npm:1.7.6":
version: 1.7.6
resolution: "@rspack/binding-darwin-x64@npm:1.7.6"
"@rspack/binding-darwin-x64@npm:1.7.5":
version: 1.7.5
resolution: "@rspack/binding-darwin-x64@npm:1.7.5"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@rspack/binding-linux-arm64-gnu@npm:1.7.6":
version: 1.7.6
resolution: "@rspack/binding-linux-arm64-gnu@npm:1.7.6"
"@rspack/binding-linux-arm64-gnu@npm:1.7.5":
version: 1.7.5
resolution: "@rspack/binding-linux-arm64-gnu@npm:1.7.5"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-arm64-musl@npm:1.7.6":
version: 1.7.6
resolution: "@rspack/binding-linux-arm64-musl@npm:1.7.6"
"@rspack/binding-linux-arm64-musl@npm:1.7.5":
version: 1.7.5
resolution: "@rspack/binding-linux-arm64-musl@npm:1.7.5"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-linux-x64-gnu@npm:1.7.6":
version: 1.7.6
resolution: "@rspack/binding-linux-x64-gnu@npm:1.7.6"
"@rspack/binding-linux-x64-gnu@npm:1.7.5":
version: 1.7.5
resolution: "@rspack/binding-linux-x64-gnu@npm:1.7.5"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-x64-musl@npm:1.7.6":
version: 1.7.6
resolution: "@rspack/binding-linux-x64-musl@npm:1.7.6"
"@rspack/binding-linux-x64-musl@npm:1.7.5":
version: 1.7.5
resolution: "@rspack/binding-linux-x64-musl@npm:1.7.5"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-wasm32-wasi@npm:1.7.6":
version: 1.7.6
resolution: "@rspack/binding-wasm32-wasi@npm:1.7.6"
"@rspack/binding-wasm32-wasi@npm:1.7.5":
version: 1.7.5
resolution: "@rspack/binding-wasm32-wasi@npm:1.7.5"
dependencies:
"@napi-rs/wasm-runtime": "npm:1.0.7"
conditions: cpu=wasm32
languageName: node
linkType: hard
"@rspack/binding-win32-arm64-msvc@npm:1.7.6":
version: 1.7.6
resolution: "@rspack/binding-win32-arm64-msvc@npm:1.7.6"
"@rspack/binding-win32-arm64-msvc@npm:1.7.5":
version: 1.7.5
resolution: "@rspack/binding-win32-arm64-msvc@npm:1.7.5"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@rspack/binding-win32-ia32-msvc@npm:1.7.6":
version: 1.7.6
resolution: "@rspack/binding-win32-ia32-msvc@npm:1.7.6"
"@rspack/binding-win32-ia32-msvc@npm:1.7.5":
version: 1.7.5
resolution: "@rspack/binding-win32-ia32-msvc@npm:1.7.5"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@rspack/binding-win32-x64-msvc@npm:1.7.6":
version: 1.7.6
resolution: "@rspack/binding-win32-x64-msvc@npm:1.7.6"
"@rspack/binding-win32-x64-msvc@npm:1.7.5":
version: 1.7.5
resolution: "@rspack/binding-win32-x64-msvc@npm:1.7.5"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@rspack/binding@npm:1.7.6":
version: 1.7.6
resolution: "@rspack/binding@npm:1.7.6"
"@rspack/binding@npm:1.7.5":
version: 1.7.5
resolution: "@rspack/binding@npm:1.7.5"
dependencies:
"@rspack/binding-darwin-arm64": "npm:1.7.6"
"@rspack/binding-darwin-x64": "npm:1.7.6"
"@rspack/binding-linux-arm64-gnu": "npm:1.7.6"
"@rspack/binding-linux-arm64-musl": "npm:1.7.6"
"@rspack/binding-linux-x64-gnu": "npm:1.7.6"
"@rspack/binding-linux-x64-musl": "npm:1.7.6"
"@rspack/binding-wasm32-wasi": "npm:1.7.6"
"@rspack/binding-win32-arm64-msvc": "npm:1.7.6"
"@rspack/binding-win32-ia32-msvc": "npm:1.7.6"
"@rspack/binding-win32-x64-msvc": "npm:1.7.6"
"@rspack/binding-darwin-arm64": "npm:1.7.5"
"@rspack/binding-darwin-x64": "npm:1.7.5"
"@rspack/binding-linux-arm64-gnu": "npm:1.7.5"
"@rspack/binding-linux-arm64-musl": "npm:1.7.5"
"@rspack/binding-linux-x64-gnu": "npm:1.7.5"
"@rspack/binding-linux-x64-musl": "npm:1.7.5"
"@rspack/binding-wasm32-wasi": "npm:1.7.5"
"@rspack/binding-win32-arm64-msvc": "npm:1.7.5"
"@rspack/binding-win32-ia32-msvc": "npm:1.7.5"
"@rspack/binding-win32-x64-msvc": "npm:1.7.5"
dependenciesMeta:
"@rspack/binding-darwin-arm64":
optional: true
@@ -4174,23 +4179,23 @@ __metadata:
optional: true
"@rspack/binding-win32-x64-msvc":
optional: true
checksum: 10/fec6c978e51f20471e278a07018b414125cf3bccf9c6bd7032ca65603cfe5bf0fdd7f58c156c0640b5dfab05e82a1e1170ac6d1aacaf4f46b61564be77dbe41b
checksum: 10/3e66805d4dae5f2051f10c5a9126e5bf25926d2a6ccb1c794af2aa49c15e4fdcb9e362bd015a9afef1e788a3272dfe7a28a3c866713badda34579896d736ed4f
languageName: node
linkType: hard
"@rspack/core@npm:1.7.6":
version: 1.7.6
resolution: "@rspack/core@npm:1.7.6"
"@rspack/core@npm:1.7.5":
version: 1.7.5
resolution: "@rspack/core@npm:1.7.5"
dependencies:
"@module-federation/runtime-tools": "npm:0.22.0"
"@rspack/binding": "npm:1.7.6"
"@rspack/binding": "npm:1.7.5"
"@rspack/lite-tapable": "npm:1.1.0"
peerDependencies:
"@swc/helpers": ">=0.5.1"
peerDependenciesMeta:
"@swc/helpers":
optional: true
checksum: 10/9f23c4849926d9ddff34f703ab2be41878bca9e877c130d16d20d911ba4b13f15dfe96d7e86225d7f5a1e48034ab92cccec89f3765f84ff518538f6bb07f1f06
checksum: 10/c17d93ef1e7e0728b74bf527150642d9bf072cabb63964ebd32c82da94d6d2f9eac7ff2cc13031bbd376c0c03710899f549e61d2bcfc0a6638a9f3bc8620b7ce
languageName: node
linkType: hard
@@ -6061,13 +6066,6 @@ __metadata:
languageName: node
linkType: hard
"balanced-match@npm:^4.0.2":
version: 4.0.4
resolution: "balanced-match@npm:4.0.4"
checksum: 10/fb07bb66a0959c2843fc055838047e2a95ccebb837c519614afb067ebfdf2fa967ca8d712c35ced07f2cd26fc6f07964230b094891315ad74f11eba3d53178a0
languageName: node
linkType: hard
"barcode-detector@npm:3.0.8":
version: 3.0.8
resolution: "barcode-detector@npm:3.0.8"
@@ -6239,15 +6237,6 @@ __metadata:
languageName: node
linkType: hard
"brace-expansion@npm:^5.0.2":
version: 5.0.3
resolution: "brace-expansion@npm:5.0.3"
dependencies:
balanced-match: "npm:^4.0.2"
checksum: 10/8ba7deae4ca333d52418d2cde3287ac23f44f7330d92c3ecd96a8941597bea8aab02227bd990944d6711dd549bcc6e550fe70be5d94aa02e2fdc88942f480c9b
languageName: node
linkType: hard
"braces@npm:^3.0.3, braces@npm:~3.0.2":
version: 3.0.3
resolution: "braces@npm:3.0.3"
@@ -7951,28 +7940,28 @@ __metadata:
languageName: node
linkType: hard
"eslint-plugin-lit@npm:2.2.1, eslint-plugin-lit@npm:^2.0.0":
version: 2.2.1
resolution: "eslint-plugin-lit@npm:2.2.1"
"eslint-plugin-lit@npm:2.1.1, eslint-plugin-lit@npm:^2.0.0":
version: 2.1.1
resolution: "eslint-plugin-lit@npm:2.1.1"
dependencies:
parse5: "npm:^6.0.1"
parse5-htmlparser2-tree-adapter: "npm:^6.0.1"
peerDependencies:
eslint: ">= 8"
checksum: 10/fc0f3c2bec52c2c1c2467b373a71e68f2bb101b3872b30be9f439660e735684bec016c6b83cea1ed71e6d94f9ec9621a805f3a188b618b84c2e25ae1adbde192
checksum: 10/4eebb8612f5383e495bea277eda3bb86ed43d6532ec6c9443ca5048a848f96bf9b7e7f55d34dd43550d5e02cd815f19bd2e5e56c11b91d418b7948b379c78d08
languageName: node
linkType: hard
"eslint-plugin-unused-imports@npm:4.4.1":
version: 4.4.1
resolution: "eslint-plugin-unused-imports@npm:4.4.1"
"eslint-plugin-unused-imports@npm:4.3.0":
version: 4.3.0
resolution: "eslint-plugin-unused-imports@npm:4.3.0"
peerDependencies:
"@typescript-eslint/eslint-plugin": ^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0
eslint: ^10.0.0 || ^9.0.0 || ^8.0.0
eslint: ^9.0.0 || ^8.0.0
peerDependenciesMeta:
"@typescript-eslint/eslint-plugin":
optional: true
checksum: 10/b420fd55c393a6fdacfdbd0d1adf4cd44bed9a6584f05245091a6716272c57f38154d04b76f253619d8bf22823c0b9d630ef6b5b09edad6e51b8a1f7aec56c22
checksum: 10/64ec1f686d90f18a27c27273a0338bad6964a611f497b81c5e7eace9d9a4f38d96070d7c389c2626095a9e5e7dbcafccc6444fa2933c9a7649996a7ca875738f
languageName: node
linkType: hard
@@ -8019,9 +8008,9 @@ __metadata:
languageName: node
linkType: hard
"eslint@npm:9.39.3, eslint@npm:^9.39.1":
version: 9.39.3
resolution: "eslint@npm:9.39.3"
"eslint@npm:9.39.2, eslint@npm:^9.39.1":
version: 9.39.2
resolution: "eslint@npm:9.39.2"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.8.0"
"@eslint-community/regexpp": "npm:^4.12.1"
@@ -8029,7 +8018,7 @@ __metadata:
"@eslint/config-helpers": "npm:^0.4.2"
"@eslint/core": "npm:^0.17.0"
"@eslint/eslintrc": "npm:^3.3.1"
"@eslint/js": "npm:9.39.3"
"@eslint/js": "npm:9.39.2"
"@eslint/plugin-kit": "npm:^0.4.1"
"@humanfs/node": "npm:^0.16.6"
"@humanwhocodes/module-importer": "npm:^1.0.1"
@@ -8064,7 +8053,7 @@ __metadata:
optional: true
bin:
eslint: bin/eslint.js
checksum: 10/1c95c92983ddf435e7f7d54edd06d703a15773a7d189583d3388e5b5ac714f0a2450b91c0b3bb9b9ccec9bd20994fd8e48d231ed6dabca0be56ef314b32820ff
checksum: 10/53ff0e9c8264e7e8d40d50fdc0c0df0b701cfc5289beedfb686c214e3e7b199702f894bbd1bb48653727bb1ecbd1147cf5f555a4ae71e1daf35020cdc9072d9f
languageName: node
linkType: hard
@@ -8845,14 +8834,14 @@ __metadata:
languageName: node
linkType: hard
"glob@npm:13.0.6, glob@npm:^13.0.0":
version: 13.0.6
resolution: "glob@npm:13.0.6"
"glob@npm:13.0.1, glob@npm:^13.0.0":
version: 13.0.1
resolution: "glob@npm:13.0.1"
dependencies:
minimatch: "npm:^10.2.2"
minipass: "npm:^7.1.3"
path-scurry: "npm:^2.0.2"
checksum: 10/201ad69e5f0aa74e1d8c00a481581f8b8c804b6a4fbfabeeb8541f5d756932800331daeba99b58fb9e4cd67e12ba5a7eba5b82fb476691588418060b84353214
minimatch: "npm:^10.1.2"
minipass: "npm:^7.1.2"
path-scurry: "npm:^2.0.0"
checksum: 10/465e8cc269ab88d7415a3906cdc0f4543a2ae54df99207204af5bc28a944396d8d893822f546a8056a78ec714e608ab4f3502532c4d6b9cc5e113adf0fe5109e
languageName: node
linkType: hard
@@ -9149,17 +9138,17 @@ __metadata:
"@babel/preset-env": "npm:7.29.0"
"@babel/runtime": "npm:7.28.6"
"@braintree/sanitize-url": "npm:7.1.2"
"@bundle-stats/plugin-webpack-filter": "npm:4.21.10"
"@bundle-stats/plugin-webpack-filter": "npm:4.21.9"
"@codemirror/autocomplete": "npm:6.20.0"
"@codemirror/commands": "npm:6.10.2"
"@codemirror/language": "npm:6.12.1"
"@codemirror/legacy-modes": "npm:6.5.2"
"@codemirror/search": "npm:6.6.0"
"@codemirror/state": "npm:6.5.4"
"@codemirror/view": "npm:6.39.15"
"@codemirror/view": "npm:6.39.12"
"@date-fns/tz": "npm:1.4.1"
"@egjs/hammerjs": "npm:2.0.17"
"@formatjs/intl-datetimeformat": "npm:7.2.2"
"@formatjs/intl-datetimeformat": "npm:7.2.1"
"@formatjs/intl-displaynames": "npm:7.2.1"
"@formatjs/intl-durationformat": "npm:0.10.1"
"@formatjs/intl-getcanonicallocales": "npm:3.2.1"
@@ -9175,7 +9164,7 @@ __metadata:
"@fullcalendar/luxon3": "npm:6.1.20"
"@fullcalendar/timegrid": "npm:6.1.20"
"@home-assistant/webawesome": "npm:3.2.1-ha.2"
"@html-eslint/eslint-plugin": "npm:0.56.0"
"@html-eslint/eslint-plugin": "npm:0.55.0"
"@lezer/highlight": "npm:1.2.3"
"@lit-labs/motion": "npm:1.1.0"
"@lit-labs/observers": "npm:2.1.0"
@@ -9207,11 +9196,11 @@ __metadata:
"@mdi/js": "npm:7.4.47"
"@mdi/svg": "npm:7.4.47"
"@octokit/auth-oauth-device": "npm:8.0.3"
"@octokit/plugin-retry": "npm:8.1.0"
"@octokit/plugin-retry": "npm:8.0.3"
"@octokit/rest": "npm:22.0.1"
"@replit/codemirror-indentation-markers": "npm:6.5.3"
"@rsdoctor/rspack-plugin": "npm:1.5.2"
"@rspack/core": "npm:1.7.6"
"@rspack/core": "npm:1.7.5"
"@rspack/dev-server": "npm:1.2.1"
"@swc/helpers": "npm:0.5.18"
"@thomasloven/round-slider": "npm:0.6.0"
@@ -9257,19 +9246,19 @@ __metadata:
dialog-polyfill: "npm:0.5.6"
echarts: "npm:6.0.0"
element-internals-polyfill: "npm:3.0.2"
eslint: "npm:9.39.3"
eslint: "npm:9.39.2"
eslint-config-airbnb-base: "npm:15.0.0"
eslint-config-prettier: "npm:10.1.8"
eslint-import-resolver-webpack: "npm:0.13.10"
eslint-plugin-import: "npm:2.32.0"
eslint-plugin-lit: "npm:2.2.1"
eslint-plugin-lit: "npm:2.1.1"
eslint-plugin-lit-a11y: "npm:5.1.1"
eslint-plugin-unused-imports: "npm:4.4.1"
eslint-plugin-unused-imports: "npm:4.3.0"
eslint-plugin-wc: "npm:3.0.2"
fancy-log: "npm:2.0.0"
fs-extra: "npm:11.3.3"
fuse.js: "npm:7.1.0"
glob: "npm:13.0.6"
glob: "npm:13.0.1"
google-timezones-json: "npm:1.2.0"
gulp: "npm:5.0.1"
gulp-brotli: "npm:3.0.0"
@@ -9296,7 +9285,7 @@ __metadata:
lodash.template: "npm:4.5.0"
luxon: "npm:3.7.2"
map-stream: "npm:0.0.7"
marked: "npm:17.0.3"
marked: "npm:17.0.1"
memoize-one: "npm:6.0.0"
node-vibrant: "npm:4.0.4"
object-hash: "npm:3.0.0"
@@ -9416,13 +9405,12 @@ __metadata:
languageName: node
linkType: hard
"html-standard@npm:^0.0.13":
version: 0.0.13
resolution: "html-standard@npm:0.0.13"
"html-standard@npm:^0.0.11":
version: 0.0.11
resolution: "html-standard@npm:0.0.11"
dependencies:
vscode-css-languageservice: "npm:^6.3.9"
vscode-languageserver-textdocument: "npm:^1.0.12"
checksum: 10/a069a58cae3367c97ed82bd31bf166a6c93d746ae406df1428832a4187f93ff1b1b06317ffe178b6bc6b79a705c175ce977c7ba2471f5fb26a1c1b65baac5de5
checksum: 10/724cf7e7234edfda8bad776f2deaf430fa986fdb97fd41d491229aa1e5a1f01dbf6603efb1651b997487fc2c67a788bd6346266830ac4d860938dc3dc11e99b4
languageName: node
linkType: hard
@@ -10891,12 +10879,12 @@ __metadata:
languageName: node
linkType: hard
"marked@npm:17.0.3":
version: 17.0.3
resolution: "marked@npm:17.0.3"
"marked@npm:17.0.1":
version: 17.0.1
resolution: "marked@npm:17.0.1"
bin:
marked: bin/marked.js
checksum: 10/2e3bce6bb012b0d39fba75689b4dea672156d372ae8f089de99e73932bf71f0bb3a5bffa43e58474d6476950b462b7580cb181d68134df3d222b12d190bfa4b2
checksum: 10/8d39cf5a1eb97288d09d47dba57a613b8c45a212287b27aa16b4bc41baaebb147340c359d379da1bc6d351474d169aa3a63f981770b67f7663ed0a479dd950c2
languageName: node
linkType: hard
@@ -11081,12 +11069,12 @@ __metadata:
languageName: node
linkType: hard
"minimatch@npm:^10.2.2":
version: 10.2.2
resolution: "minimatch@npm:10.2.2"
"minimatch@npm:^10.1.2":
version: 10.1.2
resolution: "minimatch@npm:10.1.2"
dependencies:
brace-expansion: "npm:^5.0.2"
checksum: 10/e135be7b502ac97c02bcee42ccc1c55dc26dbac036c0f4acde69e42fe339d7fb53fae711e57b3546cb533426382ea492c73a073c7f78832e0453d120d48dd015
"@isaacs/brace-expansion": "npm:^5.0.1"
checksum: 10/6f0ef975463739207144e411bdd54f7205ce38770b162fa3bc4c9be4987a16cb20d0962a82f26c2372598cfba90faa97b327239d303b529b774f17681c163b46
languageName: node
linkType: hard
@@ -11182,10 +11170,10 @@ __metadata:
languageName: node
linkType: hard
"minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2, minipass@npm:^7.1.3":
version: 7.1.3
resolution: "minipass@npm:7.1.3"
checksum: 10/175e4d5e20980c3cd316ae82d2c031c42f6c746467d8b1905b51060a0ba4461441a0c25bb67c025fd9617f9a3873e152c7b543c6b5ac83a1846be8ade80dffd6
"minipass@npm:^7.0.2, minipass@npm:^7.0.3, minipass@npm:^7.0.4, minipass@npm:^7.1.2":
version: 7.1.2
resolution: "minipass@npm:7.1.2"
checksum: 10/c25f0ee8196d8e6036661104bacd743785b2599a21de5c516b32b3fa2b83113ac89a2358465bc04956baab37ffb956ae43be679b2262bf7be15fce467ccd7950
languageName: node
linkType: hard
@@ -11861,13 +11849,13 @@ __metadata:
languageName: node
linkType: hard
"path-scurry@npm:^2.0.2":
version: 2.0.2
resolution: "path-scurry@npm:2.0.2"
"path-scurry@npm:^2.0.0":
version: 2.0.1
resolution: "path-scurry@npm:2.0.1"
dependencies:
lru-cache: "npm:^11.0.0"
minipass: "npm:^7.1.2"
checksum: 10/2b4257422bcb870a4c2d205b3acdbb213a72f5e2250f61c80f79c9d014d010f82bdf8584441612c8e1fa4eb098678f5704a66fa8377d72646bad4be38e57a2c3
checksum: 10/1e9c74e9ccf94d7c16056a5cb2dba9fa23eec1bc221ab15c44765486b9b9975b4cd9a4d55da15b96eadf67d5202e9a2f1cec9023fbb35fe7d9ccd0ff1891f88b
languageName: node
linkType: hard