Compare commits

..
Author SHA1 Message Date
Bram Kragten 8f73718a35 Bumped version to 20260826.1 2026-08-27 17:07:22 +02:00
Paul BotteinandBram Kragten 4bf2acdb43 Show serial port info in a bottom sheet on mobile (#53839) 2026-08-27 17:06:48 +02:00
Shay RedmondandBram Kragten f622e5eeb9 Small copy improvements for Cloud feature list (#53833) 2026-08-27 17:06:47 +02:00
Paul BotteinandBram Kragten 595068597f Keep the row config when changing a device automation type (#53832) 2026-08-27 17:06:46 +02:00
Aidan TimsonandBram Kragten c708f35ab6 Fix MQTT subscribe controls layout (#53831) 2026-08-27 17:06:45 +02:00
Aidan TimsonandBram Kragten 205874c800 Show app icons for serial port consumers (#53827) 2026-08-27 17:06:44 +02:00
Petar Petrov 47ddcbe2e5 Fix editor state leaking when switching automations, scripts, or scenes (#53826)
* Fix editor state leaking when switching automations, scripts, or scenes

Recreate the page when the item id in the route changes so YAML mode,
undo history, and scene live state cannot carry over to the next item.

* Declare itemId on editor routes instead of guessing from the path

Path-shape matching remounted nested config routers, Lovelace, and
developer tools. Recreate only where edit/show routes opt in.

* Do not restore scene live states when the editor is torn down

A remounted editor starts in review; disconnect only needs to drop
the subscription, not undo device changes from live mode.

* Use the normal create path when remounting itemId pages

Clear the current page instead of calling _createPanel directly so
load and the loading screen still run. Drop the redundant cache delete.

* Fix dialog-form tests leaking focus restore after jsdom teardown

Nested submit/cancel schedules a nextRender callback that ran after
Vitest tore down HTMLElement, failing CI with an unhandled rejection.
2026-08-27 17:06:43 +02:00
Petar PetrovandBram Kragten c43b8bfbe0 Do not show empty Grid legend item on energy usage graph (#53822)
Do not add empty combined Grid series on energy usage graph
2026-08-27 17:06:41 +02:00
Bram KragtenandClaude Opus 5 e349b00b15 Replace CARTO raster tiles with OpenStreetMap vector tiles (#53816)
Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-27 17:06:40 +02:00
Paul BotteinandBram Kragten 0bb8bdc98a Clarify the replaced device state in pickers and target rows (#53807) 2026-08-27 17:06:39 +02:00
Petar PetrovandBram Kragten bbc5385843 Fix more-info dialog rewriting the URL after navigation (#53806) 2026-08-27 17:06:37 +02:00
Paul BotteinandBram Kragten 43dc5651d3 Keep the automation config when replacing a device in device automations (#53805) 2026-08-27 17:06:36 +02:00
Aidan TimsonandBram Kragten 18dcf7abc0 Refine entity details context section (#53797) 2026-08-27 17:06:35 +02:00
49 changed files with 468 additions and 1722 deletions
+5 -5
View File
@@ -109,7 +109,7 @@
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.14",
"js-yaml": "5.4.0",
"js-yaml": "5.3.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
@@ -117,7 +117,7 @@
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "5.24.0",
"marked": "18.0.11",
"marked": "18.0.10",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -179,7 +179,7 @@
"browserslist": "4.28.8",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.9.1",
"eslint": "10.9.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -206,7 +206,7 @@
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
"map-stream": "0.0.7",
"minify-literals": "2.2.0",
"minify-literals": "2.1.0",
"pinst": "3.0.0",
"prettier": "3.9.6",
"rspack-manifest-plugin": "5.2.2",
@@ -216,7 +216,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.68.0",
"typescript-eslint": "8.67.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.11",
"webpack-stats-plugin": "1.1.3",
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260826.0"
version = "20260826.1"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+2 -85
View File
@@ -80,67 +80,6 @@ const ensureDialogsClosed = async (timestamp: number): Promise<boolean> => {
return ensureDialogsClosed(timestamp);
};
/**
* Lets a page with unsaved changes (e.g. the automation editor) veto
* navigation. `isDirty` is read live at navigation time; `prompt` resolves
* true when navigation may proceed.
*/
export interface UnsavedChangesGuard {
isDirty(): boolean;
prompt(): Promise<boolean>;
}
const unsavedChangesGuards = new Set<UnsavedChangesGuard>();
export const registerUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
unsavedChangesGuards.add(guard);
};
export const unregisterUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
unsavedChangesGuards.delete(guard);
};
let pendingUnsavedPrompt: Promise<boolean> | undefined;
/**
* Counts navigations that changed the history entry, so a navigation held up
* by an unsaved-changes prompt can tell whether a newer one has moved the app
* on in the meantime.
*/
let committedNavigations = 0;
/**
* Asks each dirty guard whether navigation may proceed. Returns true when
* nothing is dirty or every prompt was confirmed. Concurrent navigations
* share one pending prompt instead of stacking dialogs; the dirty check runs
* before joining it, so a navigation triggered from inside a prompt (e.g. by
* its save action) cannot deadlock on its own promise.
*/
const ensureUnsavedChangesConfirmed = (): Promise<boolean> => {
const dirtyGuards = [...unsavedChangesGuards].filter((guard) =>
guard.isDirty()
);
if (!dirtyGuards.length) {
return Promise.resolve(true);
}
if (!pendingUnsavedPrompt) {
pendingUnsavedPrompt = (async () => {
try {
for (const guard of dirtyGuards) {
// eslint-disable-next-line no-await-in-loop
if (!(await guard.prompt())) {
return false;
}
}
return true;
} finally {
pendingUnsavedPrompt = undefined;
}
})();
}
return pendingUnsavedPrompt;
};
const buildHistoryState = (
data: Record<string, unknown> | undefined,
from?: string
@@ -152,7 +91,7 @@ const buildHistoryState = (
return { ...state, from };
};
const performNavigation = async (path: string, options?: NavigateOptions) => {
export const navigate = async (path: string, options?: NavigateOptions) => {
const canProceed = await ensureDialogsClosed(Date.now());
if (!canProceed) {
return false;
@@ -188,28 +127,9 @@ const performNavigation = async (path: string, options?: NavigateOptions) => {
fireEvent(mainWindow, "location-changed", {
replace,
});
committedNavigations += 1;
return true;
};
export const navigate = async (path: string, options?: NavigateOptions) => {
// Only guard actual departures: navigating to the current path keeps the
// page, and any unsaved state on it, mounted.
if (path !== currentPath()) {
const navigationsBeforePrompt = committedNavigations;
if (!(await ensureUnsavedChangesConfirmed())) {
return false;
}
if (committedNavigations !== navigationsBeforePrompt) {
// Another navigation landed while the prompt was waiting for an answer,
// so this destination is stale. Dropping it keeps a late answer from
// pulling the user back off the page they are on now.
return false;
}
}
return performNavigation(path, options);
};
/**
* Whether the previous history entry is a page this app navigated away from.
* `history.length` cannot answer this: a login redirect goes through
@@ -222,9 +142,6 @@ export const canGoBack = (): boolean =>
/**
* Navigate back to the page we came from, falling back to a path when the
* previous entry is not ours (deep link, login redirect, fresh tab).
* Deliberately not guarded against unsaved changes: pages with such a guard
* confirm in their own back handlers, and delete flows leave through here
* after the edited item is already gone.
*/
export const goBack = async (fallbackPath?: string): Promise<void> => {
const canProceed = await ensureDialogsClosed(Date.now());
@@ -239,5 +156,5 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
return;
}
await performNavigation(fallbackPath || "/", { replace: true });
await navigate(fallbackPath || "/", { replace: true });
};
@@ -1,37 +0,0 @@
import deepFreeze from "deep-freeze";
const inFlightRequests = new WeakMap<object, Map<string, Promise<unknown>>>();
export const shareInFlightRequest = <T>(
owner: object,
key: string,
fetcher: () => Promise<T>
): Promise<T> => {
let requests = inFlightRequests.get(owner);
if (!requests) {
requests = new Map();
inFlightRequests.set(owner, requests);
}
const ownerRequests = requests;
const existing = ownerRequests.get(key);
if (existing) {
return existing as Promise<T>;
}
const request = fetcher()
.then((result) => deepFreeze(result) as T)
.finally(() => {
if (ownerRequests.get(key) !== request) {
return;
}
ownerRequests.delete(key);
if (ownerRequests.size === 0) {
inFlightRequests.delete(owner);
}
});
ownerRequests.set(key, request);
return request;
};
+14 -43
View File
@@ -3,27 +3,6 @@ import type { TooltipPositionCallback } from "echarts/types/dist/shared";
export const TOOLTIP_GAP_PX = 12;
export const TOOLTIP_TOP_OFFSET_PX = 10;
const offsetFromCursor = (
cursorX: number,
dom: unknown,
viewW: number,
tipW: number
) => {
const rtl =
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
let x = rtl ? leftOfCursor : rightOfCursor;
const overflowsRight = x + tipW > viewW;
const overflowsLeft = x < 0;
if (overflowsRight || overflowsLeft) {
x = rtl ? rightOfCursor : leftOfCursor;
}
return Math.max(0, Math.min(x, viewW - tipW));
};
/**
* Pins the tooltip near the top of the chart and offsets it horizontally
* from the cursor so it never covers the data point being inspected.
@@ -41,29 +20,21 @@ export const sideTooltipPosition: TooltipPositionCallback = (
const [viewW, viewH] = size.viewSize;
const [tipW, tipH] = size.contentSize;
const x = offsetFromCursor(cursorX, dom, viewW, tipW);
const rtl =
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
let x = rtl ? leftOfCursor : rightOfCursor;
const overflowsRight = x + tipW > viewW;
const overflowsLeft = x < 0;
if (overflowsRight || overflowsLeft) {
x = rtl ? rightOfCursor : leftOfCursor;
}
x = Math.max(0, Math.min(x, viewW - tipW));
const y = Math.max(0, Math.min(TOOLTIP_TOP_OFFSET_PX, viewH - tipH));
return [x, y];
};
/**
* Offsets the tooltip horizontally from the cursor and keeps it level with it.
* For item-trigger tooltips where the cursor's row is what the tooltip shows.
*/
export const itemTooltipPosition: TooltipPositionCallback = (
point,
_params,
dom,
_rect,
size
) => {
const [cursorX, cursorY] = point;
const [viewW, viewH] = size.viewSize;
const [tipW, tipH] = size.contentSize;
const x = offsetFromCursor(cursorX, dom, viewW, tipW);
const y = Math.max(0, Math.min(cursorY - tipH / 2, viewH - tipH));
return [x, y];
};
@@ -328,7 +328,6 @@ export class StateHistoryChartLine extends LitElement {
...createYAxisPrecisionBounds({
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
unit: this.unit,
onFractionDigits: (digits) => {
if (digits !== this._yAxisFractionDigits) {
this._yAxisFractionDigits = digits;
@@ -1,3 +1,4 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -12,7 +13,7 @@ import { computeRTL } from "../../common/util/compute_rtl";
import type { TimelineEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { itemTooltipPosition } from "./chart-tooltip-position";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeTimelineColor } from "./timeline-color";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
@@ -23,6 +24,7 @@ import { measureTextWidth } from "../../util/text";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
const ROW_HEIGHT = 30;
const ROW_HEIGHT_INSIDE_LABELS = 64;
const GRID_BOTTOM = 30;
@customElement("state-history-chart-timeline")
@@ -41,6 +43,10 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
/** Draw each row's name above its bar instead of in a label column. */
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -63,6 +69,13 @@ export class StateHistoryChartTimeline extends LitElement {
@state() private _yWidth = 0;
private _width = 0;
private _resize = new ResizeController(this, {
skipInitial: true,
callback: (entries) => entries[0]?.contentRect.width,
});
private _chartTime: Date = new Date();
protected render() {
@@ -70,7 +83,7 @@ export class StateHistoryChartTimeline extends LitElement {
<ha-chart-base
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * ROW_HEIGHT + GRID_BOTTOM}px`}
.height=${`${this.data.length * (this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) + GRID_BOTTOM}px`}
.data=${this._chartData as HaECSeries}
small-controls
@chart-click=${this._handleChartClick}
@@ -180,13 +193,19 @@ export class StateHistoryChartTimeline extends LitElement {
this._generateData();
}
const width = this.insideLabels ? Math.round(this._resize.value ?? 0) : 0;
const widthChanged = width !== this._width;
this._width = width;
if (
!this.hasUpdated ||
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("showNames") ||
changedProps.has("insideLabels") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth")
changedProps.has("_yWidth") ||
widthChanged
) {
this._createOptions();
}
@@ -196,14 +215,22 @@ export class StateHistoryChartTimeline extends LitElement {
const narrow = this.narrow;
const showNames = this.chunked || this.showNames;
const maxInternalLabelWidth = narrow ? 105 : 185;
const labelWidth = showNames
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const insideLabels = this.insideLabels;
const labelWidth =
showNames && !insideLabels
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelMargin = 5;
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
// Keeps the plot aligned with the line charts sharing the y-axis padding.
const plotPadding = insideLabels ? this.paddingYAxis : labelWidth;
// A zero width hides the labels instead of truncating them.
const insideLabelWidth = this._width
? Math.max(0, this._width - plotPadding - labelMargin)
: undefined;
this._chartOptions = {
xAxis: {
type: "time",
@@ -227,41 +254,56 @@ export class StateHistoryChartTimeline extends LitElement {
axisLine: {
show: false,
},
axisLabel: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
axisLabel: insideLabels
? {
show: showNames,
inside: true,
margin: 0,
padding: [0, rtl ? 2 : 0, 14, rtl ? 0 : 2],
align: rtl ? "right" : "left",
verticalAlign: "bottom",
width: insideLabelWidth,
overflow: "truncate",
formatter: (id: string) =>
(this._chartData.find((d) => d.id === id)?.name as string) ??
"",
hideOverlap: true,
}
return label;
},
hideOverlap: true,
},
: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
return label;
},
hideOverlap: true,
},
},
grid: {
top: 10,
top: insideLabels ? 20 : 10,
bottom: GRID_BOTTOM,
left: rtl ? 1 : labelWidth,
right: rtl ? labelWidth : 1,
left: rtl ? 1 : plotPadding,
right: rtl ? plotPadding : 1,
},
tooltip: {
renderMode: "html",
position: itemTooltipPosition,
position: sideTooltipPosition,
confine: true,
formatter: this._renderTooltip,
},
@@ -401,6 +443,10 @@ export class StateHistoryChartTimeline extends LitElement {
}
static styles = css`
:host {
display: block;
}
ha-chart-base {
--chart-max-height: none;
}
@@ -79,6 +79,10 @@ export class StateHistoryCharts extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
/** Draw timeline row names above their bar instead of in a label column. */
@property({ attribute: "inside-labels", type: Boolean, reflect: true })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -227,6 +231,7 @@ export class StateHistoryCharts extends LitElement {
.startTime=${this._computedStartTime}
.endTime=${this._computedEndTime}
.showNames=${this.showNames}
.insideLabels=${this.insideLabels}
.names=${this.names}
.narrow=${this.narrow}
.chunked=${this.virtualize}
@@ -424,6 +429,12 @@ export class StateHistoryCharts extends LitElement {
padding-top: 8px;
}
/* Names inside the plot sit close to the chart above them, so the groups
need more room between them to stay apart. */
:host([inside-labels]) .entry-container.timeline:not(:first-child) {
margin-top: var(--ha-space-8);
}
.entry-container:hover {
z-index: 1;
}
-1
View File
@@ -446,7 +446,6 @@ export class StatisticsChart extends LitElement {
...createYAxisPrecisionBounds({
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
unit: this.unit,
// Bar charts stay anchored at 0, so precision must reflect the
// 0-based range that is actually rendered.
includeZero: !yAxisScale,
+26 -121
View File
@@ -1,32 +1,13 @@
import { intervalScaleEnsureValidExtent } from "echarts/lib/scale/helper";
import { getPrecision, nice, round } from "echarts/lib/util/number";
// A range smaller than this fraction of the axis magnitude is floating-point
// noise (e.g. from summed statistics), not real precision.
const NEGLIGIBLE_RANGE_RATIO = 1e-10;
// Intervals the axis aims for. Passed to ECharts rather than assumed, so the
// precision derived here cannot drift from the ticks it renders.
const SPLIT_NUMBER = 5;
// How thin a gap between the data and the plot edge counts as no gap at all,
// as a fraction of the data span. ECharts floors the axis minimum and ceils the
// maximum to a tick multiple, which usually leaves headroom, but quantized
// states often land exactly on a tick and get none — collapsing area-filled
// series, which are drawn from their value down to the axis minimum. Widening
// the extent by this much before that rounding bumps any axis with less
// headroom out to a full tick, and leaves the rest where they are.
const GAP_FRACTION_OF_SPAN = 0.02;
// A percentage has a real ceiling the way zero is a real floor, so the gap must
// not push the axis past it. Not every `%` sensor is bounded — power factor is
// signed and can read over 100 — so this only applies while the data stays under.
const PERCENT_MAX = 100;
// Derive the number of decimal digits to use for Y-axis labels from the
// observed data range, by asking ECharts for the same tick interval it will
// render. This matches the precision it actually draws, so labels are neither
// truncated to identical values nor padded with extra zeros.
// observed data range. We mirror how ECharts sizes its ticks: it splits the
// range into ~5 intervals (its default `splitNumber`) and rounds that raw
// interval to a "nice" 1/2/3/5×10ⁿ value, then reports the decimals that nice
// interval needs. This matches the precision ECharts actually renders, so
// labels are neither truncated to identical values nor padded with extra zeros.
export function computeYAxisFractionDigits(
min: number,
max: number,
@@ -41,7 +22,13 @@ export function computeYAxisFractionDigits(
// with a tail of zeros (e.g. "0.20000000000000"), so treat it as flat.
const magnitude = Math.max(Math.abs(lo), Math.abs(hi));
if (range <= magnitude * NEGLIGIBLE_RANGE_RATIO) return 1;
return getPrecision(nice(range / SPLIT_NUMBER, true));
const rawInterval = range / 5;
const exponent = Math.floor(Math.log10(rawInterval));
const mantissa = rawInterval / 10 ** exponent; // in [1, 10)
// Rounding the mantissa to a nice value only ever carries to the next power
// of ten (mantissa ≥ 7 → 10), which needs one fewer decimal.
const niceExponent = mantissa >= 7 ? exponent + 1 : exponent;
return Math.max(0, -niceExponent);
}
interface YAxisExtentValues {
@@ -57,116 +44,34 @@ const resolveYAxisBound = (
values: YAxisExtentValues
): number | undefined => (typeof bound === "function" ? bound(values) : bound);
// A constant series has no span for the gap to be a fraction of, so ECharts
// falls back to `Math.abs(min)` when sizing it. That makes the extent unequal,
// which in turn stops `intervalScaleEnsureValidExtent` from applying the ±|v|/2
// expansion a flat series relies on for its window. Run that expansion here and
// return fixed bounds, which also suppresses the gap.
const flatSeriesExtent = (value: number, fixed: [boolean, boolean]) => {
const [lo, hi] = intervalScaleEnsureValidExtent([value, value], fixed);
const interval = nice((hi - lo) / SPLIT_NUMBER, true);
const precision = getPrecision(interval);
return {
min: round(Math.floor(lo / interval) * interval, precision),
max: round(Math.ceil(hi / interval) * interval, precision),
};
};
// Build the `yAxis` options that keep tick-label precision and the plot-edge gap
// in agreement with the extent ECharts renders. It re-invokes the `min`/`max`
// callbacks with the extent of the visible (zoom-filtered) data on every
// dataZoom, and always before the label formatter runs, so the fraction digits
// recomputed here track the zoomed range. A callback returns `undefined`
// wherever auto-scaling should stand, and a number only where the axis has to be
// pinned: an explicit bound, the zero anchor, or a constant series.
// Wrap the Y-axis `min`/`max` options in callbacks so the tick-label precision
// tracks the currently visible axis extent. ECharts re-invokes these callbacks
// with the extent of the visible (zoom-filtered) data on every dataZoom, and
// always before the label formatter runs, so recomputing the fraction digits
// here keeps zoomed-in labels distinct. The callbacks return the original
// bounds unchanged, so auto-scaling still applies when a bound is not set.
export function createYAxisPrecisionBounds(options: {
min?: YAxisBound;
max?: YAxisBound;
// Set for bar axes anchored at 0, so precision reflects the 0-based range.
// Such an axis also gets no gap: pushing it below zero would defeat the zero
// anchoring and leave the bars floating above the axis.
includeZero?: boolean;
// Used to recognise a bounded quantity, so the gap cannot widen the axis past
// a limit the data itself never crosses.
unit?: string;
onFractionDigits: (digits: number) => void;
}): {
min: (values: YAxisExtentValues) => number | undefined;
max: (values: YAxisExtentValues) => number | undefined;
boundaryGap: [number, number];
splitNumber: number;
} {
const { min, max, includeZero, unit, onFractionDigits } = options;
const naturalMax = unit === "%" ? PERCENT_MAX : undefined;
const resolveBounds = (values: YAxisExtentValues) => {
const resolvedMin = resolveYAxisBound(min, values);
const resolvedMax = resolveYAxisBound(max, values);
if (
includeZero ||
!Number.isFinite(values.min) ||
!Number.isFinite(values.max)
) {
return { min: resolvedMin, max: resolvedMax, gap: 0 };
}
if (values.min === values.max) {
const flat = flatSeriesExtent(values.min, [
resolvedMin !== undefined,
resolvedMax !== undefined,
]);
// The expansion is a fraction of the magnitude, so a constant series near
// the ceiling would otherwise overshoot it too.
const flatMax =
naturalMax !== undefined && values.max <= naturalMax
? Math.min(flat.max, naturalMax)
: flat.max;
return {
min: resolvedMin ?? flat.min,
max: resolvedMax ?? flatMax,
gap: 0,
};
}
const gap = (values.max - values.min) * GAP_FRACTION_OF_SPAN;
// Never let the gap carry a series past a boundary it does not itself cross.
const floor = values.min >= 0 ? 0 : undefined;
const ceiling =
naturalMax !== undefined && values.max <= naturalMax
? naturalMax
: values.max <= 0
? 0
: undefined;
return {
min:
resolvedMin ??
(floor !== undefined && values.min - floor < gap ? floor : undefined),
max:
resolvedMax ??
(ceiling !== undefined && ceiling - values.max < gap
? ceiling
: undefined),
gap,
};
};
const { min, max, includeZero, onFractionDigits } = options;
return {
// Always emit the key. `setOption` merges the Y axis rather than replacing
// it, so a conditionally spread gap would survive a chart switching to a
// zero-anchored type and leave its bars floating.
boundaryGap: includeZero
? [0, 0]
: [GAP_FRACTION_OF_SPAN, GAP_FRACTION_OF_SPAN],
splitNumber: SPLIT_NUMBER,
min: (values) => {
const bounds = resolveBounds(values);
const resolvedMin = resolveYAxisBound(min, values);
const resolvedMax = resolveYAxisBound(max, values);
const extentMin = resolvedMin ?? values.min;
const extentMax = resolvedMax ?? values.max;
onFractionDigits(
computeYAxisFractionDigits(
bounds.min ?? values.min - bounds.gap,
bounds.max ?? values.max + bounds.gap,
includeZero
)
computeYAxisFractionDigits(extentMin, extentMax, includeZero)
);
return bounds.min;
return resolvedMin;
},
max: (values) => resolveBounds(values).max,
max: (values) => resolveYAxisBound(max, values),
};
}
+1 -1
View File
@@ -16,10 +16,10 @@ import { computeStateName } from "../../common/entity/compute_state_name";
import { computeRTL } from "../../common/util/compute_rtl";
import { domainToName } from "../../data/integration";
import {
getStatisticIds,
getStatisticLabel,
type StatisticsMetaData,
} from "../../data/recorder";
import { getStatisticIds } from "../../data/recorder_statistic_ids";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import "../ha-combo-box-item";
+1 -1
View File
@@ -90,7 +90,7 @@ class HaAlert extends LitElement {
static styles = css`
.issue-type {
position: relative;
padding: var(--ha-alert-padding, 8px);
padding: 8px;
display: flex;
}
.icon {
-4
View File
@@ -95,10 +95,6 @@ export class HaDrawer extends LitElement {
}
private _handleAfterHide(ev: Event) {
// Ignore wa-after-hide from nested Web Awesome components (e.g. tooltips)
if (ev.target !== ev.currentTarget) {
return;
}
ev.stopPropagation();
this.open = false;
fireEvent(this, "hass-drawer-closed");
@@ -564,73 +564,75 @@ export class HaTargetPickerItemRow extends LitElement {
this.primaryEntitiesOnly
);
let referencedAreas = entries.referenced_areas;
const hiddenAreaIds: string[] = [];
if (this.type === "floor" || this.type === "label") {
referencedAreas = referencedAreas.filter((area_id) => {
const area = this.hass.areas[area_id];
// Absent from the registry is not a filter decision: drop the id
// without marking it hidden, so entities targeted through their
// own area or label are not dropped along with it.
if (!area) {
entries.referenced_areas = entries.referenced_areas.filter(
(area_id) => {
const area = this.hass.areas[area_id];
// Absent from the registry is not a filter decision: drop the id
// without marking it hidden, so entities targeted through their
// own area or label are not dropped along with it.
if (!area) {
return false;
}
if (
(this.type === "floor" || area.labels.includes(this.itemId)) &&
areaMeetsFilter(
area,
this.hass.devices,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
}
hiddenAreaIds.push(area_id);
return false;
}
if (
(this.type === "floor" || area.labels.includes(this.itemId)) &&
areaMeetsFilter(
area,
this.hass.devices,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
}
hiddenAreaIds.push(area_id);
return false;
});
);
}
let referencedDevices = entries.referenced_devices;
const hiddenDeviceIds: string[] = [];
if (
this.type === "floor" ||
this.type === "area" ||
this.type === "label"
) {
referencedDevices = referencedDevices.filter((device_id) => {
const device = this.hass.devices[device_id];
if (!device) {
entries.referenced_devices = entries.referenced_devices.filter(
(device_id) => {
const device = this.hass.devices[device_id];
if (!device) {
return false;
}
if (
!hiddenAreaIds.includes(device.area_id || "") &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
}
hiddenDeviceIds.push(device_id);
return false;
}
if (
!hiddenAreaIds.includes(device.area_id || "") &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
}
hiddenDeviceIds.push(device_id);
return false;
});
);
}
const referencedEntities = entries.referenced_entities.filter(
entries.referenced_entities = entries.referenced_entities.filter(
(entity_id) => {
const entity = this.hass.entities[entity_id];
// Core can reference entities that are absent from the display
@@ -645,9 +647,9 @@ export class HaTargetPickerItemRow extends LitElement {
(this.type === "area" && entity.area_id === this.itemId) ||
(this.type === "floor" &&
entity.area_id &&
referencedAreas.includes(entity.area_id)) ||
entries.referenced_areas.includes(entity.area_id)) ||
(this.type === "label" && entity.labels.includes(this.itemId)) ||
referencedDevices.includes(entity.device_id || "")
entries.referenced_devices.includes(entity.device_id || "")
) {
return entityRegMeetsFilter(
entity,
@@ -662,12 +664,7 @@ export class HaTargetPickerItemRow extends LitElement {
}
);
this._entries = {
...entries,
referenced_areas: referencedAreas,
referenced_devices: referencedDevices,
referenced_entities: referencedEntities,
};
this._entries = entries;
} catch (e) {
// eslint-disable-next-line no-console
console.error("Failed to extract target", e);
+28 -35
View File
@@ -1909,13 +1909,20 @@ export const downloadEnergyData = (
const device_consumption_water = energyData.prefs.device_consumption_water;
const stats = energyData.state.stats;
interface CsvRow {
id: string;
type: string;
unit: string;
data: StatisticValue[];
}
const rows: CsvRow[] = [];
const timeSet = new Set<number>();
Object.values(stats).forEach((stat) => {
stat.forEach((datapoint) => {
timeSet.add(datapoint.start);
});
});
const times = Array.from(timeSet).sort();
const headers =
"entity_id,type,unit," +
times.map((t) => new Date(t).toISOString()).join(",") +
"\n";
const csv: string[] = [];
csv[0] = headers;
const processCsvRow = function (
id: string,
@@ -1923,7 +1930,20 @@ export const downloadEnergyData = (
unit: string,
data: StatisticValue[]
) {
rows.push({ id, type, unit, data });
let n = 0;
const row: string[] = [];
row.push(id);
row.push(type);
row.push(unit.normalize("NFKD"));
times.forEach((t) => {
if (n < data.length && data[n].start === t) {
row.push((data[n].change ?? "").toString());
n++;
} else {
row.push("");
}
});
csv.push(row.join(",") + "\n");
};
const processStat = function (stat: string, type: string, unit: string) {
@@ -2170,33 +2190,6 @@ export const downloadEnergyData = (
);
}
const timeSet = new Set<number>();
rows.forEach((row) => {
row.data.forEach((datapoint) => {
timeSet.add(datapoint.start);
});
});
const times = Array.from(timeSet).sort();
const csv: string[] = [
"entity_id,type,unit," +
times.map((t) => new Date(t).toISOString()).join(",") +
"\n",
];
rows.forEach(({ id, type, unit, data }) => {
let n = 0;
const row: string[] = [id, type, unit.normalize("NFKD")];
times.forEach((t) => {
if (n < data.length && data[n].start === t) {
row.push((data[n].change ?? "").toString());
n++;
} else {
row.push("");
}
});
csv.push(row.join(",") + "\n");
});
const blob = new Blob(csv, {
type: "text/csv",
});
+3
View File
@@ -50,6 +50,9 @@ export const DOMAIN_ATTRIBUTES_UNITS = {
azimuth: "°",
elevation: "°",
},
vacuum: {
battery_level: "%",
},
valve: {
current_position: "%",
},
+4 -2
View File
@@ -40,7 +40,8 @@ export const searchPlaces = (
limit ? `&limit=${limit}` : ""
}${addressdetails ? "&addressdetails=1" : ""}&accept-language=${
hass.locale.language
}&[email protected]`
}&[email protected]`,
{ headers: { "User-Agent": `HomeAssistant/${hass.config.version}` } }
).then((res) => {
if (res.ok) {
return res.json();
@@ -58,7 +59,8 @@ export const reverseGeocode = (
location[1]
}&accept-language=${hass.locale.language}&zoom=${
zoom ?? 18
}&format=jsonv2&[email protected]`
}&format=jsonv2&[email protected]`,
{ headers: { "User-Agent": `HomeAssistant/${hass.config.version}` } }
).then((res) => {
if (res.ok) {
return res.json();
+9
View File
@@ -160,6 +160,15 @@ export const getRecorderEntityOptions = (
entity_id,
});
export const getStatisticIds = (
hass: Pick<HomeAssistant, "callWS">,
statistic_type?: "mean" | "sum"
) =>
hass.callWS<StatisticsMetaData[]>({
type: "recorder/list_statistic_ids",
statistic_type,
});
export const getStatisticMetadata = (
hass: HomeAssistant,
statistic_ids?: string[]
-21
View File
@@ -1,21 +0,0 @@
import { shareInFlightRequest } from "../common/util/share-in-flight-request";
import type { HomeAssistant } from "../types";
import type { StatisticsMetaData } from "./recorder";
type StatisticIdsType = "mean" | "sum";
type StatisticIdsApi = Pick<HomeAssistant, "callWS">;
export const getStatisticIds = (
hass: StatisticIdsApi,
statistic_type?: StatisticIdsType
) =>
shareInFlightRequest(
hass.callWS,
`recorder/list_statistic_ids:${statistic_type ?? "all"}`,
() =>
hass.callWS<StatisticsMetaData[]>({
type: "recorder/list_statistic_ids",
statistic_type,
})
);
+4 -10
View File
@@ -14,7 +14,6 @@ import {
import type { HaEntityPickerEntityFilterFunc } from "./entity/entity";
import type { EntityComboBoxItem } from "./entity/entity_picker";
import type { EntityRegistryDisplayEntry } from "./entity/entity_registry";
import { shareInFlightRequest } from "../common/util/share-in-flight-request";
export const TARGET_SEPARATOR = "________";
@@ -55,18 +54,13 @@ export const extractFromTarget = async (
target: HassServiceTarget,
expandGroup = false,
primaryEntitiesOnly = true
) => {
const request = {
type: "extract_from_target" as const,
) =>
callWS<ExtractFromTargetResult>({
type: "extract_from_target",
target,
expand_group: expandGroup,
primary_entities_only: primaryEntitiesOnly,
};
return shareInFlightRequest(callWS, JSON.stringify(request), () =>
callWS<ExtractFromTargetResult>(request)
);
};
});
export const getTargetEntityCount = (target?: HassServiceTarget): number => {
const tempTarget = {
-10
View File
@@ -526,16 +526,6 @@ export const fetchZwaveNetworkStatus = (
});
};
/** Node IDs of the nodes each node can reach directly, keyed by node ID. */
export const fetchZwaveNetworkNeighbors = (
hass: HomeAssistant,
entry_id: string
): Promise<Record<number, number[]>> =>
hass.callWS({
type: "zwave_js/network_neighbors",
entry_id,
});
export const fetchZwaveDataCollectionStatus = (
hass: HomeAssistant,
entry_id: string
+22 -15
View File
@@ -1,9 +1,5 @@
import type { LitElement, PropertyValues } from "lit";
import type { UnsavedChangesGuard } from "../common/navigate";
import {
registerUnsavedChangesGuard,
unregisterUnsavedChangesGuard,
} from "../common/navigate";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import type { Constructor } from "../types";
export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
@@ -13,34 +9,45 @@ export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
/** Provided by `DirtyStateProviderMixin`. */
declare isDirtyState: boolean;
private _handleUnload = (e: BeforeUnloadEvent) => e.preventDefault();
private _handleClick = async (e: MouseEvent) => {
// get the right target, otherwise the composedPath would return <home-assistant> in the new event
const target = e.composedPath()[0];
if (!isNavigationClick(e)) {
return;
}
private _unsavedChangesGuard: UnsavedChangesGuard = {
isDirty: () => this.isDirtyState,
prompt: () => this.promptDiscardChanges(),
const result = await this.promptDiscardChanges();
if (result) {
this._removeListeners();
if (target) {
const newEvent = new MouseEvent(e.type, e);
target.dispatchEvent(newEvent);
}
}
};
public connectedCallback(): void {
super.connectedCallback();
private _handleUnload = (e: BeforeUnloadEvent) => e.preventDefault();
registerUnsavedChangesGuard(this._unsavedChangesGuard);
private _removeListeners() {
window.removeEventListener("click", this._handleClick, true);
window.removeEventListener("beforeunload", this._handleUnload);
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (this.isDirtyState && this.isConnected) {
window.addEventListener("click", this._handleClick, true);
window.addEventListener("beforeunload", this._handleUnload);
} else {
window.removeEventListener("beforeunload", this._handleUnload);
this._removeListeners();
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
unregisterUnsavedChangesGuard(this._unsavedChangesGuard);
window.removeEventListener("beforeunload", this._handleUnload);
this._removeListeners();
}
protected async promptDiscardChanges(): Promise<boolean> {
@@ -1,5 +1,4 @@
import "@home-assistant/webawesome/dist/components/tree-item/tree-item";
import type WaTreeItem from "@home-assistant/webawesome/dist/components/tree-item/tree-item";
import "@home-assistant/webawesome/dist/components/tree/tree";
import type { WaSelectionChangeEvent } from "@home-assistant/webawesome/dist/events/selection-change";
import { consume, type ContextType } from "@lit/context";
@@ -361,7 +360,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
? html`<ha-list-base>${floorAreas}</ha-list-base>`
: html`<wa-tree
@wa-selection-change=${this._handleSelectionChange}
@dblclick=${this._handleDoubleClick}
>${floorAreas}</wa-tree
>`
}`
@@ -1446,26 +1444,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
this._toggleItem(targetId, false);
}
private _handleDoubleClick(ev: MouseEvent) {
// the expand button and non-selectable items already toggle on single click
if (
ev
.composedPath()
.some((el) => (el as HTMLElement).classList?.contains("expand-button"))
) {
return;
}
const item = (ev.target as HTMLElement).closest<
WaTreeItem & { target: string }
>("wa-tree-item");
if (!item || item.isLeaf || item.preventSelection) {
return;
}
// avoid leaving the label text selected by the double click
window.getSelection()?.removeAllRanges();
this._toggleItem(item.target, !item.expanded);
}
private async _loadConfigEntries() {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
@@ -896,15 +896,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
return;
}
this.yamlErrors = undefined;
resolve(true);
},
onClose: () => resolve(false),
onDiscard: () => {
this.yamlErrors = undefined;
this._markDirtyStateClean();
resolve(true);
},
onDiscard: () => resolve(true),
entityRegistryUpdate: this.entityRegistryUpdate,
entityRegistryEntry: this.registryEntry,
title: this.hass.localize(
@@ -57,7 +57,7 @@ export class CloudAccountOverview extends LitElement {
private _renderTopCard(): TemplateResult {
return html`
<ha-card outlined class="summary-card">
<ha-card outlined>
<div class="card-content">
<div
class="thank-you-header"
@@ -121,8 +121,8 @@ export class CloudAccountOverview extends LitElement {
<p class="muted">
${this.hass.localize("ui.panel.config.cloud.account.funding_note")}
</p>
${this._renderSubscriptionState()}
</div>
${this._renderSubscriptionState()}
</ha-card>
`;
}
@@ -132,7 +132,6 @@ export class CloudAccountOverview extends LitElement {
case "trial":
return html`
<ha-alert
class="subscription-alert"
alert-type="warning"
.title=${this.hass.localize(
"ui.panel.config.cloud.account.overview.trial_title"
@@ -157,7 +156,6 @@ export class CloudAccountOverview extends LitElement {
case "canceled":
return html`
<ha-alert
class="subscription-alert"
alert-type="warning"
.title=${this.hass.localize(
"ui.panel.config.cloud.account.overview.canceled_title"
@@ -183,7 +181,6 @@ export class CloudAccountOverview extends LitElement {
case "expired":
return html`
<ha-alert
class="subscription-alert"
alert-type="error"
.title=${this.hass.localize(
"ui.panel.config.cloud.account.overview.expired_title"
@@ -639,15 +636,11 @@ export class CloudAccountOverview extends LitElement {
width: auto;
color: var(--primary-text-color);
}
/* Prevent the embedded .subscription-alert ha-alert from bleeding outside the card */
.summary-card {
overflow: hidden;
}
.subscription-alert {
ha-alert {
display: block;
--ha-alert-padding: var(--ha-space-3);
margin-top: var(--ha-space-3);
}
.subscription-alert ha-button[slot="action"] {
ha-alert ha-button[slot="action"] {
width: max-content;
white-space: nowrap;
}
@@ -1,4 +1,3 @@
import { mdiSpiderWeb } from "@mdi/js";
import type {
CallbackDataParams,
TopLevelFormatterParams,
@@ -17,7 +16,6 @@ import type {
NetworkLink,
NetworkNode,
} from "../../../../../components/chart/ha-network-graph";
import "../../../../../components/ha-icon-button";
import "../../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
@@ -27,7 +25,6 @@ import type {
ZWaveJSNodeStatus,
} from "../../../../../data/zwave_js";
import {
fetchZwaveNetworkNeighbors,
fetchZwaveNetworkStatus,
getNodeIdFromDevice,
NodeStatus,
@@ -36,7 +33,6 @@ import {
import "../../../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
import type { HomeAssistant, Route } from "../../../../../types";
import { showToast } from "../../../../../util/toast";
@customElement("zwave_js-network-visualization")
export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
@@ -59,19 +55,11 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
@state() private _devices: Record<string, DeviceRegistryEntry> = {};
@state() private _neighbors?: Record<number, number[]>;
@state() private _showNeighbors = false;
@state() private _searchFilter = "";
// Route statistics reference repeaters by device registry ID
private _nodeIdsByDeviceId: Record<string, number> = {};
private _neighborLinks = new Set<string>();
private _loadingNeighbors = false;
public hassSubscribe() {
const subscriptions: Promise<UnsubscribeFunc>[] = [];
const devices: Record<number, DeviceRegistryEntry> = {};
@@ -101,32 +89,6 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
return subscriptions;
}
private async _toggleNeighbors() {
this._showNeighbors = !this._showNeighbors;
if (!this._showNeighbors || this._neighbors || this._loadingNeighbors) {
return;
}
// fetched on demand: reading neighbors turns the radio off briefly
this._loadingNeighbors = true;
try {
this._neighbors = await fetchZwaveNetworkNeighbors(
this.hass,
this.configEntryId
);
} catch (err: unknown) {
this._showNeighbors = false;
showToast(this, {
message:
(err as { message?: string }).message ??
this.hass.localize(
"ui.panel.config.zwave_js.visualization.neighbors_error"
),
});
} finally {
this._loadingNeighbors = false;
}
}
public connectedCallback() {
super.connectedCallback();
this._fetchNetworkStatus();
@@ -154,23 +116,13 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
.searchFilter=${this._searchFilter}
.data=${this._getNetworkData(
this._nodeStatuses,
this._nodeStatistics,
this._showNeighbors ? this._neighbors : undefined
this._nodeStatistics
)}
.searchableAttributes=${this._getSearchableAttributes}
.tooltipFormatter=${this._tooltipFormatter}
@chart-click=${this._handleChartClick}
>
${!this.narrow ? this._renderInputSearch("search") : nothing}
<ha-icon-button
slot="button"
class=${this._showNeighbors ? "active" : "inactive"}
.path=${mdiSpiderWeb}
.label=${this.hass.localize(
"ui.panel.config.zwave_js.visualization.toggle_neighbors"
)}
@click=${this._toggleNeighbors}
></ha-icon-button>
</ha-network-graph>
</hass-subpage>
`;
@@ -232,13 +184,6 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
sourceDevice?.name_by_user ?? sourceDevice?.name ?? source;
const targetName =
targetDevice?.name_by_user ?? targetDevice?.name ?? target;
if (this._neighborLinks.has(`${source}>${target}`)) {
return html`${sourceName}${targetName}<br /><b
>${this.hass.localize(
"ui.panel.config.zwave_js.visualization.neighbor"
)}</b
>`;
}
// links point away from the controller, so the route belongs to the target
const stats =
this._nodeStatistics[target] ?? this._nodeStatistics[source];
@@ -318,8 +263,7 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
private _getNetworkData = memoizeOne(
(
nodeStatuses: Record<number, ZWaveJSNodeStatus>,
nodeStatistics: Record<number, ZWaveJSNodeStatisticsUpdatedMessage>,
neighbors: Record<number, number[]> | undefined
nodeStatistics: Record<number, ZWaveJSNodeStatisticsUpdatedMessage>
): NetworkData => {
const style = getComputedStyle(this);
const nodes: NetworkNode[] = [];
@@ -476,49 +420,7 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
});
});
// Neighbors are the nodes a node can reach directly. They are symmetric
// and carry no signal information, so they fill in the mesh underneath
// the measured routes without overriding them.
const neighborLinks: NetworkLink[] = [];
const neighborKeys = new Set<string>();
Object.entries(neighbors ?? {}).forEach(([nodeId, neighborIds]) => {
neighborIds.forEach((neighborId) => {
const target = String(neighborId);
if (!nodeStatuses[neighborId] || target === nodeId) {
return;
}
const [a, b] = [nodeId, target].sort();
const key = `${a}>${b}`;
if (
neighborKeys.has(key) ||
links.some(
(link) =>
(link.source === nodeId && link.target === target) ||
(link.source === target && link.target === nodeId)
)
) {
return;
}
neighborKeys.add(key);
neighborLinks.push({
source: a,
target: b,
// equal values in both directions render the link without an arrow
value: 1,
reverseValue: 1,
lineStyle: {
width: 1,
color: style.getPropertyValue("--disabled-color"),
type: "dashed",
},
// neighbors are plentiful, let the routes shape the layout
ignoreForceLayout: true,
});
});
});
this._neighborLinks = neighborKeys;
return { nodes, links: [...neighborLinks, ...links], categories };
return { nodes, links, categories };
}
);
@@ -558,17 +460,6 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
ha-input-search {
flex: 1;
}
/* ha-chart-base can't style re-slotted buttons, so mirror its look */
ha-icon-button[slot="button"] {
background: var(--card-background-color);
border-radius: var(--ha-border-radius-sm);
--ha-icon-button-size: 32px;
color: var(--primary-color);
border: 1px solid var(--divider-color);
}
ha-icon-button[slot="button"].inactive {
color: var(--state-inactive-color);
}
`,
];
}
+13 -17
View File
@@ -1126,24 +1126,20 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
}
private async _confirmUnsavedChanged(): Promise<boolean> {
if (!this.isDirtyState) {
return true;
if (this.isDirtyState) {
return showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.panel.config.scene.editor.unsaved_confirm_title"
),
text: this.hass!.localize(
"ui.panel.config.scene.editor.unsaved_confirm_text"
),
confirmText: this.hass!.localize("ui.common.leave"),
dismissText: this.hass!.localize("ui.common.stay"),
destructive: true,
});
}
const confirmed = await showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.panel.config.scene.editor.unsaved_confirm_title"
),
text: this.hass!.localize(
"ui.panel.config.scene.editor.unsaved_confirm_text"
),
confirmText: this.hass!.localize("ui.common.leave"),
dismissText: this.hass!.localize("ui.common.stay"),
destructive: true,
});
if (confirmed) {
this._markDirtyStateClean();
}
return confirmed;
return true;
}
private async _duplicate() {
+1 -6
View File
@@ -804,15 +804,10 @@ export class HaScriptEditor extends SubscribeMixin(
return;
}
this.yamlErrors = undefined;
resolve(true);
},
onClose: () => resolve(false),
onDiscard: () => {
this.yamlErrors = undefined;
this._markDirtyStateClean();
resolve(true);
},
onDiscard: () => resolve(true),
entityRegistryUpdate: this.entityRegistryUpdate,
entityRegistryEntry: this.registryEntry,
title: this.hass.localize(
@@ -52,11 +52,11 @@ import type {
} from "../../../../data/recorder";
import {
clearStatistics,
getStatisticIds,
StatisticMeanType,
updateStatisticsIssues,
validateStatistics,
} from "../../../../data/recorder";
import { getStatisticIds } from "../../../../data/recorder_statistic_ids";
import {
apiContext,
internationalizationContext,
@@ -395,11 +395,9 @@ export class VoiceAssistantsExpose extends LitElement {
aliases: entry?.aliases || [],
};
}
if (result[entityId]) {
result[entityId].assistants_sortable_key = getAssistantsSortableKey(
result[entityId].assistants
);
}
result[entityId].assistants_sortable_key = getAssistantsSortableKey(
result[entityId].assistants
);
});
}
return Object.values(result);
+7 -1
View File
@@ -266,6 +266,7 @@ class HaPanelHistory extends LitElement {
.startTime=${this._startDate}
.endTime=${this._endDate}
.narrow=${this.narrow}
inside-labels
sync-charts
>
</state-history-charts>
@@ -809,7 +810,12 @@ class HaPanelHistory extends LitElement {
flex: 1;
min-width: 0;
overflow: hidden auto;
padding: 16px;
padding: 16px 8px;
}
/* Line the charts up with the toolbar when there are no axis labels. */
:host([narrow]) .results {
padding-inline: 16px;
}
.progress-wrapper {
@@ -188,7 +188,7 @@ class HuiHistoryChartCardFeature
}
if (this._coordinates && !this._coordinates.length) {
return html`
<div class="container no-history-found">
<div class="container">
<div class="info">
${this.hass!.localize(
"ui.components.history_charts.no_history_found"
@@ -280,14 +280,6 @@ class HuiHistoryChartCardFeature
pointer-events: none !important;
}
.no-history-found {
height: 100%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
hui-graph-base {
width: 100%;
--accent-color: var(--feature-color);
@@ -217,34 +217,22 @@ export class HuiClockCardDigital extends LitElement {
"hour minute second"
"hour minute am-pm";
font-size: var(
--ha-clock-card-digital-font-size-small,
var(--ha-font-size-2xl)
);
font-size: 1.5rem;
font-weight: var(--ha-font-weight-medium);
line-height: 0.8;
direction: ltr;
}
.time-title + .time-parts {
font-size: var(
--ha-clock-card-digital-font-size-small,
var(--ha-font-size-2xl)
);
font-size: 1.5rem;
}
.time-parts.size-medium {
font-size: var(
--ha-clock-card-digital-font-size-medium,
calc(48px * var(--ha-font-size-scale))
);
font-size: 3rem;
}
.time-parts.size-large {
font-size: var(
--ha-clock-card-digital-font-size-large,
calc(64px * var(--ha-font-size-scale))
);
font-size: 4rem;
}
.time-parts.size-medium .time-part.second,
@@ -165,7 +165,7 @@ export class HuiViewBackgroundEditor extends LitElement {
}
private _currentBackgroundImage(): string | undefined {
const background = this._backgroundData(this.config);
const background = this._backgroundData(this._config);
return typeof background.image === "object"
? background.image.media_content_id
: background.image;
@@ -1,32 +0,0 @@
import { computeDomain } from "../../../../common/entity/compute_domain";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type {
PictureEntityCardConfig,
TileCardConfig,
} from "../../cards/types";
export const computeFavoriteCardConfig = (
entityId: string
): LovelaceCardConfig => {
// A camera tile would only show a thumbnail, so give the picture the room.
if (computeDomain(entityId) === "camera") {
return {
type: "picture-entity",
entity: entityId,
show_name: false,
show_state: false,
grid_options: {
columns: 6,
rows: 2,
},
} satisfies PictureEntityCardConfig;
}
return {
type: "tile",
entity: entityId,
// Favorites come from all over the home, so name the area.
state_content: ["state", "area_name"],
show_entity_picture: true,
} satisfies TileCardConfig;
};
@@ -34,7 +34,6 @@ import type {
TileCardConfig,
UpdatesCardConfig,
} from "../../cards/types";
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
import {
LARGE_SCREEN_CONDITION,
SMALL_SCREEN_CONDITION,
@@ -272,7 +271,15 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
column_span: maxColumns,
cards: [
favoritesHeadingCard,
...favoriteEntities.map(computeFavoriteCardConfig),
...favoriteEntities.map(
(entityId) =>
({
type: "tile",
entity: entityId,
state_content: ["state", "area_name"],
show_entity_picture: true,
}) satisfies TileCardConfig
),
],
};
}
@@ -4,9 +4,8 @@ import { isComponentLoaded } from "../../../../common/config/is_component_loaded
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import { getCommonControlsUsagePrediction } from "../../../../data/usage_prediction";
import type { HomeAssistant } from "../../../../types";
import type { HeadingCardConfig } from "../../cards/types";
import type { HeadingCardConfig, TileCardConfig } from "../../cards/types";
import type { Condition } from "../../common/validate-condition";
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
import type { LovelaceStrategyDependency } from "../types";
const DEFAULT_LIMIT = 8;
@@ -26,6 +25,13 @@ export interface CommonControlsSectionStrategyConfig {
title_visibilty?: Condition[];
}
const toTileCard = (entity: string): TileCardConfig => ({
type: "tile",
entity,
state_content: ["state", "area_name"],
show_entity_picture: true,
});
@customElement("common-controls-section-strategy")
export class CommonControlsSectionStrategy extends ReactiveElement {
static registryDependencies: readonly LovelaceStrategyDependency[] = [];
@@ -57,9 +63,7 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
// Pinned entities already fill the section, skip the prediction call.
if (includedEntities.length >= limit) {
section.cards!.push(
...includedEntities.slice(0, limit).map(computeFavoriteCardConfig)
);
section.cards!.push(...includedEntities.slice(0, limit).map(toTileCard));
return section;
}
@@ -106,7 +110,7 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
return section;
}
section.cards!.push(...entities.map(computeFavoriteCardConfig));
section.cards!.push(...entities.map(toTileCard));
return section;
}
}
@@ -18,9 +18,11 @@ import type {
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
import type { HomeAssistant } from "../../../types";
import type { LogbookCardConfig } from "../../lovelace/cards/types";
import type {
LogbookCardConfig,
TileCardConfig,
} from "../../lovelace/cards/types";
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import { computeFavoriteCardConfig } from "../../lovelace/strategies/helpers/favorite-cards";
import { computeSecurityAlertCardConfig } from "./security-alerts";
export interface SecurityViewStrategyConfig {
@@ -184,7 +186,15 @@ export class SecurityViewStrategy extends ReactiveElement {
),
heading_style: "title",
},
...favoriteEntities.map(computeFavoriteCardConfig),
...favoriteEntities.map(
(entityId) =>
({
type: "tile",
entity: entityId,
state_content: ["state", "area_name"],
show_entity_picture: true,
}) satisfies TileCardConfig
),
],
});
}
+1 -4
View File
@@ -8403,10 +8403,7 @@
"status": "Status",
"version": "Version",
"data_rate": "Data rate",
"area": "Area",
"neighbor": "In range of each other",
"toggle_neighbors": "Toggle neighbor connections. Loading neighbor data temporarily turns off the Z-Wave adapter.",
"neighbors_error": "Failed to load neighbor connections"
"area": "Area"
},
"node_status": {
"0": "Unknown",
-8
View File
@@ -13,11 +13,3 @@ declare module "echarts/lib/util/states" {
declare module "echarts/lib/chart/sankey/SankeyView" {
export { default } from "echarts/types/src/chart/sankey/SankeyView";
}
declare module "echarts/lib/util/number" {
export * from "echarts/types/src/util/number";
}
declare module "echarts/lib/scale/helper" {
export * from "echarts/types/src/scale/helper";
}
+3 -151
View File
@@ -1,16 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
NavigateOptions,
UnsavedChangesGuard,
} from "../../src/common/navigate";
import {
canGoBack,
goBack,
navigate,
registerUnsavedChangesGuard,
unregisterUnsavedChangesGuard,
} from "../../src/common/navigate";
import type { NavigateOptions } from "../../src/common/navigate";
import { canGoBack, goBack, navigate } from "../../src/common/navigate";
// navigate() closes open dialogs before touching history.
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
@@ -63,145 +54,6 @@ describe("navigate", () => {
});
});
describe("unsaved changes guard", () => {
const registeredGuards: UnsavedChangesGuard[] = [];
// Registering through this keeps afterEach able to clean up the module-level
// registry, which outlives the test that filled it.
const trackGuard = <T extends UnsavedChangesGuard>(guard: T): T => {
registerUnsavedChangesGuard(guard);
registeredGuards.push(guard);
return guard;
};
const registerGuard = (isDirty: boolean, promptResult = true) =>
trackGuard({
isDirty: vi.fn(() => isDirty),
prompt: vi.fn(async () => promptResult),
});
// A guard whose prompt stays open until the test answers it.
const trackDeferredGuard = (isDirty: () => boolean) => {
let answer!: (value: boolean) => void;
const guard = trackGuard({
isDirty,
prompt: vi.fn(
() =>
new Promise<boolean>((resolve) => {
answer = resolve;
})
),
});
return { guard, answerPrompt: (value: boolean) => answer(value) };
};
beforeEach(() => {
setEntry("/config");
});
afterEach(() => {
registeredGuards.splice(0).forEach(unregisterUnsavedChangesGuard);
});
it("navigates without prompting when no guard is dirty", async () => {
const guard = registerGuard(false);
expect(await navigate("/config/areas")).toBe(true);
expect(window.location.pathname).toEqual("/config/areas");
expect(guard.prompt).not.toHaveBeenCalled();
});
it("prompts a dirty guard and navigates when confirmed", async () => {
const guard = registerGuard(true, true);
expect(await navigate("/config/areas")).toBe(true);
expect(guard.prompt).toHaveBeenCalledOnce();
expect(window.location.pathname).toEqual("/config/areas");
});
it("leaves history untouched when the prompt is declined", async () => {
const guard = registerGuard(true, false);
const listener = vi.fn();
window.addEventListener("location-changed", listener);
expect(await navigate("/config/areas")).toBe(false);
window.removeEventListener("location-changed", listener);
expect(guard.prompt).toHaveBeenCalledOnce();
expect(window.location.pathname).toEqual("/config");
expect(listener).not.toHaveBeenCalled();
});
it("does not prompt when navigating to the current path", async () => {
const guard = registerGuard(true, false);
expect(await navigate("/config")).toBe(true);
expect(guard.prompt).not.toHaveBeenCalled();
});
it("skips a pending prompt once no guard is dirty anymore", async () => {
let dirty = true;
const { guard, answerPrompt } = trackDeferredGuard(() => dirty);
const first = navigate("/config/areas");
dirty = false;
expect(await navigate("/config/devices/dashboard")).toBe(true);
expect(window.location.pathname).toEqual("/config/devices/dashboard");
expect(guard.prompt).toHaveBeenCalledOnce();
answerPrompt(true);
await first;
});
it("drops a navigation superseded while its prompt was open", async () => {
let dirty = true;
const { answerPrompt } = trackDeferredGuard(() => dirty);
const superseded = navigate("/config/areas");
dirty = false;
await navigate("/config/devices/dashboard");
answerPrompt(true);
expect(await superseded).toBe(false);
expect(window.location.pathname).toEqual("/config/devices/dashboard");
});
it("shares one pending prompt between concurrent navigations", async () => {
const { guard, answerPrompt } = trackDeferredGuard(() => true);
const first = navigate("/config/areas");
const second = navigate("/config/devices/dashboard");
answerPrompt(true);
await Promise.all([first, second]);
expect(guard.prompt).toHaveBeenCalledOnce();
});
it("stops prompting once the guard is unregistered", async () => {
const guard = registerGuard(true, false);
unregisterUnsavedChangesGuard(guard);
expect(await navigate("/config/areas")).toBe(true);
expect(guard.prompt).not.toHaveBeenCalled();
});
it("does not prompt for goBack's fallback navigation", async () => {
const guard = registerGuard(true, false);
await goBack("/config/cloud/account");
expect(window.location.pathname).toEqual("/config/cloud/account");
expect(guard.prompt).not.toHaveBeenCalled();
});
});
describe("goBack", () => {
beforeEach(() => {
setEntry("/config/cloud/remote");
@@ -1,106 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { shareInFlightRequest } from "../../../src/common/util/share-in-flight-request";
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
describe("shareInFlightRequest", () => {
it("shares an in-flight request for the same owner and key", async () => {
const owner = {};
const request = deferred<number>();
const fetcher = vi.fn(() => request.promise);
const first = shareInFlightRequest(owner, "resource:a", fetcher);
const second = shareInFlightRequest(owner, "resource:a", fetcher);
expect(first).toBe(second);
expect(fetcher).toHaveBeenCalledTimes(1);
request.resolve(42);
await expect(first).resolves.toBe(42);
await expect(second).resolves.toBe(42);
});
it("does not share requests with different keys", async () => {
const owner = {};
const fetcherA = vi.fn(async () => "a");
const fetcherB = vi.fn(async () => "b");
await Promise.all([
shareInFlightRequest(owner, "resource:a", fetcherA),
shareInFlightRequest(owner, "resource:b", fetcherB),
]);
expect(fetcherA).toHaveBeenCalledTimes(1);
expect(fetcherB).toHaveBeenCalledTimes(1);
});
it("does not share requests between different owners", async () => {
const firstOwner = {};
const secondOwner = {};
const firstFetcher = vi.fn(async () => "first");
const secondFetcher = vi.fn(async () => "second");
await Promise.all([
shareInFlightRequest(firstOwner, "resource:a", firstFetcher),
shareInFlightRequest(secondOwner, "resource:a", secondFetcher),
]);
expect(firstFetcher).toHaveBeenCalledTimes(1);
expect(secondFetcher).toHaveBeenCalledTimes(1);
});
it("forgets a request after it resolves", async () => {
const owner = {};
const fetcher = vi.fn(async () => 42);
await shareInFlightRequest(owner, "resource:a", fetcher);
await shareInFlightRequest(owner, "resource:a", fetcher);
expect(fetcher).toHaveBeenCalledTimes(2);
});
it("forgets a request after it rejects", async () => {
const owner = {};
const fetcher = vi
.fn<() => Promise<number>>()
.mockRejectedValueOnce(new Error("failed"))
.mockResolvedValueOnce(42);
await expect(
shareInFlightRequest(owner, "resource:a", fetcher)
).rejects.toThrow("failed");
await expect(
shareInFlightRequest(owner, "resource:a", fetcher)
).resolves.toBe(42);
expect(fetcher).toHaveBeenCalledTimes(2);
});
it("deep-freezes resolved results", async () => {
const owner = {};
const result = { items: ["a"] };
const fetcher = vi.fn(async () => result);
const shared = await shareInFlightRequest(owner, "resource:a", fetcher);
expect(shared).toBe(result);
expect(Object.isFrozen(shared)).toBe(true);
expect(Object.isFrozen((shared as typeof result).items)).toBe(true);
expect(() => {
(shared as typeof result).items.push("b");
}).toThrow();
});
});
@@ -1,103 +0,0 @@
import { describe, expect, it } from "vitest";
import { SVGRenderer } from "echarts/renderers";
import echarts from "../../../src/resources/echarts/echarts";
import { createYAxisPrecisionBounds } from "../../../src/components/chart/y-axis-fraction-digits";
// jsdom has no canvas, and zrender reaches for one to measure label text.
HTMLCanvasElement.prototype.getContext = (() => ({
measureText: () => ({ width: 10 }),
})) as any;
// The app registers the canvas renderer; jsdom needs the SVG one.
echarts.use([SVGRenderer]);
// The gap is opened by ECharts' own tick rounding, not by our helper, so these
// assert the extent ECharts actually renders rather than what we hand it.
const renderExtent = (
data: number[],
yAxis: Record<string, unknown>,
nextYAxis?: Record<string, unknown>
): [number, number] => {
const chart = echarts.init(null, null, {
ssr: true,
renderer: "svg",
width: 400,
height: 300,
});
const option = (axis: Record<string, unknown>) => ({
xAxis: { type: "category", data: data.map((_, index) => String(index)) },
yAxis: { type: "value", ...axis },
series: [{ type: "line", data }],
});
chart.setOption(option(yAxis));
if (nextYAxis) {
chart.setOption(option(nextYAxis), { replaceMerge: ["series"] });
}
// getModel() is internal, but it is the only way to read the rendered extent.
const extent = (chart as any)
.getModel()
.getComponent("yAxis")
.axis.scale.getExtent();
chart.dispose();
return extent;
};
const withGap = (includeZero = false, unit?: string) => ({
scale: !includeZero,
...createYAxisPrecisionBounds({
includeZero,
unit,
onFractionDigits: () => undefined,
}),
});
describe("Y-axis tick nudge", () => {
it("opens a gap under data that lands exactly on a tick", () => {
// The reported bug: a thermostat's states are quantized, so the minimum is
// an exact tick and the HVAC action band has nothing to fill.
expect(renderExtent([18, 21.2, 19], { scale: true })).toEqual([18, 21.5]);
expect(renderExtent([18, 21.2, 19], withGap())).toEqual([17.5, 21.5]);
});
it("leaves an axis with real headroom untouched", () => {
// Its minimum sits well clear of the tick below it, so the rounding it
// already gets is enough and the widened extent floors to the same place.
const data = [18.3, 21.2, 19.5];
expect(renderExtent(data, withGap())).toEqual(
renderExtent(data, { scale: true })
);
});
it("keeps a non-negative series anchored at zero", () => {
expect(renderExtent([0, 3500, 1200], withGap())[0]).toBe(0);
// Without the clamp the nudged minimum floors a whole interval below zero.
expect(
renderExtent([0, 3500, 1200], {
scale: true,
boundaryGap: [0.02, 0.02],
})[0]
).toBe(-1000);
});
it("keeps the window ECharts gives a constant series", () => {
expect(renderExtent([21, 21, 21], withGap())).toEqual(
renderExtent([21, 21, 21], { scale: true })
);
// A series flat at zero has no magnitude to expand by.
expect(renderExtent([0, 0, 0], withGap())).toEqual(
renderExtent([0, 0, 0], { scale: true })
);
});
it("does not round a percentage axis past 100", () => {
const battery = [20, 100, 60, 20, 80];
expect(renderExtent(battery, withGap())).toEqual([0, 120]);
expect(renderExtent(battery, withGap(false, "%"))).toEqual([0, 100]);
});
it("re-anchors at zero when a chart switches to a zero-anchored type", () => {
// setOption merges the Y axis, so a gap that is only emitted conditionally
// survives the switch and leaves the bars floating.
expect(renderExtent([20, 500, 300], withGap(), withGap(true))[0]).toBe(0);
});
});
@@ -72,25 +72,13 @@ describe("computeYAxisFractionDigits", () => {
});
describe("createYAxisPrecisionBounds", () => {
const makeBounds = (
options: Omit<
Parameters<typeof createYAxisPrecisionBounds>[0],
"onFractionDigits"
> = {}
) => {
const onFractionDigits = vi.fn();
return {
...createYAxisPrecisionBounds({ ...options, onFractionDigits }),
onFractionDigits,
};
};
it("computes digits from the visible extent when no bounds are set", () => {
const { min, max, onFractionDigits } = makeBounds();
const onFractionDigits = vi.fn();
const { min, max } = createYAxisPrecisionBounds({ onFractionDigits });
// Zoomed-out extent -> coarse precision, callbacks leave scaling to ECharts
expect(min({ min: 10, max: 100 })).toBeUndefined();
expect(max({ min: 10, max: 100 })).toBeUndefined();
expect(min({ min: 0, max: 100 })).toBeUndefined();
expect(max({ min: 0, max: 100 })).toBeUndefined();
expect(onFractionDigits).toHaveBeenLastCalledWith(0);
// Zoomed-in narrow extent -> more decimals so ticks stay distinct
@@ -99,7 +87,12 @@ describe("createYAxisPrecisionBounds", () => {
});
it("computes digits from numeric bounds and returns them unchanged", () => {
const { min, max, onFractionDigits } = makeBounds({ min: 1.85, max: 2 });
const onFractionDigits = vi.fn();
const { min, max } = createYAxisPrecisionBounds({
min: 1.85,
max: 2,
onFractionDigits,
});
// Fixed bounds pin the range, so the visible extent is ignored
expect(min({ min: 1.9, max: 1.95 })).toBe(1.85);
@@ -108,9 +101,11 @@ describe("createYAxisPrecisionBounds", () => {
});
it("resolves function bounds and passes their result through", () => {
const { min, max, onFractionDigits } = makeBounds({
const onFractionDigits = vi.fn();
const { min, max } = createYAxisPrecisionBounds({
min: ({ min: dataMin }) => dataMin - 1,
max: ({ max: dataMax }) => dataMax + 1,
onFractionDigits,
});
expect(min({ min: 10, max: 11 })).toBe(9);
@@ -120,7 +115,11 @@ describe("createYAxisPrecisionBounds", () => {
});
it("unions the extent with zero for anchored axes", () => {
const { min, onFractionDigits } = makeBounds({ includeZero: true });
const onFractionDigits = vi.fn();
const { min } = createYAxisPrecisionBounds({
includeZero: true,
onFractionDigits,
});
// Data sits at 20..25, but a bar axis renders from 0 -> coarse precision
min({ min: 20, max: 25 });
@@ -132,102 +131,10 @@ describe("createYAxisPrecisionBounds", () => {
});
it("does not over-pad when the visible extent collapses to noise", () => {
const { min, onFractionDigits } = makeBounds();
const onFractionDigits = vi.fn();
const { min } = createYAxisPrecisionBounds({ onFractionDigits });
min({ min: 0.3, max: 0.3 + 1e-15 });
expect(onFractionDigits).toHaveBeenLastCalledWith(1);
});
it("widens the extent so ECharts always rounds out a gap", () => {
const { min, max, boundaryGap, splitNumber } = makeBounds();
// The gap is applied by ECharts, so the bounds stay auto-scaled.
expect(boundaryGap).toEqual([0.02, 0.02]);
// Pinned rather than assumed, since the precision here is derived from it.
expect(splitNumber).toBe(5);
expect(min({ min: 18, max: 21.2 })).toBeUndefined();
expect(max({ min: 18, max: 21.2 })).toBeUndefined();
});
it("anchors at zero instead of widening a non-negative series below it", () => {
const { min, max } = makeBounds();
// Without this, ECharts floors the widened -0.0035 to a full interval below
// zero, inventing a negative region on a power chart.
expect(min({ min: 0, max: 3500 })).toBe(0);
expect(max({ min: 0, max: 3500 })).toBeUndefined();
// A minimum clear of the gap is left to ECharts.
expect(min({ min: 100, max: 3500 })).toBeUndefined();
});
it("anchors an all-negative series at zero from above", () => {
const { min, max } = makeBounds();
expect(max({ min: -3500, max: 0 })).toBe(0);
expect(min({ min: -3500, max: 0 })).toBeUndefined();
// Mixed-sign data is widened at both ends.
expect(min({ min: -20, max: 20 })).toBeUndefined();
expect(max({ min: -20, max: 20 })).toBeUndefined();
});
it("keeps the expanded window ECharts gives a constant series", () => {
const { min, max, onFractionDigits } = makeBounds();
// ECharts only expands a flat extent while its bounds are equal, so the gap
// would otherwise collapse this to 20.99998..21.00002 at five decimals.
expect(min({ min: 21, max: 21 })).toBe(10);
expect(max({ min: 21, max: 21 })).toBe(35);
expect(onFractionDigits).toHaveBeenLastCalledWith(0);
// A series flat at zero has no magnitude to expand by.
expect(min({ min: 0, max: 0 })).toBe(0);
expect(max({ min: 0, max: 0 })).toBe(1);
});
it("stops a percentage axis at 100 the way it stops at zero", () => {
const { max } = makeBounds({ unit: "%" });
// A battery that reaches full would otherwise round out to an axis
// labelled up to 120%.
expect(max({ min: 20, max: 100 })).toBe(100);
expect(max({ min: 20, max: 99 })).toBe(100);
// Well clear of the ceiling, so it still gets its gap.
expect(max({ min: 40, max: 60 })).toBeUndefined();
// Not every % sensor is bounded: power factor is signed and can read over
// 100, and a series already past the ceiling must be left alone.
expect(max({ min: -80, max: 140 })).toBeUndefined();
});
it("holds a constant percentage under the ceiling too", () => {
const { max } = makeBounds({ unit: "%" });
// A device left on the charger reads a flat 100%, and the expansion a flat
// series gets is a fraction of its magnitude, so it overshoots on its own.
expect(max({ min: 100, max: 100 })).toBe(100);
expect(max({ min: 80, max: 80 })).toBe(100);
// Far enough below that the expansion never reaches the ceiling.
expect(max({ min: 54, max: 54 })).toBe(90);
});
it("only treats a percentage as bounded", () => {
// The same extent without the unit is widened as usual.
expect(makeBounds().max({ min: 20, max: 100 })).toBeUndefined();
expect(
makeBounds({ unit: "W" }).max({ min: 20, max: 100 })
).toBeUndefined();
});
it("never widens a zero-anchored axis", () => {
const { min, max, boundaryGap } = makeBounds({ includeZero: true });
// Widening below zero would defeat ECharts' anchoring and float the bars.
expect(boundaryGap).toEqual([0, 0]);
expect(min({ min: 0, max: 3500 })).toBeUndefined();
expect(max({ min: 20, max: 20 })).toBeUndefined();
});
});
@@ -173,31 +173,4 @@ describe("ha-target-picker-item-row target extraction", () => {
expect(entries!.referenced_devices).toEqual(["dev_1"]);
expect(entries!.referenced_entities).toEqual(["light.on_dev1"]);
});
it("does not mutate the extracted target result", async () => {
const result = extractResult({
referenced_areas: ["area_missing"],
referenced_devices: ["dev_missing"],
referenced_entities: ["light.missing"],
});
const entries = await extractedBy(
result,
{
areas: {},
devices: {},
entities: {},
},
{ type: "floor", itemId: "floor_1" }
);
expect(entries).toBeDefined();
expect(entries).not.toBe(result);
expect(entries!.referenced_areas).toEqual([]);
expect(entries!.referenced_devices).toEqual([]);
expect(entries!.referenced_entities).toEqual([]);
expect(result.referenced_areas).toEqual(["area_missing"]);
expect(result.referenced_devices).toEqual(["dev_missing"]);
expect(result.referenced_entities).toEqual(["light.missing"]);
});
});
-136
View File
@@ -1,136 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { HomeAssistant } from "../../src/types";
import { getStatisticIds } from "../../src/data/recorder_statistic_ids";
import type { StatisticsMetaData } from "../../src/data/recorder";
const createHass = (callWS = vi.fn()) =>
({
callWS,
}) as unknown as Pick<HomeAssistant, "callWS">;
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
describe("getStatisticIds", () => {
it("shares concurrent requests for the same statistic type", async () => {
const request = deferred<StatisticsMetaData[]>();
const callWS = vi.fn().mockReturnValue(request.promise);
const hass = createHass(callWS);
const first = getStatisticIds(hass);
const second = getStatisticIds(hass);
expect(callWS).toHaveBeenCalledTimes(1);
expect(second).toBe(first);
request.resolve([]);
await Promise.all([first, second]);
});
it("works with callers that only expose callWS", async () => {
const request = deferred<StatisticsMetaData[]>();
const callWS = vi.fn().mockReturnValue(request.promise);
const api = { callWS } as Pick<HomeAssistant, "callWS">;
const first = getStatisticIds(api);
const second = getStatisticIds(api);
expect(callWS).toHaveBeenCalledTimes(1);
expect(second).toBe(first);
request.resolve([]);
await Promise.all([first, second]);
});
it("shares requests across callers with the same callWS", async () => {
const request = deferred<StatisticsMetaData[]>();
const callWS = vi.fn().mockReturnValue(request.promise);
const firstHass = createHass(callWS);
const secondHass = createHass(callWS);
const first = getStatisticIds(firstHass);
const second = getStatisticIds(secondHass);
expect(callWS).toHaveBeenCalledTimes(1);
expect(second).toBe(first);
request.resolve([]);
await Promise.all([first, second]);
});
it("does not share requests for different statistic types", async () => {
const callWS = vi.fn().mockResolvedValue([]);
const hass = createHass(callWS);
await Promise.all([
getStatisticIds(hass),
getStatisticIds(hass, "mean"),
getStatisticIds(hass, "sum"),
]);
expect(callWS).toHaveBeenCalledTimes(3);
expect(callWS).toHaveBeenNthCalledWith(1, {
type: "recorder/list_statistic_ids",
statistic_type: undefined,
});
expect(callWS).toHaveBeenNthCalledWith(2, {
type: "recorder/list_statistic_ids",
statistic_type: "mean",
});
expect(callWS).toHaveBeenNthCalledWith(3, {
type: "recorder/list_statistic_ids",
statistic_type: "sum",
});
});
it("does not share requests across different callWS owners", async () => {
const firstRequest = deferred<StatisticsMetaData[]>();
const secondRequest = deferred<StatisticsMetaData[]>();
const firstCallWS = vi.fn().mockReturnValue(firstRequest.promise);
const secondCallWS = vi.fn().mockReturnValue(secondRequest.promise);
const first = getStatisticIds(createHass(firstCallWS));
const second = getStatisticIds(createHass(secondCallWS));
expect(firstCallWS).toHaveBeenCalledTimes(1);
expect(secondCallWS).toHaveBeenCalledTimes(1);
expect(second).not.toBe(first);
firstRequest.resolve([]);
secondRequest.resolve([]);
await Promise.all([first, second]);
});
it("fetches again after the previous request settles", async () => {
const callWS = vi.fn().mockResolvedValue([]);
const hass = createHass(callWS);
await getStatisticIds(hass, "sum");
await getStatisticIds(hass, "sum");
expect(callWS).toHaveBeenCalledTimes(2);
});
it("retries after a failed request", async () => {
const callWS = vi
.fn()
.mockRejectedValueOnce(new Error("failed"))
.mockResolvedValueOnce([]);
const hass = createHass(callWS);
await expect(getStatisticIds(hass)).rejects.toThrow("failed");
await getStatisticIds(hass);
expect(callWS).toHaveBeenCalledTimes(2);
});
});
-80
View File
@@ -1,80 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { extractFromTarget } from "../../src/data/target";
type CallWS = Parameters<typeof extractFromTarget>[0];
const callWS = () => vi.fn().mockResolvedValue({}) as unknown as CallWS;
describe("extractFromTarget in-flight sharing", () => {
it("shares identical concurrent requests", async () => {
const ws = callWS();
const target = { area_id: ["bathroom"] };
await Promise.all([
extractFromTarget(ws, target),
extractFromTarget(ws, target),
]);
expect(ws).toHaveBeenCalledTimes(1);
});
it("does not share different targets", async () => {
const ws = callWS();
await Promise.all([
extractFromTarget(ws, { area_id: ["bathroom"] }),
extractFromTarget(ws, { area_id: ["kitchen"] }),
]);
expect(ws).toHaveBeenCalledTimes(2);
});
it("does not share different expandGroup values", async () => {
const ws = callWS();
const target = { area_id: ["bathroom"] };
await Promise.all([
extractFromTarget(ws, target, false),
extractFromTarget(ws, target, true),
]);
expect(ws).toHaveBeenCalledTimes(2);
});
it("does not share different primaryEntitiesOnly values", async () => {
const ws = callWS();
const target = { area_id: ["bathroom"] };
await Promise.all([
extractFromTarget(ws, target, false, true),
extractFromTarget(ws, target, false, false),
]);
expect(ws).toHaveBeenCalledTimes(2);
});
it("does not share between different callWS owners", async () => {
const first = callWS();
const second = callWS();
const target = { area_id: ["bathroom"] };
await Promise.all([
extractFromTarget(first, target),
extractFromTarget(second, target),
]);
expect(first).toHaveBeenCalledTimes(1);
expect(second).toHaveBeenCalledTimes(1);
});
it("fetches again after the request settles", async () => {
const ws = callWS();
const target = { area_id: ["bathroom"] };
await extractFromTarget(ws, target);
await extractFromTarget(ws, target);
expect(ws).toHaveBeenCalledTimes(2);
});
});
-143
View File
@@ -1,143 +0,0 @@
import { LitElement } from "lit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { navigate } from "../../src/common/navigate";
import { DirtyStateProviderMixin } from "../../src/mixins/dirty-state-provider-mixin";
import { PreventUnsavedMixin } from "../../src/mixins/prevent-unsaved-mixin";
// navigate() closes open dialogs before touching history.
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
closeAllDialogs: vi.fn(async () => true),
}));
interface TestState {
name: string;
}
// Mirrors the editors: DirtyStateProviderMixin wraps PreventUnsavedMixin.
class TestPreventUnsaved extends DirtyStateProviderMixin<TestState>()(
PreventUnsavedMixin(LitElement)
) {
public promptResponse = true;
public promptCalls = 0;
public initialize(value: TestState) {
this._initDirtyTracking({ type: "shallow" }, value);
}
public setValue(value: TestState) {
this._updateDirtyState(value);
}
public markClean() {
this._markDirtyStateClean();
}
protected async promptDiscardChanges(): Promise<boolean> {
this.promptCalls++;
return this.promptResponse;
}
}
customElements.define("test-prevent-unsaved", TestPreventUnsaved);
declare global {
interface HTMLElementTagNameMap {
"test-prevent-unsaved": TestPreventUnsaved;
}
}
const setEntry = (path: string) => {
window.history.replaceState(null, "", path);
};
const mountClean = async () => {
const element = document.createElement("test-prevent-unsaved");
document.body.append(element);
element.initialize({ name: "Kitchen" });
await element.updateComplete;
return element;
};
const mountDirty = async () => {
const element = await mountClean();
element.setValue({ name: "Bedroom" });
await element.updateComplete;
return element;
};
describe("PreventUnsavedMixin", () => {
beforeEach(() => {
setEntry("/config/automation/edit/1234");
});
afterEach(() => {
document.querySelectorAll("test-prevent-unsaved").forEach((element) => {
element.remove();
});
});
it("blocks navigation while dirty when the prompt is declined", async () => {
const element = await mountDirty();
element.promptResponse = false;
expect(await navigate("/config/scene/dashboard")).toBe(false);
expect(element.promptCalls).toBe(1);
expect(window.location.pathname).toEqual("/config/automation/edit/1234");
});
it("navigates while dirty when the prompt is confirmed", async () => {
const element = await mountDirty();
expect(await navigate("/config/scene/dashboard")).toBe(true);
expect(element.promptCalls).toBe(1);
expect(window.location.pathname).toEqual("/config/scene/dashboard");
});
it("does not prompt while clean", async () => {
const element = await mountClean();
expect(await navigate("/config/scene/dashboard")).toBe(true);
expect(element.promptCalls).toBe(0);
expect(window.location.pathname).toEqual("/config/scene/dashboard");
});
it("does not prompt after changes are marked clean, before the next render", async () => {
const element = await mountDirty();
element.markClean();
expect(await navigate("/config/scene/dashboard")).toBe(true);
expect(element.promptCalls).toBe(0);
expect(window.location.pathname).toEqual("/config/scene/dashboard");
});
it("stops guarding after being disconnected", async () => {
const element = await mountDirty();
element.promptResponse = false;
element.remove();
expect(await navigate("/config/scene/dashboard")).toBe(true);
expect(element.promptCalls).toBe(0);
expect(window.location.pathname).toEqual("/config/scene/dashboard");
});
it("arms beforeunload only while dirty", async () => {
const element = await mountDirty();
let event = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
element.setValue({ name: "Kitchen" });
await element.updateComplete;
event = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
});
});
+105 -114
View File
@@ -5368,7 +5368,7 @@ __metadata:
languageName: node
linkType: hard
"@sveltejs/acorn-typescript@npm:^1.0.13":
"@sveltejs/acorn-typescript@npm:^1.0.10":
version: 1.0.13
resolution: "@sveltejs/acorn-typescript@npm:1.0.13"
peerDependencies:
@@ -5951,105 +5951,105 @@ __metadata:
languageName: node
linkType: hard
"@typescript-eslint/eslint-plugin@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.68.0"
"@typescript-eslint/eslint-plugin@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.67.0"
dependencies:
"@eslint-community/regexpp": "npm:^4.12.2"
"@typescript-eslint/scope-manager": "npm:8.68.0"
"@typescript-eslint/type-utils": "npm:8.68.0"
"@typescript-eslint/utils": "npm:8.68.0"
"@typescript-eslint/visitor-keys": "npm:8.68.0"
"@typescript-eslint/scope-manager": "npm:8.67.0"
"@typescript-eslint/type-utils": "npm:8.67.0"
"@typescript-eslint/utils": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
ignore: "npm:^7.0.5"
natural-compare: "npm:^1.4.0"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
"@typescript-eslint/parser": ^8.68.0
"@typescript-eslint/parser": ^8.67.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/8776fd8ffde8eceae3b1aeca57e528e8ee61a683223c3bef455ca3c5e28e52275e6cd6ebf8ee284a46883e6220bbf297312084e1ee85e00cef017a78c169a674
checksum: 10/d53f9e51c6be98ff4cdcb94de24e2951f5e2b7a1dfc56c4f8a2d81e61346c511c0478117626d9cfd5d5d63f5e7a0e0653dc1abe1e7d926a3f9806aaf1fa11bd2
languageName: node
linkType: hard
"@typescript-eslint/parser@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/parser@npm:8.68.0"
"@typescript-eslint/parser@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/parser@npm:8.67.0"
dependencies:
"@typescript-eslint/scope-manager": "npm:8.68.0"
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/typescript-estree": "npm:8.68.0"
"@typescript-eslint/visitor-keys": "npm:8.68.0"
"@typescript-eslint/scope-manager": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
debug: "npm:^4.4.3"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/979ae0d2c6a391fd5b9b0218189fda5112ff0b5ab2538eef1da4cd77ac8ae46f9b590db9779f2614bd193105b40cf12635b565c840f4cdbbb4a683e24cf5ec4b
checksum: 10/fa156ade066d0886ea0daa0f872940cdb450a2b5dbd1687e0df64ec19aac86711dde51e17e6b20e72387aa38daec811736821e9db950e8557affa7faea63d65c
languageName: node
linkType: hard
"@typescript-eslint/project-service@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/project-service@npm:8.68.0"
"@typescript-eslint/project-service@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/project-service@npm:8.67.0"
dependencies:
"@typescript-eslint/tsconfig-utils": "npm:^8.68.0"
"@typescript-eslint/types": "npm:^8.68.0"
"@typescript-eslint/tsconfig-utils": "npm:^8.67.0"
"@typescript-eslint/types": "npm:^8.67.0"
debug: "npm:^4.4.3"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/37521df3731f9069c7df55362342dd0027f43947241de33c2a1c029b4553fe092e2ac69a534153aaf08c862e6b2390d864cb6c88a0714fc276395390df276fb6
checksum: 10/3beccdee1e74060eac4e6c5e7edd14a800c8c98e3313dbfe5838a357e83babc22c41c3ebe637c331c4b93db2da50f5479ccf4677db7b6b1a760f3cd27795b36f
languageName: node
linkType: hard
"@typescript-eslint/scope-manager@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/scope-manager@npm:8.68.0"
"@typescript-eslint/scope-manager@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/scope-manager@npm:8.67.0"
dependencies:
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/visitor-keys": "npm:8.68.0"
checksum: 10/494d499315b4f3ab8eaa114aa53a4b2b9f533b82eb007cd5b5617cececfd8ad6938edb74f734479ae6e40a4086f510b6b984de8e66d06c518cb47817549db780
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
checksum: 10/a0ca8d52e6c5b2538f7df860acd5a36437cce8e6d5bcededdfde04c02489f4b523cb178b502c266d138cff6758ea375ebe311b2b6af373b127ded4876a697ffb
languageName: node
linkType: hard
"@typescript-eslint/tsconfig-utils@npm:8.68.0, @typescript-eslint/tsconfig-utils@npm:^8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.68.0"
"@typescript-eslint/tsconfig-utils@npm:8.67.0, @typescript-eslint/tsconfig-utils@npm:^8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.67.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/b527a7c5deaad40bb72f78eec1bfea5997a7e792e64666984605e9a167246cf856c8d40290a7b3d39cefb3a242538e22a3a4fc136132e4669a314887e2de5f08
checksum: 10/736d0ab8a273b4e4e17d412b390386eec0171cd3f7ba451bacd92c4f931051bba2049e040fdae84603ea0e07430afb05b49c6d8b6d429f9012d3c6b03eb69f54
languageName: node
linkType: hard
"@typescript-eslint/type-utils@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/type-utils@npm:8.68.0"
"@typescript-eslint/type-utils@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/type-utils@npm:8.67.0"
dependencies:
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/typescript-estree": "npm:8.68.0"
"@typescript-eslint/utils": "npm:8.68.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/utils": "npm:8.67.0"
debug: "npm:^4.4.3"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/ed6b6fe74eee017a79c9dbfa72b3a08748dac8de5702314494d8849feee516c20e2907ad730fd82e50be81c5984c575b29e35eb8b5c99091445b4bc5102ce836
checksum: 10/a0b943e5a8f3c2e8490ec28599eb94561d7ad029fcf42f2e9a0e8d2685ce625898371cd0a7d3a906fe8b4077d72fee8329cad512a5b0521bc2167f7d88a984fb
languageName: node
linkType: hard
"@typescript-eslint/types@npm:8.68.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/types@npm:8.68.0"
checksum: 10/f9337e0263d95e6edca6e364f9b1f70d5840b9648b5aa0d02065bcee5809a50c92d024ba17b458182ec4e7fffe5efd05584f1667b1e43ff3e7121b218283a207
"@typescript-eslint/types@npm:8.67.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/types@npm:8.67.0"
checksum: 10/8edc1a14a52c7566409c19b34fb72ba3a62a3b409c98b1d496de9fcd83179fb128c8df2fde391c199316a54cdc5d314ba70ce7b440161dd850b73a44f3e54e6d
languageName: node
linkType: hard
"@typescript-eslint/typescript-estree@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/typescript-estree@npm:8.68.0"
"@typescript-eslint/typescript-estree@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/typescript-estree@npm:8.67.0"
dependencies:
"@typescript-eslint/project-service": "npm:8.68.0"
"@typescript-eslint/tsconfig-utils": "npm:8.68.0"
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/visitor-keys": "npm:8.68.0"
"@typescript-eslint/project-service": "npm:8.67.0"
"@typescript-eslint/tsconfig-utils": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
debug: "npm:^4.4.3"
minimatch: "npm:^10.2.2"
semver: "npm:^7.7.3"
@@ -6057,32 +6057,32 @@ __metadata:
ts-api-utils: "npm:^2.5.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/47d75cbc9c10842aeb3c1cc8d0625bd578b3b43b75d3dc86d3543a739b2fc8d0b524fd86b5af1f1493a3f0ae1b9e65c3200bf01a6bde0162efe6414708eac553
checksum: 10/31c5be812f67c997b9d1e9324c4289b6268a12e9948492403fdd9c9b5df12dcc00f845ae93ae37c78f717583519060175f2147f165b65112eef1e40acf44ad1d
languageName: node
linkType: hard
"@typescript-eslint/utils@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/utils@npm:8.68.0"
"@typescript-eslint/utils@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/utils@npm:8.67.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.9.1"
"@typescript-eslint/scope-manager": "npm:8.68.0"
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/typescript-estree": "npm:8.68.0"
"@typescript-eslint/scope-manager": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/6581a1e7ced23cce82279df001d643844c97408bc84826da77ec77d52b77047004b202ff8cfcfbc0b0fef15bf2cafb5ae21bafbda24c9b58102ce634f66c2af9
checksum: 10/8703468cdea3630275c6faacc031e7e4bd7fb8f8988e80ab987811ae3c63a9a9677496cee2b4811f49cb9bc9e6f6c0bdb91cb213353019ce31df0a2b351cea51
languageName: node
linkType: hard
"@typescript-eslint/visitor-keys@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/visitor-keys@npm:8.68.0"
"@typescript-eslint/visitor-keys@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/visitor-keys@npm:8.67.0"
dependencies:
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/types": "npm:8.67.0"
eslint-visitor-keys: "npm:^5.0.0"
checksum: 10/d802bc2c4569495f3d83f9bcf169e3a432ac1c6672e243399886409dbc55d7fa30375d9d0b2cf0d9466b81fc2b01b991483eb56e8cdb8141721009978d3b078b
checksum: 10/c92c9b202105a8f398ab2ad4be58c8c8fd9af6b5ddd28cdcd57d75f7f04f69561a56a05aac9a008668c64faa7222ebb3fefe19097923a18eb2b9f7a90809a5be
languageName: node
linkType: hard
@@ -6787,7 +6787,7 @@ __metadata:
languageName: node
linkType: hard
"acorn@npm:^8.10.0, acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.16.0, acorn@npm:^8.18.0":
"acorn@npm:^8.10.0, acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.16.0, acorn@npm:^8.17.0":
version: 8.18.0
resolution: "acorn@npm:8.18.0"
bin:
@@ -9097,9 +9097,9 @@ __metadata:
languageName: node
linkType: hard
"eslint@npm:10.9.1":
version: 10.9.1
resolution: "eslint@npm:10.9.1"
"eslint@npm:10.9.0":
version: 10.9.0
resolution: "eslint@npm:10.9.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.8.0"
"@eslint-community/regexpp": "npm:^4.12.2"
@@ -9138,7 +9138,7 @@ __metadata:
optional: true
bin:
eslint: bin/eslint.js
checksum: 10/4cee237c7d2c445a05330e595b18521d7452fd3f419241bf1ccf81c769a6761c10ca5a06988197482d4842ed1a26b1dcb51b9526408990cee84559e6c08cb3ab
checksum: 10/1cd63bbf4f2c003ed759ab6ae9e2030be0abafdcd7b9ffb5be9e9c3aa3ea6142e6af0a337aac21044957750807fde06c9d052e2c9734cca6792dc2d095f9c180
languageName: node
linkType: hard
@@ -10206,7 +10206,7 @@ __metadata:
echarts: "npm:6.1.0"
echarts-extension-chart2music: "npm:0.1.1"
element-internals-polyfill: "npm:3.0.2"
eslint: "npm:10.9.1"
eslint: "npm:10.9.0"
eslint-config-prettier: "npm:10.1.8"
eslint-import-resolver-webpack: "npm:0.13.11"
eslint-plugin-import-x: "npm:4.17.1"
@@ -10229,7 +10229,7 @@ __metadata:
husky: "npm:9.1.7"
idb-keyval: "npm:6.3.0"
intl-messageformat: "npm:11.2.14"
js-yaml: "npm:5.4.0"
js-yaml: "npm:5.3.0"
jsdom: "npm:30.0.1"
jszip: "npm:3.10.1"
leaflet: "npm:1.9.4"
@@ -10246,9 +10246,9 @@ __metadata:
luxon: "npm:3.7.2"
map-stream: "npm:0.0.7"
maplibre-gl: "npm:5.24.0"
marked: "npm:18.0.11"
marked: "npm:18.0.10"
memoize-one: "npm:6.0.0"
minify-literals: "npm:2.2.0"
minify-literals: "npm:2.1.0"
node-vibrant: "npm:4.0.4"
object-hash: "npm:3.0.0"
pinst: "npm:3.0.0"
@@ -10269,7 +10269,7 @@ __metadata:
tinykeys: "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch"
ts-lit-plugin: "npm:2.0.2"
typescript: "npm:6.0.3"
typescript-eslint: "npm:8.68.0"
typescript-eslint: "npm:8.67.0"
vite-tsconfig-paths: "npm:6.1.1"
vitest: "npm:4.1.11"
webpack-stats-plugin: "npm:1.1.3"
@@ -10327,9 +10327,9 @@ __metadata:
languageName: node
linkType: hard
"html-minifier-next@npm:^8.1.0":
version: 8.1.0
resolution: "html-minifier-next@npm:8.1.0"
"html-minifier-next@npm:^7.0.0":
version: 7.5.3
resolution: "html-minifier-next@npm:7.5.3"
dependencies:
commander: "npm:^15.0.0"
entities: "npm:^8.0.0"
@@ -10344,7 +10344,7 @@ __metadata:
bin:
hmn: cli.js
html-minifier-next: cli.js
checksum: 10/5badf1abd2c6fb9ada2f7dd4929be74f43155510298f7af323b0e39a2c20e2a33ed8de507f105bcea389118f2813e7776b559cf4b2b6397537c5871aa27f2125
checksum: 10/fe4ad84f5c577c2de936c7aad5c0a7619c5e7ce3763f3c2e15f1ecc9629d89ebc578a38c0e44883cc0388253de262962610f7dbc3101f8d16e363c477a6a9b07
languageName: node
linkType: hard
@@ -11177,14 +11177,14 @@ __metadata:
languageName: node
linkType: hard
"js-yaml@npm:5.4.0":
version: 5.4.0
resolution: "js-yaml@npm:5.4.0"
"js-yaml@npm:5.3.0":
version: 5.3.0
resolution: "js-yaml@npm:5.3.0"
dependencies:
argparse: "npm:^2.0.1"
bin:
js-yaml: bin/js-yaml.mjs
checksum: 10/d8216083212772b203a1ee16be2ea96a1638c6d7f0ff769ba4da274b5ad29e858bd26d96761feb8937fce1aa47e8acfa79b2cd21a8ced724cc5555c11b4d056c
checksum: 10/6c7e8ea8dd5643d9df73f4aa796f3572537284e9b819b74ef919467452bfa4624f8f7477cfc775c91db7e2f32387133b1b6c4e95aef9702be6bb042ae554884a
languageName: node
linkType: hard
@@ -11600,7 +11600,7 @@ __metadata:
languageName: node
linkType: hard
"lightningcss@npm:1.33.0, lightningcss@npm:^1.33.0":
"lightningcss@npm:1.33.0, lightningcss@npm:^1.32.0, lightningcss@npm:^1.33.0":
version: 1.33.0
resolution: "lightningcss@npm:1.33.0"
dependencies:
@@ -11854,15 +11854,6 @@ __metadata:
languageName: node
linkType: hard
"magic-string@npm:^1.2.2":
version: 1.2.2
resolution: "magic-string@npm:1.2.2"
dependencies:
"@jridgewell/sourcemap-codec": "npm:^1.5.5"
checksum: 10/23514d9fb744d30689037e41440125b6c4bc3fb5b8e6d992798e59f93d7452276f223d062ba9b1ce78fbee5f9cc365c1e0ae3c6babddb74cb3aa9074adb57b03
languageName: node
linkType: hard
"magicast@npm:^0.5.2":
version: 0.5.4
resolution: "magicast@npm:0.5.4"
@@ -11944,12 +11935,12 @@ __metadata:
languageName: node
linkType: hard
"marked@npm:18.0.11":
version: 18.0.11
resolution: "marked@npm:18.0.11"
"marked@npm:18.0.10":
version: 18.0.10
resolution: "marked@npm:18.0.10"
bin:
marked: bin/marked.js
checksum: 10/6da69f17820c7442358d751e902d3e0e06fef957e1178d634db938cf339c4504399c9f11a953b0a116b1e5bebc1c7fb52726777cc6c351f2c9f39e4a8276e21a
checksum: 10/0d4b560e0773fd6ba30a4e7560ba7ae1f05d47b23926ad8337d9f80c598dfdbffdf2f81e773a1789e8bc9d9c2c2d3d0c9143a54d32581c336a3bbb1bad80461c
languageName: node
linkType: hard
@@ -12067,16 +12058,16 @@ __metadata:
languageName: node
linkType: hard
"minify-literals@npm:2.2.0":
version: 2.2.0
resolution: "minify-literals@npm:2.2.0"
"minify-literals@npm:2.1.0":
version: 2.1.0
resolution: "minify-literals@npm:2.1.0"
dependencies:
"@sveltejs/acorn-typescript": "npm:^1.0.13"
acorn: "npm:^8.18.0"
html-minifier-next: "npm:^8.1.0"
lightningcss: "npm:^1.33.0"
magic-string: "npm:^1.2.2"
checksum: 10/73f17955820675f405b95810f3bf861a05baf426300e8ba46d174987ee45366c885ef1281c40842025529343a4ca71b6fd8294ce674bccbd58685e4a917b9448
"@sveltejs/acorn-typescript": "npm:^1.0.10"
acorn: "npm:^8.17.0"
html-minifier-next: "npm:^7.0.0"
lightningcss: "npm:^1.32.0"
magic-string: "npm:^0.30.21"
checksum: 10/95bcd25afcf638c00bcd8981c2e6409be8c2feb4fd8f954bc9a547b2ade917810cef17bb075ad95e5ceaac6fffc8e625c161ab77b2363c36de53da6d139de872
languageName: node
linkType: hard
@@ -15232,18 +15223,18 @@ __metadata:
languageName: node
linkType: hard
"typescript-eslint@npm:8.68.0":
version: 8.68.0
resolution: "typescript-eslint@npm:8.68.0"
"typescript-eslint@npm:8.67.0":
version: 8.67.0
resolution: "typescript-eslint@npm:8.67.0"
dependencies:
"@typescript-eslint/eslint-plugin": "npm:8.68.0"
"@typescript-eslint/parser": "npm:8.68.0"
"@typescript-eslint/typescript-estree": "npm:8.68.0"
"@typescript-eslint/utils": "npm:8.68.0"
"@typescript-eslint/eslint-plugin": "npm:8.67.0"
"@typescript-eslint/parser": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/utils": "npm:8.67.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/451c64296ac5f48e4621a03324f5aaef4a26f1dd9d4df63bd3cb07f136e8379185a800731f83634eb5d909c696fad820ba74d31ab3d7cf263328504275d1305b
checksum: 10/4efc2cea5b067f65d58e010fda3888ec94f2e67ebfa289be78c3a02458787b7125cf8f6095776b912005fb69e8748ad4018777b16436a0658957c163ef213bf9
languageName: node
linkType: hard