Compare commits

..

47 Commits

Author SHA1 Message Date
Aidan Timson
ef85b769c8 Format 2025-12-16 16:23:47 +00:00
Aidan Timson
83a9b56466 Allow images and reuse start slot instead of original fallback slot 2025-12-16 16:22:17 +00:00
Aidan Timson
0476f759fe Remove duplicate 2025-12-16 15:27:27 +00:00
Aidan Timson
3248eb87a6 Refactor pickers 2025-12-16 15:24:16 +00:00
Aidan Timson
88faedba65 Migrate ha-icon-picker to generic picker (#27677) 2025-12-16 15:14:44 +00:00
Aidan Timson
b125cd5f3e Migrate hui-dialog-select-dashboard to ha-wa-dialog (#28456) 2025-12-16 13:47:50 +01:00
Aidan Timson
3425837de3 Switch energy now chart to watts, format values to W, kW etc (#28555)
* Switch energy now chart to watts

* Add kW

* Scale formatted value based on powers of 1000

1000 W -> 1 kW
W → kW → MW → GW → TW

* Explainers

* Use 3 dp for kW+ and 0 for W

* Add non-integer test
2025-12-16 14:33:45 +02:00
Paul Bottein
24a797e46a Improve shadow and border for energy date picker (#28566)
* Improve shadow and border for energy date picker

* Use variable

* Fix z-index

* Fix margin

* Improve mobile margin
2025-12-16 14:05:47 +02:00
Copilot
4a486ff28b Add fallback for empty cards in sections view (#28565)
* Initial plan

* Add edit-mode class to container in hui-grid-section to enable minimum card height

Co-authored-by: piitaya <5878303+piitaya@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: piitaya <5878303+piitaya@users.noreply.github.com>
2025-12-16 10:17:28 +00:00
Clément Notin
6be390a07e Add ignoreLocation option to fuseMultiTerm config (#28557) 2025-12-16 09:44:33 +01:00
Wendelin
c93942919b Automation editor show targets within rows (#28510)
* Automation editor show targets within rows

* review

* Fix expandable row icons

* Use state icon instead of state-badge

* Fix target wrap

* Use default font weight for automation rows

* Remove comma from targets in row
2025-12-16 09:42:38 +02:00
ildar170975
ca06269a91 Entity card: remove whitespaces in span (#28562) 2025-12-16 08:18:13 +01:00
ildar170975
b3d7c0b6dc Helpers table: show dashes in "Area" column (#28563) 2025-12-16 08:16:15 +01:00
Paul Bottein
e709703f79 Wait for translations before showing home and domain dashboards (#28556) 2025-12-16 08:11:15 +01:00
renovate[bot]
fd42614a23 Update dependency eslint to v9.39.2 (#28560)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-16 08:41:38 +02:00
Petar Petrov
4050dd0384 Fixed period selector for energy dashboard (#28458)
* Sticky period selector for energy dashboard

* Blur view when date picker is open

* move to hui-root via slot

* fix scrollbar

* Use dialog backdrop and define default

* Set energy selector position to fixed

* format

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2025-12-15 18:21:46 +01:00
Petar Petrov
9996b1bfea Fix inability to leave scene creation page without saving (#28546) 2025-12-15 16:59:57 +01:00
Petar Petrov
8092340d2a Fix min max issues in statistics chart (#28493) 2025-12-15 15:54:47 +01:00
Petar Petrov
443e205395 Fix power sources graph ordering with multiple sources (#28549) 2025-12-15 15:52:59 +01:00
Wendelin
8c5d0c4b80 Use ha-state-icon for add from target entity icon (#28551) 2025-12-15 15:51:17 +01:00
Paul Bottein
3005f12ef5 Restore navigation header for home, light, security and climate dashboards (#28552) 2025-12-15 15:44:51 +01:00
Petar Petrov
3bcf530200 Storage space breakdown chart (#28311)
Co-authored-by: Aidan Timson <aidan@timmo.dev>
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2025-12-15 15:10:35 +01:00
ildar170975
af2fa98666 ha-map: add a variable for marker size (#28536)
* use a new variable for width & height

* use a new variable for width & height

* fix styles & creation of marker

* iconSize -> markerIconSize (for zones)

* Apply suggestion from @MindFreeze

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* format

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-12-15 13:01:27 +00:00
renovate[bot]
8f335668db Update Node.js to v24 (#27687)
* Update Node.js to v24

* fix test

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-12-15 14:56:48 +02:00
Aidan Timson
51acd271ea Add duplicate voice assistant action (#28511) 2025-12-15 13:12:57 +01:00
dependabot[bot]
830e9b5089 Bump dessant/lock-threads from 5.0.1 to 6.0.0 (#28545)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-15 11:12:59 +00:00
dependabot[bot]
b13cb84baf Bump actions/upload-artifact from 5.0.0 to 6.0.0 (#28542)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-15 11:12:33 +00:00
renovate[bot]
f8df7f37ea Update dependency @codemirror/view to v6.39.4 (#28548)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-15 12:32:35 +02:00
Aidan Timson
54e4f4e60a Attempt to ensure view transitions are always ran (#28547) 2025-12-15 11:24:50 +01:00
ildar170975
81cb483163 Generic picker: show a label for area, category, language (#28236)
Co-authored-by: Aidan Timson <aidan@timmo.dev>
2025-12-15 08:54:12 +00:00
dependabot[bot]
eb5b14ea00 Bump actions/cache from 4.3.0 to 5.0.1 (#28543)
Bumps [actions/cache](https://github.com/actions/cache) from 4.3.0 to 5.0.1.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](0057852bfa...9255dc7a25)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: 5.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-15 08:20:03 +02:00
renovate[bot]
b221fbb387 Update dependency @rsdoctor/rspack-plugin to v1.3.15 (#28541)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-15 06:11:43 +00:00
dependabot[bot]
3033bfb1fb Bump github/codeql-action from 4.31.7 to 4.31.8 (#28544)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.31.7 to 4.31.8.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](cf1bb45a27...1b168cd394)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.31.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-15 08:11:38 +02:00
karwosts
8db967eb2f Fix navigate action after confirm (#28535)
Fix navigate on confirm
2025-12-15 08:11:20 +02:00
renovate[bot]
10af86cc02 Update dependency terser-webpack-plugin to v5.3.16 (#28537)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-15 08:03:40 +02:00
renovate[bot]
dfc7116819 Update dependency @codemirror/view to v6.39.3 (#28533)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-14 15:56:09 +01:00
ildar170975
685c642bfc hui-map-card: add firstUpdated() with _getMapEntities() (#28526)
add firstUpdated() with _getMapEntities()
2025-12-13 17:15:57 +02:00
renovate[bot]
2e547937b8 Update dependency @rsdoctor/rspack-plugin to v1.3.13 (#28532)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-13 16:32:52 +02:00
Michael Bisbjerg
e3e01c327a Fix trace download truncation with Jinja comments (#28519) 2025-12-13 09:50:42 +02:00
renovate[bot]
abd706fed0 Update dependency @codemirror/view to v6.39.2 (#28522)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-13 07:59:22 +01:00
renovate[bot]
3bd45dd29b Update dependency ua-parser-js to v2.0.7 (#28516)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-12 15:34:04 +01:00
renovate[bot]
206f067d2b Update dependency @rspack/core to v1.6.7 (#28518)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-12 15:31:53 +01:00
renovate[bot]
caefa7530a Update dependency @codemirror/view to v6.39.1 (#28520)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-12 15:31:26 +01:00
Aidan Timson
de142e33a1 Use entity picker for heading card entities editor (#28463)
* Use entity picker for heading card entities editor

* Use space tokens

* Pass index

* Lint

* Set undefined

* Iterate

* Spread

* Fixes

* Fixes
2025-12-12 09:30:07 +02:00
karwosts
5d4c3ebfcd Home strategy: don't link non-admin to config pages (#28512) 2025-12-12 07:23:37 +01:00
renovate[bot]
eb910c5ac5 Update dependency typescript-eslint to v8.49.0 (#28515)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-12 07:23:10 +01:00
renovate[bot]
72726a2e0f Update dependency @codemirror/view to v6.39.0 (#28514)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-12 07:22:53 +01:00
87 changed files with 2446 additions and 1310 deletions

View File

@@ -37,7 +37,7 @@ jobs:
- name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
- name: Setup lint cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
with:
path: |
node_modules/.cache/prettier
@@ -89,7 +89,7 @@ jobs:
env:
IS_TEST: "true"
- name: Upload bundle stats
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: frontend-bundle-stats
path: build/stats/*.json
@@ -113,7 +113,7 @@ jobs:
env:
IS_TEST: "true"
- name: Upload bundle stats
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: supervisor-bundle-stats
path: build/stats/*.json

View File

@@ -36,14 +36,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
uses: github/codeql-action/init@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
uses: github/codeql-action/autobuild@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -57,4 +57,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
uses: github/codeql-action/analyze@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8

View File

@@ -9,7 +9,7 @@ jobs:
lock:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
with:
github-token: ${{ github.token }}
process-only: "issues, prs"

View File

@@ -57,14 +57,14 @@ jobs:
run: tar -czvf translations.tar.gz translations
- name: Upload build artifacts
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: wheels
path: dist/home_assistant_frontend*.whl
if-no-files-found: error
- name: Upload translations
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: translations
path: translations.tar.gz

2
.nvmrc
View File

@@ -1 +1 @@
22.21.1
24.12.0

View File

@@ -142,7 +142,7 @@ export class DemoAutomationDescribeAction extends LitElement {
<div class="action">
<span>
${this._action
? describeAction(this.hass, [], [], {}, this._action)
? describeAction(this.hass, [], this._action)
: "<invalid YAML>"}
</span>
<ha-yaml-editor
@@ -155,7 +155,7 @@ export class DemoAutomationDescribeAction extends LitElement {
${ACTIONS.map(
(conf) => html`
<div class="action">
<span>${describeAction(this.hass, [], [], {}, conf as any)}</span>
<span>${describeAction(this.hass, [], conf as any)}</span>
<pre>${dump(conf)}</pre>
</div>
`

View File

@@ -34,7 +34,7 @@
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.5.11",
"@codemirror/state": "6.5.2",
"@codemirror/view": "6.38.8",
"@codemirror/view": "6.39.4",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.18.2",
@@ -135,7 +135,7 @@
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"tinykeys": "3.0.0",
"ua-parser-js": "2.0.6",
"ua-parser-js": "2.0.7",
"vue": "2.7.16",
"vue2-daterange-picker": "0.6.8",
"weekstart": "2.0.0",
@@ -157,8 +157,8 @@
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.0.3",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.3.12",
"@rspack/core": "1.6.6",
"@rsdoctor/rspack-plugin": "1.3.15",
"@rspack/core": "1.6.7",
"@rspack/dev-server": "1.1.4",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.22",
@@ -183,7 +183,7 @@
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.1",
"eslint": "9.39.1",
"eslint": "9.39.2",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.10",
@@ -214,10 +214,10 @@
"serve": "14.2.5",
"sinon": "21.0.0",
"tar": "7.5.2",
"terser-webpack-plugin": "5.3.15",
"terser-webpack-plugin": "5.3.16",
"ts-lit-plugin": "2.0.2",
"typescript": "5.9.3",
"typescript-eslint": "8.48.1",
"typescript-eslint": "8.49.0",
"vite-tsconfig-paths": "5.1.4",
"vitest": "4.0.15",
"webpack-stats-plugin": "1.1.3",
@@ -238,6 +238,6 @@
},
"packageManager": "yarn@4.12.0",
"volta": {
"node": "22.21.1"
"node": "24.12.0"
}
}

View File

@@ -17,25 +17,36 @@ export interface NavigateOptions {
// max time to wait for dialogs to close before navigating
const DIALOG_WAIT_TIMEOUT = 500;
export const navigate = async (
path: string,
options?: NavigateOptions,
timestamp = Date.now()
) => {
/**
* Ensures all dialogs are closed before navigation.
* Returns true if navigation can proceed, false if a dialog refused to close.
*/
const ensureDialogsClosed = async (timestamp: number): Promise<boolean> => {
const { history } = mainWindow;
if (history.state?.dialog && Date.now() - timestamp < DIALOG_WAIT_TIMEOUT) {
const closed = await closeAllDialogs();
if (!closed) {
// eslint-disable-next-line no-console
console.warn("Navigation blocked, because dialog refused to close");
return false;
}
return new Promise<boolean>((resolve) => {
// need to wait for history state to be updated in case a dialog was closed
setTimeout(() => {
navigate(path, options, timestamp).then(resolve);
});
});
if (!history.state?.dialog || Date.now() - timestamp >= DIALOG_WAIT_TIMEOUT) {
return true;
}
const closed = await closeAllDialogs();
if (!closed) {
// eslint-disable-next-line no-console
console.warn("Navigation blocked, because dialog refused to close");
return false;
}
// wait for history state to be updated after dialog closed
await new Promise<void>((resolve) => {
setTimeout(resolve);
});
return ensureDialogsClosed(timestamp);
};
export const navigate = async (path: string, options?: NavigateOptions) => {
const canProceed = await ensureDialogsClosed(Date.now());
if (!canProceed) {
return false;
}
const replace = options?.replace || false;
@@ -68,10 +79,14 @@ export const navigate = async (
* Navigate back in history, with fallback to a default path if no history exists.
* This prevents a user from getting stuck when they navigate directly to a page with no history.
*/
export const goBack = (fallbackPath?: string) => {
const { history } = mainWindow;
export const goBack = async (fallbackPath?: string): Promise<void> => {
const canProceed = await ensureDialogsClosed(Date.now());
if (!canProceed) {
return;
}
// Check if we have history to go back to
const { history } = mainWindow;
if (history.length > 1) {
history.back();
return;

View File

@@ -1,30 +1,45 @@
/**
* Executes a callback within a View Transition if supported, otherwise runs it directly.
* Executes a synchronous callback within a View Transition if supported, otherwise runs it directly.
*
* @param callback - Function to execute. Can be synchronous or return a Promise. The callback will be passed a boolean indicating whether the view transition is available.
* @param callback - Synchronous function to execute. The callback will be passed a boolean indicating whether the view transition is available.
* @returns Promise that resolves when the transition completes (or immediately if not supported)
*
* @example
* ```typescript
* // Synchronous callback
* withViewTransition(() => {
* this.large = !this.large;
* });
*
* // Async callback
* await withViewTransition(async () => {
* await this.updateData();
* });
* ```
*/
export const withViewTransition = (
callback: (viewTransitionAvailable: boolean) => void | Promise<void>
callback: (viewTransitionAvailable: boolean) => void
): Promise<void> => {
if (document.startViewTransition) {
return document.startViewTransition(() => callback(true)).finished;
if (!document.startViewTransition) {
callback(false);
return Promise.resolve();
}
// Fallback: Execute callback directly without transition
const result = callback(false);
return result instanceof Promise ? result : Promise.resolve();
let callbackInvoked = false;
try {
// View Transitions require DOM updates to happen synchronously within
// the callback. Execute the callback immediately (synchronously).
const transition = document.startViewTransition(() => {
callbackInvoked = true;
callback(true);
});
return transition.finished;
} catch (err) {
// eslint-disable-next-line no-console
console.warn(
"View transition failed, falling back to direct execution.",
err
);
// Make sure the callback is invoked exactly once.
if (!callbackInvoked) {
callback(false);
return Promise.resolve();
}
return Promise.reject(err);
}
};

View File

@@ -0,0 +1,207 @@
import type { EChartsType } from "echarts/core";
import type { SunburstSeriesOption } from "echarts/types/dist/echarts";
import type { CallbackDataParams } from "echarts/types/src/util/types";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { getGraphColorByIndex } from "../../common/color/colors";
import { filterXSS } from "../../common/util/xss";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import "./ha-chart-base";
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
let SunburstChart: typeof import("echarts/lib/chart/sunburst/install");
export interface SunburstNode {
id: string;
name?: string;
value: number;
itemStyle?: {
color?: string;
};
children?: SunburstNode[];
}
@customElement("ha-sunburst-chart")
export class HaSunburstChart extends LitElement {
public hass!: HomeAssistant;
@property({ attribute: false }) public data?: SunburstNode;
@property({ type: String, attribute: false }) public valueFormatter?: (
value: number
) => string;
public chart?: EChartsType;
constructor() {
super();
if (!SunburstChart) {
import("echarts/lib/chart/sunburst/install").then((module) => {
SunburstChart = module;
this.requestUpdate();
});
}
}
render() {
if (!SunburstChart || !this.data) {
return nothing;
}
const options = {
tooltip: {
trigger: "item",
formatter: this._renderTooltip,
appendTo: document.body,
},
} as ECOption;
return html`<ha-chart-base
.data=${this._createData(this.data)}
.options=${options}
height="100%"
.extraComponents=${[SunburstChart]}
></ha-chart-base>`;
}
private _renderTooltip = (params: CallbackDataParams) => {
const data = params.data as { name: string; value: number };
const value = this.valueFormatter
? this.valueFormatter(data.value)
: data.value;
return `${params.marker} ${filterXSS(data.name)}<br>${value}`;
};
private _createData = memoizeOne(
(data: SunburstNode): SunburstSeriesOption => {
const computedStyles = getComputedStyle(this);
// Transform to echarts format (uses 'name' instead of 'id')
const transformNode = (
node: SunburstNode,
index: number,
depth: number,
parentColor?: string
) => {
const result = {
...node,
name: node.name || node.id,
};
if (depth > 0 && !node.itemStyle?.color) {
// Don't assign color to root node
result.itemStyle = {
color: parentColor ?? getGraphColorByIndex(index, computedStyles),
};
}
if (node.children && node.children.length > 0) {
result.children = node.children.map((child, i) =>
transformNode(child, i, depth + 1, result.itemStyle?.color)
);
}
return result;
};
const transformedData = transformNode(data, 0, 0);
return {
type: "sunburst",
data: transformedData.children || [transformedData],
radius: [0, "90%"],
sort: undefined, // Keep original order
label: {
show: false,
align: "center",
rotate: "radial",
minAngle: 15,
hideOverlap: true,
},
emphasis: {
focus: "ancestor",
label: {
show: false,
},
},
itemStyle: {
borderRadius: 2,
},
levels: this._generateLevels(this._getMaxDepth(data)),
} as SunburstSeriesOption;
}
);
private _getMaxDepth(node: SunburstNode, currentDepth = 0): number {
if (!node.children || node.children.length === 0) {
return currentDepth;
}
return Math.max(
...node.children.map((child) =>
this._getMaxDepth(child, currentDepth + 1)
)
);
}
private _generateLevels(depth: number): SunburstSeriesOption["levels"] {
const levels: SunburstSeriesOption["levels"] = [];
// Root level (center) - transparent, small fixed size
const rootRadius = 15;
const outerRadius = 95;
const availableRadius = outerRadius - rootRadius;
levels.push({
r0: "0%",
r: `${rootRadius}%`,
itemStyle: {
color: "transparent",
},
});
if (depth === 0) {
return levels;
}
// Distribute remaining radius among data levels using weighted distribution
// First level gets most space, each subsequent level gets progressively smaller
const weights = Array.from({ length: depth }, (_, i) => depth - i);
const totalWeight = weights.reduce((sum, w) => sum + w, 0);
let currentRadius = rootRadius;
for (let i = 0; i < depth; i++) {
const levelRadius = (weights[i] / totalWeight) * availableRadius;
const r0 = currentRadius;
const r = currentRadius + levelRadius;
currentRadius = r;
levels.push({
r0: `${r0}%`,
r: `${r}%`,
// Show labels only on first level
...(i === 0 ? { label: { show: true } } : {}),
});
}
return levels;
}
static styles = css`
:host {
display: block;
flex: 1;
}
ha-chart-base {
width: 100%;
height: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-sunburst-chart": HaSunburstChart;
}
}

View File

@@ -184,17 +184,11 @@ export class StatisticsChart extends LitElement {
}
private _datasetHidden(ev: CustomEvent) {
if (!this._legendData) {
return;
}
this._hiddenStats.add(ev.detail.id);
this.requestUpdate("_hiddenStats");
}
private _datasetUnhidden(ev: CustomEvent) {
if (!this._legendData) {
return;
}
this._hiddenStats.delete(ev.detail.id);
this.requestUpdate("_hiddenStats");
}
@@ -521,7 +515,9 @@ export class StatisticsChart extends LitElement {
`ui.components.statistics_charts.statistic_types.${type}`
),
symbol: "none",
sampling: "minmax",
// minmax sampling operates independently per series, breaking stacking alignment
// https://github.com/apache/echarts/issues/11879
sampling: band && drawBands ? "lttb" : "minmax",
animationDurationUpdate: 0,
lineStyle: {
width: 1.5,
@@ -539,10 +535,17 @@ export class StatisticsChart extends LitElement {
if (band && this.chartType === "line") {
series.stack = `band-${statistic_id}`;
series.stackStrategy = "all";
if (drawBands && type === bandTop) {
(series as LineSeriesOption).areaStyle = {
color: color + "3F",
};
if (this._hiddenStats.has(`${statistic_id}-${bandBottom}`)) {
// changing the stackOrder forces echarts to render the stacked series that are not hidden #28472
series.stackOrder = "seriesDesc";
(series as LineSeriesOption).areaStyle = undefined;
} else {
series.stackOrder = "seriesAsc";
if (drawBands && type === bandTop) {
(series as LineSeriesOption).areaStyle = {
color: color + "3F",
};
}
}
}
if (!this.hideLegend) {
@@ -586,7 +589,8 @@ export class StatisticsChart extends LitElement {
} else if (
type === bandTop &&
this.chartType === "line" &&
drawBands
drawBands &&
!this._hiddenStats.has(`${statistic_id}-${bandBottom}`)
) {
const top = stat[bandTop] || 0;
val.push(Math.abs(top - (stat[bandBottom] || 0)));

View File

@@ -101,6 +101,10 @@ const Component = Vue.extend({
type: String,
default: "en",
},
opensVertical: {
type: String,
default: undefined,
},
},
render(createElement) {
// @ts-expect-error
@@ -129,6 +133,11 @@ const Component = Vue.extend({
},
expression: "dateRange",
},
on: {
toggle: (open: boolean) => {
fireEvent(this.$el as HTMLElement, "toggle", { open });
},
},
scopedSlots: {
input() {
return createElement("slot", {
@@ -309,6 +318,10 @@ class DateRangePickerElement extends WrappedElement {
min-width: unset !important;
display: block !important;
}
:host([opens-vertical="up"]) .daterangepicker {
bottom: 100%;
top: auto !important;
}
`;
if (mainWindow.document.dir === "rtl") {
style.innerHTML += `
@@ -340,4 +353,7 @@ declare global {
interface HTMLElementTagNameMap {
"date-range-picker": DateRangePickerElement;
}
interface HASSDomEvents {
toggle: { open: boolean };
}
}

View File

@@ -365,14 +365,27 @@ export class HaAreaPicker extends LitElement {
protected render(): TemplateResult {
const placeholder =
this.placeholder ?? this.hass.localize("ui.components.area-picker.area");
const baseLabel =
this.label ?? this.hass.localize("ui.components.area-picker.area");
const valueRenderer = this._computeValueRenderer(this.hass.areas);
// Only show label if there's no floor
let label: string | undefined = baseLabel;
if (this.value && baseLabel) {
const area = this.hass.areas[this.value];
if (area) {
const { floor } = getAreaContext(area, this.hass.floors);
if (floor) {
label = undefined;
}
}
}
return html`
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.label}
.label=${label}
.helper=${this.helper}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize("ui.components.area-picker.no_areas")}

View File

@@ -52,7 +52,9 @@ export class HaAutomationRow extends LitElement {
<slot name="leading-icon"></slot>
</div>
<slot class="header" name="header"></slot>
<slot name="icons"></slot>
<div class="icons">
<slot name="icons"></slot>
</div>
</div>
`;
}
@@ -118,12 +120,11 @@ export class HaAutomationRow extends LitElement {
}
.row {
display: flex;
padding: var(--ha-space-0) var(--ha-space-2);
padding: 0 var(--ha-space-3);
min-height: 48px;
align-items: center;
align-items: flex-start;
cursor: pointer;
overflow: hidden;
font-weight: var(--ha-font-weight-medium);
outline: none;
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
}
@@ -140,11 +141,15 @@ export class HaAutomationRow extends LitElement {
background-color: var(--ha-color-fill-neutral-loud-resting);
border-radius: var(--ha-border-radius-md);
padding: var(--ha-space-1);
margin-top: 10px;
display: flex;
justify-content: center;
align-items: center;
transform: rotate(45deg);
}
.leading-icon-wrapper {
padding-top: var(--ha-space-3);
}
::slotted([slot="leading-icon"]) {
color: var(--ha-color-on-neutral-quiet);
}
@@ -172,6 +177,10 @@ export class HaAutomationRow extends LitElement {
overflow-wrap: anywhere;
margin: var(--ha-space-0) var(--ha-space-3);
}
.icons {
display: flex;
align-items: center;
}
:host([sort-selected]) .row {
outline: solid;
outline-color: rgba(var(--rgb-accent-color), 0.6);

View File

@@ -20,6 +20,17 @@ export class HaComboBoxItem extends HaMdListItem {
[slot="start"] {
--state-icon-color: var(--secondary-text-color);
}
[slot="overline"] {
/* mimicing a floating label of mdc-select */
line-height: 1.15rem;
font-size: calc(var(--mdc-typography-subtitle1-font-size, 1rem) * 0.75);
font-weight: var(--mdc-typography-subtitle1-font-weight, 400);
font-family: var(
--mdc-typography-subtitle1-font-family,
var(--mdc-typography-font-family)
);
color: var(--mdc-select-label-ink-color, rgba(0, 0, 0, 0.6));
}
[slot="headline"] {
line-height: var(--ha-line-height-normal);
font-size: var(--ha-font-size-m);

View File

@@ -74,6 +74,9 @@ export class HaDateRangePicker extends LitElement {
@property({ attribute: "extended-presets", type: Boolean })
public extendedPresets = false;
@property({ attribute: "vertical-opening-direction" })
public verticalOpeningDirection?: "up" | "down";
@property({ attribute: false }) public openingDirection?:
| "right"
| "left"
@@ -127,6 +130,7 @@ export class HaDateRangePicker extends LitElement {
opening-direction=${ifDefined(
this.openingDirection || this._calcedOpeningDirection
)}
opens-vertical=${ifDefined(this.verticalOpeningDirection)}
first-day=${firstWeekdayIndex(this.hass.locale)}
language=${this.hass.locale.language}
@change=${this._handleChange}

View File

@@ -47,6 +47,7 @@ export class HaDomainIcon extends LitElement {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});

View File

@@ -7,6 +7,7 @@ import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import { PickerMixin } from "../mixins/picker-mixin";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
import "./ha-bottom-sheet";
@@ -20,34 +21,18 @@ import type {
PickerComboBoxSearchFn,
} from "./ha-picker-combo-box";
import "./ha-picker-field";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon";
@customElement("ha-generic-picker")
export class HaGenericPicker extends LitElement {
export class HaGenericPicker extends PickerMixin(LitElement) {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property({ type: Boolean, attribute: "allow-custom-value" })
public allowCustomValue;
@property() public label?: string;
@property() public value?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: String, attribute: "search-label" })
public searchLabel?: string;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
/** To prevent lags, getItems needs to be memoized */
@property({ attribute: false })
public getItems!: (
@@ -61,9 +46,6 @@ export class HaGenericPicker extends LitElement {
@property({ attribute: false })
public rowRenderer?: RenderItemFunction<PickerComboBoxItem>;
@property({ attribute: false })
public valueRenderer?: PickerValueRenderer;
@property({ attribute: false })
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
@@ -113,8 +95,6 @@ export class HaGenericPicker extends LitElement {
@property({ attribute: "selected-section" }) public selectedSection?: string;
@property({ attribute: "unknown-item-text" }) public unknownItemText?: string;
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@@ -137,6 +117,10 @@ export class HaGenericPicker extends LitElement {
// helper to set new value after closing picker, to avoid flicker
private _newValue?: string;
@property({ attribute: "error-message" }) public errorMessage?: string;
@property({ type: Boolean, reflect: true }) public invalid = false;
private _unsubscribeTinyKeys?: () => void;
protected render() {
@@ -169,13 +153,18 @@ export class HaGenericPicker extends LitElement {
aria-label=${ifDefined(this.label)}
@click=${this.open}
@clear=${this._clear}
.icon=${this.icon}
.image=${this.image}
.label=${this.label}
.placeholder=${this.placeholder}
.value=${this.value}
.valueRenderer=${this.valueRenderer}
.required=${this.required}
.disabled=${this.disabled}
.invalid=${this.invalid}
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${this.valueRenderer}
>
<slot name="start"></slot>
</ha-picker-field>`}
</slot>
</div>
@@ -257,11 +246,16 @@ export class HaGenericPicker extends LitElement {
);
private _renderHelper() {
return this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: nothing;
const showError = this.invalid && this.errorMessage;
const showHelper = !showError && this.helper;
if (!showError && !showHelper) {
return nothing;
}
return html`<ha-input-helper-text .disabled=${this.disabled}>
${showError ? this.errorMessage : this.helper}
</ha-input-helper-text>`;
}
private _dialogOpened = () => {
@@ -360,6 +354,9 @@ export class HaGenericPicker extends LitElement {
display: block;
margin: var(--ha-space-2) 0 0;
}
:host([invalid]) ha-input-helper-text {
color: var(--mdc-theme-error, var(--error-color, #b00020));
}
wa-popover {
--wa-space-l: var(--ha-space-0);
@@ -382,12 +379,6 @@ export class HaGenericPicker extends LitElement {
}
}
@media (max-height: 1000px) {
wa-popover::part(body) {
max-height: 400px;
}
}
ha-bottom-sheet {
--ha-bottom-sheet-height: 90vh;
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));

View File

@@ -1,8 +1,4 @@
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type {
ComboBoxDataProviderCallback,
ComboBoxDataProviderParams,
} from "@vaadin/combo-box/vaadin-combo-box-light";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
@@ -10,35 +6,54 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { customIcons } from "../data/custom_icons";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-combo-box";
import "./ha-icon";
import "./ha-combo-box-item";
interface IconItem {
icon: string;
parts: Set<string>;
keywords: string[];
}
import "./ha-generic-picker";
import "./ha-icon";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
interface RankedIcon {
icon: string;
item: PickerComboBoxItem;
rank: number;
}
let ICONS: IconItem[] = [];
let ICONS: PickerComboBoxItem[] = [];
let ICONS_LOADED = false;
interface IconData {
name: string;
keywords?: string[];
}
const createIconItem = (icon: IconData, prefix: string): PickerComboBoxItem => {
const iconId = `${prefix}:${icon.name}`;
const iconName = icon.name;
const parts = iconName.split("-");
const keywords = icon.keywords ?? [];
const searchLabels: Record<string, string> = {
iconName,
};
parts.forEach((part, index) => {
searchLabels[`part${index}`] = part;
});
keywords.forEach((keyword, index) => {
searchLabels[`keyword${index}`] = keyword;
});
return {
id: iconId,
primary: iconId,
icon: iconId,
search_labels: searchLabels,
sorting_label: iconId,
};
};
const loadIcons = async () => {
ICONS_LOADED = true;
const iconList = await import("../../build/mdi/iconList.json");
ICONS = iconList.default.map((icon) => ({
icon: `mdi:${icon.name}`,
parts: new Set(icon.name.split("-")),
keywords: icon.keywords,
}));
ICONS = iconList.default.map((icon) => createIconItem(icon, "mdi"));
const customIconLoads: Promise<IconItem[]>[] = [];
const customIconLoads: Promise<PickerComboBoxItem[]>[] = [];
Object.keys(customIcons).forEach((iconSet) => {
customIconLoads.push(loadCustomIconItems(iconSet));
});
@@ -47,19 +62,16 @@ const loadIcons = async () => {
});
};
const loadCustomIconItems = async (iconsetPrefix: string) => {
const loadCustomIconItems = async (
iconsetPrefix: string
): Promise<PickerComboBoxItem[]> => {
try {
const getIconList = customIcons[iconsetPrefix].getIconList;
if (typeof getIconList !== "function") {
return [];
}
const iconList = await getIconList();
const customIconItems = iconList.map((icon) => ({
icon: `${iconsetPrefix}:${icon.name}`,
parts: new Set(icon.name.split("-")),
keywords: icon.keywords ?? [],
}));
return customIconItems;
return iconList.map((icon) => createIconItem(icon, iconsetPrefix));
} catch (_err) {
// eslint-disable-next-line no-console
console.warn(`Unable to load icon list for ${iconsetPrefix} iconset`);
@@ -67,10 +79,10 @@ const loadCustomIconItems = async (iconsetPrefix: string) => {
}
};
const rowRenderer: ComboBoxLitRenderer<IconItem | RankedIcon> = (item) => html`
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item type="button">
<ha-icon .icon=${item.icon} slot="start"></ha-icon>
${item.icon}
<ha-icon .icon=${item.id} slot="start"></ha-icon>
${item.id}
</ha-combo-box-item>
`;
@@ -94,85 +106,99 @@ export class HaIconPicker extends LitElement {
@property({ type: Boolean }) public invalid = false;
private _getIconPickerItems = (): PickerComboBoxItem[] => ICONS;
protected render(): TemplateResult {
return html`
<ha-combo-box
<ha-generic-picker
.hass=${this.hass}
item-value-path="icon"
item-label-path="icon"
.value=${this._value}
allow-custom-value
.dataProvider=${ICONS_LOADED ? this._iconProvider : undefined}
.label=${this.label}
.getItems=${this._getIconPickerItems}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
.placeholder=${this.placeholder}
.errorMessage=${this.errorMessage}
.invalid=${this.invalid}
.renderer=${rowRenderer}
icon
@opened-changed=${this._openedChanged}
.rowRenderer=${rowRenderer}
.icon=${this._icon}
.label=${this.label}
.value=${this._value}
.searchFn=${this._filterIcons}
.notFoundLabel=${this.hass?.localize(
"ui.components.icon-picker.no_match"
)}
popover-placement="bottom-start"
@value-changed=${this._valueChanged}
>
${this._value || this.placeholder
? html`
<ha-icon .icon=${this._value || this.placeholder} slot="icon">
</ha-icon>
`
: html`<slot slot="icon" name="fallback"></slot>`}
</ha-combo-box>
<slot name="start"></slot>
</ha-generic-picker>
`;
}
// Filter can take a significant chunk of frame (up to 3-5 ms)
private _filterIcons = memoizeOne(
(filter: string, iconItems: IconItem[] = ICONS) => {
if (!filter) {
(
filter: string,
filteredItems: PickerComboBoxItem[],
allItems: PickerComboBoxItem[]
): PickerComboBoxItem[] => {
const normalizedFilter = filter.toLowerCase().replace(/\s+/g, "-");
const iconItems = allItems?.length ? allItems : filteredItems;
if (!normalizedFilter.length) {
return iconItems;
}
const filteredItems: RankedIcon[] = [];
const addIcon = (icon: string, rank: number) =>
filteredItems.push({ icon, rank });
const rankedItems: RankedIcon[] = [];
// Filter and rank such that exact matches rank higher, and prefer icon name matches over keywords
for (const item of iconItems) {
if (item.parts.has(filter)) {
addIcon(item.icon, 1);
} else if (item.keywords.includes(filter)) {
addIcon(item.icon, 2);
} else if (item.icon.includes(filter)) {
addIcon(item.icon, 3);
} else if (item.keywords.some((word) => word.includes(filter))) {
addIcon(item.icon, 4);
const iconName = (item.id.split(":")[1] || item.id).toLowerCase();
const parts = iconName.split("-");
const keywords = item.search_labels
? Object.values(item.search_labels)
.filter((v): v is string => v !== null)
.map((v) => v.toLowerCase())
: [];
const id = item.id.toLowerCase();
if (parts.includes(normalizedFilter)) {
rankedItems.push({ item, rank: 1 });
} else if (keywords.includes(normalizedFilter)) {
rankedItems.push({ item, rank: 2 });
} else if (id.includes(normalizedFilter)) {
rankedItems.push({ item, rank: 3 });
} else if (keywords.some((word) => word.includes(normalizedFilter))) {
rankedItems.push({ item, rank: 4 });
}
}
// Allow preview for custom icon not in list
if (filteredItems.length === 0) {
addIcon(filter, 0);
if (rankedItems.length === 0) {
rankedItems.push({
item: {
id: filter,
primary: filter,
icon: filter,
search_labels: { keyword: filter },
sorting_label: filter,
},
rank: 0,
});
}
return filteredItems.sort((itemA, itemB) => itemA.rank - itemB.rank);
return rankedItems
.sort((itemA, itemB) => itemA.rank - itemB.rank)
.map((item) => item.item);
}
);
private _iconProvider = (
params: ComboBoxDataProviderParams,
callback: ComboBoxDataProviderCallback<IconItem | RankedIcon>
) => {
const filteredItems = this._filterIcons(params.filter.toLowerCase(), ICONS);
const iStart = params.page * params.pageSize;
const iEnd = iStart + params.pageSize;
callback(filteredItems.slice(iStart, iEnd), filteredItems.length);
};
private async _openedChanged(ev: ValueChangedEvent<boolean>) {
const opened = ev.detail.value;
if (opened && !ICONS_LOADED) {
await loadIcons();
this.requestUpdate();
protected firstUpdated() {
if (!ICONS_LOADED) {
loadIcons().then(() => {
this._getIconPickerItems = (): PickerComboBoxItem[] => ICONS;
this.requestUpdate();
});
}
}
@@ -194,20 +220,18 @@ export class HaIconPicker extends LitElement {
);
}
private get _icon() {
return this.value?.length ? this.value : this.placeholder;
}
private get _value() {
return this.value || "";
}
static styles = css`
*[slot="icon"] {
color: var(--primary-text-color);
position: relative;
bottom: 2px;
}
*[slot="prefix"] {
margin-right: 8px;
margin-inline-end: 8px;
margin-inline-start: initial;
ha-generic-picker {
width: 100%;
display: block;
}
`;
}

View File

@@ -116,6 +116,11 @@ export class HaLanguagePicker extends LitElement {
> `;
protected render() {
const label =
this.label ??
(this.hass?.localize("ui.components.language-picker.language") ||
"Language");
const value =
this.value ??
(this.required && !this.disabled ? this._getItems()[0].id : this.value);
@@ -129,9 +134,8 @@ export class HaLanguagePicker extends LitElement {
.emptyLabel=${this.hass?.localize(
"ui.components.language-picker.no_languages"
) || "No languages available"}
.placeholder=${this.label ??
(this.hass?.localize("ui.components.language-picker.language") ||
"Language")}
.placeholder=${label}
.label=${label}
.value=${value}
.valueRenderer=${this._valueRenderer}
.disabled=${this.disabled}

View File

@@ -10,11 +10,13 @@ import {
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { PickerMixin } from "../mixins/picker-mixin";
import { localizeContext } from "../data/context";
import type { HomeAssistant } from "../types";
import "./ha-combo-box-item";
import type { HaComboBoxItem } from "./ha-combo-box-item";
import "./ha-icon-button";
import "./ha-icon";
declare global {
interface HASSDomEvents {
@@ -25,26 +27,8 @@ declare global {
export type PickerValueRenderer = (value: string) => TemplateResult<1>;
@customElement("ha-picker-field")
export class HaPickerField extends LitElement {
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property() public value?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: Boolean, reflect: true }) public unknown = false;
@property({ attribute: "unknown-item-text" }) public unknownItemText?: string;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ attribute: false })
public valueRenderer?: PickerValueRenderer;
export class HaPickerField extends PickerMixin(LitElement) {
@property({ type: Boolean, reflect: true }) public invalid = false;
@query("ha-combo-box-item", true) public item!: HaComboBoxItem;
@@ -58,20 +42,42 @@ export class HaPickerField extends LitElement {
}
protected render() {
const hasValue = !!this.value?.length;
const showClearIcon =
!!this.value && !this.required && !this.disabled && !this.hideClearIcon;
const placeholderText = this.placeholder ?? this.label;
const overlineLabel =
this.label && hasValue
? html`<span slot="overline">${this.label}</span>`
: nothing;
const headlineContent = hasValue
? this.valueRenderer
? this.valueRenderer(this.value ?? "")
: html`<span slot="headline">${this.value}</span>`
: placeholderText
? html`<span slot="headline" class="placeholder">
${placeholderText}
</span>`
: nothing;
return html`
<ha-combo-box-item .disabled=${this.disabled} type="button" compact>
${this.value
? this.valueRenderer
? this.valueRenderer(this.value)
: html`<slot name="headline">${this.value}</slot>`
: html`
<span slot="headline" class="placeholder">
${this.placeholder}
</span>
`}
${this.image
? html`<img
alt=${this.label || ""}
slot="start"
.src=${this.image}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>`
: this.icon
? html`<ha-icon slot="start" .icon=${this.icon}></ha-icon>`
: html`<slot name="start"></slot>`}
${overlineLabel}${headlineContent}
${this.unknown
? html`<div slot="supporting-text" class="unknown">
${this.unknownItemText ||
@@ -163,6 +169,11 @@ export class HaPickerField extends LitElement {
background-color: var(--ha-color-fill-warning-quiet-resting);
}
:host([invalid]) ha-combo-box-item:after {
height: 2px;
background-color: var(--mdc-theme-error, var(--error-color, #b00020));
}
.clear {
margin: 0 -8px;
--mdc-icon-button-size: 32px;

View File

@@ -52,7 +52,7 @@ export class HaIconSelector extends LitElement {
${!placeholder && stateObj
? html`
<ha-state-icon
slot="fallback"
slot="start"
.hass=${this.hass}
.stateObj=${stateObj}
></ha-state-icon>

View File

@@ -68,8 +68,8 @@ class HaEntityMarker extends LitElement {
text-align: center;
align-items: center;
box-sizing: border-box;
width: 48px;
height: 48px;
width: var(--ha-marker-size, 48px);
height: var(--ha-marker-size, 48px);
font-size: var(--ha-marker-font-size, var(--ha-font-size-xl));
border-radius: var(--ha-marker-border-radius, 50%);
border: 1px solid var(--ha-marker-color, var(--primary-color));

View File

@@ -560,10 +560,11 @@ export class HaMap extends ReactiveElement {
radius,
});
const markerIconSize = this._getMarkerSize(computedStyles) / 2;
const marker = new DecoratedMarker([latitude, longitude], circle, {
icon: Leaflet.divIcon({
html: iconHTML,
iconSize: [24, 24],
iconSize: [markerIconSize, markerIconSize],
className,
}),
interactive: this.interactiveZones,
@@ -618,10 +619,11 @@ export class HaMap extends ReactiveElement {
}
// create marker with the icon
const markerSize = this._getMarkerSize(computedStyles);
const marker = new DecoratedMarker([latitude, longitude], undefined, {
icon: Leaflet.divIcon({
html: entityMarker,
iconSize: [48, 48],
iconSize: [markerSize, markerSize],
className: "",
}),
title: title,
@@ -657,6 +659,13 @@ export class HaMap extends ReactiveElement {
this._mapZones.forEach((marker) => map.addLayer(marker));
}
private _getMarkerSize(computedStyles: CSSStyleDeclaration): number {
const markerSizeVarValue =
computedStyles.getPropertyValue("--ha-marker-size");
const parsed = parseFloat(markerSizeVarValue);
return Number.isNaN(parsed) ? 48 : parsed;
}
private async _attachObserver(): Promise<void> {
if (!this._resizeObserver) {
this._resizeObserver = new ResizeObserver(() => {
@@ -739,14 +748,19 @@ export class HaMap extends ReactiveElement {
text-align: center;
}
ha-icon {
--mdc-icon-size: calc(var(--ha-marker-size, 48px) / 2);
}
.marker-cluster div {
background-clip: padding-box;
background-color: var(--primary-color);
border: 3px solid rgba(var(--rgb-primary-color), 0.2);
width: 32px;
height: 32px;
border-radius: var(--ha-border-radius-2xl);
width: calc(var(--ha-marker-size, 48px) * 0.667);
height: calc(var(--ha-marker-size, 48px) * 0.667);
border-radius: 50%;
text-align: center;
align-content: center;
color: var(--text-primary-color);
font-size: var(--ha-font-size-m);
}

View File

@@ -6,13 +6,8 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import { describeCondition, describeTrigger } from "../../data/automation_i18n";
import {
floorsContext,
fullEntitiesContext,
labelsContext,
} from "../../data/context";
import { fullEntitiesContext, labelsContext } from "../../data/context";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { FloorRegistryEntry } from "../../data/floor_registry";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
import type { LogbookEntry } from "../../data/logbook";
import { describeAction } from "../../data/script_i18n";
@@ -63,10 +58,6 @@ export class HaTracePathDetails extends LitElement {
@consume({ context: labelsContext, subscribe: true })
_labelReg!: LabelRegistryEntry[];
@state()
@consume({ context: floorsContext, subscribe: true })
_floorReg!: Record<string, FloorRegistryEntry>;
protected render(): TemplateResult {
return html`
<div class="padded-box trace-info">
@@ -193,8 +184,6 @@ export class HaTracePathDetails extends LitElement {
${describeAction(
this.hass,
this._entityReg,
this._labelReg,
this._floorReg,
currentDetail
)}
</h2>`

View File

@@ -15,14 +15,8 @@ import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_tim
import { relativeTime } from "../../common/datetime/relative_time";
import { fireEvent } from "../../common/dom/fire_event";
import { toggleAttribute } from "../../common/dom/toggle_attribute";
import {
floorsContext,
fullEntitiesContext,
labelsContext,
} from "../../data/context";
import { fullEntitiesContext } from "../../data/context";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { FloorRegistryEntry } from "../../data/floor_registry";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
import type { LogbookEntry } from "../../data/logbook";
import type {
ChooseAction,
@@ -197,8 +191,6 @@ class ActionRenderer {
constructor(
private hass: HomeAssistant,
private entityReg: EntityRegistryEntry[],
private labelReg: LabelRegistryEntry[],
private floorReg: Record<string, FloorRegistryEntry>,
private entries: TemplateResult[],
private trace: AutomationTraceExtended,
private logbookRenderer: LogbookRenderer,
@@ -313,14 +305,7 @@ class ActionRenderer {
this._renderEntry(
path,
describeAction(
this.hass,
this.entityReg,
this.labelReg,
this.floorReg,
data,
actionType
),
describeAction(this.hass, this.entityReg, data, actionType),
undefined,
data.enabled === false
);
@@ -485,13 +470,7 @@ class ActionRenderer {
const name =
repeatConfig.alias ||
describeAction(
this.hass,
this.entityReg,
this.labelReg,
this.floorReg,
repeatConfig
);
describeAction(this.hass, this.entityReg, repeatConfig);
this._renderEntry(repeatPath, name, undefined, disabled);
@@ -585,14 +564,7 @@ class ActionRenderer {
this._renderEntry(
sequencePath,
sequenceConfig.alias ||
describeAction(
this.hass,
this.entityReg,
this.labelReg,
this.floorReg,
sequenceConfig,
"sequence"
),
describeAction(this.hass, this.entityReg, sequenceConfig, "sequence"),
undefined,
sequenceConfig.enabled === false
);
@@ -683,14 +655,6 @@ export class HaAutomationTracer extends LitElement {
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg!: EntityRegistryEntry[];
@state()
@consume({ context: labelsContext, subscribe: true })
_labelReg!: LabelRegistryEntry[];
@state()
@consume({ context: floorsContext, subscribe: true })
_floorReg!: Record<string, FloorRegistryEntry>;
protected render() {
if (!this.trace) {
return nothing;
@@ -707,8 +671,6 @@ export class HaAutomationTracer extends LitElement {
const actionRenderer = new ActionRenderer(
this.hass,
this._entityReg,
this._labelReg,
this._floorReg,
entries,
this.trace,
logbookRenderer,

View File

@@ -1363,9 +1363,9 @@ export const calculateSolarConsumedGauge = (
};
/**
* Get current power value from entity state, normalized to kW
* Get current power value from entity state, normalized to watts (W)
* @param stateObj - The entity state object to get power value from
* @returns Power value in kW, or 0 if entity not found or invalid
* @returns Power value in W (watts), or undefined if entity not found or invalid
*/
export const getPowerFromState = (stateObj: HassEntity): number | undefined => {
if (!stateObj) {
@@ -1376,22 +1376,54 @@ export const getPowerFromState = (stateObj: HassEntity): number | undefined => {
return undefined;
}
// Normalize to kW based on unit of measurement (case-sensitive)
// Normalize to watts (W) based on unit of measurement (case-sensitive)
// Supported units: GW, kW, MW, mW, TW, W
const unit = stateObj.attributes.unit_of_measurement;
switch (unit) {
case "W":
return value / 1000;
case "mW":
return value / 1000000;
case "MW":
return value;
case "kW":
return value * 1000;
case "mW":
return value / 1000;
case "MW":
return value * 1_000_000;
case "GW":
return value * 1000000;
return value * 1_000_000_000;
case "TW":
return value * 1000000000;
return value * 1_000_000_000_000;
default:
// Assume kW if no unit or unit is kW
// Assume value is in watts (W) if no unit or an unsupported unit is provided
return value;
}
};
/**
* Format power value in watts (W) to a short string with the appropriate unit
* @param hass - The HomeAssistant instance
* @param powerWatts - The power value in watts (W)
* @returns A string with the formatted power value and unit
*/
export const formatPowerShort = (
hass: HomeAssistant,
powerWatts: number
): string => {
const units = ["W", "kW", "MW", "GW", "TW"];
let unitIndex = 0;
let value = powerWatts;
// Scale the unit to the appropriate power of 1000
while (Math.abs(value) >= 1000 && unitIndex < units.length - 1) {
value /= 1000;
unitIndex++;
}
return (
formatNumber(value, hass.locale, {
// For watts, show no decimals. For kW and above, always show 3 decimals.
maximumFractionDigits: units[unitIndex] === "W" ? 0 : 3,
}) +
" " +
units[unitIndex]
);
};

View File

@@ -196,6 +196,7 @@ export const fetchHostDisksUsage = async (hass: HomeAssistant) => {
endpoint: "/host/disks/default/usage",
method: "get",
timeout: 3600, // seconds. This can take a while
params: { max_depth: 3 },
});
}

View File

@@ -1,7 +1,6 @@
import { ensureArray } from "../common/array/ensure-array";
import { formatNumericDuration } from "../common/datetime/format_duration";
import secondsToDuration from "../common/datetime/seconds_to_duration";
import { computeDeviceNameDisplay } from "../common/entity/compute_device_name";
import { computeStateName } from "../common/entity/compute_state_name";
import { formatListWithAnds } from "../common/string/format-list";
import { isTemplate } from "../common/string/has-template";
@@ -10,13 +9,7 @@ import type { Condition } from "./automation";
import { describeCondition } from "./automation_i18n";
import { localizeDeviceAutomationAction } from "./device/device_automation";
import type { EntityRegistryEntry } from "./entity/entity_registry";
import {
computeEntityRegistryName,
entityRegistryById,
} from "./entity/entity_registry";
import type { FloorRegistryEntry } from "./floor_registry";
import { domainToName } from "./integration";
import type { LabelRegistryEntry } from "./label/label_registry";
import type {
ActionType,
ActionTypes,
@@ -41,8 +34,6 @@ const actionTranslationBaseKey =
export const describeAction = <T extends ActionType>(
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
labelRegistry: LabelRegistryEntry[],
floorRegistry: Record<string, FloorRegistryEntry>,
action: ActionTypes[T],
actionType?: T,
ignoreAlias = false
@@ -51,8 +42,6 @@ export const describeAction = <T extends ActionType>(
const description = tryDescribeAction(
hass,
entityRegistry,
labelRegistry,
floorRegistry,
action,
actionType,
ignoreAlias
@@ -75,8 +64,6 @@ export const describeAction = <T extends ActionType>(
const tryDescribeAction = <T extends ActionType>(
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
labelRegistry: LabelRegistryEntry[],
floorRegistry: Record<string, FloorRegistryEntry>,
action: ActionTypes[T],
actionType?: T,
ignoreAlias = false
@@ -100,107 +87,6 @@ const tryDescribeAction = <T extends ActionType>(
{ name: "target" }
)
);
} else if (targetOrData) {
for (const [key, name] of Object.entries({
area_id: "areas",
device_id: "devices",
entity_id: "entities",
floor_id: "floors",
label_id: "labels",
})) {
if (!(key in targetOrData)) {
continue;
}
const keyConf: string[] = ensureArray(targetOrData[key]) || [];
for (const targetThing of keyConf) {
if (isTemplate(targetThing)) {
targets.push(
hass.localize(
`${actionTranslationBaseKey}.service.description.target_template`,
{ name }
)
);
break;
} else if (key === "entity_id") {
if (targetThing.includes(".")) {
const state = hass.states[targetThing];
if (state) {
targets.push(computeStateName(state));
} else {
targets.push(targetThing);
}
} else {
const entityReg = entityRegistryById(entityRegistry)[targetThing];
if (entityReg) {
targets.push(
computeEntityRegistryName(hass, entityReg) || targetThing
);
} else if (targetThing === "all") {
targets.push(
hass.localize(
`${actionTranslationBaseKey}.service.description.target_every_entity`
)
);
} else {
targets.push(
hass.localize(
`${actionTranslationBaseKey}.service.description.target_unknown_entity`
)
);
}
}
} else if (key === "device_id") {
const device = hass.devices[targetThing];
if (device) {
targets.push(computeDeviceNameDisplay(device, hass));
} else {
targets.push(
hass.localize(
`${actionTranslationBaseKey}.service.description.target_unknown_device`
)
);
}
} else if (key === "area_id") {
const area = hass.areas[targetThing];
if (area?.name) {
targets.push(area.name);
} else {
targets.push(
hass.localize(
`${actionTranslationBaseKey}.service.description.target_unknown_area`
)
);
}
} else if (key === "floor_id") {
const floor = floorRegistry[targetThing] ?? undefined;
if (floor?.name) {
targets.push(floor.name);
} else {
targets.push(
hass.localize(
`${actionTranslationBaseKey}.service.description.target_unknown_floor`
)
);
}
} else if (key === "label_id") {
const label = labelRegistry.find(
(lbl) => lbl.label_id === targetThing
);
if (label?.name) {
targets.push(label.name);
} else {
targets.push(
hass.localize(
`${actionTranslationBaseKey}.service.description.target_unknown_label`
)
);
}
} else {
targets.push(targetThing);
}
}
}
}
if (
@@ -229,26 +115,20 @@ const tryDescribeAction = <T extends ActionType>(
if (config.metadata) {
return hass.localize(
targets.length
? `${actionTranslationBaseKey}.service.description.service_name`
: `${actionTranslationBaseKey}.service.description.service_name_no_targets`,
`${actionTranslationBaseKey}.service.description.service_name_no_targets`,
{
domain: domainToName(hass.localize, domain),
name: service || config.action,
targets: formatListWithAnds(hass.locale, targets),
}
);
}
return hass.localize(
targets.length
? `${actionTranslationBaseKey}.service.description.service_based_on_name`
: `${actionTranslationBaseKey}.service.description.service_based_on_name_no_targets`,
`${actionTranslationBaseKey}.service.description.service_based_on_name_no_targets`,
{
name: service
? `${domainToName(hass.localize, domain)}: ${service}`
: config.action,
targets: formatListWithAnds(hass.locale, targets),
}
);
}

View File

@@ -39,6 +39,10 @@ class DialogBox extends LitElement {
}
public closeDialog(): boolean {
if (!this._open) {
// Dialog is already closing
return true;
}
if (this._params?.confirmation || this._params?.prompt) {
return false;
}

View File

@@ -6,7 +6,6 @@ import {
mdiEarth,
mdiKeyboard,
mdiMagnify,
mdiPuzzle,
mdiReload,
mdiServerNetwork,
} from "@mdi/js";
@@ -95,22 +94,12 @@ interface DeviceItem extends QuickBarItem {
area?: string;
}
interface IntegrationItem extends QuickBarItem {
domain: string;
translatedDomain: string;
}
const isCommandItem = (item: QuickBarItem): item is CommandItem =>
(item as CommandItem).categoryKey !== undefined;
const isDeviceItem = (item: QuickBarItem): item is DeviceItem =>
(item as DeviceItem).deviceId !== undefined;
const isIntegrationItem = (item: QuickBarItem): item is IntegrationItem =>
(item as DeviceItem).deviceId === undefined &&
(item as IntegrationItem).domain !== undefined &&
(item as IntegrationItem).translatedDomain !== undefined;
interface QuickBarNavigationItem extends CommandItem {
path: string;
}
@@ -132,8 +121,6 @@ export class QuickBar extends LitElement {
@state() private _deviceItems?: DeviceItem[];
@state() private _integrationItems?: IntegrationItem[];
@state() private _filter = "";
@state() private _search = "";
@@ -172,8 +159,6 @@ export class QuickBar extends LitElement {
this._search = "";
this._entityItems = undefined;
this._commandItems = undefined;
this._deviceItems = undefined;
this._integrationItems = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -189,7 +174,6 @@ export class QuickBar extends LitElement {
commandItems,
entityItems,
deviceItems,
integrationItems,
filter: string
) => {
let items = entityItems;
@@ -198,8 +182,6 @@ export class QuickBar extends LitElement {
items = commandItems;
} else if (mode === QuickBarMode.Device) {
items = deviceItems;
} else if (mode === QuickBarMode.Integration) {
items = integrationItems;
}
if (items && filter && filter !== " ") {
@@ -219,37 +201,25 @@ export class QuickBar extends LitElement {
this._commandItems,
this._entityItems,
this._deviceItems,
this._integrationItems,
this._filter
);
const translationKey =
this._mode === QuickBarMode.Device
? "filter_placeholder_devices"
: this._mode === QuickBarMode.Integration
? "filter_placeholder_integrations"
: "filter_placeholder";
: "filter_placeholder";
const placeholder = this.hass.localize(
`ui.dialogs.quick-bar.${translationKey}`
);
const commandMode = this._mode === QuickBarMode.Command;
const deviceMode = this._mode === QuickBarMode.Device;
const integrationMode = this._mode === QuickBarMode.Integration;
const icon = commandMode
? mdiConsoleLine
: deviceMode
? mdiDevices
: integrationMode
? mdiPuzzle
: mdiMagnify;
const searchPrefix = commandMode
? ">"
: deviceMode
? "#"
: integrationMode
? "@"
: "";
: mdiMagnify;
const searchPrefix = commandMode ? ">" : deviceMode ? "#" : "";
return html`
<ha-dialog
@@ -348,9 +318,6 @@ export class QuickBar extends LitElement {
} else if (this._mode === QuickBarMode.Device) {
this._deviceItems =
this._deviceItems || (await this._generateDeviceItems());
} else if (this._mode === QuickBarMode.Integration) {
this._integrationItems =
this._integrationItems || (await this._generateIntegrationItems());
} else {
this._entityItems =
this._entityItems || (await this._generateEntityItems());
@@ -385,10 +352,6 @@ export class QuickBar extends LitElement {
return this._renderCommandItem(item, index);
}
if (isIntegrationItem(item)) {
return this._renderIntegrationItem(item, index);
}
return this._renderEntityItem(item as EntityItem, index);
};
@@ -496,35 +459,6 @@ export class QuickBar extends LitElement {
`;
}
private _renderIntegrationItem(item: IntegrationItem, index?: number) {
return html`
<ha-md-list-item
class="two-line"
.item=${item}
index=${ifDefined(index)}
tabindex="0"
type="button"
>
<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl({
domain: item.domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
/>
<span slot="headline">${item.primaryText}</span>
<div slot="trailing-supporting-text" class="domain">
${item.translatedDomain}
</div>
</ha-md-list-item>
`;
}
private async _processItemAndCloseDialog(item: QuickBarItem, index: number) {
this._addSpinnerToCommandItem(index);
@@ -573,9 +507,6 @@ export class QuickBar extends LitElement {
} else if (newFilter.startsWith("#")) {
newMode = QuickBarMode.Device;
newSearch = newFilter.substring(1);
} else if (newFilter.startsWith("@")) {
newMode = QuickBarMode.Integration;
newSearch = newFilter.substring(1);
} else {
newMode = QuickBarMode.Entity;
newSearch = newFilter;
@@ -775,43 +706,6 @@ export class QuickBar extends LitElement {
);
}
private async _generateIntegrationItems(): Promise<IntegrationItem[]> {
const configEntries = await getConfigEntries(this.hass);
// Get unique domains from enabled entries
const domains = new Set<string>();
for (const entry of configEntries) {
if (entry.disabled_by || entry.source === "ignore") {
continue;
}
domains.add(entry.domain);
}
const integrationItems: IntegrationItem[] = [];
for (const domain of domains) {
const translatedDomain = domainToName(this.hass.localize, domain);
const primaryText = translatedDomain;
integrationItems.push({
id: `integration-${domain}`,
primaryText,
domain,
translatedDomain,
action: () => navigate(`/config/integrations/integration/${domain}`),
strings: [primaryText, domain, translatedDomain],
});
}
return integrationItems.sort((a, b) =>
caseInsensitiveStringCompare(
a.primaryText,
b.primaryText,
this.hass.locale.language
)
);
}
private async _generateCommandItems(): Promise<CommandItem[]> {
return [
...(await this._generateReloadCommands()),

View File

@@ -4,7 +4,6 @@ export const enum QuickBarMode {
Command = "command",
Device = "device",
Entity = "entity",
Integration = "integration",
}
export interface QuickBarParams {

View File

@@ -0,0 +1,38 @@
import type { ReactiveElement } from "lit";
import { property } from "lit/decorators";
import type { Constructor } from "../types";
import type { PickerValueRenderer } from "../components/ha-picker-field";
export const PickerMixin = <T extends Constructor<ReactiveElement>>(
superClass: T
) => {
class PickerFieldClass extends superClass {
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property() public icon?: string;
@property() public image?: string;
@property() public label?: string;
@property() public placeholder?: string;
@property() public helper?: string;
@property() public value?: string;
@property({ type: Boolean, reflect: true }) public unknown = false;
@property({ attribute: "unknown-item-text" })
public unknownItemText?: string;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ attribute: false })
public valueRenderer?: PickerValueRenderer;
}
return PickerFieldClass;
};

View File

@@ -37,8 +37,7 @@ class PanelClimate extends LitElement {
super.willUpdate(changedProps);
// Initial setup
if (!this.hasUpdated) {
this.hass.loadFragmentTranslation("lovelace");
this._setLovelace();
this._setup();
return;
}
@@ -75,6 +74,11 @@ class PanelClimate extends LitElement {
}
}
private async _setup() {
await this.hass.loadFragmentTranslation("lovelace");
this._setLovelace();
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200

View File

@@ -103,6 +103,10 @@ const processAreasForClimate = (
heading_style: "subtitle",
type: "heading",
heading: area.name,
tap_action: {
action: "navigate",
navigation_path: `/home/areas-${area.area_id}`,
},
});
cards.push(...areaCards);
}

View File

@@ -160,7 +160,7 @@ class DialogFloorDetail extends LitElement {
${!this._icon
? html`
<ha-floor-icon
slot="fallback"
slot="start"
.floor=${{ level: this._level }}
></ha-floor-icon>
`

View File

@@ -17,6 +17,7 @@ import {
mdiStopCircleOutline,
} from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { dump } from "js-yaml";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, html, nothing } from "lit";
@@ -53,18 +54,13 @@ import type {
} from "../../../../data/automation";
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
import { validateConfig } from "../../../../data/config";
import {
floorsContext,
fullEntitiesContext,
labelsContext,
} from "../../../../data/context";
import { fullEntitiesContext } from "../../../../data/context";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { FloorRegistryEntry } from "../../../../data/floor_registry";
import type { LabelRegistryEntry } from "../../../../data/label/label_registry";
import type {
Action,
NonConditionAction,
RepeatAction,
ServiceAction,
} from "../../../../data/script";
import { getActionType, isAction } from "../../../../data/script";
import { describeAction } from "../../../../data/script_i18n";
@@ -78,6 +74,7 @@ import { isMac } from "../../../../util/is_mac";
import { showToast } from "../../../../util/toast";
import "../ha-automation-editor-warning";
import { overflowStyles, rowStyles } from "../styles";
import "../target/ha-automation-row-targets";
import "./ha-automation-action-editor";
import type HaAutomationActionEditor from "./ha-automation-action-editor";
import "./types/ha-automation-action-choose";
@@ -176,14 +173,6 @@ export default class HaAutomationActionRow extends LitElement {
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg!: EntityRegistryEntry[];
@state()
@consume({ context: labelsContext, subscribe: true })
_labelReg!: LabelRegistryEntry[];
@state()
@consume({ context: floorsContext, subscribe: true })
_floorReg!: Record<string, FloorRegistryEntry>;
@state() private _uiModeAvailable = true;
@state() private _yamlMode = false;
@@ -263,14 +252,11 @@ export default class HaAutomationActionRow extends LitElement {
`}
<h3 slot="header">
${capitalizeFirstLetter(
describeAction(
this.hass,
this._entityReg,
this._labelReg,
this._floorReg,
this.action
)
describeAction(this.hass, this._entityReg, this.action)
)}
${type === "service" && "target" in this.action
? this._renderTargets((this.action as ServiceAction).target)
: nothing}
</h3>
<slot name="icons" slot="icons"></slot>
@@ -556,6 +542,14 @@ export default class HaAutomationActionRow extends LitElement {
`;
}
private _renderTargets = memoizeOne(
(target?: HassServiceTarget) =>
html`<ha-automation-row-targets
.hass=${this.hass}
.target=${target}
></ha-automation-row-targets>`
);
private _onValueChange(event: CustomEvent) {
// reload sidebar if sort, deleted,... happend
if (this._selected && this.optionsInSidebar) {
@@ -668,15 +662,7 @@ export default class HaAutomationActionRow extends LitElement {
),
inputType: "string",
placeholder: capitalizeFirstLetter(
describeAction(
this.hass,
this._entityReg,
this._labelReg,
this._floorReg,
this.action,
undefined,
true
)
describeAction(this.hass, this._entityReg, this.action, undefined, true)
),
defaultValue: this.action.alias,
confirmText: this.hass.localize("ui.common.submit"),

View File

@@ -18,7 +18,6 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import { mainWindow } from "../../../common/dom/get_main_window";
import { computeAreaName } from "../../../common/entity/compute_area_name";
import { computeDeviceName } from "../../../common/entity/compute_device_name";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeEntityNameList } from "../../../common/entity/compute_entity_name_display";
import { computeFloorName } from "../../../common/entity/compute_floor_name";
@@ -123,6 +122,7 @@ import "./add-automation-element/ha-automation-add-items";
import "./add-automation-element/ha-automation-add-search";
import type { AddAutomationElementDialogParams } from "./show-add-automation-element-dialog";
import { PASTE_VALUE } from "./show-add-automation-element-dialog";
import { getTargetText } from "./target/get_target_text";
const TYPES = {
trigger: { collections: TRIGGER_COLLECTIONS, icons: TRIGGER_ICONS },
@@ -1393,8 +1393,8 @@ class DialogAddAutomationElement
}
);
private _getLabel = memoizeOne((labelId) =>
this._labelRegistry?.find(({ label_id }) => label_id === labelId)
private _getLabel = memoizeOne((id: string) =>
this._labelRegistry?.find(({ label_id }) => label_id === id)
);
private _getDomainType(domain: string) {
@@ -1926,32 +1926,12 @@ class DialogAddAutomationElement
}
if (targetId) {
if (targetType === "floor") {
return computeFloorName(this.hass.floors[targetId]) || targetId;
}
if (targetType === "area") {
return computeAreaName(this.hass.areas[targetId]) || targetId;
}
if (targetType === "device") {
return computeDeviceName(this.hass.devices[targetId]) || targetId;
}
if (targetType === "entity" && this.hass.states[targetId]) {
const stateObj = this.hass.states[targetId];
const [entityName, deviceName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
return entityName || deviceName || targetId;
}
if (targetType === "label") {
const label = this._getLabel(targetId);
return label?.name || targetId;
}
return getTargetText(
this.hass,
targetType as "floor" | "area" | "device" | "entity" | "label",
targetId,
this._getLabel
);
}
return undefined;

View File

@@ -20,13 +20,13 @@ import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
import { computeEntityNameList } from "../../../../common/entity/compute_entity_name_display";
import { stringCompare } from "../../../../common/string/compare";
import "../../../../components/entity/state-badge";
import "../../../../components/ha-floor-icon";
import "../../../../components/ha-icon";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-section-title";
import "../../../../components/ha-state-icon";
import "../../../../components/ha-svg-icon";
import {
getAreasNestedInFloors,
@@ -779,11 +779,11 @@ export default class HaAutomationAddFromTarget extends LitElement {
private _renderEntityIcon =
(stateObj: HassEntity) => (slot: string | undefined) =>
html`<state-badge
html`<ha-state-icon
.hass=${this.hass}
slot=${ifDefined(slot)}
.stateObj=${stateObj}
.hass=${this.hass}
></state-badge>`;
></ha-state-icon>`;
private _renderItem(
label: string,
@@ -1435,6 +1435,7 @@ export default class HaAutomationAddFromTarget extends LitElement {
ha-svg-icon,
ha-icon,
ha-state-icon,
ha-floor-icon {
padding: var(--ha-space-1);
color: var(--ha-color-on-neutral-quiet);
@@ -1459,13 +1460,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
height: 24px;
}
wa-tree-item[selected],
wa-tree-item[selected] > ha-svg-icon,
wa-tree-item[selected] > ha-icon,
wa-tree-item[selected] > ha-floor-icon {
color: var(--ha-color-on-primary-normal);
}
wa-tree-item[selected]::part(item):hover {
background-color: var(--ha-color-fill-primary-normal-hover);
}
@@ -1490,6 +1484,11 @@ export default class HaAutomationAddFromTarget extends LitElement {
--icon-primary-color: var(--ha-color-on-primary-normal);
}
wa-tree-item[selected],
wa-tree-item[selected] > ha-svg-icon,
wa-tree-item[selected] > ha-icon,
wa-tree-item[selected] > ha-state-icon,
wa-tree-item[selected] > ha-floor-icon,
ha-md-list-item.selected ha-icon,
ha-md-list-item.selected ha-svg-icon {
color: var(--ha-color-on-primary-normal);

View File

@@ -1,10 +1,5 @@
import {
mdiInformationOutline,
mdiLabel,
mdiPlus,
mdiTextureBox,
} from "@mdi/js";
import { LitElement, css, html, nothing, type TemplateResult } from "lit";
import { mdiInformationOutline, mdiPlus } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import {
customElement,
eventOptions,
@@ -17,17 +12,15 @@ import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import "../../../../components/entity/state-badge";
import "../../../../components/ha-domain-icon";
import "../../../../components/ha-floor-icon";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tooltip";
import type { ConfigEntry } from "../../../../data/config_entries";
import type { LabelRegistryEntry } from "../../../../data/label/label_registry";
import type { HomeAssistant } from "../../../../types";
import type { AddAutomationElementListItem } from "../add-automation-element-dialog";
import { getTargetIcon } from "../target/get_target_icon";
type Target = [string, string | undefined, string | undefined];
@@ -50,7 +43,7 @@ export class HaAutomationAddItems extends LitElement {
@property({ attribute: false }) public getLabel!: (
id: string
) => { name: string; icon?: string } | undefined;
) => LabelRegistryEntry | undefined;
@property({ attribute: false }) public configEntryLookup: Record<
string,
@@ -164,72 +157,17 @@ export class HaAutomationAddItems extends LitElement {
}
return html`<div class="selected-target">
${this._getSelectedTargetIcon(target[0], target[1])}
${getTargetIcon(
this.hass,
target[0],
target[1],
this.configEntryLookup,
this.getLabel
)}
<div class="label">${target[2]}</div>
</div>`;
});
private _getSelectedTargetIcon(
targetType: string,
targetId: string | undefined
): TemplateResult | typeof nothing {
if (!targetId) {
return nothing;
}
if (targetType === "floor") {
return html`<ha-floor-icon
.floor=${this.hass.floors[targetId]}
></ha-floor-icon>`;
}
if (targetType === "area" && this.hass.areas[targetId]) {
const area = this.hass.areas[targetId];
if (area.icon) {
return html`<ha-icon .icon=${area.icon}></ha-icon>`;
}
return html`<ha-svg-icon .path=${mdiTextureBox}></ha-svg-icon>`;
}
if (targetType === "device" && this.hass.devices[targetId]) {
const device = this.hass.devices[targetId];
const configEntry = device.primary_config_entry
? this.configEntryLookup[device.primary_config_entry]
: undefined;
const domain = configEntry?.domain;
if (domain) {
return html`<ha-domain-icon
slot="start"
.hass=${this.hass}
.domain=${domain}
brand-fallback
></ha-domain-icon>`;
}
}
if (targetType === "entity" && this.hass.states[targetId]) {
const stateObj = this.hass.states[targetId];
if (stateObj) {
return html`<state-badge
.stateObj=${stateObj}
.hass=${this.hass}
.stateColor=${false}
></state-badge>`;
}
}
if (targetType === "label") {
const label = this.getLabel(targetId);
if (label?.icon) {
return html`<ha-icon .icon=${label.icon}></ha-icon>`;
}
return html`<ha-svg-icon .path=${mdiLabel}></ha-svg-icon>`;
}
return nothing;
}
private _selected(ev) {
const item = ev.currentTarget;
fireEvent(this, "value-changed", {
@@ -335,10 +273,6 @@ export class HaAutomationAddItems extends LitElement {
border-bottom: 1px solid var(--ha-color-border-neutral-quiet);
}
ha-icon-next {
width: var(--ha-space-6);
}
ha-svg-icon.plus {
color: var(--primary-color);
}
@@ -362,16 +296,11 @@ export class HaAutomationAddItems extends LitElement {
.selected-target ha-icon,
.selected-target ha-svg-icon,
.selected-target state-badge,
.selected-target ha-domain-icon {
display: flex;
padding: var(--ha-space-1) 0;
}
.selected-target state-badge {
--mdc-icon-size: 24px;
}
.selected-target state-badge,
.selected-target ha-floor-icon {
display: flex;
height: 32px;

View File

@@ -148,7 +148,7 @@ class DialogAutomationSave extends LitElement implements HassDialog {
@value-changed=${this._iconChanged}
>
<ha-domain-icon
slot="fallback"
slot="start"
domain=${this._params.domain}
.hass=${this.hass}
>

View File

@@ -16,6 +16,7 @@ import {
mdiStopCircleOutline,
} from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { dump } from "js-yaml";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -43,6 +44,7 @@ import type {
AutomationClipboard,
Condition,
ConditionSidebarConfig,
PlatformCondition,
} from "../../../../data/automation";
import { isCondition, testCondition } from "../../../../data/automation";
import { describeCondition } from "../../../../data/automation_i18n";
@@ -60,6 +62,7 @@ import { isMac } from "../../../../util/is_mac";
import { showToast } from "../../../../util/toast";
import "../ha-automation-editor-warning";
import { overflowStyles, rowStyles } from "../styles";
import "../target/ha-automation-row-targets";
import "./ha-automation-condition-editor";
import type HaAutomationConditionEditor from "./ha-automation-condition-editor";
import "./types/ha-automation-condition-and";
@@ -191,6 +194,10 @@ export default class HaAutomationConditionRow extends LitElement {
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
)}
${"target" in
(this.conditionDescriptions[this.condition.condition] || {})
? this._renderTargets((this.condition as PlatformCondition).target)
: nothing}
</h3>
<slot name="icons" slot="icons"></slot>
@@ -475,6 +482,14 @@ export default class HaAutomationConditionRow extends LitElement {
`;
}
private _renderTargets = memoizeOne(
(target?: HassServiceTarget) =>
html`<ha-automation-row-targets
.hass=${this.hass}
.target=${target}
></ha-automation-row-targets>`
);
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);

View File

@@ -46,6 +46,7 @@ import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { fileDownload } from "../../../util/file_download";
const TABS = ["details", "timeline", "logbook", "automation_config"] as const;
@@ -444,21 +445,21 @@ export class HaAutomationTrace extends LitElement {
}
private _downloadTrace() {
const aEl = document.createElement("a");
aEl.download = `trace ${this._entityId} ${
this._trace!.timestamp.start
}.json`;
aEl.href = `data:application/json;charset=utf-8,${encodeURI(
JSON.stringify(
{
trace: this._trace,
logbookEntries: this._logbookEntries,
},
undefined,
2
)
)}`;
aEl.click();
const json = JSON.stringify(
{
trace: this._trace,
logbookEntries: this._logbookEntries,
},
undefined,
2
);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
fileDownload(
url,
`trace ${this._entityId} ${this._trace!.timestamp.start}.json`
);
URL.revokeObjectURL(url);
}
private _importTrace() {

View File

@@ -14,6 +14,12 @@ export const rowStyles = css`
h3 {
font-size: inherit;
font-weight: inherit;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ha-space-2);
padding: var(--ha-space-2) 0;
min-height: 32px;
}
ha-card {

View File

@@ -0,0 +1,69 @@
import { mdiLabel, mdiTextureBox } from "@mdi/js";
import { html, nothing, type TemplateResult } from "lit";
import "../../../../components/ha-domain-icon";
import "../../../../components/ha-floor-icon";
import "../../../../components/ha-icon";
import "../../../../components/ha-state-icon";
import "../../../../components/ha-svg-icon";
import type { ConfigEntry } from "../../../../data/config_entries";
import type { LabelRegistryEntry } from "../../../../data/label/label_registry";
import type { HomeAssistant } from "../../../../types";
export const getTargetIcon = (
hass: HomeAssistant,
targetType: string,
targetId: string | undefined,
configEntryLookup: Record<string, ConfigEntry>,
getLabel?: (id: string) => LabelRegistryEntry | undefined
): TemplateResult | typeof nothing => {
if (!targetId) {
return nothing;
}
if (targetType === "floor" && hass.floors[targetId]) {
return html`<ha-floor-icon
.floor=${hass.floors[targetId]}
></ha-floor-icon>`;
}
if (targetType === "area") {
const area = hass.areas[targetId];
if (area?.icon) {
return html`<ha-icon .icon=${area.icon}></ha-icon>`;
}
return html`<ha-svg-icon .path=${mdiTextureBox}></ha-svg-icon>`;
}
if (targetType === "device" && hass.devices[targetId]) {
const device = hass.devices[targetId];
const configEntry = device.primary_config_entry
? configEntryLookup[device.primary_config_entry]
: undefined;
const domain = configEntry?.domain;
if (domain) {
return html`<ha-domain-icon
.hass=${hass}
.domain=${domain}
brand-fallback
></ha-domain-icon>`;
}
}
if (targetType === "entity" && hass.states[targetId]) {
return html`<ha-state-icon
.hass=${hass}
.stateObj=${hass.states[targetId]}
></ha-state-icon>`;
}
if (targetType === "label" && getLabel) {
const label = getLabel(targetId);
if (label?.icon) {
return html`<ha-icon .icon=${label.icon}></ha-icon>`;
}
return html`<ha-svg-icon .path=${mdiLabel}></ha-svg-icon>`;
}
return nothing;
};

View File

@@ -0,0 +1,68 @@
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
import { computeEntityNameList } from "../../../../common/entity/compute_entity_name_display";
import { computeFloorName } from "../../../../common/entity/compute_floor_name";
import type { LabelRegistryEntry } from "../../../../data/label/label_registry";
import type { HomeAssistant } from "../../../../types";
export const getTargetText = (
hass: HomeAssistant,
targetType: "floor" | "area" | "device" | "entity" | "label",
targetId: string,
getLabel?: (id: string) => LabelRegistryEntry | undefined
): string => {
if (targetType === "floor") {
return (
(hass.floors[targetId] && computeFloorName(hass.floors[targetId])) ||
hass.localize(
"ui.panel.config.automation.editor.actions.type.service.description.target_unknown_floor"
)
);
}
if (targetType === "area") {
return (
(hass.areas[targetId] && computeAreaName(hass.areas[targetId])) ||
hass.localize(
"ui.panel.config.automation.editor.actions.type.service.description.target_unknown_area"
)
);
}
if (targetType === "device") {
return (
(hass.devices[targetId] && computeDeviceName(hass.devices[targetId])) ||
hass.localize(
"ui.panel.config.automation.editor.actions.type.service.description.target_unknown_device"
)
);
}
if (targetType === "entity" && hass.states[targetId]) {
const stateObj = hass.states[targetId];
const [entityName, deviceName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
return entityName || deviceName || targetId;
}
if (targetType === "entity") {
return hass.localize(
"ui.panel.config.automation.editor.actions.type.service.description.target_unknown_entity"
);
}
if (targetType === "label" && getLabel) {
const label = getLabel(targetId);
return (
label?.name ||
hass.localize(
"ui.panel.config.automation.editor.actions.type.service.description.target_unknown_label"
)
);
}
return targetId;
};

View File

@@ -0,0 +1,260 @@
import { consume } from "@lit/context";
import { mdiAlert, mdiFormatListBulleted, mdiShape } from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { LitElement, css, html, nothing, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import { ensureArray } from "../../../../common/array/ensure-array";
import "../../../../components/ha-svg-icon";
import {
getConfigEntries,
type ConfigEntry,
} from "../../../../data/config_entries";
import {
areasContext,
devicesContext,
floorsContext,
labelsContext,
localizeContext,
statesContext,
} from "../../../../data/context";
import type { LabelRegistryEntry } from "../../../../data/label/label_registry";
import type { HomeAssistant } from "../../../../types";
import { getTargetIcon } from "./get_target_icon";
import { getTargetText } from "./get_target_text";
@customElement("ha-automation-row-targets")
export class HaAutomationRowTargets extends LitElement {
@property({ attribute: false })
public hass!: HomeAssistant;
@property({ attribute: false })
public target?: HassServiceTarget;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: HomeAssistant["localize"];
@state()
@consume({ context: floorsContext, subscribe: true })
private floors!: HomeAssistant["floors"];
@state()
@consume({ context: areasContext, subscribe: true })
private areas!: HomeAssistant["areas"];
@state()
@consume({ context: devicesContext, subscribe: true })
private devices!: HomeAssistant["devices"];
@state()
@consume({ context: statesContext, subscribe: true })
private states!: HomeAssistant["states"];
@state()
@consume({ context: labelsContext, subscribe: true })
private _labelRegistry!: LabelRegistryEntry[];
private _configEntryLookup?: Record<string, ConfigEntry>;
protected render() {
const length = Object.keys(this.target || {}).length;
if (!length) {
return html`<span class="target">
<div class="label">
${this.localize(
"ui.panel.config.automation.editor.target_summary.no_target"
)}
</div>
</span>`;
}
const totalLength = Object.values(this.target || {}).reduce(
(acc, val) => acc + ensureArray(val).length,
0
);
if (totalLength <= 5) {
const targets = Object.entries(this.target!).reduce<
["floor" | "area" | "device" | "entity" | "label", string][]
>((acc, [targetType, targetId]) => {
const type = targetType.replace("_id", "") as
| "floor"
| "area"
| "device"
| "entity"
| "label";
return [
...acc,
...ensureArray(targetId).map((id): [typeof type, string] => [
type,
id,
]),
];
}, []);
return targets.map(
([targetType, targetId]) =>
html`<span class="target-wrapper">
${this._renderTarget(targetType, targetId)}
</span>`
);
}
return html`<span class="target">
<ha-svg-icon .path=${mdiFormatListBulleted}></ha-svg-icon>
<div class="label">
${this.localize(
"ui.panel.config.automation.editor.target_summary.targets",
{
count: totalLength,
}
)}
</div>
</span>`;
}
private _getLabel = (id: string) =>
this._labelRegistry?.find(({ label_id }) => label_id === id);
private _checkTargetExists(
targetType: "floor" | "area" | "device" | "entity" | "label",
targetId: string
): boolean {
if (targetType === "floor") {
return !!this.floors[targetId];
}
if (targetType === "area") {
return !!this.areas[targetId];
}
if (targetType === "device") {
return !!this.devices[targetId];
}
if (targetType === "entity") {
return !!this.states[targetId];
}
if (targetType === "label") {
return !!this._getLabel(targetId);
}
return false;
}
private _renderTargetBadge(
icon: TemplateResult | typeof nothing,
label: string,
alert = false
) {
return html`<div class="target ${alert ? "alert" : ""}">
${icon}
<div class="label">${label}</div>
</div>`;
}
private async _loadConfigEntries() {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
configEntries.map((entry) => [entry.entry_id, entry])
);
}
private _renderTarget(
targetType: "floor" | "area" | "device" | "entity" | "label",
targetId: string
) {
if (targetType === "entity" && ["all", "none"].includes(targetId)) {
return this._renderTargetBadge(
html`<ha-svg-icon .path=${mdiShape}></ha-svg-icon>`,
this.localize(
`ui.panel.config.automation.editor.target_summary.${targetId as "all" | "none"}_entities`
)
);
}
const exists = this._checkTargetExists(targetType, targetId);
if (!exists) {
return this._renderTargetBadge(
html`<ha-svg-icon .path=${mdiAlert}></ha-svg-icon>`,
getTargetText(this.hass, targetType, targetId, this._getLabel),
true
);
}
if (targetType === "device" && !this._configEntryLookup) {
const loadConfigEntries = this._loadConfigEntries().then(() =>
this._renderTargetBadge(
getTargetIcon(
this.hass,
targetType,
targetId,
this._configEntryLookup!
),
getTargetText(this.hass, targetType, targetId)
)
);
return html`${until(loadConfigEntries, nothing)}`;
}
return this._renderTargetBadge(
getTargetIcon(
this.hass,
targetType,
targetId,
this._configEntryLookup || {},
this._getLabel
),
getTargetText(this.hass, targetType, targetId, this._getLabel)
);
}
static styles = css`
:host {
display: contents;
min-height: 32px;
}
.target-wrapper {
display: inline-flex;
align-items: flex-end;
gap: var(--ha-space-1);
}
.target {
display: inline-flex;
gap: var(--ha-space-1);
justify-content: center;
align-items: center;
border-radius: var(--ha-border-radius-md);
background: var(--ha-color-fill-neutral-normal-resting);
padding: 0 var(--ha-space-2) 0 var(--ha-space-1);
color: var(--ha-color-on-neutral-normal);
overflow: hidden;
height: 32px;
}
.target.alert {
background: var(--ha-color-fill-warning-normal-resting);
color: var(--ha-color-on-warning-normal);
}
.target .label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.target ha-icon,
.target ha-svg-icon,
.target ha-domain-icon {
display: flex;
padding: var(--ha-space-1) 0;
}
.target ha-floor-icon {
display: flex;
height: 32px;
align-items: center;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-row-targets": HaAutomationRowTargets;
}
}

View File

@@ -15,7 +15,10 @@ import {
mdiRenameBox,
mdiStopCircleOutline,
} from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type {
HassServiceTarget,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import { dump } from "js-yaml";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -44,6 +47,7 @@ import "../../../../components/ha-svg-icon";
import { TRIGGER_ICONS } from "../../../../components/ha-trigger-icon";
import type {
AutomationClipboard,
PlatformTrigger,
Trigger,
TriggerList,
TriggerSidebarConfig,
@@ -64,6 +68,7 @@ import { isMac } from "../../../../util/is_mac";
import { showToast } from "../../../../util/toast";
import "../ha-automation-editor-warning";
import { overflowStyles, rowStyles } from "../styles";
import "../target/ha-automation-row-targets";
import "./ha-automation-trigger-editor";
import type HaAutomationTriggerEditor from "./ha-automation-trigger-editor";
import "./types/ha-automation-trigger-calendar";
@@ -205,6 +210,11 @@ export default class HaAutomationTriggerRow extends LitElement {
></ha-trigger-icon>`}
<h3 slot="header">
${describeTrigger(this.trigger, this.hass, this._entityReg)}
${type === "platform" &&
"target" in
this.triggerDescriptions[(this.trigger as PlatformTrigger).trigger]
? this._renderTargets((this.trigger as PlatformTrigger).target)
: nothing}
</h3>
<slot name="icons" slot="icons"></slot>
@@ -450,6 +460,14 @@ export default class HaAutomationTriggerRow extends LitElement {
`;
}
private _renderTargets = memoizeOne(
(target?: HassServiceTarget) =>
html`<ha-automation-row-targets
.hass=${this.hass}
.target=${target}
></ha-automation-row-targets>`
);
protected willUpdate(changedProperties) {
// on yaml toggle --> clear warnings
if (changedProperties.has("yamlMode")) {

View File

@@ -403,7 +403,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
${!this._icon && !stateObj?.attributes.icon && stateObj
? html`
<ha-state-icon
slot="fallback"
slot="start"
.hass=${this.hass}
.stateObj=${stateObj}
></ha-state-icon>

View File

@@ -354,6 +354,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
sortable: true,
filterable: true,
groupable: true,
template: (helper) => helper.area || "—",
},
labels: {
title: "",

View File

@@ -43,6 +43,7 @@ import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { fileDownload } from "../../../util/file_download";
@customElement("ha-script-trace")
export class HaScriptTrace extends LitElement {
@@ -455,21 +456,21 @@ export class HaScriptTrace extends LitElement {
}
private _downloadTrace() {
const aEl = document.createElement("a");
aEl.download = `trace ${this._entityId} ${
this._trace!.timestamp.start
}.json`;
aEl.href = `data:application/json;charset=utf-8,${encodeURI(
JSON.stringify(
{
trace: this._trace,
logbookEntries: this._logbookEntries,
},
undefined,
2
)
)}`;
aEl.click();
const json = JSON.stringify(
{
trace: this._trace,
logbookEntries: this._logbookEntries,
},
undefined,
2
);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
fileDownload(
url,
`trace ${this._entityId} ${this._trace!.timestamp.start}.json`
);
URL.revokeObjectURL(url);
}
private _importTrace() {

View File

@@ -9,8 +9,6 @@ import {
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { getGraphColorByIndex } from "../../../common/color/colors";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { navigate } from "../../../common/navigate";
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
@@ -44,10 +42,10 @@ import {
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import type { HomeAssistant, Route } from "../../../types";
import { roundWithOneDecimal } from "../../../util/calculate";
import "../core/ha-config-analytics";
import { showMoveDatadiskDialog } from "./show-dialog-move-datadisk";
import { showMountViewDialog } from "./show-dialog-view-mount";
import "./storage-breakdown-chart";
@customElement("ha-config-section-storage")
class HaConfigSectionStorage extends LitElement {
@@ -104,10 +102,11 @@ class HaConfigSectionStorage extends LitElement {
)}
>
<div class="card-content">
${this._renderStorageMetrics(
this._hostInfo,
this._storageInfo
)}
<storage-breakdown-chart
.hass=${this.hass}
.hostInfo=${this._hostInfo}
.storageInfo=${this._storageInfo}
></storage-breakdown-chart>
${this._renderDiskLifeTime(this._hostInfo.disk_life_time)}
</div>
${this._hostInfo
@@ -269,95 +268,6 @@ class HaConfigSectionStorage extends LitElement {
`;
}
private _renderStorageMetrics = memoizeOne(
(hostInfo?: HassioHostInfo, storageInfo?: HostDisksUsage | null) => {
if (!hostInfo) {
return nothing;
}
const computedStyles = getComputedStyle(this);
let totalSpaceGB = hostInfo.disk_total;
let usedSpaceGB = hostInfo.disk_used;
// hostInfo.disk_free is sometimes 0, so we may need to calculate it
let freeSpaceGB =
hostInfo.disk_free || hostInfo.disk_total - hostInfo.disk_used;
const segments: Segment[] = [];
if (storageInfo) {
const totalSpace =
storageInfo.total_bytes ?? this._gbToBytes(hostInfo.disk_total);
totalSpaceGB = this._bytesToGB(totalSpace);
usedSpaceGB = this._bytesToGB(storageInfo.used_bytes);
freeSpaceGB = this._bytesToGB(totalSpace - storageInfo.used_bytes);
storageInfo.children?.forEach((child, index) => {
if (child.used_bytes > 0) {
const space = this._bytesToGB(child.used_bytes);
segments.push({
value: space,
color: getGraphColorByIndex(index, computedStyles),
label: html`${this.hass.localize(
`ui.panel.config.storage.segments.${child.id}`
) ||
child.label ||
child.id}
<span style="color: var(--secondary-text-color)"
>${roundWithOneDecimal(space)} GB</span
>`,
});
}
});
} else {
segments.push({
value: usedSpaceGB,
color: "var(--primary-color)",
label: html`${this.hass.localize(
"ui.panel.config.storage.segments.used"
)}
<span style="color: var(--secondary-text-color)"
>${roundWithOneDecimal(usedSpaceGB)} GB</span
>`,
});
}
segments.push({
value: freeSpaceGB,
color:
"var(--ha-bar-background-color, var(--secondary-background-color))",
label: html`${this.hass.localize(
"ui.panel.config.storage.segments.free"
)}
<span style="color: var(--secondary-text-color)"
>${roundWithOneDecimal(freeSpaceGB)} GB</span
>`,
});
return html`<ha-segmented-bar
.heading=${this.hass.localize("ui.panel.config.storage.used_space")}
.description=${this.hass.localize(
"ui.panel.config.storage.detailed_description",
{
used: `${roundWithOneDecimal(usedSpaceGB)} GB`,
total: `${roundWithOneDecimal(totalSpaceGB)} GB`,
}
)}
.segments=${segments}
></ha-segmented-bar>
${!storageInfo || storageInfo === null
? html`<ha-alert alert-type="info">
<ha-spinner slot="icon"></ha-spinner>
${this.hass.localize(
"ui.panel.config.storage.loading_detailed"
)}</ha-alert
>`
: nothing}`;
}
);
private _bytesToGB(bytes: number) {
return bytes / 1024 / 1024 / 1024;
}
private _gbToBytes(GB: number) {
return GB * 1024 * 1024 * 1024;
}
private async _load() {
this._loadStorageInfo();
try {
@@ -523,10 +433,6 @@ class HaConfigSectionStorage extends LitElement {
ha-alert {
--ha-alert-icon-size: 24px;
}
ha-alert ha-spinner {
--ha-spinner-size: 24px;
}
`;
}

View File

@@ -0,0 +1,293 @@
import { mdiChartDonutVariant, mdiViewArray } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { getGraphColorByIndex } from "../../../common/color/colors";
import "../../../components/chart/ha-sunburst-chart";
import type { SunburstNode } from "../../../components/chart/ha-sunburst-chart";
import "../../../components/ha-alert";
import "../../../components/ha-icon-button";
import "../../../components/ha-segmented-bar";
import "../../../components/ha-spinner";
import type { Segment } from "../../../components/ha-segmented-bar";
import type { HassioHostInfo, HostDisksUsage } from "../../../data/hassio/host";
import type { HomeAssistant } from "../../../types";
import { roundWithOneDecimal } from "../../../util/calculate";
@customElement("storage-breakdown-chart")
export class StorageBreakdownChart extends LitElement {
@property({ attribute: false })
public hass!: HomeAssistant;
@property({ attribute: false })
public hostInfo?: HassioHostInfo;
@property({ attribute: false })
public storageInfo?: HostDisksUsage | null;
@state()
private _chartType: "bar" | "sunburst" = "bar";
protected render(): TemplateResult | typeof nothing {
if (!this.hostInfo) {
return nothing;
}
const { totalSpaceGB, usedSpaceGB, freeSpaceGB } = this._computeSpaceValues(
this.hostInfo,
this.storageInfo
);
const hasChildren = Boolean(this.storageInfo?.children?.length);
const heading = this.hass.localize("ui.panel.config.storage.used_space");
const description = this.hass.localize(
"ui.panel.config.storage.detailed_description",
{
used: `${roundWithOneDecimal(usedSpaceGB)} GB`,
total: `${roundWithOneDecimal(totalSpaceGB)} GB`,
}
);
const showBarChart = this._chartType === "bar" || !hasChildren;
return html`
<div class="header">
<div class="heading-text">
<span class="heading">${heading}</span>
<span class="description">${description}</span>
</div>
${hasChildren
? html`<ha-icon-button
.path=${this._chartType === "sunburst"
? mdiViewArray
: mdiChartDonutVariant}
.label=${this.hass.localize(
"ui.panel.config.storage.change_chart_type"
)}
@click=${this._handleChartTypeChange}
></ha-icon-button>`
: nothing}
</div>
<div class="chart-container ${this._chartType}">
${showBarChart
? html`<ha-segmented-bar
.heading=${""}
.segments=${this._computeSegments(
this.storageInfo,
usedSpaceGB,
freeSpaceGB
)}
></ha-segmented-bar>`
: html`<ha-sunburst-chart
.hass=${this.hass}
.data=${this._transformToSunburstData(this.storageInfo!)}
.valueFormatter=${this._formatBytes}
></ha-sunburst-chart>`}
</div>
${!this.storageInfo || this.storageInfo === null
? html`<ha-alert alert-type="info">
<ha-spinner slot="icon"></ha-spinner>
${this.hass.localize(
"ui.panel.config.storage.loading_detailed"
)}</ha-alert
>`
: nothing}
`;
}
private _handleChartTypeChange(): void {
this._chartType = this._chartType === "bar" ? "sunburst" : "bar";
}
private _computeSpaceValues = memoizeOne(
(
hostInfo: HassioHostInfo,
storageInfo: HostDisksUsage | null | undefined
) => {
let totalSpaceGB = hostInfo.disk_total;
let usedSpaceGB = hostInfo.disk_used;
let freeSpaceGB =
hostInfo.disk_free || hostInfo.disk_total - hostInfo.disk_used;
if (storageInfo) {
const totalSpace =
storageInfo.total_bytes ?? this._gbToBytes(hostInfo.disk_total);
totalSpaceGB = this._bytesToGB(totalSpace);
usedSpaceGB = this._bytesToGB(storageInfo.used_bytes);
freeSpaceGB = this._bytesToGB(totalSpace - storageInfo.used_bytes);
}
return { totalSpaceGB, usedSpaceGB, freeSpaceGB };
}
);
private _computeSegments = memoizeOne(
(
storageInfo: HostDisksUsage | null | undefined,
usedSpaceGB: number,
freeSpaceGB: number
): Segment[] => {
const computedStyles = getComputedStyle(this);
const segments: Segment[] = [];
if (storageInfo) {
storageInfo.children?.forEach((child, index) => {
if (child.used_bytes > 0) {
const space = this._bytesToGB(child.used_bytes);
segments.push({
value: space,
color: getGraphColorByIndex(index, computedStyles),
label: html`${this.hass.localize(
`ui.panel.config.storage.segments.${child.id}`
) ||
child.label ||
child.id}
<span style="color: var(--secondary-text-color)"
>${roundWithOneDecimal(space)} GB</span
>`,
});
}
});
} else {
segments.push({
value: usedSpaceGB,
color: "var(--primary-color)",
label: html`${this.hass.localize(
"ui.panel.config.storage.segments.used"
)}
<span style="color: var(--secondary-text-color)"
>${roundWithOneDecimal(usedSpaceGB)} GB</span
>`,
});
}
segments.push({
value: freeSpaceGB,
color:
"var(--ha-bar-background-color, var(--secondary-background-color))",
label: html`${this.hass.localize(
"ui.panel.config.storage.segments.free"
)}
<span style="color: var(--secondary-text-color)"
>${roundWithOneDecimal(freeSpaceGB)} GB</span
>`,
});
return segments;
}
);
private _transformToSunburstData = memoizeOne(
(storageInfo: HostDisksUsage): SunburstNode => {
const transform = (
node: HostDisksUsage,
parentNode?: HostDisksUsage
): SunburstNode => ({
// prefix with parent id to avoid duplicate ids
id: parentNode ? `${parentNode.id}.${node.id}` : node.id,
name: this._formatLabel(node.id) || node.label,
value: node.used_bytes,
children: node.children?.map((child) => transform(child, node)),
});
return transform(storageInfo);
}
);
private _formatBytes = (bytes: number): string => {
const gb = this._bytesToGB(bytes);
return `${roundWithOneDecimal(gb)} GB`;
};
private _formatLabel = (id: string): string =>
this.hass.localize(`ui.panel.config.storage.segments.${id}`) || id;
private _bytesToGB(bytes: number): number {
return bytes / 1024 / 1024 / 1024;
}
private _gbToBytes(GB: number): number {
return GB * 1024 * 1024 * 1024;
}
static styles = css`
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: var(--ha-space-2);
}
.heading-text {
display: flex;
flex-direction: column;
gap: var(--ha-space-1);
}
.heading {
font-weight: 500;
font-size: var(--ha-font-size-m);
color: var(--primary-text-color);
}
.description {
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
ha-icon-button {
--mdc-icon-button-size: 36px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
}
.chart-container {
transition: height var(--ha-animation-base-duration) ease;
overflow: hidden;
}
.chart-container.bar {
height: calc-size(auto, size);
}
.chart-container.sunburst {
height: 400px;
}
ha-segmented-bar {
display: block;
}
ha-sunburst-chart {
height: 400px;
}
ha-segmented-bar,
ha-sunburst-chart {
animation: fade-in var(--ha-animation-base-duration) ease;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
ha-alert {
--ha-alert-icon-size: 24px;
}
ha-alert ha-spinner {
--ha-spinner-size: 24px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"storage-breakdown-chart": StorageBreakdownChart;
}
}

View File

@@ -1,6 +1,7 @@
import {
mdiBug,
mdiCommentProcessingOutline,
mdiContentDuplicate,
mdiDotsVertical,
mdiHelpCircle,
mdiPlus,
@@ -189,6 +190,17 @@ export class AssistPref extends LitElement {
)}
<ha-svg-icon slot="graphic" .path=${mdiBug}></ha-svg-icon>
</ha-list-item>
<ha-list-item
graphic="icon"
.id=${pipeline.id}
@request-selected=${this._duplicatePipeline}
>
${this.hass.localize("ui.common.duplicate")}
<ha-svg-icon
slot="graphic"
.path=${mdiContentDuplicate}
></ha-svg-icon>
</ha-list-item>
<ha-list-item
class="danger"
graphic="icon"
@@ -294,6 +306,30 @@ export class AssistPref extends LitElement {
navigate(`/config/voice-assistants/debug/${id}`);
}
private async _duplicatePipeline(ev: Event) {
const id = (ev.currentTarget as HTMLElement).id as string;
const pipeline = this._pipelines.find((res) => res.id === id);
if (!pipeline) {
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.voice_assistants.assistants.pipeline.duplicate.error_pipeline_not_found"
),
});
return;
}
const { id: _id, ...pipelineWithoutId } = pipeline;
const newPipeline = {
...pipelineWithoutId,
name: this.hass.localize(
"ui.panel.config.voice_assistants.assistants.pipeline.duplicate.name",
{ name: pipeline.name }
),
};
this._openDialog(newPipeline);
}
private async _deletePipeline(ev) {
const id = ev.currentTarget.id as string;
if (this._preferred === id) {
@@ -337,7 +373,9 @@ export class AssistPref extends LitElement {
this._openDialog();
}
private async _openDialog(pipeline?: AssistPipeline): Promise<void> {
private async _openDialog(
pipeline?: AssistPipeline | Omit<AssistPipeline, "id">
): Promise<void> {
showVoiceAssistantPipelineDetailDialog(this, {
cloudActiveSubscription:
this.cloudStatus?.logged_in && this.cloudStatus.active_subscription,
@@ -346,16 +384,21 @@ export class AssistPref extends LitElement {
const created = await createAssistPipeline(this.hass!, values);
this._pipelines = this._pipelines!.concat(created);
},
updatePipeline: async (values) => {
const updated = await updateAssistPipeline(
this.hass!,
pipeline!.id,
values
);
this._pipelines = this._pipelines!.map((res) =>
res === pipeline ? updated : res
);
},
...(pipeline && "id" in pipeline
? {
updatePipeline: async (values) => {
const updated = await updateAssistPipeline(
this.hass,
pipeline.id,
values
);
const pipelineToUpdate = pipeline as AssistPipeline;
this._pipelines = this._pipelines!.map((res) =>
res.id === pipelineToUpdate.id ? updated : res
);
},
}
: {}),
});
}

View File

@@ -48,7 +48,11 @@ export class DialogVoiceAssistantPipelineDetail extends LitElement {
this._error = undefined;
this._cloudActive = this._params.cloudActiveSubscription;
if (this._params.pipeline) {
if (
this._params.pipeline &&
"id" in this._params.pipeline &&
this._params.pipeline.id
) {
this._data = { prefer_local_intents: false, ...this._params.pipeline };
this._hideWakeWord =
@@ -79,11 +83,15 @@ export class DialogVoiceAssistantPipelineDetail extends LitElement {
}
}
this._data = {
language: (
this.hass.config.language || this.hass.locale.language
).substring(0, 2),
stt_engine: sstDefault,
tts_engine: ttsDefault,
...(this._params.pipeline || {}),
language:
this._params.pipeline?.language ||
(this.hass.config.language || this.hass.locale.language).substring(
0,
2
),
stt_engine: this._params.pipeline?.stt_engine || sstDefault,
tts_engine: this._params.pipeline?.tts_engine || ttsDefault,
};
}
@@ -112,11 +120,17 @@ export class DialogVoiceAssistantPipelineDetail extends LitElement {
return nothing;
}
const title = this._params.pipeline?.id
? this._params.pipeline.name
: this.hass.localize(
"ui.panel.config.voice_assistants.assistants.pipeline.detail.add_assistant_title"
);
const isExistingPipeline =
this._params.pipeline &&
"id" in this._params.pipeline &&
!!this._params.pipeline.id;
const title =
isExistingPipeline && this._params.pipeline?.name
? this._params.pipeline.name
: this.hass.localize(
"ui.panel.config.voice_assistants.assistants.pipeline.detail.add_assistant_title"
);
return html`
<ha-dialog
@@ -166,7 +180,7 @@ export class DialogVoiceAssistantPipelineDetail extends LitElement {
.supportedLanguages=${this._supportedLanguages}
keys="name,language"
@value-changed=${this._valueChanged}
?dialogInitialFocus=${!this._params.pipeline?.id}
?dialogInitialFocus=${!isExistingPipeline}
></assist-pipeline-detail-config>
<assist-pipeline-detail-conversation
.hass=${this.hass}
@@ -217,7 +231,7 @@ export class DialogVoiceAssistantPipelineDetail extends LitElement {
.loading=${this._submitting}
dialogInitialFocus
>
${this._params.pipeline?.id
${isExistingPipeline
? this.hass.localize(
"ui.panel.config.voice_assistants.assistants.pipeline.detail.update_assistant_action"
)
@@ -263,7 +277,12 @@ export class DialogVoiceAssistantPipelineDetail extends LitElement {
wake_word_entity: data.wake_word_entity ?? null,
wake_word_id: data.wake_word_id ?? null,
};
if (this._params!.pipeline?.id) {
if (
this._params!.pipeline &&
"id" in this._params!.pipeline &&
!!this._params!.pipeline.id &&
this._params!.updatePipeline
) {
await this._params!.updatePipeline(values);
} else if (this._params!.createPipeline) {
await this._params!.createPipeline(values);

View File

@@ -6,9 +6,9 @@ import type {
export interface VoiceAssistantPipelineDetailsDialogParams {
cloudActiveSubscription?: boolean;
pipeline?: AssistPipeline;
pipeline?: AssistPipeline | Omit<AssistPipeline, "id">;
hideWakeWord?: boolean;
updatePipeline: (updates: AssistPipelineMutableParams) => Promise<unknown>;
updatePipeline?: (updates: AssistPipelineMutableParams) => Promise<unknown>;
createPipeline?: (values: AssistPipelineMutableParams) => Promise<unknown>;
}

View File

@@ -2,7 +2,9 @@ import { mdiDownload } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { navigate } from "../../common/navigate";
import type { LocalizeKeys } from "../../common/translations/localize";
import "../../components/ha-alert";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
@@ -22,7 +24,10 @@ import {
getSummedData,
} from "../../data/energy";
import type { LovelaceConfig } from "../../data/lovelace/config/types";
import type { LovelaceViewConfig } from "../../data/lovelace/config/view";
import {
isStrategyView,
type LovelaceViewConfig,
} from "../../data/lovelace/config/view";
import type { StatisticValue } from "../../data/recorder";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, PanelInfo } from "../../types";
@@ -33,7 +38,6 @@ import type { ExtraActionItem } from "../lovelace/hui-root";
import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container";
import type { LocalizeKeys } from "../../common/translations/localize";
export const DEFAULT_ENERGY_COLLECTION_KEY = "energy_dashboard";
@@ -48,6 +52,7 @@ const OVERVIEW_VIEW = {
strategy: {
type: "energy-overview",
collection_key: DEFAULT_ENERGY_COLLECTION_KEY,
show_period_selector: true,
},
} as LovelaceViewConfig;
@@ -56,6 +61,7 @@ const ENERGY_VIEW = {
strategy: {
type: "energy",
collection_key: DEFAULT_ENERGY_COLLECTION_KEY,
show_period_selector: true,
},
} as LovelaceViewConfig;
@@ -64,6 +70,7 @@ const WATER_VIEW = {
strategy: {
type: "water",
collection_key: DEFAULT_ENERGY_COLLECTION_KEY,
show_period_selector: true,
},
} as LovelaceViewConfig;
@@ -72,6 +79,7 @@ const GAS_VIEW = {
strategy: {
type: "gas",
collection_key: DEFAULT_ENERGY_COLLECTION_KEY,
show_period_selector: true,
},
} as LovelaceViewConfig;
@@ -223,6 +231,16 @@ class PanelEnergy extends LitElement {
return nothing;
}
const routePath = this.route?.path?.split("/")[1] || "";
const currentView = this._lovelace.config.views.find(
(view) => view.path === routePath
);
const showEnergySelector =
currentView &&
isStrategyView(currentView) &&
currentView.strategy?.show_period_selector;
return html`
<hui-root
.hass=${this.hass}
@@ -232,7 +250,20 @@ class PanelEnergy extends LitElement {
.panel=${this.panel}
.extraActionItems=${this._extraActionItems}
@reload-energy-panel=${this._reloadConfig}
></hui-root>
class=${classMap({ "has-period-selector": showEnergySelector })}
>
</hui-root>
${showEnergySelector
? html`
<ha-card class="period-selector">
<hui-energy-period-selector
.hass=${this.hass}
.collectionKey=${DEFAULT_ENERGY_COLLECTION_KEY}
vertical-opening-direction="up"
></hui-energy-period-selector>
</ha-card>
`
: nothing}
`;
}
@@ -631,24 +662,6 @@ class PanelEnergy extends LitElement {
-webkit-user-select: none;
-moz-user-select: none;
}
.toolbar {
height: var(--header-height);
display: flex;
flex: 1;
align-items: center;
font-size: var(--ha-font-size-xl);
padding: 0px 12px;
font-weight: var(--ha-font-weight-normal);
box-sizing: border-box;
}
:host([narrow]) .toolbar {
padding: 0 4px;
}
.main-title {
margin: var(--margin-title);
line-height: var(--ha-line-height-normal);
flex-grow: 1;
}
.centered {
width: 100%;
height: 100%;
@@ -656,6 +669,45 @@ class PanelEnergy extends LitElement {
align-items: center;
justify-content: center;
}
hui-root.has-period-selector {
--view-container-padding-bottom: var(--ha-space-18);
}
.period-selector {
position: fixed;
z-index: 4;
bottom: max(var(--ha-space-4), var(--safe-area-inset-bottom, 0px));
left: max(
var(--mdc-drawer-width, 0px),
var(--safe-area-inset-left, 0px)
);
right: var(--safe-area-inset-right, 0);
inset-inline-start: max(
var(--mdc-drawer-width, 0px),
var(--safe-area-inset-left, 0px)
);
inset-inline-end: var(--safe-area-inset-right, 0);
margin: 0 auto;
max-width: calc(min(450px, 100% - var(--ha-space-4)));
box-sizing: border-box;
padding-left: var(--ha-space-2);
padding-right: 0;
padding-inline-start: var(--ha-space-4);
padding-inline-end: 0;
--ha-card-box-shadow:
0px 3px 5px -1px rgba(0, 0, 0, 0.2),
0px 6px 10px 0px rgba(0, 0, 0, 0.14),
0px 1px 18px 0px rgba(0, 0, 0, 0.12);
--ha-card-border-color: var(--divider-color);
--ha-card-border-width: var(--ha-card-border-width, 1px);
}
@media all and (max-width: 450px), all and (max-height: 500px) {
hui-root.has-period-selector {
--view-container-padding-bottom: var(--ha-space-14);
}
.period-selector {
bottom: max(var(--ha-space-2), var(--safe-area-inset-bottom, 0px));
}
}
`,
];
}

View File

@@ -5,7 +5,6 @@ import { getEnergyDataCollection } from "../../../data/energy";
import type { HomeAssistant } from "../../../types";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../ha-panel-energy";
@customElement("energy-overview-view-strategy")
@@ -64,24 +63,20 @@ export class EnergyOverviewViewStrategy extends ReactiveElement {
(source.type === "grid" && source.power?.length)
);
const overviewSection: LovelaceSectionConfig = {
type: "grid",
cards: [
{
type: "energy-date-selection",
collection_key: collectionKey,
allow_compare: false,
},
],
};
if (hasGrid || hasBattery || hasSolar) {
overviewSection.cards!.push({
title: hass.localize("ui.panel.energy.cards.energy_distribution_title"),
type: "energy-distribution",
collection_key: collectionKey,
view.sections!.push({
type: "grid",
cards: [
{
title: hass.localize(
"ui.panel.energy.cards.energy_distribution_title"
),
type: "energy-distribution",
collection_key: collectionKey,
},
],
});
}
view.sections!.push(overviewSection);
if (prefs.energy_sources.length) {
view.sections!.push({

View File

@@ -53,10 +53,6 @@ export class EnergyViewStrategy extends ReactiveElement {
(d) => d.included_in_stat
);
view.cards!.push({
type: "energy-date-selection",
collection_key: collectionKey,
});
view.cards!.push({
type: "energy-compare",
collection_key: "energy_dashboard",

View File

@@ -40,10 +40,6 @@ export class GasViewStrategy extends ReactiveElement {
const section = view.sections![0] as LovelaceSectionConfig;
section.cards!.push({
type: "energy-date-selection",
collection_key: collectionKey,
});
section.cards!.push({
type: "energy-compare",
collection_key: collectionKey,

View File

@@ -41,10 +41,6 @@ export class WaterViewStrategy extends ReactiveElement {
const section = view.sections![0] as LovelaceSectionConfig;
section.cards!.push({
type: "energy-date-selection",
collection_key: collectionKey,
});
section.cards!.push({
type: "energy-compare",
collection_key: collectionKey,

View File

@@ -12,7 +12,7 @@ import type { LovelaceDashboardStrategyConfig } from "../../data/lovelace/config
import type { HomeAssistant, PanelInfo, Route } from "../../types";
import { showToast } from "../../util/toast";
import "../lovelace/hui-root";
import { generateLovelaceDashboardStrategy } from "../lovelace/strategies/get-strategy";
import { expandLovelaceConfigStrategies } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import { showEditHomeDialog } from "./dialogs/show-dialog-edit-home";
@@ -34,8 +34,7 @@ class PanelHome extends LitElement {
super.willUpdate(changedProps);
// Initial setup
if (!this.hasUpdated) {
this.hass.loadFragmentTranslation("lovelace");
this._loadConfig();
this._setup();
return;
}
@@ -72,6 +71,21 @@ class PanelHome extends LitElement {
}
}
private async _setup() {
try {
const [_, data] = await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
fetchFrontendSystemData(this.hass.connection, "home"),
]);
this._config = data || {};
} catch (err) {
// eslint-disable-next-line no-console
console.error("Failed to load favorites:", err);
this._config = {};
}
this._setLovelace();
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200
@@ -97,18 +111,6 @@ class PanelHome extends LitElement {
`;
}
private async _loadConfig() {
try {
const data = await fetchFrontendSystemData(this.hass.connection, "home");
this._config = data || {};
} catch (err) {
// eslint-disable-next-line no-console
console.error("Failed to load favorites:", err);
this._config = {};
}
this._setLovelace();
}
private async _setLovelace() {
const strategyConfig: LovelaceDashboardStrategyConfig = {
strategy: {
@@ -117,7 +119,7 @@ class PanelHome extends LitElement {
},
};
const config = await generateLovelaceDashboardStrategy(
const config = await expandLovelaceConfigStrategies(
strategyConfig,
this.hass
);

View File

@@ -37,8 +37,7 @@ class PanelLight extends LitElement {
super.willUpdate(changedProps);
// Initial setup
if (!this.hasUpdated) {
this.hass.loadFragmentTranslation("lovelace");
this._setLovelace();
this._setup();
return;
}
@@ -75,6 +74,11 @@ class PanelLight extends LitElement {
}
}
private async _setup() {
await this.hass.loadFragmentTranslation("lovelace");
this._setLovelace();
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200

View File

@@ -49,6 +49,10 @@ const processAreasForLight = (
heading_style: "subtitle",
type: "heading",
heading: area.name,
tap_action: {
action: "navigate",
navigation_path: `/home/areas-${area.area_id}`,
},
});
cards.push(...areaCards);
}

View File

@@ -295,32 +295,41 @@ export function fillDataGapsAndRoundCaps(datasets: BarSeriesOption[]) {
});
}
function getDatapointX(datapoint: NonNullable<LineSeriesOption["data"]>[0]) {
const item =
datapoint && typeof datapoint === "object" && "value" in datapoint
? datapoint
: { value: datapoint };
return Number(item.value?.[0]);
}
export function fillLineGaps(datasets: LineSeriesOption[]) {
const buckets = Array.from(
new Set(
datasets
.map((dataset) =>
dataset.data!.map((datapoint) => Number(datapoint![0]))
dataset.data!.map((datapoint) => getDatapointX(datapoint))
)
.flat()
)
).sort((a, b) => a - b);
buckets.forEach((bucket, index) => {
for (let i = datasets.length - 1; i >= 0; i--) {
const dataPoint = datasets[i].data![index];
datasets.forEach((dataset) => {
const dataMap = new Map<number, LineDataItemOption>();
dataset.data!.forEach((datapoint) => {
const item: LineDataItemOption =
dataPoint && typeof dataPoint === "object" && "value" in dataPoint
? dataPoint
: ({ value: dataPoint } as LineDataItemOption);
const x = item.value?.[0];
if (x === undefined) {
continue;
datapoint && typeof datapoint === "object" && "value" in datapoint
? datapoint
: ({ value: datapoint } as LineDataItemOption);
const x = getDatapointX(datapoint);
if (!Number.isNaN(x)) {
dataMap.set(x, item);
}
if (Number(x) !== bucket) {
datasets[i].data?.splice(index, 0, [bucket, 0]);
}
}
});
dataset.data = buckets.map((bucket) => dataMap.get(bucket) ?? [bucket, 0]);
});
return datasets;
}

View File

@@ -7,6 +7,7 @@ import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
import {
formatPowerShort,
getEnergyDataCollection,
getPowerFromState,
} from "../../../../data/energy";
@@ -17,7 +18,6 @@ import type { PowerSankeyCardConfig } 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 { formatNumber } from "../../../../common/number/format_number";
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
import { MobileAwareMixin } from "../../../../mixins/mobile-aware-mixin";
@@ -26,8 +26,8 @@ const DEFAULT_CONFIG: Partial<PowerSankeyCardConfig> = {
group_by_area: true,
};
// Minimum power threshold in kW to display a device node
const MIN_POWER_THRESHOLD = 0.01;
// Minimum power threshold in watts (W) to display a device node
const MIN_POWER_THRESHOLD = 10;
interface PowerData {
solar: number;
@@ -472,8 +472,8 @@ class HuiPowerSankeyCard
private _valueFormatter = (value: number) =>
`<div style="direction:ltr; display: inline;">
${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)}
kW</div>`;
${formatPowerShort(this.hass, value)}
</div>`;
/**
* Compute real-time power data from current entity states.
@@ -719,14 +719,15 @@ class HuiPowerSankeyCard
}
/**
* Get current power value from entity state, normalized to kW
* Get current power value from entity state, normalized to watts (W)
* @param entityId - The entity ID to get power value from
* @returns Power value in kW, or 0 if entity not found or invalid
* @returns Power value in W, or 0 if entity not found or invalid
*/
private _getCurrentPower(entityId: string): number {
// Track this entity for state change detection
this._entities.add(entityId);
// getPowerFromState returns power in W
return getPowerFromState(this.hass.states[entityId]) ?? 0;
}

View File

@@ -210,9 +210,14 @@ export class HuiPowerSourcesGraphCard
const { positive, negative } = this._processData(
statIds[key].stats.map((id: string) => {
const stats = energyData.stats[id] ?? [];
const currentState = getPowerFromState(this.hass.states[id]);
if (currentState !== undefined) {
stats.push({ start: now, end: now, mean: currentState });
const currentStateWatts = getPowerFromState(this.hass.states[id]);
if (currentStateWatts !== undefined) {
// getPowerFromState returns power in W; convert to kW for this graph
stats.push({
start: now,
end: now,
mean: currentStateWatts / 1000,
});
}
return stats;
})

View File

@@ -165,15 +165,13 @@ export class HuiEntityCard extends LitElement implements LovelaceCard {
<span class="value"
>${"attribute" in this._config
? stateObj.attributes[this._config.attribute!] !== undefined
? html`
<ha-attribute-value
hide-unit
.hass=${this.hass}
.stateObj=${stateObj}
.attribute=${this._config.attribute!}
>
</ha-attribute-value>
`
? html`<ha-attribute-value
hide-unit
.hass=${this.hass}
.stateObj=${stateObj}
.attribute=${this._config.attribute!}
>
</ha-attribute-value>`
: this.hass.localize("state.default.unknown")
: (isNumericState(stateObj) || this._config.unit) &&
stateObj.attributes.device_class !== "duration"

View File

@@ -176,6 +176,10 @@ class HuiMapCard extends LitElement implements LovelaceCard {
return { type: "map", entities: foundEntities, theme_mode: "auto" };
}
protected firstUpdated() {
this._mapEntities = this._getMapEntities();
}
protected render() {
if (!this._config) {
return nothing;

View File

@@ -17,6 +17,7 @@ import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { classMap } from "lit/directives/class-map";
import {
calcDate,
calcDateProperty,
@@ -69,6 +70,11 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
@property({ type: Boolean, attribute: "allow-compare" }) public allowCompare =
true;
@property({ attribute: "vertical-opening-direction" })
public verticalOpeningDirection?: "up" | "down";
@state() _datepickerOpen = false;
@state() _startDate?: Date;
@state() _endDate?: Date;
@@ -150,7 +156,13 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
);
return html`
<div class="row">
<div
class=${classMap({
row: true,
"datepicker-open": this._datepickerOpen,
})}
>
<div class="backdrop"></div>
<div class="label">
${simpleRange === "day"
? this.narrow
@@ -202,8 +214,10 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
.ranges=${this._ranges}
@value-changed=${this._dateRangeChanged}
@preset-selected=${this._presetSelected}
@toggle=${this._handleDatepickerToggle}
minimal
header-position
.verticalOpeningDirection=${this.verticalOpeningDirection}
></ha-date-range-picker>
</div>
@@ -446,6 +460,10 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
this._endDate = energyData.end || endOfToday();
}
private _handleDatepickerToggle(ev: CustomEvent<{ open: boolean }>) {
this._datepickerOpen = ev.detail.open;
}
private _toggleCompare(ev: CustomEvent<RequestSelectedDetail>) {
if (ev.detail.source !== "interaction") {
return;
@@ -496,6 +514,29 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
flex-shrink: 0;
--ha-button-theme-color: currentColor;
}
.backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: var(--dialog-z-index, 8);
-webkit-backdrop-filter: var(
--ha-dialog-scrim-backdrop-filter,
var(--dialog-backdrop-filter)
);
backdrop-filter: var(
--ha-dialog-scrim-backdrop-filter,
var(--dialog-backdrop-filter)
);
pointer-events: none;
opacity: 0;
transition: opacity var(--ha-animation-base-duration) ease-in-out;
}
.datepicker-open .backdrop {
opacity: 1;
pointer-events: auto;
}
`;
}

View File

@@ -5,14 +5,17 @@ import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../../../common/dom/fire_event";
import { preventDefault } from "../../../../common/dom/prevent_default";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import "../../../../components/entity/ha-entity-picker";
import type { HaEntityPicker } from "../../../../components/entity/ha-entity-picker";
import "../../../../components/ha-button";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-sortable";
import "../../../../components/ha-svg-icon";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceHeadingBadgeConfig } from "../../heading-badges/types";
import type {
EntityHeadingBadgeConfig,
LovelaceHeadingBadgeConfig,
} from "../../heading-badges/types";
declare global {
interface HASSDomEvents {
@@ -38,20 +41,8 @@ export class HuiHeadingBadgesEditor extends LitElement {
return this._badgesKeys.get(badge)!;
}
private _computeBadgeLabel(badge: LovelaceHeadingBadgeConfig) {
const type = badge.type ?? "entity";
if (type === "entity") {
const entityId = "entity" in badge ? (badge.entity as string) : undefined;
const stateObj = entityId ? this.hass.states[entityId] : undefined;
return (
(stateObj && computeStateName(stateObj)) ||
entityId ||
type ||
"Unknown badge"
);
}
return type;
private _createValueChangedHandler(index: number) {
return (ev: CustomEvent) => this._valueChanged(ev, index);
}
protected render() {
@@ -71,7 +62,12 @@ export class HuiHeadingBadgesEditor extends LitElement {
this.badges,
(badge) => this._getKey(badge),
(badge, index) => {
const label = this._computeBadgeLabel(badge);
const type = badge.type ?? "entity";
const isEntityBadge =
type === "entity" && "entity" in badge;
const entityBadge = isEntityBadge
? (badge as EntityHeadingBadgeConfig)
: undefined;
return html`
<div class="badge">
<div class="handle">
@@ -79,9 +75,23 @@ export class HuiHeadingBadgesEditor extends LitElement {
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</div>
<div class="badge-content">
<span>${label}</span>
</div>
${isEntityBadge && entityBadge
? html`
<ha-entity-picker
allow-custom-entity
hide-clear-icon
.hass=${this.hass}
.value=${entityBadge.entity ?? ""}
@value-changed=${this._createValueChangedHandler(
index
)}
></ha-entity-picker>
`
: html`
<div class="badge-content">
<span>${type}</span>
</div>
`}
<ha-icon-button
.label=${this.hass!.localize(
`ui.panel.lovelace.editor.entities.edit`
@@ -128,7 +138,7 @@ export class HuiHeadingBadgesEditor extends LitElement {
`;
}
private _entityPicked(ev) {
private _entityPicked(ev: CustomEvent): void {
ev.stopPropagation();
if (!ev.detail.value) {
return;
@@ -137,15 +147,32 @@ export class HuiHeadingBadgesEditor extends LitElement {
type: "entity",
entity: ev.detail.value,
};
const newBadges = (this.badges || []).concat(newEntity);
const newBadges = [...(this.badges || []), newEntity];
(ev.target as HaEntityPicker).value = undefined;
fireEvent(this, "heading-badges-changed", { badges: newBadges });
}
private _valueChanged(ev: CustomEvent, index: number): void {
ev.stopPropagation();
const value = ev.detail.value;
const newBadges = [...(this.badges || [])];
if (!value) {
newBadges.splice(index, 1);
} else {
newBadges[index] = {
...newBadges[index],
entity: value,
};
}
fireEvent(this, "heading-badges-changed", { badges: newBadges });
}
private _badgeMoved(ev: CustomEvent): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const newBadges = (this.badges || []).concat();
const newBadges = [...(this.badges || [])];
newBadges.splice(newIndex, 0, newBadges.splice(oldIndex, 1)[0]);
@@ -154,7 +181,7 @@ export class HuiHeadingBadgesEditor extends LitElement {
private _removeEntity(ev: CustomEvent): void {
const index = (ev.currentTarget as any).index;
const newBadges = (this.badges || []).concat();
const newBadges = [...(this.badges || [])];
newBadges.splice(index, 1);
@@ -174,8 +201,15 @@ export class HuiHeadingBadgesEditor extends LitElement {
flex-direction: column;
}
ha-button {
margin-top: 8px;
margin-top: var(--ha-space-2);
}
.entities {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.badge {
display: flex;
align-items: center;
@@ -183,8 +217,8 @@ export class HuiHeadingBadgesEditor extends LitElement {
.badge .handle {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
padding-right: 8px;
padding-inline-end: 8px;
padding-right: var(--ha-space-2);
padding-inline-end: var(--ha-space-2);
padding-inline-start: initial;
direction: var(--direction);
}
@@ -206,6 +240,12 @@ export class HuiHeadingBadgesEditor extends LitElement {
flex-direction: column;
}
.badge ha-entity-picker {
flex-grow: 1;
min-width: 0;
margin-top: 0;
}
.remove-icon,
.edit-icon {
--mdc-icon-button-size: 36px;
@@ -224,6 +264,7 @@ export class HuiHeadingBadgesEditor extends LitElement {
.add-container {
position: relative;
width: 100%;
margin-top: var(--ha-space-2);
}
mwc-menu-surface {

View File

@@ -228,7 +228,7 @@ export class HuiHeadingCardEditor
}
ha-form {
display: block;
margin-bottom: 24px;
margin-bottom: var(--ha-space-6);
}
`,
];

View File

@@ -1,13 +1,10 @@
import { mdiClose } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, query, state } from "lit/decorators";
import { customElement, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-wa-dialog";
import "../../../../components/ha-md-select";
import "../../../../components/ha-md-select-option";
import "../../../../components/ha-spinner";
@@ -18,6 +15,7 @@ import { getDefaultPanelUrlPath } from "../../../../data/panel";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { SelectDashboardDialogParams } from "./show-select-dashboard-dialog";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
@customElement("hui-dialog-select-dashboard")
export class HuiDialogSelectDashboard extends LitElement {
@@ -35,25 +33,29 @@ export class HuiDialogSelectDashboard extends LitElement {
@state() private _saving = false;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
@state() private _open = false;
public showDialog(params: SelectDashboardDialogParams): void {
this._config = params.lovelaceConfig;
this._fromUrlPath = params.urlPath;
this._params = params;
this._open = true;
this._getDashboards();
}
public closeDialog(): void {
if (this._open) {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
this._saving = false;
this._dashboards = undefined;
this._toUrlPath = undefined;
this._dialog?.close();
this._open = false;
this._params = undefined;
}
private _dialogClosed(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
this.closeDialog();
}
protected render() {
@@ -66,52 +68,42 @@ export class HuiDialogSelectDashboard extends LitElement {
this.hass.localize("ui.panel.lovelace.editor.select_dashboard.header");
return html`
<ha-md-dialog
open
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${dialogTitle}
.preventScrimClose=${this._saving}
@closed=${this._dialogClosed}
.ariaLabel=${dialogTitle}
.disableCancelAction=${this._saving}
>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
@click=${this.closeDialog}
.disabled=${this._saving}
></ha-icon-button>
<span slot="title" .title=${dialogTitle}>${dialogTitle}</span>
</ha-dialog-header>
<div slot="content">
${this._dashboards && !this._saving
? html`
<ha-md-select
.label=${this.hass.localize(
"ui.panel.lovelace.editor.select_view.dashboard_label"
)}
@change=${this._dashboardChanged}
.value=${this._toUrlPath || ""}
>
${this._dashboards.map(
(dashboard) => html`
<ha-md-select-option
.disabled=${dashboard.mode !== "storage" ||
dashboard.url_path === this._fromUrlPath ||
(dashboard.url_path === "lovelace" &&
this._fromUrlPath === null)}
.value=${dashboard.url_path}
>${dashboard.title}</ha-md-select-option
>
`
)}
</ha-md-select>
`
: html`<div class="loading">
<ha-spinner size="medium"></ha-spinner>
</div>`}
</div>
<div slot="actions">
${this._dashboards && !this._saving
? html`
<ha-md-select
.label=${this.hass.localize(
"ui.panel.lovelace.editor.select_view.dashboard_label"
)}
@change=${this._dashboardChanged}
.value=${this._toUrlPath || ""}
>
${this._dashboards.map(
(dashboard) => html`
<ha-md-select-option
.disabled=${dashboard.mode !== "storage" ||
dashboard.url_path === this._fromUrlPath ||
(dashboard.url_path === "lovelace" &&
this._fromUrlPath === null)}
.value=${dashboard.url_path}
>${dashboard.title}</ha-md-select-option
>
`
)}
</ha-md-select>
`
: html`<div class="loading">
<ha-spinner size="medium"></ha-spinner>
</div>`}
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this.closeDialog}
.disabled=${this._saving}
@@ -119,6 +111,7 @@ export class HuiDialogSelectDashboard extends LitElement {
${this.hass!.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._selectDashboard}
.disabled=${!this._config ||
this._fromUrlPath === this._toUrlPath ||
@@ -126,12 +119,31 @@ export class HuiDialogSelectDashboard extends LitElement {
>
${this._params.actionLabel || this.hass!.localize("ui.common.move")}
</ha-button>
</div>
</ha-md-dialog>
</ha-dialog-footer>
</ha-wa-dialog>
`;
}
private async _getDashboards() {
let dashboards: LovelaceDashboard[] | undefined = this._params!.dashboards;
if (!dashboards) {
try {
dashboards = await fetchDashboards(this.hass);
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error fetching dashboards:", error);
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.lovelace.editor.select_dashboard.error_title"
),
text: this.hass.localize(
"ui.panel.lovelace.editor.select_dashboard.error_text"
),
});
}
}
this._dashboards = [
{
id: "lovelace",
@@ -141,7 +153,7 @@ export class HuiDialogSelectDashboard extends LitElement {
title: this.hass.localize("ui.common.default"),
mode: this.hass.panels.lovelace?.config?.mode,
},
...(this._params!.dashboards || (await fetchDashboards(this.hass))),
...(dashboards ?? []),
];
const defaultPanel = getDefaultPanelUrlPath(this.hass);

View File

@@ -26,6 +26,7 @@ import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { UndoRedoController } from "../../common/controllers/undo-redo-controller";
import { fireEvent } from "../../common/dom/fire_event";
import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event";
import { goBack, navigate } from "../../common/navigate";
@@ -37,7 +38,6 @@ import {
removeSearchParam,
} from "../../common/url/search-params";
import { debounce } from "../../common/util/debounce";
import { isMobileClient } from "../../util/is_mobile";
import { afterNextRender } from "../../common/util/render-status";
import "../../components/ha-button";
import "../../components/ha-button-menu";
@@ -81,6 +81,7 @@ import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, PanelInfo } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import { isMobileClient } from "../../util/is_mobile";
import { showToast } from "../../util/toast";
import { showAreaRegistryDetailDialog } from "../config/areas/show-dialog-area-registry-detail";
import { showNewAutomationDialog } from "../config/automation/show-dialog-new-automation";
@@ -98,7 +99,6 @@ import "./views/hui-view";
import type { HUIView } from "./views/hui-view";
import "./views/hui-view-background";
import "./views/hui-view-container";
import { UndoRedoController } from "../../common/controllers/undo-redo-controller";
interface ActionItem {
icon: string;
@@ -1543,7 +1543,10 @@ class HUIRoot extends LitElement {
padding-top: calc(var(--header-height) + var(--safe-area-inset-top));
padding-right: var(--safe-area-inset-right);
padding-inline-end: var(--safe-area-inset-right);
padding-bottom: var(--safe-area-inset-bottom);
padding-bottom: calc(
var(--safe-area-inset-bottom) +
var(--view-container-padding-bottom, 0px)
);
}
.narrow hui-view-container {
padding-left: var(--safe-area-inset-left);

View File

@@ -99,7 +99,7 @@ export class GridSection extends LitElement implements LovelaceSectionElement {
@item-removed=${this._cardRemoved}
invert-swap
>
<div class="container">
<div class="container ${classMap({ "edit-mode": editMode })}">
${repeat(
cardsConfig,
(cardConfig) => this._getKey(cardConfig),

View File

@@ -8,7 +8,6 @@ import {
} from "../../../../common/entity/entity_filter";
import { clamp } from "../../../../common/number/clamp";
import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
@@ -27,23 +26,6 @@ export interface HomeAreaViewStrategyConfig {
area?: string;
}
const computeHeadingCard = (
heading: string,
icon: string,
navigation_path?: string
): LovelaceCardConfig =>
({
type: "heading",
heading: heading,
icon: icon,
tap_action: navigation_path
? {
action: "navigate",
navigation_path,
}
: undefined,
}) satisfies HeadingCardConfig;
@customElement("home-area-view-strategy")
export class HomeAreaViewStrategy extends ReactiveElement {
static async generate(
@@ -112,11 +94,15 @@ export class HomeAreaViewStrategy extends ReactiveElement {
sections.push({
type: "grid",
cards: [
computeHeadingCard(
getSummaryLabel(hass.localize, "light"),
HOME_SUMMARIES_ICONS.light,
"/light?historyBack=1"
),
{
type: "heading",
heading: getSummaryLabel(hass.localize, "light"),
icon: HOME_SUMMARIES_ICONS.light,
tap_action: {
action: "navigate",
navigation_path: "/light?historyBack=1",
},
} satisfies HeadingCardConfig,
...light.map(computeTileCard),
],
});
@@ -126,11 +112,15 @@ export class HomeAreaViewStrategy extends ReactiveElement {
sections.push({
type: "grid",
cards: [
computeHeadingCard(
getSummaryLabel(hass.localize, "climate"),
HOME_SUMMARIES_ICONS.climate,
"/climate?historyBack=1"
),
{
type: "heading",
heading: getSummaryLabel(hass.localize, "climate"),
icon: HOME_SUMMARIES_ICONS.climate,
tap_action: {
action: "navigate",
navigation_path: "/climate?historyBack=1",
},
} satisfies HeadingCardConfig,
...climate.map(computeTileCard),
],
});
@@ -140,11 +130,15 @@ export class HomeAreaViewStrategy extends ReactiveElement {
sections.push({
type: "grid",
cards: [
computeHeadingCard(
getSummaryLabel(hass.localize, "security"),
HOME_SUMMARIES_ICONS.security,
"/security?historyBack=1"
),
{
type: "heading",
heading: getSummaryLabel(hass.localize, "security"),
icon: HOME_SUMMARIES_ICONS.security,
tap_action: {
action: "navigate",
navigation_path: "/security?historyBack=1",
},
} satisfies HeadingCardConfig,
...security.map(computeTileCard),
],
});
@@ -154,11 +148,15 @@ export class HomeAreaViewStrategy extends ReactiveElement {
sections.push({
type: "grid",
cards: [
computeHeadingCard(
getSummaryLabel(hass.localize, "media_players"),
HOME_SUMMARIES_ICONS.media_players,
"/media-players"
),
{
type: "heading",
heading: getSummaryLabel(hass.localize, "media_players"),
icon: HOME_SUMMARIES_ICONS.media_players,
tap_action: {
action: "navigate",
navigation_path: "media-players?historyBack=1",
},
} satisfies HeadingCardConfig,
...mediaPlayers.map(computeTileCard),
],
});
@@ -179,11 +177,17 @@ export class HomeAreaViewStrategy extends ReactiveElement {
sections.push({
type: "grid",
cards: [
computeHeadingCard(
hass.localize("ui.panel.lovelace.strategy.home.scenes"),
"mdi:palette",
"/config/scene/dashboard"
),
{
type: "heading",
heading: hass.localize("ui.panel.lovelace.strategy.home.scenes"),
icon: "mdi:palette",
tap_action: hass.user?.is_admin
? {
action: "navigate",
navigation_path: "/config/scene/dashboard",
}
: undefined,
} satisfies HeadingCardConfig,
...scenes.map(computeTileCard),
],
});
@@ -285,12 +289,13 @@ export class HomeAreaViewStrategy extends ReactiveElement {
{
type: "heading",
heading: heading,
tap_action: device
? {
action: "navigate",
navigation_path: `/config/devices/device/${device.id}`,
}
: undefined,
tap_action:
hass.user?.is_admin && device
? {
action: "navigate",
navigation_path: `/config/devices/device/${device.id}`,
}
: undefined,
badges: [
...batteryEntities.slice(0, 1).map((e) => ({
entity: e,
@@ -331,11 +336,19 @@ export class HomeAreaViewStrategy extends ReactiveElement {
sections.push({
type: "grid",
cards: [
computeHeadingCard(
hass.localize("ui.panel.lovelace.strategy.home.automations"),
"mdi:robot",
"/config/automation/dashboard"
),
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.home.automations"
),
icon: "mdi:robot",
tap_action: hass.user?.is_admin
? {
action: "navigate",
navigation_path: "/config/automation/dashboard",
}
: undefined,
} satisfies HeadingCardConfig,
...automations.map(computeTileCard),
],
});

View File

@@ -37,8 +37,7 @@ class PanelSecurity extends LitElement {
super.willUpdate(changedProps);
// Initial setup
if (!this.hasUpdated) {
this.hass.loadFragmentTranslation("lovelace");
this._setLovelace();
this._setup();
return;
}
@@ -75,6 +74,11 @@ class PanelSecurity extends LitElement {
}
}
private async _setup() {
await this.hass.loadFragmentTranslation("lovelace");
this._setLovelace();
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200

View File

@@ -91,6 +91,10 @@ const processAreasForSecurity = (
heading_style: "subtitle",
type: "heading",
heading: area.name,
tap_action: {
action: "navigate",
navigation_path: `/home/areas-${area.area_id}`,
},
});
cards.push(...areaCards);
}

View File

@@ -36,6 +36,7 @@ import type {
CustomSeriesOption,
SankeySeriesOption,
GraphSeriesOption,
SunburstSeriesOption,
} from "echarts/charts";
import type {
// The component option types are defined with the ComponentOption suffix
@@ -61,6 +62,7 @@ export type ECOption = ComposeOption<
| VisualMapComponentOption
| SankeySeriesOption
| GraphSeriesOption
| SunburstSeriesOption
>;
// Register the required components

View File

@@ -17,6 +17,7 @@ const DEFAULT_OPTIONS: IFuseOptions<any> = {
isCaseSensitive: false,
threshold: 0.3,
minMatchCharLength: 2,
ignoreLocation: true, // don't care where the pattern is
};
const DEFAULT_MIN_CHAR_LENGTH = 2;

View File

@@ -48,6 +48,9 @@ export const mainStyles = css`
*/
--safe-width: calc(100vw - var(--safe-area-inset-left) - var(--safe-area-inset-right));
--safe-height: calc(100vh - var(--safe-area-inset-top) - var(--safe-area-inset-bottom));
/* dialog backdrop filter */
--ha-dialog-scrim-backdrop-filter: brightness(68%);
}
`;

View File

@@ -47,9 +47,6 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
case "d":
this._showQuickBar(ev.detail, QuickBarMode.Device);
break;
case "i":
this._showQuickBar(ev.detail, QuickBarMode.Integration);
break;
case "m":
this._createMyLink(ev.detail);
break;
@@ -73,9 +70,6 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
m: { handler: (ev) => this._createMyLink(ev) },
a: { handler: (ev) => this._showVoiceCommandDialog(ev) },
d: { handler: (ev) => this._showQuickBar(ev, QuickBarMode.Device) },
i: {
handler: (ev) => this._showQuickBar(ev, QuickBarMode.Integration),
},
// Workaround see https://github.com/jamiebuilds/tinykeys/issues/130
"Shift+?": { handler: (ev) => this._showShortcutDialog(ev) },
// Those are fallbacks for non-latin keyboards that don't have e, c, m keys (qwerty-based shortcuts)
@@ -84,9 +78,6 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
KeyM: { handler: (ev) => this._createMyLink(ev) },
KeyA: { handler: (ev) => this._showVoiceCommandDialog(ev) },
KeyD: { handler: (ev) => this._showQuickBar(ev, QuickBarMode.Device) },
KeyI: {
handler: (ev) => this._showQuickBar(ev, QuickBarMode.Integration),
},
});
}

View File

@@ -769,6 +769,9 @@
"no_match": "No languages found for {term}",
"no_languages": "No languages available"
},
"icon-picker": {
"no_match": "No matching icons found"
},
"tts-picker": {
"tts": "Text-to-speech",
"none": "None"
@@ -1407,7 +1410,6 @@
},
"filter_placeholder": "Search entities",
"filter_placeholder_devices": "Search devices",
"filter_placeholder_integrations": "Search integrations",
"title": "Quick search",
"key_c_tip": "[%key:ui::tips::key_c_tip%]",
"nothing_found": "Nothing found!"
@@ -3689,6 +3691,10 @@
},
"no_cloud_message": "You should have an active cloud subscription to use cloud speech services.",
"no_cloud_action": "Subscribe"
},
"duplicate": {
"error_pipeline_not_found": "Pipeline not found",
"name": "{name} (Copy)"
}
},
"cloud": {
@@ -4074,6 +4080,13 @@
"services": "Services",
"helpers": "Helpers",
"entity_hidden": "[%key:ui::panel::config::devices::entities::hidden%]",
"target_summary": {
"no_target": "No target set",
"targets": "{count} {count, plural,\n one {target}\n other {targets}\n}",
"invalid": "Invalid target",
"all_entities": "All entities",
"none_entities": "No entities"
},
"triggers": {
"name": "Triggers",
"header": "When",
@@ -4585,11 +4598,11 @@
"service": "Perform an action",
"target_template": "templated {name}",
"target_every_entity": "every entity",
"target_unknown_entity": "unknown entity",
"target_unknown_device": "unknown device",
"target_unknown_area": "unknown area",
"target_unknown_floor": "unknown floor",
"target_unknown_label": "unknown label"
"target_unknown_entity": "Unknown entity",
"target_unknown_device": "Unknown device",
"target_unknown_area": "Unknown area",
"target_unknown_floor": "Unknown floor",
"target_unknown_label": "Unknown label"
}
},
"play_media": {
@@ -7020,6 +7033,7 @@
"lifetime_description": "{lifetime} used",
"lifetime_used_description": "The drives wear level is shown as a percentage, based on endurance indicators reported by the device via NVMe SMART or eMMC lifetime estimate fields.",
"disk_metrics": "Disk metrics",
"change_chart_type": "Change chart type",
"datadisk": {
"title": "Move data disk",
"description": "You are currently using ''{current_path}'' as data disk. Moving the data disk will reboot your device and it's estimated to take {time} minutes. Your Home Assistant installation will not be accessible during this period. Do not disconnect the power during the move!",
@@ -7645,6 +7659,8 @@
"strategy_type": "strategy"
},
"select_dashboard": {
"error_title": "Error fetching dashboards",
"error_text": "Failed to fetch dashboards, please try again.",
"header": "Choose a dashboard",
"cannot_move_to_strategy": "The view cannot be moved because the selected dashboard is auto-generated.",
"get_config_failed": "Failed to load selected dashboard config.",

View File

@@ -643,7 +643,7 @@ describe("computeStateDisplayFromEntityAttributes with numeric device classes",
},
"12"
);
expect(result).toBe("12.00 min");
expect(result).toBe("12m");
});
it("Should format duration sensor with seconds", () => {
const result = computeStateDisplayFromEntityAttributes(

View File

@@ -12,6 +12,7 @@ import {
computeConsumptionSingle,
formatConsumptionShort,
calculateSolarConsumedGauge,
formatPowerShort,
} from "../../src/data/energy";
import type { HomeAssistant } from "../../src/types";
@@ -171,6 +172,17 @@ describe("Energy Short Format Test", () => {
"151 MWh"
);
});
it("Power Short Format", () => {
assert.strictEqual(formatPowerShort(hass, 0), "0 W");
assert.strictEqual(formatPowerShort(hass, 10), "10 W");
assert.strictEqual(formatPowerShort(hass, 12.2), "12 W");
assert.strictEqual(formatPowerShort(hass, 999), "999 W");
assert.strictEqual(formatPowerShort(hass, 1000), "1 kW");
assert.strictEqual(formatPowerShort(hass, 1234), "1.234 kW");
assert.strictEqual(formatPowerShort(hass, 10_500), "10.5 kW");
assert.strictEqual(formatPowerShort(hass, 1_500_000), "1.5 MW");
assert.strictEqual(formatPowerShort(hass, -1500), "-1.5 kW");
});
});
describe("Energy Usage Calculation Tests", () => {

View File

@@ -0,0 +1,199 @@
import { assert, describe, it } from "vitest";
import type { LineSeriesOption } from "echarts/charts";
import { fillLineGaps } from "../../../../../../src/panels/lovelace/cards/energy/common/energy-chart-options";
// Helper to get x value from either [x,y] or {value: [x,y]} format
function getX(item: any): number {
return item?.value?.[0] ?? item?.[0];
}
// Helper to get y value from either [x,y] or {value: [x,y]} format
function getY(item: any): number {
return item?.value?.[1] ?? item?.[1];
}
describe("fillLineGaps", () => {
it("fills gaps in datasets with missing timestamps", () => {
const datasets: LineSeriesOption[] = [
{
type: "line",
data: [
[1000, 10],
[3000, 30],
],
},
{
type: "line",
data: [
[1000, 100],
[2000, 200],
[3000, 300],
],
},
];
const result = fillLineGaps(datasets);
// First dataset should have gap at 2000 filled with 0
assert.equal(result[0].data!.length, 3);
assert.equal(getX(result[0].data![0]), 1000);
assert.equal(getY(result[0].data![0]), 10);
assert.equal(getX(result[0].data![1]), 2000);
assert.equal(getY(result[0].data![1]), 0);
assert.equal(getX(result[0].data![2]), 3000);
assert.equal(getY(result[0].data![2]), 30);
// Second dataset should be unchanged
assert.equal(result[1].data!.length, 3);
assert.equal(getX(result[1].data![0]), 1000);
assert.equal(getY(result[1].data![0]), 100);
assert.equal(getX(result[1].data![1]), 2000);
assert.equal(getY(result[1].data![1]), 200);
assert.equal(getX(result[1].data![2]), 3000);
assert.equal(getY(result[1].data![2]), 300);
});
it("handles unsorted data from multiple sources", () => {
// This is the bug we're fixing: when multiple power sources are combined,
// the data may not be in chronological order
const datasets: LineSeriesOption[] = [
{
type: "line",
data: [
[3000, 30],
[1000, 10],
[2000, 20],
],
},
];
const result = fillLineGaps(datasets);
// Data should be sorted by timestamp
assert.equal(result[0].data!.length, 3);
assert.equal(getX(result[0].data![0]), 1000);
assert.equal(getY(result[0].data![0]), 10);
assert.equal(getX(result[0].data![1]), 2000);
assert.equal(getY(result[0].data![1]), 20);
assert.equal(getX(result[0].data![2]), 3000);
assert.equal(getY(result[0].data![2]), 30);
});
it("handles multiple datasets with unsorted data", () => {
const datasets: LineSeriesOption[] = [
{
type: "line",
data: [
[3000, 30],
[1000, 10],
],
},
{
type: "line",
data: [
[2000, 200],
[1000, 100],
[3000, 300],
],
},
];
const result = fillLineGaps(datasets);
// First dataset should be sorted and have gap at 2000 filled
assert.equal(result[0].data!.length, 3);
assert.equal(getX(result[0].data![0]), 1000);
assert.equal(getY(result[0].data![0]), 10);
assert.equal(getX(result[0].data![1]), 2000);
assert.equal(getY(result[0].data![1]), 0);
assert.equal(getX(result[0].data![2]), 3000);
assert.equal(getY(result[0].data![2]), 30);
// Second dataset should be sorted
assert.equal(result[1].data!.length, 3);
assert.equal(getX(result[1].data![0]), 1000);
assert.equal(getY(result[1].data![0]), 100);
assert.equal(getX(result[1].data![1]), 2000);
assert.equal(getY(result[1].data![1]), 200);
assert.equal(getX(result[1].data![2]), 3000);
assert.equal(getY(result[1].data![2]), 300);
});
it("handles data with object format (LineDataItemOption)", () => {
const datasets: LineSeriesOption[] = [
{
type: "line",
data: [{ value: [3000, 30] }, { value: [1000, 10] }],
},
];
const result = fillLineGaps(datasets);
assert.equal(result[0].data!.length, 2);
assert.equal(getX(result[0].data![0]), 1000);
assert.equal(getY(result[0].data![0]), 10);
assert.equal(getX(result[0].data![1]), 3000);
assert.equal(getY(result[0].data![1]), 30);
});
it("returns empty array for empty datasets", () => {
const datasets: LineSeriesOption[] = [
{
type: "line",
data: [],
},
];
const result = fillLineGaps(datasets);
assert.deepEqual(result[0].data, []);
});
it("handles already sorted data with no gaps", () => {
const datasets: LineSeriesOption[] = [
{
type: "line",
data: [
[1000, 10],
[2000, 20],
[3000, 30],
],
},
];
const result = fillLineGaps(datasets);
assert.equal(result[0].data!.length, 3);
assert.equal(getX(result[0].data![0]), 1000);
assert.equal(getY(result[0].data![0]), 10);
assert.equal(getX(result[0].data![1]), 2000);
assert.equal(getY(result[0].data![1]), 20);
assert.equal(getX(result[0].data![2]), 3000);
assert.equal(getY(result[0].data![2]), 30);
});
it("preserves original data item properties", () => {
const datasets: LineSeriesOption[] = [
{
type: "line",
data: [
{ value: [2000, 20], itemStyle: { color: "red" } },
{ value: [1000, 10], itemStyle: { color: "blue" } },
],
},
];
const result = fillLineGaps(datasets);
// First item should be the one with timestamp 1000
const firstItem = result[0].data![0] as any;
assert.equal(getX(firstItem), 1000);
assert.equal(firstItem.itemStyle.color, "blue");
// Second item should be the one with timestamp 2000
const secondItem = result[0].data![1] as any;
assert.equal(getX(secondItem), 2000);
assert.equal(secondItem.itemStyle.color, "red");
});
});

394
yarn.lock
View File

@@ -1282,15 +1282,15 @@ __metadata:
languageName: node
linkType: hard
"@codemirror/view@npm:6.38.8, @codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0":
version: 6.38.8
resolution: "@codemirror/view@npm:6.38.8"
"@codemirror/view@npm:6.39.4, @codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0":
version: 6.39.4
resolution: "@codemirror/view@npm:6.39.4"
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/81b1508015a378e4719d0239254173f0c5cd340c2abf96eb488fe5fb474bdb37ec1f010b9890ced774accd7aeb9443e7337cb6a89544b954273e5ddabece7cea
checksum: 10/8fa1122f2c1dec38eb3a68cd7173c77ea21625bcf2164374bef482213b35222a7811bcc74f221c932c165f8d0a13a17b57456bed6fbae2b12cb9b8355664995a
languageName: node
linkType: hard
@@ -1646,10 +1646,10 @@ __metadata:
languageName: node
linkType: hard
"@eslint/js@npm:9.39.1":
version: 9.39.1
resolution: "@eslint/js@npm:9.39.1"
checksum: 10/b10b9b953212c0f3ffca475159bbe519e9e98847200c7432d1637d444fddcd7b712d2b7710a7dc20510f9cfbe8db330039b2aad09cb55d9545b116d940dbeed2
"@eslint/js@npm:9.39.2":
version: 9.39.2
resolution: "@eslint/js@npm:9.39.2"
checksum: 10/6b7f676746f3111b5d1b23715319212ab9297868a0fa9980d483c3da8965d5841673aada2d5653e85a3f7156edee0893a7ae7035211b4efdcb2848154bb947f2
languageName: node
linkType: hard
@@ -3893,22 +3893,22 @@ __metadata:
languageName: node
linkType: hard
"@rsdoctor/client@npm:1.3.12":
version: 1.3.12
resolution: "@rsdoctor/client@npm:1.3.12"
checksum: 10/3abd14af2bd9a34da2199bad3a9c9aef34381beebebf3d0aceb90e4e9dc9ea19e804372bcdb8e082888dedb268fe544b658537c510df7ae9160df822ced9712f
"@rsdoctor/client@npm:1.3.15":
version: 1.3.15
resolution: "@rsdoctor/client@npm:1.3.15"
checksum: 10/a1b024b5af2d09763125bad2f71e476fb741ba473daf4eebda37aa403568b70609b411efe9562071472c4b2f2f2ae412d41efab312b64fda800dc618fe25241c
languageName: node
linkType: hard
"@rsdoctor/core@npm:1.3.12":
version: 1.3.12
resolution: "@rsdoctor/core@npm:1.3.12"
"@rsdoctor/core@npm:1.3.15":
version: 1.3.15
resolution: "@rsdoctor/core@npm:1.3.15"
dependencies:
"@rsbuild/plugin-check-syntax": "npm:1.5.0"
"@rsdoctor/graph": "npm:1.3.12"
"@rsdoctor/sdk": "npm:1.3.12"
"@rsdoctor/types": "npm:1.3.12"
"@rsdoctor/utils": "npm:1.3.12"
"@rsdoctor/graph": "npm:1.3.15"
"@rsdoctor/sdk": "npm:1.3.15"
"@rsdoctor/types": "npm:1.3.15"
"@rsdoctor/utils": "npm:1.3.15"
browserslist-load-config: "npm:^1.0.1"
enhanced-resolve: "npm:5.12.0"
es-toolkit: "npm:^1.41.0"
@@ -3916,59 +3916,59 @@ __metadata:
fs-extra: "npm:^11.1.1"
semver: "npm:^7.7.3"
source-map: "npm:^0.7.6"
checksum: 10/fda3d1a0acb0e57888a5bbe28649c1b439951e915d6411982116bdaabb72185d4f339009fd748681fe1c9c341c3c46fad597b587d8c3273b2024d682f6b40aca
checksum: 10/9de4c2c3953473cd87d393248df75f1ccf03191b5aa2f45e331d0d300e484208de97616b3d06d1910fdde68678e5e752eadb509abe09c470383e9427eb30faf3
languageName: node
linkType: hard
"@rsdoctor/graph@npm:1.3.12":
version: 1.3.12
resolution: "@rsdoctor/graph@npm:1.3.12"
"@rsdoctor/graph@npm:1.3.15":
version: 1.3.15
resolution: "@rsdoctor/graph@npm:1.3.15"
dependencies:
"@rsdoctor/types": "npm:1.3.12"
"@rsdoctor/utils": "npm:1.3.12"
"@rsdoctor/types": "npm:1.3.15"
"@rsdoctor/utils": "npm:1.3.15"
es-toolkit: "npm:^1.41.0"
path-browserify: "npm:1.0.1"
source-map: "npm:^0.7.6"
checksum: 10/38b07882c0e90fc9a97c68ee571f7b649c454624d403e8745ef85b6c2ad2c698f922aa1fb313a23cc1ceadfe391f48b23d381f73cf089ab5762a14a095dd26f3
checksum: 10/3bbafc0d03463346c00cd0fd29da01d66d086342c228c41ee2cf9e63e87bc3c8e30e571aff02b679f93bf8ca8726b861c64bfd8b41dde2d035935027b9c1fcba
languageName: node
linkType: hard
"@rsdoctor/rspack-plugin@npm:1.3.12":
version: 1.3.12
resolution: "@rsdoctor/rspack-plugin@npm:1.3.12"
"@rsdoctor/rspack-plugin@npm:1.3.15":
version: 1.3.15
resolution: "@rsdoctor/rspack-plugin@npm:1.3.15"
dependencies:
"@rsdoctor/core": "npm:1.3.12"
"@rsdoctor/graph": "npm:1.3.12"
"@rsdoctor/sdk": "npm:1.3.12"
"@rsdoctor/types": "npm:1.3.12"
"@rsdoctor/utils": "npm:1.3.12"
"@rsdoctor/core": "npm:1.3.15"
"@rsdoctor/graph": "npm:1.3.15"
"@rsdoctor/sdk": "npm:1.3.15"
"@rsdoctor/types": "npm:1.3.15"
"@rsdoctor/utils": "npm:1.3.15"
peerDependencies:
"@rspack/core": "*"
peerDependenciesMeta:
"@rspack/core":
optional: true
checksum: 10/2825034e62d89d9e9eeb5d4fa68ab3b19a12ee6b270fb103fd694e649dcaccfe08016879271c5d2cd2ee961179df43e9b124f965d2933b24dfaadc98441c2cc3
checksum: 10/c20eac90976f5cf4b0659c4b83a283cd512e79c4af35a5701eb458efa5466084a723c766324686d68b0261aa472b38f23c96605641693d60d48002ae9295ff93
languageName: node
linkType: hard
"@rsdoctor/sdk@npm:1.3.12":
version: 1.3.12
resolution: "@rsdoctor/sdk@npm:1.3.12"
"@rsdoctor/sdk@npm:1.3.15":
version: 1.3.15
resolution: "@rsdoctor/sdk@npm:1.3.15"
dependencies:
"@rsdoctor/client": "npm:1.3.12"
"@rsdoctor/graph": "npm:1.3.12"
"@rsdoctor/types": "npm:1.3.12"
"@rsdoctor/utils": "npm:1.3.12"
"@rsdoctor/client": "npm:1.3.15"
"@rsdoctor/graph": "npm:1.3.15"
"@rsdoctor/types": "npm:1.3.15"
"@rsdoctor/utils": "npm:1.3.15"
safer-buffer: "npm:2.1.2"
socket.io: "npm:4.8.1"
tapable: "npm:2.2.3"
checksum: 10/5b60197500b73b1ae671565a2c2886a40df36fcf0e7277457840f569cb845d2c725fbfe43a4f6496ac5a26d749e0249e1a2a79bdb582a0fe16beeaa9bea0cdaa
checksum: 10/c1f6a55359df4e8eac83ef8bfa21a80515a05ab7a803bc28c8535c2d6e73510c8764a35adc0aa1605138753bdb221b37c0af04e4367a6f19f4c45b1c52908fa7
languageName: node
linkType: hard
"@rsdoctor/types@npm:1.3.12":
version: 1.3.12
resolution: "@rsdoctor/types@npm:1.3.12"
"@rsdoctor/types@npm:1.3.15":
version: 1.3.15
resolution: "@rsdoctor/types@npm:1.3.15"
dependencies:
"@types/connect": "npm:3.4.38"
"@types/estree": "npm:1.0.5"
@@ -3982,16 +3982,16 @@ __metadata:
optional: true
webpack:
optional: true
checksum: 10/1d8891362056332289dfefd22a9ab0daf977ef8d82cffa1bc2abf507423ddf628242188b826bcf34e8fd908bb1b22f093c840675e77593a7f93e671e91261c5f
checksum: 10/cf97aa38bab865c0fc1c96ce695ed9295095b86d744c2f38fc7ef2d9c50850a32bfe49b08d489665878bf2d0e18a03feea866b092942cac092289fcd540362f5
languageName: node
linkType: hard
"@rsdoctor/utils@npm:1.3.12":
version: 1.3.12
resolution: "@rsdoctor/utils@npm:1.3.12"
"@rsdoctor/utils@npm:1.3.15":
version: 1.3.15
resolution: "@rsdoctor/utils@npm:1.3.15"
dependencies:
"@babel/code-frame": "npm:7.26.2"
"@rsdoctor/types": "npm:1.3.12"
"@rsdoctor/types": "npm:1.3.15"
"@types/estree": "npm:1.0.5"
acorn: "npm:^8.10.0"
acorn-import-attributes: "npm:^1.9.5"
@@ -4005,96 +4005,96 @@ __metadata:
picocolors: "npm:^1.1.1"
rslog: "npm:^1.2.11"
strip-ansi: "npm:^6.0.1"
checksum: 10/24d46e84abf2f85514f9feabb44db9b7a1e9e161052c8cdb8edc561db797928fd0d257249e1fb228f2b9346dc446e0ec59e03d46307f5b1e4bf5554a73274ee6
checksum: 10/086341603606eb76bb291582d00d6e4741aa065a6cad6bff04ec679fa05db4897bb821bdde3f6bed2961b3798b2fc17ce0d4b827d0955fb307c4b0f800459880
languageName: node
linkType: hard
"@rspack/binding-darwin-arm64@npm:1.6.6":
version: 1.6.6
resolution: "@rspack/binding-darwin-arm64@npm:1.6.6"
"@rspack/binding-darwin-arm64@npm:1.6.7":
version: 1.6.7
resolution: "@rspack/binding-darwin-arm64@npm:1.6.7"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@rspack/binding-darwin-x64@npm:1.6.6":
version: 1.6.6
resolution: "@rspack/binding-darwin-x64@npm:1.6.6"
"@rspack/binding-darwin-x64@npm:1.6.7":
version: 1.6.7
resolution: "@rspack/binding-darwin-x64@npm:1.6.7"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@rspack/binding-linux-arm64-gnu@npm:1.6.6":
version: 1.6.6
resolution: "@rspack/binding-linux-arm64-gnu@npm:1.6.6"
"@rspack/binding-linux-arm64-gnu@npm:1.6.7":
version: 1.6.7
resolution: "@rspack/binding-linux-arm64-gnu@npm:1.6.7"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-arm64-musl@npm:1.6.6":
version: 1.6.6
resolution: "@rspack/binding-linux-arm64-musl@npm:1.6.6"
"@rspack/binding-linux-arm64-musl@npm:1.6.7":
version: 1.6.7
resolution: "@rspack/binding-linux-arm64-musl@npm:1.6.7"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-linux-x64-gnu@npm:1.6.6":
version: 1.6.6
resolution: "@rspack/binding-linux-x64-gnu@npm:1.6.6"
"@rspack/binding-linux-x64-gnu@npm:1.6.7":
version: 1.6.7
resolution: "@rspack/binding-linux-x64-gnu@npm:1.6.7"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@rspack/binding-linux-x64-musl@npm:1.6.6":
version: 1.6.6
resolution: "@rspack/binding-linux-x64-musl@npm:1.6.6"
"@rspack/binding-linux-x64-musl@npm:1.6.7":
version: 1.6.7
resolution: "@rspack/binding-linux-x64-musl@npm:1.6.7"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@rspack/binding-wasm32-wasi@npm:1.6.6":
version: 1.6.6
resolution: "@rspack/binding-wasm32-wasi@npm:1.6.6"
"@rspack/binding-wasm32-wasi@npm:1.6.7":
version: 1.6.7
resolution: "@rspack/binding-wasm32-wasi@npm:1.6.7"
dependencies:
"@napi-rs/wasm-runtime": "npm:1.0.7"
conditions: cpu=wasm32
languageName: node
linkType: hard
"@rspack/binding-win32-arm64-msvc@npm:1.6.6":
version: 1.6.6
resolution: "@rspack/binding-win32-arm64-msvc@npm:1.6.6"
"@rspack/binding-win32-arm64-msvc@npm:1.6.7":
version: 1.6.7
resolution: "@rspack/binding-win32-arm64-msvc@npm:1.6.7"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@rspack/binding-win32-ia32-msvc@npm:1.6.6":
version: 1.6.6
resolution: "@rspack/binding-win32-ia32-msvc@npm:1.6.6"
"@rspack/binding-win32-ia32-msvc@npm:1.6.7":
version: 1.6.7
resolution: "@rspack/binding-win32-ia32-msvc@npm:1.6.7"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@rspack/binding-win32-x64-msvc@npm:1.6.6":
version: 1.6.6
resolution: "@rspack/binding-win32-x64-msvc@npm:1.6.6"
"@rspack/binding-win32-x64-msvc@npm:1.6.7":
version: 1.6.7
resolution: "@rspack/binding-win32-x64-msvc@npm:1.6.7"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@rspack/binding@npm:1.6.6":
version: 1.6.6
resolution: "@rspack/binding@npm:1.6.6"
"@rspack/binding@npm:1.6.7":
version: 1.6.7
resolution: "@rspack/binding@npm:1.6.7"
dependencies:
"@rspack/binding-darwin-arm64": "npm:1.6.6"
"@rspack/binding-darwin-x64": "npm:1.6.6"
"@rspack/binding-linux-arm64-gnu": "npm:1.6.6"
"@rspack/binding-linux-arm64-musl": "npm:1.6.6"
"@rspack/binding-linux-x64-gnu": "npm:1.6.6"
"@rspack/binding-linux-x64-musl": "npm:1.6.6"
"@rspack/binding-wasm32-wasi": "npm:1.6.6"
"@rspack/binding-win32-arm64-msvc": "npm:1.6.6"
"@rspack/binding-win32-ia32-msvc": "npm:1.6.6"
"@rspack/binding-win32-x64-msvc": "npm:1.6.6"
"@rspack/binding-darwin-arm64": "npm:1.6.7"
"@rspack/binding-darwin-x64": "npm:1.6.7"
"@rspack/binding-linux-arm64-gnu": "npm:1.6.7"
"@rspack/binding-linux-arm64-musl": "npm:1.6.7"
"@rspack/binding-linux-x64-gnu": "npm:1.6.7"
"@rspack/binding-linux-x64-musl": "npm:1.6.7"
"@rspack/binding-wasm32-wasi": "npm:1.6.7"
"@rspack/binding-win32-arm64-msvc": "npm:1.6.7"
"@rspack/binding-win32-ia32-msvc": "npm:1.6.7"
"@rspack/binding-win32-x64-msvc": "npm:1.6.7"
dependenciesMeta:
"@rspack/binding-darwin-arm64":
optional: true
@@ -4116,23 +4116,23 @@ __metadata:
optional: true
"@rspack/binding-win32-x64-msvc":
optional: true
checksum: 10/37b69398a0679c25e0479b6eb11ea2c110a8b57367af2c808a473d19d58c9dd09e7763b3dfbec06284d6863e7a301d71509128fe22da2b0c57c06b718f67e66a
checksum: 10/80837f5cbdcac9eb6fb2213436baa3a5a841508340456c473e36bed2882197f456462db675b2d43cc1ed84d61109bae4bef0d474131a670113601ad80727769f
languageName: node
linkType: hard
"@rspack/core@npm:1.6.6":
version: 1.6.6
resolution: "@rspack/core@npm:1.6.6"
"@rspack/core@npm:1.6.7":
version: 1.6.7
resolution: "@rspack/core@npm:1.6.7"
dependencies:
"@module-federation/runtime-tools": "npm:0.21.6"
"@rspack/binding": "npm:1.6.6"
"@rspack/binding": "npm:1.6.7"
"@rspack/lite-tapable": "npm:1.1.0"
peerDependencies:
"@swc/helpers": ">=0.5.1"
peerDependenciesMeta:
"@swc/helpers":
optional: true
checksum: 10/e706c19085729f52f3e80c6945edc32def2091270fc9a15d6aea336754719d0bdce93e62dae3a675c412b5e433cc2fc093591d74e731722196d0de2ff269b198
checksum: 10/c374d954c86a24dfba59b6e3c6388ce365723c83ea7012a6e95175b6245f7080b13e8560c25d744558e850c04a10d95fbe7ee92b4eabbd40a1ef578caf60615b
languageName: node
linkType: hard
@@ -4945,106 +4945,105 @@ __metadata:
languageName: node
linkType: hard
"@typescript-eslint/eslint-plugin@npm:8.48.1":
version: 8.48.1
resolution: "@typescript-eslint/eslint-plugin@npm:8.48.1"
"@typescript-eslint/eslint-plugin@npm:8.49.0":
version: 8.49.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.49.0"
dependencies:
"@eslint-community/regexpp": "npm:^4.10.0"
"@typescript-eslint/scope-manager": "npm:8.48.1"
"@typescript-eslint/type-utils": "npm:8.48.1"
"@typescript-eslint/utils": "npm:8.48.1"
"@typescript-eslint/visitor-keys": "npm:8.48.1"
graphemer: "npm:^1.4.0"
"@typescript-eslint/scope-manager": "npm:8.49.0"
"@typescript-eslint/type-utils": "npm:8.49.0"
"@typescript-eslint/utils": "npm:8.49.0"
"@typescript-eslint/visitor-keys": "npm:8.49.0"
ignore: "npm:^7.0.0"
natural-compare: "npm:^1.4.0"
ts-api-utils: "npm:^2.1.0"
peerDependencies:
"@typescript-eslint/parser": ^8.48.1
"@typescript-eslint/parser": ^8.49.0
eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10/3ccf420805fb8adb2f3059fa26eb9c6211c0624966d8c8654a1bd586bf87f30be0c62524dfd785185ef573bedd91c42ec3c98c23aed5d60cb9ac583dd9334bc8
checksum: 10/f51c45c7e3fe367a9855742229d1893b3df61aa725a199ee87fa81c7fc80128a0ba6971d39192be023d08262f320688c3483821d139024911cc9e88dbcd58c6b
languageName: node
linkType: hard
"@typescript-eslint/parser@npm:8.48.1":
version: 8.48.1
resolution: "@typescript-eslint/parser@npm:8.48.1"
"@typescript-eslint/parser@npm:8.49.0":
version: 8.49.0
resolution: "@typescript-eslint/parser@npm:8.49.0"
dependencies:
"@typescript-eslint/scope-manager": "npm:8.48.1"
"@typescript-eslint/types": "npm:8.48.1"
"@typescript-eslint/typescript-estree": "npm:8.48.1"
"@typescript-eslint/visitor-keys": "npm:8.48.1"
"@typescript-eslint/scope-manager": "npm:8.49.0"
"@typescript-eslint/types": "npm:8.49.0"
"@typescript-eslint/typescript-estree": "npm:8.49.0"
"@typescript-eslint/visitor-keys": "npm:8.49.0"
debug: "npm:^4.3.4"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10/d8409c9ede4b1cd2ad0e10e94bb00c54f79352f7d54c97bf24419cb983c19b9f6097e6c31b217ce7ec5cfc9a48117e732d9f88ce0cb8c0ccf7fc3faecdf854a3
checksum: 10/b41706ff8b7bd65ca197cd334493c1062cda6cbbef23221b4937a6aed3c04dc74c3f58afb2cbb463f42980bae41289216cb3174bd74ead7a504878277d4ee3a6
languageName: node
linkType: hard
"@typescript-eslint/project-service@npm:8.48.1":
version: 8.48.1
resolution: "@typescript-eslint/project-service@npm:8.48.1"
"@typescript-eslint/project-service@npm:8.49.0":
version: 8.49.0
resolution: "@typescript-eslint/project-service@npm:8.49.0"
dependencies:
"@typescript-eslint/tsconfig-utils": "npm:^8.48.1"
"@typescript-eslint/types": "npm:^8.48.1"
"@typescript-eslint/tsconfig-utils": "npm:^8.49.0"
"@typescript-eslint/types": "npm:^8.49.0"
debug: "npm:^4.3.4"
peerDependencies:
typescript: ">=4.8.4 <6.0.0"
checksum: 10/66ecc7ef9572748860517cde7fbfc335d05ca8c99dcf13ac6d728ac93388d90cdc3ebe2ff33a85c0a03487b3c1c4e36c6e3fe413ee16d8fb003621cb58e65e52
checksum: 10/ce6ed14247b2fbbd108e1accbf050d0585932a14bb9424ef0bc4f1e421a054c4da16caedb3469e3f1bacf0e5d6de78291aa633321ff6a7c79e5767d1c6d4ea51
languageName: node
linkType: hard
"@typescript-eslint/scope-manager@npm:8.48.1":
version: 8.48.1
resolution: "@typescript-eslint/scope-manager@npm:8.48.1"
"@typescript-eslint/scope-manager@npm:8.49.0":
version: 8.49.0
resolution: "@typescript-eslint/scope-manager@npm:8.49.0"
dependencies:
"@typescript-eslint/types": "npm:8.48.1"
"@typescript-eslint/visitor-keys": "npm:8.48.1"
checksum: 10/5040246220f9872ec47633297b7896ed5587af3163e06ddcb7ca0dcf1e171f359bd4f1c82f794a6adfecbccfb5ef437d51b522321034603c93ba1993c407bdf2
"@typescript-eslint/types": "npm:8.49.0"
"@typescript-eslint/visitor-keys": "npm:8.49.0"
checksum: 10/ef13c9f7842efd5141798f4cc02ba46763e1bc0154ba804df143dfdf84ee2ee33de2932bef286c3e5a4806bf142b0327f37061d9c50153f31401c4f5e82086ce
languageName: node
linkType: hard
"@typescript-eslint/tsconfig-utils@npm:8.48.1, @typescript-eslint/tsconfig-utils@npm:^8.48.1":
version: 8.48.1
resolution: "@typescript-eslint/tsconfig-utils@npm:8.48.1"
"@typescript-eslint/tsconfig-utils@npm:8.49.0, @typescript-eslint/tsconfig-utils@npm:^8.49.0":
version: 8.49.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.49.0"
peerDependencies:
typescript: ">=4.8.4 <6.0.0"
checksum: 10/830bcd0e7628441f91899e8e24aaed66d32a239babcc205aba1d08c08ff5a636d8c04f96d9873578df59d7468fc4c5df032667764b3b2ee0a733af36fca21c4a
checksum: 10/296f8b078ecc5f954a6834f7b044ee4786784bae60a6d42037caad34b4602bdb2c2f0a18f36faee47f59c70727ac2abac264a225ab305bc80cfb21cd2ef9f852
languageName: node
linkType: hard
"@typescript-eslint/type-utils@npm:8.48.1":
version: 8.48.1
resolution: "@typescript-eslint/type-utils@npm:8.48.1"
"@typescript-eslint/type-utils@npm:8.49.0":
version: 8.49.0
resolution: "@typescript-eslint/type-utils@npm:8.49.0"
dependencies:
"@typescript-eslint/types": "npm:8.48.1"
"@typescript-eslint/typescript-estree": "npm:8.48.1"
"@typescript-eslint/utils": "npm:8.48.1"
"@typescript-eslint/types": "npm:8.49.0"
"@typescript-eslint/typescript-estree": "npm:8.49.0"
"@typescript-eslint/utils": "npm:8.49.0"
debug: "npm:^4.3.4"
ts-api-utils: "npm:^2.1.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10/6cf9370ac5437e2d64c71964646aed9e6c1ea3c7bb473258b50ae422106461d290f4215b9435b892a2dd563e3c31feb3169532375513b56b7e48f4a425283091
checksum: 10/d4e88841edd5ddb6fcdb00011e2f67daa792dad01d7e2bd7a7c04fe3f3ffe59680fd707ccad27332003f5c469e89f16961cb1dfbd2e1b214ce3625378397f825
languageName: node
linkType: hard
"@typescript-eslint/types@npm:8.48.1, @typescript-eslint/types@npm:^8.48.1":
version: 8.48.1
resolution: "@typescript-eslint/types@npm:8.48.1"
checksum: 10/1aa1e3f25b429bcebd9eb45b5252d950f1b24dbc6014a47dff8d00547e2e1ac47f351846fb996b6ebd49da37a85394051d36191cbbbf2c431b8db9d95afd198d
"@typescript-eslint/types@npm:8.49.0, @typescript-eslint/types@npm:^8.49.0":
version: 8.49.0
resolution: "@typescript-eslint/types@npm:8.49.0"
checksum: 10/40efafd393d9a3343a9e4bd127c6d5a919f34088227a3d0d6021b603d44f9c0403ad93d8c832959f49b71dfb4603721600363060d3a8f3637ac3fb5d6981ece7
languageName: node
linkType: hard
"@typescript-eslint/typescript-estree@npm:8.48.1":
version: 8.48.1
resolution: "@typescript-eslint/typescript-estree@npm:8.48.1"
"@typescript-eslint/typescript-estree@npm:8.49.0":
version: 8.49.0
resolution: "@typescript-eslint/typescript-estree@npm:8.49.0"
dependencies:
"@typescript-eslint/project-service": "npm:8.48.1"
"@typescript-eslint/tsconfig-utils": "npm:8.48.1"
"@typescript-eslint/types": "npm:8.48.1"
"@typescript-eslint/visitor-keys": "npm:8.48.1"
"@typescript-eslint/project-service": "npm:8.49.0"
"@typescript-eslint/tsconfig-utils": "npm:8.49.0"
"@typescript-eslint/types": "npm:8.49.0"
"@typescript-eslint/visitor-keys": "npm:8.49.0"
debug: "npm:^4.3.4"
minimatch: "npm:^9.0.4"
semver: "npm:^7.6.0"
@@ -5052,32 +5051,32 @@ __metadata:
ts-api-utils: "npm:^2.1.0"
peerDependencies:
typescript: ">=4.8.4 <6.0.0"
checksum: 10/485aa44d22453396dbe61c560c6f583bf876f971d9e70773093cd729279f88184cf5793bf706033bbd8465cce6f9d045b63574727d58d5996519c29e1adbbfe5
checksum: 10/f84280d8068732d643c101dfb1018db23c1f142b3991e8a5a8e6d6813b79dc00dda96f7f08f256f20cf7efc14655bdc241a1eea406b56eb01156847a91ee621d
languageName: node
linkType: hard
"@typescript-eslint/utils@npm:8.48.1":
version: 8.48.1
resolution: "@typescript-eslint/utils@npm:8.48.1"
"@typescript-eslint/utils@npm:8.49.0":
version: 8.49.0
resolution: "@typescript-eslint/utils@npm:8.49.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.7.0"
"@typescript-eslint/scope-manager": "npm:8.48.1"
"@typescript-eslint/types": "npm:8.48.1"
"@typescript-eslint/typescript-estree": "npm:8.48.1"
"@typescript-eslint/scope-manager": "npm:8.49.0"
"@typescript-eslint/types": "npm:8.49.0"
"@typescript-eslint/typescript-estree": "npm:8.49.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10/34afe5cf78020b682473e6529d6268eb8015bdb020a3c5303c4abb230d4d7c39e6fc8b9df58d1f0f35a1ceeb5d6182e71e42fe7a28dde8ffc31f8560f2dacc7c
checksum: 10/3b5fe5184af4d7379498610ed71fa38476f4133b6b4a761b267ee1b103ab422e3082d071fed138d42e5c18b445a29dba496df74a88d0690053c58adc881ffe6e
languageName: node
linkType: hard
"@typescript-eslint/visitor-keys@npm:8.48.1":
version: 8.48.1
resolution: "@typescript-eslint/visitor-keys@npm:8.48.1"
"@typescript-eslint/visitor-keys@npm:8.49.0":
version: 8.49.0
resolution: "@typescript-eslint/visitor-keys@npm:8.49.0"
dependencies:
"@typescript-eslint/types": "npm:8.48.1"
"@typescript-eslint/types": "npm:8.49.0"
eslint-visitor-keys: "npm:^4.2.1"
checksum: 10/63aa165c57e6b38700adf84da2e90537577cdeb69d05031e3e70785fa412d96d539dc4c1696a0b7bc93284613f8b92fb1bb40f6068bb75347a942120b246ac60
checksum: 10/f778c588f49174f21866c59f8d46d2c0cad0d68b7acc87982e279c28d63df9f229fafdc13f36932b45fb8151aaeb1f8f70b1a00c83e7dae3782121ec3e1dac68
languageName: node
linkType: hard
@@ -8044,9 +8043,9 @@ __metadata:
languageName: node
linkType: hard
"eslint@npm:9.39.1":
version: 9.39.1
resolution: "eslint@npm:9.39.1"
"eslint@npm:9.39.2":
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"
@@ -8054,7 +8053,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.1"
"@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"
@@ -8089,7 +8088,7 @@ __metadata:
optional: true
bin:
eslint: bin/eslint.js
checksum: 10/c85fefe4a81a1a476e62087366907af830b62a6565ac153f6d50a100a42a946aeb049c3af8f06c0e091105ba0fe97ac109f379f32755a67f66ecb7d4d1e4dca3
checksum: 10/53ff0e9c8264e7e8d40d50fdc0c0df0b701cfc5289beedfb686c214e3e7b199702f894bbd1bb48653727bb1ecbd1147cf5f555a4ae71e1daf35020cdc9072d9f
languageName: node
linkType: hard
@@ -8994,13 +8993,6 @@ __metadata:
languageName: node
linkType: hard
"graphemer@npm:^1.4.0":
version: 1.4.0
resolution: "graphemer@npm:1.4.0"
checksum: 10/6dd60dba97007b21e3a829fab3f771803cc1292977fe610e240ea72afd67e5690ac9eeaafc4a99710e78962e5936ab5a460787c2a1180f1cb0ccfac37d29f897
languageName: node
linkType: hard
"gulp-brotli@npm:3.0.0":
version: 3.0.0
resolution: "gulp-brotli@npm:3.0.0"
@@ -9195,7 +9187,7 @@ __metadata:
"@codemirror/legacy-modes": "npm:6.5.2"
"@codemirror/search": "npm:6.5.11"
"@codemirror/state": "npm:6.5.2"
"@codemirror/view": "npm:6.38.8"
"@codemirror/view": "npm:6.39.4"
"@date-fns/tz": "npm:1.4.1"
"@egjs/hammerjs": "npm:2.0.17"
"@formatjs/intl-datetimeformat": "npm:6.18.2"
@@ -9250,8 +9242,8 @@ __metadata:
"@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.3.12"
"@rspack/core": "npm:1.6.6"
"@rsdoctor/rspack-plugin": "npm:1.3.15"
"@rspack/core": "npm:1.6.7"
"@rspack/dev-server": "npm:1.1.4"
"@swc/helpers": "npm:0.5.17"
"@thomasloven/round-slider": "npm:0.6.0"
@@ -9299,7 +9291,7 @@ __metadata:
dialog-polyfill: "npm:0.5.6"
echarts: "npm:6.0.0"
element-internals-polyfill: "npm:3.0.2"
eslint: "npm:9.39.1"
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"
@@ -9356,12 +9348,12 @@ __metadata:
stacktrace-js: "npm:2.0.2"
superstruct: "npm:2.0.2"
tar: "npm:7.5.2"
terser-webpack-plugin: "npm:5.3.15"
terser-webpack-plugin: "npm:5.3.16"
tinykeys: "npm:3.0.0"
ts-lit-plugin: "npm:2.0.2"
typescript: "npm:5.9.3"
typescript-eslint: "npm:8.48.1"
ua-parser-js: "npm:2.0.6"
typescript-eslint: "npm:8.49.0"
ua-parser-js: "npm:2.0.7"
vite-tsconfig-paths: "npm:5.1.4"
vitest: "npm:4.0.15"
vue: "npm:2.7.16"
@@ -13895,9 +13887,9 @@ __metadata:
languageName: node
linkType: hard
"terser-webpack-plugin@npm:5.3.15":
version: 5.3.15
resolution: "terser-webpack-plugin@npm:5.3.15"
"terser-webpack-plugin@npm:5.3.16":
version: 5.3.16
resolution: "terser-webpack-plugin@npm:5.3.16"
dependencies:
"@jridgewell/trace-mapping": "npm:^0.3.25"
jest-worker: "npm:^27.4.5"
@@ -13913,7 +13905,7 @@ __metadata:
optional: true
uglify-js:
optional: true
checksum: 10/54059f0fe56c16a1e30032b33b1321051d562b5292adcf88d160c7d8e74779867014e98548ae59ba5283fe60f8725bea37c16b42f2c89ee430382cff74198b7a
checksum: 10/09dfbff602acfa114cdd174254b69a04adbc47856021ab351e37982202fd1ec85e0b62ffd5864c98beb8e96aef2f43da490b3448b4541db539c2cff6607394a6
languageName: node
linkType: hard
@@ -14321,18 +14313,18 @@ __metadata:
languageName: node
linkType: hard
"typescript-eslint@npm:8.48.1":
version: 8.48.1
resolution: "typescript-eslint@npm:8.48.1"
"typescript-eslint@npm:8.49.0":
version: 8.49.0
resolution: "typescript-eslint@npm:8.49.0"
dependencies:
"@typescript-eslint/eslint-plugin": "npm:8.48.1"
"@typescript-eslint/parser": "npm:8.48.1"
"@typescript-eslint/typescript-estree": "npm:8.48.1"
"@typescript-eslint/utils": "npm:8.48.1"
"@typescript-eslint/eslint-plugin": "npm:8.49.0"
"@typescript-eslint/parser": "npm:8.49.0"
"@typescript-eslint/typescript-estree": "npm:8.49.0"
"@typescript-eslint/utils": "npm:8.49.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10/2b5318d74f9b8c4cd5d253b4d5249a184a0c5ed9eaf998a604c0d7b816acdc04f40570964d35fc5c93d40171ed3d95b8eef721578b7bedc8433607f4f7e59520
checksum: 10/face6f4043e00ce1e27e63f8e364c1e098c8f3e50111d139da280e412b67d1d758f626087340007960c70df4829b448e632f244193394fd77bbce60a6aee9d5d
languageName: node
linkType: hard
@@ -14383,16 +14375,16 @@ __metadata:
languageName: node
linkType: hard
"ua-parser-js@npm:2.0.6":
version: 2.0.6
resolution: "ua-parser-js@npm:2.0.6"
"ua-parser-js@npm:2.0.7":
version: 2.0.7
resolution: "ua-parser-js@npm:2.0.7"
dependencies:
detect-europe-js: "npm:^0.1.2"
is-standalone-pwa: "npm:^0.1.1"
ua-is-frozen: "npm:^0.1.2"
bin:
ua-parser-js: script/cli.js
checksum: 10/b0049d3b272979049c7df6af2ec2ce032e4351316b10c33699f6e3f0bec701336f67530cc3ccb363c554b1bb5047b75d2f46575699afacd6e541762ca3861f4d
checksum: 10/bf94032b429447a988ec0761276b1bada2657af8af547ba0c07e756f56041f76c49f1638fa502a1cb3f66c46a3e7e33c1b930d13c3c2cd520703c85f0d829182
languageName: node
linkType: hard