Compare commits

...
Author SHA1 Message Date
Paul BotteinandGitHub 83fcccf138 Fix sources resolution, fetching and filter badge in History and Activity 2026-08-18 18:35:56 +02:00
Paul BotteinandGitHub dbaa219db6 Count the same entities in the sources chip and the target picker 2026-08-18 18:35:56 +02:00
Paul BotteinandGitHub 57dff15962 Show the entity count in the sources chip 2026-08-18 18:35:56 +02:00
Paul BotteinandGitHub dea46ee63a Open the sources pane by default on wide screens 2026-08-18 18:35:56 +02:00
Paul BotteinandGitHub c8527aa8d9 Show device class icons in the sources filter 2026-08-18 18:35:56 +02:00
Paul BotteinandGitHub 5dced10578 Truncate timeline names that do not fit the plot 2026-08-18 18:35:56 +02:00
Paul BotteinandGitHub c125101d43 Remember the source filters across visits 2026-08-18 18:35:56 +02:00
Paul BotteinandGitHub 897c10d937 Keep the target picker out of the redesign 2026-08-18 18:35:56 +02:00
Paul BotteinandGitHub ca1a651aa9 Fix stuck loading state when the selection resolves to no entity 2026-08-18 18:35:56 +02:00
Paul BotteinandGitHub 272ce3ed08 Remove dead code breaking the type check 2026-08-18 18:35:56 +02:00
64c58cc152 Redesign History and Activity filtering into a sources pane
Give both panels the toolbar + left pane layout of the data tables, and
merge target picking and filtering into one "Sources" surface, per the UX
discussion.

- ha-filter-pane: pane on wide screens, bottom sheet on narrow ones,
  mirroring the filter pane of hass-tabs-subpage-data-table
- ha-sources-picker: target picker plus domain, device class and
  integration filters, shared by both panels
- ha-filter-device-classes: new filter panel, labelled with the backend
  device class names
- ha-filter-pane-chip and ha-empty-state: the chip with a filter count
  badge and the centered placeholder both panels need
- ha-date-range-nav: the date range picker as one pill with previous and
  next steppers, for the toolbar
- History draws timeline names above their bar (inside-labels), which
  gives long names the full width
- Activity keeps a floating date header while scrolling, and reset moved
  into the overflow menu next to refresh and download
- Drop the now unused compact and add-on-top modes of ha-target-picker,
  including ha-target-picker-value-chip
- Right-align the clear button in the domains filter header, matching the
  other filter panels

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-18 18:35:56 +02:00
14 changed files with 1771 additions and 379 deletions
@@ -1,4 +1,5 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { deepEqual } from "../util/deep-equal";
import {
createQueryString,
decodeQueryParams,
@@ -35,6 +36,15 @@ export const historyLogbookTargetFromQueryParams = (
): HassServiceTarget | undefined =>
serviceTargetFromQueryParams(params, historyLogbookTargetParamKeys);
export const historyLogbookTargetsEqual = (
a: HassServiceTarget,
b: HassServiceTarget
): boolean =>
deepEqual(
queryParamsFromServiceTarget(a, historyLogbookTargetParamKeys),
queryParamsFromServiceTarget(b, historyLogbookTargetParamKeys)
);
export const createHistoryLogbookUrl = (
path: string,
target: HassServiceTarget,
@@ -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";
@@ -22,6 +23,10 @@ import { hex2rgb } from "../../common/color/convert-color";
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")
export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -38,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;
@@ -60,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() {
@@ -67,7 +83,7 @@ export class StateHistoryChartTimeline extends LitElement {
<ha-chart-base
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * 30 + 30}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}
@@ -177,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();
}
@@ -193,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",
@@ -224,37 +254,52 @@ 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,
bottom: 30,
left: rtl ? 1 : labelWidth,
right: rtl ? labelWidth : 1,
top: insideLabels ? 20 : 10,
bottom: GRID_BOTTOM,
left: rtl ? 1 : plotPadding,
right: rtl ? plotPadding : 1,
},
tooltip: {
renderMode: "html",
@@ -398,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;
}
@@ -0,0 +1,79 @@
import { mdiCalendar } from "@mdi/js";
import { css, html } from "lit";
import { customElement } from "lit/decorators";
import "../chips/ha-assist-chip";
import "../ha-icon-button-next";
import "../ha-icon-button-prev";
import "../ha-svg-icon";
import {
haDateRangePickerStyles,
HaDateRangePicker,
} from "./ha-date-range-picker";
/**
* Date range picker as a single pill that also steps through ranges: a
* previous button, the selected range and a next button. Meant for a toolbar,
* next to other chips.
*/
@customElement("ha-date-range-nav")
export class HaDateRangeNav extends HaDateRangePicker {
protected override _renderField() {
return html`
<ha-icon-button-prev
class="step"
.label=${this._i18n.localize("ui.common.previous")}
.disabled=${this.disabled}
@click=${this._handlePrev}
></ha-icon-button-prev>
<ha-assist-chip
id="field"
class="range"
.label=${this._formatRange(" ")}
.disabled=${this.disabled}
@click=${this._openPicker}
>
<ha-svg-icon slot="icon" .path=${mdiCalendar}></ha-svg-icon>
</ha-assist-chip>
<ha-icon-button-next
class="step"
.label=${this._i18n.localize("ui.common.next")}
.disabled=${this.disabled}
@click=${this._handleNext}
></ha-icon-button-next>
`;
}
static override styles = [
haDateRangePickerStyles,
css`
/* The three controls read as one pill, with the range chip's borders as
the dividers between them. */
.date-range-inputs {
gap: 0;
border: 1px solid var(--outline-color);
border-radius: var(--ha-assist-chip-container-shape, 10px);
background: var(--ha-assist-chip-container-color, transparent);
overflow: hidden;
width: fit-content;
}
.step {
--ha-icon-button-size: 32px;
--mdc-icon-size: 20px;
}
.range {
--md-assist-chip-outline-color: transparent;
--ha-assist-chip-container-shape: 0;
--ha-assist-chip-container-color: transparent;
border-inline: 1px solid var(--divider-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-date-range-nav": HaDateRangeNav;
}
}
+112 -128
View File
@@ -2,7 +2,6 @@ import "@home-assistant/webawesome/dist/components/popover/popover";
import { consume, type ContextType } from "@lit/context";
import { mdiCalendar } from "@mdi/js";
import "cally";
import { isThisYear } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -11,10 +10,7 @@ import { tinykeys } from "tinykeys";
import { shiftDateRange } from "../../common/datetime/calc_date";
import type { DateRange } from "../../common/datetime/calc_date_range";
import { calcDateRange } from "../../common/datetime/calc_date_range";
import {
formatShortDateTime,
formatShortDateTimeWithYear,
} from "../../common/datetime/format_date_time";
import { formatShortDateTimeWithConditionalYear } from "../../common/datetime/format_date_time";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import { configContext, internationalizationContext } from "../../data/context";
@@ -42,18 +38,67 @@ const EXTENDED_RANGE_KEYS: DateRange[] = [
"now-30d",
];
export const haDateRangePickerStyles = css`
ha-icon-button {
direction: var(--direction);
}
.date-range-inputs {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
ha-textarea {
display: inline-block;
width: 340px;
}
@media only screen and (max-width: 460px) {
ha-textarea {
width: 100%;
}
}
wa-popover {
--wa-space-l: 0;
}
wa-popover::part(dialog)::backdrop {
opacity: 0;
transition: opacity var(--ha-animation-duration-normal) ease-out;
}
wa-popover.open::part(dialog)::backdrop {
opacity: 1;
}
:host(:not([backdrop])) wa-popover::part(dialog)::backdrop {
background: none;
}
wa-popover::part(body) {
min-width: max(var(--body-width), 250px);
max-width: calc(
100vw - var(--safe-area-inset-left) - var(--safe-area-inset-right) - var(
--ha-space-8
)
);
overflow: hidden;
}
`;
@customElement("ha-date-range-picker")
export class HaDateRangePicker extends LitElement {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
protected _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _hassConfig!: HassConfig;
protected _hassConfig!: HassConfig;
@property({ attribute: false }) public startDate!: Date;
@@ -143,73 +188,7 @@ export class HaDateRangePicker extends LitElement {
protected render(): TemplateResult {
return html`
<div class="container">
<div class="date-range-inputs">
${
!this.minimal
? html`<ha-textarea
id="field"
rows="1"
resize="auto"
@click=${this._openPicker}
@keydown=${this._handleKeydown}
.value=${
(isThisYear(this.startDate)
? formatShortDateTime(
this.startDate,
this._i18n.locale,
this._hassConfig
)
: formatShortDateTimeWithYear(
this.startDate,
this._i18n.locale,
this._hassConfig
)) +
(window.innerWidth >= 459 ? " - " : " - \n") +
(isThisYear(this.endDate)
? formatShortDateTime(
this.endDate,
this._i18n.locale,
this._hassConfig
)
: formatShortDateTimeWithYear(
this.endDate,
this._i18n.locale,
this._hassConfig
))
}
.label=${
this._i18n.localize(
"ui.components.date-range-picker.start_date"
) +
" - " +
this._i18n.localize(
"ui.components.date-range-picker.end_date"
)
}
.disabled=${this.disabled}
readonly
></ha-textarea>
<ha-icon-button-prev
.label=${this._i18n.localize("ui.common.previous")}
@click=${this._handlePrev}
>
</ha-icon-button-prev>
<ha-icon-button-next
.label=${this._i18n.localize("ui.common.next")}
@click=${this._handleNext}
>
</ha-icon-button-next>`
: html`<ha-icon-button
@click=${this._openPicker}
.disabled=${this.disabled}
id="field"
.label=${this._i18n.localize(
"ui.components.date-range-picker.select_date_range"
)}
.path=${mdiCalendar}
></ha-icon-button>`
}
</div>
<div class="date-range-inputs">${this._renderField()}</div>
${
this._pickerWrapperOpen || this._opened
? this._openedNarrow
@@ -248,6 +227,60 @@ export class HaDateRangePicker extends LitElement {
`;
}
/**
* The control that opens the picker. It has to carry `id="field"`, which the
* popover anchors to.
*/
protected _renderField() {
if (this.minimal) {
return html`<ha-icon-button
@click=${this._openPicker}
.disabled=${this.disabled}
id="field"
.label=${this._i18n.localize(
"ui.components.date-range-picker.select_date_range"
)}
.path=${mdiCalendar}
></ha-icon-button>`;
}
return html`<ha-textarea
id="field"
rows="1"
resize="auto"
@click=${this._openPicker}
@keydown=${this._handleKeydown}
.value=${this._formatRange(window.innerWidth >= 459 ? " - " : " - \n")}
.label=${
this._i18n.localize("ui.components.date-range-picker.start_date") +
" - " +
this._i18n.localize("ui.components.date-range-picker.end_date")
}
.disabled=${this.disabled}
readonly
></ha-textarea>
<ha-icon-button-prev
.label=${this._i18n.localize("ui.common.previous")}
@click=${this._handlePrev}
>
</ha-icon-button-prev>
<ha-icon-button-next
.label=${this._i18n.localize("ui.common.next")}
@click=${this._handleNext}
>
</ha-icon-button-next>`;
}
protected _formatRange(separator: string): string {
const format = (date: Date) =>
formatShortDateTimeWithConditionalYear(
date,
this._i18n.locale,
this._hassConfig
);
return format(this.startDate) + separator + format(this.endDate);
}
private _renderPicker() {
if (!this._opened) {
return nothing;
@@ -303,12 +336,12 @@ export class HaDateRangePicker extends LitElement {
this._opened = false;
};
private _handleNext(ev: MouseEvent): void {
protected _handleNext(ev: MouseEvent): void {
if (ev && ev.stopPropagation) ev.stopPropagation();
this._shift(true);
}
private _handlePrev(ev: MouseEvent): void {
protected _handlePrev(ev: MouseEvent): void {
if (ev && ev.stopPropagation) ev.stopPropagation();
this._shift(false);
}
@@ -336,7 +369,7 @@ export class HaDateRangePicker extends LitElement {
this._pickerWrapperOpen = false;
}
private _openPicker(ev?: Event) {
protected _openPicker(ev?: Event) {
if (this.disabled) {
return;
}
@@ -352,7 +385,7 @@ export class HaDateRangePicker extends LitElement {
});
}
private _handleKeydown(ev: KeyboardEvent) {
protected _handleKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
this._openPicker(ev);
@@ -369,56 +402,7 @@ export class HaDateRangePicker extends LitElement {
}
}
static styles = [
css`
ha-icon-button {
direction: var(--direction);
}
.date-range-inputs {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
ha-textarea {
display: inline-block;
width: 340px;
}
@media only screen and (max-width: 460px) {
ha-textarea {
width: 100%;
}
}
wa-popover {
--wa-space-l: 0;
}
wa-popover::part(dialog)::backdrop {
opacity: 0;
transition: opacity var(--ha-animation-duration-normal) ease-out;
}
wa-popover.open::part(dialog)::backdrop {
opacity: 1;
}
:host(:not([backdrop])) wa-popover::part(dialog)::backdrop {
background: none;
}
wa-popover::part(body) {
min-width: max(var(--body-width), 250px);
max-width: calc(
100vw - var(--safe-area-inset-left) - var(
--safe-area-inset-right
) - var(--ha-space-8)
);
overflow: hidden;
}
`,
];
static styles = [haDateRangePickerStyles];
}
declare global {
+79
View File
@@ -0,0 +1,79 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "./ha-svg-icon";
/**
* Centered placeholder for a surface that has nothing to show, with an icon, a
* heading, an optional description and optional actions.
*
* @slot - Actions that help the user fill the surface, e.g. a button.
*/
@customElement("ha-empty-state")
export class HaEmptyState extends LitElement {
/** SVG path of the icon shown above the heading. */
@property() public icon?: string;
@property() public heading?: string;
@property() public description?: string;
protected render() {
return html`
<div class="content">
${
this.icon
? html`<ha-svg-icon .path=${this.icon}></ha-svg-icon>`
: nothing
}
${this.heading ? html`<h2>${this.heading}</h2>` : nothing}
${this.description ? html`<p>${this.description}</p>` : nothing}
<slot></slot>
</div>
`;
}
static styles = css`
:host {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: 100%;
width: 100%;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ha-space-4);
box-sizing: border-box;
max-width: 500px;
padding: var(--ha-space-8) var(--ha-space-4);
text-align: center;
}
ha-svg-icon {
--mdc-icon-size: var(--ha-empty-state-icon-size, 64px);
color: var(--secondary-text-color);
}
h2 {
margin: 0;
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
}
p {
margin: 0;
color: var(--secondary-text-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-empty-state": HaEmptyState;
}
}
+287
View File
@@ -0,0 +1,287 @@
import { consume, type ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent, type HASSDomEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, statesContext } from "../data/context";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-icon-button";
import "./ha-list";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
interface DeviceClassItem {
deviceClass: string;
domain: string;
name: string;
}
@customElement("ha-filter-device-classes")
export class HaFilterDeviceClasses extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: statesContext, subscribe: true })
@state()
private _states!: ContextType<typeof statesContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this._localize("ui.components.filter-device-classes.caption")}
${
this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
></ha-icon-button>`
: nothing
}
</div>
${
this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._handleItemSelected}
multi
>
${repeat(
this._deviceClasses(
this._states,
this._localize,
this._i18n.locale.language,
this._filter
),
(item) => item.deviceClass,
(item) =>
html`<ha-check-list-item
.value=${item.deviceClass}
.selected=${(this.value || []).includes(item.deviceClass)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${item.domain}
.deviceClass=${item.deviceClass}
.state=${item.domain === "binary_sensor" ? "on" : undefined}
></ha-domain-icon>
${item.name}
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
`;
}
private _deviceClasses = memoizeOne(
(
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string | undefined,
filter: string | undefined
): DeviceClassItem[] =>
this._deviceClassItems(this._deviceClassDomains(states), localize)
.filter(
(item) =>
!filter ||
item.deviceClass.toLowerCase().includes(filter) ||
item.name.toLowerCase().includes(filter)
)
.sort((a, b) => stringCompare(a.name, b.name, language))
);
private _deviceClassDomains = memoizeOne(
(states: ContextType<typeof statesContext>): Map<string, string[]> => {
const domains = new Map<string, string[]>();
Object.values(states).forEach((stateObj) => {
const deviceClass = stateObj.attributes.device_class;
if (!deviceClass) {
return;
}
const domain = computeStateDomain(stateObj);
const known = domains.get(deviceClass);
if (!known) {
domains.set(deviceClass, [domain]);
} else if (!known.includes(domain)) {
known.push(domain);
}
});
return domains;
}
);
private _deviceClassItems = memoizeOne(
(
deviceClassDomains: Map<string, string[]>,
localize: LocalizeFunc
): DeviceClassItem[] =>
[...deviceClassDomains].map(([deviceClass, domains]) => {
for (const domain of domains) {
const name = localize(
`component.${domain}.entity_component.${deviceClass}.name`
);
if (name) {
return { deviceClass, domain, name };
}
}
return { deviceClass, domain: domains[0], name: deviceClass };
}),
([domainsA, localizeA], [domainsB, localizeB]) =>
localizeA === localizeB &&
domainsA.size === domainsB.size &&
[...domainsA].every(
([deviceClass, domains]) =>
domainsB.get(deviceClass)?.join() === domains.join()
)
);
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
}, 300);
}
}
private _expandedWillChange(ev: HASSDomEvent<{ expanded: boolean }>) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev: HASSDomEvent<{ expanded: boolean }>) {
this.expanded = ev.detail.expanded;
}
private _handleItemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
const deviceClasses = this._deviceClasses(
this._states,
this._localize,
this._i18n.locale.language,
this._filter
);
const visible = new Set(deviceClasses.map((item) => item.deviceClass));
const preserved = (this.value || []).filter((d) => !visible.has(d));
const selected = [...ev.detail.index]
.map((i) => deviceClasses[i]?.deviceClass)
.filter((d): d is string => !!d);
this.value = [...preserved, ...selected];
fireEvent(this, "data-table-filter-changed", {
value: this.value.length ? this.value : undefined,
items: undefined,
});
}
private _clearFilter(ev: Event) {
ev.preventDefault();
this.value = undefined;
fireEvent(this, "data-table-filter-changed", {
value: undefined,
items: undefined,
});
}
private _handleSearchChange(ev: InputEvent) {
const target = ev.target as HaInputSearch;
this._filter = (target.value ?? "").toLowerCase();
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-device-classes": HaFilterDeviceClasses;
}
}
+182
View File
@@ -0,0 +1,182 @@
import { mdiFilterVariant, mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-adaptive-dialog";
import "./ha-button";
import "./ha-dialog-footer";
import "./ha-filter-pane-chip";
import "./ha-icon-button";
/**
* Filter pane for a filtered page: a column next to the content on wide
* screens, a bottom sheet on narrow ones. Mirrors the filter pane of
* `hass-tabs-subpage-data-table` for pages that are not a data table.
*
* The page keeps ownership of whether the pane is shown, so that it can also
* open it from elsewhere (e.g. an empty state) and hide its own toolbar chip
* while it is open.
*
* @slot - Filter panels, e.g. `ha-filter-domains`.
*/
@customElement("ha-filter-pane")
export class HaFilterPane extends LitElement {
@property({ type: Boolean, reflect: true }) public narrow = false;
/** Header label, defaults to "Filters". */
@property() public label?: string;
/** SVG path of the header chip icon. */
@property() public path = mdiFilterVariant;
/** Number of active filters, shows the clear button when above zero. */
@property({ type: Number }) public count = 0;
/**
* Number of results the current filters resolve to, shown on the narrow
* confirm button. Leave undefined when the page shows everything.
*/
@property({ attribute: false }) public resultCount?: number;
@property({ type: Boolean }) public disabled = false;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render() {
const label =
this.label ?? this._localize("ui.components.subpage-data-table.filters");
if (this.narrow) {
return html`
<ha-adaptive-dialog
open
flexcontent
.headerTitle=${label}
@closed=${this._close}
>
${this._renderClearButton("headerActionItems")}
<div class="sheet-content">
<slot></slot>
</div>
<ha-dialog-footer slot="footer">
<ha-button slot="primaryAction" data-dialog="close">
${
this.resultCount === undefined
? this._localize("ui.common.close")
: this._localize(
"ui.components.subpage-data-table.show_results",
{ number: this.resultCount }
)
}
</ha-button>
</ha-dialog-footer>
</ha-adaptive-dialog>
`;
}
return html`
<div class="header">
<ha-filter-pane-chip
active
.label=${label}
.path=${this.path}
.disabled=${this.disabled}
@click=${this._close}
></ha-filter-pane-chip>
${this._renderClearButton()}
</div>
<div class="content ha-scrollbar">
<slot></slot>
</div>
`;
}
private _renderClearButton(slot?: string) {
if (!this.count) {
return nothing;
}
return html`
<ha-icon-button
slot=${ifDefined(slot)}
.path=${mdiFilterVariantRemove}
.disabled=${this.disabled}
.label=${this._localize("ui.components.subpage-data-table.clear_filter")}
@click=${this._clear}
></ha-icon-button>
`;
}
private _close() {
fireEvent(this, "close-filter-pane");
}
private _clear() {
fireEvent(this, "clear-filter");
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
display: flex;
flex-direction: column;
flex: 0 0 var(--ha-filter-pane-width, 320px);
width: var(--ha-filter-pane-width, 320px);
box-sizing: border-box;
overflow: hidden;
border-inline-end: 1px solid var(--divider-color);
}
/* The bottom sheet positions itself, so the pane takes no space. */
:host([narrow]) {
display: contents;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ha-space-4);
box-sizing: border-box;
height: 56px;
flex-shrink: 0;
padding: 0 16px;
background: var(--primary-background-color);
border-bottom: 1px solid var(--divider-color);
}
.content,
.sheet-content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
}
ha-adaptive-dialog {
--dialog-content-padding: 0;
/* Fixed height so the sheet does not resize while filtering. */
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-pane": HaFilterPane;
}
interface HASSDomEvents {
"close-filter-pane": undefined;
}
}
+268
View File
@@ -0,0 +1,268 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import type { DataTableFiltersValue } from "../data/data_table_filters";
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
import type { EntitySources } from "../data/entity/entity_sources";
import type { HomeAssistant } from "../types";
import "./ha-filter-device-classes";
import "./ha-filter-domains";
import "./ha-filter-integrations";
import "./ha-target-picker";
/**
* Ways to narrow down the entities a target selection resolves to. Not to be
* confused with `EntitySources`, which maps an entity to its integration.
*/
export interface SourceFilters {
domains?: string[];
deviceClasses?: string[];
integrations?: string[];
}
const TARGET_KEYS = [
"floor_id",
"area_id",
"device_id",
"entity_id",
"label_id",
] as const;
/** Number of picked targets, no matter which type they are. */
export const countTargets = (target: HassServiceTarget): number =>
TARGET_KEYS.reduce(
(count, key) => count + (target[key] ? ensureArray(target[key]).length : 0),
0
);
/** Number of filters that have at least one option selected. */
export const countSourceFilters = (filters: SourceFilters): number =>
Object.values(filters).filter((value) => value?.length).length;
/**
* Narrows entity IDs down by the selected filters: an entity is kept when it
* matches every filter that has a selection.
*/
export const applySourceFilters = (
entityIds: string[],
filters: SourceFilters,
states: HomeAssistant["states"],
entities: HomeAssistant["entities"],
entitySources?: EntitySources
): string[] => {
const domains = filters.domains?.length ? filters.domains : undefined;
const deviceClasses = filters.deviceClasses?.length
? filters.deviceClasses
: undefined;
const integrations = filters.integrations?.length
? filters.integrations
: undefined;
if (!domains && !deviceClasses && !integrations) {
return entityIds;
}
return entityIds.filter((entityId) => {
if (domains && !domains.includes(computeDomain(entityId))) {
return false;
}
if (deviceClasses) {
const deviceClass = states[entityId]?.attributes.device_class;
if (!deviceClass || !deviceClasses.includes(deviceClass)) {
return false;
}
}
if (integrations) {
const integration =
entities[entityId]?.platform ?? entitySources?.[entityId]?.domain;
if (!integration || !integrations.includes(integration)) {
return false;
}
}
return true;
});
};
/**
* Picker for what a page shows: the targets to include, narrowed down by
* domain, device class and integration. Meant to be placed in an
* `ha-filter-pane`.
*
* The pages resolve every entity of a target, secondary ones included, so the
* target picker counts them too.
*/
@customElement("ha-sources-picker")
export class HaSourcesPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value: HassServiceTarget = {};
@property({ attribute: false }) public filters: SourceFilters = {};
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
/** Explains what the page shows while no target is picked. */
@property() public description?: string;
@property({ type: Boolean }) public disabled = false;
// Only one filter panel is expanded at a time, so that the expanded one can
// use the height that is left in the pane.
@state() private _expandedFilter?: keyof SourceFilters;
protected render() {
const noTargets = countTargets(this.value) === 0;
return html`
${
this.description && noTargets
? html`<div class="description">${this.description}</div>`
: nothing
}
<ha-target-picker
class=${classMap({ "no-padding-top": noTargets })}
.hass=${this.hass}
.value=${this.value}
.entityFilter=${this.entityFilter}
.primaryEntitiesOnly=${false}
.disabled=${this.disabled}
@value-changed=${this._targetsChanged}
></ha-target-picker>
<div
class=${classMap({ filters: true, expanded: !!this._expandedFilter })}
>
<ha-filter-domains
.value=${this.filters.domains}
.expanded=${this._expandedFilter === "domains"}
@data-table-filter-changed=${this._domainsChanged}
@expanded-changed=${this._domainsExpanded}
></ha-filter-domains>
<ha-filter-device-classes
.value=${this.filters.deviceClasses}
.expanded=${this._expandedFilter === "deviceClasses"}
@data-table-filter-changed=${this._deviceClassesChanged}
@expanded-changed=${this._deviceClassesExpanded}
></ha-filter-device-classes>
<ha-filter-integrations
.value=${this.filters.integrations}
.expanded=${this._expandedFilter === "integrations"}
@data-table-filter-changed=${this._integrationsChanged}
@expanded-changed=${this._integrationsExpanded}
></ha-filter-integrations>
</div>
`;
}
protected firstUpdated() {
// The filter panels label themselves with keys from the config panel.
this.hass.loadFragmentTranslation("config");
}
private _targetsChanged(ev: CustomEvent) {
ev.stopPropagation();
fireEvent(this, "value-changed", { value: ev.detail.value || {} });
}
private _domainsChanged(ev: CustomEvent) {
this._filterChanged("domains", ev);
}
private _deviceClassesChanged(ev: CustomEvent) {
this._filterChanged("deviceClasses", ev);
}
private _integrationsChanged(ev: CustomEvent) {
this._filterChanged("integrations", ev);
}
private _filterChanged(key: keyof SourceFilters, ev: CustomEvent) {
ev.stopPropagation();
const value = ev.detail.value as DataTableFiltersValue;
fireEvent(this, "source-filters-changed", {
value: {
...this.filters,
[key]: Array.isArray(value) && value.length ? value : undefined,
},
});
}
private _domainsExpanded(ev: CustomEvent) {
this._filterExpanded("domains", ev);
}
private _deviceClassesExpanded(ev: CustomEvent) {
this._filterExpanded("deviceClasses", ev);
}
private _integrationsExpanded(ev: CustomEvent) {
this._filterExpanded("integrations", ev);
}
private _filterExpanded(key: keyof SourceFilters, ev: CustomEvent) {
if (ev.detail.expanded) {
this._expandedFilter = key;
} else if (this._expandedFilter === key) {
this._expandedFilter = undefined;
}
}
static styles = css`
/* The sections are laid out by the pane, so that an expanded filter
panel can use the height that is left. */
:host {
display: contents;
}
.description {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
min-height: 92px;
margin: var(--ha-space-4) var(--ha-space-4) 0;
padding: 0 var(--ha-space-6);
border-radius: var(--ha-border-radius-lg);
background-color: var(--ha-color-fill-neutral-quiet-resting);
text-align: center;
color: var(--secondary-text-color);
}
ha-target-picker {
display: block;
flex: none;
padding: var(--ha-space-4);
}
/* The description already spaces the picker from the pane header. */
ha-target-picker.no-padding-top {
padding-top: 0;
}
.filters {
display: flex;
flex-direction: column;
flex: 1 0 auto;
border-top: 1px solid var(--divider-color);
}
/* An expanded panel sizes itself to the space that is left over. */
.filters.expanded {
flex: 1 1 auto;
min-height: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-sources-picker": HaSourcesPicker;
}
interface HASSDomEvents {
"source-filters-changed": { value: SourceFilters };
}
}
+285 -108
View File
@@ -1,8 +1,9 @@
import {
mdiChartBoxOutline,
mdiDotsVertical,
mdiDownload,
mdiFilterRemove,
mdiImagePlus,
mdiTuneVariant,
} from "@mdi/js";
import { differenceInHours } from "date-fns";
import type {
@@ -10,17 +11,21 @@ import type {
UnsubscribeFunc,
} from "home-assistant-js-websocket/dist/types";
import type { PropertyValues } from "lit";
import { LitElement, css, html } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { storage } from "../../common/decorators/storage";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import { shallowEqual } from "../../common/util/shallow-equal";
import {
createHistoryLogbookUrl,
decodeHistoryLogbookQueryParams,
historyLogbookTargetFromQueryParams,
historyLogbookTargetsEqual,
} from "../../common/url/history-logbook-query-params";
import {
extractSearchParamsObject,
@@ -29,14 +34,25 @@ import {
import { MIN_TIME_BETWEEN_UPDATES } from "../../components/chart/ha-chart-base";
import "../../components/chart/state-history-charts";
import type { StateHistoryCharts } from "../../components/chart/state-history-charts";
import "../../components/date-picker/ha-date-range-picker";
import "../../components/date-picker/ha-date-range-nav";
import "../../components/ha-button";
import "../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
import "../../components/ha-dropdown-item";
import "../../components/ha-empty-state";
import "../../components/ha-filter-pane-chip";
import "../../components/ha-filter-pane";
import "../../components/ha-icon-button";
import {
applySourceFilters,
countSourceFilters,
countTargets,
} from "../../components/ha-sources-picker";
import type { SourceFilters } from "../../components/ha-sources-picker";
import "../../components/ha-spinner";
import "../../components/ha-target-picker";
import "../../components/ha-top-app-bar-fixed";
import type { EntitySources } from "../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
import type { HistoryResult } from "../../data/history";
import {
computeHistory,
@@ -52,6 +68,8 @@ import type { HomeAssistant } from "../../types";
import { addEntitiesToLovelaceView } from "../lovelace/editor/add-entities-to-view";
import { csvSafeString, csvDownload } from "../../util/csv";
const EMPTY_STATES: HomeAssistant["states"] = {};
@customElement("ha-panel-history")
class HaPanelHistory extends LitElement {
@property({ attribute: false }) hass!: HomeAssistant;
@@ -78,6 +96,19 @@ class HaPanelHistory extends LitElement {
@state() private _isLoading = false;
@state() private _filters: SourceFilters = {};
@storage({
key: "historySourceFilters",
state: false,
subscribe: false,
})
private _storedFilters?: SourceFilters;
@state() private _showSources?: boolean;
@state() private _entitySources?: EntitySources;
@state() private _stateHistory?: HistoryResult;
private _mungedStateHistory?: HistoryResult;
@@ -92,6 +123,8 @@ class HaPanelHistory extends LitElement {
private _subscribed?: Promise<UnsubscribeFunc | undefined>;
private _fetchedEntityIds?: string[];
private _interval?: number;
public constructor() {
@@ -119,7 +152,24 @@ class HaPanelHistory extends LitElement {
}
protected render() {
const entitiesSelected = this._getEntityIds().length > 0;
const entityIds = this._getEntityIds();
const targetCount = countTargets(this._targetPickerValue);
const filterCount = countSourceFilters(this._filters);
const sourceCount = targetCount + filterCount;
const hasTargets = targetCount > 0;
// A target whose entities are all filtered out fetches nothing.
const loading =
this._isLoading || (entityIds.length > 0 && !this._mungedStateHistory);
const hasResults =
!!this._mungedStateHistory &&
(this._mungedStateHistory.line.length > 0 ||
this._mungedStateHistory.timeline.length > 0);
const sourcesLabel = sourceCount
? this.hass.localize("ui.panel.history.sources_count", {
count: entityIds.length,
})
: this.hass.localize("ui.panel.history.sources");
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
@@ -128,13 +178,6 @@ class HaPanelHistory extends LitElement {
<h1 class="page-title" slot="title">
${this.hass.localize("panel.history")}
</h1>
<ha-icon-button
slot="actionItems"
@click=${this._removeAll}
.disabled=${this._isLoading || !entitiesSelected}
.path=${mdiFilterRemove}
.label=${this.hass.localize("ui.panel.history.remove_all")}
></ha-icon-button>
<ha-dropdown slot="actionItems" @wa-select=${this._handleMenuAction}>
<ha-icon-button
slot="trigger"
@@ -153,51 +196,112 @@ class HaPanelHistory extends LitElement {
</ha-dropdown-item>
</ha-dropdown>
<div class="flex content ha-scrollbar">
<div class="filters">
<ha-date-range-picker
?disabled=${this._isLoading}
.startDate=${this._startDate}
.endDate=${this._endDate}
extended-presets
time-picker
@value-changed=${this._dateRangeChanged}
></ha-date-range-picker>
<ha-target-picker
.hass=${this.hass}
.value=${this._targetPickerValue}
.disabled=${this._isLoading}
add-on-top
@value-changed=${this._targetsChanged}
compact
></ha-target-picker>
</div>
${
this._isLoading
? html`<div class="progress-wrapper">
<ha-spinner></ha-spinner>
</div>`
: !entitiesSelected
? html`<div class="start-search">
${this.hass.localize("ui.panel.history.start_search")}
</div>`
: html`
<state-history-charts
<div class="content">
<div class="main">
${
this._sourcesShown()
? html`<ha-filter-pane
.narrow=${this.narrow}
.label=${sourcesLabel}
.path=${mdiTuneVariant}
.count=${sourceCount}
.resultCount=${entityIds.length}
.disabled=${this._isLoading}
@close-filter-pane=${this._closeSources}
@clear-filter=${this._clearSources}
>
<ha-sources-picker
.hass=${this.hass}
.historyData=${this._mungedStateHistory}
.startTime=${this._startDate}
.endTime=${this._endDate}
.narrow=${this.narrow}
sync-charts
>
</state-history-charts>
`
}
.value=${this._targetPickerValue}
.filters=${this._filters}
.disabled=${this._isLoading}
.description=${this.hass.localize(
"ui.panel.history.no_targets"
)}
@value-changed=${this._targetsChanged}
@source-filters-changed=${this._filtersChanged}
></ha-sources-picker>
</ha-filter-pane>`
: nothing
}
<div class="content-column">
<div class="toolbar">
${
this._sourcesShown() && !this.narrow
? nothing
: html`<ha-filter-pane-chip
.label=${sourcesLabel}
.path=${mdiTuneVariant}
.count=${filterCount}
.active=${sourceCount > 0}
.disabled=${this._isLoading}
@click=${this._toggleSources}
></ha-filter-pane-chip>`
}
<ha-date-range-nav
.disabled=${this._isLoading}
.startDate=${this._startDate}
.endDate=${this._endDate}
extended-presets
time-picker
@value-changed=${this._dateRangeChanged}
></ha-date-range-nav>
</div>
<div class="results ha-scrollbar">
${
loading
? html`<div class="progress-wrapper">
<ha-spinner></ha-spinner>
</div>`
: !hasTargets || !hasResults
? this._renderEmptyState(hasTargets)
: html`
<state-history-charts
.hass=${this.hass}
.historyData=${this._mungedStateHistory}
.startTime=${this._startDate}
.endTime=${this._endDate}
.narrow=${this.narrow}
inside-labels
sync-charts
>
</state-history-charts>
`
}
</div>
</div>
</div>
</div>
</ha-top-app-bar-fixed>
`;
}
private _renderEmptyState(hasTargets: boolean) {
return html`
<ha-empty-state
.icon=${mdiChartBoxOutline}
.heading=${this.hass.localize(
hasTargets
? "ui.panel.history.no_results_title"
: "ui.panel.history.start_search_title"
)}
.description=${this.hass.localize(
hasTargets
? "ui.panel.history.no_results"
: "ui.panel.history.start_search"
)}
>
<ha-button appearance="plain" @click=${this._openSources}>
${this.hass.localize(
hasTargets
? "ui.panel.history.change_sources"
: "ui.panel.history.add_targets"
)}
</ha-button>
</ha-empty-state>
`;
}
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
@@ -226,12 +330,22 @@ class HaPanelHistory extends LitElement {
const queryParams = decodeHistoryLogbookQueryParams(
extractSearchParamsObject()
);
const initialValue =
historyLogbookTargetFromQueryParams(queryParams) ??
this._storedTargetPickerValue;
const urlTarget = historyLogbookTargetFromQueryParams(queryParams);
const initialValue = urlTarget ?? this._storedTargetPickerValue;
if (initialValue) {
this._targetPickerValue = initialValue;
}
// A target linked from another page must not be narrowed by stored filters.
if (
this._storedFilters &&
(!urlTarget ||
historyLogbookTargetsEqual(
urlTarget,
this._storedTargetPickerValue ?? {}
))
) {
this._filters = this._storedFilters;
}
if (queryParams.start_date) {
this._startDate = queryParams.start_date;
}
@@ -242,6 +356,9 @@ class HaPanelHistory extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
const searchParams = extractSearchParamsObject();
if (searchParams.back === "1" && history.length > 1) {
this._showBack = true;
@@ -255,18 +372,39 @@ class HaPanelHistory extends LitElement {
if (
changedProps.has("_startDate") ||
changedProps.has("_endDate") ||
changedProps.has("_targetPickerValue") ||
(!this._stateHistory &&
(changedProps.has("_deviceEntityLookup") ||
changedProps.has("_areaEntityLookup") ||
changedProps.has("_areaDeviceLookup")))
!shallowEqual(this._getEntityIds(), this._fetchedEntityIds)
) {
this._getHistory();
this._getStats();
}
}
private _removeAll() {
private _sourcesShown(): boolean {
return this._showSources ?? !this.narrow;
}
private _toggleSources() {
this._showSources = !this._sourcesShown();
}
private _openSources() {
this._showSources = true;
}
private _closeSources() {
this._showSources = false;
}
private _filtersChanged(
ev: HASSDomEvent<HASSDomEvents["source-filters-changed"]>
) {
this._filters = ev.detail.value;
this._storedFilters = this._filters;
}
private _clearSources() {
this._filters = {};
this._storedFilters = this._filters;
this._targetPickerValue = {};
this._storedTargetPickerValue = this._targetPickerValue;
this._updatePath();
@@ -274,6 +412,7 @@ class HaPanelHistory extends LitElement {
private async _getStats() {
const statisticIds = this._getEntityIds();
this._fetchedEntityIds = statisticIds;
if (statisticIds.length === 0) {
this._statisticsHistory = undefined;
@@ -310,9 +449,13 @@ class HaPanelHistory extends LitElement {
private async _getHistory() {
const entityIds = this._getEntityIds();
this._fetchedEntityIds = entityIds;
if (entityIds.length === 0) {
// The running subscription would keep pushing the previous entities.
this._unsubscribeHistory();
this._stateHistory = undefined;
this._isLoading = false;
return;
}
@@ -342,6 +485,7 @@ class HaPanelHistory extends LitElement {
);
this._subscribed.catch(() => {
this._isLoading = false;
this._stateHistory = { line: [], timeline: [] };
this._unsubscribeHistory();
});
if (this._endDate > now) {
@@ -377,24 +521,44 @@ class HaPanelHistory extends LitElement {
}
private _getEntityIds(): string[] {
return this.__getEntityIds(
this._targetPickerValue,
return this.__filterEntityIds(
this.__resolveTargetEntityIds(
this._targetPickerValue,
this.hass.entities,
this.hass.devices,
this.hass.areas
),
this._filters,
// Only the device class filter reads the states.
this._filters.deviceClasses?.length ? this.hass.states : EMPTY_STATES,
this.hass.entities,
this.hass.devices,
this.hass.areas
this._entitySources
);
}
private __getEntityIds = memoizeOne(
// Same rules as the target picker, so that the chip and the picker agree.
private __resolveTargetEntityIds = memoizeOne(
(
targetPickerValue: HassServiceTarget,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"]
): string[] =>
resolveEntityIDs(this.hass, targetPickerValue, entities, devices, areas)
): string[] => {
const picked = new Set(ensureArray(targetPickerValue.entity_id));
return resolveEntityIDs(
this.hass,
targetPickerValue,
entities,
devices,
areas
).filter(
(entityId) => picked.has(entityId) || !entities[entityId]?.hidden
);
}
);
private __filterEntityIds = memoizeOne(applySourceFilters);
private _dateRangeChanged(ev) {
this._startDate = ev.detail.value.startDate;
this._endDate = ev.detail.value.endDate;
@@ -573,7 +737,14 @@ class HaPanelHistory extends LitElement {
line-height: inherit;
}
:host {
--ha-generic-picker-width: min(400px, calc(100vw - 32px));
--ha-generic-picker-max-width: 400px;
}
.content {
display: flex;
flex-direction: column;
height: calc(
100vh - var(--header-height, 0px) - var(
--safe-area-inset-top,
@@ -581,13 +752,55 @@ class HaPanelHistory extends LitElement {
) - var(--safe-area-inset-bottom, 0px)
);
box-sizing: border-box;
overflow-x: hidden;
padding: 0 16px 16px;
overflow: hidden;
}
:host([virtualize]) {
height: 100%;
--ha-generic-picker-max-width: 400px;
.main {
display: flex;
flex: 1;
min-height: 0;
}
.content-column {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.toolbar {
display: flex;
align-items: center;
gap: var(--ha-space-4);
box-sizing: border-box;
height: 56px;
flex-shrink: 0;
padding: 0 16px;
background: var(--primary-background-color);
border-bottom: 1px solid var(--divider-color);
direction: var(--direction);
overflow-x: auto;
scrollbar-width: none;
}
.toolbar::-webkit-scrollbar {
display: none;
}
.toolbar > * {
flex-shrink: 0;
}
.results {
flex: 1;
min-width: 0;
overflow: hidden auto;
padding: 16px 8px;
}
/* Line the charts up with the toolbar when there are no axis labels. */
:host([narrow]) .results {
padding-inline: 16px;
}
.progress-wrapper {
@@ -597,42 +810,6 @@ class HaPanelHistory extends LitElement {
flex-direction: column;
padding: 16px;
}
.filters {
display: flex;
align-items: flex-start;
margin-top: 16px;
}
ha-date-range-picker {
margin-right: 16px;
margin-inline-end: 16px;
margin-inline-start: initial;
max-width: 100%;
direction: var(--direction);
}
ha-target-picker {
flex: 1;
max-width: 100%;
min-width: 0;
}
@media all and (max-width: 1025px) {
.filters {
flex-direction: column;
}
ha-date-range-picker {
width: 100%;
margin-bottom: 8px;
}
}
.start-search {
padding-top: 16px;
text-align: center;
color: var(--secondary-text-color);
}
`,
];
}
+37 -1
View File
@@ -2,7 +2,7 @@ import type { VisibilityChangedEvent } from "@lit-labs/virtualizer";
import memoizeOne from "memoize-one";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, eventOptions, property } from "lit/decorators";
import { customElement, eventOptions, property, state } from "lit/decorators";
import { formatDate } from "../../common/datetime/format_date";
import { capitalizeFirstLetter } from "../../common/string/capitalize-first-letter";
import { restoreScroll } from "../../common/decorators/restore-scroll";
@@ -60,6 +60,8 @@ class HaLogbookRenderer extends LitElement {
// @ts-ignore
@restoreScroll(".container") private _savedScrollPos?: number;
@state() private _firstVisibleIndex = 0;
protected willUpdate(changedProps: PropertyValues<this>) {
if (
(!this.hasUpdated && this.virtualize) ||
@@ -80,9 +82,11 @@ class HaLogbookRenderer extends LitElement {
return (
changedProps.has("entries") ||
changedProps.has("traceContexts") ||
changedProps.has("noDetail") ||
changedProps.has("userIdToName") ||
changedProps.has("systemUserIds") ||
changedProps.has("_firstVisibleIndex" as never) ||
languageChanged
);
}
@@ -96,12 +100,24 @@ class HaLogbookRenderer extends LitElement {
`;
}
// Rows positioned by the virtualizer cannot carry a sticky date header.
const floatingEntry = this.virtualize
? this.entries[this._firstVisibleIndex]
: undefined;
return html`
<div
class="container ha-scrollbar"
@scroll=${this._saveScrollPos}
@logbook-entry-selected=${this._handleEntrySelected}
>
${
floatingEntry
? html`<h4 class="date floating-date">
${this._formatDateHeader(new Date(floatingEntry.when * 1000))}
</h4>`
: nothing
}
${
this.virtualize
? html`<lit-virtualizer
@@ -176,6 +192,11 @@ class HaLogbookRenderer extends LitElement {
});
}
private _dayOf(index: number): number | undefined {
const entry = this.entries[index];
return entry ? new Date(entry.when * 1000).setHours(0, 0, 0, 0) : undefined;
}
private _formatDateHeader(date: Date): string {
const today = new Date();
today.setHours(0, 0, 0, 0);
@@ -200,6 +221,10 @@ class HaLogbookRenderer extends LitElement {
@eventOptions({ passive: true })
private _visibilityChanged(e: VisibilityChangedEvent) {
const first = Math.max(0, e.first);
if (this._dayOf(first) !== this._dayOf(this._firstVisibleIndex)) {
this._firstVisibleIndex = first;
}
fireEvent(this, "hass-logbook-live", {
enable: e.first === 0,
});
@@ -226,12 +251,23 @@ class HaLogbookRenderer extends LitElement {
font-weight: var(--ha-font-weight-medium);
}
.floating-date {
position: absolute;
top: 0;
inset-inline: 0;
z-index: 2;
margin: 0;
padding-bottom: var(--ha-space-2);
background-color: var(--card-background-color);
}
.no-entries {
text-align: center;
color: var(--secondary-text-color);
}
.container {
position: relative;
max-height: var(--logbook-max-height);
}
+21 -13
View File
@@ -29,15 +29,17 @@ const idsChanged = (oldIds?: string[], newIds?: string[]) => {
if (oldIds === undefined && newIds === undefined) {
return false;
}
return (
!oldIds ||
!newIds ||
oldIds.length !== newIds.length ||
oldIds.some((val) => !newIds.includes(val)) ||
newIds.some((val) => !oldIds.includes(val))
);
if (!oldIds || !newIds || oldIds.length !== newIds.length) {
return true;
}
const newIdSet = new Set(newIds);
return oldIds.some((val) => !newIdSet.has(val));
};
/**
* @slot empty - Shown instead of the default text when there is no activity,
* for surfaces that can offer a way out (e.g. changing the filters).
*/
@customElement("ha-logbook")
export class HaLogbook extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -126,9 +128,11 @@ export class HaLogbook extends LitElement {
}
if (this._logbookEntries.length === 0) {
return html`<div class="no-entries">
${this.hass.localize("ui.components.logbook.entries_not_found")}
</div>`;
return html`<slot name="empty">
<div class="no-entries">
${this.hass.localize("ui.components.logbook.entries_not_found")}
</div>
</slot>`;
}
return html`
@@ -243,17 +247,21 @@ export class HaLogbook extends LitElement {
if (this._unsubLogbook) {
this._unsubLogbook.then((unsub) => unsub());
this._unsubLogbook = undefined;
this._logbookEntries = loading ? undefined : [];
this._pendingStreamMessages = [];
}
this._logbookEntries = loading ? undefined : [];
}
public connectedCallback() {
super.connectedCallback();
this._attachReadyListener();
if (this.hasUpdated) {
// Ensure clean state before subscribing
this._subscribeLogbookPeriod(this._calculateLogbookPeriod());
if (this._filterAlwaysEmptyResults) {
this._unsubscribe(false);
} else {
// Ensure clean state before subscribing
this._subscribeLogbookPeriod(this._calculateLogbookPeriod());
}
}
}
+300 -94
View File
@@ -3,34 +3,51 @@ import {
mdiDownload,
mdiFilterRemove,
mdiRefresh,
mdiTextBoxOutline,
mdiTuneVariant,
} from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fromUnixTime } from "date-fns";
import { ensureArray } from "../../common/array/ensure-array";
import { storage } from "../../common/decorators/storage";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { navigate } from "../../common/navigate";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import {
createHistoryLogbookUrl,
decodeHistoryLogbookQueryParams,
historyLogbookTargetFromQueryParams,
historyLogbookTargetsEqual,
} from "../../common/url/history-logbook-query-params";
import {
extractSearchParamsObject,
removeSearchParam,
} from "../../common/url/search-params";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/date-picker/ha-date-range-picker";
import { shallowEqual } from "../../common/util/shallow-equal";
import "../../components/date-picker/ha-date-range-nav";
import "../../components/ha-button";
import "../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
import "../../components/ha-dropdown-item";
import "../../components/ha-empty-state";
import "../../components/ha-filter-pane-chip";
import "../../components/ha-filter-pane";
import "../../components/ha-icon-button";
import "../../components/ha-target-picker";
import {
applySourceFilters,
countSourceFilters,
countTargets,
} from "../../components/ha-sources-picker";
import type { SourceFilters } from "../../components/ha-sources-picker";
import "../../components/ha-top-app-bar-fixed";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { EntitySources } from "../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
import { filterLogbookCompatibleEntities } from "../../data/logbook";
import { resolveEntityIDs } from "../../data/selector";
import { haStyle } from "../../resources/styles";
@@ -39,6 +56,8 @@ import "./ha-logbook";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { csvDownload, csvSafeString } from "../../util/csv";
const EMPTY_STATES: HomeAssistant["states"] = {};
interface LogbookState {
time: { range: [Date, Date] };
targetPickerValue: HassServiceTarget;
@@ -57,6 +76,12 @@ export class HaPanelLogbook extends LitElement {
@state()
private _showBack?: boolean;
@state() private _filters: SourceFilters = {};
@state() private _showSources?: boolean;
@state() private _entitySources?: EntitySources;
@state() private _targetPickerValue: HassServiceTarget = {};
// Remembers the last user-picked selection as a fallback for visits without
@@ -69,25 +94,34 @@ export class HaPanelLogbook extends LitElement {
})
private _storedTargetPickerValue?: HassServiceTarget;
@storage({
key: "logbookSourceFilters",
state: false,
subscribe: false,
})
private _storedFilters?: SourceFilters;
public constructor() {
super();
this._time = this._defaultState.time;
}
protected render() {
const entityIds = this._getEntityIds();
const filterCount = countSourceFilters(this._filters);
const sourceCount = countTargets(this._targetPickerValue) + filterCount;
const sourcesLabel = sourceCount
? this.hass.localize("ui.panel.logbook.sources_count", {
count: entityIds?.length ?? 0,
})
: this.hass.localize("ui.panel.logbook.sources");
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${!!this._showBack}
>
<div slot="title">${this.hass.localize("panel.logbook")}</div>
<ha-icon-button
slot="actionItems"
@click=${this._resetLogbook}
.disabled=${this._isDefaultState()}
.path=${mdiFilterRemove}
.label=${this.hass.localize("ui.common.reset")}
></ha-icon-button>
<ha-dropdown slot="actionItems" @wa-select=${this._handleMenuAction}>
<ha-icon-button
@@ -105,40 +139,131 @@ export class HaPanelLogbook extends LitElement {
${this.hass.localize("ui.panel.logbook.download_data")}
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item value="reset" .disabled=${this._isDefaultState()}>
${this.hass.localize("ui.common.reset")}
<ha-svg-icon slot="icon" .path=${mdiFilterRemove}></ha-svg-icon>
</ha-dropdown-item>
</ha-dropdown>
<div class="content">
<div class="filters">
<ha-date-range-picker
.startDate=${this._time.range[0]}
.endDate=${this._time.range[1]}
@value-changed=${this._dateRangeChanged}
time-picker
></ha-date-range-picker>
<div class="main">
${
this._sourcesShown()
? html`<ha-filter-pane
.narrow=${this.narrow}
.label=${sourcesLabel}
.path=${mdiTuneVariant}
.count=${sourceCount}
.resultCount=${entityIds?.length}
@close-filter-pane=${this._closeSources}
@clear-filter=${this._clearSources}
>
<ha-sources-picker
.hass=${this.hass}
.value=${this._targetPickerValue}
.filters=${this._filters}
.entityFilter=${this._filterFunc}
.description=${this.hass.localize(
"ui.panel.logbook.no_targets"
)}
@value-changed=${this._targetsChanged}
@source-filters-changed=${this._filtersChanged}
></ha-sources-picker>
</ha-filter-pane>`
: nothing
}
<div class="content-column">
<div class="toolbar">
${
this._sourcesShown() && !this.narrow
? nothing
: html`<ha-filter-pane-chip
.label=${sourcesLabel}
.path=${mdiTuneVariant}
.count=${filterCount}
.active=${sourceCount > 0}
@click=${this._toggleSources}
></ha-filter-pane-chip>`
}
<ha-date-range-nav
.startDate=${this._time.range[0]}
.endDate=${this._time.range[1]}
@value-changed=${this._dateRangeChanged}
time-picker
></ha-date-range-nav>
</div>
<ha-target-picker
.hass=${this.hass}
.entityFilter=${this._filterFunc}
.value=${this._targetPickerValue}
add-on-top
@value-changed=${this._targetsChanged}
compact
></ha-target-picker>
<ha-logbook
.hass=${this.hass}
.time=${this._time}
.entityIds=${entityIds}
.narrow=${this.narrow}
show-cause
virtualize
>
${
sourceCount > 0
? html`<ha-empty-state
slot="empty"
.icon=${mdiTextBoxOutline}
.heading=${this.hass.localize(
"ui.panel.logbook.no_results_title"
)}
.description=${this.hass.localize(
"ui.panel.logbook.no_results"
)}
>
<ha-button
appearance="plain"
@click=${this._openSources}
>
${this.hass.localize(
"ui.panel.logbook.change_sources"
)}
</ha-button>
</ha-empty-state>`
: nothing
}
</ha-logbook>
</div>
</div>
<ha-logbook
.hass=${this.hass}
.time=${this._time}
.entityIds=${this._getEntityIds()}
.narrow=${this.narrow}
show-cause
virtualize
></ha-logbook>
</div>
</ha-top-app-bar-fixed>
`;
}
private _sourcesShown(): boolean {
return this._showSources ?? !this.narrow;
}
private _toggleSources() {
this._showSources = !this._sourcesShown();
}
private _openSources() {
this._showSources = true;
}
private _closeSources() {
this._showSources = false;
}
private _filtersChanged(
ev: HASSDomEvent<HASSDomEvents["source-filters-changed"]>
) {
this._filters = ev.detail.value;
this._storedFilters = this._filters;
}
private _clearSources() {
this._filters = {};
this._storedFilters = this._filters;
this._targetPickerValue = {};
this._storedTargetPickerValue = this._targetPickerValue;
this._updatePath();
}
private _filterFunc: HaEntityPickerEntityFilterFunc = (entity) =>
filterLogbookCompatibleEntities(entity);
@@ -155,6 +280,9 @@ export class HaPanelLogbook extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("title");
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
const searchParams = extractSearchParamsObject();
if (searchParams.back === "1" && history.length > 1) {
@@ -179,20 +307,38 @@ export class HaPanelLogbook extends LitElement {
this._applyURLParams();
};
/** The entities to show activity for, or undefined for all of them. */
private _getEntityIds(): string[] | undefined {
const entities = this.__getEntityIds(
this._targetPickerValue,
this.hass.entities,
this.hass.devices,
this.hass.areas
);
if (entities.length === 0) {
return undefined;
const hasTargets = countTargets(this._targetPickerValue) > 0;
const targetEntities = hasTargets
? this.__filterTargetEntityIds(
this.__resolveTargetEntityIds(
this._targetPickerValue,
this.hass.entities,
this.hass.devices,
this.hass.areas
),
this._targetPickerValue.entity_id,
this.hass.entities,
this.hass.states
)
: undefined;
if (!countSourceFilters(this._filters)) {
return targetEntities;
}
return entities;
return this.__filterEntityIds(
targetEntities ?? this.__logbookEntityIds(this.hass.states),
this._filters,
// Only the device class filter reads the states.
this._filters.deviceClasses?.length ? this.hass.states : EMPTY_STATES,
this.hass.entities,
this._entitySources
);
}
private __getEntityIds = memoizeOne(
private __resolveTargetEntityIds = memoizeOne(
(
targetPickerValue: HassServiceTarget,
entities: HomeAssistant["entities"],
@@ -202,6 +348,53 @@ export class HaPanelLogbook extends LitElement {
resolveEntityIDs(this.hass, targetPickerValue, entities, devices, areas)
);
// Same rules as the target picker, so that the chip and the picker agree.
private __filterTargetEntityIds = memoizeOne(
(
entityIds: string[],
pickedEntityIds: string | string[] | undefined,
entities: HomeAssistant["entities"],
states: HomeAssistant["states"]
): string[] => {
const picked = new Set(ensureArray(pickedEntityIds));
return this._stableEntityIds(
entityIds.filter((entityId) => {
if (picked.has(entityId)) {
return true;
}
const stateObj = states[entityId];
return (
!entities[entityId]?.hidden &&
stateObj &&
filterLogbookCompatibleEntities(stateObj)
);
})
);
}
);
private __logbookEntityIds = memoizeOne(
(states: HomeAssistant["states"]): string[] =>
this._stableEntityIds(
Object.values(states)
.filter((stateObj) => filterLogbookCompatibleEntities(stateObj))
.map((stateObj) => stateObj.entity_id)
)
);
private __filterEntityIds = memoizeOne(applySourceFilters);
private _lastEntityIds?: string[];
// A list keyed on the states must keep its identity or ha-logbook resubscribes.
private _stableEntityIds(entityIds: string[]): string[] {
if (this._lastEntityIds && shallowEqual(this._lastEntityIds, entityIds)) {
return this._lastEntityIds;
}
this._lastEntityIds = entityIds;
return entityIds;
}
private _applyURLParams() {
const queryParams = decodeHistoryLogbookQueryParams(
extractSearchParamsObject()
@@ -213,6 +406,19 @@ export class HaPanelLogbook extends LitElement {
this._targetPickerValue = this._storedTargetPickerValue;
}
// A target linked from another page must not be narrowed by the filters.
if (
targetPickerValue &&
!historyLogbookTargetsEqual(
targetPickerValue,
this._storedTargetPickerValue ?? {}
)
) {
this._filters = {};
} else if (!this.hasUpdated && this._storedFilters) {
this._filters = this._storedFilters;
}
if (queryParams.start_date || queryParams.end_date) {
const startDate = queryParams.start_date ?? this._time.range[0];
const endDate = queryParams.end_date ?? this._time.range[1];
@@ -273,9 +479,12 @@ export class HaPanelLogbook extends LitElement {
}
private _isDefaultState(): boolean {
return deepEqual(
{ time: this._time, targetPickerValue: this._targetPickerValue },
this._defaultState
return (
!countSourceFilters(this._filters) &&
deepEqual(
{ time: this._time, targetPickerValue: this._targetPickerValue },
this._defaultState
)
);
}
@@ -284,6 +493,8 @@ export class HaPanelLogbook extends LitElement {
this._time = defaultState.time;
this._targetPickerValue = defaultState.targetPickerValue;
this._storedTargetPickerValue = undefined;
this._filters = {};
this._storedFilters = undefined;
navigate("/logbook", { replace: true });
}
@@ -300,6 +511,9 @@ export class HaPanelLogbook extends LitElement {
case "refresh":
this._refreshLogbook();
break;
case "reset":
this._resetLogbook();
break;
}
}
@@ -363,6 +577,7 @@ export class HaPanelLogbook extends LitElement {
haStyle,
css`
:host {
--ha-generic-picker-width: min(400px, calc(100vw - 32px));
--ha-generic-picker-max-width: 400px;
}
@@ -375,59 +590,50 @@ export class HaPanelLogbook extends LitElement {
0px
) - var(--safe-area-inset-bottom, 0px)
);
overflow-x: hidden;
padding: 0 0 16px;
box-sizing: border-box;
overflow: hidden;
}
.main {
display: flex;
flex: 1;
min-height: 0;
}
.content-column {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.toolbar {
display: flex;
align-items: center;
gap: var(--ha-space-4);
box-sizing: border-box;
height: 56px;
flex-shrink: 0;
padding: 0 16px;
background: var(--primary-background-color);
border-bottom: 1px solid var(--divider-color);
direction: var(--direction);
overflow-x: auto;
scrollbar-width: none;
}
.toolbar::-webkit-scrollbar {
display: none;
}
.toolbar > * {
flex-shrink: 0;
}
ha-logbook {
flex: 1;
min-height: 0;
}
ha-date-range-picker {
margin-right: 16px;
margin-inline-end: 16px;
margin-inline-start: initial;
max-width: 100%;
direction: var(--direction);
}
@media all and (max-width: 870px) {
ha-date-range-picker {
width: 100%;
}
.filters {
flex-direction: column;
}
}
:host([narrow]) ha-date-range-picker {
margin-right: 0;
margin-inline-end: 0;
margin-inline-start: initial;
direction: var(--direction);
margin-bottom: 8px;
}
.content {
overflow-x: hidden;
}
.filters {
display: flex;
padding: 16px 16px 0;
}
:host([narrow]) .filters {
flex-wrap: wrap;
}
ha-target-picker {
flex: 1;
max-width: 100%;
min-width: 0;
}
`,
];
}
+18 -2
View File
@@ -841,6 +841,9 @@
},
"style": "Time format style"
},
"filter-device-classes": {
"caption": "Device class"
},
"subpage-data-table": {
"filters": "Filters",
"show_results": "Show {number} results",
@@ -11720,9 +11723,16 @@
}
},
"history": {
"start_search": "Select areas, devices, entities or labels above",
"sources": "Sources",
"sources_count": "Sources: {count}",
"add_targets": "Add targets",
"change_sources": "Change sources",
"no_targets": "No targets selected",
"start_search_title": "Nothing to show yet",
"start_search": "Select areas, devices, entities or labels to see their history",
"no_results_title": "No results found",
"no_results": "No history for the current selection. Try changing the targets, filters, or time range.",
"add_all": "Add all entities",
"remove_all": "Remove all selections",
"download_data": "Download data",
"download_data_error": "Unable to download data",
"add_card": "Add current view as card",
@@ -11730,6 +11740,12 @@
"error_no_data": "You need to select some data sources first."
},
"logbook": {
"sources": "[%key:ui::panel::history::sources%]",
"sources_count": "[%key:ui::panel::history::sources_count%]",
"change_sources": "[%key:ui::panel::history::change_sources%]",
"no_targets": "Showing all activity. Add targets to narrow it down.",
"no_results_title": "[%key:ui::panel::history::no_results_title%]",
"no_results": "No activity for the current selection. Try changing the targets, filters, or time range.",
"download_data": "[%key:ui::panel::history::download_data%]",
"download_data_error": "[%key:ui::panel::history::download_data_error%]",
"error_no_data": "No activity for the selected target in the selected time range."