From 292c522af526464bfbc2bbd588d50985cde2717f Mon Sep 17 00:00:00 2001 From: Oliver Lillie Date: Mon, 12 Oct 2026 19:25:10 +0300 Subject: [PATCH 01/17] feat(aggregation): define aggregate series domain contract and persisted configuration --- .../aggregation/__tests__/aggregation.spec.ts | 40 ++++++ .../src/lib/domain/aggregation/aggregation.ts | 124 ++++++++++++++++++ .../src/lib/domain/aggregation/types.ts | 70 ++++++++++ .../src/lib/domain/history-series.ts | 6 + .../src/molecules/target-row/types.ts | 2 + 5 files changed, 242 insertions(+) create mode 100644 custom_components/hass_datapoints/src/lib/domain/aggregation/__tests__/aggregation.spec.ts create mode 100644 custom_components/hass_datapoints/src/lib/domain/aggregation/aggregation.ts create mode 100644 custom_components/hass_datapoints/src/lib/domain/aggregation/types.ts diff --git a/custom_components/hass_datapoints/src/lib/domain/aggregation/__tests__/aggregation.spec.ts b/custom_components/hass_datapoints/src/lib/domain/aggregation/__tests__/aggregation.spec.ts new file mode 100644 index 00000000..da597917 --- /dev/null +++ b/custom_components/hass_datapoints/src/lib/domain/aggregation/__tests__/aggregation.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { calculateAggregate } from "@/lib/domain/aggregation/aggregation"; +import type { AggregateInput } from "@/lib/domain/aggregation/types"; + +const start = Date.parse("2026-01-01T00:00:00Z"); +const hour = 3600000; +function input(overrides: Partial = {}): AggregateInput { + return { + definition: { + id: "a", + operation: "mean", + interval: "hour", + mode: "interval", + resetOnZoom: false, + }, + samples: [], + range: { start, end: start + hour }, + timeZone: "UTC", + coverage: [{ start, end: start + hour }], + ...overrides, + }; +} +describe("GIVEN measurement readings 2, 3, and 10", () => { + describe("WHEN displaying the hourly average", () => { + it("THEN returns their arithmetic mean", () => { + expect.assertions(1); + expect( + calculateAggregate( + input({ + samples: [ + { time: start, value: 2 }, + { time: start + 1000, value: 3 }, + { time: start + 2000, value: 10 }, + ], + }) + ).points[0].value + ).toBe(5); + }); + }); +}); diff --git a/custom_components/hass_datapoints/src/lib/domain/aggregation/aggregation.ts b/custom_components/hass_datapoints/src/lib/domain/aggregation/aggregation.ts new file mode 100644 index 00000000..88b65b1f --- /dev/null +++ b/custom_components/hass_datapoints/src/lib/domain/aggregation/aggregation.ts @@ -0,0 +1,124 @@ +import { + AGGREGATE_OPERATIONS, + type AggregateDefinition, + type AggregateInput, + type AggregateOperation, + type AggregateResult, +} from "@/lib/domain/aggregation/types"; + +export function normalizeAggregateDefinitions( + value: unknown +): AggregateDefinition[] { + if (!Array.isArray(value)) { + return []; + } + const ids = new Set(); + return value.flatMap((item) => { + if ( + !item || + typeof item !== "object" || + typeof item.id !== "string" || + !item.id || + ids.has(item.id) || + !AGGREGATE_OPERATIONS.includes(item.operation) || + !["hour", "day", "week", "month"].includes(item.interval) + ) { + return []; + } + ids.add(item.id); + const definition: AggregateDefinition = { + id: item.id, + operation: item.operation, + interval: item.interval, + mode: + item.mode === "cumulative" && + [ + "consumption", + "transition_count", + "state_duration", + "event_count", + ].includes(item.operation) + ? "cumulative" + : "interval", + resetOnZoom: item.resetOnZoom === true, + }; + for (const key of ["states", "eventTypes"] as const) { + if (Array.isArray(item[key])) { + definition[key] = [ + ...new Set( + item[key].filter( + (v: unknown): v is string => typeof v === "string" && v.length > 0 + ) + ), + ]; + } + } + for (const key of ["color", "label"] as const) { + if (typeof item[key] === "string") { + definition[key] = item[key]; + } + } + if (typeof item.visible === "boolean") { + definition.visible = item.visible; + } + return [definition]; + }); +} +export function getAggregateOperations(entity: { + entity_id: string; + state?: string; + attributes?: Record; +}): AggregateOperation[] { + if (entity.entity_id.startsWith("event.")) { + return ["event_count"]; + } + if ( + entity.entity_id.startsWith("binary_sensor.") || + (entity.state !== undefined && + !["unknown", "unavailable"].includes(entity.state) && + !Number.isFinite(Number(entity.state))) + ) { + return ["transition_count", "state_duration", "state_percentage"]; + } + const operations: AggregateOperation[] = [ + "mean", + "min", + "max", + "median", + "first", + "last", + ]; + if ( + ["total", "total_increasing"].includes( + String(entity.attributes?.state_class) + ) + ) { + operations.push("consumption"); + } + return operations; +} +export function calculateAggregate(input: AggregateInput): AggregateResult { + const values = input.samples + .map((s) => s.value) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); + return { + points: [ + { + time: input.range.start, + value: values.reduce((s, v) => s + v, 0) / values.length, + periodStart: input.range.start, + periodEnd: input.range.end, + start: input.range.start, + end: input.range.end, + partial: false, + incomplete: false, + knownDuration: input.range.end - input.range.start, + totalDuration: input.range.end - input.range.start, + reasons: [], + }, + ], + unit: input.unit ?? "", + presentation: "line", + baseline: null, + }; +} diff --git a/custom_components/hass_datapoints/src/lib/domain/aggregation/types.ts b/custom_components/hass_datapoints/src/lib/domain/aggregation/types.ts new file mode 100644 index 00000000..41472b63 --- /dev/null +++ b/custom_components/hass_datapoints/src/lib/domain/aggregation/types.ts @@ -0,0 +1,70 @@ +export const AGGREGATE_OPERATIONS = [ + "mean", + "min", + "max", + "median", + "first", + "last", + "consumption", + "transition_count", + "state_duration", + "state_percentage", + "event_count", +] as const; +export type AggregateOperation = (typeof AGGREGATE_OPERATIONS)[number]; +export type AggregateInterval = "hour" | "day" | "week" | "month"; +export interface AggregateDefinition { + id: string; + operation: AggregateOperation; + interval: AggregateInterval; + mode: "interval" | "cumulative"; + states?: string[]; + eventTypes?: string[]; + resetOnZoom: boolean; + color?: string; + visible?: boolean; + label?: string; +} +export interface AggregateSample { + time: number; + state?: string; + value?: number | null; + eventType?: string; + eventTime?: number; + sum?: number | null; + lastReset?: number; + boundary?: boolean; +} +export interface AggregateRange { + start: number; + end: number; +} +export interface AggregateInput { + definition: AggregateDefinition; + samples: AggregateSample[]; + range: AggregateRange; + zoom?: AggregateRange; + timeZone: string; + stateClass?: string; + unit?: string; + coverage?: AggregateRange[]; +} +export interface AggregatePeriod extends AggregateRange { + periodStart: number; + periodEnd: number; + partial: boolean; +} +export interface AggregatePoint extends AggregatePeriod { + time: number; + value: number | null; + incomplete: boolean; + knownDuration: number; + totalDuration: number; + reasons: string[]; +} +export interface AggregateResult { + points: AggregatePoint[]; + unit: string; + presentation: "bar" | "line"; + baseline: number | null; +} diff --git a/custom_components/hass_datapoints/src/lib/domain/history-series.ts b/custom_components/hass_datapoints/src/lib/domain/history-series.ts index e3170afb..d2bf1657 100644 --- a/custom_components/hass_datapoints/src/lib/domain/history-series.ts +++ b/custom_components/hass_datapoints/src/lib/domain/history-series.ts @@ -1,3 +1,5 @@ +import { normalizeAggregateDefinitions } from "@/lib/domain/aggregation/aggregation"; +import type { AggregateDefinition } from "@/lib/domain/aggregation/types"; import { COLORS } from "@/constants"; import { type AnomalyDirection, @@ -10,6 +12,7 @@ import { normalizeEntityIds } from "@/lib/domain/target-selection"; */ export interface HistorySeriesAnalysis { + aggregates?: AggregateDefinition[]; expanded: boolean; show_trend_lines: boolean; trend_method: @@ -164,6 +167,9 @@ export function normalizeHistorySeriesAnalysis( ): HistorySeriesAnalysis { const source = analysis && typeof analysis === "object" ? analysis : {}; return { + ...(Array.isArray(source.aggregates) + ? { aggregates: normalizeAggregateDefinitions(source.aggregates) } + : {}), expanded: source.expanded === true, show_trend_lines: source.show_trend_lines === true, trend_method: ( diff --git a/custom_components/hass_datapoints/src/molecules/target-row/types.ts b/custom_components/hass_datapoints/src/molecules/target-row/types.ts index f67628cf..b870b91b 100644 --- a/custom_components/hass_datapoints/src/molecules/target-row/types.ts +++ b/custom_components/hass_datapoints/src/molecules/target-row/types.ts @@ -1,8 +1,10 @@ +import type { AggregateDefinition } from "@/lib/domain/aggregation/types"; // --------------------------------------------------------------------------- // Shared types for target-row and related components // --------------------------------------------------------------------------- export interface NormalizedAnalysis { + aggregates?: AggregateDefinition[]; expanded: boolean; show_trend_lines: boolean; trend_method: string; From 2531bd065b67255eca90883c2b60ddf7ab36b858 Mon Sep 17 00:00:00 2001 From: Ollie Date: Mon, 19 Oct 2026 18:59:45 +0300 Subject: [PATCH 02/17] chore: add pre-commit hook running lint-staged fixes Co-Authored-By: Claude Sonnet 5.5 --- .husky/pre-commit | 1 + package.json | 14 ++++- pnpm-lock.yaml | 137 ++++++++++++++++++++++++++++++++-------------- 3 files changed, 109 insertions(+), 43 deletions(-) create mode 100755 .husky/pre-commit diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..5ee7abd8 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm exec lint-staged diff --git a/package.json b/package.json index 0df06627..6f80332b 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "lint": "pnpm run lint:eslint && pnpm run lint:package-json && pnpm run lint:prettier && pnpm run lint:types", "format": "pnpm run format:eslint && pnpm run format:prettier", "sb": "storybook dev -p 6006", - "sb:build": "storybook build" + "sb:build": "storybook build", + "prepare": "husky" }, "devDependencies": { "@chromatic-com/storybook": "5.1.1", @@ -61,6 +62,8 @@ "eslint-plugin-wc": "3.1.0", "globals": "17.4.0", "happy-dom": "20.8.9", + "husky": "9.1.7", + "lint-staged": "17.6.0", "mockdate": "3.0.5", "npm-package-json-lint": "10.4.0", "playwright": "1.58.2", @@ -87,5 +90,14 @@ "@lit/context": "1.1.6", "@lit/localize": "0.12.2", "lit": "3.3.2" + }, + "lint-staged": { + "custom_components/hass_datapoints/src/**/*.{js,ts}": [ + "eslint --fix --max-warnings=0", + "prettier --write" + ], + "*.{json,md,yaml,yml,css}": [ + "prettier --write" + ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf0cba91..dac564b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,7 +46,7 @@ importers: version: 10.3.3(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0)) "@storybook/addon-docs": specifier: 10.3.3 - version: 10.3.3(@types/react@19.2.14)(esbuild@0.28.2)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)) + version: 10.3.3(@types/react@19.2.14)(esbuild@0.28.2)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1)) "@storybook/addon-mcp": specifier: 0.5.0 version: 0.5.0(@storybook/addon-vitest@10.3.3(@vitest/browser-playwright@4.1.11)(@vitest/browser@4.1.11)(@vitest/runner@4.1.11)(react@19.3.0)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0))(vitest@4.1.11))(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0))(typescript@5.9.3) @@ -58,13 +58,13 @@ importers: version: 8.6.15(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0)) "@storybook/web-components-vite": specifier: 10.3.3 - version: 10.3.3(esbuild@0.28.2)(lit@3.3.2)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)) + version: 10.3.3(esbuild@0.28.2)(lit@3.3.2)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1)) "@vitest/browser": specifier: 4.1.11 - version: 4.1.11(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2))(vitest@4.1.11) + version: 4.1.11(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1))(vitest@4.1.11) "@vitest/browser-playwright": specifier: 4.1.11 - version: 4.1.11(playwright@1.58.2)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2))(vitest@4.1.11) + version: 4.1.11(playwright@1.58.2)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1))(vitest@4.1.11) "@vitest/coverage-v8": specifier: 4.1.11 version: 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) @@ -101,6 +101,12 @@ importers: happy-dom: specifier: 20.8.9 version: 20.8.9 + husky: + specifier: 9.1.7 + version: 9.1.7 + lint-staged: + specifier: 17.6.0 + version: 17.6.0 mockdate: specifier: 3.0.5 version: 3.0.5 @@ -124,10 +130,10 @@ importers: version: 8.58.1(eslint@10.4.0)(typescript@5.9.3) vite: specifier: 8.3.3 - version: 8.3.3(@types/node@26.6.4)(esbuild@0.28.2) + version: 8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1) vitest: specifier: 4.1.11 - version: 4.1.11(@types/node@26.6.4)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.8.9)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)) + version: 4.1.11(@types/node@26.6.4)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.8.9)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1)) packages: "@adobe/css-tools@4.5.0": @@ -3313,6 +3319,14 @@ packages: integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==, } + husky@9.1.7: + resolution: + { + integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==, + } + engines: { node: ">=18" } + hasBin: true + ignore@5.3.2: resolution: { @@ -3877,6 +3891,14 @@ packages: integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==, } + lint-staged@17.6.0: + resolution: + { + integrity: sha512-nOhBfYkZEwkFtNzD5pK9s9LkcWoxJ+whLzEnhqi9nl7zTB5n7tg8+9iYgMGx2Wm3VlLu8cbNj0RACzY+nWi5iQ==, + } + engines: { node: ">=22.22.1" } + hasBin: true + lit-element@4.2.2: resolution: { @@ -4050,18 +4072,18 @@ packages: integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==, } - mrmime@2.0.1: + mockdate@3.0.5: resolution: { - integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==, + integrity: sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==, } - engines: { node: ">=10" } - mockdate@3.0.5: + mrmime@2.0.1: resolution: { - integrity: sha512-iniQP4rj1FhBdBYS/+eQv7j1tadJ9lJtdzgOpvsOHng/GbcDh2Fhdeq+ZRldrPYdXvCyfFUmFeEwEGXZB5I/AQ==, + integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==, } + engines: { node: ">=10" } ms@2.1.3: resolution: @@ -4790,6 +4812,13 @@ packages: prettier: optional: true + string-argv@0.3.2: + resolution: + { + integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==, + } + engines: { node: ">=0.6.19" } + string.prototype.includes@2.0.1: resolution: { @@ -5366,6 +5395,14 @@ packages: integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==, } + yaml@2.9.1: + resolution: + { + integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==, + } + engines: { node: ">= 14.6" } + hasBin: true + yargs-parser@20.2.9: resolution: { @@ -5878,10 +5915,10 @@ snapshots: axe-core: 4.14.0 storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0) - "@storybook/addon-docs@10.3.3(@types/react@19.2.14)(esbuild@0.28.2)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2))": + "@storybook/addon-docs@10.3.3(@types/react@19.2.14)(esbuild@0.28.2)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1))": dependencies: "@mdx-js/react": 3.1.1(@types/react@19.2.14)(react@19.3.0) - "@storybook/csf-plugin": 10.3.3(esbuild@0.28.2)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)) + "@storybook/csf-plugin": 10.3.3(esbuild@0.28.2)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1)) "@storybook/icons": 2.1.0(react@19.3.0) "@storybook/react-dom-shim": 10.3.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0)) react: 19.3.0 @@ -5916,32 +5953,32 @@ snapshots: "@storybook/icons": 2.1.0(react@19.3.0) storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0) optionalDependencies: - "@vitest/browser": 4.1.11(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2))(vitest@4.1.11) - "@vitest/browser-playwright": 4.1.11(playwright@1.58.2)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2))(vitest@4.1.11) + "@vitest/browser": 4.1.11(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1))(vitest@4.1.11) + "@vitest/browser-playwright": 4.1.11(playwright@1.58.2)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1))(vitest@4.1.11) "@vitest/runner": 4.1.11 - vitest: 4.1.11(@types/node@26.6.4)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.8.9)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)) + vitest: 4.1.11(@types/node@26.6.4)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.8.9)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1)) transitivePeerDependencies: - react - "@storybook/builder-vite@10.3.3(esbuild@0.28.2)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2))": + "@storybook/builder-vite@10.3.3(esbuild@0.28.2)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1))": dependencies: - "@storybook/csf-plugin": 10.3.3(esbuild@0.28.2)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)) + "@storybook/csf-plugin": 10.3.3(esbuild@0.28.2)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1)) storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0) ts-dedent: 2.3.0 - vite: 8.3.3(@types/node@26.6.4)(esbuild@0.28.2) + vite: 8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1) transitivePeerDependencies: - esbuild - rollup - webpack - "@storybook/csf-plugin@10.3.3(esbuild@0.28.2)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2))": + "@storybook/csf-plugin@10.3.3(esbuild@0.28.2)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1))": dependencies: storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0) unplugin: 2.3.11 optionalDependencies: esbuild: 0.28.2 rollup: 4.60.1 - vite: 8.3.3(@types/node@26.6.4)(esbuild@0.28.2) + vite: 8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1) "@storybook/global@5.0.0": {} @@ -5982,9 +6019,9 @@ snapshots: "@vitest/spy": 2.0.5 storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0) - "@storybook/web-components-vite@10.3.3(esbuild@0.28.2)(lit@3.3.2)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2))": + "@storybook/web-components-vite@10.3.3(esbuild@0.28.2)(lit@3.3.2)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1))": dependencies: - "@storybook/builder-vite": 10.3.3(esbuild@0.28.2)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)) + "@storybook/builder-vite": 10.3.3(esbuild@0.28.2)(rollup@4.60.1)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0))(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1)) "@storybook/web-components": 10.3.3(lit@3.3.2)(storybook@10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0)) storybook: 10.3.3(@testing-library/dom@10.4.0)(prettier@3.8.1)(react@19.3.0) transitivePeerDependencies: @@ -6289,29 +6326,29 @@ snapshots: dependencies: valibot: 1.5.0(typescript@5.9.3) - "@vitest/browser-playwright@4.1.11(playwright@1.58.2)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2))(vitest@4.1.11)": + "@vitest/browser-playwright@4.1.11(playwright@1.58.2)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1))(vitest@4.1.11)": dependencies: - "@vitest/browser": 4.1.11(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2))(vitest@4.1.11) - "@vitest/mocker": 4.1.11(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)) + "@vitest/browser": 4.1.11(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1))(vitest@4.1.11) + "@vitest/mocker": 4.1.11(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1)) playwright: 1.58.2 tinyrainbow: 3.2.0 - vitest: 4.1.11(@types/node@26.6.4)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.8.9)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)) + vitest: 4.1.11(@types/node@26.6.4)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.8.9)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - "@vitest/browser@4.1.11(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2))(vitest@4.1.11)": + "@vitest/browser@4.1.11(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1))(vitest@4.1.11)": dependencies: "@blazediff/core": 1.9.1 - "@vitest/mocker": 4.1.11(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)) + "@vitest/mocker": 4.1.11(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1)) "@vitest/utils": 4.1.11 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.2.0 - vitest: 4.1.11(@types/node@26.6.4)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.8.9)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)) + vitest: 4.1.11(@types/node@26.6.4)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.8.9)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1)) ws: 8.22.0 transitivePeerDependencies: - bufferutil @@ -6331,9 +6368,9 @@ snapshots: obug: 2.2.1 std-env: 4.3.0 tinyrainbow: 3.2.0 - vitest: 4.1.11(@types/node@26.6.4)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.8.9)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)) + vitest: 4.1.11(@types/node@26.6.4)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.8.9)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1)) optionalDependencies: - "@vitest/browser": 4.1.11(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2))(vitest@4.1.11) + "@vitest/browser": 4.1.11(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1))(vitest@4.1.11) "@vitest/expect@2.0.5": dependencies: @@ -6359,13 +6396,13 @@ snapshots: chai: 6.3.0 tinyrainbow: 3.2.0 - "@vitest/mocker@4.1.11(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2))": + "@vitest/mocker@4.1.11(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1))": dependencies: "@vitest/spy": 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.3.3(@types/node@26.6.4)(esbuild@0.28.2) + vite: 8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1) "@vitest/pretty-format@2.0.5": dependencies: @@ -6414,7 +6451,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.2.0 - vitest: 4.1.11(@types/node@26.6.4)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.8.9)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)) + vitest: 4.1.11(@types/node@26.6.4)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.8.9)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1)) "@vitest/utils@2.0.5": dependencies: @@ -7453,6 +7490,8 @@ snapshots: html-escaper@2.0.2: {} + husky@9.1.7: {} + ignore@5.3.2: {} ignore@7.0.12: {} @@ -7745,6 +7784,14 @@ snapshots: lines-and-columns@1.2.4: {} + lint-staged@17.6.0: + dependencies: + picomatch: 4.0.7 + string-argv: 0.3.2 + tinyexec: 1.3.1 + optionalDependencies: + yaml: 2.9.1 + lit-element@4.2.2: dependencies: "@lit-labs/ssr-dom-shim": 1.6.0 @@ -7851,10 +7898,10 @@ snapshots: minimist@1.2.8: {} - mrmime@2.0.1: {} - mockdate@3.0.5: {} + mrmime@2.0.1: {} + ms@2.1.3: {} nanoid@3.3.20: {} @@ -8360,6 +8407,8 @@ snapshots: - react - utf-8-validate + string-argv@0.3.2: {} + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.9 @@ -8623,7 +8672,7 @@ snapshots: validate-npm-package-name@6.0.0: {} - vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2): + vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -8634,11 +8683,12 @@ snapshots: "@types/node": 26.6.4 esbuild: 0.28.2 fsevents: 2.3.3 + yaml: 2.9.1 - vitest@4.1.11(@types/node@26.6.4)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.8.9)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)): + vitest@4.1.11(@types/node@26.6.4)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(happy-dom@20.8.9)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1)): dependencies: "@vitest/expect": 4.1.11 - "@vitest/mocker": 4.1.11(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)) + "@vitest/mocker": 4.1.11(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1)) "@vitest/pretty-format": 4.1.11 "@vitest/runner": 4.1.11 "@vitest/snapshot": 4.1.11 @@ -8655,11 +8705,11 @@ snapshots: tinyexec: 1.3.1 tinyglobby: 0.2.17 tinyrainbow: 3.2.0 - vite: 8.3.3(@types/node@26.6.4)(esbuild@0.28.2) + vite: 8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1) why-is-node-running: 2.3.0 optionalDependencies: "@types/node": 26.6.4 - "@vitest/browser-playwright": 4.1.11(playwright@1.58.2)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2))(vitest@4.1.11) + "@vitest/browser-playwright": 4.1.11(playwright@1.58.2)(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(yaml@2.9.1))(vitest@4.1.11) "@vitest/coverage-v8": 4.1.11(@vitest/browser@4.1.11)(vitest@4.1.11) "@vitest/ui": 4.1.11(vitest@4.1.11) happy-dom: 20.8.9 @@ -8732,6 +8782,9 @@ snapshots: yallist@4.0.0: {} + yaml@2.9.1: + optional: true + yargs-parser@20.2.9: {} yocto-queue@0.1.0: {} From 1e258828825114dec5f9847b790e22ba8bea15f9 Mon Sep 17 00:00:00 2001 From: Ollie Date: Sat, 24 Oct 2026 21:59:09 +0300 Subject: [PATCH 03/17] chore(build): rebuild bundle for aggregate series Co-Authored-By: Claude Sonnet 5.5 --- .../hass_datapoints/hass-datapoints-cards.js | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/custom_components/hass_datapoints/hass-datapoints-cards.js b/custom_components/hass_datapoints/hass-datapoints-cards.js index e3e65a61..2f520515 100644 --- a/custom_components/hass_datapoints/hass-datapoints-cards.js +++ b/custom_components/hass_datapoints/hass-datapoints-cards.js @@ -11350,6 +11350,52 @@ })); } //#endregion + //#region custom_components/hass_datapoints/src/lib/domain/aggregation/types.ts + var AGGREGATE_OPERATIONS = [ + "mean", + "min", + "max", + "median", + "first", + "last", + "consumption", + "transition_count", + "state_duration", + "state_percentage", + "event_count" + ]; + //#endregion + //#region custom_components/hass_datapoints/src/lib/domain/aggregation/aggregation.ts + function normalizeAggregateDefinitions(value) { + if (!Array.isArray(value)) return []; + const ids = /* @__PURE__ */ new Set(); + return value.flatMap((item) => { + if (!item || typeof item !== "object" || typeof item.id !== "string" || !item.id || ids.has(item.id) || !AGGREGATE_OPERATIONS.includes(item.operation) || ![ + "hour", + "day", + "week", + "month" + ].includes(item.interval)) return []; + ids.add(item.id); + const definition = { + id: item.id, + operation: item.operation, + interval: item.interval, + mode: item.mode === "cumulative" && [ + "consumption", + "transition_count", + "state_duration", + "event_count" + ].includes(item.operation) ? "cumulative" : "interval", + resetOnZoom: item.resetOnZoom === true + }; + for (const key of ["states", "eventTypes"]) if (Array.isArray(item[key])) definition[key] = [...new Set(item[key].filter((v) => typeof v === "string" && v.length > 0))]; + for (const key of ["color", "label"]) if (typeof item[key] === "string") definition[key] = item[key]; + if (typeof item.visible === "boolean") definition.visible = item.visible; + return [definition]; + }); + } + //#endregion //#region custom_components/hass_datapoints/src/lib/anomaly-direction.ts var DEFAULT_ANOMALY_DIRECTION = "both"; /** Narrow an unknown value to a valid direction, defaulting to "both". */ @@ -11427,6 +11473,7 @@ function normalizeHistorySeriesAnalysis(analysis) { const source = analysis && typeof analysis === "object" ? analysis : {}; return { + ...Array.isArray(source.aggregates) ? { aggregates: normalizeAggregateDefinitions(source.aggregates) } : {}, expanded: source.expanded === true, show_trend_lines: source.show_trend_lines === true, trend_method: [ From e4414d1913d366167206db13c4507d483991e49c Mon Sep 17 00:00:00 2001 From: Ollie Date: Sat, 24 Oct 2026 22:03:13 +0300 Subject: [PATCH 04/17] feat(aggregation): calculate calendar-aware aggregate series Co-Authored-By: Claude Sonnet 5.5 --- .../aggregation/__tests__/aggregation.spec.ts | 292 ++++++++++++++ .../aggregation/__tests__/calendar.spec.ts | 93 +++++ .../src/lib/domain/aggregation/aggregation.ts | 28 +- .../src/lib/domain/aggregation/calculate.ts | 367 ++++++++++++++++++ .../src/lib/domain/aggregation/calendar.ts | 158 ++++++++ 5 files changed, 911 insertions(+), 27 deletions(-) create mode 100644 custom_components/hass_datapoints/src/lib/domain/aggregation/__tests__/calendar.spec.ts create mode 100644 custom_components/hass_datapoints/src/lib/domain/aggregation/calculate.ts create mode 100644 custom_components/hass_datapoints/src/lib/domain/aggregation/calendar.ts diff --git a/custom_components/hass_datapoints/src/lib/domain/aggregation/__tests__/aggregation.spec.ts b/custom_components/hass_datapoints/src/lib/domain/aggregation/__tests__/aggregation.spec.ts index da597917..443489e3 100644 --- a/custom_components/hass_datapoints/src/lib/domain/aggregation/__tests__/aggregation.spec.ts +++ b/custom_components/hass_datapoints/src/lib/domain/aggregation/__tests__/aggregation.spec.ts @@ -38,3 +38,295 @@ describe("GIVEN measurement readings 2, 3, and 10", () => { }); }); }); + +const day = 24 * hour; +function dayInput(overrides: Partial): AggregateInput { + return input({ + definition: { + id: "a", + operation: "transition_count", + interval: "day", + mode: "interval", + resetOnZoom: false, + states: ["on"], + }, + range: { start, end: start + 2 * day }, + coverage: [{ start, end: start + 2 * day }], + ...overrides, + }); +} + +describe("GIVEN a door that opens twice with an initial open boundary", () => { + describe("WHEN counting transitions into on", () => { + it("THEN the boundary state is not counted as a transition", () => { + expect.assertions(1); + const result = calculateAggregate( + dayInput({ + samples: [ + { time: start - hour, state: "on", boundary: true }, + { time: start + hour, state: "off" }, + { time: start + 2 * hour, state: "on" }, + { time: start + 3 * hour, state: "off" }, + { time: start + 4 * hour, state: "on" }, + ], + }) + ); + expect(result.points[0].value).toBe(2); + }); + }); + describe("WHEN counting all state changes", () => { + it("THEN both directions are counted", () => { + expect.assertions(1); + const base = dayInput({}); + const result = calculateAggregate({ + ...base, + definition: { ...base.definition, states: [] }, + samples: [ + { time: start, state: "off" }, + { time: start + hour, state: "on" }, + { time: start + 2 * hour, state: "off" }, + ], + }); + expect(result.points[0].value).toBe(2); + }); + }); +}); + +describe("GIVEN a sensor that goes unavailable between two on readings", () => { + describe("WHEN counting transitions into on", () => { + it("THEN recovery from unavailable is not counted", () => { + expect.assertions(1); + const result = calculateAggregate( + dayInput({ + samples: [ + { time: start, state: "on" }, + { time: start + hour, state: "unavailable" }, + { time: start + 2 * hour, state: "on" }, + ], + }) + ); + expect(result.points[0].value).toBe(0); + }); + }); +}); + +describe("GIVEN a door on for 6 hours then off", () => { + const samples = [ + { time: start, state: "off" }, + { time: start + 6 * hour, state: "on" }, + { time: start + 12 * hour, state: "off" }, + ]; + const base = dayInput({ samples }); + describe("WHEN showing duration on", () => { + it("THEN returns 360 minutes", () => { + expect.assertions(2); + const result = calculateAggregate({ + ...base, + definition: { ...base.definition, operation: "state_duration" }, + }); + expect(result.points[0].value).toBe(360); + expect(result.presentation).toBe("bar"); + }); + }); + describe("WHEN showing percentage on", () => { + it("THEN uses known time as the denominator", () => { + expect.assertions(1); + const result = calculateAggregate({ + ...base, + definition: { ...base.definition, operation: "state_percentage" }, + coverage: [{ start, end: start + 12 * hour }], + }); + expect(result.points[0].value).toBe(50); + }); + }); +}); + +describe("GIVEN a day with no recorded history", () => { + describe("WHEN counting events", () => { + it("THEN the value is null and incomplete, not zero", () => { + expect.assertions(2); + const base = dayInput({ coverage: [] }); + const result = calculateAggregate({ + ...base, + definition: { ...base.definition, operation: "event_count" }, + }); + expect(result.points[0].value).toBeNull(); + expect(result.points[0].incomplete).toBe(true); + }); + }); +}); + +describe("GIVEN a button with single and double presses", () => { + const samples = [ + { time: start + hour, eventType: "single_press" }, + { time: start + 2 * hour, eventType: "double_press" }, + { time: start + 3 * hour, eventType: "single_press" }, + { time: start - hour, eventType: "single_press", boundary: true }, + ]; + const base = dayInput({ samples }); + describe("WHEN selecting single_press", () => { + it("THEN only those occurrences are counted", () => { + expect.assertions(1); + const result = calculateAggregate({ + ...base, + definition: { + ...base.definition, + operation: "event_count", + eventTypes: ["single_press"], + }, + }); + expect(result.points[0].value).toBe(2); + }); + }); + describe("WHEN selecting all types cumulatively", () => { + it("THEN the running total begins at the first matching event", () => { + expect.assertions(2); + const result = calculateAggregate({ + ...base, + definition: { + ...base.definition, + operation: "event_count", + eventTypes: [], + mode: "cumulative", + }, + samples: [{ time: start + day + hour, eventType: "single_press" }], + }); + expect(result.points.map((p) => p.value)).toEqual([1]); + expect(result.presentation).toBe("line"); + }); + }); +}); + +describe("GIVEN an energy meter reading 100, 110, then reset to 5", () => { + const samples = [ + { time: start, value: 100 }, + { time: start + hour, value: 110 }, + { time: start + 2 * hour, value: 5 }, + ]; + const base = dayInput({ samples, stateClass: "total_increasing" }); + const definition = { ...base.definition, operation: "consumption" as const }; + describe("WHEN showing consumption", () => { + it("THEN reflects consumption, not summed readings, and flags the reset", () => { + expect.assertions(2); + const result = calculateAggregate({ ...base, definition }); + expect(result.points[0].value).toBe(15); + expect(result.points[0].reasons).toContain("reset"); + }); + }); + describe("WHEN the state class is unknown", () => { + it("THEN a negative change is flagged ambiguous and not guessed", () => { + expect.assertions(2); + const result = calculateAggregate({ + ...base, + stateClass: undefined, + definition, + }); + expect(result.points[0].value).toBe(10); + expect(result.points[0].reasons).toContain("ambiguous-reset"); + }); + }); +}); + +describe("GIVEN a bidirectional total meter", () => { + describe("WHEN the total decreases", () => { + it("THEN the signed change is retained", () => { + expect.assertions(1); + const base = dayInput({ + stateClass: "total", + samples: [ + { time: start, value: 50 }, + { time: start + hour, value: 40 }, + ], + }); + const result = calculateAggregate({ + ...base, + definition: { ...base.definition, operation: "consumption" }, + }); + expect(result.points[0].value).toBe(-10); + }); + }); +}); + +describe("GIVEN cumulative consumption with a zoomed range", () => { + const samples = [ + { time: start, value: 0 }, + { time: start + hour, value: 10 }, + { time: start + day + hour, value: 30 }, + ]; + const zoom = { start: start + day, end: start + 2 * day }; + const base = dayInput({ samples, stateClass: "total_increasing", zoom }); + describe("WHEN reset on zoom is disabled", () => { + it("THEN the full-range baseline is retained", () => { + expect.assertions(1); + const result = calculateAggregate({ + ...base, + definition: { + ...base.definition, + operation: "consumption", + mode: "cumulative", + }, + }); + expect(result.points.map((p) => p.value)).toEqual([10, 30]); + }); + }); + describe("WHEN reset on zoom is enabled", () => { + it("THEN accumulation restarts within the zoomed range", () => { + expect.assertions(1); + const result = calculateAggregate({ + ...base, + definition: { + ...base.definition, + operation: "consumption", + mode: "cumulative", + resetOnZoom: true, + }, + }); + expect(result.points.map((p) => p.value)).toEqual([20]); + }); + }); +}); + +describe("GIVEN a range ending mid-day", () => { + describe("WHEN aggregating by day", () => { + it("THEN the edge bucket is partial and ignores later activity", () => { + expect.assertions(2); + const base = dayInput({ + range: { start, end: start + 12 * hour }, + coverage: [{ start, end: start + 12 * hour }], + samples: [ + { time: start + hour, eventType: "press" }, + { time: start + 13 * hour, eventType: "press" }, + ], + }); + const result = calculateAggregate({ + ...base, + definition: { ...base.definition, operation: "event_count" }, + }); + expect(result.points[0].partial).toBe(true); + expect(result.points[0].value).toBe(1); + }); + }); +}); + +describe("GIVEN readings 1, 2, 3, 9 in one hour", () => { + const samples = [1, 2, 3, 9].map((value, i) => ({ + time: start + i * 1000, + value, + })); + it.each([ + ["min", 1], + ["max", 9], + ["median", 2.5], + ["first", 1], + ["last", 9], + ] as const)("THEN %s is %d", (operation, expected) => { + expect.assertions(2); + const base = input({ samples }); + const result = calculateAggregate({ + ...base, + definition: { ...base.definition, operation }, + }); + expect(result.points[0].value).toBe(expected); + expect(result.presentation).toBe("line"); + }); +}); diff --git a/custom_components/hass_datapoints/src/lib/domain/aggregation/__tests__/calendar.spec.ts b/custom_components/hass_datapoints/src/lib/domain/aggregation/__tests__/calendar.spec.ts new file mode 100644 index 00000000..a4104bd4 --- /dev/null +++ b/custom_components/hass_datapoints/src/lib/domain/aggregation/__tests__/calendar.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { getPeriods } from "@/lib/domain/aggregation/calendar"; + +const hours = (n: number) => n * 3600000; + +describe("GIVEN a Europe/London spring-forward weekend", () => { + describe("WHEN splitting the range into days", () => { + it("THEN the 30 March 2025 day is 23 hours long", () => { + expect.assertions(2); + const periods = getPeriods( + { + start: Date.parse("2025-03-29T00:00:00Z"), + end: Date.parse("2025-03-31T23:00:00Z"), + }, + "day", + "Europe/London" + ); + expect(periods).toHaveLength(3); + expect(periods[1].periodEnd - periods[1].periodStart).toBe(hours(23)); + }); + }); +}); + +describe("GIVEN a New York autumn fall-back day", () => { + describe("WHEN splitting the range into hours", () => { + it("THEN the repeated hour yields distinct buckets for a 25-hour day", () => { + expect.assertions(2); + const start = Date.parse("2025-11-02T04:00:00Z"); + const periods = getPeriods( + { start, end: start + hours(25) }, + "hour", + "America/New_York" + ); + expect(periods).toHaveLength(25); + expect(new Set(periods.map((p) => p.start)).size).toBe(25); + }); + }); +}); + +describe("GIVEN a timezone different from UTC", () => { + describe("WHEN splitting into days", () => { + it("THEN day boundaries follow the configured timezone", () => { + expect.assertions(1); + const [first] = getPeriods( + { + start: Date.parse("2026-01-01T10:00:00Z"), + end: Date.parse("2026-01-01T12:00:00Z"), + }, + "day", + "Australia/Sydney" + ); + expect(first.periodStart).toBe(Date.parse("2025-12-31T13:00:00Z")); + }); + }); +}); + +describe("GIVEN a range starting on a Wednesday", () => { + describe("WHEN splitting into weeks", () => { + it("THEN weeks start on Monday and edge weeks are partial", () => { + expect.assertions(3); + const periods = getPeriods( + { + start: Date.parse("2026-01-07T00:00:00Z"), + end: Date.parse("2026-01-14T00:00:00Z"), + }, + "week", + "UTC" + ); + expect(periods[0].periodStart).toBe(Date.parse("2026-01-05T00:00:00Z")); + expect(periods[0].partial).toBe(true); + expect(periods).toHaveLength(2); + }); + }); +}); + +describe("GIVEN a range crossing February", () => { + describe("WHEN splitting into months", () => { + it("THEN February has 28 days in 2026", () => { + expect.assertions(1); + const periods = getPeriods( + { + start: Date.parse("2026-01-31T00:00:00Z"), + end: Date.parse("2026-03-02T00:00:00Z"), + }, + "month", + "UTC" + ); + expect(periods[1].periodEnd - periods[1].periodStart).toBe( + 28 * hours(24) + ); + }); + }); +}); diff --git a/custom_components/hass_datapoints/src/lib/domain/aggregation/aggregation.ts b/custom_components/hass_datapoints/src/lib/domain/aggregation/aggregation.ts index 88b65b1f..f73dd877 100644 --- a/custom_components/hass_datapoints/src/lib/domain/aggregation/aggregation.ts +++ b/custom_components/hass_datapoints/src/lib/domain/aggregation/aggregation.ts @@ -1,9 +1,7 @@ import { AGGREGATE_OPERATIONS, type AggregateDefinition, - type AggregateInput, type AggregateOperation, - type AggregateResult, } from "@/lib/domain/aggregation/types"; export function normalizeAggregateDefinitions( @@ -97,28 +95,4 @@ export function getAggregateOperations(entity: { } return operations; } -export function calculateAggregate(input: AggregateInput): AggregateResult { - const values = input.samples - .map((s) => s.value) - .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); - return { - points: [ - { - time: input.range.start, - value: values.reduce((s, v) => s + v, 0) / values.length, - periodStart: input.range.start, - periodEnd: input.range.end, - start: input.range.start, - end: input.range.end, - partial: false, - incomplete: false, - knownDuration: input.range.end - input.range.start, - totalDuration: input.range.end - input.range.start, - reasons: [], - }, - ], - unit: input.unit ?? "", - presentation: "line", - baseline: null, - }; -} +export { calculateAggregate } from "@/lib/domain/aggregation/calculate"; diff --git a/custom_components/hass_datapoints/src/lib/domain/aggregation/calculate.ts b/custom_components/hass_datapoints/src/lib/domain/aggregation/calculate.ts new file mode 100644 index 00000000..29b20f9d --- /dev/null +++ b/custom_components/hass_datapoints/src/lib/domain/aggregation/calculate.ts @@ -0,0 +1,367 @@ +import { getPeriods } from "@/lib/domain/aggregation/calendar"; +import type { + AggregateInput, + AggregatePeriod, + AggregatePoint, + AggregateRange, + AggregateResult, + AggregateSample, +} from "@/lib/domain/aggregation/types"; + +const MINUTE_MS = 60000; +const UNKNOWN_STATES = new Set(["unknown", "unavailable"]); +const CUMULATIVE_OPERATIONS = new Set([ + "consumption", + "transition_count", + "state_duration", + "event_count", +]); + +interface StateSpan extends AggregateRange { + state: string; +} + +interface Bucket { + value: number | null; + reasons: string[]; +} + +function isKnownState(state: string | undefined): state is string { + return typeof state === "string" && !UNKNOWN_STATES.has(state); +} + +function overlap(a: AggregateRange, b: AggregateRange): number { + return Math.max(0, Math.min(a.end, b.end) - Math.max(a.start, b.start)); +} + +function inPeriod(time: number, period: AggregatePeriod): boolean { + return time >= period.start && time < period.end; +} + +function sortSamples(samples: AggregateSample[]): AggregateSample[] { + return [...samples].sort((a, b) => a.time - b.time); +} + +function knownDurationOf( + period: AggregatePeriod, + coverage: AggregateRange[] | undefined +): number { + if (!coverage) { + return period.end - period.start; + } + return coverage.reduce((sum, c) => sum + overlap(period, c), 0); +} + +/** Known-state spans over `range`, seeded by the last sample at/before start. */ +function buildStateSpans( + samples: AggregateSample[], + range: AggregateRange, + coverage: AggregateRange[] | undefined +): StateSpan[] { + const stateful = sortSamples(samples).filter( + (s) => typeof s.state === "string" + ); + const points: { time: number; state: string }[] = []; + let seed: { time: number; state: string } | undefined; + for (const s of stateful) { + if (s.time <= range.start) { + seed = { time: range.start, state: s.state as string }; + } else if (s.time < range.end) { + points.push({ time: s.time, state: s.state as string }); + } + } + if (seed) { + points.unshift(seed); + } + const spans: StateSpan[] = []; + points.forEach((point, i) => { + if (!isKnownState(point.state)) { + return; + } + const end = i + 1 < points.length ? points[i + 1].time : range.end; + const span = { start: point.time, end, state: point.state }; + if (!coverage) { + spans.push(span); + return; + } + for (const c of coverage) { + const start = Math.max(span.start, c.start); + const clippedEnd = Math.min(span.end, c.end); + if (clippedEnd > start) { + spans.push({ start, end: clippedEnd, state: span.state }); + } + } + }); + return spans; +} + +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +function calculateMeasurement( + input: AggregateInput, + periods: AggregatePeriod[] +): Bucket[] { + const readings = sortSamples(input.samples).filter( + (s) => + !s.boundary && typeof s.value === "number" && Number.isFinite(s.value) + ); + return periods.map((period) => { + const values = readings + .filter((s) => inPeriod(s.time, period)) + .map((s) => s.value as number); + if (!values.length) { + return { value: null, reasons: ["no-data"] }; + } + const op = input.definition.operation; + let value: number; + if (op === "min") { + value = Math.min(...values); + } else if (op === "max") { + value = Math.max(...values); + } else if (op === "median") { + value = median(values); + } else if (op === "first") { + value = values[0]; + } else if (op === "last") { + value = values[values.length - 1]; + } else { + value = values.reduce((sum, v) => sum + v, 0) / values.length; + } + return { value, reasons: [] }; + }); +} + +function calculateTransitions( + input: AggregateInput, + periods: AggregatePeriod[] +): Bucket[] { + const selected = input.definition.states ?? []; + const stateful = sortSamples(input.samples).filter( + (s) => typeof s.state === "string" + ); + const times: number[] = []; + stateful.forEach((curr, i) => { + const prev = stateful[i - 1]; + if ( + !prev || + curr.time <= input.range.start || + curr.time >= input.range.end || + !isKnownState(prev.state) || + !isKnownState(curr.state) || + prev.state === curr.state || + (selected.length > 0 && !selected.includes(curr.state)) + ) { + return; + } + times.push(curr.time); + }); + return periods.map((period) => ({ + value: times.filter((t) => inPeriod(t, period)).length, + reasons: [], + })); +} + +function calculateDurations( + input: AggregateInput, + periods: AggregatePeriod[], + percentage: boolean +): Bucket[] { + const selected = input.definition.states ?? []; + if (!selected.length) { + return periods.map(() => ({ value: null, reasons: ["no-state-selected"] })); + } + const spans = buildStateSpans(input.samples, input.range, input.coverage); + return periods.map((period) => { + let known = 0; + let matching = 0; + for (const span of spans) { + const ms = overlap(period, span); + known += ms; + if (selected.includes(span.state)) { + matching += ms; + } + } + if (percentage) { + return known > 0 + ? { value: (matching / known) * 100, reasons: [] } + : { value: null, reasons: ["no-known-time"] }; + } + return known > 0 + ? { value: matching / MINUTE_MS, reasons: [] } + : { value: null, reasons: ["no-known-time"] }; + }); +} + +function calculateEvents( + input: AggregateInput, + periods: AggregatePeriod[] +): Bucket[] { + const types = input.definition.eventTypes ?? []; + const times = input.samples + .filter( + (s) => + !s.boundary && + typeof s.eventType === "string" && + (types.length === 0 || types.includes(s.eventType)) + ) + .map((s) => s.eventTime ?? s.time); + return periods.map((period) => ({ + value: times.filter((t) => inPeriod(t, period)).length, + reasons: [], + })); +} + +function calculateConsumption( + input: AggregateInput, + periods: AggregatePeriod[] +): Bucket[] { + const readings = sortSamples(input.samples).filter((s) => { + const v = s.sum ?? s.value; + return typeof v === "number" && Number.isFinite(v); + }); + const deltas: { time: number; value: number; reason?: string }[] = []; + readings.forEach((curr, i) => { + const prev = readings[i - 1]; + if ( + !prev || + curr.time <= input.range.start || + curr.time >= input.range.end + ) { + return; + } + const usesSum = + typeof curr.sum === "number" && typeof prev.sum === "number"; + const currValue = (usesSum ? curr.sum : curr.value) as number; + const prevValue = (usesSum ? prev.sum : prev.value) as number; + const delta = currValue - prevValue; + if (usesSum || delta >= 0) { + deltas.push({ time: curr.time, value: delta }); + } else if ( + input.stateClass === "total_increasing" || + (curr.lastReset !== undefined && curr.lastReset !== prev.lastReset) + ) { + deltas.push({ time: curr.time, value: currValue, reason: "reset" }); + } else if (input.stateClass === "total") { + deltas.push({ time: curr.time, value: delta }); + } else { + deltas.push({ time: curr.time, value: 0, reason: "ambiguous-reset" }); + } + }); + return periods.map((period) => { + const inside = deltas.filter((d) => inPeriod(d.time, period)); + if (!inside.length) { + return { value: null, reasons: ["no-data"] }; + } + return { + value: inside.reduce((sum, d) => sum + d.value, 0), + reasons: [ + ...new Set(inside.flatMap((d) => (d.reason ? [d.reason] : []))), + ], + }; + }); +} + +function toCumulative( + operation: string, + periods: AggregatePeriod[], + buckets: Bucket[] +): { buckets: Bucket[]; periods: AggregatePeriod[]; started: boolean } { + const startsAtZeroActivity = operation !== "consumption"; + let running = 0; + let started = false; + const keptBuckets: Bucket[] = []; + const keptPeriods: AggregatePeriod[] = []; + buckets.forEach((bucket, i) => { + if (!started) { + started = + bucket.value !== null && (!startsAtZeroActivity || bucket.value > 0); + if (!started) { + return; + } + } + running += bucket.value ?? 0; + keptBuckets.push({ value: running, reasons: bucket.reasons }); + keptPeriods.push(periods[i]); + }); + return { buckets: keptBuckets, periods: keptPeriods, started }; +} + +export function calculateAggregate(input: AggregateInput): AggregateResult { + const { definition } = input; + const cumulative = + definition.mode === "cumulative" && + CUMULATIVE_OPERATIONS.has(definition.operation); + if (cumulative && definition.resetOnZoom && input.zoom) { + const start = Math.max(input.range.start, input.zoom.start); + const end = Math.min(input.range.end, input.zoom.end); + if (end > start) { + return calculateAggregate({ + ...input, + zoom: undefined, + range: { start, end }, + }); + } + } + + let periods = getPeriods(input.range, definition.interval, input.timeZone); + const op = definition.operation; + let buckets: Bucket[]; + let unit = input.unit ?? ""; + if (op === "transition_count") { + buckets = calculateTransitions(input, periods); + unit = ""; + } else if (op === "state_duration") { + buckets = calculateDurations(input, periods, false); + unit = "min"; + } else if (op === "state_percentage") { + buckets = calculateDurations(input, periods, true); + unit = "%"; + } else if (op === "event_count") { + buckets = calculateEvents(input, periods); + unit = ""; + } else if (op === "consumption") { + buckets = calculateConsumption(input, periods); + } else { + buckets = calculateMeasurement(input, periods); + } + + let started = true; + if (cumulative) { + const result = toCumulative(op, periods, buckets); + ({ buckets, periods, started } = result); + } + + const points: AggregatePoint[] = periods.map((period, i) => { + const totalDuration = period.end - period.start; + const knownDuration = knownDurationOf(period, input.coverage); + const reasons = [...buckets[i].reasons]; + const incomplete = knownDuration < totalDuration; + if (incomplete) { + reasons.push("incomplete-coverage"); + } + return { + ...period, + time: period.start, + // Missing history is never reported as zero activity. + value: knownDuration === 0 ? null : buckets[i].value, + incomplete: incomplete || reasons.includes("no-data"), + knownDuration, + totalDuration, + reasons, + }; + }); + + const isBar = + !cumulative && + !["mean", "min", "max", "median", "first", "last"].includes(op); + return { + points, + unit, + presentation: isBar ? "bar" : "line", + baseline: cumulative && started && points.length ? 0 : null, + }; +} diff --git a/custom_components/hass_datapoints/src/lib/domain/aggregation/calendar.ts b/custom_components/hass_datapoints/src/lib/domain/aggregation/calendar.ts new file mode 100644 index 00000000..88d8465e --- /dev/null +++ b/custom_components/hass_datapoints/src/lib/domain/aggregation/calendar.ts @@ -0,0 +1,158 @@ +import type { + AggregateInterval, + AggregatePeriod, + AggregateRange, +} from "@/lib/domain/aggregation/types"; + +const HOUR_MS = 3600000; +const formatters = new Map(); + +function getFormatter(timeZone: string): Intl.DateTimeFormat { + let formatter = formatters.get(timeZone); + if (!formatter) { + formatter = new Intl.DateTimeFormat("en-US", { + timeZone, + hourCycle: "h23", + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + }); + formatters.set(timeZone, formatter); + } + return formatter; +} + +interface LocalParts { + year: number; + month: number; + day: number; + minute: number; + second: number; + weekday: number; +} + +function getLocalParts(ts: number, timeZone: string): LocalParts { + const values: Record = {}; + for (const part of getFormatter(timeZone).formatToParts(ts)) { + if (part.type !== "literal") { + values[part.type] = Number(part.value); + } + } + return { + year: values.year, + month: values.month, + day: values.day, + minute: values.minute, + second: values.second, + weekday: new Date( + Date.UTC(values.year, values.month - 1, values.day) + ).getUTCDay(), + }; +} + +function getOffsetMs(ts: number, timeZone: string): number { + const whole = Math.floor(ts / 1000) * 1000; + const values: Record = {}; + for (const part of getFormatter(timeZone).formatToParts(whole)) { + if (part.type !== "literal") { + values[part.type] = Number(part.value); + } + } + return ( + Date.UTC( + values.year, + values.month - 1, + values.day, + values.hour, + values.minute, + values.second + ) - whole + ); +} + +/** Local midnight (in `timeZone`) of a possibly un-normalised calendar date. */ +function localMidnight( + year: number, + month: number, + day: number, + timeZone: string +): number { + const wall = Date.UTC(year, month - 1, day); + const guess = wall - getOffsetMs(wall, timeZone); + return wall - getOffsetMs(guess, timeZone); +} + +export function getPeriodStart( + ts: number, + interval: AggregateInterval, + timeZone: string +): number { + const p = getLocalParts(ts, timeZone); + if (interval === "hour") { + const ms = ((ts % 1000) + 1000) % 1000; + return ts - ((p.minute * 60 + p.second) * 1000 + ms); + } + if (interval === "day") { + return localMidnight(p.year, p.month, p.day, timeZone); + } + if (interval === "week") { + return localMidnight( + p.year, + p.month, + p.day - ((p.weekday + 6) % 7), + timeZone + ); + } + return localMidnight(p.year, p.month, 1, timeZone); +} + +export function getNextPeriodStart( + periodStart: number, + interval: AggregateInterval, + timeZone: string +): number { + if (interval === "hour") { + return periodStart + HOUR_MS; + } + const p = getLocalParts(periodStart + HOUR_MS * 2, timeZone); + if (interval === "day") { + return localMidnight(p.year, p.month, p.day + 1, timeZone); + } + if (interval === "week") { + return localMidnight(p.year, p.month, p.day + 7, timeZone); + } + return localMidnight(p.year, p.month + 1, 1, timeZone); +} + +/** + * Calendar periods overlapping `range`, clipped to it. Periods cut by the + * range edge are flagged partial; the full period bounds are preserved. + */ +export function getPeriods( + range: AggregateRange, + interval: AggregateInterval, + timeZone: string +): AggregatePeriod[] { + const periods: AggregatePeriod[] = []; + if (!(range.end > range.start)) { + return periods; + } + let periodStart = getPeriodStart(range.start, interval, timeZone); + while (periodStart < range.end) { + const periodEnd = getNextPeriodStart(periodStart, interval, timeZone); + const start = Math.max(periodStart, range.start); + const end = Math.min(periodEnd, range.end); + periods.push({ + periodStart, + periodEnd, + start, + end, + partial: start !== periodStart || end !== periodEnd, + }); + periodStart = periodEnd; + } + return periods; +} From ca8c3b87a08391e74f827bd76cdd6444150335a0 Mon Sep 17 00:00:00 2001 From: Ollie Date: Sat, 24 Oct 2026 22:24:38 +0300 Subject: [PATCH 05/17] feat(aggregation): add aggregate controls and persistence for binary rows Refs #90 Co-Authored-By: Claude Sonnet 5.5 --- .../hass_datapoints/hass-datapoints-cards.js | 1779 +++++++++++------ .../src/lib/chart/chart-analysis.ts | 3 +- .../lib/domain/__tests__/series-rows.spec.ts | 42 + .../src/lib/domain/aggregation/labels.ts | 104 + .../src/lib/domain/history-series.ts | 3 +- .../analysis-aggregate-group.spec.ts | 170 ++ .../analysis-aggregate-group.styles.ts | 57 + .../analysis-aggregate-group.ts | 143 ++ .../analysis-aggregate-group/i18n/de.ts | 27 + .../analysis-aggregate-group/i18n/es.ts | 27 + .../analysis-aggregate-group/i18n/fi.ts | 27 + .../analysis-aggregate-group/i18n/fr.ts | 27 + .../analysis-aggregate-group/i18n/pt.ts | 27 + .../analysis-aggregate-group/i18n/zh-hans.ts | 27 + .../analysis-aggregate-group.stories.ts | 64 + .../__tests__/target-row-aggregates.spec.ts | 67 + .../src/molecules/target-row/target-row.ts | 35 +- .../__tests__/datapoints-url-sync.spec.ts | 66 + 18 files changed, 2037 insertions(+), 658 deletions(-) create mode 100644 custom_components/hass_datapoints/src/lib/domain/aggregation/labels.ts create mode 100644 custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/__tests__/analysis-aggregate-group.spec.ts create mode 100644 custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.styles.ts create mode 100644 custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts create mode 100644 custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts create mode 100644 custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts create mode 100644 custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts create mode 100644 custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts create mode 100644 custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts create mode 100644 custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts create mode 100644 custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/stories/analysis-aggregate-group.stories.ts create mode 100644 custom_components/hass_datapoints/src/molecules/target-row/__tests__/target-row-aggregates.spec.ts diff --git a/custom_components/hass_datapoints/hass-datapoints-cards.js b/custom_components/hass_datapoints/hass-datapoints-cards.js index 2f520515..3b728e64 100644 --- a/custom_components/hass_datapoints/hass-datapoints-cards.js +++ b/custom_components/hass_datapoints/hass-datapoints-cards.js @@ -735,7 +735,7 @@ } //#endregion //#region custom_components/hass_datapoints/src/cards/action/action.styles.ts - var styles$78 = i$5` + var styles$79 = i$5` :host { display: block; } @@ -844,7 +844,7 @@ ]; //#endregion //#region custom_components/hass_datapoints/src/atoms/display/color-swatch/color-swatch.styles.ts - var styles$77 = i$5` + var styles$78 = i$5` :host { display: block; } @@ -1021,13 +1021,13 @@ `; } }; - _defineProperty(ColorSwatch, "styles", styles$77); + _defineProperty(ColorSwatch, "styles", styles$78); __decorate([n$1({ type: String })], ColorSwatch.prototype, "color", null); __decorate([n$1({ type: String })], ColorSwatch.prototype, "label", null); customElements.define("color-swatch", ColorSwatch); //#endregion //#region custom_components/hass_datapoints/src/atoms/display/feedback-banner/feedback-banner.styles.ts - var styles$76 = i$5` + var styles$77 = i$5` :host { display: block; } @@ -1104,7 +1104,7 @@ `; } }; - _defineProperty(FeedbackBanner, "styles", styles$76); + _defineProperty(FeedbackBanner, "styles", styles$77); __decorate([n$1({ type: String })], FeedbackBanner.prototype, "kind", null); __decorate([n$1({ type: String })], FeedbackBanner.prototype, "text", null); __decorate([n$1({ type: Boolean })], FeedbackBanner.prototype, "visible", null); @@ -1112,7 +1112,7 @@ customElements.define("feedback-banner", FeedbackBanner); //#endregion //#region custom_components/hass_datapoints/src/cards/action/action-targets/action-targets.styles.ts - var styles$75 = i$5` + var styles$76 = i$5` :host { display: block; } @@ -1124,7 +1124,7 @@ `; //#endregion //#region custom_components/hass_datapoints/src/molecules/chip-group/chip-group.styles.ts - var styles$74 = i$5` + var styles$75 = i$5` :host { display: block; } @@ -1141,7 +1141,7 @@ `; //#endregion //#region custom_components/hass_datapoints/src/atoms/form/entity-chip/entity-chip.styles.ts - var styles$73 = i$5` + var styles$74 = i$5` :host { display: inline-flex; } @@ -1272,7 +1272,7 @@ `; } }; - _defineProperty(EntityChip, "styles", styles$73); + _defineProperty(EntityChip, "styles", styles$74); __decorate([n$1({ type: String })], EntityChip.prototype, "type", null); __decorate([n$1({ type: String, @@ -1346,7 +1346,7 @@ `; } }; - _defineProperty(ChipGroup, "styles", styles$74); + _defineProperty(ChipGroup, "styles", styles$75); __decorate([n$1({ type: Array })], ChipGroup.prototype, "items", null); __decorate([n$1({ type: Object })], ChipGroup.prototype, "hass", null); __decorate([n$1({ type: Boolean })], ChipGroup.prototype, "removable", null); @@ -1433,7 +1433,7 @@ `; } }; - _defineProperty(CardActionTargets, "styles", styles$75); + _defineProperty(CardActionTargets, "styles", styles$76); __decorate([n$1({ attribute: false })], CardActionTargets.prototype, "hass", null); __decorate([n$1({ type: Boolean, @@ -1796,7 +1796,7 @@ _feedbackText: { state: true }, _feedbackVisible: { state: true } }); - _defineProperty(HassDatapointsActionCard, "styles", styles$78); + _defineProperty(HassDatapointsActionCard, "styles", styles$79); __decorate([e$4("#msg")], HassDatapointsActionCard.prototype, "_msgEl", null); __decorate([e$4("#btn")], HassDatapointsActionCard.prototype, "_btnEl", null); __decorate([e$4("#ann")], HassDatapointsActionCard.prototype, "_annEl", null); @@ -2223,10 +2223,10 @@ ]; //#endregion //#region custom_components/hass_datapoints/src/cards/action/i18n/fi.ts - var fi_exports$28 = /* @__PURE__ */ __exportAll({ translations: () => translations$167 }); - var translations$167; - var init_fi$28 = __esmMin((() => { - translations$167 = { + var fi_exports$29 = /* @__PURE__ */ __exportAll({ translations: () => translations$173 }); + var translations$173; + var init_fi$29 = __esmMin((() => { + translations$173 = { General: "Yleiset", "Related items": "Liittyvät kohteet", "Datapoint Appearance": "Datapisteen ulkoasu", @@ -2243,20 +2243,20 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/history/history-chart/i18n/fi.ts - var fi_exports$27 = /* @__PURE__ */ __exportAll({ translations: () => translations$166 }); - var translations$166; - var init_fi$27 = __esmMin((() => { - translations$166 = { + var fi_exports$28 = /* @__PURE__ */ __exportAll({ translations: () => translations$172 }); + var translations$172; + var init_fi$28 = __esmMin((() => { + translations$172 = { "Date window:": "Aikaikkuna:", "Actual:": "Todellinen:" }; })); //#endregion //#region custom_components/hass_datapoints/src/cards/history/i18n/fi.ts - var fi_exports$26 = /* @__PURE__ */ __exportAll({ translations: () => translations$165 }); - var translations$165; - var init_fi$26 = __esmMin((() => { - translations$165 = { + var fi_exports$27 = /* @__PURE__ */ __exportAll({ translations: () => translations$171 }); + var translations$171; + var init_fi$27 = __esmMin((() => { + translations$171 = { General: "Yleiset", Entity: "Entiteetti", "Multiple entities": "Useita entiteettejä", @@ -2273,10 +2273,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/list/i18n/fi.ts - var fi_exports$25 = /* @__PURE__ */ __exportAll({ translations: () => translations$164 }); - var translations$164; - var init_fi$25 = __esmMin((() => { - translations$164 = { + var fi_exports$26 = /* @__PURE__ */ __exportAll({ translations: () => translations$170 }); + var translations$170; + var init_fi$26 = __esmMin((() => { + translations$170 = { "Search datapoints…": "Hae datapisteitä…", "Delete record": "Poista tietue", Delete: "Poista", @@ -2294,10 +2294,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/quick/i18n/fi.ts - var fi_exports$24 = /* @__PURE__ */ __exportAll({ translations: () => translations$163 }); - var translations$163; - var init_fi$24 = __esmMin((() => { - translations$163 = { + var fi_exports$25 = /* @__PURE__ */ __exportAll({ translations: () => translations$169 }); + var translations$169; + var init_fi$25 = __esmMin((() => { + translations$169 = { General: "Yleiset", "Icon & colour": "Kuvake ja väri", "Related items": "Liittyvät kohteet", @@ -2315,10 +2315,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/sensor/i18n/fi.ts - var fi_exports$23 = /* @__PURE__ */ __exportAll({ translations: () => translations$162 }); - var translations$162; - var init_fi$23 = __esmMin((() => { - translations$162 = { + var fi_exports$24 = /* @__PURE__ */ __exportAll({ translations: () => translations$168 }); + var translations$168; + var init_fi$24 = __esmMin((() => { + translations$168 = { Entity: "Entiteetti", Display: "Näyttö", "Records list": "Tietueiden luettelo", @@ -2338,10 +2338,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/chart/i18n/fi.ts - var fi_exports$22 = /* @__PURE__ */ __exportAll({ translations: () => translations$161 }); - var translations$161; - var init_fi$22 = __esmMin((() => { - translations$161 = { + var fi_exports$23 = /* @__PURE__ */ __exportAll({ translations: () => translations$167 }); + var translations$167; + var init_fi$23 = __esmMin((() => { + translations$167 = { "⚠️ Anomaly Insight": "⚠️ Poikkeavuushavainto", "⚠️ Multi-method Anomaly": "⚠️ Monimenetelmäinen poikkeavuus", "Click the highlighted circle to add an annotation.": "Klikkaa korostettua ympyrää lisätäksesi huomautuksen.", @@ -2376,10 +2376,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/ha/i18n/fi.ts - var fi_exports$21 = /* @__PURE__ */ __exportAll({ translations: () => translations$160 }); - var translations$160; - var init_fi$21 = __esmMin((() => { - translations$160 = { + var fi_exports$22 = /* @__PURE__ */ __exportAll({ translations: () => translations$166 }); + var translations$166; + var init_fi$22 = __esmMin((() => { + translations$166 = { "Confirm delete": "Vahvista poisto", "Are you sure you want to delete this item?": "Oletko varma, että haluat poistaa tämän kohteen?", Cancel: "Peruuta", @@ -2394,10 +2394,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/history-page/i18n/fi.ts - var fi_exports$20 = /* @__PURE__ */ __exportAll({ translations: () => translations$159 }); - var translations$159; - var init_fi$20 = __esmMin((() => { - translations$159 = { + var fi_exports$21 = /* @__PURE__ */ __exportAll({ translations: () => translations$165 }); + var translations$165; + var init_fi$21 = __esmMin((() => { + translations$165 = { "entity_id={0}": "entity_id={0}", "device_id={0}": "device_id={0}", "area_id={0}": "area_id={0}", @@ -2495,20 +2495,51 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/timeline/i18n/fi.ts - var fi_exports$19 = /* @__PURE__ */ __exportAll({ translations: () => translations$158 }); - var translations$158; - var init_fi$19 = __esmMin((() => { - translations$158 = { + var fi_exports$20 = /* @__PURE__ */ __exportAll({ translations: () => translations$164 }); + var translations$164; + var init_fi$20 = __esmMin((() => { + translations$164 = { Wk: "Vk", "Week of": "Viikko" }; })); //#endregion + //#region custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts + var fi_exports$19 = /* @__PURE__ */ __exportAll({ translations: () => translations$163 }); + var translations$163; + var init_fi$19 = __esmMin((() => { + translations$163 = { + "Aggregate series": "Koosteet", + "Add aggregate": "Lisää koostesarja", + "Remove aggregate": "Poista koostesarja", + "Aggregate operation": "Koosteen toiminto", + "Aggregate state": "Koosteen tila", + "Aggregate interval": "Koosteen jakso", + "Count changes to a state": "Laske muutokset tilaan", + "Count all state changes": "Laske kaikki tilamuutokset", + "Time in a state": "Aika tilassa", + "Percentage of time in a state": "Tilassa vietetyn ajan osuus prosentteina", + Hourly: "Tunneittain", + Daily: "Päivittäin", + Weekly: "Viikoittain", + Monthly: "Kuukausittain", + On: "Päällä", + Off: "Pois", + Open: "Auki", + Closed: "Kiinni", + Detected: "Havaittu", + Clear: "Vapaa", + Occupied: "Varattu", + Unlocked: "Avattu", + Locked: "Lukittu" + }; + })); + //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-anomaly-group/i18n/fi.ts - var fi_exports$18 = /* @__PURE__ */ __exportAll({ translations: () => translations$157 }); - var translations$157; + var fi_exports$18 = /* @__PURE__ */ __exportAll({ translations: () => translations$162 }); + var translations$162; var init_fi$18 = __esmMin((() => { - translations$157 = { + translations$162 = { Direction: "Suunta", Both: "Molemmat", Above: "Yläpuolella", @@ -2556,10 +2587,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-delta-group/i18n/fi.ts - var fi_exports$17 = /* @__PURE__ */ __exportAll({ translations: () => translations$156 }); - var translations$156; + var fi_exports$17 = /* @__PURE__ */ __exportAll({ translations: () => translations$161 }); + var translations$161; var init_fi$17 = __esmMin((() => { - translations$156 = { + translations$161 = { "Show delta vs selected date window": "Näytä delta vs. valittu aikaikkuna", "Select a date window tab to enable delta analysis.": "Valitse aikaikkuna-välilehti ottaaksesi delta-analyysin käyttöön.", "Show delta in tooltip": "Näytä delta työkaluvihjeessä", @@ -2568,10 +2599,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-rate-group/i18n/fi.ts - var fi_exports$16 = /* @__PURE__ */ __exportAll({ translations: () => translations$155 }); - var translations$155; + var fi_exports$16 = /* @__PURE__ */ __exportAll({ translations: () => translations$160 }); + var translations$160; var init_fi$16 = __esmMin((() => { - translations$155 = { + translations$160 = { "Show rate of change": "Näytä muutosnopeus", "Show rate of change crosshairs": "Näytä muutosnopeuden tähtäin", "Rate window": "Muutosikkuna", @@ -2583,10 +2614,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-sample-group/i18n/fi.ts - var fi_exports$15 = /* @__PURE__ */ __exportAll({ translations: () => translations$154 }); - var translations$154; + var fi_exports$15 = /* @__PURE__ */ __exportAll({ translations: () => translations$159 }); + var translations$159; var init_fi$15 = __esmMin((() => { - translations$154 = { + translations$159 = { Downsampling: "Alasnäytteistys", Interval: "Väli", Aggregate: "Kooste", @@ -2618,20 +2649,20 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-summary-group/i18n/fi.ts - var fi_exports$14 = /* @__PURE__ */ __exportAll({ translations: () => translations$153 }); - var translations$153; + var fi_exports$14 = /* @__PURE__ */ __exportAll({ translations: () => translations$158 }); + var translations$158; var init_fi$14 = __esmMin((() => { - translations$153 = { + translations$158 = { "Show min / max / mean": "Näytä min / max / keskiarvo", "Show range shading": "Näytä aluevarjostus" }; })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-threshold-group/i18n/fi.ts - var fi_exports$13 = /* @__PURE__ */ __exportAll({ translations: () => translations$152 }); - var translations$152; + var fi_exports$13 = /* @__PURE__ */ __exportAll({ translations: () => translations$157 }); + var translations$157; var init_fi$13 = __esmMin((() => { - translations$152 = { + translations$157 = { "Show threshold analysis": "Näytä kynnysanalyysi", "Shade threshold area": "Varjosta kynnysalue", Threshold: "Kynnys", @@ -2642,10 +2673,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-trend-group/i18n/fi.ts - var fi_exports$12 = /* @__PURE__ */ __exportAll({ translations: () => translations$151 }); - var translations$151; + var fi_exports$12 = /* @__PURE__ */ __exportAll({ translations: () => translations$156 }); + var translations$156; var init_fi$12 = __esmMin((() => { - translations$151 = { + translations$156 = { "Show trend lines": "Näytä trendiviivat", "Show trend crosshairs": "Näytä trenditähtäin", "Trend method": "Trendimenetelmä", @@ -2663,10 +2694,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/anomaly-monitor-wizard/i18n/fi.ts - var fi_exports$11 = /* @__PURE__ */ __exportAll({ translations: () => translations$150 }); - var translations$150; + var fi_exports$11 = /* @__PURE__ */ __exportAll({ translations: () => translations$155 }); + var translations$155; var init_fi$11 = __esmMin((() => { - translations$150 = { + translations$155 = { "Add all series from chart": "Lisää kaikki sarjat kaaviosta", "Add from current chart": "Lisää nykyisestä kaaviosta", "Create anomaly monitor": "Luo poikkeamamonitori", @@ -2676,17 +2707,17 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/comparison-tab-rail/i18n/fi.ts - var fi_exports$10 = /* @__PURE__ */ __exportAll({ translations: () => translations$149 }); - var translations$149; + var fi_exports$10 = /* @__PURE__ */ __exportAll({ translations: () => translations$154 }); + var translations$154; var init_fi$10 = __esmMin((() => { - translations$149 = { "Add date window": "Lisää aikaikkuna" }; + translations$154 = { "Add date window": "Lisää aikaikkuna" }; })); //#endregion //#region custom_components/hass_datapoints/src/molecules/date-window-dialog/i18n/fi.ts - var fi_exports$9 = /* @__PURE__ */ __exportAll({ translations: () => translations$148 }); - var translations$148; + var fi_exports$9 = /* @__PURE__ */ __exportAll({ translations: () => translations$153 }); + var translations$153; var init_fi$9 = __esmMin((() => { - translations$148 = { + translations$153 = { "A date window saves a named date range as a tab, so you can quickly preview it against the selected range or jump the chart back to it later.": "Aikaikkuna tallentaa nimetyn päivävälin välilehteksi, jotta voit nopeasti esikatsella sitä suhteessa valittuun alueeseen tai palata kaavion kyseiseen ajanjaksoon.", Name: "Nimi", "e.g. Heating season start": "esim. Lämmityskausi alkaa", @@ -2703,10 +2734,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/sidebar-options/sections/i18n/fi.ts - var fi_exports$8 = /* @__PURE__ */ __exportAll({ translations: () => translations$147 }); - var translations$147; + var fi_exports$8 = /* @__PURE__ */ __exportAll({ translations: () => translations$152 }); + var translations$152; var init_fi$8 = __esmMin((() => { - translations$147 = { + translations$152 = { Datapoints: "Datapisteet", "Choose which annotation datapoints appear on the chart.": "Valitse, mitkä huomautusten datapisteet näkyvät kaaviossa.", "Linked to selected targets": "Linkitetty valittuihin kohteisiin", @@ -2716,10 +2747,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/target-row/i18n/fi.ts - var fi_exports$7 = /* @__PURE__ */ __exportAll({ translations: () => translations$146 }); - var translations$146; + var fi_exports$7 = /* @__PURE__ */ __exportAll({ translations: () => translations$151 }); + var translations$151; var init_fi$7 = __esmMin((() => { - translations$146 = { + translations$151 = { "Analysis configured": "Analyysi määritetty", "Configure analysis": "Määritä analyysi", "Stepped series": "Porrastettu sarja", @@ -2731,17 +2762,17 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/target-row-list/i18n/fi.ts - var fi_exports$6 = /* @__PURE__ */ __exportAll({ translations: () => translations$145 }); - var translations$145; + var fi_exports$6 = /* @__PURE__ */ __exportAll({ translations: () => translations$150 }); + var translations$150; var init_fi$6 = __esmMin((() => { - translations$145 = {}; + translations$150 = {}; })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/components/ai-query-brief-dialog/i18n/fi.ts - var fi_exports$5 = /* @__PURE__ */ __exportAll({ translations: () => translations$144 }); - var translations$144; + var fi_exports$5 = /* @__PURE__ */ __exportAll({ translations: () => translations$149 }); + var translations$149; var init_fi$5 = __esmMin((() => { - translations$144 = { + translations$149 = { "AI query brief": "AI-kyselytiivistelma", "This brief is intended for another AI to fetch raw Home Assistant history and hass_datapoints anomaly details. Review it, then copy it into your AI tool of choice.": "Tama tiivistelma on tarkoitettu toiselle AI:lle Home Assistantin raakadat historian ja hass_datapointsin poikkeamien lisatietojen hakemista varten. Tarkista se ja kopioi sitten haluamaasi AI-tyokaluun.", "Copy-ready brief": "Kopiointivalmis tiivistelma", @@ -2753,10 +2784,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/components/history-targets/i18n/fi.ts - var fi_exports$4 = /* @__PURE__ */ __exportAll({ translations: () => translations$143 }); - var translations$143; + var fi_exports$4 = /* @__PURE__ */ __exportAll({ translations: () => translations$148 }); + var translations$148; var init_fi$4 = __esmMin((() => { - translations$143 = { + translations$148 = { Targets: "Kohteet", "Each row controls one chart series.": "Jokainen rivi ohjaa yhtä kaaviosarjaa.", "Add target": "Lisää kohde", @@ -2765,10 +2796,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/components/panel-shell/i18n/fi.ts - var fi_exports$3 = /* @__PURE__ */ __exportAll({ translations: () => translations$142 }); - var translations$142; + var fi_exports$3 = /* @__PURE__ */ __exportAll({ translations: () => translations$147 }); + var translations$147; var init_fi$3 = __esmMin((() => { - translations$142 = { + translations$147 = { "Loading Datapoints…": "Ladataan Datapoints…", Datapoints: "Datapoints", "Page options": "Sivun asetukset", @@ -2783,10 +2814,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/components/range-toolbar/i18n/fi.ts - var fi_exports$2 = /* @__PURE__ */ __exportAll({ translations: () => translations$141 }); - var translations$141; + var fi_exports$2 = /* @__PURE__ */ __exportAll({ translations: () => translations$146 }); + var translations$146; var init_fi$2 = __esmMin((() => { - translations$141 = { + translations$146 = { "Toggle sidebar": "Vaihda sivupalkki", Start: "Alku", End: "Loppu", @@ -2811,16 +2842,17 @@ })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/i18n/fi.ts - var fi_exports$1 = /* @__PURE__ */ __exportAll({ translations: () => translations$140 }); - var translations$140; + var fi_exports$1 = /* @__PURE__ */ __exportAll({ translations: () => translations$145 }); + var translations$145; var init_fi$1 = __esmMin((() => { - translations$140 = { "Selected range": "Valittu aikaväli" }; + translations$145 = { "Selected range": "Valittu aikaväli" }; })); //#endregion //#region custom_components/hass_datapoints/src/lib/i18n/locales/fi.ts var fi_exports = /* @__PURE__ */ __exportAll({ templates: () => templates$5 }); var modules$5, merged$5, templates$5; var init_fi = __esmMin((() => { + init_fi$29(); init_fi$28(); init_fi$27(); init_fi$26(); @@ -2850,16 +2882,17 @@ init_fi$2(); init_fi$1(); modules$5 = /* #__PURE__ */ Object.assign({ - "../../../cards/action/i18n/fi.ts": fi_exports$28, - "../../../cards/history/history-chart/i18n/fi.ts": fi_exports$27, - "../../../cards/history/i18n/fi.ts": fi_exports$26, - "../../../cards/list/i18n/fi.ts": fi_exports$25, - "../../../cards/quick/i18n/fi.ts": fi_exports$24, - "../../../cards/sensor/i18n/fi.ts": fi_exports$23, - "../../chart/i18n/fi.ts": fi_exports$22, - "../../ha/i18n/fi.ts": fi_exports$21, - "../../history-page/i18n/fi.ts": fi_exports$20, - "../../timeline/i18n/fi.ts": fi_exports$19, + "../../../cards/action/i18n/fi.ts": fi_exports$29, + "../../../cards/history/history-chart/i18n/fi.ts": fi_exports$28, + "../../../cards/history/i18n/fi.ts": fi_exports$27, + "../../../cards/list/i18n/fi.ts": fi_exports$26, + "../../../cards/quick/i18n/fi.ts": fi_exports$25, + "../../../cards/sensor/i18n/fi.ts": fi_exports$24, + "../../chart/i18n/fi.ts": fi_exports$23, + "../../ha/i18n/fi.ts": fi_exports$22, + "../../history-page/i18n/fi.ts": fi_exports$21, + "../../timeline/i18n/fi.ts": fi_exports$20, + "../../../molecules/analysis-aggregate-group/i18n/fi.ts": fi_exports$19, "../../../molecules/analysis-anomaly-group/i18n/fi.ts": fi_exports$18, "../../../molecules/analysis-delta-group/i18n/fi.ts": fi_exports$17, "../../../molecules/analysis-rate-group/i18n/fi.ts": fi_exports$16, @@ -2885,10 +2918,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/action/i18n/fr.ts - var fr_exports$28 = /* @__PURE__ */ __exportAll({ translations: () => translations$139 }); - var translations$139; - var init_fr$28 = __esmMin((() => { - translations$139 = { + var fr_exports$29 = /* @__PURE__ */ __exportAll({ translations: () => translations$144 }); + var translations$144; + var init_fr$29 = __esmMin((() => { + translations$144 = { General: "Général", "Related items": "Éléments liés", "Datapoint Appearance": "Apparence du point de données", @@ -2905,20 +2938,20 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/history/history-chart/i18n/fr.ts - var fr_exports$27 = /* @__PURE__ */ __exportAll({ translations: () => translations$138 }); - var translations$138; - var init_fr$27 = __esmMin((() => { - translations$138 = { + var fr_exports$28 = /* @__PURE__ */ __exportAll({ translations: () => translations$143 }); + var translations$143; + var init_fr$28 = __esmMin((() => { + translations$143 = { "Date window:": "Fenêtre de dates :", "Actual:": "Réel :" }; })); //#endregion //#region custom_components/hass_datapoints/src/cards/history/i18n/fr.ts - var fr_exports$26 = /* @__PURE__ */ __exportAll({ translations: () => translations$137 }); - var translations$137; - var init_fr$26 = __esmMin((() => { - translations$137 = { + var fr_exports$27 = /* @__PURE__ */ __exportAll({ translations: () => translations$142 }); + var translations$142; + var init_fr$27 = __esmMin((() => { + translations$142 = { General: "Général", Entity: "Entité", "Multiple entities": "Plusieurs entités", @@ -2935,10 +2968,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/list/i18n/fr.ts - var fr_exports$25 = /* @__PURE__ */ __exportAll({ translations: () => translations$136 }); - var translations$136; - var init_fr$25 = __esmMin((() => { - translations$136 = { + var fr_exports$26 = /* @__PURE__ */ __exportAll({ translations: () => translations$141 }); + var translations$141; + var init_fr$26 = __esmMin((() => { + translations$141 = { "Search datapoints…": "Rechercher des points de données…", "Delete record": "Supprimer l’enregistrement", Delete: "Supprimer", @@ -2956,10 +2989,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/quick/i18n/fr.ts - var fr_exports$24 = /* @__PURE__ */ __exportAll({ translations: () => translations$135 }); - var translations$135; - var init_fr$24 = __esmMin((() => { - translations$135 = { + var fr_exports$25 = /* @__PURE__ */ __exportAll({ translations: () => translations$140 }); + var translations$140; + var init_fr$25 = __esmMin((() => { + translations$140 = { General: "Général", "Icon & colour": "Icône et couleur", "Related items": "Éléments liés", @@ -2977,10 +3010,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/sensor/i18n/fr.ts - var fr_exports$23 = /* @__PURE__ */ __exportAll({ translations: () => translations$134 }); - var translations$134; - var init_fr$23 = __esmMin((() => { - translations$134 = { + var fr_exports$24 = /* @__PURE__ */ __exportAll({ translations: () => translations$139 }); + var translations$139; + var init_fr$24 = __esmMin((() => { + translations$139 = { Entity: "Entité", Display: "Affichage", "Records list": "Liste des enregistrements", @@ -3000,10 +3033,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/chart/i18n/fr.ts - var fr_exports$22 = /* @__PURE__ */ __exportAll({ translations: () => translations$133 }); - var translations$133; - var init_fr$22 = __esmMin((() => { - translations$133 = { + var fr_exports$23 = /* @__PURE__ */ __exportAll({ translations: () => translations$138 }); + var translations$138; + var init_fr$23 = __esmMin((() => { + translations$138 = { "⚠️ Anomaly Insight": "⚠️ Analyse d’anomalie", "⚠️ Multi-method Anomaly": "⚠️ Anomalie multi-méthodes", "Click the highlighted circle to add an annotation.": "Cliquez sur le cercle en surbrillance pour ajouter une annotation.", @@ -3038,10 +3071,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/ha/i18n/fr.ts - var fr_exports$21 = /* @__PURE__ */ __exportAll({ translations: () => translations$132 }); - var translations$132; - var init_fr$21 = __esmMin((() => { - translations$132 = { + var fr_exports$22 = /* @__PURE__ */ __exportAll({ translations: () => translations$137 }); + var translations$137; + var init_fr$22 = __esmMin((() => { + translations$137 = { "Confirm delete": "Confirmer la suppression", "Are you sure you want to delete this item?": "Voulez-vous vraiment supprimer cet élément ?", Cancel: "Annuler", @@ -3056,10 +3089,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/history-page/i18n/fr.ts - var fr_exports$20 = /* @__PURE__ */ __exportAll({ translations: () => translations$131 }); - var translations$131; - var init_fr$20 = __esmMin((() => { - translations$131 = { + var fr_exports$21 = /* @__PURE__ */ __exportAll({ translations: () => translations$136 }); + var translations$136; + var init_fr$21 = __esmMin((() => { + translations$136 = { "entity_id={0}": "entity_id={0}", "device_id={0}": "device_id={0}", "area_id={0}": "area_id={0}", @@ -3157,20 +3190,51 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/timeline/i18n/fr.ts - var fr_exports$19 = /* @__PURE__ */ __exportAll({ translations: () => translations$130 }); - var translations$130; - var init_fr$19 = __esmMin((() => { - translations$130 = { + var fr_exports$20 = /* @__PURE__ */ __exportAll({ translations: () => translations$135 }); + var translations$135; + var init_fr$20 = __esmMin((() => { + translations$135 = { Wk: "Sem.", "Week of": "Semaine du" }; })); //#endregion + //#region custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts + var fr_exports$19 = /* @__PURE__ */ __exportAll({ translations: () => translations$134 }); + var translations$134; + var init_fr$19 = __esmMin((() => { + translations$134 = { + "Aggregate series": "Séries agrégées", + "Add aggregate": "Ajouter un agrégat", + "Remove aggregate": "Supprimer l'agrégat", + "Aggregate operation": "Opération de l'agrégat", + "Aggregate state": "État de l'agrégat", + "Aggregate interval": "Intervalle de l'agrégat", + "Count changes to a state": "Compter les passages à un état", + "Count all state changes": "Compter tous les changements d'état", + "Time in a state": "Durée dans un état", + "Percentage of time in a state": "Pourcentage du temps dans un état", + Hourly: "Par heure", + Daily: "Par jour", + Weekly: "Par semaine", + Monthly: "Par mois", + On: "Allumé", + Off: "Éteint", + Open: "Ouvert", + Closed: "Fermé", + Detected: "Détecté", + Clear: "Libre", + Occupied: "Occupé", + Unlocked: "Déverrouillé", + Locked: "Verrouillé" + }; + })); + //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-anomaly-group/i18n/fr.ts - var fr_exports$18 = /* @__PURE__ */ __exportAll({ translations: () => translations$129 }); - var translations$129; + var fr_exports$18 = /* @__PURE__ */ __exportAll({ translations: () => translations$133 }); + var translations$133; var init_fr$18 = __esmMin((() => { - translations$129 = { + translations$133 = { Direction: "Sens", Both: "Les deux", Above: "Au-dessus", @@ -3218,10 +3282,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-delta-group/i18n/fr.ts - var fr_exports$17 = /* @__PURE__ */ __exportAll({ translations: () => translations$128 }); - var translations$128; + var fr_exports$17 = /* @__PURE__ */ __exportAll({ translations: () => translations$132 }); + var translations$132; var init_fr$17 = __esmMin((() => { - translations$128 = { + translations$132 = { "Show delta vs selected date window": "Afficher le delta par rapport à la fenêtre de dates sélectionnée", "Select a date window tab to enable delta analysis.": "Sélectionnez un onglet de fenêtre de dates pour activer l’analyse delta.", "Show delta in tooltip": "Afficher le delta dans l’infobulle", @@ -3230,10 +3294,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-rate-group/i18n/fr.ts - var fr_exports$16 = /* @__PURE__ */ __exportAll({ translations: () => translations$127 }); - var translations$127; + var fr_exports$16 = /* @__PURE__ */ __exportAll({ translations: () => translations$131 }); + var translations$131; var init_fr$16 = __esmMin((() => { - translations$127 = { + translations$131 = { "Show rate of change": "Afficher le taux de variation", "Show rate of change crosshairs": "Afficher les repères du taux de variation", "Rate window": "Fenêtre de variation", @@ -3245,10 +3309,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-sample-group/i18n/fr.ts - var fr_exports$15 = /* @__PURE__ */ __exportAll({ translations: () => translations$126 }); - var translations$126; + var fr_exports$15 = /* @__PURE__ */ __exportAll({ translations: () => translations$130 }); + var translations$130; var init_fr$15 = __esmMin((() => { - translations$126 = { + translations$130 = { Downsampling: "Rééchantillonnage", Interval: "Intervalle", Aggregate: "Agrégat", @@ -3280,20 +3344,20 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-summary-group/i18n/fr.ts - var fr_exports$14 = /* @__PURE__ */ __exportAll({ translations: () => translations$125 }); - var translations$125; + var fr_exports$14 = /* @__PURE__ */ __exportAll({ translations: () => translations$129 }); + var translations$129; var init_fr$14 = __esmMin((() => { - translations$125 = { + translations$129 = { "Show min / max / mean": "Afficher min / max / moyenne", "Show range shading": "Afficher l’ombrage de la plage" }; })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-threshold-group/i18n/fr.ts - var fr_exports$13 = /* @__PURE__ */ __exportAll({ translations: () => translations$124 }); - var translations$124; + var fr_exports$13 = /* @__PURE__ */ __exportAll({ translations: () => translations$128 }); + var translations$128; var init_fr$13 = __esmMin((() => { - translations$124 = { + translations$128 = { "Show threshold analysis": "Afficher l’analyse de seuil", "Shade threshold area": "Ombrer la zone du seuil", Threshold: "Seuil", @@ -3304,10 +3368,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-trend-group/i18n/fr.ts - var fr_exports$12 = /* @__PURE__ */ __exportAll({ translations: () => translations$123 }); - var translations$123; + var fr_exports$12 = /* @__PURE__ */ __exportAll({ translations: () => translations$127 }); + var translations$127; var init_fr$12 = __esmMin((() => { - translations$123 = { + translations$127 = { "Show trend lines": "Afficher les lignes de tendance", "Show trend crosshairs": "Afficher les repères de tendance", "Trend method": "Méthode de tendance", @@ -3325,10 +3389,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/anomaly-monitor-wizard/i18n/fr.ts - var fr_exports$11 = /* @__PURE__ */ __exportAll({ translations: () => translations$122 }); - var translations$122; + var fr_exports$11 = /* @__PURE__ */ __exportAll({ translations: () => translations$126 }); + var translations$126; var init_fr$11 = __esmMin((() => { - translations$122 = { + translations$126 = { "Add all series from chart": "Ajouter toutes les séries du graphique", "Add from current chart": "Ajouter depuis le graphique actuel", "Create anomaly monitor": "Créer un moniteur d'anomalies", @@ -3338,17 +3402,17 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/comparison-tab-rail/i18n/fr.ts - var fr_exports$10 = /* @__PURE__ */ __exportAll({ translations: () => translations$121 }); - var translations$121; + var fr_exports$10 = /* @__PURE__ */ __exportAll({ translations: () => translations$125 }); + var translations$125; var init_fr$10 = __esmMin((() => { - translations$121 = { "Add date window": "Ajouter une fenêtre de dates" }; + translations$125 = { "Add date window": "Ajouter une fenêtre de dates" }; })); //#endregion //#region custom_components/hass_datapoints/src/molecules/date-window-dialog/i18n/fr.ts - var fr_exports$9 = /* @__PURE__ */ __exportAll({ translations: () => translations$120 }); - var translations$120; + var fr_exports$9 = /* @__PURE__ */ __exportAll({ translations: () => translations$124 }); + var translations$124; var init_fr$9 = __esmMin((() => { - translations$120 = { + translations$124 = { "A date window saves a named date range as a tab, so you can quickly preview it against the selected range or jump the chart back to it later.": "Une fenêtre de dates enregistre une plage de dates nommée comme onglet afin que vous puissiez la prévisualiser rapidement par rapport à la plage sélectionnée ou y revenir plus tard dans le graphique.", Name: "Nom", "e.g. Heating season start": "ex. Début de la saison de chauffe", @@ -3365,10 +3429,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/sidebar-options/sections/i18n/fr.ts - var fr_exports$8 = /* @__PURE__ */ __exportAll({ translations: () => translations$119 }); - var translations$119; + var fr_exports$8 = /* @__PURE__ */ __exportAll({ translations: () => translations$123 }); + var translations$123; var init_fr$8 = __esmMin((() => { - translations$119 = { + translations$123 = { Datapoints: "Points de données", "Choose which annotation datapoints appear on the chart.": "Choisissez quels points de données d’annotation apparaissent sur le graphique.", "Linked to selected targets": "Liés aux cibles sélectionnées", @@ -3378,10 +3442,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/target-row/i18n/fr.ts - var fr_exports$7 = /* @__PURE__ */ __exportAll({ translations: () => translations$118 }); - var translations$118; + var fr_exports$7 = /* @__PURE__ */ __exportAll({ translations: () => translations$122 }); + var translations$122; var init_fr$7 = __esmMin((() => { - translations$118 = { + translations$122 = { "Analysis configured": "Analyse configurée", "Configure analysis": "Configurer l’analyse", "Stepped series": "Série en escalier", @@ -3393,17 +3457,17 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/target-row-list/i18n/fr.ts - var fr_exports$6 = /* @__PURE__ */ __exportAll({ translations: () => translations$117 }); - var translations$117; + var fr_exports$6 = /* @__PURE__ */ __exportAll({ translations: () => translations$121 }); + var translations$121; var init_fr$6 = __esmMin((() => { - translations$117 = {}; + translations$121 = {}; })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/components/ai-query-brief-dialog/i18n/fr.ts - var fr_exports$5 = /* @__PURE__ */ __exportAll({ translations: () => translations$116 }); - var translations$116; + var fr_exports$5 = /* @__PURE__ */ __exportAll({ translations: () => translations$120 }); + var translations$120; var init_fr$5 = __esmMin((() => { - translations$116 = { + translations$120 = { "AI query brief": "Brief de requete IA", "This brief is intended for another AI to fetch raw Home Assistant history and hass_datapoints anomaly details. Review it, then copy it into your AI tool of choice.": "Ce brief est destine a une autre IA afin de recuperer l'historique brut de Home Assistant et les details d'anomalie de hass_datapoints. Relisez-le, puis copiez-le dans l'outil IA de votre choix.", "Copy-ready brief": "Brief pret a copier", @@ -3415,10 +3479,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/components/history-targets/i18n/fr.ts - var fr_exports$4 = /* @__PURE__ */ __exportAll({ translations: () => translations$115 }); - var translations$115; + var fr_exports$4 = /* @__PURE__ */ __exportAll({ translations: () => translations$119 }); + var translations$119; var init_fr$4 = __esmMin((() => { - translations$115 = { + translations$119 = { Targets: "Cibles", "Each row controls one chart series.": "Chaque ligne contrôle une série du graphique.", "Add target": "Ajouter une cible", @@ -3427,10 +3491,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/components/panel-shell/i18n/fr.ts - var fr_exports$3 = /* @__PURE__ */ __exportAll({ translations: () => translations$114 }); - var translations$114; + var fr_exports$3 = /* @__PURE__ */ __exportAll({ translations: () => translations$118 }); + var translations$118; var init_fr$3 = __esmMin((() => { - translations$114 = { + translations$118 = { "Loading Datapoints…": "Chargement des points de données…", Datapoints: "Points de données", "Page options": "Options de la page", @@ -3445,10 +3509,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/components/range-toolbar/i18n/fr.ts - var fr_exports$2 = /* @__PURE__ */ __exportAll({ translations: () => translations$113 }); - var translations$113; + var fr_exports$2 = /* @__PURE__ */ __exportAll({ translations: () => translations$117 }); + var translations$117; var init_fr$2 = __esmMin((() => { - translations$113 = { + translations$117 = { "Toggle sidebar": "Basculer la barre latérale", Start: "Début", End: "Fin", @@ -3473,16 +3537,17 @@ })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/i18n/fr.ts - var fr_exports$1 = /* @__PURE__ */ __exportAll({ translations: () => translations$112 }); - var translations$112; + var fr_exports$1 = /* @__PURE__ */ __exportAll({ translations: () => translations$116 }); + var translations$116; var init_fr$1 = __esmMin((() => { - translations$112 = { "Selected range": "Période sélectionnée" }; + translations$116 = { "Selected range": "Période sélectionnée" }; })); //#endregion //#region custom_components/hass_datapoints/src/lib/i18n/locales/fr.ts var fr_exports = /* @__PURE__ */ __exportAll({ templates: () => templates$4 }); var modules$4, merged$4, templates$4; var init_fr = __esmMin((() => { + init_fr$29(); init_fr$28(); init_fr$27(); init_fr$26(); @@ -3512,16 +3577,17 @@ init_fr$2(); init_fr$1(); modules$4 = /* #__PURE__ */ Object.assign({ - "../../../cards/action/i18n/fr.ts": fr_exports$28, - "../../../cards/history/history-chart/i18n/fr.ts": fr_exports$27, - "../../../cards/history/i18n/fr.ts": fr_exports$26, - "../../../cards/list/i18n/fr.ts": fr_exports$25, - "../../../cards/quick/i18n/fr.ts": fr_exports$24, - "../../../cards/sensor/i18n/fr.ts": fr_exports$23, - "../../chart/i18n/fr.ts": fr_exports$22, - "../../ha/i18n/fr.ts": fr_exports$21, - "../../history-page/i18n/fr.ts": fr_exports$20, - "../../timeline/i18n/fr.ts": fr_exports$19, + "../../../cards/action/i18n/fr.ts": fr_exports$29, + "../../../cards/history/history-chart/i18n/fr.ts": fr_exports$28, + "../../../cards/history/i18n/fr.ts": fr_exports$27, + "../../../cards/list/i18n/fr.ts": fr_exports$26, + "../../../cards/quick/i18n/fr.ts": fr_exports$25, + "../../../cards/sensor/i18n/fr.ts": fr_exports$24, + "../../chart/i18n/fr.ts": fr_exports$23, + "../../ha/i18n/fr.ts": fr_exports$22, + "../../history-page/i18n/fr.ts": fr_exports$21, + "../../timeline/i18n/fr.ts": fr_exports$20, + "../../../molecules/analysis-aggregate-group/i18n/fr.ts": fr_exports$19, "../../../molecules/analysis-anomaly-group/i18n/fr.ts": fr_exports$18, "../../../molecules/analysis-delta-group/i18n/fr.ts": fr_exports$17, "../../../molecules/analysis-rate-group/i18n/fr.ts": fr_exports$16, @@ -3547,10 +3613,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/action/i18n/de.ts - var de_exports$28 = /* @__PURE__ */ __exportAll({ translations: () => translations$111 }); - var translations$111; - var init_de$28 = __esmMin((() => { - translations$111 = { + var de_exports$29 = /* @__PURE__ */ __exportAll({ translations: () => translations$115 }); + var translations$115; + var init_de$29 = __esmMin((() => { + translations$115 = { General: "Allgemein", "Related items": "Verknüpfte Elemente", "Datapoint Appearance": "Darstellung des Datenpunkts", @@ -3567,20 +3633,20 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/history/history-chart/i18n/de.ts - var de_exports$27 = /* @__PURE__ */ __exportAll({ translations: () => translations$110 }); - var translations$110; - var init_de$27 = __esmMin((() => { - translations$110 = { + var de_exports$28 = /* @__PURE__ */ __exportAll({ translations: () => translations$114 }); + var translations$114; + var init_de$28 = __esmMin((() => { + translations$114 = { "Date window:": "Datumsfenster:", "Actual:": "Tatsächlich:" }; })); //#endregion //#region custom_components/hass_datapoints/src/cards/history/i18n/de.ts - var de_exports$26 = /* @__PURE__ */ __exportAll({ translations: () => translations$109 }); - var translations$109; - var init_de$26 = __esmMin((() => { - translations$109 = { + var de_exports$27 = /* @__PURE__ */ __exportAll({ translations: () => translations$113 }); + var translations$113; + var init_de$27 = __esmMin((() => { + translations$113 = { General: "Allgemein", Entity: "Entität", "Multiple entities": "Mehrere Entitäten", @@ -3597,10 +3663,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/list/i18n/de.ts - var de_exports$25 = /* @__PURE__ */ __exportAll({ translations: () => translations$108 }); - var translations$108; - var init_de$25 = __esmMin((() => { - translations$108 = { + var de_exports$26 = /* @__PURE__ */ __exportAll({ translations: () => translations$112 }); + var translations$112; + var init_de$26 = __esmMin((() => { + translations$112 = { "Search datapoints…": "Datenpunkte suchen…", "Delete record": "Eintrag löschen", Delete: "Löschen", @@ -3618,10 +3684,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/quick/i18n/de.ts - var de_exports$24 = /* @__PURE__ */ __exportAll({ translations: () => translations$107 }); - var translations$107; - var init_de$24 = __esmMin((() => { - translations$107 = { + var de_exports$25 = /* @__PURE__ */ __exportAll({ translations: () => translations$111 }); + var translations$111; + var init_de$25 = __esmMin((() => { + translations$111 = { General: "Allgemein", "Icon & colour": "Symbol und Farbe", "Related items": "Verknüpfte Elemente", @@ -3639,10 +3705,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/sensor/i18n/de.ts - var de_exports$23 = /* @__PURE__ */ __exportAll({ translations: () => translations$106 }); - var translations$106; - var init_de$23 = __esmMin((() => { - translations$106 = { + var de_exports$24 = /* @__PURE__ */ __exportAll({ translations: () => translations$110 }); + var translations$110; + var init_de$24 = __esmMin((() => { + translations$110 = { Entity: "Entität", Display: "Anzeige", "Records list": "Eintragsliste", @@ -3662,10 +3728,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/chart/i18n/de.ts - var de_exports$22 = /* @__PURE__ */ __exportAll({ translations: () => translations$105 }); - var translations$105; - var init_de$22 = __esmMin((() => { - translations$105 = { + var de_exports$23 = /* @__PURE__ */ __exportAll({ translations: () => translations$109 }); + var translations$109; + var init_de$23 = __esmMin((() => { + translations$109 = { "⚠️ Anomaly Insight": "⚠️ Anomalie-Einblick", "⚠️ Multi-method Anomaly": "⚠️ Anomalie mit mehreren Methoden", "Click the highlighted circle to add an annotation.": "Klicken Sie auf den hervorgehobenen Kreis, um eine Anmerkung hinzuzufügen.", @@ -3700,10 +3766,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/ha/i18n/de.ts - var de_exports$21 = /* @__PURE__ */ __exportAll({ translations: () => translations$104 }); - var translations$104; - var init_de$21 = __esmMin((() => { - translations$104 = { + var de_exports$22 = /* @__PURE__ */ __exportAll({ translations: () => translations$108 }); + var translations$108; + var init_de$22 = __esmMin((() => { + translations$108 = { "Confirm delete": "Löschen bestätigen", "Are you sure you want to delete this item?": "Möchten Sie dieses Element wirklich löschen?", Cancel: "Abbrechen", @@ -3718,10 +3784,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/history-page/i18n/de.ts - var de_exports$20 = /* @__PURE__ */ __exportAll({ translations: () => translations$103 }); - var translations$103; - var init_de$20 = __esmMin((() => { - translations$103 = { + var de_exports$21 = /* @__PURE__ */ __exportAll({ translations: () => translations$107 }); + var translations$107; + var init_de$21 = __esmMin((() => { + translations$107 = { "entity_id={0}": "entity_id={0}", "device_id={0}": "device_id={0}", "area_id={0}": "area_id={0}", @@ -3819,20 +3885,51 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/timeline/i18n/de.ts - var de_exports$19 = /* @__PURE__ */ __exportAll({ translations: () => translations$102 }); - var translations$102; - var init_de$19 = __esmMin((() => { - translations$102 = { + var de_exports$20 = /* @__PURE__ */ __exportAll({ translations: () => translations$106 }); + var translations$106; + var init_de$20 = __esmMin((() => { + translations$106 = { Wk: "KW", "Week of": "Woche von" }; })); //#endregion + //#region custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts + var de_exports$19 = /* @__PURE__ */ __exportAll({ translations: () => translations$105 }); + var translations$105; + var init_de$19 = __esmMin((() => { + translations$105 = { + "Aggregate series": "Aggregierte Serien", + "Add aggregate": "Aggregat hinzufügen", + "Remove aggregate": "Aggregat entfernen", + "Aggregate operation": "Aggregatoperation", + "Aggregate state": "Aggregatzustand", + "Aggregate interval": "Aggregatintervall", + "Count changes to a state": "Wechsel in einen Zustand zählen", + "Count all state changes": "Alle Zustandswechsel zählen", + "Time in a state": "Zeit in einem Zustand", + "Percentage of time in a state": "Zeitanteil in einem Zustand in Prozent", + Hourly: "Stündlich", + Daily: "Täglich", + Weekly: "Wöchentlich", + Monthly: "Monatlich", + On: "An", + Off: "Aus", + Open: "Offen", + Closed: "Geschlossen", + Detected: "Erkannt", + Clear: "Frei", + Occupied: "Belegt", + Unlocked: "Entsperrt", + Locked: "Gesperrt" + }; + })); + //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-anomaly-group/i18n/de.ts - var de_exports$18 = /* @__PURE__ */ __exportAll({ translations: () => translations$101 }); - var translations$101; + var de_exports$18 = /* @__PURE__ */ __exportAll({ translations: () => translations$104 }); + var translations$104; var init_de$18 = __esmMin((() => { - translations$101 = { + translations$104 = { Direction: "Richtung", Both: "Beide", Above: "Oberhalb", @@ -3880,10 +3977,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-delta-group/i18n/de.ts - var de_exports$17 = /* @__PURE__ */ __exportAll({ translations: () => translations$100 }); - var translations$100; + var de_exports$17 = /* @__PURE__ */ __exportAll({ translations: () => translations$103 }); + var translations$103; var init_de$17 = __esmMin((() => { - translations$100 = { + translations$103 = { "Show delta vs selected date window": "Delta gegenüber dem ausgewählten Datumsfenster anzeigen", "Select a date window tab to enable delta analysis.": "Wählen Sie einen Tab für ein Datumsfenster, um die Delta-Analyse zu aktivieren.", "Show delta in tooltip": "Delta im Tooltip anzeigen", @@ -3892,10 +3989,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-rate-group/i18n/de.ts - var de_exports$16 = /* @__PURE__ */ __exportAll({ translations: () => translations$99 }); - var translations$99; + var de_exports$16 = /* @__PURE__ */ __exportAll({ translations: () => translations$102 }); + var translations$102; var init_de$16 = __esmMin((() => { - translations$99 = { + translations$102 = { "Show rate of change": "Änderungsrate anzeigen", "Show rate of change crosshairs": "Fadenkreuz für Änderungsrate anzeigen", "Rate window": "Ratenfenster", @@ -3907,10 +4004,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-sample-group/i18n/de.ts - var de_exports$15 = /* @__PURE__ */ __exportAll({ translations: () => translations$98 }); - var translations$98; + var de_exports$15 = /* @__PURE__ */ __exportAll({ translations: () => translations$101 }); + var translations$101; var init_de$15 = __esmMin((() => { - translations$98 = { + translations$101 = { Downsampling: "Downsampling", Interval: "Intervall", Aggregate: "Aggregation", @@ -3942,20 +4039,20 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-summary-group/i18n/de.ts - var de_exports$14 = /* @__PURE__ */ __exportAll({ translations: () => translations$97 }); - var translations$97; + var de_exports$14 = /* @__PURE__ */ __exportAll({ translations: () => translations$100 }); + var translations$100; var init_de$14 = __esmMin((() => { - translations$97 = { + translations$100 = { "Show min / max / mean": "Min / Max / Mittelwert anzeigen", "Show range shading": "Bereichsschattierung anzeigen" }; })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-threshold-group/i18n/de.ts - var de_exports$13 = /* @__PURE__ */ __exportAll({ translations: () => translations$96 }); - var translations$96; + var de_exports$13 = /* @__PURE__ */ __exportAll({ translations: () => translations$99 }); + var translations$99; var init_de$13 = __esmMin((() => { - translations$96 = { + translations$99 = { "Show threshold analysis": "Schwellwertanalyse anzeigen", "Shade threshold area": "Schwellwertbereich schattieren", Threshold: "Schwellenwert", @@ -3966,10 +4063,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-trend-group/i18n/de.ts - var de_exports$12 = /* @__PURE__ */ __exportAll({ translations: () => translations$95 }); - var translations$95; + var de_exports$12 = /* @__PURE__ */ __exportAll({ translations: () => translations$98 }); + var translations$98; var init_de$12 = __esmMin((() => { - translations$95 = { + translations$98 = { "Show trend lines": "Trendlinien anzeigen", "Show trend crosshairs": "Fadenkreuz für Trend anzeigen", "Trend method": "Trendmethode", @@ -3987,10 +4084,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/anomaly-monitor-wizard/i18n/de.ts - var de_exports$11 = /* @__PURE__ */ __exportAll({ translations: () => translations$94 }); - var translations$94; + var de_exports$11 = /* @__PURE__ */ __exportAll({ translations: () => translations$97 }); + var translations$97; var init_de$11 = __esmMin((() => { - translations$94 = { + translations$97 = { "Add all series from chart": "Alle Reihen aus dem Diagramm hinzufügen", "Add from current chart": "Aus aktuellem Diagramm hinzufügen", "Create anomaly monitor": "Anomaliemonitor erstellen", @@ -4000,17 +4097,17 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/comparison-tab-rail/i18n/de.ts - var de_exports$10 = /* @__PURE__ */ __exportAll({ translations: () => translations$93 }); - var translations$93; + var de_exports$10 = /* @__PURE__ */ __exportAll({ translations: () => translations$96 }); + var translations$96; var init_de$10 = __esmMin((() => { - translations$93 = { "Add date window": "Datumsfenster hinzufügen" }; + translations$96 = { "Add date window": "Datumsfenster hinzufügen" }; })); //#endregion //#region custom_components/hass_datapoints/src/molecules/date-window-dialog/i18n/de.ts - var de_exports$9 = /* @__PURE__ */ __exportAll({ translations: () => translations$92 }); - var translations$92; + var de_exports$9 = /* @__PURE__ */ __exportAll({ translations: () => translations$95 }); + var translations$95; var init_de$9 = __esmMin((() => { - translations$92 = { + translations$95 = { "A date window saves a named date range as a tab, so you can quickly preview it against the selected range or jump the chart back to it later.": "Ein Datumsfenster speichert einen benannten Datumsbereich als Registerkarte, damit Sie ihn schnell mit dem ausgewählten Bereich vergleichen oder später im Diagramm wieder dorthin springen können.", Name: "Name", "e.g. Heating season start": "z. B. Beginn der Heizsaison", @@ -4027,10 +4124,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/sidebar-options/sections/i18n/de.ts - var de_exports$8 = /* @__PURE__ */ __exportAll({ translations: () => translations$91 }); - var translations$91; + var de_exports$8 = /* @__PURE__ */ __exportAll({ translations: () => translations$94 }); + var translations$94; var init_de$8 = __esmMin((() => { - translations$91 = { + translations$94 = { Datapoints: "Datenpunkte", "Choose which annotation datapoints appear on the chart.": "Wählen Sie aus, welche Anmerkungs-Datenpunkte im Diagramm angezeigt werden.", "Linked to selected targets": "Mit ausgewählten Zielen verknüpft", @@ -4040,10 +4137,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/target-row/i18n/de.ts - var de_exports$7 = /* @__PURE__ */ __exportAll({ translations: () => translations$90 }); - var translations$90; + var de_exports$7 = /* @__PURE__ */ __exportAll({ translations: () => translations$93 }); + var translations$93; var init_de$7 = __esmMin((() => { - translations$90 = { + translations$93 = { "Analysis configured": "Analyse konfiguriert", "Configure analysis": "Analyse konfigurieren", "Stepped series": "Stufenserie", @@ -4055,17 +4152,17 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/target-row-list/i18n/de.ts - var de_exports$6 = /* @__PURE__ */ __exportAll({ translations: () => translations$89 }); - var translations$89; + var de_exports$6 = /* @__PURE__ */ __exportAll({ translations: () => translations$92 }); + var translations$92; var init_de$6 = __esmMin((() => { - translations$89 = {}; + translations$92 = {}; })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/components/ai-query-brief-dialog/i18n/de.ts - var de_exports$5 = /* @__PURE__ */ __exportAll({ translations: () => translations$88 }); - var translations$88; + var de_exports$5 = /* @__PURE__ */ __exportAll({ translations: () => translations$91 }); + var translations$91; var init_de$5 = __esmMin((() => { - translations$88 = { + translations$91 = { "AI query brief": "KI-Abfragebriefing", "This brief is intended for another AI to fetch raw Home Assistant history and hass_datapoints anomaly details. Review it, then copy it into your AI tool of choice.": "Dieses Briefing ist fuer eine andere KI gedacht, damit sie rohe Home-Assistant-Verlaeufe und hass_datapoints-Anomaliedetails abrufen kann. Pruefe es und kopiere es dann in dein KI-Werkzeug deiner Wahl.", "Copy-ready brief": "Kopierfertiges Briefing", @@ -4077,10 +4174,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/components/history-targets/i18n/de.ts - var de_exports$4 = /* @__PURE__ */ __exportAll({ translations: () => translations$87 }); - var translations$87; + var de_exports$4 = /* @__PURE__ */ __exportAll({ translations: () => translations$90 }); + var translations$90; var init_de$4 = __esmMin((() => { - translations$87 = { + translations$90 = { Targets: "Ziele", "Each row controls one chart series.": "Jede Zeile steuert eine Diagrammserie.", "Add target": "Ziel hinzufügen", @@ -4089,10 +4186,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/components/panel-shell/i18n/de.ts - var de_exports$3 = /* @__PURE__ */ __exportAll({ translations: () => translations$86 }); - var translations$86; + var de_exports$3 = /* @__PURE__ */ __exportAll({ translations: () => translations$89 }); + var translations$89; var init_de$3 = __esmMin((() => { - translations$86 = { + translations$89 = { "Loading Datapoints…": "Datenpunkte werden geladen…", Datapoints: "Datenpunkte", "Page options": "Seitenoptionen", @@ -4107,10 +4204,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/components/range-toolbar/i18n/de.ts - var de_exports$2 = /* @__PURE__ */ __exportAll({ translations: () => translations$85 }); - var translations$85; + var de_exports$2 = /* @__PURE__ */ __exportAll({ translations: () => translations$88 }); + var translations$88; var init_de$2 = __esmMin((() => { - translations$85 = { + translations$88 = { "Toggle sidebar": "Seitenleiste umschalten", Start: "Start", End: "Ende", @@ -4135,16 +4232,17 @@ })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/i18n/de.ts - var de_exports$1 = /* @__PURE__ */ __exportAll({ translations: () => translations$84 }); - var translations$84; + var de_exports$1 = /* @__PURE__ */ __exportAll({ translations: () => translations$87 }); + var translations$87; var init_de$1 = __esmMin((() => { - translations$84 = { "Selected range": "Ausgewählter Zeitraum" }; + translations$87 = { "Selected range": "Ausgewählter Zeitraum" }; })); //#endregion //#region custom_components/hass_datapoints/src/lib/i18n/locales/de.ts var de_exports = /* @__PURE__ */ __exportAll({ templates: () => templates$3 }); var modules$3, merged$3, templates$3; var init_de = __esmMin((() => { + init_de$29(); init_de$28(); init_de$27(); init_de$26(); @@ -4174,16 +4272,17 @@ init_de$2(); init_de$1(); modules$3 = /* #__PURE__ */ Object.assign({ - "../../../cards/action/i18n/de.ts": de_exports$28, - "../../../cards/history/history-chart/i18n/de.ts": de_exports$27, - "../../../cards/history/i18n/de.ts": de_exports$26, - "../../../cards/list/i18n/de.ts": de_exports$25, - "../../../cards/quick/i18n/de.ts": de_exports$24, - "../../../cards/sensor/i18n/de.ts": de_exports$23, - "../../chart/i18n/de.ts": de_exports$22, - "../../ha/i18n/de.ts": de_exports$21, - "../../history-page/i18n/de.ts": de_exports$20, - "../../timeline/i18n/de.ts": de_exports$19, + "../../../cards/action/i18n/de.ts": de_exports$29, + "../../../cards/history/history-chart/i18n/de.ts": de_exports$28, + "../../../cards/history/i18n/de.ts": de_exports$27, + "../../../cards/list/i18n/de.ts": de_exports$26, + "../../../cards/quick/i18n/de.ts": de_exports$25, + "../../../cards/sensor/i18n/de.ts": de_exports$24, + "../../chart/i18n/de.ts": de_exports$23, + "../../ha/i18n/de.ts": de_exports$22, + "../../history-page/i18n/de.ts": de_exports$21, + "../../timeline/i18n/de.ts": de_exports$20, + "../../../molecules/analysis-aggregate-group/i18n/de.ts": de_exports$19, "../../../molecules/analysis-anomaly-group/i18n/de.ts": de_exports$18, "../../../molecules/analysis-delta-group/i18n/de.ts": de_exports$17, "../../../molecules/analysis-rate-group/i18n/de.ts": de_exports$16, @@ -4209,10 +4308,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/action/i18n/es.ts - var es_exports$28 = /* @__PURE__ */ __exportAll({ translations: () => translations$83 }); - var translations$83; - var init_es$28 = __esmMin((() => { - translations$83 = { + var es_exports$29 = /* @__PURE__ */ __exportAll({ translations: () => translations$86 }); + var translations$86; + var init_es$29 = __esmMin((() => { + translations$86 = { General: "General", "Related items": "Elementos relacionados", "Datapoint Appearance": "Apariencia del punto de datos", @@ -4229,20 +4328,20 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/history/history-chart/i18n/es.ts - var es_exports$27 = /* @__PURE__ */ __exportAll({ translations: () => translations$82 }); - var translations$82; - var init_es$27 = __esmMin((() => { - translations$82 = { + var es_exports$28 = /* @__PURE__ */ __exportAll({ translations: () => translations$85 }); + var translations$85; + var init_es$28 = __esmMin((() => { + translations$85 = { "Date window:": "Ventana de fechas:", "Actual:": "Real:" }; })); //#endregion //#region custom_components/hass_datapoints/src/cards/history/i18n/es.ts - var es_exports$26 = /* @__PURE__ */ __exportAll({ translations: () => translations$81 }); - var translations$81; - var init_es$26 = __esmMin((() => { - translations$81 = { + var es_exports$27 = /* @__PURE__ */ __exportAll({ translations: () => translations$84 }); + var translations$84; + var init_es$27 = __esmMin((() => { + translations$84 = { General: "General", Entity: "Entidad", "Multiple entities": "Varias entidades", @@ -4259,10 +4358,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/list/i18n/es.ts - var es_exports$25 = /* @__PURE__ */ __exportAll({ translations: () => translations$80 }); - var translations$80; - var init_es$25 = __esmMin((() => { - translations$80 = { + var es_exports$26 = /* @__PURE__ */ __exportAll({ translations: () => translations$83 }); + var translations$83; + var init_es$26 = __esmMin((() => { + translations$83 = { "Search datapoints…": "Buscar puntos de datos…", "Delete record": "Eliminar registro", Delete: "Eliminar", @@ -4280,10 +4379,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/quick/i18n/es.ts - var es_exports$24 = /* @__PURE__ */ __exportAll({ translations: () => translations$79 }); - var translations$79; - var init_es$24 = __esmMin((() => { - translations$79 = { + var es_exports$25 = /* @__PURE__ */ __exportAll({ translations: () => translations$82 }); + var translations$82; + var init_es$25 = __esmMin((() => { + translations$82 = { General: "General", "Icon & colour": "Icono y color", "Related items": "Elementos relacionados", @@ -4301,10 +4400,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/sensor/i18n/es.ts - var es_exports$23 = /* @__PURE__ */ __exportAll({ translations: () => translations$78 }); - var translations$78; - var init_es$23 = __esmMin((() => { - translations$78 = { + var es_exports$24 = /* @__PURE__ */ __exportAll({ translations: () => translations$81 }); + var translations$81; + var init_es$24 = __esmMin((() => { + translations$81 = { Entity: "Entidad", Display: "Visualización", "Records list": "Lista de registros", @@ -4324,10 +4423,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/chart/i18n/es.ts - var es_exports$22 = /* @__PURE__ */ __exportAll({ translations: () => translations$77 }); - var translations$77; - var init_es$22 = __esmMin((() => { - translations$77 = { + var es_exports$23 = /* @__PURE__ */ __exportAll({ translations: () => translations$80 }); + var translations$80; + var init_es$23 = __esmMin((() => { + translations$80 = { "⚠️ Anomaly Insight": "⚠️ Información de anomalía", "⚠️ Multi-method Anomaly": "⚠️ Anomalía multimétodo", "Click the highlighted circle to add an annotation.": "Haz clic en el círculo resaltado para añadir una anotación.", @@ -4362,10 +4461,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/ha/i18n/es.ts - var es_exports$21 = /* @__PURE__ */ __exportAll({ translations: () => translations$76 }); - var translations$76; - var init_es$21 = __esmMin((() => { - translations$76 = { + var es_exports$22 = /* @__PURE__ */ __exportAll({ translations: () => translations$79 }); + var translations$79; + var init_es$22 = __esmMin((() => { + translations$79 = { "Confirm delete": "Confirmar eliminación", "Are you sure you want to delete this item?": "¿Seguro que quieres eliminar este elemento?", Cancel: "Cancelar", @@ -4380,10 +4479,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/history-page/i18n/es.ts - var es_exports$20 = /* @__PURE__ */ __exportAll({ translations: () => translations$75 }); - var translations$75; - var init_es$20 = __esmMin((() => { - translations$75 = { + var es_exports$21 = /* @__PURE__ */ __exportAll({ translations: () => translations$78 }); + var translations$78; + var init_es$21 = __esmMin((() => { + translations$78 = { "entity_id={0}": "entity_id={0}", "device_id={0}": "device_id={0}", "area_id={0}": "area_id={0}", @@ -4481,20 +4580,51 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/timeline/i18n/es.ts - var es_exports$19 = /* @__PURE__ */ __exportAll({ translations: () => translations$74 }); - var translations$74; - var init_es$19 = __esmMin((() => { - translations$74 = { + var es_exports$20 = /* @__PURE__ */ __exportAll({ translations: () => translations$77 }); + var translations$77; + var init_es$20 = __esmMin((() => { + translations$77 = { Wk: "Sem.", "Week of": "Semana del" }; })); //#endregion + //#region custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts + var es_exports$19 = /* @__PURE__ */ __exportAll({ translations: () => translations$76 }); + var translations$76; + var init_es$19 = __esmMin((() => { + translations$76 = { + "Aggregate series": "Series agregadas", + "Add aggregate": "Añadir agregado", + "Remove aggregate": "Quitar agregado", + "Aggregate operation": "Operación del agregado", + "Aggregate state": "Estado del agregado", + "Aggregate interval": "Intervalo del agregado", + "Count changes to a state": "Contar cambios a un estado", + "Count all state changes": "Contar todos los cambios de estado", + "Time in a state": "Tiempo en un estado", + "Percentage of time in a state": "Porcentaje de tiempo en un estado", + Hourly: "Por hora", + Daily: "Diario", + Weekly: "Semanal", + Monthly: "Mensual", + On: "Encendido", + Off: "Apagado", + Open: "Abierto", + Closed: "Cerrado", + Detected: "Detectado", + Clear: "Libre", + Occupied: "Ocupado", + Unlocked: "Desbloqueado", + Locked: "Bloqueado" + }; + })); + //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-anomaly-group/i18n/es.ts - var es_exports$18 = /* @__PURE__ */ __exportAll({ translations: () => translations$73 }); - var translations$73; + var es_exports$18 = /* @__PURE__ */ __exportAll({ translations: () => translations$75 }); + var translations$75; var init_es$18 = __esmMin((() => { - translations$73 = { + translations$75 = { Direction: "Dirección", Both: "Ambos", Above: "Por encima", @@ -4542,10 +4672,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-delta-group/i18n/es.ts - var es_exports$17 = /* @__PURE__ */ __exportAll({ translations: () => translations$72 }); - var translations$72; + var es_exports$17 = /* @__PURE__ */ __exportAll({ translations: () => translations$74 }); + var translations$74; var init_es$17 = __esmMin((() => { - translations$72 = { + translations$74 = { "Show delta vs selected date window": "Mostrar delta frente a la ventana de fechas seleccionada", "Select a date window tab to enable delta analysis.": "Selecciona una pestaña de ventana de fechas para habilitar el análisis delta.", "Show delta in tooltip": "Mostrar delta en la información sobre herramientas", @@ -4554,10 +4684,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-rate-group/i18n/es.ts - var es_exports$16 = /* @__PURE__ */ __exportAll({ translations: () => translations$71 }); - var translations$71; + var es_exports$16 = /* @__PURE__ */ __exportAll({ translations: () => translations$73 }); + var translations$73; var init_es$16 = __esmMin((() => { - translations$71 = { + translations$73 = { "Show rate of change": "Mostrar tasa de cambio", "Show rate of change crosshairs": "Mostrar guías de la tasa de cambio", "Rate window": "Ventana de tasa", @@ -4569,10 +4699,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-sample-group/i18n/es.ts - var es_exports$15 = /* @__PURE__ */ __exportAll({ translations: () => translations$70 }); - var translations$70; + var es_exports$15 = /* @__PURE__ */ __exportAll({ translations: () => translations$72 }); + var translations$72; var init_es$15 = __esmMin((() => { - translations$70 = { + translations$72 = { Downsampling: "Submuestreo", Interval: "Intervalo", Aggregate: "Agregado", @@ -4604,20 +4734,20 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-summary-group/i18n/es.ts - var es_exports$14 = /* @__PURE__ */ __exportAll({ translations: () => translations$69 }); - var translations$69; + var es_exports$14 = /* @__PURE__ */ __exportAll({ translations: () => translations$71 }); + var translations$71; var init_es$14 = __esmMin((() => { - translations$69 = { + translations$71 = { "Show min / max / mean": "Mostrar mín. / máx. / media", "Show range shading": "Mostrar sombreado del rango" }; })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-threshold-group/i18n/es.ts - var es_exports$13 = /* @__PURE__ */ __exportAll({ translations: () => translations$68 }); - var translations$68; + var es_exports$13 = /* @__PURE__ */ __exportAll({ translations: () => translations$70 }); + var translations$70; var init_es$13 = __esmMin((() => { - translations$68 = { + translations$70 = { "Show threshold analysis": "Mostrar análisis de umbral", "Shade threshold area": "Sombrear área del umbral", Threshold: "Umbral", @@ -4628,10 +4758,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-trend-group/i18n/es.ts - var es_exports$12 = /* @__PURE__ */ __exportAll({ translations: () => translations$67 }); - var translations$67; + var es_exports$12 = /* @__PURE__ */ __exportAll({ translations: () => translations$69 }); + var translations$69; var init_es$12 = __esmMin((() => { - translations$67 = { + translations$69 = { "Show trend lines": "Mostrar líneas de tendencia", "Show trend crosshairs": "Mostrar guías de tendencia", "Trend method": "Método de tendencia", @@ -4649,10 +4779,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/anomaly-monitor-wizard/i18n/es.ts - var es_exports$11 = /* @__PURE__ */ __exportAll({ translations: () => translations$66 }); - var translations$66; + var es_exports$11 = /* @__PURE__ */ __exportAll({ translations: () => translations$68 }); + var translations$68; var init_es$11 = __esmMin((() => { - translations$66 = { + translations$68 = { "Add all series from chart": "Añadir todas las series del gráfico", "Add from current chart": "Añadir desde el gráfico actual", "Create anomaly monitor": "Crear monitor de anomalías", @@ -4662,17 +4792,17 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/comparison-tab-rail/i18n/es.ts - var es_exports$10 = /* @__PURE__ */ __exportAll({ translations: () => translations$65 }); - var translations$65; + var es_exports$10 = /* @__PURE__ */ __exportAll({ translations: () => translations$67 }); + var translations$67; var init_es$10 = __esmMin((() => { - translations$65 = { "Add date window": "Añadir ventana de fechas" }; + translations$67 = { "Add date window": "Añadir ventana de fechas" }; })); //#endregion //#region custom_components/hass_datapoints/src/molecules/date-window-dialog/i18n/es.ts - var es_exports$9 = /* @__PURE__ */ __exportAll({ translations: () => translations$64 }); - var translations$64; + var es_exports$9 = /* @__PURE__ */ __exportAll({ translations: () => translations$66 }); + var translations$66; var init_es$9 = __esmMin((() => { - translations$64 = { + translations$66 = { "A date window saves a named date range as a tab, so you can quickly preview it against the selected range or jump the chart back to it later.": "Una ventana de fechas guarda un rango de fechas con nombre como pestaña, para que puedas previsualizarlo rápidamente frente al rango seleccionado o volver más tarde a él en el gráfico.", Name: "Nombre", "e.g. Heating season start": "p. ej., inicio de la temporada de calefacción", @@ -4689,10 +4819,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/sidebar-options/sections/i18n/es.ts - var es_exports$8 = /* @__PURE__ */ __exportAll({ translations: () => translations$63 }); - var translations$63; + var es_exports$8 = /* @__PURE__ */ __exportAll({ translations: () => translations$65 }); + var translations$65; var init_es$8 = __esmMin((() => { - translations$63 = { + translations$65 = { Datapoints: "Puntos de datos", "Choose which annotation datapoints appear on the chart.": "Elige qué puntos de datos de anotación aparecen en el gráfico.", "Linked to selected targets": "Vinculados a los objetivos seleccionados", @@ -4702,10 +4832,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/target-row/i18n/es.ts - var es_exports$7 = /* @__PURE__ */ __exportAll({ translations: () => translations$62 }); - var translations$62; + var es_exports$7 = /* @__PURE__ */ __exportAll({ translations: () => translations$64 }); + var translations$64; var init_es$7 = __esmMin((() => { - translations$62 = { + translations$64 = { "Analysis configured": "Análisis configurado", "Configure analysis": "Configurar análisis", "Stepped series": "Serie escalonada", @@ -4717,17 +4847,17 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/target-row-list/i18n/es.ts - var es_exports$6 = /* @__PURE__ */ __exportAll({ translations: () => translations$61 }); - var translations$61; + var es_exports$6 = /* @__PURE__ */ __exportAll({ translations: () => translations$63 }); + var translations$63; var init_es$6 = __esmMin((() => { - translations$61 = {}; + translations$63 = {}; })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/components/ai-query-brief-dialog/i18n/es.ts - var es_exports$5 = /* @__PURE__ */ __exportAll({ translations: () => translations$60 }); - var translations$60; + var es_exports$5 = /* @__PURE__ */ __exportAll({ translations: () => translations$62 }); + var translations$62; var init_es$5 = __esmMin((() => { - translations$60 = { + translations$62 = { "AI query brief": "Resumen de consulta para IA", "This brief is intended for another AI to fetch raw Home Assistant history and hass_datapoints anomaly details. Review it, then copy it into your AI tool of choice.": "Este resumen esta pensado para que otra IA obtenga el historial sin procesar de Home Assistant y los detalles de anomalias de hass_datapoints. Revisalo y luego copialo en la herramienta de IA que prefieras.", "Copy-ready brief": "Resumen listo para copiar", @@ -4739,10 +4869,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/components/history-targets/i18n/es.ts - var es_exports$4 = /* @__PURE__ */ __exportAll({ translations: () => translations$59 }); - var translations$59; + var es_exports$4 = /* @__PURE__ */ __exportAll({ translations: () => translations$61 }); + var translations$61; var init_es$4 = __esmMin((() => { - translations$59 = { + translations$61 = { Targets: "Objetivos", "Each row controls one chart series.": "Cada fila controla una serie del gráfico.", "Add target": "Añadir objetivo", @@ -4751,10 +4881,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/components/panel-shell/i18n/es.ts - var es_exports$3 = /* @__PURE__ */ __exportAll({ translations: () => translations$58 }); - var translations$58; + var es_exports$3 = /* @__PURE__ */ __exportAll({ translations: () => translations$60 }); + var translations$60; var init_es$3 = __esmMin((() => { - translations$58 = { + translations$60 = { "Loading Datapoints…": "Cargando puntos de datos…", Datapoints: "Puntos de datos", "Page options": "Opciones de la página", @@ -4769,10 +4899,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/components/range-toolbar/i18n/es.ts - var es_exports$2 = /* @__PURE__ */ __exportAll({ translations: () => translations$57 }); - var translations$57; + var es_exports$2 = /* @__PURE__ */ __exportAll({ translations: () => translations$59 }); + var translations$59; var init_es$2 = __esmMin((() => { - translations$57 = { + translations$59 = { "Toggle sidebar": "Alternar barra lateral", Start: "Inicio", End: "Fin", @@ -4797,16 +4927,17 @@ })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/i18n/es.ts - var es_exports$1 = /* @__PURE__ */ __exportAll({ translations: () => translations$56 }); - var translations$56; + var es_exports$1 = /* @__PURE__ */ __exportAll({ translations: () => translations$58 }); + var translations$58; var init_es$1 = __esmMin((() => { - translations$56 = { "Selected range": "Intervalo seleccionado" }; + translations$58 = { "Selected range": "Intervalo seleccionado" }; })); //#endregion //#region custom_components/hass_datapoints/src/lib/i18n/locales/es.ts var es_exports = /* @__PURE__ */ __exportAll({ templates: () => templates$2 }); var modules$2, merged$2, templates$2; var init_es = __esmMin((() => { + init_es$29(); init_es$28(); init_es$27(); init_es$26(); @@ -4836,16 +4967,17 @@ init_es$2(); init_es$1(); modules$2 = /* #__PURE__ */ Object.assign({ - "../../../cards/action/i18n/es.ts": es_exports$28, - "../../../cards/history/history-chart/i18n/es.ts": es_exports$27, - "../../../cards/history/i18n/es.ts": es_exports$26, - "../../../cards/list/i18n/es.ts": es_exports$25, - "../../../cards/quick/i18n/es.ts": es_exports$24, - "../../../cards/sensor/i18n/es.ts": es_exports$23, - "../../chart/i18n/es.ts": es_exports$22, - "../../ha/i18n/es.ts": es_exports$21, - "../../history-page/i18n/es.ts": es_exports$20, - "../../timeline/i18n/es.ts": es_exports$19, + "../../../cards/action/i18n/es.ts": es_exports$29, + "../../../cards/history/history-chart/i18n/es.ts": es_exports$28, + "../../../cards/history/i18n/es.ts": es_exports$27, + "../../../cards/list/i18n/es.ts": es_exports$26, + "../../../cards/quick/i18n/es.ts": es_exports$25, + "../../../cards/sensor/i18n/es.ts": es_exports$24, + "../../chart/i18n/es.ts": es_exports$23, + "../../ha/i18n/es.ts": es_exports$22, + "../../history-page/i18n/es.ts": es_exports$21, + "../../timeline/i18n/es.ts": es_exports$20, + "../../../molecules/analysis-aggregate-group/i18n/es.ts": es_exports$19, "../../../molecules/analysis-anomaly-group/i18n/es.ts": es_exports$18, "../../../molecules/analysis-delta-group/i18n/es.ts": es_exports$17, "../../../molecules/analysis-rate-group/i18n/es.ts": es_exports$16, @@ -4871,10 +5003,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/action/i18n/pt.ts - var pt_exports$28 = /* @__PURE__ */ __exportAll({ translations: () => translations$55 }); - var translations$55; - var init_pt$28 = __esmMin((() => { - translations$55 = { + var pt_exports$29 = /* @__PURE__ */ __exportAll({ translations: () => translations$57 }); + var translations$57; + var init_pt$29 = __esmMin((() => { + translations$57 = { General: "Geral", "Related items": "Itens relacionados", "Datapoint Appearance": "Aspeto do ponto de dados", @@ -4891,20 +5023,20 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/history/history-chart/i18n/pt.ts - var pt_exports$27 = /* @__PURE__ */ __exportAll({ translations: () => translations$54 }); - var translations$54; - var init_pt$27 = __esmMin((() => { - translations$54 = { + var pt_exports$28 = /* @__PURE__ */ __exportAll({ translations: () => translations$56 }); + var translations$56; + var init_pt$28 = __esmMin((() => { + translations$56 = { "Date window:": "Janela de datas:", "Actual:": "Real:" }; })); //#endregion //#region custom_components/hass_datapoints/src/cards/history/i18n/pt.ts - var pt_exports$26 = /* @__PURE__ */ __exportAll({ translations: () => translations$53 }); - var translations$53; - var init_pt$26 = __esmMin((() => { - translations$53 = { + var pt_exports$27 = /* @__PURE__ */ __exportAll({ translations: () => translations$55 }); + var translations$55; + var init_pt$27 = __esmMin((() => { + translations$55 = { General: "Geral", Entity: "Entidade", "Multiple entities": "Múltiplas entidades", @@ -4921,10 +5053,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/list/i18n/pt.ts - var pt_exports$25 = /* @__PURE__ */ __exportAll({ translations: () => translations$52 }); - var translations$52; - var init_pt$25 = __esmMin((() => { - translations$52 = { + var pt_exports$26 = /* @__PURE__ */ __exportAll({ translations: () => translations$54 }); + var translations$54; + var init_pt$26 = __esmMin((() => { + translations$54 = { "Search datapoints…": "Pesquisar pontos de dados…", "Delete record": "Eliminar registo", Delete: "Eliminar", @@ -4942,10 +5074,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/quick/i18n/pt.ts - var pt_exports$24 = /* @__PURE__ */ __exportAll({ translations: () => translations$51 }); - var translations$51; - var init_pt$24 = __esmMin((() => { - translations$51 = { + var pt_exports$25 = /* @__PURE__ */ __exportAll({ translations: () => translations$53 }); + var translations$53; + var init_pt$25 = __esmMin((() => { + translations$53 = { General: "Geral", "Icon & colour": "Ícone e cor", "Related items": "Itens relacionados", @@ -4963,10 +5095,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/sensor/i18n/pt.ts - var pt_exports$23 = /* @__PURE__ */ __exportAll({ translations: () => translations$50 }); - var translations$50; - var init_pt$23 = __esmMin((() => { - translations$50 = { + var pt_exports$24 = /* @__PURE__ */ __exportAll({ translations: () => translations$52 }); + var translations$52; + var init_pt$24 = __esmMin((() => { + translations$52 = { Entity: "Entidade", Display: "Visualização", "Records list": "Lista de registos", @@ -4986,10 +5118,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/chart/i18n/pt.ts - var pt_exports$22 = /* @__PURE__ */ __exportAll({ translations: () => translations$49 }); - var translations$49; - var init_pt$22 = __esmMin((() => { - translations$49 = { + var pt_exports$23 = /* @__PURE__ */ __exportAll({ translations: () => translations$51 }); + var translations$51; + var init_pt$23 = __esmMin((() => { + translations$51 = { "⚠️ Anomaly Insight": "⚠️ Informação de anomalia", "⚠️ Multi-method Anomaly": "⚠️ Anomalia multimétodo", "Click the highlighted circle to add an annotation.": "Clique no círculo destacado para adicionar uma anotação.", @@ -5024,10 +5156,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/ha/i18n/pt.ts - var pt_exports$21 = /* @__PURE__ */ __exportAll({ translations: () => translations$48 }); - var translations$48; - var init_pt$21 = __esmMin((() => { - translations$48 = { + var pt_exports$22 = /* @__PURE__ */ __exportAll({ translations: () => translations$50 }); + var translations$50; + var init_pt$22 = __esmMin((() => { + translations$50 = { "Confirm delete": "Confirmar eliminação", "Are you sure you want to delete this item?": "Tem a certeza de que pretende eliminar este item?", Cancel: "Cancelar", @@ -5042,10 +5174,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/history-page/i18n/pt.ts - var pt_exports$20 = /* @__PURE__ */ __exportAll({ translations: () => translations$47 }); - var translations$47; - var init_pt$20 = __esmMin((() => { - translations$47 = { + var pt_exports$21 = /* @__PURE__ */ __exportAll({ translations: () => translations$49 }); + var translations$49; + var init_pt$21 = __esmMin((() => { + translations$49 = { "entity_id={0}": "entity_id={0}", "device_id={0}": "device_id={0}", "area_id={0}": "area_id={0}", @@ -5143,20 +5275,51 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/timeline/i18n/pt.ts - var pt_exports$19 = /* @__PURE__ */ __exportAll({ translations: () => translations$46 }); - var translations$46; - var init_pt$19 = __esmMin((() => { - translations$46 = { + var pt_exports$20 = /* @__PURE__ */ __exportAll({ translations: () => translations$48 }); + var translations$48; + var init_pt$20 = __esmMin((() => { + translations$48 = { Wk: "Sem.", "Week of": "Semana de" }; })); //#endregion + //#region custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts + var pt_exports$19 = /* @__PURE__ */ __exportAll({ translations: () => translations$47 }); + var translations$47; + var init_pt$19 = __esmMin((() => { + translations$47 = { + "Aggregate series": "Séries agregadas", + "Add aggregate": "Adicionar agregado", + "Remove aggregate": "Remover agregado", + "Aggregate operation": "Operação do agregado", + "Aggregate state": "Estado do agregado", + "Aggregate interval": "Intervalo do agregado", + "Count changes to a state": "Contar mudanças para um estado", + "Count all state changes": "Contar todas as mudanças de estado", + "Time in a state": "Tempo num estado", + "Percentage of time in a state": "Percentagem de tempo num estado", + Hourly: "Por hora", + Daily: "Diário", + Weekly: "Semanal", + Monthly: "Mensal", + On: "Ligado", + Off: "Desligado", + Open: "Aberto", + Closed: "Fechado", + Detected: "Detetado", + Clear: "Livre", + Occupied: "Ocupado", + Unlocked: "Desbloqueado", + Locked: "Bloqueado" + }; + })); + //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-anomaly-group/i18n/pt.ts - var pt_exports$18 = /* @__PURE__ */ __exportAll({ translations: () => translations$45 }); - var translations$45; + var pt_exports$18 = /* @__PURE__ */ __exportAll({ translations: () => translations$46 }); + var translations$46; var init_pt$18 = __esmMin((() => { - translations$45 = { + translations$46 = { Direction: "Direção", Both: "Ambos", Above: "Acima", @@ -5204,10 +5367,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-delta-group/i18n/pt.ts - var pt_exports$17 = /* @__PURE__ */ __exportAll({ translations: () => translations$44 }); - var translations$44; + var pt_exports$17 = /* @__PURE__ */ __exportAll({ translations: () => translations$45 }); + var translations$45; var init_pt$17 = __esmMin((() => { - translations$44 = { + translations$45 = { "Show delta vs selected date window": "Mostrar delta face à janela de datas selecionada", "Select a date window tab to enable delta analysis.": "Selecione um separador de janela de datas para ativar a análise delta.", "Show delta in tooltip": "Mostrar delta na dica", @@ -5216,10 +5379,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-rate-group/i18n/pt.ts - var pt_exports$16 = /* @__PURE__ */ __exportAll({ translations: () => translations$43 }); - var translations$43; + var pt_exports$16 = /* @__PURE__ */ __exportAll({ translations: () => translations$44 }); + var translations$44; var init_pt$16 = __esmMin((() => { - translations$43 = { + translations$44 = { "Show rate of change": "Mostrar taxa de variação", "Show rate of change crosshairs": "Mostrar guias da taxa de variação", "Rate window": "Janela da taxa", @@ -5231,10 +5394,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-sample-group/i18n/pt.ts - var pt_exports$15 = /* @__PURE__ */ __exportAll({ translations: () => translations$42 }); - var translations$42; + var pt_exports$15 = /* @__PURE__ */ __exportAll({ translations: () => translations$43 }); + var translations$43; var init_pt$15 = __esmMin((() => { - translations$42 = { + translations$43 = { Downsampling: "Reamostragem", Interval: "Intervalo", Aggregate: "Agregado", @@ -5266,20 +5429,20 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-summary-group/i18n/pt.ts - var pt_exports$14 = /* @__PURE__ */ __exportAll({ translations: () => translations$41 }); - var translations$41; + var pt_exports$14 = /* @__PURE__ */ __exportAll({ translations: () => translations$42 }); + var translations$42; var init_pt$14 = __esmMin((() => { - translations$41 = { + translations$42 = { "Show min / max / mean": "Mostrar mín. / máx. / média", "Show range shading": "Mostrar sombreamento do intervalo" }; })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-threshold-group/i18n/pt.ts - var pt_exports$13 = /* @__PURE__ */ __exportAll({ translations: () => translations$40 }); - var translations$40; + var pt_exports$13 = /* @__PURE__ */ __exportAll({ translations: () => translations$41 }); + var translations$41; var init_pt$13 = __esmMin((() => { - translations$40 = { + translations$41 = { "Show threshold analysis": "Mostrar análise de limiar", "Shade threshold area": "Sombrear área do limiar", Threshold: "Limiar", @@ -5290,10 +5453,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-trend-group/i18n/pt.ts - var pt_exports$12 = /* @__PURE__ */ __exportAll({ translations: () => translations$39 }); - var translations$39; + var pt_exports$12 = /* @__PURE__ */ __exportAll({ translations: () => translations$40 }); + var translations$40; var init_pt$12 = __esmMin((() => { - translations$39 = { + translations$40 = { "Show trend lines": "Mostrar linhas de tendência", "Show trend crosshairs": "Mostrar guias da tendência", "Trend method": "Método de tendência", @@ -5311,10 +5474,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/anomaly-monitor-wizard/i18n/pt.ts - var pt_exports$11 = /* @__PURE__ */ __exportAll({ translations: () => translations$38 }); - var translations$38; + var pt_exports$11 = /* @__PURE__ */ __exportAll({ translations: () => translations$39 }); + var translations$39; var init_pt$11 = __esmMin((() => { - translations$38 = { + translations$39 = { "Add all series from chart": "Adicionar todas as séries do gráfico", "Add from current chart": "Adicionar do gráfico atual", "Create anomaly monitor": "Criar monitor de anomalias", @@ -5324,17 +5487,17 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/comparison-tab-rail/i18n/pt.ts - var pt_exports$10 = /* @__PURE__ */ __exportAll({ translations: () => translations$37 }); - var translations$37; + var pt_exports$10 = /* @__PURE__ */ __exportAll({ translations: () => translations$38 }); + var translations$38; var init_pt$10 = __esmMin((() => { - translations$37 = { "Add date window": "Adicionar janela de datas" }; + translations$38 = { "Add date window": "Adicionar janela de datas" }; })); //#endregion //#region custom_components/hass_datapoints/src/molecules/date-window-dialog/i18n/pt.ts - var pt_exports$9 = /* @__PURE__ */ __exportAll({ translations: () => translations$36 }); - var translations$36; + var pt_exports$9 = /* @__PURE__ */ __exportAll({ translations: () => translations$37 }); + var translations$37; var init_pt$9 = __esmMin((() => { - translations$36 = { + translations$37 = { "A date window saves a named date range as a tab, so you can quickly preview it against the selected range or jump the chart back to it later.": "Uma janela de datas guarda um intervalo de datas com nome como separador, para que possa pré-visualizá-lo rapidamente face ao intervalo selecionado ou regressar mais tarde a ele no gráfico.", Name: "Nome", "e.g. Heating season start": "ex.: início da época de aquecimento", @@ -5351,10 +5514,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/sidebar-options/sections/i18n/pt.ts - var pt_exports$8 = /* @__PURE__ */ __exportAll({ translations: () => translations$35 }); - var translations$35; + var pt_exports$8 = /* @__PURE__ */ __exportAll({ translations: () => translations$36 }); + var translations$36; var init_pt$8 = __esmMin((() => { - translations$35 = { + translations$36 = { Datapoints: "Pontos de dados", "Choose which annotation datapoints appear on the chart.": "Escolha quais os pontos de dados de anotação que aparecem no gráfico.", "Linked to selected targets": "Ligados aos alvos selecionados", @@ -5364,10 +5527,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/target-row/i18n/pt.ts - var pt_exports$7 = /* @__PURE__ */ __exportAll({ translations: () => translations$34 }); - var translations$34; + var pt_exports$7 = /* @__PURE__ */ __exportAll({ translations: () => translations$35 }); + var translations$35; var init_pt$7 = __esmMin((() => { - translations$34 = { + translations$35 = { "Analysis configured": "Análise configurada", "Configure analysis": "Configurar análise", "Stepped series": "Série em degraus", @@ -5379,17 +5542,17 @@ })); //#endregion //#region custom_components/hass_datapoints/src/molecules/target-row-list/i18n/pt.ts - var pt_exports$6 = /* @__PURE__ */ __exportAll({ translations: () => translations$33 }); - var translations$33; + var pt_exports$6 = /* @__PURE__ */ __exportAll({ translations: () => translations$34 }); + var translations$34; var init_pt$6 = __esmMin((() => { - translations$33 = {}; + translations$34 = {}; })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/components/ai-query-brief-dialog/i18n/pt.ts - var pt_exports$5 = /* @__PURE__ */ __exportAll({ translations: () => translations$32 }); - var translations$32; + var pt_exports$5 = /* @__PURE__ */ __exportAll({ translations: () => translations$33 }); + var translations$33; var init_pt$5 = __esmMin((() => { - translations$32 = { + translations$33 = { "AI query brief": "Resumo de consulta para IA", "This brief is intended for another AI to fetch raw Home Assistant history and hass_datapoints anomaly details. Review it, then copy it into your AI tool of choice.": "Este resumo foi feito para outra IA obter o historico bruto do Home Assistant e os detalhes de anomalias do hass_datapoints. Revise-o e depois copie-o para a ferramenta de IA de sua escolha.", "Copy-ready brief": "Resumo pronto para copiar", @@ -5401,10 +5564,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/components/history-targets/i18n/pt.ts - var pt_exports$4 = /* @__PURE__ */ __exportAll({ translations: () => translations$31 }); - var translations$31; + var pt_exports$4 = /* @__PURE__ */ __exportAll({ translations: () => translations$32 }); + var translations$32; var init_pt$4 = __esmMin((() => { - translations$31 = { + translations$32 = { Targets: "Alvos", "Each row controls one chart series.": "Cada linha controla uma série do gráfico.", "Add target": "Adicionar alvo", @@ -5413,10 +5576,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/components/panel-shell/i18n/pt.ts - var pt_exports$3 = /* @__PURE__ */ __exportAll({ translations: () => translations$30 }); - var translations$30; + var pt_exports$3 = /* @__PURE__ */ __exportAll({ translations: () => translations$31 }); + var translations$31; var init_pt$3 = __esmMin((() => { - translations$30 = { + translations$31 = { "Loading Datapoints…": "A carregar pontos de dados…", Datapoints: "Pontos de dados", "Page options": "Opções da página", @@ -5431,10 +5594,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/components/range-toolbar/i18n/pt.ts - var pt_exports$2 = /* @__PURE__ */ __exportAll({ translations: () => translations$29 }); - var translations$29; + var pt_exports$2 = /* @__PURE__ */ __exportAll({ translations: () => translations$30 }); + var translations$30; var init_pt$2 = __esmMin((() => { - translations$29 = { + translations$30 = { "Toggle sidebar": "Alternar barra lateral", Start: "Início", End: "Fim", @@ -5459,16 +5622,17 @@ })); //#endregion //#region custom_components/hass_datapoints/src/panels/datapoints/i18n/pt.ts - var pt_exports$1 = /* @__PURE__ */ __exportAll({ translations: () => translations$28 }); - var translations$28; + var pt_exports$1 = /* @__PURE__ */ __exportAll({ translations: () => translations$29 }); + var translations$29; var init_pt$1 = __esmMin((() => { - translations$28 = { "Selected range": "Intervalo selecionado" }; + translations$29 = { "Selected range": "Intervalo selecionado" }; })); //#endregion //#region custom_components/hass_datapoints/src/lib/i18n/locales/pt.ts var pt_exports = /* @__PURE__ */ __exportAll({ templates: () => templates$1 }); var modules$1, merged$1, templates$1; var init_pt = __esmMin((() => { + init_pt$29(); init_pt$28(); init_pt$27(); init_pt$26(); @@ -5498,16 +5662,17 @@ init_pt$2(); init_pt$1(); modules$1 = /* #__PURE__ */ Object.assign({ - "../../../cards/action/i18n/pt.ts": pt_exports$28, - "../../../cards/history/history-chart/i18n/pt.ts": pt_exports$27, - "../../../cards/history/i18n/pt.ts": pt_exports$26, - "../../../cards/list/i18n/pt.ts": pt_exports$25, - "../../../cards/quick/i18n/pt.ts": pt_exports$24, - "../../../cards/sensor/i18n/pt.ts": pt_exports$23, - "../../chart/i18n/pt.ts": pt_exports$22, - "../../ha/i18n/pt.ts": pt_exports$21, - "../../history-page/i18n/pt.ts": pt_exports$20, - "../../timeline/i18n/pt.ts": pt_exports$19, + "../../../cards/action/i18n/pt.ts": pt_exports$29, + "../../../cards/history/history-chart/i18n/pt.ts": pt_exports$28, + "../../../cards/history/i18n/pt.ts": pt_exports$27, + "../../../cards/list/i18n/pt.ts": pt_exports$26, + "../../../cards/quick/i18n/pt.ts": pt_exports$25, + "../../../cards/sensor/i18n/pt.ts": pt_exports$24, + "../../chart/i18n/pt.ts": pt_exports$23, + "../../ha/i18n/pt.ts": pt_exports$22, + "../../history-page/i18n/pt.ts": pt_exports$21, + "../../timeline/i18n/pt.ts": pt_exports$20, + "../../../molecules/analysis-aggregate-group/i18n/pt.ts": pt_exports$19, "../../../molecules/analysis-anomaly-group/i18n/pt.ts": pt_exports$18, "../../../molecules/analysis-delta-group/i18n/pt.ts": pt_exports$17, "../../../molecules/analysis-rate-group/i18n/pt.ts": pt_exports$16, @@ -5533,10 +5698,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/action/i18n/zh-hans.ts - var zh_hans_exports$28 = /* @__PURE__ */ __exportAll({ translations: () => translations$27 }); - var translations$27; - var init_zh_hans$28 = __esmMin((() => { - translations$27 = { + var zh_hans_exports$29 = /* @__PURE__ */ __exportAll({ translations: () => translations$28 }); + var translations$28; + var init_zh_hans$29 = __esmMin((() => { + translations$28 = { General: "常规", "Related items": "关联项", "Datapoint Appearance": "数据点外观", @@ -5553,20 +5718,20 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/history/history-chart/i18n/zh-hans.ts - var zh_hans_exports$27 = /* @__PURE__ */ __exportAll({ translations: () => translations$26 }); - var translations$26; - var init_zh_hans$27 = __esmMin((() => { - translations$26 = { + var zh_hans_exports$28 = /* @__PURE__ */ __exportAll({ translations: () => translations$27 }); + var translations$27; + var init_zh_hans$28 = __esmMin((() => { + translations$27 = { "Date window:": "日期窗口:", "Actual:": "实际:" }; })); //#endregion //#region custom_components/hass_datapoints/src/cards/history/i18n/zh-hans.ts - var zh_hans_exports$26 = /* @__PURE__ */ __exportAll({ translations: () => translations$25 }); - var translations$25; - var init_zh_hans$26 = __esmMin((() => { - translations$25 = { + var zh_hans_exports$27 = /* @__PURE__ */ __exportAll({ translations: () => translations$26 }); + var translations$26; + var init_zh_hans$27 = __esmMin((() => { + translations$26 = { General: "常规", Entity: "实体", "Multiple entities": "多个实体", @@ -5580,13 +5745,13 @@ "Background band": "背景条带", "Row strip": "行条带" }; - })); - //#endregion - //#region custom_components/hass_datapoints/src/cards/list/i18n/zh-hans.ts - var zh_hans_exports$25 = /* @__PURE__ */ __exportAll({ translations: () => translations$24 }); - var translations$24; - var init_zh_hans$25 = __esmMin((() => { - translations$24 = { + })); + //#endregion + //#region custom_components/hass_datapoints/src/cards/list/i18n/zh-hans.ts + var zh_hans_exports$26 = /* @__PURE__ */ __exportAll({ translations: () => translations$25 }); + var translations$25; + var init_zh_hans$26 = __esmMin((() => { + translations$25 = { "Search datapoints…": "搜索数据点…", "Delete record": "删除记录", Delete: "删除", @@ -5604,10 +5769,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/quick/i18n/zh-hans.ts - var zh_hans_exports$24 = /* @__PURE__ */ __exportAll({ translations: () => translations$23 }); - var translations$23; - var init_zh_hans$24 = __esmMin((() => { - translations$23 = { + var zh_hans_exports$25 = /* @__PURE__ */ __exportAll({ translations: () => translations$24 }); + var translations$24; + var init_zh_hans$25 = __esmMin((() => { + translations$24 = { General: "常规", "Icon & colour": "图标和颜色", "Related items": "关联项", @@ -5625,10 +5790,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/cards/sensor/i18n/zh-hans.ts - var zh_hans_exports$23 = /* @__PURE__ */ __exportAll({ translations: () => translations$22 }); - var translations$22; - var init_zh_hans$23 = __esmMin((() => { - translations$22 = { + var zh_hans_exports$24 = /* @__PURE__ */ __exportAll({ translations: () => translations$23 }); + var translations$23; + var init_zh_hans$24 = __esmMin((() => { + translations$23 = { Entity: "实体", Display: "显示", "Records list": "记录列表", @@ -5648,10 +5813,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/chart/i18n/zh-hans.ts - var zh_hans_exports$22 = /* @__PURE__ */ __exportAll({ translations: () => translations$21 }); - var translations$21; - var init_zh_hans$22 = __esmMin((() => { - translations$21 = { + var zh_hans_exports$23 = /* @__PURE__ */ __exportAll({ translations: () => translations$22 }); + var translations$22; + var init_zh_hans$23 = __esmMin((() => { + translations$22 = { "⚠️ Anomaly Insight": "⚠️ 异常洞察", "⚠️ Multi-method Anomaly": "⚠️ 多方法异常", "Click the highlighted circle to add an annotation.": "点击高亮圆圈以添加注释。", @@ -5686,10 +5851,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/ha/i18n/zh-hans.ts - var zh_hans_exports$21 = /* @__PURE__ */ __exportAll({ translations: () => translations$20 }); - var translations$20; - var init_zh_hans$21 = __esmMin((() => { - translations$20 = { + var zh_hans_exports$22 = /* @__PURE__ */ __exportAll({ translations: () => translations$21 }); + var translations$21; + var init_zh_hans$22 = __esmMin((() => { + translations$21 = { "Confirm delete": "确认删除", "Are you sure you want to delete this item?": "确定要删除此项目吗?", Cancel: "取消", @@ -5704,10 +5869,10 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/history-page/i18n/zh-hans.ts - var zh_hans_exports$20 = /* @__PURE__ */ __exportAll({ translations: () => translations$19 }); - var translations$19; - var init_zh_hans$20 = __esmMin((() => { - translations$19 = { + var zh_hans_exports$21 = /* @__PURE__ */ __exportAll({ translations: () => translations$20 }); + var translations$20; + var init_zh_hans$21 = __esmMin((() => { + translations$20 = { "entity_id={0}": "entity_id={0}", "device_id={0}": "device_id={0}", "area_id={0}": "area_id={0}", @@ -5805,12 +5970,43 @@ })); //#endregion //#region custom_components/hass_datapoints/src/lib/timeline/i18n/zh-hans.ts + var zh_hans_exports$20 = /* @__PURE__ */ __exportAll({ translations: () => translations$19 }); + var translations$19; + var init_zh_hans$20 = __esmMin((() => { + translations$19 = { + Wk: "周", + "Week of": "所在周" + }; + })); + //#endregion + //#region custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts var zh_hans_exports$19 = /* @__PURE__ */ __exportAll({ translations: () => translations$18 }); var translations$18; var init_zh_hans$19 = __esmMin((() => { translations$18 = { - Wk: "周", - "Week of": "所在周" + "Aggregate series": "聚合序列", + "Add aggregate": "添加聚合", + "Remove aggregate": "移除聚合", + "Aggregate operation": "聚合操作", + "Aggregate state": "聚合状态", + "Aggregate interval": "聚合间隔", + "Count changes to a state": "统计变为某状态的次数", + "Count all state changes": "统计所有状态变化次数", + "Time in a state": "处于某状态的时长", + "Percentage of time in a state": "处于某状态的时间百分比", + Hourly: "每小时", + Daily: "每天", + Weekly: "每周", + Monthly: "每月", + On: "开", + Off: "关", + Open: "打开", + Closed: "关闭", + Detected: "已检测到", + Clear: "无人", + Occupied: "有人", + Unlocked: "已解锁", + Locked: "已锁定" }; })); //#endregion @@ -6131,6 +6327,7 @@ var zh_hans_exports = /* @__PURE__ */ __exportAll({ templates: () => templates }); var modules, merged, templates; var init_zh_hans = __esmMin((() => { + init_zh_hans$29(); init_zh_hans$28(); init_zh_hans$27(); init_zh_hans$26(); @@ -6160,16 +6357,17 @@ init_zh_hans$2(); init_zh_hans$1(); modules = /* #__PURE__ */ Object.assign({ - "../../../cards/action/i18n/zh-hans.ts": zh_hans_exports$28, - "../../../cards/history/history-chart/i18n/zh-hans.ts": zh_hans_exports$27, - "../../../cards/history/i18n/zh-hans.ts": zh_hans_exports$26, - "../../../cards/list/i18n/zh-hans.ts": zh_hans_exports$25, - "../../../cards/quick/i18n/zh-hans.ts": zh_hans_exports$24, - "../../../cards/sensor/i18n/zh-hans.ts": zh_hans_exports$23, - "../../chart/i18n/zh-hans.ts": zh_hans_exports$22, - "../../ha/i18n/zh-hans.ts": zh_hans_exports$21, - "../../history-page/i18n/zh-hans.ts": zh_hans_exports$20, - "../../timeline/i18n/zh-hans.ts": zh_hans_exports$19, + "../../../cards/action/i18n/zh-hans.ts": zh_hans_exports$29, + "../../../cards/history/history-chart/i18n/zh-hans.ts": zh_hans_exports$28, + "../../../cards/history/i18n/zh-hans.ts": zh_hans_exports$27, + "../../../cards/list/i18n/zh-hans.ts": zh_hans_exports$26, + "../../../cards/quick/i18n/zh-hans.ts": zh_hans_exports$25, + "../../../cards/sensor/i18n/zh-hans.ts": zh_hans_exports$24, + "../../chart/i18n/zh-hans.ts": zh_hans_exports$23, + "../../ha/i18n/zh-hans.ts": zh_hans_exports$22, + "../../history-page/i18n/zh-hans.ts": zh_hans_exports$21, + "../../timeline/i18n/zh-hans.ts": zh_hans_exports$20, + "../../../molecules/analysis-aggregate-group/i18n/zh-hans.ts": zh_hans_exports$19, "../../../molecules/analysis-anomaly-group/i18n/zh-hans.ts": zh_hans_exports$18, "../../../molecules/analysis-delta-group/i18n/zh-hans.ts": zh_hans_exports$17, "../../../molecules/analysis-rate-group/i18n/zh-hans.ts": zh_hans_exports$16, @@ -6240,7 +6438,7 @@ } //#endregion //#region custom_components/hass_datapoints/src/cards/action/editor.styles.ts - var styles$72 = i$5` + var styles$73 = i$5` .note { font-size: 0.78rem; color: var(--secondary-text-color); @@ -6248,7 +6446,7 @@ `; //#endregion //#region custom_components/hass_datapoints/src/molecules/editor-base/editor-base.styles.ts - var styles$71 = i$5` + var styles$72 = i$5` :host { display: block; } @@ -6299,7 +6497,7 @@ this._config = cfg; this._fire(cfg); } - }, _defineProperty(_EditorBase, "styles", styles$71), _EditorBase); + }, _defineProperty(_EditorBase, "styles", styles$72), _EditorBase); __decorate([r$2()], EditorBase.prototype, "_config", null); __decorate([n$1({ type: Object })], EditorBase.prototype, "hass", null); EditorBase = __decorate([localized()], EditorBase); @@ -6377,7 +6575,7 @@ } //#endregion //#region custom_components/hass_datapoints/src/atoms/display/section-heading/section-heading.styles.ts - var styles$70 = i$5` + var styles$71 = i$5` :host { display: block; } @@ -6407,12 +6605,12 @@ return b`
${this.text}
`; } }; - _defineProperty(SectionHeading, "styles", styles$70); + _defineProperty(SectionHeading, "styles", styles$71); __decorate([n$1({ type: String })], SectionHeading.prototype, "text", null); customElements.define("section-heading", SectionHeading); //#endregion //#region custom_components/hass_datapoints/src/atoms/form/editor-text-field/editor-text-field.styles.ts - var styles$69 = i$5` + var styles$70 = i$5` :host { display: block; } @@ -6489,7 +6687,7 @@ >`; } }; - _defineProperty(EditorTextField, "styles", styles$69); + _defineProperty(EditorTextField, "styles", styles$70); __decorate([n$1({ type: String })], EditorTextField.prototype, "label", null); __decorate([n$1({ type: String })], EditorTextField.prototype, "value", null); __decorate([n$1({ type: String })], EditorTextField.prototype, "type", null); @@ -6498,7 +6696,7 @@ customElements.define("editor-text-field", EditorTextField); //#endregion //#region custom_components/hass_datapoints/src/atoms/form/editor-switch/editor-switch.styles.ts - var styles$68 = i$5` + var styles$69 = i$5` :host { display: block; } @@ -6596,14 +6794,14 @@ `; } }; - _defineProperty(EditorSwitch, "styles", styles$68); + _defineProperty(EditorSwitch, "styles", styles$69); __decorate([n$1({ type: String })], EditorSwitch.prototype, "label", null); __decorate([n$1({ type: Boolean })], EditorSwitch.prototype, "checked", null); __decorate([n$1({ type: String })], EditorSwitch.prototype, "tooltip", null); customElements.define("editor-switch", EditorSwitch); //#endregion //#region custom_components/hass_datapoints/src/atoms/form/editor-icon-picker/editor-icon-picker.styles.ts - var styles$67 = i$5` + var styles$68 = i$5` :host { display: block; } @@ -6661,7 +6859,7 @@ >`; } }; - _defineProperty(EditorIconPicker, "styles", styles$67); + _defineProperty(EditorIconPicker, "styles", styles$68); __decorate([n$1({ type: String })], EditorIconPicker.prototype, "label", null); __decorate([n$1({ type: String })], EditorIconPicker.prototype, "value", null); __decorate([n$1({ type: Object })], EditorIconPicker.prototype, "hass", null); @@ -6778,7 +6976,7 @@ `; } }; - _defineProperty(HassDatapointsActionCardEditor, "styles", [EditorBase.styles, styles$72]); + _defineProperty(HassDatapointsActionCardEditor, "styles", [EditorBase.styles, styles$73]); __decorate([e$4("#target-picker")], HassDatapointsActionCardEditor.prototype, "_targetPickerEl", null); __decorate([r$1("ha-selector")], HassDatapointsActionCardEditor.prototype, "_selectorEls", null); //#endregion @@ -7022,7 +7220,7 @@ } //#endregion //#region custom_components/hass_datapoints/src/cards/dev-tool/dev-tool.styles.ts - var styles$66 = i$5` + var styles$67 = i$5` :host { display: block; } @@ -7097,7 +7295,7 @@ _defineProperty(HassDatapointsDevToolCardEditor, "styles", [EditorBase.styles]); //#endregion //#region custom_components/hass_datapoints/src/cards/dev-tool/dev-tool-results/dev-tool-results.styles.ts - var styles$65 = i$5` + var styles$66 = i$5` :host { display: block; margin-top: 18px; @@ -7464,7 +7662,7 @@ `; } }; - _defineProperty(CardDevToolResults, "styles", styles$65); + _defineProperty(CardDevToolResults, "styles", styles$66); __decorate([n$1({ attribute: false })], CardDevToolResults.prototype, "results", null); __decorate([n$1({ type: Boolean })], CardDevToolResults.prototype, "isAdmin", null); __decorate([n$1({ type: String })], CardDevToolResults.prototype, "statusKind", null); @@ -7474,7 +7672,7 @@ customElements.define("dev-tool-results", CardDevToolResults); //#endregion //#region custom_components/hass_datapoints/src/cards/dev-tool/dev-tool-windows/dev-tool-windows.styles.ts - var styles$64 = i$5` + var styles$65 = i$5` :host { display: block; } @@ -7717,7 +7915,7 @@ `; } }; - _defineProperty(CardDevToolWindows, "styles", styles$64); + _defineProperty(CardDevToolWindows, "styles", styles$65); __decorate([n$1({ attribute: false })], CardDevToolWindows.prototype, "windows", null); __decorate([r$2()], CardDevToolWindows.prototype, "_nextWindowId", null); customElements.define("dev-tool-windows", CardDevToolWindows); @@ -8228,7 +8426,7 @@ return document.createElement("hass-datapoints-dev-tool-card-editor"); } }; - _defineProperty(HassDatapointsDevToolCard, "styles", styles$66); + _defineProperty(HassDatapointsDevToolCard, "styles", styles$67); __decorate([r$2()], HassDatapointsDevToolCard.prototype, "_config", null); __decorate([r$2()], HassDatapointsDevToolCard.prototype, "_hass", null); __decorate([r$2()], HassDatapointsDevToolCard.prototype, "_entities", null); @@ -8401,7 +8599,7 @@ }; //#endregion //#region custom_components/hass_datapoints/src/cards/history/history.styles.ts - var styles$63 = i$5` + var styles$64 = i$5` :host { display: block; height: 100%; @@ -12519,7 +12717,7 @@ }; } function seriesHasActiveAnalysis(analysis, hasSelectedComparisonWindow = false) { - return !!(analysis.show_trend_lines || analysis.show_summary_stats || analysis.show_rate_of_change || analysis.show_threshold_analysis || analysis.show_anomalies || analysis.show_delta_analysis && hasSelectedComparisonWindow); + return !!(analysis.show_trend_lines || analysis.show_summary_stats || analysis.show_rate_of_change || analysis.show_threshold_analysis || analysis.show_anomalies || analysis.show_delta_analysis && hasSelectedComparisonWindow || (analysis.aggregates?.length ?? 0) > 0); } function seriesShouldHideSource(analysis, hasSelectedComparisonWindow = false) { return analysis.hide_source_series === true && seriesHasActiveAnalysis(analysis, hasSelectedComparisonWindow); @@ -13053,7 +13251,7 @@ } //#endregion //#region custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.styles.ts - var styles$62 = ` + var styles$63 = ` hass-datapoints-history-chart { position: relative; display: flex; @@ -13978,7 +14176,7 @@ connectedCallback() { this.style.cssText = "position:relative;display:flex;flex-direction:column;height:100%;min-height:0;padding:var(--dp-spacing-sm,8px) var(--dp-spacing-md,12px) var(--dp-spacing-md,12px);box-sizing:border-box;overflow:visible;isolation:isolate;z-index:3;"; if (this.querySelector("#chart")) return; - this.innerHTML = ` + this.innerHTML = `
@@ -17248,7 +17446,7 @@ }); //#endregion //#region custom_components/hass_datapoints/src/molecules/annotation-chip-row/annotation-chip-row.styles.ts - var styles$61 = i$5` + var styles$62 = i$5` :host { display: block; } @@ -17279,7 +17477,7 @@ `; //#endregion //#region custom_components/hass_datapoints/src/atoms/interactive/annotation-chip/annotation-chip.styles.ts - var styles$60 = i$5` + var styles$61 = i$5` :host { display: inline-flex; } @@ -17449,7 +17647,7 @@ `; } }; - _defineProperty(AnnotationChip, "styles", styles$60); + _defineProperty(AnnotationChip, "styles", styles$61); __decorate([n$1({ type: String })], AnnotationChip.prototype, "type", null); __decorate([n$1({ type: String, @@ -17564,7 +17762,7 @@ `; } }; - _defineProperty(AnnotationChipRow, "styles", styles$61); + _defineProperty(AnnotationChipRow, "styles", styles$62); __decorate([n$1({ type: Array })], AnnotationChipRow.prototype, "chips", null); __decorate([n$1({ type: Object, @@ -19012,11 +19210,11 @@ return document.createElement("hass-datapoints-history-card-editor"); } }; - _defineProperty(HassDatapointsHistoryCard, "styles", styles$63); + _defineProperty(HassDatapointsHistoryCard, "styles", styles$64); customElements.define("hass-datapoints-history-card", HassDatapointsHistoryCard); //#endregion //#region custom_components/hass_datapoints/src/cards/history/editor.styles.ts - var styles$59 = i$5` + var styles$60 = i$5` .binary-display-row { display: flex; align-items: center; @@ -19181,10 +19379,10 @@ `; //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-sample-group/analysis-sample-group.styles.ts - var styles$58 = i$5``; + var styles$59 = i$5``; //#endregion //#region custom_components/hass_datapoints/src/atoms/analysis/analysis-group/analysis-group.styles.ts - var styles$57 = i$5` + var styles$58 = i$5` :host { display: block; --dp-spacing-xs: calc(var(--spacing, 8px) * 0.5); @@ -19317,7 +19515,7 @@ `; } }; - _defineProperty(AnalysisGroup, "styles", styles$57); + _defineProperty(AnalysisGroup, "styles", styles$58); __decorate([n$1({ type: String })], AnalysisGroup.prototype, "label", null); __decorate([n$1({ type: Boolean })], AnalysisGroup.prototype, "checked", null); __decorate([n$1({ type: Boolean })], AnalysisGroup.prototype, "disabled", null); @@ -19328,7 +19526,7 @@ customElements.define("analysis-group", AnalysisGroup); //#endregion //#region custom_components/hass_datapoints/src/atoms/form/inline-select/inline-select.styles.ts - var styles$56 = i$5` + var styles$57 = i$5` :host { display: inline-block; } @@ -19407,7 +19605,7 @@ `; } }; - _defineProperty(InlineSelect, "styles", styles$56); + _defineProperty(InlineSelect, "styles", styles$57); __decorate([n$1({ type: String })], InlineSelect.prototype, "value", null); __decorate([n$1({ type: Array })], InlineSelect.prototype, "options", null); __decorate([n$1({ type: Boolean })], InlineSelect.prototype, "disabled", null); @@ -19551,12 +19749,12 @@ `; } - }, _defineProperty(_AnalysisSampleGroup, "styles", [sharedStyles, styles$58]), _AnalysisSampleGroup); + }, _defineProperty(_AnalysisSampleGroup, "styles", [sharedStyles, styles$59]), _AnalysisSampleGroup); AnalysisSampleGroup = __decorate([localized()], AnalysisSampleGroup); customElements.define("analysis-sample-group", AnalysisSampleGroup); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-trend-group/analysis-trend-group.styles.ts - var styles$55 = i$5``; + var styles$56 = i$5``; //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-trend-group/analysis-trend-group.ts var _AnalysisTrendGroup; @@ -19669,12 +19867,12 @@ `; } - }, _defineProperty(_AnalysisTrendGroup, "styles", [sharedStyles, styles$55]), _AnalysisTrendGroup); + }, _defineProperty(_AnalysisTrendGroup, "styles", [sharedStyles, styles$56]), _AnalysisTrendGroup); AnalysisTrendGroup = __decorate([localized()], AnalysisTrendGroup); customElements.define("analysis-trend-group", AnalysisTrendGroup); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-summary-group/analysis-summary-group.styles.ts - var styles$54 = i$5``; + var styles$55 = i$5``; //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-summary-group/analysis-summary-group.ts var _AnalysisSummaryGroup = class AnalysisSummaryGroup extends AnalysisGroupMixin(i$2) { @@ -19701,12 +19899,12 @@ `; } }; - var AnalysisSummaryGroup = (_defineProperty(_AnalysisSummaryGroup, "styles", [sharedStyles, styles$54]), _AnalysisSummaryGroup); + var AnalysisSummaryGroup = (_defineProperty(_AnalysisSummaryGroup, "styles", [sharedStyles, styles$55]), _AnalysisSummaryGroup); AnalysisSummaryGroup = __decorate([localized()], AnalysisSummaryGroup); customElements.define("analysis-summary-group", AnalysisSummaryGroup); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-rate-group/analysis-rate-group.styles.ts - var styles$53 = i$5``; + var styles$54 = i$5``; //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-rate-group/analysis-rate-group.ts var _AnalysisRateGroup; @@ -19775,15 +19973,15 @@ `; } - }, _defineProperty(_AnalysisRateGroup, "styles", [sharedStyles, styles$53]), _AnalysisRateGroup); + }, _defineProperty(_AnalysisRateGroup, "styles", [sharedStyles, styles$54]), _AnalysisRateGroup); AnalysisRateGroup = __decorate([localized()], AnalysisRateGroup); customElements.define("analysis-rate-group", AnalysisRateGroup); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-threshold-group/analysis-threshold-group.styles.ts - var styles$52 = i$5``; + var styles$53 = i$5``; //#endregion //#region custom_components/hass_datapoints/src/atoms/form/number-input/number-input.styles.ts - var styles$51 = i$5` + var styles$52 = i$5` :host { display: inline-flex; align-items: center; @@ -19865,7 +20063,7 @@ `; } }; - _defineProperty(NumberInput, "styles", styles$51); + _defineProperty(NumberInput, "styles", styles$52); __decorate([n$1({ type: String })], NumberInput.prototype, "value", null); __decorate([n$1({ type: String })], NumberInput.prototype, "placeholder", null); __decorate([n$1({ type: String })], NumberInput.prototype, "suffix", null); @@ -19934,13 +20132,13 @@ `; } - }, _defineProperty(_AnalysisThresholdGroup, "styles", [sharedStyles, styles$52]), _AnalysisThresholdGroup); + }, _defineProperty(_AnalysisThresholdGroup, "styles", [sharedStyles, styles$53]), _AnalysisThresholdGroup); __decorate([n$1({ type: String })], AnalysisThresholdGroup.prototype, "unit", null); AnalysisThresholdGroup = __decorate([localized()], AnalysisThresholdGroup); customElements.define("analysis-threshold-group", AnalysisThresholdGroup); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-anomaly-group/analysis-anomaly-group.styles.ts - var styles$50 = i$5` + var styles$51 = i$5` .method-computing-indicator { display: inline-flex; align-items: center; @@ -20081,7 +20279,7 @@ `; //#endregion //#region custom_components/hass_datapoints/src/atoms/analysis/analysis-method-subopts/analysis-method-subopts.styles.ts - var styles$49 = i$5` + var styles$50 = i$5` :host { display: block; --dp-spacing-sm: var(--spacing, 8px); @@ -20103,7 +20301,7 @@ return b`
`; } }; - _defineProperty(AnalysisMethodSubopts, "styles", styles$49); + _defineProperty(AnalysisMethodSubopts, "styles", styles$50); customElements.define("analysis-method-subopts", AnalysisMethodSubopts); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-anomaly-group/analysis-anomaly-group.ts @@ -20609,7 +20807,7 @@ composed: true })); } - }, _defineProperty(_AnalysisAnomalyGroup, "styles", [sharedStyles, styles$50]), _AnalysisAnomalyGroup); + }, _defineProperty(_AnalysisAnomalyGroup, "styles", [sharedStyles, styles$51]), _AnalysisAnomalyGroup); __decorate([n$1({ type: Array, attribute: "comparison-windows" @@ -20638,7 +20836,7 @@ customElements.define("analysis-anomaly-group", AnalysisAnomalyGroup); //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-delta-group/analysis-delta-group.styles.ts - var styles$48 = i$5` + var styles$49 = i$5` .help-text { display: inline-block; color: var(--secondary-text-color); @@ -20701,7 +20899,7 @@ `; } - }, _defineProperty(_AnalysisDeltaGroup, "styles", [sharedStyles, styles$48]), _AnalysisDeltaGroup); + }, _defineProperty(_AnalysisDeltaGroup, "styles", [sharedStyles, styles$49]), _AnalysisDeltaGroup); __decorate([n$1({ type: Boolean, attribute: "can-show-delta-analysis" @@ -20710,14 +20908,14 @@ customElements.define("analysis-delta-group", AnalysisDeltaGroup); //#endregion //#region custom_components/hass_datapoints/src/molecules/sidebar-options/sections/sidebar-datapoints-section.styles.ts - var styles$47 = i$5` + var styles$48 = i$5` :host { display: block; } `; //#endregion //#region custom_components/hass_datapoints/src/atoms/display/sidebar-options-section/sidebar-options-section.styles.ts - var styles$46 = i$5` + var styles$47 = i$5` :host { display: block; --dp-spacing-xs: calc(var(--spacing, 8px) * 0.5); @@ -20731,7 +20929,7 @@ `; //#endregion //#region custom_components/hass_datapoints/src/atoms/display/sidebar-section-header/sidebar-section-header.styles.ts - var styles$45 = i$5` + var styles$46 = i$5` :host { display: block; } @@ -20888,7 +21086,7 @@ `; } }; - _defineProperty(SidebarSectionHeader, "styles", styles$45); + _defineProperty(SidebarSectionHeader, "styles", styles$46); __decorate([n$1({ type: String })], SidebarSectionHeader.prototype, "title", null); __decorate([n$1({ type: String })], SidebarSectionHeader.prototype, "subtitle", null); __decorate([n$1({ type: Boolean })], SidebarSectionHeader.prototype, "collapsible", null); @@ -20966,7 +21164,7 @@ `; } }; - _defineProperty(SidebarOptionsSection, "styles", styles$46); + _defineProperty(SidebarOptionsSection, "styles", styles$47); __decorate([n$1({ type: String })], SidebarOptionsSection.prototype, "title", null); __decorate([n$1({ type: String })], SidebarOptionsSection.prototype, "subtitle", null); __decorate([n$1({ type: Boolean })], SidebarOptionsSection.prototype, "collapsible", null); @@ -20975,7 +21173,7 @@ customElements.define("sidebar-options-section", SidebarOptionsSection); //#endregion //#region custom_components/hass_datapoints/src/atoms/form/radio-group/radio-group.styles.ts - var styles$44 = i$5` + var styles$45 = i$5` :host { display: block; } @@ -21061,7 +21259,7 @@ `; } }; - _defineProperty(RadioGroup, "styles", styles$44); + _defineProperty(RadioGroup, "styles", styles$45); __decorate([n$1({ type: String })], RadioGroup.prototype, "name", null); __decorate([n$1({ type: String })], RadioGroup.prototype, "value", null); __decorate([n$1({ type: Array })], RadioGroup.prototype, "options", null); @@ -21141,7 +21339,7 @@ `; } - }, _defineProperty(_SidebarDatapointsSection, "styles", styles$47), _SidebarDatapointsSection); + }, _defineProperty(_SidebarDatapointsSection, "styles", styles$48), _SidebarDatapointsSection); __decorate([n$1({ type: String, attribute: "datapoint-scope" @@ -21152,14 +21350,14 @@ customElements.define("sidebar-datapoints-section", SidebarDatapointsSection); //#endregion //#region custom_components/hass_datapoints/src/molecules/sidebar-options/sections/sidebar-datapoint-display-section.styles.ts - var styles$43 = i$5` + var styles$44 = i$5` :host { display: block; } `; //#endregion //#region custom_components/hass_datapoints/src/atoms/form/checkbox-list/checkbox-list.styles.ts - var styles$42 = i$5` + var styles$43 = i$5` :host { display: block; } @@ -21224,7 +21422,7 @@ `; } }; - _defineProperty(CheckboxList, "styles", styles$42); + _defineProperty(CheckboxList, "styles", styles$43); __decorate([n$1({ type: Array })], CheckboxList.prototype, "items", null); customElements.define("checkbox-list", CheckboxList); //#endregion @@ -21300,7 +21498,7 @@ `; } - }, _defineProperty(_SidebarDatapointDisplaySection, "styles", styles$43), _SidebarDatapointDisplaySection); + }, _defineProperty(_SidebarDatapointDisplaySection, "styles", styles$44), _SidebarDatapointDisplaySection); __decorate([n$1({ type: Boolean, attribute: "show-icons" @@ -21315,7 +21513,7 @@ customElements.define("sidebar-datapoint-display-section", SidebarDatapointDisplaySection); //#endregion //#region custom_components/hass_datapoints/src/molecules/sidebar-options/sections/sidebar-analysis-section.styles.ts - var styles$41 = i$5` + var styles$42 = i$5` :host { display: block; } @@ -21456,7 +21654,7 @@ `; } - }, _defineProperty(_SidebarAnalysisSection, "styles", styles$41), _SidebarAnalysisSection); + }, _defineProperty(_SidebarAnalysisSection, "styles", styles$42), _SidebarAnalysisSection); __decorate([n$1({ type: String, attribute: "anomaly-overlap-mode" @@ -21475,7 +21673,7 @@ customElements.define("sidebar-analysis-section", SidebarAnalysisSection); //#endregion //#region custom_components/hass_datapoints/src/molecules/sidebar-options/sections/sidebar-chart-display-section.styles.ts - var styles$40 = i$5` + var styles$41 = i$5` :host { display: block; --dp-spacing-sm: var(--spacing, 8px); @@ -21732,7 +21930,7 @@ `; } - }, _defineProperty(_SidebarChartDisplaySection, "styles", styles$40), _SidebarChartDisplaySection); + }, _defineProperty(_SidebarChartDisplaySection, "styles", styles$41), _SidebarChartDisplaySection); __decorate([n$1({ type: Boolean, attribute: "show-tooltips" @@ -22138,7 +22336,7 @@ `; } }; - _defineProperty(HassDatapointsHistoryCardEditor, "styles", [EditorBase.styles, styles$59]); + _defineProperty(HassDatapointsHistoryCardEditor, "styles", [EditorBase.styles, styles$60]); //#endregion //#region custom_components/hass_datapoints/src/lib/data/preferences-api.ts /** HA user-data key for the saved history page. Stored via frontend/set_user_data. */ @@ -22936,7 +23134,7 @@ } //#endregion //#region custom_components/hass_datapoints/src/molecules/target-row/target-row.styles.ts - var styles$39 = i$5` + var styles$40 = i$5` :host { display: block; --dp-spacing-xs: calc(var(--spacing, 8px) * 0.5); @@ -23437,6 +23635,257 @@ border-radius: 4px; } `; + //#endregion + //#region custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.styles.ts + var styles$39 = i$5` + .heading { + font-size: 0.84rem; + font-weight: 600; + color: var(--primary-text-color); + margin-bottom: var(--dp-spacing-xs); + } + + .list { + display: grid; + gap: var(--dp-spacing-sm); + } + + .definition { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--dp-spacing-sm); + padding: var(--dp-spacing-sm); + border-radius: 10px; + border: 1px solid + color-mix( + in srgb, + var(--divider-color, rgba(0, 0, 0, 0.12)) 88%, + transparent + ); + } + + .button { + display: inline-flex; + align-items: center; + gap: var(--dp-spacing-xs); + padding: var(--dp-spacing-xs) var(--dp-spacing-sm); + border-radius: 8px; + border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.12)); + background: transparent; + color: var(--primary-text-color); + font: inherit; + font-size: 0.8rem; + cursor: pointer; + } + + .button:focus-visible { + outline: 2px solid var(--primary-color, #03a9f4); + outline-offset: 2px; + } + + .remove { + margin-left: auto; + } + + .add { + margin-top: var(--dp-spacing-sm); + } +`; + //#endregion + //#region custom_components/hass_datapoints/src/lib/domain/aggregation/labels.ts + var BINARY_AGGREGATE_KINDS = [ + { + value: "transition_count", + label: "Count changes to a state" + }, + { + value: "all_changes", + label: "Count all state changes" + }, + { + value: "state_duration", + label: "Time in a state" + }, + { + value: "state_percentage", + label: "Percentage of time in a state" + } + ]; + var AGGREGATE_INTERVAL_OPTIONS = [ + { + value: "hour", + label: "Hourly" + }, + { + value: "day", + label: "Daily" + }, + { + value: "week", + label: "Weekly" + }, + { + value: "month", + label: "Monthly" + } + ]; + var STATE_LABELS = { + door: ["Open", "Closed"], + garage_door: ["Open", "Closed"], + window: ["Open", "Closed"], + opening: ["Open", "Closed"], + motion: ["Detected", "Clear"], + occupancy: ["Occupied", "Clear"], + lock: ["Unlocked", "Locked"] + }; + /** On/off options labelled for the entity's device class (e.g. Open/Closed). */ + function getBinaryStateOptions(deviceClass) { + const [on, off] = STATE_LABELS[deviceClass ?? ""] ?? ["On", "Off"]; + return [{ + value: "on", + label: on + }, { + value: "off", + label: off + }]; + } + function getBinaryAggregateKind(definition) { + if (definition.operation === "transition_count") return definition.states?.length ? "transition_count" : "all_changes"; + return definition.operation === "state_percentage" ? "state_percentage" : "state_duration"; + } + /** Apply a user-facing kind to a definition, keeping states consistent. */ + function applyBinaryAggregateKind(definition, kind, defaultState = "on") { + const states = definition.states?.length ? definition.states : [defaultState]; + if (kind === "all_changes") return { + ...definition, + operation: "transition_count", + states: [] + }; + const operation = kind; + const mode = operation === "state_percentage" ? "interval" : definition.mode; + return { + ...definition, + operation, + mode, + states + }; + } + function createBinaryAggregate(existingIds, defaultState = "on") { + let id; + do + id = `agg_${Math.random().toString(36).slice(2, 8)}`; + while (existingIds.includes(id)); + return { + id, + operation: "transition_count", + interval: "day", + mode: "interval", + states: [defaultState], + resetOnZoom: false + }; + } + //#endregion + //#region custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts + var _AnalysisAggregateGroup; + var _deviceClass_accessor_storage = /* @__PURE__ */ new WeakMap(); + var AnalysisAggregateGroup = (_AnalysisAggregateGroup = class AnalysisAggregateGroup extends AnalysisGroupMixin(i$2) { + constructor(..._args) { + super(..._args); + _classPrivateFieldInitSpec(this, _deviceClass_accessor_storage, ""); + } + get deviceClass() { + return _classPrivateFieldGet2(_deviceClass_accessor_storage, this); + } + set deviceClass(value) { + _classPrivateFieldSet2(_deviceClass_accessor_storage, this, value); + } + get _definitions() { + return this.analysis.aggregates ?? []; + } + _commit(next) { + this._emit("aggregates", next); + } + _update(id, patch) { + this._commit(this._definitions.map((d) => d.id === id ? patch(d) : d)); + } + _onAdd() { + const defaultState = getBinaryStateOptions(this.deviceClass)[0].value; + this._commit([...this._definitions, createBinaryAggregate(this._definitions.map((d) => d.id), defaultState)]); + } + _onRemove(id) { + this._commit(this._definitions.filter((d) => d.id !== id)); + } + _selectValue(e) { + return e.detail.value; + } + _renderDefinition(definition) { + const kind = getBinaryAggregateKind(definition); + const stateOptions = getBinaryStateOptions(this.deviceClass).map((o) => ({ + value: o.value, + label: msg(o.label) + })); + const showState = kind !== "all_changes"; + return b` +
+ this._update(definition.id, (d) => applyBinaryAggregateKind(d, this._selectValue(e), stateOptions[0].value))} + > + ${showState ? b` this._update(definition.id, (d) => ({ + ...d, + states: [this._selectValue(e)] + }))} + >` : A} + this._update(definition.id, (d) => ({ + ...d, + interval: this._selectValue(e) + }))} + > + +
+ `; + } + render() { + return b` +
${msg("Aggregate series")}
+
+ ${this._definitions.map((d) => this._renderDefinition(d))} +
+ + `; + } + }, _defineProperty(_AnalysisAggregateGroup, "styles", [sharedStyles, styles$39]), _AnalysisAggregateGroup); + __decorate([n$1({ + type: String, + attribute: "device-class" + })], AnalysisAggregateGroup.prototype, "deviceClass", null); + AnalysisAggregateGroup = __decorate([localized()], AnalysisAggregateGroup); + customElements.define("analysis-aggregate-group", AnalysisAggregateGroup); //#endregion //#region custom_components/hass_datapoints/src/molecules/target-row/target-row.ts var _TargetRow; @@ -23475,11 +23924,14 @@ ].map((c) => Math.max(0, Math.min(255, Math.round(c * (1 - mixStrength) + mixTarget * mixStrength)))); return `rgb(${mixed[0]}, ${mixed[1]}, ${mixed[2]})`; } + function hasAggregates(a) { + return (a.aggregates?.length ?? 0) > 0; + } function _hasConfiguredAnalysis(a) { - return a.show_trend_lines || a.show_summary_stats || a.show_rate_of_change || a.show_threshold_analysis || a.show_anomalies || a.show_delta_analysis || a.stepped_series || a.hide_source_series; + return a.show_trend_lines || a.show_summary_stats || a.show_rate_of_change || a.show_threshold_analysis || a.show_anomalies || a.show_delta_analysis || a.stepped_series || a.hide_source_series || hasAggregates(a); } function _hasActiveAnalysis(a, hasComparisonWindow) { - return a.show_trend_lines || a.show_summary_stats || a.show_rate_of_change || a.show_threshold_analysis || a.show_anomalies || a.show_delta_analysis && hasComparisonWindow; + return a.show_trend_lines || a.show_summary_stats || a.show_rate_of_change || a.show_threshold_analysis || a.show_anomalies || a.show_delta_analysis && hasComparisonWindow || hasAggregates(a); } var TargetRow = (_color_accessor_storage$1 = /* @__PURE__ */ new WeakMap(), _visible_accessor_storage$1 = /* @__PURE__ */ new WeakMap(), _analysis_accessor_storage = /* @__PURE__ */ new WeakMap(), _index_accessor_storage = /* @__PURE__ */ new WeakMap(), _entityId_accessor_storage = /* @__PURE__ */ new WeakMap(), _canShowDeltaAnalysis_accessor_storage$2 = /* @__PURE__ */ new WeakMap(), _stateObj_accessor_storage$1 = /* @__PURE__ */ new WeakMap(), _hass_accessor_storage$10 = /* @__PURE__ */ new WeakMap(), _comparisonWindows_accessor_storage$2 = /* @__PURE__ */ new WeakMap(), _computing_accessor_storage = /* @__PURE__ */ new WeakMap(), _computingProgress_accessor_storage = /* @__PURE__ */ new WeakMap(), _computingMethods_accessor_storage = /* @__PURE__ */ new WeakMap(), _rowCount_accessor_storage = /* @__PURE__ */ new WeakMap(), _allAnalysisSame_accessor_storage = /* @__PURE__ */ new WeakMap(), _hideDragHandle_accessor_storage = /* @__PURE__ */ new WeakMap(), _label_accessor_storage$8 = /* @__PURE__ */ new WeakMap(), _binaryDisplay_accessor_storage = /* @__PURE__ */ new WeakMap(), _showBinaryDisplay_accessor_storage$1 = /* @__PURE__ */ new WeakMap(), _TargetRow = class TargetRow extends i$2 { constructor(..._args) { @@ -23878,13 +24330,32 @@ @dp-change=${this._onBinaryDisplayChange} > + +
+ +
` : A} `; } - }, _defineProperty(_TargetRow, "styles", styles$39), _TargetRow); + }, _defineProperty(_TargetRow, "styles", styles$40), _TargetRow); __decorate([n$1({ type: String })], TargetRow.prototype, "color", null); __decorate([n$1({ type: Boolean })], TargetRow.prototype, "visible", null); __decorate([n$1({ type: Object })], TargetRow.prototype, "analysis", null); diff --git a/custom_components/hass_datapoints/src/lib/chart/chart-analysis.ts b/custom_components/hass_datapoints/src/lib/chart/chart-analysis.ts index d2f30c9e..040f08a0 100644 --- a/custom_components/hass_datapoints/src/lib/chart/chart-analysis.ts +++ b/custom_components/hass_datapoints/src/lib/chart/chart-analysis.ts @@ -208,7 +208,8 @@ export function seriesHasActiveAnalysis( analysis.show_rate_of_change || analysis.show_threshold_analysis || analysis.show_anomalies || - (analysis.show_delta_analysis && hasSelectedComparisonWindow) + (analysis.show_delta_analysis && hasSelectedComparisonWindow) || + (analysis.aggregates?.length ?? 0) > 0 ); } diff --git a/custom_components/hass_datapoints/src/lib/domain/__tests__/series-rows.spec.ts b/custom_components/hass_datapoints/src/lib/domain/__tests__/series-rows.spec.ts index e4706b06..0961ea29 100644 --- a/custom_components/hass_datapoints/src/lib/domain/__tests__/series-rows.spec.ts +++ b/custom_components/hass_datapoints/src/lib/domain/__tests__/series-rows.spec.ts @@ -430,3 +430,45 @@ describe("series-rows", () => { }); }); }); + +describe("series-rows aggregates", () => { + const aggregates = [ + { + id: "a1", + operation: "transition_count", + interval: "day", + mode: "interval", + states: ["on"], + resetOnZoom: false, + }, + ]; + + describe("GIVEN a row analysis", () => { + describe("WHEN the aggregates key is set", () => { + it("THEN the definitions are stored and survive row normalization", () => { + expect.assertions(2); + const next = computeNextAnalysis( + normalizeHistorySeriesAnalysis(null), + "aggregates", + aggregates + ); + expect(next?.aggregates).toStrictEqual(aggregates); + expect( + normalizeHistorySeriesAnalysis(JSON.parse(JSON.stringify(next))) + .aggregates + ).toStrictEqual(aggregates); + }); + }); + }); + + describe("GIVEN a legacy analysis without aggregates", () => { + describe("WHEN normalized", () => { + it("THEN no aggregates key is introduced", () => { + expect.assertions(1); + expect(normalizeHistorySeriesAnalysis({})).not.toHaveProperty( + "aggregates" + ); + }); + }); + }); +}); diff --git a/custom_components/hass_datapoints/src/lib/domain/aggregation/labels.ts b/custom_components/hass_datapoints/src/lib/domain/aggregation/labels.ts new file mode 100644 index 00000000..e06f12db --- /dev/null +++ b/custom_components/hass_datapoints/src/lib/domain/aggregation/labels.ts @@ -0,0 +1,104 @@ +import type { + AggregateDefinition, + AggregateInterval, +} from "@/lib/domain/aggregation/types"; + +export interface AggregateStateOption { + /** Canonical HA state value, stored in the definition. */ + value: string; + /** English label; localize with `msg()` at render time. */ + label: string; +} + +/** Choices a user makes for a binary row; maps onto an operation + states. */ +export type BinaryAggregateKind = + | "transition_count" + | "all_changes" + | "state_duration" + | "state_percentage"; + +export const BINARY_AGGREGATE_KINDS: { + value: BinaryAggregateKind; + label: string; +}[] = [ + { value: "transition_count", label: "Count changes to a state" }, + { value: "all_changes", label: "Count all state changes" }, + { value: "state_duration", label: "Time in a state" }, + { value: "state_percentage", label: "Percentage of time in a state" }, +]; + +export const AGGREGATE_INTERVAL_OPTIONS: { + value: AggregateInterval; + label: string; +}[] = [ + { value: "hour", label: "Hourly" }, + { value: "day", label: "Daily" }, + { value: "week", label: "Weekly" }, + { value: "month", label: "Monthly" }, +]; + +const STATE_LABELS: Record = { + door: ["Open", "Closed"], + garage_door: ["Open", "Closed"], + window: ["Open", "Closed"], + opening: ["Open", "Closed"], + motion: ["Detected", "Clear"], + occupancy: ["Occupied", "Clear"], + lock: ["Unlocked", "Locked"], +}; + +/** On/off options labelled for the entity's device class (e.g. Open/Closed). */ +export function getBinaryStateOptions( + deviceClass?: string +): AggregateStateOption[] { + const [on, off] = STATE_LABELS[deviceClass ?? ""] ?? ["On", "Off"]; + return [ + { value: "on", label: on }, + { value: "off", label: off }, + ]; +} + +export function getBinaryAggregateKind( + definition: AggregateDefinition +): BinaryAggregateKind { + if (definition.operation === "transition_count") { + return definition.states?.length ? "transition_count" : "all_changes"; + } + return definition.operation === "state_percentage" + ? "state_percentage" + : "state_duration"; +} + +/** Apply a user-facing kind to a definition, keeping states consistent. */ +export function applyBinaryAggregateKind( + definition: AggregateDefinition, + kind: BinaryAggregateKind, + defaultState = "on" +): AggregateDefinition { + const states = definition.states?.length ? definition.states : [defaultState]; + if (kind === "all_changes") { + return { ...definition, operation: "transition_count", states: [] }; + } + const operation = kind; + // Cumulative is only valid for counts and durations. + const mode = operation === "state_percentage" ? "interval" : definition.mode; + return { ...definition, operation, mode, states }; +} + +export function createBinaryAggregate( + existingIds: string[], + defaultState = "on" +): AggregateDefinition { + let id: string; + do { + id = `agg_${Math.random().toString(36).slice(2, 8)}`; + } while (existingIds.includes(id)); + return { + id, + operation: "transition_count", + interval: "day", + mode: "interval", + states: [defaultState], + resetOnZoom: false, + }; +} diff --git a/custom_components/hass_datapoints/src/lib/domain/history-series.ts b/custom_components/hass_datapoints/src/lib/domain/history-series.ts index d2bf1657..b9d00d5b 100644 --- a/custom_components/hass_datapoints/src/lib/domain/history-series.ts +++ b/custom_components/hass_datapoints/src/lib/domain/history-series.ts @@ -310,7 +310,8 @@ export function historySeriesRowHasConfiguredAnalysis( analysis.show_anomalies || analysis.show_delta_analysis || analysis.stepped_series || - analysis.hide_source_series + analysis.hide_source_series || + (analysis.aggregates?.length ?? 0) > 0 ); } diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/__tests__/analysis-aggregate-group.spec.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/__tests__/analysis-aggregate-group.spec.ts new file mode 100644 index 00000000..08166b1f --- /dev/null +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/__tests__/analysis-aggregate-group.spec.ts @@ -0,0 +1,170 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import "../analysis-aggregate-group"; +import type { AggregateDefinition } from "@/lib/domain/aggregation/types"; +import type { NormalizedAnalysis } from "../../target-row/types"; + +type GroupElement = HTMLElement & { + analysis: NormalizedAnalysis; + entityId: string; + deviceClass: string; + updateComplete: Promise; +}; + +const definition = ( + overrides: Partial = {} +): AggregateDefinition => ({ + id: "a1", + operation: "transition_count", + interval: "day", + mode: "interval", + states: ["on"], + resetOnZoom: false, + ...overrides, +}); + +async function mount(aggregates: AggregateDefinition[], deviceClass = "door") { + const el = document.createElement("analysis-aggregate-group") as GroupElement; + el.analysis = { aggregates } as NormalizedAnalysis; + el.entityId = "binary_sensor.front_door"; + el.deviceClass = deviceClass; + document.body.appendChild(el); + await el.updateComplete; + return el; +} + +function selects(el: GroupElement) { + return [ + ...el.shadowRoot!.querySelectorAll("inline-select"), + ] as (HTMLElement & { + value: string; + options: { value: string; label: string }[]; + })[]; +} + +function choose(select: HTMLElement, value: string) { + select.dispatchEvent( + new CustomEvent("dp-change", { + detail: { type: "select", value }, + bubbles: true, + composed: true, + }) + ); +} + +describe("analysis-aggregate-group", () => { + let el: GroupElement; + afterEach(() => el?.remove()); + + describe("GIVEN no aggregates on a door sensor", () => { + describe("WHEN the add button is activated", () => { + it("THEN emits the full list with one new daily transition aggregate for the open state", async () => { + expect.assertions(4); + el = await mount([]); + const handler = vi.fn(); + el.addEventListener("dp-group-analysis-change", handler); + (el.shadowRoot!.querySelector(".add") as HTMLButtonElement).click(); + const { key, value, entityId } = handler.mock.calls[0][0].detail; + expect(key).toBe("aggregates"); + expect(entityId).toBe("binary_sensor.front_door"); + expect(value).toHaveLength(1); + expect(value[0]).toMatchObject({ + operation: "transition_count", + interval: "day", + states: ["on"], + }); + }); + }); + }); + + describe("GIVEN an aggregate on a door sensor", () => { + describe("WHEN rendered", () => { + it("THEN state choices use device labels but store canonical values", async () => { + expect.assertions(2); + el = await mount([definition()]); + const stateSelect = selects(el)[1]; + expect(stateSelect.options.map((o) => o.label)).toStrictEqual([ + "Open", + "Closed", + ]); + expect(stateSelect.options.map((o) => o.value)).toStrictEqual([ + "on", + "off", + ]); + }); + }); + + describe("WHEN the state is changed to Closed", () => { + it("THEN emits the stored value off", async () => { + expect.assertions(1); + el = await mount([definition()]); + const handler = vi.fn(); + el.addEventListener("dp-group-analysis-change", handler); + choose(selects(el)[1], "off"); + expect(handler.mock.calls[0][0].detail.value[0].states).toStrictEqual([ + "off", + ]); + }); + }); + + describe("WHEN the operation is changed to all state changes", () => { + it("THEN states are cleared and the state selector is hidden", async () => { + expect.assertions(2); + el = await mount([definition()]); + const handler = vi.fn(); + el.addEventListener("dp-group-analysis-change", handler); + choose(selects(el)[0], "all_changes"); + expect(handler.mock.calls[0][0].detail.value[0]).toMatchObject({ + operation: "transition_count", + states: [], + }); + el.analysis = { + aggregates: handler.mock.calls[0][0].detail.value, + } as NormalizedAnalysis; + await el.updateComplete; + expect(selects(el)).toHaveLength(2); + }); + }); + + describe("WHEN the operation is changed to percentage of time", () => { + it("THEN a cumulative aggregate falls back to per-interval mode", async () => { + expect.assertions(1); + el = await mount([definition({ mode: "cumulative" })]); + const handler = vi.fn(); + el.addEventListener("dp-group-analysis-change", handler); + choose(selects(el)[0], "state_percentage"); + expect(handler.mock.calls[0][0].detail.value[0]).toMatchObject({ + operation: "state_percentage", + mode: "interval", + }); + }); + }); + + describe("WHEN the interval is changed", () => { + it("THEN only that definition changes", async () => { + expect.assertions(2); + el = await mount([definition(), definition({ id: "a2" })]); + const handler = vi.fn(); + el.addEventListener("dp-group-analysis-change", handler); + choose(selects(el)[2], "month"); + const next = handler.mock.calls[0][0].detail.value; + expect(next[0].interval).toBe("month"); + expect(next[1].interval).toBe("day"); + }); + }); + + describe("WHEN the remove button is activated", () => { + it("THEN emits the list without that definition", async () => { + expect.assertions(1); + el = await mount([definition(), definition({ id: "a2" })]); + const handler = vi.fn(); + el.addEventListener("dp-group-analysis-change", handler); + (el.shadowRoot!.querySelector(".remove") as HTMLButtonElement).click(); + expect( + handler.mock.calls[0][0].detail.value.map( + (d: AggregateDefinition) => d.id + ) + ).toStrictEqual(["a2"]); + }); + }); + }); +}); diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.styles.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.styles.ts new file mode 100644 index 00000000..983c7d4b --- /dev/null +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.styles.ts @@ -0,0 +1,57 @@ +import { css } from "lit"; + +export const styles = css` + .heading { + font-size: 0.84rem; + font-weight: 600; + color: var(--primary-text-color); + margin-bottom: var(--dp-spacing-xs); + } + + .list { + display: grid; + gap: var(--dp-spacing-sm); + } + + .definition { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--dp-spacing-sm); + padding: var(--dp-spacing-sm); + border-radius: 10px; + border: 1px solid + color-mix( + in srgb, + var(--divider-color, rgba(0, 0, 0, 0.12)) 88%, + transparent + ); + } + + .button { + display: inline-flex; + align-items: center; + gap: var(--dp-spacing-xs); + padding: var(--dp-spacing-xs) var(--dp-spacing-sm); + border-radius: 8px; + border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.12)); + background: transparent; + color: var(--primary-text-color); + font: inherit; + font-size: 0.8rem; + cursor: pointer; + } + + .button:focus-visible { + outline: 2px solid var(--primary-color, #03a9f4); + outline-offset: 2px; + } + + .remove { + margin-left: auto; + } + + .add { + margin-top: var(--dp-spacing-sm); + } +`; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts new file mode 100644 index 00000000..86e69a26 --- /dev/null +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts @@ -0,0 +1,143 @@ +import { LitElement, html, nothing } from "lit"; +import { property } from "lit/decorators.js"; +import { localized, msg } from "@/lib/i18n/localize"; + +import { AnalysisGroupMixin } from "../analysis-group-shared/analysis-group.mixin"; +import { sharedStyles } from "../analysis-group-shared/analysis-group-shared.styles"; +import { styles } from "./analysis-aggregate-group.styles"; +import { + AGGREGATE_INTERVAL_OPTIONS, + BINARY_AGGREGATE_KINDS, + type BinaryAggregateKind, + applyBinaryAggregateKind, + createBinaryAggregate, + getBinaryAggregateKind, + getBinaryStateOptions, +} from "@/lib/domain/aggregation/labels"; +import type { + AggregateDefinition, + AggregateInterval, +} from "@/lib/domain/aggregation/types"; +import "@/atoms/form/inline-select/inline-select"; + +/** + * Add, edit and remove aggregate series definitions for a binary row. + * @fires dp-group-analysis-change - `{ entityId, key: "aggregates", value: AggregateDefinition[] }` — the full next list of definitions + */ +@localized() +export class AnalysisAggregateGroup extends AnalysisGroupMixin(LitElement) { + /** HA device class of the source entity; selects Open/Closed style labels. */ + @property({ type: String, attribute: "device-class" }) + accessor deviceClass: string = ""; + + static styles = [sharedStyles, styles]; + + private get _definitions(): AggregateDefinition[] { + return this.analysis.aggregates ?? []; + } + + private _commit(next: AggregateDefinition[]) { + this._emit("aggregates", next); + } + + private _update( + id: string, + patch: (d: AggregateDefinition) => AggregateDefinition + ) { + this._commit(this._definitions.map((d) => (d.id === id ? patch(d) : d))); + } + + private _onAdd() { + const defaultState = getBinaryStateOptions(this.deviceClass)[0].value; + this._commit([ + ...this._definitions, + createBinaryAggregate( + this._definitions.map((d) => d.id), + defaultState + ), + ]); + } + + private _onRemove(id: string) { + this._commit(this._definitions.filter((d) => d.id !== id)); + } + + private _selectValue(e: Event): string { + return (e as CustomEvent<{ type: "select"; value: string }>).detail.value; + } + + private _renderDefinition(definition: AggregateDefinition) { + const kind = getBinaryAggregateKind(definition); + const stateOptions = getBinaryStateOptions(this.deviceClass).map((o) => ({ + value: o.value, + label: msg(o.label), + })); + const showState = kind !== "all_changes"; + return html` +
+ + this._update(definition.id, (d) => + applyBinaryAggregateKind( + d, + this._selectValue(e) as BinaryAggregateKind, + stateOptions[0].value + ) + )} + > + ${showState + ? html` + this._update(definition.id, (d) => ({ + ...d, + states: [this._selectValue(e)], + }))} + >` + : nothing} + + this._update(definition.id, (d) => ({ + ...d, + interval: this._selectValue(e) as AggregateInterval, + }))} + > + +
+ `; + } + + render() { + return html` +
${msg("Aggregate series")}
+
+ ${this._definitions.map((d) => this._renderDefinition(d))} +
+ + `; + } +} + +customElements.define("analysis-aggregate-group", AnalysisAggregateGroup); diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts new file mode 100644 index 00000000..ddda4e0a --- /dev/null +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts @@ -0,0 +1,27 @@ +import type { ComponentTranslations } from "@/lib/i18n/types"; + +export const translations: ComponentTranslations = { + "Aggregate series": "Aggregierte Serien", + "Add aggregate": "Aggregat hinzufügen", + "Remove aggregate": "Aggregat entfernen", + "Aggregate operation": "Aggregatoperation", + "Aggregate state": "Aggregatzustand", + "Aggregate interval": "Aggregatintervall", + "Count changes to a state": "Wechsel in einen Zustand zählen", + "Count all state changes": "Alle Zustandswechsel zählen", + "Time in a state": "Zeit in einem Zustand", + "Percentage of time in a state": "Zeitanteil in einem Zustand in Prozent", + Hourly: "Stündlich", + Daily: "Täglich", + Weekly: "Wöchentlich", + Monthly: "Monatlich", + On: "An", + Off: "Aus", + Open: "Offen", + Closed: "Geschlossen", + Detected: "Erkannt", + Clear: "Frei", + Occupied: "Belegt", + Unlocked: "Entsperrt", + Locked: "Gesperrt", +}; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts new file mode 100644 index 00000000..cdddf10f --- /dev/null +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts @@ -0,0 +1,27 @@ +import type { ComponentTranslations } from "@/lib/i18n/types"; + +export const translations: ComponentTranslations = { + "Aggregate series": "Series agregadas", + "Add aggregate": "Añadir agregado", + "Remove aggregate": "Quitar agregado", + "Aggregate operation": "Operación del agregado", + "Aggregate state": "Estado del agregado", + "Aggregate interval": "Intervalo del agregado", + "Count changes to a state": "Contar cambios a un estado", + "Count all state changes": "Contar todos los cambios de estado", + "Time in a state": "Tiempo en un estado", + "Percentage of time in a state": "Porcentaje de tiempo en un estado", + Hourly: "Por hora", + Daily: "Diario", + Weekly: "Semanal", + Monthly: "Mensual", + On: "Encendido", + Off: "Apagado", + Open: "Abierto", + Closed: "Cerrado", + Detected: "Detectado", + Clear: "Libre", + Occupied: "Ocupado", + Unlocked: "Desbloqueado", + Locked: "Bloqueado", +}; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts new file mode 100644 index 00000000..9182b8f4 --- /dev/null +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts @@ -0,0 +1,27 @@ +import type { ComponentTranslations } from "@/lib/i18n/types"; + +export const translations: ComponentTranslations = { + "Aggregate series": "Koosteet", + "Add aggregate": "Lisää koostesarja", + "Remove aggregate": "Poista koostesarja", + "Aggregate operation": "Koosteen toiminto", + "Aggregate state": "Koosteen tila", + "Aggregate interval": "Koosteen jakso", + "Count changes to a state": "Laske muutokset tilaan", + "Count all state changes": "Laske kaikki tilamuutokset", + "Time in a state": "Aika tilassa", + "Percentage of time in a state": "Tilassa vietetyn ajan osuus prosentteina", + Hourly: "Tunneittain", + Daily: "Päivittäin", + Weekly: "Viikoittain", + Monthly: "Kuukausittain", + On: "Päällä", + Off: "Pois", + Open: "Auki", + Closed: "Kiinni", + Detected: "Havaittu", + Clear: "Vapaa", + Occupied: "Varattu", + Unlocked: "Avattu", + Locked: "Lukittu", +}; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts new file mode 100644 index 00000000..331df8e4 --- /dev/null +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts @@ -0,0 +1,27 @@ +import type { ComponentTranslations } from "@/lib/i18n/types"; + +export const translations: ComponentTranslations = { + "Aggregate series": "Séries agrégées", + "Add aggregate": "Ajouter un agrégat", + "Remove aggregate": "Supprimer l'agrégat", + "Aggregate operation": "Opération de l'agrégat", + "Aggregate state": "État de l'agrégat", + "Aggregate interval": "Intervalle de l'agrégat", + "Count changes to a state": "Compter les passages à un état", + "Count all state changes": "Compter tous les changements d'état", + "Time in a state": "Durée dans un état", + "Percentage of time in a state": "Pourcentage du temps dans un état", + Hourly: "Par heure", + Daily: "Par jour", + Weekly: "Par semaine", + Monthly: "Par mois", + On: "Allumé", + Off: "Éteint", + Open: "Ouvert", + Closed: "Fermé", + Detected: "Détecté", + Clear: "Libre", + Occupied: "Occupé", + Unlocked: "Déverrouillé", + Locked: "Verrouillé", +}; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts new file mode 100644 index 00000000..c4dc14cd --- /dev/null +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts @@ -0,0 +1,27 @@ +import type { ComponentTranslations } from "@/lib/i18n/types"; + +export const translations: ComponentTranslations = { + "Aggregate series": "Séries agregadas", + "Add aggregate": "Adicionar agregado", + "Remove aggregate": "Remover agregado", + "Aggregate operation": "Operação do agregado", + "Aggregate state": "Estado do agregado", + "Aggregate interval": "Intervalo do agregado", + "Count changes to a state": "Contar mudanças para um estado", + "Count all state changes": "Contar todas as mudanças de estado", + "Time in a state": "Tempo num estado", + "Percentage of time in a state": "Percentagem de tempo num estado", + Hourly: "Por hora", + Daily: "Diário", + Weekly: "Semanal", + Monthly: "Mensal", + On: "Ligado", + Off: "Desligado", + Open: "Aberto", + Closed: "Fechado", + Detected: "Detetado", + Clear: "Livre", + Occupied: "Ocupado", + Unlocked: "Desbloqueado", + Locked: "Bloqueado", +}; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts new file mode 100644 index 00000000..29ce062e --- /dev/null +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts @@ -0,0 +1,27 @@ +import type { ComponentTranslations } from "@/lib/i18n/types"; + +export const translations: ComponentTranslations = { + "Aggregate series": "聚合序列", + "Add aggregate": "添加聚合", + "Remove aggregate": "移除聚合", + "Aggregate operation": "聚合操作", + "Aggregate state": "聚合状态", + "Aggregate interval": "聚合间隔", + "Count changes to a state": "统计变为某状态的次数", + "Count all state changes": "统计所有状态变化次数", + "Time in a state": "处于某状态的时长", + "Percentage of time in a state": "处于某状态的时间百分比", + Hourly: "每小时", + Daily: "每天", + Weekly: "每周", + Monthly: "每月", + On: "开", + Off: "关", + Open: "打开", + Closed: "关闭", + Detected: "已检测到", + Clear: "无人", + Occupied: "有人", + Unlocked: "已解锁", + Locked: "已锁定", +}; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/stories/analysis-aggregate-group.stories.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/stories/analysis-aggregate-group.stories.ts new file mode 100644 index 00000000..6cc07063 --- /dev/null +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/stories/analysis-aggregate-group.stories.ts @@ -0,0 +1,64 @@ +import { html } from "lit"; +import { expect, userEvent } from "@storybook/test"; +import "../analysis-aggregate-group"; +import type { AggregateDefinition } from "@/lib/domain/aggregation/types"; + +export default { + title: "Molecules/Analysis/Aggregate Group", + component: "analysis-aggregate-group", + parameters: { + actions: { handles: ["dp-group-analysis-change"] }, + }, +}; + +const door: AggregateDefinition = { + id: "door-opens", + operation: "transition_count", + interval: "day", + mode: "interval", + states: ["on"], + resetOnZoom: false, +}; + +export const Empty = { + render: () => html` + + `, +}; + +export const DoorSensorWithAggregates = { + render: () => html` + + `, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const group = canvasElement.querySelector( + "analysis-aggregate-group" + ) as HTMLElement; + const events: CustomEvent[] = []; + group.addEventListener("dp-group-analysis-change", (e) => + events.push(e as CustomEvent) + ); + const add = group.shadowRoot!.querySelector(".add") as HTMLElement; + add.focus(); + await userEvent.keyboard("{Enter}"); + await expect(events[0].detail.value).toHaveLength(3); + }, +}; diff --git a/custom_components/hass_datapoints/src/molecules/target-row/__tests__/target-row-aggregates.spec.ts b/custom_components/hass_datapoints/src/molecules/target-row/__tests__/target-row-aggregates.spec.ts new file mode 100644 index 00000000..0a98d8b2 --- /dev/null +++ b/custom_components/hass_datapoints/src/molecules/target-row/__tests__/target-row-aggregates.spec.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { _hasActiveAnalysis, _hasConfiguredAnalysis } from "../target-row"; +import { normalizeHistorySeriesAnalysis } from "@/lib/domain/history-series"; +import type { NormalizedAnalysis } from "../types"; + +const withAggregate = (hide = false) => + normalizeHistorySeriesAnalysis({ + aggregates: [ + { + id: "a1", + operation: "state_duration", + interval: "day", + states: ["on"], + }, + ], + hide_source_series: hide, + }) as unknown as NormalizedAnalysis; + +describe("target-row aggregates", () => { + let el: HTMLElement | undefined; + afterEach(() => el?.remove()); + + describe("GIVEN a row with an aggregate and no other analysis", () => { + describe("WHEN checking analysis state", () => { + it("THEN it is configured and active so Hide source series is enabled", () => { + expect.assertions(2); + expect(_hasConfiguredAnalysis(withAggregate())).toBe(true); + expect(_hasActiveAnalysis(withAggregate(), false)).toBe(true); + }); + }); + }); + + describe("GIVEN a row whose last aggregate was removed", () => { + describe("WHEN checking analysis state", () => { + it("THEN it is inactive but keeps the stored hide preference", () => { + expect.assertions(2); + const analysis = normalizeHistorySeriesAnalysis({ + aggregates: [], + hide_source_series: true, + }) as unknown as NormalizedAnalysis; + expect(_hasActiveAnalysis(analysis, false)).toBe(false); + expect(analysis.hide_source_series).toBe(true); + }); + }); + }); + + describe("GIVEN an expanded binary row with display options enabled", () => { + describe("WHEN rendered", () => { + it("THEN the aggregate group and Hide source series are offered", async () => { + expect.assertions(2); + el = document.createElement("target-row"); + Object.assign(el, { + entityId: "binary_sensor.front_door", + showBinaryDisplay: true, + analysis: { ...withAggregate(), expanded: true }, + }); + document.body.appendChild(el); + await (el as HTMLElement & { updateComplete: Promise }) + .updateComplete; + expect( + el.shadowRoot!.querySelector("analysis-aggregate-group") + ).not.toBeNull(); + expect(el.shadowRoot!.textContent).toContain("Hide source series"); + }); + }); + }); +}); diff --git a/custom_components/hass_datapoints/src/molecules/target-row/target-row.ts b/custom_components/hass_datapoints/src/molecules/target-row/target-row.ts index 2b1e8f50..06d439e5 100644 --- a/custom_components/hass_datapoints/src/molecules/target-row/target-row.ts +++ b/custom_components/hass_datapoints/src/molecules/target-row/target-row.ts @@ -28,6 +28,7 @@ import "@/molecules/analysis-rate-group/analysis-rate-group"; import "@/molecules/analysis-threshold-group/analysis-threshold-group"; import "@/molecules/analysis-anomaly-group/analysis-anomaly-group"; import "@/molecules/analysis-delta-group/analysis-delta-group"; +import "@/molecules/analysis-aggregate-group/analysis-aggregate-group"; export type { NormalizedAnalysis, ComparisonWindow, HassEntityState }; @@ -60,6 +61,10 @@ export function deriveSwatchIconColor(color: string): string { return `rgb(${mixed[0]}, ${mixed[1]}, ${mixed[2]})`; } +function hasAggregates(a: NormalizedAnalysis): boolean { + return (a.aggregates?.length ?? 0) > 0; +} + export function _hasConfiguredAnalysis(a: NormalizedAnalysis): boolean { return ( a.show_trend_lines || @@ -69,7 +74,8 @@ export function _hasConfiguredAnalysis(a: NormalizedAnalysis): boolean { a.show_anomalies || a.show_delta_analysis || a.stepped_series || - a.hide_source_series + a.hide_source_series || + hasAggregates(a) ); } @@ -83,7 +89,8 @@ export function _hasActiveAnalysis( a.show_rate_of_change || a.show_threshold_analysis || a.show_anomalies || - (a.show_delta_analysis && hasComparisonWindow) + (a.show_delta_analysis && hasComparisonWindow) || + hasAggregates(a) ); } @@ -506,6 +513,30 @@ export class TargetRow extends LitElement { @dp-change=${this._onBinaryDisplayChange} > + +
+ +
` diff --git a/custom_components/hass_datapoints/src/panels/datapoints/__tests__/datapoints-url-sync.spec.ts b/custom_components/hass_datapoints/src/panels/datapoints/__tests__/datapoints-url-sync.spec.ts index 716be21a..5b2f829b 100644 --- a/custom_components/hass_datapoints/src/panels/datapoints/__tests__/datapoints-url-sync.spec.ts +++ b/custom_components/hass_datapoints/src/panels/datapoints/__tests__/datapoints-url-sync.spec.ts @@ -108,3 +108,69 @@ describe("HassDatapointsHistoryPanel URL sync", () => { }); }); }); + +describe("datapoints panel aggregate series", () => { + const agg = (id: string, operation: string) => ({ + id, + operation, + interval: "day", + mode: "interval", + states: ["on"], + resetOnZoom: false, + }); + const makePanel = (analysis: unknown) => ({ + _seriesRows: [ + { + entity_id: "binary_sensor.door", + color: "#03a9f4", + visible: true, + analysis: normalizeHistorySeriesAnalysis(analysis), + }, + ], + _saveSessionState: vi.fn(), + _updateUrl: vi.fn(), + _renderTargetRows: vi.fn(), + _renderSidebarOptions: vi.fn(), + _renderContent: vi.fn(), + }); + const setAggregates = (panel: ReturnType, value: unknown) => + HassDatapointsHistoryPanel.prototype._setSeriesAnalysisOption.call( + panel, + "binary_sensor.door", + "aggregates", + value + ); + + describe("GIVEN a binary row with no aggregates", () => { + describe("WHEN two independent aggregates are added", () => { + it("THEN both are stored, the session saved and the chart re-rendered", () => { + expect.assertions(3); + const panel = makePanel(null); + setAggregates(panel, [ + agg("a1", "transition_count"), + agg("a2", "state_duration"), + ]); + expect( + panel._seriesRows[0].analysis.aggregates?.map((d) => d.operation) + ).toStrictEqual(["transition_count", "state_duration"]); + expect(panel._saveSessionState).toHaveBeenCalledTimes(1); + expect(panel._renderContent).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe("GIVEN a row hiding its source behind an aggregate", () => { + describe("WHEN the last aggregate is removed", () => { + it("THEN the stored hide preference is retained", () => { + expect.assertions(2); + const panel = makePanel({ + aggregates: [agg("a1", "transition_count")], + hide_source_series: true, + }); + setAggregates(panel, []); + expect(panel._seriesRows[0].analysis.aggregates).toStrictEqual([]); + expect(panel._seriesRows[0].analysis.hide_source_series).toBe(true); + }); + }); + }); +}); From 739659fc1c21e69845430a9a94655ff496f1a69c Mon Sep 17 00:00:00 2001 From: Ollie Date: Sat, 24 Oct 2026 22:31:08 +0300 Subject: [PATCH 06/17] feat(aggregation): render binary aggregates as bars from loaded history Refs #91 Co-Authored-By: Claude Sonnet 5.5 --- .../hass_datapoints/hass-datapoints-cards.js | 701 +++++++++++++++--- .../history/history-chart/history-chart.ts | 101 ++- .../stories/history-chart.stories.ts | 83 +++ .../chart/__tests__/chart-aggregates.spec.ts | 100 +++ .../src/lib/chart/chart-aggregates.ts | 154 ++++ .../src/lib/chart/chart-interaction.ts | 35 +- 6 files changed, 1056 insertions(+), 118 deletions(-) create mode 100644 custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts create mode 100644 custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts diff --git a/custom_components/hass_datapoints/hass-datapoints-cards.js b/custom_components/hass_datapoints/hass-datapoints-cards.js index 3b728e64..c72f1dcf 100644 --- a/custom_components/hass_datapoints/hass-datapoints-cards.js +++ b/custom_components/hass_datapoints/hass-datapoints-cards.js @@ -8645,6 +8645,541 @@ min-height: 0; } `; + //#endregion + //#region custom_components/hass_datapoints/src/lib/domain/aggregation/calendar.ts + var HOUR_MS$2 = 36e5; + var formatters = /* @__PURE__ */ new Map(); + function getFormatter(timeZone) { + let formatter = formatters.get(timeZone); + if (!formatter) { + formatter = new Intl.DateTimeFormat("en-US", { + timeZone, + hourCycle: "h23", + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric" + }); + formatters.set(timeZone, formatter); + } + return formatter; + } + function getLocalParts(ts, timeZone) { + const values = {}; + for (const part of getFormatter(timeZone).formatToParts(ts)) if (part.type !== "literal") values[part.type] = Number(part.value); + return { + year: values.year, + month: values.month, + day: values.day, + minute: values.minute, + second: values.second, + weekday: new Date(Date.UTC(values.year, values.month - 1, values.day)).getUTCDay() + }; + } + function getOffsetMs(ts, timeZone) { + const whole = Math.floor(ts / 1e3) * 1e3; + const values = {}; + for (const part of getFormatter(timeZone).formatToParts(whole)) if (part.type !== "literal") values[part.type] = Number(part.value); + return Date.UTC(values.year, values.month - 1, values.day, values.hour, values.minute, values.second) - whole; + } + /** Local midnight (in `timeZone`) of a possibly un-normalised calendar date. */ + function localMidnight(year, month, day, timeZone) { + const wall = Date.UTC(year, month - 1, day); + return wall - getOffsetMs(wall - getOffsetMs(wall, timeZone), timeZone); + } + function getPeriodStart(ts, interval, timeZone) { + const p = getLocalParts(ts, timeZone); + if (interval === "hour") { + const ms = (ts % 1e3 + 1e3) % 1e3; + return ts - ((p.minute * 60 + p.second) * 1e3 + ms); + } + if (interval === "day") return localMidnight(p.year, p.month, p.day, timeZone); + if (interval === "week") return localMidnight(p.year, p.month, p.day - (p.weekday + 6) % 7, timeZone); + return localMidnight(p.year, p.month, 1, timeZone); + } + function getNextPeriodStart(periodStart, interval, timeZone) { + if (interval === "hour") return periodStart + HOUR_MS$2; + const p = getLocalParts(periodStart + HOUR_MS$2 * 2, timeZone); + if (interval === "day") return localMidnight(p.year, p.month, p.day + 1, timeZone); + if (interval === "week") return localMidnight(p.year, p.month, p.day + 7, timeZone); + return localMidnight(p.year, p.month + 1, 1, timeZone); + } + /** + * Calendar periods overlapping `range`, clipped to it. Periods cut by the + * range edge are flagged partial; the full period bounds are preserved. + */ + function getPeriods(range, interval, timeZone) { + const periods = []; + if (!(range.end > range.start)) return periods; + let periodStart = getPeriodStart(range.start, interval, timeZone); + while (periodStart < range.end) { + const periodEnd = getNextPeriodStart(periodStart, interval, timeZone); + const start = Math.max(periodStart, range.start); + const end = Math.min(periodEnd, range.end); + periods.push({ + periodStart, + periodEnd, + start, + end, + partial: start !== periodStart || end !== periodEnd + }); + periodStart = periodEnd; + } + return periods; + } + //#endregion + //#region custom_components/hass_datapoints/src/lib/domain/aggregation/calculate.ts + var MINUTE_MS$1 = 6e4; + var UNKNOWN_STATES$1 = /* @__PURE__ */ new Set(["unknown", "unavailable"]); + var CUMULATIVE_OPERATIONS = /* @__PURE__ */ new Set([ + "consumption", + "transition_count", + "state_duration", + "event_count" + ]); + function isKnownState(state) { + return typeof state === "string" && !UNKNOWN_STATES$1.has(state); + } + function overlap(a, b) { + return Math.max(0, Math.min(a.end, b.end) - Math.max(a.start, b.start)); + } + function inPeriod(time, period) { + return time >= period.start && time < period.end; + } + function sortSamples(samples) { + return [...samples].sort((a, b) => a.time - b.time); + } + function knownDurationOf(period, coverage) { + if (!coverage) return period.end - period.start; + return coverage.reduce((sum, c) => sum + overlap(period, c), 0); + } + /** Known-state spans over `range`, seeded by the last sample at/before start. */ + function buildStateSpans(samples, range, coverage) { + const stateful = sortSamples(samples).filter((s) => typeof s.state === "string"); + const points = []; + let seed; + for (const s of stateful) if (s.time <= range.start) seed = { + time: range.start, + state: s.state + }; + else if (s.time < range.end) points.push({ + time: s.time, + state: s.state + }); + if (seed) points.unshift(seed); + const spans = []; + points.forEach((point, i) => { + if (!isKnownState(point.state)) return; + const end = i + 1 < points.length ? points[i + 1].time : range.end; + const span = { + start: point.time, + end, + state: point.state + }; + if (!coverage) { + spans.push(span); + return; + } + for (const c of coverage) { + const start = Math.max(span.start, c.start); + const clippedEnd = Math.min(span.end, c.end); + if (clippedEnd > start) spans.push({ + start, + end: clippedEnd, + state: span.state + }); + } + }); + return spans; + } + function median(values) { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; + } + function calculateMeasurement(input, periods) { + const readings = sortSamples(input.samples).filter((s) => !s.boundary && typeof s.value === "number" && Number.isFinite(s.value)); + return periods.map((period) => { + const values = readings.filter((s) => inPeriod(s.time, period)).map((s) => s.value); + if (!values.length) return { + value: null, + reasons: ["no-data"] + }; + const op = input.definition.operation; + let value; + if (op === "min") value = Math.min(...values); + else if (op === "max") value = Math.max(...values); + else if (op === "median") value = median(values); + else if (op === "first") value = values[0]; + else if (op === "last") value = values[values.length - 1]; + else value = values.reduce((sum, v) => sum + v, 0) / values.length; + return { + value, + reasons: [] + }; + }); + } + function calculateTransitions(input, periods) { + const selected = input.definition.states ?? []; + const stateful = sortSamples(input.samples).filter((s) => typeof s.state === "string"); + const times = []; + stateful.forEach((curr, i) => { + const prev = stateful[i - 1]; + if (!prev || curr.time <= input.range.start || curr.time >= input.range.end || !isKnownState(prev.state) || !isKnownState(curr.state) || prev.state === curr.state || selected.length > 0 && !selected.includes(curr.state)) return; + times.push(curr.time); + }); + return periods.map((period) => ({ + value: times.filter((t) => inPeriod(t, period)).length, + reasons: [] + })); + } + function calculateDurations(input, periods, percentage) { + const selected = input.definition.states ?? []; + if (!selected.length) return periods.map(() => ({ + value: null, + reasons: ["no-state-selected"] + })); + const spans = buildStateSpans(input.samples, input.range, input.coverage); + return periods.map((period) => { + let known = 0; + let matching = 0; + for (const span of spans) { + const ms = overlap(period, span); + known += ms; + if (selected.includes(span.state)) matching += ms; + } + if (percentage) return known > 0 ? { + value: matching / known * 100, + reasons: [] + } : { + value: null, + reasons: ["no-known-time"] + }; + return known > 0 ? { + value: matching / MINUTE_MS$1, + reasons: [] + } : { + value: null, + reasons: ["no-known-time"] + }; + }); + } + function calculateEvents(input, periods) { + const types = input.definition.eventTypes ?? []; + const times = input.samples.filter((s) => !s.boundary && typeof s.eventType === "string" && (types.length === 0 || types.includes(s.eventType))).map((s) => s.eventTime ?? s.time); + return periods.map((period) => ({ + value: times.filter((t) => inPeriod(t, period)).length, + reasons: [] + })); + } + function calculateConsumption(input, periods) { + const readings = sortSamples(input.samples).filter((s) => { + const v = s.sum ?? s.value; + return typeof v === "number" && Number.isFinite(v); + }); + const deltas = []; + readings.forEach((curr, i) => { + const prev = readings[i - 1]; + if (!prev || curr.time <= input.range.start || curr.time >= input.range.end) return; + const usesSum = typeof curr.sum === "number" && typeof prev.sum === "number"; + const currValue = usesSum ? curr.sum : curr.value; + const delta = currValue - (usesSum ? prev.sum : prev.value); + if (usesSum || delta >= 0) deltas.push({ + time: curr.time, + value: delta + }); + else if (input.stateClass === "total_increasing" || curr.lastReset !== void 0 && curr.lastReset !== prev.lastReset) deltas.push({ + time: curr.time, + value: currValue, + reason: "reset" + }); + else if (input.stateClass === "total") deltas.push({ + time: curr.time, + value: delta + }); + else deltas.push({ + time: curr.time, + value: 0, + reason: "ambiguous-reset" + }); + }); + return periods.map((period) => { + const inside = deltas.filter((d) => inPeriod(d.time, period)); + if (!inside.length) return { + value: null, + reasons: ["no-data"] + }; + return { + value: inside.reduce((sum, d) => sum + d.value, 0), + reasons: [...new Set(inside.flatMap((d) => d.reason ? [d.reason] : []))] + }; + }); + } + function toCumulative(operation, periods, buckets) { + const startsAtZeroActivity = operation !== "consumption"; + let running = 0; + let started = false; + const keptBuckets = []; + const keptPeriods = []; + buckets.forEach((bucket, i) => { + if (!started) { + started = bucket.value !== null && (!startsAtZeroActivity || bucket.value > 0); + if (!started) return; + } + running += bucket.value ?? 0; + keptBuckets.push({ + value: running, + reasons: bucket.reasons + }); + keptPeriods.push(periods[i]); + }); + return { + buckets: keptBuckets, + periods: keptPeriods, + started + }; + } + function calculateAggregate(input) { + const { definition } = input; + const cumulative = definition.mode === "cumulative" && CUMULATIVE_OPERATIONS.has(definition.operation); + if (cumulative && definition.resetOnZoom && input.zoom) { + const start = Math.max(input.range.start, input.zoom.start); + const end = Math.min(input.range.end, input.zoom.end); + if (end > start) return calculateAggregate({ + ...input, + zoom: void 0, + range: { + start, + end + } + }); + } + let periods = getPeriods(input.range, definition.interval, input.timeZone); + const op = definition.operation; + let buckets; + let unit = input.unit ?? ""; + if (op === "transition_count") { + buckets = calculateTransitions(input, periods); + unit = ""; + } else if (op === "state_duration") { + buckets = calculateDurations(input, periods, false); + unit = "min"; + } else if (op === "state_percentage") { + buckets = calculateDurations(input, periods, true); + unit = "%"; + } else if (op === "event_count") { + buckets = calculateEvents(input, periods); + unit = ""; + } else if (op === "consumption") buckets = calculateConsumption(input, periods); + else buckets = calculateMeasurement(input, periods); + let started = true; + if (cumulative) { + const result = toCumulative(op, periods, buckets); + ({buckets, periods, started} = result); + } + const points = periods.map((period, i) => { + const totalDuration = period.end - period.start; + const knownDuration = knownDurationOf(period, input.coverage); + const reasons = [...buckets[i].reasons]; + const incomplete = knownDuration < totalDuration; + if (incomplete) reasons.push("incomplete-coverage"); + return { + ...period, + time: period.start, + value: knownDuration === 0 ? null : buckets[i].value, + incomplete: incomplete || reasons.includes("no-data"), + knownDuration, + totalDuration, + reasons + }; + }); + const isBar = !cumulative && ![ + "mean", + "min", + "max", + "median", + "first", + "last" + ].includes(op); + return { + points, + unit, + presentation: isBar ? "bar" : "line", + baseline: cumulative && started && points.length ? 0 : null + }; + } + //#endregion + //#region custom_components/hass_datapoints/src/lib/domain/aggregation/labels.ts + var BINARY_AGGREGATE_KINDS = [ + { + value: "transition_count", + label: "Count changes to a state" + }, + { + value: "all_changes", + label: "Count all state changes" + }, + { + value: "state_duration", + label: "Time in a state" + }, + { + value: "state_percentage", + label: "Percentage of time in a state" + } + ]; + var AGGREGATE_INTERVAL_OPTIONS = [ + { + value: "hour", + label: "Hourly" + }, + { + value: "day", + label: "Daily" + }, + { + value: "week", + label: "Weekly" + }, + { + value: "month", + label: "Monthly" + } + ]; + var STATE_LABELS = { + door: ["Open", "Closed"], + garage_door: ["Open", "Closed"], + window: ["Open", "Closed"], + opening: ["Open", "Closed"], + motion: ["Detected", "Clear"], + occupancy: ["Occupied", "Clear"], + lock: ["Unlocked", "Locked"] + }; + /** On/off options labelled for the entity's device class (e.g. Open/Closed). */ + function getBinaryStateOptions(deviceClass) { + const [on, off] = STATE_LABELS[deviceClass ?? ""] ?? ["On", "Off"]; + return [{ + value: "on", + label: on + }, { + value: "off", + label: off + }]; + } + function getBinaryAggregateKind(definition) { + if (definition.operation === "transition_count") return definition.states?.length ? "transition_count" : "all_changes"; + return definition.operation === "state_percentage" ? "state_percentage" : "state_duration"; + } + /** Apply a user-facing kind to a definition, keeping states consistent. */ + function applyBinaryAggregateKind(definition, kind, defaultState = "on") { + const states = definition.states?.length ? definition.states : [defaultState]; + if (kind === "all_changes") return { + ...definition, + operation: "transition_count", + states: [] + }; + const operation = kind; + const mode = operation === "state_percentage" ? "interval" : definition.mode; + return { + ...definition, + operation, + mode, + states + }; + } + function createBinaryAggregate(existingIds, defaultState = "on") { + let id; + do + id = `agg_${Math.random().toString(36).slice(2, 8)}`; + while (existingIds.includes(id)); + return { + id, + operation: "transition_count", + interval: "day", + mode: "interval", + states: [defaultState], + resetOnZoom: false + }; + } + //#endregion + //#region custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts + var UNKNOWN_STATES = /* @__PURE__ */ new Set(["unknown", "unavailable"]); + function getAggregateSeriesId(entityId, definitionId) { + return `aggregate:${entityId}:${definitionId}`; + } + /** Spans where a known (not unknown/unavailable) state was recorded. */ + function buildCoverage(samples, range) { + const sorted = [...samples].sort((a, b) => a.time - b.time); + const coverage = []; + let seed; + const inRange = []; + for (const sample of sorted) if (sample.time <= range.start) seed = sample; + else if (sample.time < range.end) inRange.push(sample); + const points = seed ? [{ + ...seed, + time: range.start + }, ...inRange] : inRange; + points.forEach((point, i) => { + if (!point.state || UNKNOWN_STATES.has(point.state)) return; + const end = i + 1 < points.length ? points[i + 1].time : range.end; + if (end > point.time) coverage.push({ + start: point.time, + end + }); + }); + return coverage; + } + function describe(definition, stateLabel) { + const kind = getBinaryAggregateKind(definition); + const kindLabel = msg(BINARY_AGGREGATE_KINDS.find((k) => k.value === kind)?.label ?? kind); + return `${msg(AGGREGATE_INTERVAL_OPTIONS.find((o) => o.value === definition.interval)?.label ?? definition.interval)} · ${kindLabel}${kind === "all_changes" ? "" : ` (${stateLabel})`}`; + } + /** + * Calculate every configured aggregate for a binary row and shape the results + * as chart series. Periods with no known history have no bar (never zero). + */ + function buildBinaryAggregateSeries(args) { + const samples = args.stateList.map((entry) => ({ + time: Math.round(entry.lu * 1e3), + state: entry.s + })); + const coverage = buildCoverage(samples, args.range); + const stateOptions = getBinaryStateOptions(args.deviceClass); + return args.definitions.filter((definition) => definition.visible !== false).flatMap((definition) => { + const result = calculateAggregate({ + definition, + samples, + range: args.range, + timeZone: args.timeZone, + coverage + }); + const valued = result.points.filter((p) => typeof p.value === "number" && Number.isFinite(p.value)); + const pts = valued.map((p) => [(p.start + p.end) / 2, p.value]); + if (!pts.length) return []; + const stateValue = definition.states?.[0] ?? stateOptions[0].value; + const stateLabel = msg(stateOptions.find((o) => o.value === stateValue)?.label ?? stateValue); + const id = getAggregateSeriesId(args.entityId, definition.id); + return [{ + entityId: id, + legendEntityId: id, + sourceEntityId: args.entityId, + definitionId: definition.id, + label: definition.label || `${args.entityLabel} · ${describe(definition, stateLabel)}`, + unit: result.unit, + pts, + periods: valued.map((p) => ({ + start: p.start, + end: p.end + })), + color: definition.color || args.color, + axisKey: `aggregate:${result.unit || "count"}`, + bars: true, + result + }]; + }); + } //#endregion //#region custom_components/hass_datapoints/src/cards/history/data/downsampling.ts /** @@ -10434,7 +10969,7 @@ if (isSubordinate || isComparisonDerived) return thresholdLabel; return `${thresholdLabel}: ${entry.baseLabel || entry.label || ""}`; } - return String(entry.label || ""); + return entry.periodLabel ? `${String(entry.label || "")} (${entry.periodLabel})` : String(entry.label || ""); } function showLineChartTooltip(card, hover, clientX, clientY) { const root = getRoot(card); @@ -10915,9 +11450,19 @@ const timeMs = resolveLineChartHoverTime(resolvedSeries, rawTimeMs, options.hoverSnapMode || "follow_series"); const x = renderer.xOf(timeMs, t0, t1); const values = resolvedSeries.map((seriesItem) => { - const value = renderer._interpolateValue(seriesItem.pts || [], timeMs, seriesItem.stepped); + let value; + let periodLabel; + if (seriesItem.bars && seriesItem.periods) { + const index = seriesItem.periods.findIndex((period) => timeMs >= period.start && timeMs < period.end); + const point = index === -1 ? void 0 : seriesItem.pts?.[index]; + value = point ? point[1] : null; + if (index !== -1) { + const { start, end } = seriesItem.periods[index]; + periodLabel = `${formatTooltipDateTimeFromMs(start)} – ${formatTooltipDateTimeFromMs(end)}`; + } + } else value = renderer._interpolateValue(seriesItem.pts || [], timeMs, seriesItem.stepped); const axis = resolveHoverAxis(seriesItem); - return buildHoverValueEntry(seriesItem, value, axis, {}, { + return buildHoverValueEntry(seriesItem, value, axis, periodLabel ? { periodLabel } : {}, { includePosition: value != null, x }); @@ -14562,11 +15107,19 @@ return laneCenters; } /** Draw a single series line onto the renderer, with optional data-gap rendering. */ + /** HA's configured timezone; aggregate calendar periods never use the browser's. */ + _getTimeZone() { + return this._hass?.config?.time_zone || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } _drawSeriesLine(renderer, pts, color, t0, t1, min, max, opts) { const r = renderer; const config = this._config; const showGaps = config?.show_data_gaps !== false; const gapThresholdMs = SAMPLE_INTERVAL_MS[config?.data_gap_threshold || "2h"] ?? 72e5; + if (opts.bars === true) { + renderer.drawBars(pts, color, t0, t1, min, max, { fillAlpha: opts.lineOpacity ?? .78 }); + return; + } if (!showGaps || pts.length < 2 || !Number.isFinite(gapThresholdMs)) { r.drawLine(pts, color, t0, t1, min, max, opts); return; @@ -15203,10 +15756,42 @@ if (entityId.split(".")[0] === "binary_sensor") { const stateList = this._buildEntityStateList(entityId, histResult, statsResult); const binaryDisplay = resolveBinaryDisplayMode(seriesSetting.binary_display); - binaryEntities.push({ + const binaryAnalysis = analysisMap.get(entityId) || normalizeHistorySeriesAnalysis(null); + const binaryColor = seriesSetting.color || COLORS[i % COLORS.length]; + const binaryLabel = entityLabelMap.get(entityId) || entityId; + const aggregateSeries = buildBinaryAggregateSeries({ entityId, - label: entityLabelMap.get(entityId) || entityId, - color: seriesSetting.color || COLORS[i % COLORS.length], + entityLabel: binaryLabel, + color: binaryColor, + deviceClass: String(this._hass?.states?.[entityId]?.attributes?.device_class ?? ""), + definitions: binaryAnalysis.aggregates ?? [], + stateList, + range: { + start: t0, + end: t1 + }, + timeZone: this._getTimeZone() + }); + for (const aggregate of aggregateSeries) { + let axis = axisMap.get(aggregate.axisKey); + if (!axis) { + axis = { + key: aggregate.axisKey, + unit: aggregate.unit, + color: aggregate.color, + side: axisMap.size === 0 ? "left" : "right", + values: [] + }; + axisMap.set(aggregate.axisKey, axis); + axes.push(axis); + } + axis.values.push(...aggregate.pts.map(([, v]) => v)); + series.push(aggregate); + } + if (!this._seriesShouldHideSource(binaryAnalysis, hasSelectedComparisonWindow)) binaryEntities.push({ + entityId, + label: binaryLabel, + color: binaryColor, onLabel: this._binaryOnLabel(entityId), offLabel: this._binaryOffLabel(entityId), binaryDisplay, @@ -15625,12 +16210,13 @@ if (!extent) return null; const { min, max } = extent; const pad = (max - min) * .1 || 1; + const barsOnly = series.filter((entry) => entry.axisKey === axis.key).every((entry) => entry.bars === true); return { key: axis.key, unit: axis.unit, color: axis.color, side: axis.side === "right" ? "right" : "left", - min: min - pad, + min: barsOnly ? Math.min(0, min) : min - pad, max: max + pad }; }).filter((ax) => ax !== null); @@ -15956,7 +16542,8 @@ this._drawSeriesLine(renderer, s.pts, s.color, renderT0, renderT1, sWithAxis.axis.min, sWithAxis.axis.max, { lineOpacity: mainSeriesHoverOpacity, lineWidth: this._config?.comparison_hover_active === true ? 1.25 : void 0, - stepped: (analysisMap.get(s.entityId) || normalizeHistorySeriesAnalysis(null)).stepped_series === true + stepped: (analysisMap.get(s.entityId) || normalizeHistorySeriesAnalysis(null)).stepped_series === true, + bars: s.bars === true }); } const trendHoverSeries = trendSeries.map((seriesItem) => { @@ -16410,7 +16997,7 @@ let axisMax = 1; if (extent) { const pad = (extent.max - extent.min) * .1 || 1; - axisMin = extent.min - pad; + axisMin = seriesItem.bars === true ? Math.min(0, extent.min) : extent.min - pad; axisMax = extent.max + pad; } const primaryAxisKey = seriesItem.axisKey || seriesItem.unit || "__unitless__"; @@ -16476,7 +17063,8 @@ if (!rowHideSource) this._drawSeriesLine(renderer, seriesItem.pts, seriesItem.color, renderT0, renderT1, resolvedAxis.min, resolvedAxis.max, { lineWidth: comparisonPreviewActive ? 1.25 : 1.75, lineOpacity: mainSeriesOpacity, - stepped: rowAnalysis.stepped_series === true + stepped: rowAnalysis.stepped_series === true, + bars: seriesItem.bars === true }); for (const win of drawableComparisonResults || []) { const winPts = await this._resolveComparisonWindowPoints(seriesItem.entityId, win, rowAnalysis, renderT0, renderT1); @@ -23692,99 +24280,6 @@ margin-top: var(--dp-spacing-sm); } `; - //#endregion - //#region custom_components/hass_datapoints/src/lib/domain/aggregation/labels.ts - var BINARY_AGGREGATE_KINDS = [ - { - value: "transition_count", - label: "Count changes to a state" - }, - { - value: "all_changes", - label: "Count all state changes" - }, - { - value: "state_duration", - label: "Time in a state" - }, - { - value: "state_percentage", - label: "Percentage of time in a state" - } - ]; - var AGGREGATE_INTERVAL_OPTIONS = [ - { - value: "hour", - label: "Hourly" - }, - { - value: "day", - label: "Daily" - }, - { - value: "week", - label: "Weekly" - }, - { - value: "month", - label: "Monthly" - } - ]; - var STATE_LABELS = { - door: ["Open", "Closed"], - garage_door: ["Open", "Closed"], - window: ["Open", "Closed"], - opening: ["Open", "Closed"], - motion: ["Detected", "Clear"], - occupancy: ["Occupied", "Clear"], - lock: ["Unlocked", "Locked"] - }; - /** On/off options labelled for the entity's device class (e.g. Open/Closed). */ - function getBinaryStateOptions(deviceClass) { - const [on, off] = STATE_LABELS[deviceClass ?? ""] ?? ["On", "Off"]; - return [{ - value: "on", - label: on - }, { - value: "off", - label: off - }]; - } - function getBinaryAggregateKind(definition) { - if (definition.operation === "transition_count") return definition.states?.length ? "transition_count" : "all_changes"; - return definition.operation === "state_percentage" ? "state_percentage" : "state_duration"; - } - /** Apply a user-facing kind to a definition, keeping states consistent. */ - function applyBinaryAggregateKind(definition, kind, defaultState = "on") { - const states = definition.states?.length ? definition.states : [defaultState]; - if (kind === "all_changes") return { - ...definition, - operation: "transition_count", - states: [] - }; - const operation = kind; - const mode = operation === "state_percentage" ? "interval" : definition.mode; - return { - ...definition, - operation, - mode, - states - }; - } - function createBinaryAggregate(existingIds, defaultState = "on") { - let id; - do - id = `agg_${Math.random().toString(36).slice(2, 8)}`; - while (existingIds.includes(id)); - return { - id, - operation: "transition_count", - interval: "day", - mode: "interval", - states: [defaultState], - resetOnZoom: false - }; - } //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts var _AnalysisAggregateGroup; diff --git a/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.ts b/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.ts index f28a2593..aa2afaca 100644 --- a/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.ts +++ b/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.ts @@ -10,6 +10,7 @@ */ import { html, render } from "lit"; +import { buildBinaryAggregateSeries } from "@/lib/chart/chart-aggregates"; import { msg } from "@/lib/i18n/localize"; import { binaryOffLabel, @@ -954,6 +955,13 @@ export class HistoryChart extends HTMLElement { } /** Draw a single series line onto the renderer, with optional data-gap rendering. */ + /** HA's configured timezone; aggregate calendar periods never use the browser's. */ + _getTimeZone(): string { + const zone = (this._hass as Nullable<{ config?: { time_zone?: string } }>) + ?.config?.time_zone; + return zone || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } + _drawSeriesLine( renderer: unknown, pts: [number, number][], @@ -990,6 +998,25 @@ export class HistoryChart extends HTMLElement { const gapThresholdMs = SAMPLE_INTERVAL_MS[gapThresholdKey] ?? 2 * 60 * 60_000; + if (opts.bars === true) { + ( + renderer as { + drawBars: ( + pts: [number, number][], + color: string, + t0: number, + t1: number, + min: number, + max: number, + opts: RecordWithUnknownValues + ) => void; + } + ).drawBars(pts, color, t0, t1, min, max, { + fillAlpha: (opts.lineOpacity as number | undefined) ?? 0.78, + }); + return; + } + if (!showGaps || pts.length < 2 || !Number.isFinite(gapThresholdMs)) { r.drawLine(pts, color, t0, t1, min, max, opts); return; @@ -2402,6 +2429,8 @@ export class HistoryChart extends HTMLElement { axisKey: string; axis?: unknown; stepped?: boolean; + bars?: boolean; + periods?: { start: number; end: number }[]; }> = []; const axes: Array<{ key: string; @@ -2465,15 +2494,61 @@ export class HistoryChart extends HTMLElement { const binaryDisplay = resolveBinaryDisplayMode( seriesSetting.binary_display ); - binaryEntities.push({ + const binaryAnalysis = (analysisMap.get(entityId) || + normalizeHistorySeriesAnalysis(null)) as SeriesAnalysis; + const binaryColor = seriesSetting.color || COLORS[i % COLORS.length]; + const binaryLabel = entityLabelMap.get(entityId) || entityId; + const aggregateSeries = buildBinaryAggregateSeries({ entityId, - label: entityLabelMap.get(entityId) || entityId, - color: seriesSetting.color || COLORS[i % COLORS.length], - onLabel: this._binaryOnLabel(entityId), - offLabel: this._binaryOffLabel(entityId), - binaryDisplay, + entityLabel: binaryLabel, + color: binaryColor, + deviceClass: String( + ( + this._hass as Nullable<{ + states?: Record< + string, + { attributes: RecordWithUnknownValues } + >; + }> + )?.states?.[entityId]?.attributes?.device_class ?? "" + ), + definitions: binaryAnalysis.aggregates ?? [], stateList, + range: { start: t0, end: t1 }, + timeZone: this._getTimeZone(), }); + for (const aggregate of aggregateSeries) { + let axis = axisMap.get(aggregate.axisKey); + if (!axis) { + axis = { + key: aggregate.axisKey, + unit: aggregate.unit, + color: aggregate.color, + side: axisMap.size === 0 ? "left" : "right", + values: [], + }; + axisMap.set(aggregate.axisKey, axis); + axes.push(axis); + } + axis.values.push(...aggregate.pts.map(([, v]) => v)); + series.push(aggregate); + } + if ( + !this._seriesShouldHideSource( + binaryAnalysis, + hasSelectedComparisonWindow + ) + ) { + binaryEntities.push({ + entityId, + label: binaryLabel, + color: binaryColor, + onLabel: this._binaryOnLabel(entityId), + offLabel: this._binaryOffLabel(entityId), + binaryDisplay, + stateList, + }); + } return; } const stateList = this._buildEntityStateList( @@ -3333,12 +3408,17 @@ export class HistoryChart extends HTMLElement { } const { min, max } = extent; const pad = (max - min) * 0.1 || 1; + // Bars grow from zero, so a bar-only axis is anchored at 0 (or the + // lowest negative value) instead of being padded around the data. + const barsOnly = series + .filter((entry) => entry.axisKey === axis.key) + .every((entry) => entry.bars === true); return { key: axis.key, unit: axis.unit, color: axis.color, side: axis.side === "right" ? "right" : "left", - min: min - pad, + min: barsOnly ? Math.min(0, min) : min - pad, max: max + pad, }; }) @@ -3920,6 +4000,7 @@ export class HistoryChart extends HTMLElement { analysisMap.get(s.entityId) || normalizeHistorySeriesAnalysis(null) ).stepped_series === true, + bars: s.bars === true, } ); } @@ -5069,7 +5150,10 @@ export class HistoryChart extends HTMLElement { ((extent as RecordWithNumericValues).max - (extent as RecordWithNumericValues).min) * 0.1 || 1; - axisMin = (extent as RecordWithNumericValues).min - pad; + axisMin = + seriesItem.bars === true + ? Math.min(0, (extent as RecordWithNumericValues).min) + : (extent as RecordWithNumericValues).min - pad; axisMax = (extent as RecordWithNumericValues).max + pad; } const primaryAxisKey = @@ -5178,6 +5262,7 @@ export class HistoryChart extends HTMLElement { lineWidth: comparisonPreviewActive ? 1.25 : 1.75, lineOpacity: mainSeriesOpacity, stepped: rowAnalysis.stepped_series === true, + bars: seriesItem.bars === true, } ); } diff --git a/custom_components/hass_datapoints/src/cards/history/history-chart/stories/history-chart.stories.ts b/custom_components/hass_datapoints/src/cards/history/history-chart/stories/history-chart.stories.ts index 10f7df88..cfaa9656 100644 --- a/custom_components/hass_datapoints/src/cards/history/history-chart/stories/history-chart.stories.ts +++ b/custom_components/hass_datapoints/src/cards/history/history-chart/stories/history-chart.stories.ts @@ -324,3 +324,86 @@ export const SplitViewRow: Story = { await waitFor(() => expect(laneRegion()).toBeNull()); }, }; + +// ── (d) Binary aggregates as bars ─────────────────────────────────────────────── +const doorToggles = binaryHistory([ + { at: 0, s: "off" }, + { at: 0.2, s: "on" }, + { at: 0.3, s: "off" }, + { at: 0.6, s: "on" }, + { at: 0.7, s: "off" }, +]); + +const dailyOpens = { + id: "opens", + operation: "transition_count", + interval: "day", + mode: "interval", + states: ["on"], + resetOnZoom: false, +}; + +/** + * A door sensor with a daily "changes to open" aggregate. The aggregate appears + * as a bar series in the legend alongside the source band. + */ +export const BinaryAggregateBars: Story = { + render: () => makeChart(), + play: async ({ canvasElement }) => { + const el = canvasElement.querySelector( + "hass-datapoints-history-chart" + ) as DrawableChart; + el.draw( + drawArgs( + { + series_settings: [ + { + entity_id: "binary_sensor.front_door", + analysis: { aggregates: [dailyOpens] }, + }, + ], + }, + { "binary_sensor.front_door": doorToggles } + ) + ); + + await waitFor(() => + expect(legendLabels(el)).toEqual([ + "Front Door · Daily · Count changes to a state (Open)", + "Front Door", + ]) + ); + }, +}; + +/** With Hide source series on, only the aggregate remains in the legend. */ +export const BinaryAggregateHidesSource: Story = { + render: () => makeChart(), + play: async ({ canvasElement }) => { + const el = canvasElement.querySelector( + "hass-datapoints-history-chart" + ) as DrawableChart; + el.draw( + drawArgs( + { + series_settings: [ + { + entity_id: "binary_sensor.front_door", + analysis: { + aggregates: [dailyOpens], + hide_source_series: true, + }, + }, + ], + }, + { "binary_sensor.front_door": doorToggles } + ) + ); + + await waitFor(() => + expect(legendLabels(el)).toEqual([ + "Front Door · Daily · Count changes to a state (Open)", + ]) + ); + }, +}; diff --git a/custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts b/custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts new file mode 100644 index 00000000..0f5d03b2 --- /dev/null +++ b/custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { buildBinaryAggregateSeries } from "@/lib/chart/chart-aggregates"; +import type { AggregateDefinition } from "@/lib/domain/aggregation/types"; + +const start = Date.parse("2026-03-01T00:00:00Z"); +const hour = 3600000; +const day = 24 * hour; +const sec = (ms: number) => ms / 1000; + +const definition = ( + overrides: Partial = {} +): AggregateDefinition => ({ + id: "a1", + operation: "transition_count", + interval: "day", + mode: "interval", + states: ["on"], + resetOnZoom: false, + ...overrides, +}); + +const build = ( + definitions: AggregateDefinition[], + stateList: { s: string; lu: number }[] +) => + buildBinaryAggregateSeries({ + entityId: "binary_sensor.front_door", + entityLabel: "Front Door", + color: "#ff9800", + deviceClass: "door", + definitions, + stateList, + range: { start, end: start + 2 * day }, + timeZone: "UTC", + }); + +describe("buildBinaryAggregateSeries", () => { + describe("GIVEN a door opened three times on day one and never on day two", () => { + const stateList = [ + { s: "off", lu: sec(start) }, + { s: "on", lu: sec(start + hour) }, + { s: "off", lu: sec(start + 2 * hour) }, + { s: "on", lu: sec(start + 3 * hour) }, + { s: "off", lu: sec(start + 4 * hour) }, + { s: "on", lu: sec(start + 5 * hour) }, + { s: "off", lu: sec(start + 6 * hour) }, + ]; + describe("WHEN counting daily changes to open", () => { + it("THEN draws bars of 3 and 0 at the period centres with a count axis", () => { + expect.assertions(5); + const [series] = build([definition()], stateList); + expect(series.pts).toStrictEqual([ + [start + day / 2, 3], + [start + day + day / 2, 0], + ]); + expect(series.bars).toBe(true); + expect(series.axisKey).toBe("aggregate:count"); + expect(series.entityId).toBe("aggregate:binary_sensor.front_door:a1"); + expect(series.label).toBe( + "Front Door · Daily · Count changes to a state (Open)" + ); + }); + }); + describe("WHEN showing daily time open", () => { + it("THEN reports minutes on a minutes axis", () => { + expect.assertions(2); + const [series] = build( + [definition({ operation: "state_duration" })], + stateList + ); + expect(series.pts[0][1]).toBe(180); + expect(series.unit).toBe("min"); + }); + }); + describe("WHEN two aggregates are configured", () => { + it("THEN each gets an independent series", () => { + expect.assertions(1); + const out = build( + [ + definition(), + definition({ id: "a2", operation: "state_percentage" }), + ], + stateList + ); + expect(out.map((s) => s.definitionId)).toStrictEqual(["a1", "a2"]); + }); + }); + }); + + describe("GIVEN a sensor with no recorded history", () => { + describe("WHEN duration is requested", () => { + it("THEN no bars are produced rather than zeros", () => { + expect.assertions(1); + expect( + build([definition({ operation: "state_duration" })], []) + ).toStrictEqual([]); + }); + }); + }); +}); diff --git a/custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts b/custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts new file mode 100644 index 00000000..05502819 --- /dev/null +++ b/custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts @@ -0,0 +1,154 @@ +import { msg } from "@/lib/i18n/localize"; +import { calculateAggregate } from "@/lib/domain/aggregation/calculate"; +import { + AGGREGATE_INTERVAL_OPTIONS, + BINARY_AGGREGATE_KINDS, + getBinaryAggregateKind, + getBinaryStateOptions, +} from "@/lib/domain/aggregation/labels"; +import type { + AggregateDefinition, + AggregateRange, + AggregateResult, + AggregateSample, +} from "@/lib/domain/aggregation/types"; + +const UNKNOWN_STATES = new Set(["unknown", "unavailable"]); + +/** A derived series ready to be drawn by the history chart. */ +export interface AggregateChartSeries { + entityId: string; + legendEntityId: string; + sourceEntityId: string; + definitionId: string; + label: string; + unit: string; + pts: [number, number][]; + /** Calendar period behind each point, aligned with `pts`. */ + periods: { start: number; end: number }[]; + color: string; + axisKey: string; + bars: true; + result: AggregateResult; +} + +export interface BuildBinaryAggregateSeriesArgs { + entityId: string; + entityLabel: string; + color: string; + deviceClass?: string; + definitions: AggregateDefinition[]; + /** Recorded states; `lu` is the last-updated time in seconds. */ + stateList: { s: string; lu: number }[]; + range: AggregateRange; + timeZone: string; +} + +export function getAggregateSeriesId( + entityId: string, + definitionId: string +): string { + return `aggregate:${entityId}:${definitionId}`; +} + +/** Spans where a known (not unknown/unavailable) state was recorded. */ +function buildCoverage( + samples: AggregateSample[], + range: AggregateRange +): AggregateRange[] { + const sorted = [...samples].sort((a, b) => a.time - b.time); + const coverage: AggregateRange[] = []; + let seed: AggregateSample | undefined; + const inRange: AggregateSample[] = []; + for (const sample of sorted) { + if (sample.time <= range.start) { + seed = sample; + } else if (sample.time < range.end) { + inRange.push(sample); + } + } + const points = seed ? [{ ...seed, time: range.start }, ...inRange] : inRange; + points.forEach((point, i) => { + if (!point.state || UNKNOWN_STATES.has(point.state)) { + return; + } + const end = i + 1 < points.length ? points[i + 1].time : range.end; + if (end > point.time) { + coverage.push({ start: point.time, end }); + } + }); + return coverage; +} + +function describe(definition: AggregateDefinition, stateLabel: string): string { + const kind = getBinaryAggregateKind(definition); + const kindLabel = msg( + BINARY_AGGREGATE_KINDS.find((k) => k.value === kind)?.label ?? kind + ); + const intervalLabel = msg( + AGGREGATE_INTERVAL_OPTIONS.find((o) => o.value === definition.interval) + ?.label ?? definition.interval + ); + const suffix = kind === "all_changes" ? "" : ` (${stateLabel})`; + return `${intervalLabel} · ${kindLabel}${suffix}`; +} + +/** + * Calculate every configured aggregate for a binary row and shape the results + * as chart series. Periods with no known history have no bar (never zero). + */ +export function buildBinaryAggregateSeries( + args: BuildBinaryAggregateSeriesArgs +): AggregateChartSeries[] { + const samples: AggregateSample[] = args.stateList.map((entry) => ({ + time: Math.round(entry.lu * 1000), + state: entry.s, + })); + const coverage = buildCoverage(samples, args.range); + const stateOptions = getBinaryStateOptions(args.deviceClass); + return args.definitions + .filter((definition) => definition.visible !== false) + .flatMap((definition) => { + const result = calculateAggregate({ + definition, + samples, + range: args.range, + timeZone: args.timeZone, + coverage, + }); + const valued = result.points.filter( + (p): p is typeof p & { value: number } => + typeof p.value === "number" && Number.isFinite(p.value) + ); + const pts = valued.map((p): [number, number] => [ + (p.start + p.end) / 2, + p.value, + ]); + if (!pts.length) { + return []; + } + const stateValue = definition.states?.[0] ?? stateOptions[0].value; + const stateLabel = msg( + stateOptions.find((o) => o.value === stateValue)?.label ?? stateValue + ); + const id = getAggregateSeriesId(args.entityId, definition.id); + return [ + { + entityId: id, + legendEntityId: id, + sourceEntityId: args.entityId, + definitionId: definition.id, + label: + definition.label || + `${args.entityLabel} · ${describe(definition, stateLabel)}`, + unit: result.unit, + pts, + periods: valued.map((p) => ({ start: p.start, end: p.end })), + color: definition.color || args.color, + axisKey: `aggregate:${result.unit || "count"}`, + bars: true as const, + result, + }, + ]; + }); +} diff --git a/custom_components/hass_datapoints/src/lib/chart/chart-interaction.ts b/custom_components/hass_datapoints/src/lib/chart/chart-interaction.ts index 7912b6de..d569c920 100644 --- a/custom_components/hass_datapoints/src/lib/chart/chart-interaction.ts +++ b/custom_components/hass_datapoints/src/lib/chart/chart-interaction.ts @@ -147,6 +147,9 @@ export interface HoverSeriesLike { onLabel?: string; offLabel?: string; stepped?: boolean; + /** Bar series: values are per calendar period, not interpolated. */ + bars?: boolean; + periods?: Array<{ start: number; end: number }>; } export interface HoverValueEntry { @@ -161,6 +164,8 @@ export interface HoverValueEntry { color?: Nullable; opacity?: number; hasValue: boolean; + /** Formatted calendar period for aggregate bars. */ + periodLabel?: string; x?: number; y?: number; axisSide?: "left" | "right"; @@ -885,7 +890,9 @@ function resolveTooltipSeriesLabel(entry: HoverValueEntry): string { } return `${thresholdLabel}: ${entry.baseLabel || entry.label || ""}`; } - return String(entry.label || ""); + return entry.periodLabel + ? `${String(entry.label || "")} (${entry.periodLabel})` + : String(entry.label || ""); } export function showLineChartTooltip( @@ -1733,17 +1740,31 @@ export function attachLineChartHover( const x = renderer.xOf(timeMs, t0, t1); const values = resolvedSeries.map((seriesItem) => { - const value = renderer._interpolateValue( - seriesItem.pts || [], - timeMs, - seriesItem.stepped - ); + let value: Nullable; + let periodLabel: string | undefined; + if (seriesItem.bars && seriesItem.periods) { + const index = seriesItem.periods.findIndex( + (period) => timeMs >= period.start && timeMs < period.end + ); + const point = index === -1 ? undefined : seriesItem.pts?.[index]; + value = point ? point[1] : null; + if (index !== -1) { + const { start, end } = seriesItem.periods[index]; + periodLabel = `${formatTooltipDateTimeFromMs(start)} – ${formatTooltipDateTimeFromMs(end)}`; + } + } else { + value = renderer._interpolateValue( + seriesItem.pts || [], + timeMs, + seriesItem.stepped + ); + } const axis = resolveHoverAxis(seriesItem); return buildHoverValueEntry( seriesItem, value, axis, - {}, + periodLabel ? { periodLabel } : {}, { includePosition: value != null, x, From 70622b31828e5476ec21e3acd7beedacf49267d4 Mon Sep 17 00:00:00 2001 From: Ollie Date: Sat, 24 Oct 2026 22:34:30 +0300 Subject: [PATCH 07/17] feat(aggregation): measurement summaries on numeric rows Refs #92 Co-Authored-By: Claude Sonnet 5.5 --- .../hass_datapoints/hass-datapoints-cards.js | 254 +++++++++++++++++- .../history/history-chart/history-chart.ts | 22 +- .../chart/__tests__/chart-aggregates.spec.ts | 62 ++++- .../src/lib/chart/chart-aggregates.ts | 88 +++++- .../src/lib/chart/chart-interaction.ts | 5 +- .../src/lib/domain/aggregation/labels.ts | 29 ++ .../__tests__/translations-coverage.spec.ts | 2 + .../analysis-aggregate-group.spec.ts | 66 +++++ .../analysis-aggregate-group.ts | 65 +++++ .../analysis-aggregate-group/i18n/de.ts | 6 + .../analysis-aggregate-group/i18n/es.ts | 6 + .../analysis-aggregate-group/i18n/fi.ts | 6 + .../analysis-aggregate-group/i18n/fr.ts | 6 + .../analysis-aggregate-group/i18n/pt.ts | 6 + .../analysis-aggregate-group/i18n/zh-hans.ts | 6 + .../src/molecules/target-row/target-row.ts | 13 + 16 files changed, 623 insertions(+), 19 deletions(-) diff --git a/custom_components/hass_datapoints/hass-datapoints-cards.js b/custom_components/hass_datapoints/hass-datapoints-cards.js index c72f1dcf..7d82c3ca 100644 --- a/custom_components/hass_datapoints/hass-datapoints-cards.js +++ b/custom_components/hass_datapoints/hass-datapoints-cards.js @@ -1059,14 +1059,14 @@ var _kind_accessor_storage = /* @__PURE__ */ new WeakMap(); var _text_accessor_storage$2 = /* @__PURE__ */ new WeakMap(); var _visible_accessor_storage$2 = /* @__PURE__ */ new WeakMap(); - var _variant_accessor_storage = /* @__PURE__ */ new WeakMap(); + var _variant_accessor_storage$1 = /* @__PURE__ */ new WeakMap(); var FeedbackBanner = class extends i$2 { constructor(..._args) { super(..._args); _classPrivateFieldInitSpec(this, _kind_accessor_storage, ""); _classPrivateFieldInitSpec(this, _text_accessor_storage$2, ""); _classPrivateFieldInitSpec(this, _visible_accessor_storage$2, false); - _classPrivateFieldInitSpec(this, _variant_accessor_storage, "default"); + _classPrivateFieldInitSpec(this, _variant_accessor_storage$1, "default"); } get kind() { return _classPrivateFieldGet2(_kind_accessor_storage, this); @@ -1087,10 +1087,10 @@ _classPrivateFieldSet2(_visible_accessor_storage$2, this, value); } get variant() { - return _classPrivateFieldGet2(_variant_accessor_storage, this); + return _classPrivateFieldGet2(_variant_accessor_storage$1, this); } set variant(value) { - _classPrivateFieldSet2(_variant_accessor_storage, this, value); + _classPrivateFieldSet2(_variant_accessor_storage$1, this, value); } render() { if (!this.text) return A; @@ -2531,7 +2531,13 @@ Clear: "Vapaa", Occupied: "Varattu", Unlocked: "Avattu", - Locked: "Lukittu" + Locked: "Lukittu", + Mean: "Keskiarvo", + Minimum: "Pienin", + Maximum: "Suurin", + Median: "Mediaani", + "First reading": "Ensimmäinen lukema", + "Last reading": "Viimeisin lukema" }; })); //#endregion @@ -3226,7 +3232,13 @@ Clear: "Libre", Occupied: "Occupé", Unlocked: "Déverrouillé", - Locked: "Verrouillé" + Locked: "Verrouillé", + Mean: "Moyenne", + Minimum: "Minimum", + Maximum: "Maximum", + Median: "Médiane", + "First reading": "Première mesure", + "Last reading": "Dernière mesure" }; })); //#endregion @@ -3921,7 +3933,13 @@ Clear: "Frei", Occupied: "Belegt", Unlocked: "Entsperrt", - Locked: "Gesperrt" + Locked: "Gesperrt", + Mean: "Mittelwert", + Minimum: "Minimum", + Maximum: "Maximum", + Median: "Median", + "First reading": "Erster Messwert", + "Last reading": "Letzter Messwert" }; })); //#endregion @@ -4616,7 +4634,13 @@ Clear: "Libre", Occupied: "Ocupado", Unlocked: "Desbloqueado", - Locked: "Bloqueado" + Locked: "Bloqueado", + Mean: "Media", + Minimum: "Mínimo", + Maximum: "Máximo", + Median: "Mediana", + "First reading": "Primera lectura", + "Last reading": "Última lectura" }; })); //#endregion @@ -5311,7 +5335,13 @@ Clear: "Livre", Occupied: "Ocupado", Unlocked: "Desbloqueado", - Locked: "Bloqueado" + Locked: "Bloqueado", + Mean: "Média", + Minimum: "Mínimo", + Maximum: "Máximo", + Median: "Mediana", + "First reading": "Primeira leitura", + "Last reading": "Última leitura" }; })); //#endregion @@ -6006,7 +6036,13 @@ Clear: "无人", Occupied: "有人", Unlocked: "已解锁", - Locked: "已锁定" + Locked: "已锁定", + Mean: "平均值", + Minimum: "最小值", + Maximum: "最大值", + Median: "中位数", + "First reading": "首次读数", + "Last reading": "最后读数" }; })); //#endregion @@ -9103,6 +9139,43 @@ resetOnZoom: false }; } + var MEASUREMENT_OPERATIONS = [ + { + value: "mean", + label: "Mean" + }, + { + value: "min", + label: "Minimum" + }, + { + value: "max", + label: "Maximum" + }, + { + value: "median", + label: "Median" + }, + { + value: "first", + label: "First reading" + }, + { + value: "last", + label: "Last reading" + } + ]; + var MEASUREMENT_VALUES = new Set(MEASUREMENT_OPERATIONS.map((o) => o.value)); + function isMeasurementOperation(operation) { + return MEASUREMENT_VALUES.has(operation); + } + function createMeasurementAggregate(existingIds, operation = "mean") { + return { + ...createBinaryAggregate(existingIds), + operation, + states: void 0 + }; + } //#endregion //#region custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts var UNKNOWN_STATES = /* @__PURE__ */ new Set(["unknown", "unavailable"]); @@ -9175,7 +9248,50 @@ })), color: definition.color || args.color, axisKey: `aggregate:${result.unit || "count"}`, - bars: true, + bars: result.presentation === "bar", + result + }]; + }); + } + /** Aggregate numeric history into per-period measurement summaries (lines). */ + function buildMeasurementAggregateSeries(args) { + const samples = args.stateList.flatMap((entry) => { + const value = parseFloat(entry.s); + return Number.isNaN(value) ? [] : [{ + time: Math.round(entry.lu * 1e3), + value + }]; + }); + if (!samples.length) return []; + return args.definitions.filter((definition) => definition.visible !== false && isMeasurementOperation(definition.operation)).flatMap((definition, index) => { + const result = calculateAggregate({ + definition, + samples, + range: args.range, + timeZone: args.timeZone, + unit: args.unit, + coverage: [args.range] + }); + const valued = result.points.filter((p) => typeof p.value === "number" && Number.isFinite(p.value)); + if (!valued.length) return []; + const operationLabel = msg(MEASUREMENT_OPERATIONS.find((o) => o.value === definition.operation)?.label ?? definition.operation); + const intervalLabel = msg(AGGREGATE_INTERVAL_OPTIONS.find((o) => o.value === definition.interval)?.label ?? definition.interval); + const id = getAggregateSeriesId(args.entityId, definition.id); + return [{ + entityId: id, + legendEntityId: id, + sourceEntityId: args.entityId, + definitionId: definition.id, + label: definition.label || `${args.entityLabel} · ${intervalLabel} · ${operationLabel}`, + unit: args.unit, + pts: valued.map((p) => [(p.start + p.end) / 2, p.value]), + periods: valued.map((p) => ({ + start: p.start, + end: p.end + })), + color: definition.color || args.colors[index % args.colors.length], + axisKey: args.axisKey, + bars: result.presentation === "bar", result }]; }); @@ -11452,7 +11568,7 @@ const values = resolvedSeries.map((seriesItem) => { let value; let periodLabel; - if (seriesItem.bars && seriesItem.periods) { + if (seriesItem.periods) { const index = seriesItem.periods.findIndex((period) => timeMs >= period.start && timeMs < period.end); const point = index === -1 ? void 0 : seriesItem.pts?.[index]; value = point ? point[1] : null; @@ -12138,6 +12254,24 @@ return [definition]; }); } + function getAggregateOperations(entity) { + if (entity.entity_id.startsWith("event.")) return ["event_count"]; + if (entity.entity_id.startsWith("binary_sensor.") || entity.state !== void 0 && !["unknown", "unavailable"].includes(entity.state) && !Number.isFinite(Number(entity.state))) return [ + "transition_count", + "state_duration", + "state_percentage" + ]; + const operations = [ + "mean", + "min", + "max", + "median", + "first", + "last" + ]; + if (["total", "total_increasing"].includes(String(entity.attributes?.state_class))) operations.push("consumption"); + return operations; + } //#endregion //#region custom_components/hass_datapoints/src/lib/anomaly-direction.ts var DEFAULT_ANOMALY_DIRECTION = "both"; @@ -15824,6 +15958,26 @@ } if (pts.length) { const seriesAnalysis = analysisMap.get(entityId) || normalizeHistorySeriesAnalysis(null); + const aggregateSeries = buildMeasurementAggregateSeries({ + entityId, + entityLabel: entityLabelMap.get(entityId) || entityId, + colors: [ + 1, + 2, + 3, + 4 + ].map((offset) => COLORS[(i + offset) % COLORS.length]), + definitions: seriesAnalysis.aggregates ?? [], + stateList, + range: { + start: t0, + end: t1 + }, + timeZone: this._getTimeZone(), + unit, + axisKey + }); + for (const aggregate of aggregateSeries) axis.values.push(...aggregate.pts.map(([, v]) => v)); series.push({ entityId, legendEntityId: entityId, @@ -15834,6 +15988,7 @@ axisKey, stepped: seriesAnalysis.stepped_series === true }); + series.push(...aggregateSeries); } }); const binaryModel = buildBinaryRenderModel(binaryEntities, t0, t1); @@ -24284,10 +24439,14 @@ //#region custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts var _AnalysisAggregateGroup; var _deviceClass_accessor_storage = /* @__PURE__ */ new WeakMap(); + var _variant_accessor_storage = /* @__PURE__ */ new WeakMap(); + var _operations_accessor_storage = /* @__PURE__ */ new WeakMap(); var AnalysisAggregateGroup = (_AnalysisAggregateGroup = class AnalysisAggregateGroup extends AnalysisGroupMixin(i$2) { constructor(..._args) { super(..._args); _classPrivateFieldInitSpec(this, _deviceClass_accessor_storage, ""); + _classPrivateFieldInitSpec(this, _variant_accessor_storage, "binary"); + _classPrivateFieldInitSpec(this, _operations_accessor_storage, []); } get deviceClass() { return _classPrivateFieldGet2(_deviceClass_accessor_storage, this); @@ -24295,6 +24454,18 @@ set deviceClass(value) { _classPrivateFieldSet2(_deviceClass_accessor_storage, this, value); } + get variant() { + return _classPrivateFieldGet2(_variant_accessor_storage, this); + } + set variant(value) { + _classPrivateFieldSet2(_variant_accessor_storage, this, value); + } + get operations() { + return _classPrivateFieldGet2(_operations_accessor_storage, this); + } + set operations(value) { + _classPrivateFieldSet2(_operations_accessor_storage, this, value); + } get _definitions() { return this.analysis.aggregates ?? []; } @@ -24305,6 +24476,11 @@ this._commit(this._definitions.map((d) => d.id === id ? patch(d) : d)); } _onAdd() { + if (this.variant === "measurement") { + const ids = this._definitions.map((d) => d.id); + this._commit([...this._definitions, createMeasurementAggregate(ids, this.operations[0] ?? "mean")]); + return; + } const defaultState = getBinaryStateOptions(this.deviceClass)[0].value; this._commit([...this._definitions, createBinaryAggregate(this._definitions.map((d) => d.id), defaultState)]); } @@ -24314,7 +24490,45 @@ _selectValue(e) { return e.detail.value; } + _renderMeasurementDefinition(definition) { + const available = MEASUREMENT_OPERATIONS.filter((o) => this.operations.includes(o.value) || o.value === definition.operation); + return b` +
+ this._update(definition.id, (d) => ({ + ...d, + operation: this._selectValue(e) + }))} + > + this._update(definition.id, (d) => ({ + ...d, + interval: this._selectValue(e) + }))} + > + +
+ `; + } _renderDefinition(definition) { + if (this.variant === "measurement") return this._renderMeasurementDefinition(definition); const kind = getBinaryAggregateKind(definition); const stateOptions = getBinaryStateOptions(this.deviceClass).map((o) => ({ value: o.value, @@ -24379,6 +24593,11 @@ type: String, attribute: "device-class" })], AnalysisAggregateGroup.prototype, "deviceClass", null); + __decorate([n$1({ type: String })], AnalysisAggregateGroup.prototype, "variant", null); + __decorate([n$1({ + type: Array, + attribute: false + })], AnalysisAggregateGroup.prototype, "operations", null); AnalysisAggregateGroup = __decorate([localized()], AnalysisAggregateGroup); customElements.define("analysis-aggregate-group", AnalysisAggregateGroup); //#endregion @@ -24738,6 +24957,17 @@ /> ${msg("Stepped series")} + COLORS[(i + offset) % COLORS.length] + ), + definitions: (seriesAnalysis as SeriesAnalysis).aggregates ?? [], + stateList, + range: { start: t0, end: t1 }, + timeZone: this._getTimeZone(), + unit, + axisKey, + }); + for (const aggregate of aggregateSeries) { + axis.values.push(...aggregate.pts.map(([, v]) => v)); + } series.push({ entityId, legendEntityId: entityId, @@ -2598,6 +2617,7 @@ export class HistoryChart extends HTMLElement { axisKey, stepped: (seriesAnalysis as SeriesAnalysis).stepped_series === true, }); + series.push(...aggregateSeries); } }); diff --git a/custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts b/custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts index 0f5d03b2..4c967f75 100644 --- a/custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts +++ b/custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { buildBinaryAggregateSeries } from "@/lib/chart/chart-aggregates"; +import { + buildBinaryAggregateSeries, + buildMeasurementAggregateSeries, +} from "@/lib/chart/chart-aggregates"; import type { AggregateDefinition } from "@/lib/domain/aggregation/types"; const start = Date.parse("2026-03-01T00:00:00Z"); @@ -98,3 +101,60 @@ describe("buildBinaryAggregateSeries", () => { }); }); }); + +describe("buildMeasurementAggregateSeries", () => { + const readings = [2, 3, 10, 4, 6].map((v, i) => ({ + s: String(v), + lu: sec(start + (i < 3 ? i : 24 + i) * hour), + })); + const run = (operation: AggregateDefinition["operation"]) => + buildMeasurementAggregateSeries({ + entityId: "sensor.temp", + entityLabel: "Temperature", + colors: ["#111111", "#222222"], + definitions: [ + definition({ operation, states: undefined, interval: "day" }), + ], + stateList: readings, + range: { start, end: start + 2 * day }, + timeZone: "UTC", + unit: "°C", + axisKey: "°C", + }); + + describe("GIVEN readings 2, 3, 10 on day one and 4, 6 on day two", () => { + describe("WHEN showing the daily mean", () => { + it("THEN draws a line of arithmetic means on the source axis", () => { + expect.assertions(4); + const [series] = run("mean"); + expect(series.pts.map(([, v]) => v)).toStrictEqual([5, 5]); + expect(series.bars).toBe(false); + expect(series.axisKey).toBe("°C"); + expect(series.color).toBe("#111111"); + }); + }); + describe("WHEN showing the daily maximum", () => { + it("THEN reports the peak of each period", () => { + expect.assertions(1); + expect(run("max")[0].pts.map(([, v]) => v)).toStrictEqual([10, 6]); + }); + }); + describe("WHEN a period has no readings", () => { + it("THEN no point is produced for it", () => { + expect.assertions(1); + const out = buildMeasurementAggregateSeries({ + entityId: "sensor.temp", + entityLabel: "Temperature", + colors: ["#111111"], + definitions: [definition({ operation: "mean", states: undefined })], + stateList: [{ s: "5", lu: sec(start + hour) }], + range: { start, end: start + 2 * day }, + timeZone: "UTC", + unit: "°C", + axisKey: "°C", + }); + expect(out[0].pts).toHaveLength(1); + }); + }); + }); +}); diff --git a/custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts b/custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts index 05502819..4011e6b8 100644 --- a/custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts +++ b/custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts @@ -5,6 +5,8 @@ import { BINARY_AGGREGATE_KINDS, getBinaryAggregateKind, getBinaryStateOptions, + MEASUREMENT_OPERATIONS, + isMeasurementOperation, } from "@/lib/domain/aggregation/labels"; import type { AggregateDefinition, @@ -28,7 +30,7 @@ export interface AggregateChartSeries { periods: { start: number; end: number }[]; color: string; axisKey: string; - bars: true; + bars: boolean; result: AggregateResult; } @@ -146,7 +148,89 @@ export function buildBinaryAggregateSeries( periods: valued.map((p) => ({ start: p.start, end: p.end })), color: definition.color || args.color, axisKey: `aggregate:${result.unit || "count"}`, - bars: true as const, + bars: result.presentation === "bar", + result, + }, + ]; + }); +} + +export interface BuildMeasurementAggregateSeriesArgs { + entityId: string; + entityLabel: string; + /** Colours cycled across this row's aggregates so they differ from the source line. */ + colors: string[]; + definitions: AggregateDefinition[]; + stateList: { s: string; lu: number }[]; + range: AggregateRange; + timeZone: string; + unit: string; + /** Value axis shared with the source row (same unit). */ + axisKey: string; +} + +/** Aggregate numeric history into per-period measurement summaries (lines). */ +export function buildMeasurementAggregateSeries( + args: BuildMeasurementAggregateSeriesArgs +): AggregateChartSeries[] { + const samples: AggregateSample[] = args.stateList.flatMap((entry) => { + const value = parseFloat(entry.s); + return Number.isNaN(value) + ? [] + : [{ time: Math.round(entry.lu * 1000), value }]; + }); + if (!samples.length) { + return []; + } + return args.definitions + .filter( + (definition) => + definition.visible !== false && + isMeasurementOperation(definition.operation) + ) + .flatMap((definition, index) => { + const result = calculateAggregate({ + definition, + samples, + range: args.range, + timeZone: args.timeZone, + unit: args.unit, + coverage: [args.range], + }); + const valued = result.points.filter( + (p): p is typeof p & { value: number } => + typeof p.value === "number" && Number.isFinite(p.value) + ); + if (!valued.length) { + return []; + } + const operationLabel = msg( + MEASUREMENT_OPERATIONS.find((o) => o.value === definition.operation) + ?.label ?? definition.operation + ); + const intervalLabel = msg( + AGGREGATE_INTERVAL_OPTIONS.find((o) => o.value === definition.interval) + ?.label ?? definition.interval + ); + const id = getAggregateSeriesId(args.entityId, definition.id); + return [ + { + entityId: id, + legendEntityId: id, + sourceEntityId: args.entityId, + definitionId: definition.id, + label: + definition.label || + `${args.entityLabel} · ${intervalLabel} · ${operationLabel}`, + unit: args.unit, + pts: valued.map((p): [number, number] => [ + (p.start + p.end) / 2, + p.value, + ]), + periods: valued.map((p) => ({ start: p.start, end: p.end })), + color: definition.color || args.colors[index % args.colors.length], + axisKey: args.axisKey, + bars: result.presentation === "bar", result, }, ]; diff --git a/custom_components/hass_datapoints/src/lib/chart/chart-interaction.ts b/custom_components/hass_datapoints/src/lib/chart/chart-interaction.ts index d569c920..f767585e 100644 --- a/custom_components/hass_datapoints/src/lib/chart/chart-interaction.ts +++ b/custom_components/hass_datapoints/src/lib/chart/chart-interaction.ts @@ -147,8 +147,7 @@ export interface HoverSeriesLike { onLabel?: string; offLabel?: string; stepped?: boolean; - /** Bar series: values are per calendar period, not interpolated. */ - bars?: boolean; + /** Aggregate series: values are per calendar period, not interpolated. */ periods?: Array<{ start: number; end: number }>; } @@ -1742,7 +1741,7 @@ export function attachLineChartHover( const values = resolvedSeries.map((seriesItem) => { let value: Nullable; let periodLabel: string | undefined; - if (seriesItem.bars && seriesItem.periods) { + if (seriesItem.periods) { const index = seriesItem.periods.findIndex( (period) => timeMs >= period.start && timeMs < period.end ); diff --git a/custom_components/hass_datapoints/src/lib/domain/aggregation/labels.ts b/custom_components/hass_datapoints/src/lib/domain/aggregation/labels.ts index e06f12db..1f305e4f 100644 --- a/custom_components/hass_datapoints/src/lib/domain/aggregation/labels.ts +++ b/custom_components/hass_datapoints/src/lib/domain/aggregation/labels.ts @@ -1,6 +1,7 @@ import type { AggregateDefinition, AggregateInterval, + AggregateOperation, } from "@/lib/domain/aggregation/types"; export interface AggregateStateOption { @@ -102,3 +103,31 @@ export function createBinaryAggregate( resetOnZoom: false, }; } + +export const MEASUREMENT_OPERATIONS: { + value: AggregateOperation; + label: string; +}[] = [ + { value: "mean", label: "Mean" }, + { value: "min", label: "Minimum" }, + { value: "max", label: "Maximum" }, + { value: "median", label: "Median" }, + { value: "first", label: "First reading" }, + { value: "last", label: "Last reading" }, +]; + +const MEASUREMENT_VALUES = new Set( + MEASUREMENT_OPERATIONS.map((o) => o.value) +); + +export function isMeasurementOperation(operation: string): boolean { + return MEASUREMENT_VALUES.has(operation); +} + +export function createMeasurementAggregate( + existingIds: string[], + operation: AggregateOperation = "mean" +): AggregateDefinition { + const base = createBinaryAggregate(existingIds); + return { ...base, operation, states: undefined }; +} diff --git a/custom_components/hass_datapoints/src/lib/i18n/__tests__/translations-coverage.spec.ts b/custom_components/hass_datapoints/src/lib/i18n/__tests__/translations-coverage.spec.ts index 533e72ee..ef90c5e9 100644 --- a/custom_components/hass_datapoints/src/lib/i18n/__tests__/translations-coverage.spec.ts +++ b/custom_components/hass_datapoints/src/lib/i18n/__tests__/translations-coverage.spec.ts @@ -52,6 +52,8 @@ const UNTRANSLATED_WHITELIST = new Set([ "Max", "Min", "Median", + "Minimum", // German / French "Minimum" + "Maximum", // German / French "Maximum" // Technical terms borrowed without translation "Auto", diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/__tests__/analysis-aggregate-group.spec.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/__tests__/analysis-aggregate-group.spec.ts index 08166b1f..8472d972 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/__tests__/analysis-aggregate-group.spec.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/__tests__/analysis-aggregate-group.spec.ts @@ -168,3 +168,69 @@ describe("analysis-aggregate-group", () => { }); }); }); + +describe("analysis-aggregate-group measurement variant", () => { + let el: GroupElement; + afterEach(() => el?.remove()); + + async function mountMeasurement(aggregates: AggregateDefinition[]) { + el = document.createElement("analysis-aggregate-group") as GroupElement; + Object.assign(el, { + variant: "measurement", + operations: ["mean", "min", "max"], + analysis: { aggregates } as NormalizedAnalysis, + entityId: "sensor.temp", + }); + document.body.appendChild(el); + await el.updateComplete; + return el; + } + + describe("GIVEN a numeric sensor suggesting mean, min and max", () => { + describe("WHEN an aggregate is added", () => { + it("THEN a daily mean is created without states", async () => { + expect.assertions(2); + await mountMeasurement([]); + const handler = vi.fn(); + el.addEventListener("dp-group-analysis-change", handler); + (el.shadowRoot!.querySelector(".add") as HTMLButtonElement).click(); + const [created] = handler.mock.calls[0][0].detail.value; + expect(created).toMatchObject({ operation: "mean", interval: "day" }); + expect(created.states).toBeUndefined(); + }); + }); + describe("WHEN the operation select is rendered", () => { + it("THEN offers only the suggested operations", async () => { + expect.assertions(1); + await mountMeasurement([ + definition({ operation: "mean", states: undefined }), + ]); + expect(selects(el)[0].options.map((o) => o.value)).toStrictEqual([ + "mean", + "min", + "max", + ]); + }); + }); + describe("WHEN the operation is changed to max", () => { + it("THEN the definition keeps its id and interval", async () => { + expect.assertions(1); + await mountMeasurement([ + definition({ + operation: "mean", + interval: "week", + states: undefined, + }), + ]); + const handler = vi.fn(); + el.addEventListener("dp-group-analysis-change", handler); + choose(selects(el)[0], "max"); + expect(handler.mock.calls[0][0].detail.value[0]).toMatchObject({ + id: "a1", + operation: "max", + interval: "week", + }); + }); + }); + }); +}); diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts index 86e69a26..978c15ad 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts @@ -10,13 +10,16 @@ import { BINARY_AGGREGATE_KINDS, type BinaryAggregateKind, applyBinaryAggregateKind, + MEASUREMENT_OPERATIONS, createBinaryAggregate, + createMeasurementAggregate, getBinaryAggregateKind, getBinaryStateOptions, } from "@/lib/domain/aggregation/labels"; import type { AggregateDefinition, AggregateInterval, + AggregateOperation, } from "@/lib/domain/aggregation/types"; import "@/atoms/form/inline-select/inline-select"; @@ -30,6 +33,14 @@ export class AnalysisAggregateGroup extends AnalysisGroupMixin(LitElement) { @property({ type: String, attribute: "device-class" }) accessor deviceClass: string = ""; + /** `binary` offers state operations; `measurement` offers numeric summaries. */ + @property({ type: String }) + accessor variant: "binary" | "measurement" = "binary"; + + /** Measurement operations suggested for the entity; the user still chooses. */ + @property({ type: Array, attribute: false }) + accessor operations: AggregateOperation[] = []; + static styles = [sharedStyles, styles]; private get _definitions(): AggregateDefinition[] { @@ -48,6 +59,14 @@ export class AnalysisAggregateGroup extends AnalysisGroupMixin(LitElement) { } private _onAdd() { + if (this.variant === "measurement") { + const ids = this._definitions.map((d) => d.id); + this._commit([ + ...this._definitions, + createMeasurementAggregate(ids, this.operations[0] ?? "mean"), + ]); + return; + } const defaultState = getBinaryStateOptions(this.deviceClass)[0].value; this._commit([ ...this._definitions, @@ -66,7 +85,53 @@ export class AnalysisAggregateGroup extends AnalysisGroupMixin(LitElement) { return (e as CustomEvent<{ type: "select"; value: string }>).detail.value; } + private _renderMeasurementDefinition(definition: AggregateDefinition) { + const available = MEASUREMENT_OPERATIONS.filter( + (o) => + this.operations.includes(o.value) || o.value === definition.operation + ); + return html` +
+ + this._update(definition.id, (d) => ({ + ...d, + operation: this._selectValue(e) as AggregateOperation, + }))} + > + + this._update(definition.id, (d) => ({ + ...d, + interval: this._selectValue(e) as AggregateInterval, + }))} + > + +
+ `; + } + private _renderDefinition(definition: AggregateDefinition) { + if (this.variant === "measurement") { + return this._renderMeasurementDefinition(definition); + } const kind = getBinaryAggregateKind(definition); const stateOptions = getBinaryStateOptions(this.deviceClass).map((o) => ({ value: o.value, diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts index ddda4e0a..f4204bf7 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts @@ -24,4 +24,10 @@ export const translations: ComponentTranslations = { Occupied: "Belegt", Unlocked: "Entsperrt", Locked: "Gesperrt", + Mean: "Mittelwert", + Minimum: "Minimum", + Maximum: "Maximum", + Median: "Median", + "First reading": "Erster Messwert", + "Last reading": "Letzter Messwert", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts index cdddf10f..b019b9bc 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts @@ -24,4 +24,10 @@ export const translations: ComponentTranslations = { Occupied: "Ocupado", Unlocked: "Desbloqueado", Locked: "Bloqueado", + Mean: "Media", + Minimum: "Mínimo", + Maximum: "Máximo", + Median: "Mediana", + "First reading": "Primera lectura", + "Last reading": "Última lectura", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts index 9182b8f4..11fcd092 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts @@ -24,4 +24,10 @@ export const translations: ComponentTranslations = { Occupied: "Varattu", Unlocked: "Avattu", Locked: "Lukittu", + Mean: "Keskiarvo", + Minimum: "Pienin", + Maximum: "Suurin", + Median: "Mediaani", + "First reading": "Ensimmäinen lukema", + "Last reading": "Viimeisin lukema", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts index 331df8e4..18047fea 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts @@ -24,4 +24,10 @@ export const translations: ComponentTranslations = { Occupied: "Occupé", Unlocked: "Déverrouillé", Locked: "Verrouillé", + Mean: "Moyenne", + Minimum: "Minimum", + Maximum: "Maximum", + Median: "Médiane", + "First reading": "Première mesure", + "Last reading": "Dernière mesure", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts index c4dc14cd..424a1193 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts @@ -24,4 +24,10 @@ export const translations: ComponentTranslations = { Occupied: "Ocupado", Unlocked: "Desbloqueado", Locked: "Bloqueado", + Mean: "Média", + Minimum: "Mínimo", + Maximum: "Máximo", + Median: "Mediana", + "First reading": "Primeira leitura", + "Last reading": "Última leitura", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts index 29ce062e..ce0745b6 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts @@ -24,4 +24,10 @@ export const translations: ComponentTranslations = { Occupied: "有人", Unlocked: "已解锁", Locked: "已锁定", + Mean: "平均值", + Minimum: "最小值", + Maximum: "最大值", + Median: "中位数", + "First reading": "首次读数", + "Last reading": "最后读数", }; diff --git a/custom_components/hass_datapoints/src/molecules/target-row/target-row.ts b/custom_components/hass_datapoints/src/molecules/target-row/target-row.ts index 06d439e5..833acb98 100644 --- a/custom_components/hass_datapoints/src/molecules/target-row/target-row.ts +++ b/custom_components/hass_datapoints/src/molecules/target-row/target-row.ts @@ -4,6 +4,8 @@ import { localized, msg } from "@/lib/i18n/localize"; import { styles } from "./target-row.styles"; import type { HassLike, HassState } from "@/lib/types"; +import { getAggregateOperations } from "@/lib/domain/aggregation/aggregation"; +import { isMeasurementOperation } from "@/lib/domain/aggregation/labels"; import { type BinaryDisplayMode, isBinarySensorEntity, @@ -416,6 +418,17 @@ export class TargetRow extends LitElement { /> ${msg("Stepped series")} + Date: Sat, 24 Oct 2026 22:42:02 +0300 Subject: [PATCH 08/17] feat(aggregation): count events for HA event entities Refs #93 Co-Authored-By: Claude Sonnet 5.5 --- .../hass_datapoints/hass-datapoints-cards.js | 237 +++++++++++++++++- .../src/cards/history/data/types.ts | 3 + .../history/history-chart/history-chart.ts | 36 +++ .../stories/history-chart.stories.ts | 50 ++++ .../src/cards/history/history.ts | 6 +- .../chart/__tests__/chart-aggregates.spec.ts | 72 ++++++ .../src/lib/chart/chart-aggregates.ts | 111 ++++++++ .../lib/data/__tests__/history-api.spec.ts | 69 +++++ .../src/lib/data/history-api.ts | 45 ++++ .../src/lib/domain/aggregation/labels.ts | 13 + .../analysis-aggregate-group.spec.ts | 68 +++++ .../analysis-aggregate-group.ts | 98 +++++++- .../analysis-aggregate-group/i18n/de.ts | 1 + .../analysis-aggregate-group/i18n/es.ts | 1 + .../analysis-aggregate-group/i18n/fi.ts | 1 + .../analysis-aggregate-group/i18n/fr.ts | 1 + .../analysis-aggregate-group/i18n/pt.ts | 1 + .../analysis-aggregate-group/i18n/zh-hans.ts | 1 + .../src/molecules/target-row/target-row.ts | 24 +- 19 files changed, 822 insertions(+), 16 deletions(-) diff --git a/custom_components/hass_datapoints/hass-datapoints-cards.js b/custom_components/hass_datapoints/hass-datapoints-cards.js index 7d82c3ca..5a376438 100644 --- a/custom_components/hass_datapoints/hass-datapoints-cards.js +++ b/custom_components/hass_datapoints/hass-datapoints-cards.js @@ -2537,7 +2537,8 @@ Maximum: "Suurin", Median: "Mediaani", "First reading": "Ensimmäinen lukema", - "Last reading": "Viimeisin lukema" + "Last reading": "Viimeisin lukema", + "All event types": "Kaikki tapahtumatyypit" }; })); //#endregion @@ -3238,7 +3239,8 @@ Maximum: "Maximum", Median: "Médiane", "First reading": "Première mesure", - "Last reading": "Dernière mesure" + "Last reading": "Dernière mesure", + "All event types": "Tous les types d'événement" }; })); //#endregion @@ -3939,7 +3941,8 @@ Maximum: "Maximum", Median: "Median", "First reading": "Erster Messwert", - "Last reading": "Letzter Messwert" + "Last reading": "Letzter Messwert", + "All event types": "Alle Ereignistypen" }; })); //#endregion @@ -4640,7 +4643,8 @@ Maximum: "Máximo", Median: "Mediana", "First reading": "Primera lectura", - "Last reading": "Última lectura" + "Last reading": "Última lectura", + "All event types": "Todos los tipos de evento" }; })); //#endregion @@ -5341,7 +5345,8 @@ Maximum: "Máximo", Median: "Mediana", "First reading": "Primeira leitura", - "Last reading": "Última leitura" + "Last reading": "Última leitura", + "All event types": "Todos os tipos de evento" }; })); //#endregion @@ -6042,7 +6047,8 @@ Maximum: "最大值", Median: "中位数", "First reading": "首次读数", - "Last reading": "最后读数" + "Last reading": "最后读数", + "All event types": "所有事件类型" }; })); //#endregion @@ -9176,6 +9182,14 @@ states: void 0 }; } + function createEventAggregate(existingIds, eventTypes = []) { + return { + ...createBinaryAggregate(existingIds), + operation: "event_count", + states: void 0, + eventTypes + }; + } //#endregion //#region custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts var UNKNOWN_STATES = /* @__PURE__ */ new Set(["unknown", "unavailable"]); @@ -9296,6 +9310,65 @@ }]; }); } + /** + * Turn an event entity's recorded states into occurrences. The state value is + * the event timestamp and `event_type` is an attribute. States at or before the + * range start are the initial snapshot, not events that happened in range. + */ + function extractEventSamples(states, range) { + return states.flatMap((entry) => { + const raw = entry.s ?? entry.state; + if (raw === void 0 || UNKNOWN_STATES.has(raw)) return []; + const eventType = (entry.a ?? entry.attributes)?.event_type; + if (typeof eventType !== "string" || !eventType) return []; + const recordedAt = typeof entry.lu === "number" ? entry.lu * 1e3 : Date.parse(entry.last_updated ?? ""); + const parsed = Date.parse(raw); + const eventTime = Number.isFinite(parsed) ? parsed : recordedAt; + if (!Number.isFinite(eventTime)) return []; + return [{ + time: Number.isFinite(recordedAt) ? recordedAt : eventTime, + eventTime, + eventType, + boundary: Number.isFinite(recordedAt) && recordedAt <= range.start + }]; + }); + } + /** Count event occurrences per calendar period as bars (or cumulative lines). */ + function buildEventAggregateSeries(args) { + if (!args.states.length) return []; + const samples = extractEventSamples(args.states, args.range); + return args.definitions.filter((definition) => definition.visible !== false && definition.operation === "event_count").flatMap((definition) => { + const result = calculateAggregate({ + definition, + samples, + range: args.range, + timeZone: args.timeZone, + coverage: [args.range] + }); + const valued = result.points.filter((p) => typeof p.value === "number" && Number.isFinite(p.value)); + if (!valued.length) return []; + const intervalLabel = msg(AGGREGATE_INTERVAL_OPTIONS.find((o) => o.value === definition.interval)?.label ?? definition.interval); + const typesLabel = definition.eventTypes?.length ? definition.eventTypes.join(", ") : msg("All event types"); + const id = getAggregateSeriesId(args.entityId, definition.id); + return [{ + entityId: id, + legendEntityId: id, + sourceEntityId: args.entityId, + definitionId: definition.id, + label: definition.label || `${args.entityLabel} · ${intervalLabel} · ${typesLabel}`, + unit: result.unit, + pts: valued.map((p) => [(p.start + p.end) / 2, p.value]), + periods: valued.map((p) => ({ + start: p.start, + end: p.end + })), + color: definition.color || args.color, + axisKey: `aggregate:${result.unit || "count"}`, + bars: result.presentation === "bar", + result + }]; + }); + } //#endregion //#region custom_components/hass_datapoints/src/cards/history/data/downsampling.ts /** @@ -12208,6 +12281,25 @@ no_attributes: options.no_attributes !== false })); } + /** + * Like {@link fetchHistoryDuringPeriod}, but keeps state attributes for HA + * `event.*` entities (their `event_type` attribute is what event counting + * needs). Every other entity keeps the lean `no_attributes` request. + */ + async function fetchHistoryWithEventAttributes(hass, startTime, endTime, entityIds, options = {}) { + const ids = normalizeCacheIdList(entityIds); + const eventIds = ids.filter((id) => id.startsWith("event.")); + if (!eventIds.length) return await fetchHistoryDuringPeriod(hass, startTime, endTime, ids, options); + const otherIds = ids.filter((id) => !id.startsWith("event.")); + const [others, events] = await Promise.all([otherIds.length ? fetchHistoryDuringPeriod(hass, startTime, endTime, otherIds, options) : Promise.resolve({}), fetchHistoryDuringPeriod(hass, startTime, endTime, eventIds, { + ...options, + no_attributes: false + })]); + return { + ...others, + ...events + }; + } //#endregion //#region custom_components/hass_datapoints/src/lib/domain/aggregation/types.ts var AGGREGATE_OPERATIONS = [ @@ -15887,7 +15979,8 @@ const entityLabelMap = disambiguateEntityNames(this._hass, seriesSettings.map((s) => s.entity_id)); seriesSettings.forEach((seriesSetting, i) => { const entityId = seriesSetting.entity_id; - if (entityId.split(".")[0] === "binary_sensor") { + const domain = entityId.split(".")[0]; + if (domain === "binary_sensor") { const stateList = this._buildEntityStateList(entityId, histResult, statsResult); const binaryDisplay = resolveBinaryDisplayMode(seriesSetting.binary_display); const binaryAnalysis = analysisMap.get(entityId) || normalizeHistorySeriesAnalysis(null); @@ -15933,6 +16026,38 @@ }); return; } + if (domain === "event") { + const eventAnalysis = analysisMap.get(entityId) || normalizeHistorySeriesAnalysis(null); + const eventColor = seriesSetting.color || COLORS[i % COLORS.length]; + for (const aggregate of buildEventAggregateSeries({ + entityId, + entityLabel: entityLabelMap.get(entityId) || entityId, + color: eventColor, + definitions: eventAnalysis.aggregates ?? [], + states: getHistoryStatesForEntity$1(histResult, entityId, seriesSettings.map((setting) => setting.entity_id)), + range: { + start: t0, + end: t1 + }, + timeZone: this._getTimeZone() + })) { + let axis = axisMap.get(aggregate.axisKey); + if (!axis) { + axis = { + key: aggregate.axisKey, + unit: aggregate.unit, + color: aggregate.color, + side: axisMap.size === 0 ? "left" : "right", + values: [] + }; + axisMap.set(aggregate.axisKey, axis); + axes.push(axis); + } + axis.values.push(...aggregate.pts.map(([, v]) => v)); + series.push(aggregate); + } + return; + } const stateList = this._buildEntityStateList(entityId, histResult, statsResult); const pts = []; const unit = this._hass?.states?.[entityId]?.attributes?.unit_of_measurement || ""; @@ -19398,7 +19523,7 @@ const cacheKey = this._getComparisonCacheKey(win, start, end); const cached = this._comparisonDataCache.get(cacheKey); if (cached) return cached; - const historyPromise = fetchHistoryDuringPeriod(hass, start.toISOString(), end.toISOString(), this._entityIds, { + const historyPromise = fetchHistoryWithEventAttributes(hass, start.toISOString(), end.toISOString(), this._entityIds, { include_start_time_state: true, significant_changes_only: false, no_attributes: true @@ -19629,7 +19754,7 @@ this._setChartLoading(false); return; } - fetchHistoryDuringPeriod(hass, start.toISOString(), end.toISOString(), this._entityIds, { + fetchHistoryWithEventAttributes(hass, start.toISOString(), end.toISOString(), this._entityIds, { include_start_time_state: true, significant_changes_only: false, no_attributes: true @@ -24441,12 +24566,14 @@ var _deviceClass_accessor_storage = /* @__PURE__ */ new WeakMap(); var _variant_accessor_storage = /* @__PURE__ */ new WeakMap(); var _operations_accessor_storage = /* @__PURE__ */ new WeakMap(); + var _eventTypes_accessor_storage = /* @__PURE__ */ new WeakMap(); var AnalysisAggregateGroup = (_AnalysisAggregateGroup = class AnalysisAggregateGroup extends AnalysisGroupMixin(i$2) { constructor(..._args) { super(..._args); _classPrivateFieldInitSpec(this, _deviceClass_accessor_storage, ""); _classPrivateFieldInitSpec(this, _variant_accessor_storage, "binary"); _classPrivateFieldInitSpec(this, _operations_accessor_storage, []); + _classPrivateFieldInitSpec(this, _eventTypes_accessor_storage, []); } get deviceClass() { return _classPrivateFieldGet2(_deviceClass_accessor_storage, this); @@ -24466,6 +24593,12 @@ set operations(value) { _classPrivateFieldSet2(_operations_accessor_storage, this, value); } + get eventTypes() { + return _classPrivateFieldGet2(_eventTypes_accessor_storage, this); + } + set eventTypes(value) { + _classPrivateFieldSet2(_eventTypes_accessor_storage, this, value); + } get _definitions() { return this.analysis.aggregates ?? []; } @@ -24476,6 +24609,10 @@ this._commit(this._definitions.map((d) => d.id === id ? patch(d) : d)); } _onAdd() { + if (this.variant === "event") { + this._commit([...this._definitions, createEventAggregate(this._definitions.map((d) => d.id))]); + return; + } if (this.variant === "measurement") { const ids = this._definitions.map((d) => d.id); this._commit([...this._definitions, createMeasurementAggregate(ids, this.operations[0] ?? "mean")]); @@ -24525,9 +24662,70 @@ + `; + } + _onEventTypeToggle(definition, type, checked) { + const current = definition.eventTypes ?? []; + const next = checked ? [.../* @__PURE__ */ new Set([...current, type])] : current.filter((t) => t !== type); + this._update(definition.id, (d) => ({ + ...d, + eventTypes: next + })); + } + _renderEventDefinition(definition) { + const selected = definition.eventTypes ?? []; + const allTypes = selected.length === 0; + return b` +
+ this._update(definition.id, (d) => ({ + ...d, + interval: this._selectValue(e) + }))} + > + + ${allTypes ? A : this.eventTypes.map((type) => b` + + `)} + +
`; } _renderDefinition(definition) { + if (this.variant === "event") return this._renderEventDefinition(definition); if (this.variant === "measurement") return this._renderMeasurementDefinition(definition); const kind = getBinaryAggregateKind(definition); const stateOptions = getBinaryStateOptions(this.deviceClass).map((o) => ({ @@ -24598,6 +24796,10 @@ type: Array, attribute: false })], AnalysisAggregateGroup.prototype, "operations", null); + __decorate([n$1({ + type: Array, + attribute: false + })], AnalysisAggregateGroup.prototype, "eventTypes", null); AnalysisAggregateGroup = __decorate([localized()], AnalysisAggregateGroup); customElements.define("analysis-aggregate-group", AnalysisAggregateGroup); //#endregion @@ -24673,6 +24875,7 @@ _defineProperty(this, "_unit", ""); _defineProperty(this, "_supportsAnalysis", false); _defineProperty(this, "_isBinary", false); + _defineProperty(this, "_isEvent", false); } get color() { return _classPrivateFieldGet2(_color_accessor_storage$1, this); @@ -24790,6 +24993,7 @@ this._unit = attrs?.unit_of_measurement ?? ""; this._supportsAnalysis = Boolean(this._entityId) && !this._entityId.startsWith("binary_sensor."); this._isBinary = isBinarySensorEntity(this._entityId); + this._isEvent = this._entityId.startsWith("event."); } } _emit(name, detail) { @@ -24941,7 +25145,7 @@ - ${this._supportsAnalysis && this.analysis?.expanded ? b` + ${this._supportsAnalysis && !this._isEvent && this.analysis?.expanded ? b`
` : A} + ${this._isEvent && expanded ? b` +
+
+ +
+
+ ` : A} ${hasBinaryDisplayOption && expanded ? b`
diff --git a/custom_components/hass_datapoints/src/cards/history/data/types.ts b/custom_components/hass_datapoints/src/cards/history/data/types.ts index 126028c9..087545a5 100644 --- a/custom_components/hass_datapoints/src/cards/history/data/types.ts +++ b/custom_components/hass_datapoints/src/cards/history/data/types.ts @@ -10,6 +10,9 @@ export interface HaStateEntry { last_changed?: string; last_updated?: string; entity_id?: string; + /** State attributes; only present when requested (e.g. event entities). */ + a?: Record; + attributes?: Record; } /** Normalised statistics entry returned by the HA statistics API. */ diff --git a/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.ts b/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.ts index 02593969..62ce0390 100644 --- a/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.ts +++ b/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.ts @@ -12,6 +12,7 @@ import { html, render } from "lit"; import { buildBinaryAggregateSeries, + buildEventAggregateSeries, buildMeasurementAggregateSeries, } from "@/lib/chart/chart-aggregates"; import { msg } from "@/lib/i18n/localize"; @@ -19,6 +20,7 @@ import { binaryOffLabel, binaryOnLabel, getAxisValueExtent, + getHistoryStatesForEntity, normalizeStatisticsHistory, SAMPLE_INTERVAL_MS, } from "../history-data"; @@ -2554,6 +2556,40 @@ export class HistoryChart extends HTMLElement { } return; } + if (domain === "event") { + const eventAnalysis = (analysisMap.get(entityId) || + normalizeHistorySeriesAnalysis(null)) as SeriesAnalysis; + const eventColor = seriesSetting.color || COLORS[i % COLORS.length]; + for (const aggregate of buildEventAggregateSeries({ + entityId, + entityLabel: entityLabelMap.get(entityId) || entityId, + color: eventColor, + definitions: eventAnalysis.aggregates ?? [], + states: getHistoryStatesForEntity( + histResult, + entityId, + seriesSettings.map((setting) => setting.entity_id) + ), + range: { start: t0, end: t1 }, + timeZone: this._getTimeZone(), + })) { + let axis = axisMap.get(aggregate.axisKey); + if (!axis) { + axis = { + key: aggregate.axisKey, + unit: aggregate.unit, + color: aggregate.color, + side: axisMap.size === 0 ? "left" : "right", + values: [], + }; + axisMap.set(aggregate.axisKey, axis); + axes.push(axis); + } + axis.values.push(...aggregate.pts.map(([, v]) => v)); + series.push(aggregate); + } + return; + } const stateList = this._buildEntityStateList( entityId, histResult, diff --git a/custom_components/hass_datapoints/src/cards/history/history-chart/stories/history-chart.stories.ts b/custom_components/hass_datapoints/src/cards/history/history-chart/stories/history-chart.stories.ts index cfaa9656..f6a29bf9 100644 --- a/custom_components/hass_datapoints/src/cards/history/history-chart/stories/history-chart.stories.ts +++ b/custom_components/hass_datapoints/src/cards/history/history-chart/stories/history-chart.stories.ts @@ -407,3 +407,53 @@ export const BinaryAggregateHidesSource: Story = { ); }, }; + +// ── (e) Event counts ───────────────────────────────────────────────────────────── +/** An event entity's presses are counted per day as bars, by selected type. */ +export const EventCountBars: Story = { + render: () => makeChart(), + play: async ({ canvasElement }) => { + const el = canvasElement.querySelector( + "hass-datapoints-history-chart" + ) as DrawableChart; + const press = (at: number, type: string) => ({ + lu: SEC(at), + s: new Date(SEC(at) * 1000).toISOString(), + a: { event_type: type }, + }); + el.draw( + drawArgs( + { + series_settings: [ + { + entity_id: "event.button", + analysis: { + aggregates: [ + { + id: "presses", + operation: "event_count", + interval: "hour", + mode: "interval", + eventTypes: ["double_press"], + resetOnZoom: false, + }, + ], + }, + }, + ], + }, + { + "event.button": [ + press(0.2, "single_press"), + press(0.4, "double_press"), + press(0.6, "double_press"), + ], + } + ) + ); + + await waitFor(() => + expect(legendLabels(el)).toEqual(["event.button · Hourly · double_press"]) + ); + }, +}; diff --git a/custom_components/hass_datapoints/src/cards/history/history.ts b/custom_components/hass_datapoints/src/cards/history/history.ts index af381f14..a91085c3 100644 --- a/custom_components/hass_datapoints/src/cards/history/history.ts +++ b/custom_components/hass_datapoints/src/cards/history/history.ts @@ -17,7 +17,7 @@ import { resolveEntityIdsFromTarget, } from "@/lib/domain/target-selection"; import { HistoryAnnotationDialogController } from "@/components/annotation-dialog/annotation-dialog"; -import { fetchHistoryDuringPeriod } from "@/lib/data/history-api"; +import { fetchHistoryWithEventAttributes } from "@/lib/data/history-api"; import { fetchStatisticsDuringPeriod } from "@/lib/data/statistics-api"; import { fetchEvents } from "@/lib/data/events-api"; import { logger } from "@/lib/logger"; @@ -580,7 +580,7 @@ export class HassDatapointsHistoryCard extends ChartCardBase { return cached; } - const historyPromise = fetchHistoryDuringPeriod( + const historyPromise = fetchHistoryWithEventAttributes( hass, start.toISOString(), end.toISOString(), @@ -1027,7 +1027,7 @@ export class HassDatapointsHistoryCard extends ChartCardBase { this._setChartLoading(false); return; } - fetchHistoryDuringPeriod( + fetchHistoryWithEventAttributes( hass, start.toISOString(), end.toISOString(), diff --git a/custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts b/custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts index 4c967f75..8226122b 100644 --- a/custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts +++ b/custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildBinaryAggregateSeries, + buildEventAggregateSeries, buildMeasurementAggregateSeries, } from "@/lib/chart/chart-aggregates"; import type { AggregateDefinition } from "@/lib/domain/aggregation/types"; @@ -158,3 +159,74 @@ describe("buildMeasurementAggregateSeries", () => { }); }); }); + +describe("buildEventAggregateSeries", () => { + const state = (type: string, at: number, recordedAt = at) => ({ + s: new Date(at).toISOString(), + lu: sec(recordedAt), + a: { event_type: type }, + }); + const states = [ + // Initial snapshot recorded at the start of the range: not an occurrence. + state("single_press", start - 5 * hour, start), + state("single_press", start + hour), + state("double_press", start + 2 * hour), + state("single_press", start + day + hour), + { s: "unavailable", lu: sec(start + day + 2 * hour) }, + ]; + const run = (eventTypes: string[]) => + buildEventAggregateSeries({ + entityId: "event.button", + entityLabel: "Button", + color: "#4caf50", + definitions: [ + definition({ + operation: "event_count", + states: undefined, + eventTypes, + }), + ], + states, + range: { start, end: start + 2 * day }, + timeZone: "UTC", + }); + + describe("GIVEN single and double presses with an initial snapshot", () => { + describe("WHEN counting all event types", () => { + it("THEN occurrences are counted per day and the snapshot is ignored", () => { + expect.assertions(2); + const [series] = run([]); + expect(series.pts.map(([, v]) => v)).toStrictEqual([2, 1]); + expect(series.label).toBe("Button · Daily · All event types"); + }); + }); + describe("WHEN selecting only double_press", () => { + it("THEN other types are not counted and zero-activity days stay zero", () => { + expect.assertions(1); + const [series] = run(["double_press"]); + expect(series.pts.map(([, v]) => v)).toStrictEqual([1, 0]); + }); + }); + describe("WHEN the required attribute is absent", () => { + it("THEN those states are not counted as events", () => { + expect.assertions(1); + const out = buildEventAggregateSeries({ + entityId: "event.button", + entityLabel: "Button", + color: "#4caf50", + definitions: [ + definition({ operation: "event_count", states: undefined }), + ], + states: [ + { s: new Date(start + hour).toISOString(), lu: sec(start + hour) }, + ], + range: { start, end: start + 2 * day }, + timeZone: "UTC", + }); + expect(out.flatMap((s) => s.pts.map(([, v]) => v))).toStrictEqual([ + 0, 0, + ]); + }); + }); + }); +}); diff --git a/custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts b/custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts index 4011e6b8..0ed3e994 100644 --- a/custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts +++ b/custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts @@ -8,6 +8,7 @@ import { MEASUREMENT_OPERATIONS, isMeasurementOperation, } from "@/lib/domain/aggregation/labels"; +import type { HaStateEntry } from "@/cards/history/data/types"; import type { AggregateDefinition, AggregateRange, @@ -236,3 +237,113 @@ export function buildMeasurementAggregateSeries( ]; }); } + +export interface BuildEventAggregateSeriesArgs { + entityId: string; + entityLabel: string; + color: string; + definitions: AggregateDefinition[]; + /** Raw history states for the event entity, requested with attributes. */ + states: HaStateEntry[]; + range: AggregateRange; + timeZone: string; +} + +/** + * Turn an event entity's recorded states into occurrences. The state value is + * the event timestamp and `event_type` is an attribute. States at or before the + * range start are the initial snapshot, not events that happened in range. + */ +export function extractEventSamples( + states: HaStateEntry[], + range: AggregateRange +): AggregateSample[] { + return states.flatMap((entry): AggregateSample[] => { + const raw = entry.s ?? entry.state; + if (raw === undefined || UNKNOWN_STATES.has(raw)) { + return []; + } + const attributes = entry.a ?? entry.attributes; + const eventType = attributes?.event_type; + if (typeof eventType !== "string" || !eventType) { + return []; + } + const recordedAt = + typeof entry.lu === "number" + ? entry.lu * 1000 + : Date.parse(entry.last_updated ?? ""); + const parsed = Date.parse(raw); + const eventTime = Number.isFinite(parsed) ? parsed : recordedAt; + if (!Number.isFinite(eventTime)) { + return []; + } + return [ + { + time: Number.isFinite(recordedAt) ? recordedAt : eventTime, + eventTime, + eventType, + boundary: Number.isFinite(recordedAt) && recordedAt <= range.start, + }, + ]; + }); +} + +/** Count event occurrences per calendar period as bars (or cumulative lines). */ +export function buildEventAggregateSeries( + args: BuildEventAggregateSeriesArgs +): AggregateChartSeries[] { + if (!args.states.length) { + return []; + } + const samples = extractEventSamples(args.states, args.range); + return args.definitions + .filter( + (definition) => + definition.visible !== false && definition.operation === "event_count" + ) + .flatMap((definition) => { + const result = calculateAggregate({ + definition, + samples, + range: args.range, + timeZone: args.timeZone, + coverage: [args.range], + }); + const valued = result.points.filter( + (p): p is typeof p & { value: number } => + typeof p.value === "number" && Number.isFinite(p.value) + ); + if (!valued.length) { + return []; + } + const intervalLabel = msg( + AGGREGATE_INTERVAL_OPTIONS.find((o) => o.value === definition.interval) + ?.label ?? definition.interval + ); + const typesLabel = definition.eventTypes?.length + ? definition.eventTypes.join(", ") + : msg("All event types"); + const id = getAggregateSeriesId(args.entityId, definition.id); + return [ + { + entityId: id, + legendEntityId: id, + sourceEntityId: args.entityId, + definitionId: definition.id, + label: + definition.label || + `${args.entityLabel} · ${intervalLabel} · ${typesLabel}`, + unit: result.unit, + pts: valued.map((p): [number, number] => [ + (p.start + p.end) / 2, + p.value, + ]), + periods: valued.map((p) => ({ start: p.start, end: p.end })), + color: definition.color || args.color, + axisKey: `aggregate:${result.unit || "count"}`, + bars: result.presentation === "bar", + result, + }, + ]; + }); +} diff --git a/custom_components/hass_datapoints/src/lib/data/__tests__/history-api.spec.ts b/custom_components/hass_datapoints/src/lib/data/__tests__/history-api.spec.ts index da75fd26..558523ce 100644 --- a/custom_components/hass_datapoints/src/lib/data/__tests__/history-api.spec.ts +++ b/custom_components/hass_datapoints/src/lib/data/__tests__/history-api.spec.ts @@ -5,6 +5,7 @@ import { fetchDownsampledHistory, fetchDownsampledHistoryProgressive, fetchHistoryDuringPeriod, + fetchHistoryWithEventAttributes, } from "@/lib/data/history-api"; describe("history-api", () => { @@ -353,3 +354,71 @@ describe("history-api", () => { }); }); }); + +describe("fetchHistoryWithEventAttributes", () => { + const makeHass = () => { + const sendMessagePromise = vi.fn( + async (message: { entity_ids: string[]; no_attributes: boolean }) => + Object.fromEntries( + message.entity_ids.map((id) => [ + id, + [ + { + s: "x", + a: message.no_attributes ? undefined : { event_type: "t" }, + }, + ], + ]) + ) + ); + return { + sendMessagePromise, + hass: { connection: { sendMessagePromise, sendMessage: vi.fn() } }, + }; + }; + + describe("GIVEN a mix of event and sensor entities", () => { + describe("WHEN history is fetched", () => { + it("THEN only event entities are requested with attributes and results merge", async () => { + expect.assertions(4); + const { hass, sendMessagePromise } = makeHass(); + const result = await fetchHistoryWithEventAttributes( + hass, + "2026-01-01T00:00:00.000Z", + "2026-01-02T00:00:00.000Z", + ["event.button", "sensor.temp"], + { include_start_time_state: true, no_attributes: true } + ); + const calls = sendMessagePromise.mock.calls.map(([m]) => m); + expect(calls).toHaveLength(2); + expect( + calls.find((m) => m.entity_ids.includes("event.button")) + ?.no_attributes + ).toBe(false); + expect( + calls.find((m) => m.entity_ids.includes("sensor.temp"))?.no_attributes + ).toBe(true); + expect(Object.keys(result).sort()).toStrictEqual([ + "event.button", + "sensor.temp", + ]); + }); + }); + }); + + describe("GIVEN no event entities", () => { + describe("WHEN history is fetched", () => { + it("THEN a single lean request is made", async () => { + expect.assertions(1); + const { hass, sendMessagePromise } = makeHass(); + await fetchHistoryWithEventAttributes( + hass, + "2026-02-01T00:00:00.000Z", + "2026-02-02T00:00:00.000Z", + ["sensor.only"] + ); + expect(sendMessagePromise).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/custom_components/hass_datapoints/src/lib/data/history-api.ts b/custom_components/hass_datapoints/src/lib/data/history-api.ts index 5266f6b2..2af75afd 100644 --- a/custom_components/hass_datapoints/src/lib/data/history-api.ts +++ b/custom_components/hass_datapoints/src/lib/data/history-api.ts @@ -324,3 +324,48 @@ export async function fetchHistoryDuringPeriod( }) as Promise ); } + +/** + * Like {@link fetchHistoryDuringPeriod}, but keeps state attributes for HA + * `event.*` entities (their `event_type` attribute is what event counting + * needs). Every other entity keeps the lean `no_attributes` request. + */ +export async function fetchHistoryWithEventAttributes( + hass: Pick, + startTime: string, + endTime: string, + entityIds: unknown, + options: HistoryPeriodOptions = {} +): Promise> { + const ids = normalizeCacheIdList(entityIds); + const eventIds = ids.filter((id) => id.startsWith("event.")); + if (!eventIds.length) { + return (await fetchHistoryDuringPeriod( + hass, + startTime, + endTime, + ids, + options + )) as Record; + } + const otherIds = ids.filter((id) => !id.startsWith("event.")); + const [others, events] = await Promise.all([ + otherIds.length + ? fetchHistoryDuringPeriod>( + hass, + startTime, + endTime, + otherIds, + options + ) + : Promise.resolve({} as Record), + fetchHistoryDuringPeriod>( + hass, + startTime, + endTime, + eventIds, + { ...options, no_attributes: false } + ), + ]); + return { ...others, ...events }; +} diff --git a/custom_components/hass_datapoints/src/lib/domain/aggregation/labels.ts b/custom_components/hass_datapoints/src/lib/domain/aggregation/labels.ts index 1f305e4f..49905c9e 100644 --- a/custom_components/hass_datapoints/src/lib/domain/aggregation/labels.ts +++ b/custom_components/hass_datapoints/src/lib/domain/aggregation/labels.ts @@ -131,3 +131,16 @@ export function createMeasurementAggregate( const base = createBinaryAggregate(existingIds); return { ...base, operation, states: undefined }; } + +export function createEventAggregate( + existingIds: string[], + eventTypes: string[] = [] +): AggregateDefinition { + const base = createBinaryAggregate(existingIds); + return { + ...base, + operation: "event_count", + states: undefined, + eventTypes, + }; +} diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/__tests__/analysis-aggregate-group.spec.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/__tests__/analysis-aggregate-group.spec.ts index 8472d972..953688d7 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/__tests__/analysis-aggregate-group.spec.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/__tests__/analysis-aggregate-group.spec.ts @@ -234,3 +234,71 @@ describe("analysis-aggregate-group measurement variant", () => { }); }); }); + +describe("analysis-aggregate-group event variant", () => { + let el: GroupElement & { eventTypes: string[]; variant: string }; + afterEach(() => el?.remove()); + + async function mountEvent(aggregates: AggregateDefinition[]) { + el = document.createElement("analysis-aggregate-group") as typeof el; + Object.assign(el, { + variant: "event", + eventTypes: ["single_press", "double_press"], + analysis: { aggregates } as NormalizedAnalysis, + entityId: "event.button", + }); + document.body.appendChild(el); + await el.updateComplete; + } + const eventDefinition = (eventTypes: string[]) => + definition({ operation: "event_count", states: undefined, eventTypes }); + + describe("GIVEN a button reporting single and double presses", () => { + describe("WHEN an aggregate is added", () => { + it("THEN it counts all event types by default", async () => { + expect.assertions(1); + await mountEvent([]); + const handler = vi.fn(); + el.addEventListener("dp-group-analysis-change", handler); + (el.shadowRoot!.querySelector(".add") as HTMLButtonElement).click(); + expect(handler.mock.calls[0][0].detail.value[0]).toMatchObject({ + operation: "event_count", + eventTypes: [], + }); + }); + }); + describe("WHEN All event types is unchecked", () => { + it("THEN the first type is selected and type choices appear", async () => { + expect.assertions(2); + await mountEvent([eventDefinition([])]); + const handler = vi.fn(); + el.addEventListener("dp-group-analysis-change", handler); + const all = el.shadowRoot!.querySelector( + ".all-types" + ) as HTMLInputElement; + all.checked = false; + all.dispatchEvent(new Event("change")); + expect( + handler.mock.calls[0][0].detail.value[0].eventTypes + ).toStrictEqual(["single_press"]); + expect(el.shadowRoot!.querySelectorAll(".event-type")).toHaveLength(0); + }); + }); + describe("WHEN specific types are selected", () => { + it("THEN each type is individually toggleable", async () => { + expect.assertions(2); + await mountEvent([eventDefinition(["single_press"])]); + const boxes = el.shadowRoot!.querySelectorAll(".event-type"); + expect(boxes).toHaveLength(2); + const handler = vi.fn(); + el.addEventListener("dp-group-analysis-change", handler); + const second = boxes[1] as HTMLInputElement; + second.checked = true; + second.dispatchEvent(new Event("change")); + expect( + handler.mock.calls[0][0].detail.value[0].eventTypes + ).toStrictEqual(["single_press", "double_press"]); + }); + }); + }); +}); diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts index 978c15ad..58444eff 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts @@ -12,6 +12,7 @@ import { applyBinaryAggregateKind, MEASUREMENT_OPERATIONS, createBinaryAggregate, + createEventAggregate, createMeasurementAggregate, getBinaryAggregateKind, getBinaryStateOptions, @@ -33,14 +34,18 @@ export class AnalysisAggregateGroup extends AnalysisGroupMixin(LitElement) { @property({ type: String, attribute: "device-class" }) accessor deviceClass: string = ""; - /** `binary` offers state operations; `measurement` offers numeric summaries. */ + /** `binary` offers state operations; `measurement` numeric summaries; `event` event counts. */ @property({ type: String }) - accessor variant: "binary" | "measurement" = "binary"; + accessor variant: "binary" | "measurement" | "event" = "binary"; /** Measurement operations suggested for the entity; the user still chooses. */ @property({ type: Array, attribute: false }) accessor operations: AggregateOperation[] = []; + /** Event types the entity can emit (HA `event_types` attribute). */ + @property({ type: Array, attribute: false }) + accessor eventTypes: string[] = []; + static styles = [sharedStyles, styles]; private get _definitions(): AggregateDefinition[] { @@ -59,6 +64,13 @@ export class AnalysisAggregateGroup extends AnalysisGroupMixin(LitElement) { } private _onAdd() { + if (this.variant === "event") { + this._commit([ + ...this._definitions, + createEventAggregate(this._definitions.map((d) => d.id)), + ]); + return; + } if (this.variant === "measurement") { const ids = this._definitions.map((d) => d.id); this._commit([ @@ -128,7 +140,89 @@ export class AnalysisAggregateGroup extends AnalysisGroupMixin(LitElement) { `; } + private _onEventTypeToggle( + definition: AggregateDefinition, + type: string, + checked: boolean + ) { + const current = definition.eventTypes ?? []; + const next = checked + ? [...new Set([...current, type])] + : current.filter((t) => t !== type); + // Deselecting the last specific type falls back to All event types. + this._update(definition.id, (d) => ({ ...d, eventTypes: next })); + } + + private _renderEventDefinition(definition: AggregateDefinition) { + const selected = definition.eventTypes ?? []; + const allTypes = selected.length === 0; + return html` +
+ + this._update(definition.id, (d) => ({ + ...d, + interval: this._selectValue(e) as AggregateInterval, + }))} + > + + ${allTypes + ? nothing + : this.eventTypes.map( + (type) => html` + + ` + )} + +
+ `; + } + private _renderDefinition(definition: AggregateDefinition) { + if (this.variant === "event") { + return this._renderEventDefinition(definition); + } if (this.variant === "measurement") { return this._renderMeasurementDefinition(definition); } diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts index f4204bf7..c37ac68b 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts @@ -30,4 +30,5 @@ export const translations: ComponentTranslations = { Median: "Median", "First reading": "Erster Messwert", "Last reading": "Letzter Messwert", + "All event types": "Alle Ereignistypen", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts index b019b9bc..eab60a64 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts @@ -30,4 +30,5 @@ export const translations: ComponentTranslations = { Median: "Mediana", "First reading": "Primera lectura", "Last reading": "Última lectura", + "All event types": "Todos los tipos de evento", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts index 11fcd092..d649a99f 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts @@ -30,4 +30,5 @@ export const translations: ComponentTranslations = { Median: "Mediaani", "First reading": "Ensimmäinen lukema", "Last reading": "Viimeisin lukema", + "All event types": "Kaikki tapahtumatyypit", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts index 18047fea..6a4702fc 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts @@ -30,4 +30,5 @@ export const translations: ComponentTranslations = { Median: "Médiane", "First reading": "Première mesure", "Last reading": "Dernière mesure", + "All event types": "Tous les types d'événement", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts index 424a1193..95128674 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts @@ -30,4 +30,5 @@ export const translations: ComponentTranslations = { Median: "Mediana", "First reading": "Primeira leitura", "Last reading": "Última leitura", + "All event types": "Todos os tipos de evento", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts index ce0745b6..1b0ab69b 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts @@ -30,4 +30,5 @@ export const translations: ComponentTranslations = { Median: "中位数", "First reading": "首次读数", "Last reading": "最后读数", + "All event types": "所有事件类型", }; diff --git a/custom_components/hass_datapoints/src/molecules/target-row/target-row.ts b/custom_components/hass_datapoints/src/molecules/target-row/target-row.ts index 833acb98..fca5327a 100644 --- a/custom_components/hass_datapoints/src/molecules/target-row/target-row.ts +++ b/custom_components/hass_datapoints/src/molecules/target-row/target-row.ts @@ -196,6 +196,8 @@ export class TargetRow extends LitElement { private _isBinary = false; + private _isEvent = false; + override willUpdate(changed: PropertyValues) { if ( changed.has("stateObj") || @@ -213,6 +215,7 @@ export class TargetRow extends LitElement { this._supportsAnalysis = Boolean(this._entityId) && !this._entityId.startsWith("binary_sensor."); this._isBinary = isBinarySensorEntity(this._entityId); + this._isEvent = this._entityId.startsWith("event."); } } @@ -400,7 +403,7 @@ export class TargetRow extends LitElement {
- ${this._supportsAnalysis && this.analysis?.expanded + ${this._supportsAnalysis && !this._isEvent && this.analysis?.expanded ? html`
@@ -506,6 +509,25 @@ export class TargetRow extends LitElement {
` : nothing} + ${this._isEvent && expanded + ? html` +
+
+ +
+
+ ` + : nothing} ${hasBinaryDisplayOption && expanded ? html`
From 81ce07e2982acd85627ad7dfbd3e11163fba2c89 Mon Sep 17 00:00:00 2001 From: Ollie Date: Sat, 24 Oct 2026 22:45:16 +0300 Subject: [PATCH 09/17] feat(aggregation): energy consumption from meter statistics and readings Refs #94 Co-Authored-By: Claude Sonnet 5.5 --- .../hass_datapoints/hass-datapoints-cards.js | 177 ++++++++++++++++-- .../statistics-normalization.spec.ts | 32 ++++ .../history/data/statistics-normalization.ts | 38 ++++ .../src/cards/history/data/types.ts | 2 + .../history/history-chart/history-chart.ts | 44 ++++- .../src/cards/history/history-data.ts | 1 + .../src/cards/history/history.ts | 4 +- .../chart/__tests__/chart-aggregates.spec.ts | 89 +++++++++ .../src/lib/chart/chart-aggregates.ts | 117 ++++++++++++ .../src/lib/chart/chart-interaction.ts | 4 +- .../aggregation/__tests__/aggregation.spec.ts | 19 ++ .../src/lib/domain/aggregation/calculate.ts | 17 +- .../src/lib/domain/aggregation/labels.ts | 13 ++ .../src/lib/domain/aggregation/types.ts | 2 + .../analysis-aggregate-group.ts | 4 +- .../analysis-aggregate-group/i18n/de.ts | 4 + .../analysis-aggregate-group/i18n/es.ts | 4 + .../analysis-aggregate-group/i18n/fi.ts | 4 + .../analysis-aggregate-group/i18n/fr.ts | 4 + .../analysis-aggregate-group/i18n/pt.ts | 4 + .../analysis-aggregate-group/i18n/zh-hans.ts | 3 + .../src/molecules/target-row/target-row.ts | 4 +- 22 files changed, 566 insertions(+), 24 deletions(-) diff --git a/custom_components/hass_datapoints/hass-datapoints-cards.js b/custom_components/hass_datapoints/hass-datapoints-cards.js index 5a376438..a483862d 100644 --- a/custom_components/hass_datapoints/hass-datapoints-cards.js +++ b/custom_components/hass_datapoints/hass-datapoints-cards.js @@ -2538,7 +2538,10 @@ Median: "Mediaani", "First reading": "Ensimmäinen lukema", "Last reading": "Viimeisin lukema", - "All event types": "Kaikki tapahtumatyypit" + "All event types": "Kaikki tapahtumatyypit", + Consumption: "Kulutus", + "Meter reset detected": "Mittarin nollaus havaittu", + "Ambiguous meter reset, not counted": "Epäselvä mittarin nollaus, ei laskettu" }; })); //#endregion @@ -3240,7 +3243,10 @@ Median: "Médiane", "First reading": "Première mesure", "Last reading": "Dernière mesure", - "All event types": "Tous les types d'événement" + "All event types": "Tous les types d'événement", + Consumption: "Consommation", + "Meter reset detected": "Réinitialisation du compteur détectée", + "Ambiguous meter reset, not counted": "Réinitialisation du compteur ambiguë, non comptée" }; })); //#endregion @@ -3942,7 +3948,10 @@ Median: "Median", "First reading": "Erster Messwert", "Last reading": "Letzter Messwert", - "All event types": "Alle Ereignistypen" + "All event types": "Alle Ereignistypen", + Consumption: "Verbrauch", + "Meter reset detected": "Zählerrücksetzung erkannt", + "Ambiguous meter reset, not counted": "Unklare Zählerrücksetzung, nicht gezählt" }; })); //#endregion @@ -4644,7 +4653,10 @@ Median: "Mediana", "First reading": "Primera lectura", "Last reading": "Última lectura", - "All event types": "Todos los tipos de evento" + "All event types": "Todos los tipos de evento", + Consumption: "Consumo", + "Meter reset detected": "Reinicio del contador detectado", + "Ambiguous meter reset, not counted": "Reinicio del contador ambiguo, no contabilizado" }; })); //#endregion @@ -5346,7 +5358,10 @@ Median: "Mediana", "First reading": "Primeira leitura", "Last reading": "Última leitura", - "All event types": "Todos os tipos de evento" + "All event types": "Todos os tipos de evento", + Consumption: "Consumo", + "Meter reset detected": "Reposição do contador detetada", + "Ambiguous meter reset, not counted": "Reposição do contador ambígua, não contabilizada" }; })); //#endregion @@ -6048,7 +6063,10 @@ Median: "中位数", "First reading": "首次读数", "Last reading": "最后读数", - "All event types": "所有事件类型" + "All event types": "所有事件类型", + Consumption: "用量", + "Meter reset detected": "检测到电表重置", + "Ambiguous meter reset, not counted": "重置情况不明确,未计入" }; })); //#endregion @@ -8917,11 +8935,15 @@ })); } function calculateConsumption(input, periods) { + const deltas = []; + for (const s of input.samples) if (typeof s.change === "number" && Number.isFinite(s.change) && s.time >= input.range.start && s.time < input.range.end) deltas.push({ + time: s.time, + value: s.change + }); const readings = sortSamples(input.samples).filter((s) => { const v = s.sum ?? s.value; - return typeof v === "number" && Number.isFinite(v); + return s.change === void 0 && typeof v === "number" && Number.isFinite(v); }); - const deltas = []; readings.forEach((curr, i) => { const prev = readings[i - 1]; if (!prev || curr.time <= input.range.start || curr.time >= input.range.end) return; @@ -9171,6 +9193,14 @@ label: "Last reading" } ]; + /** Everything offered on a numeric row: measurement summaries plus consumption. */ + var NUMERIC_AGGREGATE_OPERATIONS = [...MEASUREMENT_OPERATIONS, { + value: "consumption", + label: "Consumption" + }]; + function isNumericAggregateOperation(operation) { + return NUMERIC_AGGREGATE_OPERATIONS.some((o) => o.value === operation); + } var MEASUREMENT_VALUES = new Set(MEASUREMENT_OPERATIONS.map((o) => o.value)); function isMeasurementOperation(operation) { return MEASUREMENT_VALUES.has(operation); @@ -9369,6 +9399,75 @@ }]; }); } + var STATISTICS_PERIOD_MS = 36e5; + function describeConsumptionReasons(reasons) { + const notes = []; + if (reasons.includes("reset")) notes.push(msg("Meter reset detected")); + if (reasons.includes("ambiguous-reset")) notes.push(msg("Ambiguous meter reset, not counted")); + return notes.join(", "); + } + /** + * Consumption per period for total / total_increasing meters. Statistics + * `change` is used where HA has compiled it; raw readings cover the rest. + */ + function buildConsumptionAggregateSeries(args) { + const readings = args.readings.flatMap((entry) => { + const value = parseFloat(entry.s); + return Number.isNaN(value) ? [] : [{ + time: Math.round(entry.lu * 1e3), + value + }]; + }); + const changeSamples = args.changes.map((entry) => ({ + time: entry.time, + change: entry.change + })); + let samples; + if (changeSamples.length) { + const cutoff = changeSamples[changeSamples.length - 1].time + STATISTICS_PERIOD_MS; + const before = readings.filter((r) => r.time < cutoff); + const seed = before[before.length - 1]; + samples = [ + ...changeSamples, + ...seed ? [seed] : [], + ...readings.filter((r) => r.time >= cutoff) + ]; + } else samples = readings; + if (!samples.length) return []; + return args.definitions.filter((definition) => definition.visible !== false && definition.operation === "consumption").flatMap((definition) => { + const result = calculateAggregate({ + definition, + samples, + range: args.range, + timeZone: args.timeZone, + stateClass: args.stateClass, + unit: args.unit, + coverage: [args.range] + }); + const valued = result.points.filter((p) => typeof p.value === "number" && Number.isFinite(p.value)); + if (!valued.length) return []; + const intervalLabel = msg(AGGREGATE_INTERVAL_OPTIONS.find((o) => o.value === definition.interval)?.label ?? definition.interval); + const id = getAggregateSeriesId(args.entityId, definition.id); + return [{ + entityId: id, + legendEntityId: id, + sourceEntityId: args.entityId, + definitionId: definition.id, + label: definition.label || `${args.entityLabel} · ${intervalLabel} · ${msg("Consumption")}`, + unit: args.unit, + pts: valued.map((p) => [(p.start + p.end) / 2, p.value]), + periods: valued.map((p) => ({ + start: p.start, + end: p.end + })), + periodNotes: valued.map((p) => describeConsumptionReasons(p.reasons)), + color: definition.color || args.color, + axisKey: `aggregate:${args.unit || "count"}`, + bars: result.presentation === "bar", + result + }]; + }); + } //#endregion //#region custom_components/hass_datapoints/src/cards/history/data/downsampling.ts /** @@ -9569,6 +9668,24 @@ merged.sort((a, b) => a.lu - b.lu); return merged; } + /** + * Per-period consumption from HA cumulative statistics (`change`). Entities + * without cumulative statistics yield an empty list. + */ + function normalizeStatisticsChanges(entityId, statsData) { + const statEntries = statsData && typeof statsData === "object" ? statsData[entityId] ?? [] : []; + return (Array.isArray(statEntries) ? statEntries : []).flatMap((entry) => { + const change = Number(entry?.change); + const raw = entry?.start; + let time; + if (typeof raw === "number") time = raw > 1e11 ? raw : raw * 1e3; + else time = new Date(raw).getTime(); + return entry?.change == null || !Number.isFinite(change) || !Number.isFinite(time) ? [] : [{ + time: Math.round(time), + change + }]; + }).sort((a, b) => a.time - b.time); + } //#endregion //#region custom_components/hass_datapoints/src/cards/history/data/axis-extent.ts /** @@ -11647,7 +11764,8 @@ value = point ? point[1] : null; if (index !== -1) { const { start, end } = seriesItem.periods[index]; - periodLabel = `${formatTooltipDateTimeFromMs(start)} – ${formatTooltipDateTimeFromMs(end)}`; + const note = seriesItem.periodNotes?.[index]; + periodLabel = `${formatTooltipDateTimeFromMs(start)} – ${formatTooltipDateTimeFromMs(end)}${note ? ` · ${note}` : ""}`; } } else value = renderer._interpolateValue(seriesItem.pts || [], timeMs, seriesItem.stepped); const axis = resolveHoverAxis(seriesItem); @@ -16103,6 +16221,37 @@ axisKey }); for (const aggregate of aggregateSeries) axis.values.push(...aggregate.pts.map(([, v]) => v)); + const stateAttributes = this._hass?.states?.[entityId]?.attributes; + const consumptionSeries = buildConsumptionAggregateSeries({ + entityId, + entityLabel: entityLabelMap.get(entityId) || entityId, + color: seriesSetting.color || COLORS[i % COLORS.length], + definitions: seriesAnalysis.aggregates ?? [], + changes: normalizeStatisticsChanges(entityId, statsResult), + readings: normalizeNumericHistory(entityId, getHistoryStatesForEntity$1(histResult, entityId, seriesSettings.map((setting) => setting.entity_id))), + stateClass: stateAttributes?.state_class, + unit, + range: { + start: t0, + end: t1 + }, + timeZone: this._getTimeZone() + }); + for (const aggregate of consumptionSeries) { + let consumptionAxis = axisMap.get(aggregate.axisKey); + if (!consumptionAxis) { + consumptionAxis = { + key: aggregate.axisKey, + unit: aggregate.unit, + color: aggregate.color, + side: axisMap.size === 0 ? "left" : "right", + values: [] + }; + axisMap.set(aggregate.axisKey, consumptionAxis); + axes.push(consumptionAxis); + } + consumptionAxis.values.push(...aggregate.pts.map(([, v]) => v)); + } series.push({ entityId, legendEntityId: entityId, @@ -16113,7 +16262,7 @@ axisKey, stepped: seriesAnalysis.stepped_series === true }); - series.push(...aggregateSeries); + series.push(...aggregateSeries, ...consumptionSeries); } }); const binaryModel = buildBinaryRenderModel(binaryEntities, t0, t1); @@ -19530,7 +19679,7 @@ }).catch(() => ({})); const statisticsPromise = this._statisticsEntityIds.length ? fetchStatisticsDuringPeriod(hass, start.toISOString(), end.toISOString(), this._statisticsEntityIds, { period: "hour", - types: ["mean"], + types: ["mean", "change"], units: {} }).catch(() => ({})) : Promise.resolve({}); const [histResult, statsResult] = await Promise.all([historyPromise, statisticsPromise]); @@ -19772,7 +19921,7 @@ }); if (this._statisticsEntityIds.length) fetchStatisticsDuringPeriod(hass, start.toISOString(), end.toISOString(), this._statisticsEntityIds, { period: "hour", - types: ["mean"], + types: ["mean", "change"], units: {} }).then((statsResult) => { partial.statsResult = statsResult || {}; @@ -24628,7 +24777,7 @@ return e.detail.value; } _renderMeasurementDefinition(definition) { - const available = MEASUREMENT_OPERATIONS.filter((o) => this.operations.includes(o.value) || o.value === definition.operation); + const available = NUMERIC_AGGREGATE_OPERATIONS.filter((o) => this.operations.includes(o.value) || o.value === definition.operation); return b`
{ }); }); }); + +describe("normalizeStatisticsChanges", () => { + describe("GIVEN statistics with change, missing change and an ISO start", () => { + it("THEN keeps valid changes in time order with start times in ms", () => { + expect.assertions(1); + expect( + normalizeStatisticsChanges("sensor.energy", { + "sensor.energy": [ + { start: 1_700_003_600_000, change: 2 }, + { start: 1_700_000_000, change: "1.5" }, + { start: 1_700_007_200_000, change: null }, + { start: "2023-11-14T22:13:20.000Z", change: 4 }, + ], + }) + ).toStrictEqual([ + { time: 1_700_000_000_000, change: 1.5 }, + { time: 1_700_000_000_000, change: 4 }, + { time: 1_700_003_600_000, change: 2 }, + ]); + }); + }); + + describe("GIVEN an entity without cumulative statistics", () => { + it("THEN returns an empty list", () => { + expect.assertions(1); + expect( + normalizeStatisticsChanges("sensor.none", { "sensor.other": [] }) + ).toStrictEqual([]); + }); + }); +}); diff --git a/custom_components/hass_datapoints/src/cards/history/data/statistics-normalization.ts b/custom_components/hass_datapoints/src/cards/history/data/statistics-normalization.ts index e28fe0d0..78e47fb6 100644 --- a/custom_components/hass_datapoints/src/cards/history/data/statistics-normalization.ts +++ b/custom_components/hass_datapoints/src/cards/history/data/statistics-normalization.ts @@ -71,3 +71,41 @@ export function mergeNumericHistoryWithStatistics( merged.sort((a, b) => a.lu - b.lu); return merged; } + +export interface StatisticsChangeEntry { + /** Start of the statistics period, in ms. */ + time: number; + /** Consumption during the period (HA `change`). */ + change: number; +} + +/** + * Per-period consumption from HA cumulative statistics (`change`). Entities + * without cumulative statistics yield an empty list. + */ +export function normalizeStatisticsChanges( + entityId: string, + statsData: unknown +): StatisticsChangeEntry[] { + const statEntries = + statsData && typeof statsData === "object" + ? ((statsData as RecordWithUnknownValues)[entityId] ?? []) + : []; + return (Array.isArray(statEntries) ? (statEntries as HaStatEntry[]) : []) + .flatMap((entry) => { + const change = Number(entry?.change); + const raw = entry?.start; + let time: number; + if (typeof raw === "number") { + time = raw > 1e11 ? raw : raw * 1000; + } else { + time = new Date(raw as string).getTime(); + } + return entry?.change == null || + !Number.isFinite(change) || + !Number.isFinite(time) + ? [] + : [{ time: Math.round(time), change }]; + }) + .sort((a, b) => a.time - b.time); +} diff --git a/custom_components/hass_datapoints/src/cards/history/data/types.ts b/custom_components/hass_datapoints/src/cards/history/data/types.ts index 087545a5..0c1e757a 100644 --- a/custom_components/hass_datapoints/src/cards/history/data/types.ts +++ b/custom_components/hass_datapoints/src/cards/history/data/types.ts @@ -19,6 +19,8 @@ export interface HaStateEntry { export interface HaStatEntry { start?: number | string; mean?: Nullable; + sum?: Nullable; + change?: Nullable; } /** Internal intermediate state record used during normalisation. */ diff --git a/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.ts b/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.ts index 62ce0390..02808e0e 100644 --- a/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.ts +++ b/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.ts @@ -12,6 +12,7 @@ import { html, render } from "lit"; import { buildBinaryAggregateSeries, + buildConsumptionAggregateSeries, buildEventAggregateSeries, buildMeasurementAggregateSeries, } from "@/lib/chart/chart-aggregates"; @@ -21,6 +22,8 @@ import { binaryOnLabel, getAxisValueExtent, getHistoryStatesForEntity, + normalizeNumericHistory, + normalizeStatisticsChanges, normalizeStatisticsHistory, SAMPLE_INTERVAL_MS, } from "../history-data"; @@ -2643,6 +2646,45 @@ export class HistoryChart extends HTMLElement { for (const aggregate of aggregateSeries) { axis.values.push(...aggregate.pts.map(([, v]) => v)); } + const stateAttributes = ( + this._hass as Nullable<{ + states?: Record; + }> + )?.states?.[entityId]?.attributes; + const consumptionSeries = buildConsumptionAggregateSeries({ + entityId, + entityLabel: entityLabelMap.get(entityId) || entityId, + color: seriesSetting.color || COLORS[i % COLORS.length], + definitions: (seriesAnalysis as SeriesAnalysis).aggregates ?? [], + changes: normalizeStatisticsChanges(entityId, statsResult), + readings: normalizeNumericHistory( + entityId, + getHistoryStatesForEntity( + histResult, + entityId, + seriesSettings.map((setting) => setting.entity_id) + ) + ), + stateClass: stateAttributes?.state_class as string | undefined, + unit, + range: { start: t0, end: t1 }, + timeZone: this._getTimeZone(), + }); + for (const aggregate of consumptionSeries) { + let consumptionAxis = axisMap.get(aggregate.axisKey); + if (!consumptionAxis) { + consumptionAxis = { + key: aggregate.axisKey, + unit: aggregate.unit, + color: aggregate.color, + side: axisMap.size === 0 ? "left" : "right", + values: [], + }; + axisMap.set(aggregate.axisKey, consumptionAxis); + axes.push(consumptionAxis); + } + consumptionAxis.values.push(...aggregate.pts.map(([, v]) => v)); + } series.push({ entityId, legendEntityId: entityId, @@ -2653,7 +2695,7 @@ export class HistoryChart extends HTMLElement { axisKey, stepped: (seriesAnalysis as SeriesAnalysis).stepped_series === true, }); - series.push(...aggregateSeries); + series.push(...aggregateSeries, ...consumptionSeries); } }); diff --git a/custom_components/hass_datapoints/src/cards/history/history-data.ts b/custom_components/hass_datapoints/src/cards/history/history-data.ts index f213265d..8312147f 100644 --- a/custom_components/hass_datapoints/src/cards/history/history-data.ts +++ b/custom_components/hass_datapoints/src/cards/history/history-data.ts @@ -18,6 +18,7 @@ export { } from "./data/history-normalization"; export { mergeNumericHistoryWithStatistics, + normalizeStatisticsChanges, normalizeStatisticsHistory, } from "./data/statistics-normalization"; export { getAxisValueExtent } from "./data/axis-extent"; diff --git a/custom_components/hass_datapoints/src/cards/history/history.ts b/custom_components/hass_datapoints/src/cards/history/history.ts index a91085c3..1aa44583 100644 --- a/custom_components/hass_datapoints/src/cards/history/history.ts +++ b/custom_components/hass_datapoints/src/cards/history/history.ts @@ -600,7 +600,7 @@ export class HassDatapointsHistoryCard extends ChartCardBase { this._statisticsEntityIds, { period: "hour", - types: ["mean"], + types: ["mean", "change"], units: {}, } ).catch(() => ({}) as RecordWithUnknownValues) @@ -1063,7 +1063,7 @@ export class HassDatapointsHistoryCard extends ChartCardBase { this._statisticsEntityIds, { period: "hour", - types: ["mean"], + types: ["mean", "change"], units: {}, } ) diff --git a/custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts b/custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts index 8226122b..8dfb5a1e 100644 --- a/custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts +++ b/custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildBinaryAggregateSeries, + buildConsumptionAggregateSeries, buildEventAggregateSeries, buildMeasurementAggregateSeries, } from "@/lib/chart/chart-aggregates"; @@ -230,3 +231,91 @@ describe("buildEventAggregateSeries", () => { }); }); }); + +describe("buildConsumptionAggregateSeries", () => { + const consumption = definition({ + operation: "consumption", + states: undefined, + }); + const run = (args: { + changes?: { time: number; change: number }[]; + readings?: { s: string; lu: number }[]; + stateClass?: string; + }) => + buildConsumptionAggregateSeries({ + entityId: "sensor.energy", + entityLabel: "Energy", + color: "#ff9800", + definitions: [consumption], + changes: args.changes ?? [], + readings: args.readings ?? [], + stateClass: args.stateClass, + unit: "kWh", + range: { start, end: start + 2 * day }, + timeZone: "UTC", + }); + + describe("GIVEN a meter reading 1000 then 1010 then 1012", () => { + const readings = [ + { s: "1000", lu: sec(start) }, + { s: "1010", lu: sec(start + hour) }, + { s: "1012", lu: sec(start + day + hour) }, + ]; + describe("WHEN showing daily consumption", () => { + it("THEN draws consumption bars on their own kWh axis, not the meter reading", () => { + expect.assertions(3); + const [series] = run({ readings, stateClass: "total_increasing" }); + expect(series.pts.map(([, v]) => v)).toStrictEqual([10, 2]); + expect(series.axisKey).toBe("aggregate:kWh"); + expect(series.bars).toBe(true); + }); + }); + }); + + describe("GIVEN a meter that resets mid-day", () => { + describe("WHEN showing daily consumption", () => { + it("THEN counts post-reset usage and notes the reset", () => { + expect.assertions(2); + const [series] = run({ + readings: [ + { s: "100", lu: sec(start) }, + { s: "110", lu: sec(start + hour) }, + { s: "5", lu: sec(start + 2 * hour) }, + ], + stateClass: "total_increasing", + }); + expect(series.pts[0][1]).toBe(15); + expect(series.periodNotes?.[0]).toBe("Meter reset detected"); + }); + }); + }); + + describe("GIVEN statistics for day one and a raw reading after the last statistic", () => { + describe("WHEN showing daily consumption", () => { + it("THEN statistics cover compiled hours and readings bridge the rest", () => { + expect.assertions(1); + const [series] = run({ + changes: [ + { time: start, change: 1 }, + { time: start + hour, change: 2 }, + ], + readings: [ + { s: "50", lu: sec(start + 2 * hour) }, + { s: "53", lu: sec(start + 3 * hour) }, + ], + stateClass: "total_increasing", + }); + expect(series.pts[0][1]).toBe(6); + }); + }); + }); + + describe("GIVEN no statistics and no readings", () => { + describe("WHEN showing consumption", () => { + it("THEN no series is produced", () => { + expect.assertions(1); + expect(run({})).toStrictEqual([]); + }); + }); + }); +}); diff --git a/custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts b/custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts index 0ed3e994..d9a24be6 100644 --- a/custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts +++ b/custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts @@ -9,6 +9,7 @@ import { isMeasurementOperation, } from "@/lib/domain/aggregation/labels"; import type { HaStateEntry } from "@/cards/history/data/types"; +import type { StatisticsChangeEntry } from "@/cards/history/data/statistics-normalization"; import type { AggregateDefinition, AggregateRange, @@ -29,6 +30,8 @@ export interface AggregateChartSeries { pts: [number, number][]; /** Calendar period behind each point, aligned with `pts`. */ periods: { start: number; end: number }[]; + /** Localized caveats per point (e.g. a detected meter reset), aligned with `pts`. */ + periodNotes?: string[]; color: string; axisKey: string; bars: boolean; @@ -347,3 +350,117 @@ export function buildEventAggregateSeries( ]; }); } + +export interface BuildConsumptionAggregateSeriesArgs { + entityId: string; + entityLabel: string; + color: string; + definitions: AggregateDefinition[]; + /** Per-period consumption from HA cumulative statistics (preferred). */ + changes: StatisticsChangeEntry[]; + /** Raw meter readings from history; bridges the period statistics lack. */ + readings: { s: string; lu: number }[]; + stateClass?: string; + unit: string; + range: AggregateRange; + timeZone: string; +} + +const STATISTICS_PERIOD_MS = 3600000; + +function describeConsumptionReasons(reasons: string[]): string { + const notes: string[] = []; + if (reasons.includes("reset")) { + notes.push(msg("Meter reset detected")); + } + if (reasons.includes("ambiguous-reset")) { + notes.push(msg("Ambiguous meter reset, not counted")); + } + return notes.join(", "); +} + +/** + * Consumption per period for total / total_increasing meters. Statistics + * `change` is used where HA has compiled it; raw readings cover the rest. + */ +export function buildConsumptionAggregateSeries( + args: BuildConsumptionAggregateSeriesArgs +): AggregateChartSeries[] { + const readings: AggregateSample[] = args.readings.flatMap((entry) => { + const value = parseFloat(entry.s); + return Number.isNaN(value) + ? [] + : [{ time: Math.round(entry.lu * 1000), value }]; + }); + const changeSamples: AggregateSample[] = args.changes.map((entry) => ({ + time: entry.time, + change: entry.change, + })); + let samples: AggregateSample[]; + if (changeSamples.length) { + const cutoff = + changeSamples[changeSamples.length - 1].time + STATISTICS_PERIOD_MS; + const before = readings.filter((r) => r.time < cutoff); + const seed = before[before.length - 1]; + samples = [ + ...changeSamples, + ...(seed ? [seed] : []), + ...readings.filter((r) => r.time >= cutoff), + ]; + } else { + samples = readings; + } + if (!samples.length) { + return []; + } + return args.definitions + .filter( + (definition) => + definition.visible !== false && definition.operation === "consumption" + ) + .flatMap((definition) => { + const result = calculateAggregate({ + definition, + samples, + range: args.range, + timeZone: args.timeZone, + stateClass: args.stateClass, + unit: args.unit, + coverage: [args.range], + }); + const valued = result.points.filter( + (p): p is typeof p & { value: number } => + typeof p.value === "number" && Number.isFinite(p.value) + ); + if (!valued.length) { + return []; + } + const intervalLabel = msg( + AGGREGATE_INTERVAL_OPTIONS.find((o) => o.value === definition.interval) + ?.label ?? definition.interval + ); + const id = getAggregateSeriesId(args.entityId, definition.id); + return [ + { + entityId: id, + legendEntityId: id, + sourceEntityId: args.entityId, + definitionId: definition.id, + label: + definition.label || + `${args.entityLabel} · ${intervalLabel} · ${msg("Consumption")}`, + unit: args.unit, + pts: valued.map((p): [number, number] => [ + (p.start + p.end) / 2, + p.value, + ]), + periods: valued.map((p) => ({ start: p.start, end: p.end })), + periodNotes: valued.map((p) => describeConsumptionReasons(p.reasons)), + color: definition.color || args.color, + axisKey: `aggregate:${args.unit || "count"}`, + bars: result.presentation === "bar", + result, + }, + ]; + }); +} diff --git a/custom_components/hass_datapoints/src/lib/chart/chart-interaction.ts b/custom_components/hass_datapoints/src/lib/chart/chart-interaction.ts index f767585e..6f1ffff9 100644 --- a/custom_components/hass_datapoints/src/lib/chart/chart-interaction.ts +++ b/custom_components/hass_datapoints/src/lib/chart/chart-interaction.ts @@ -149,6 +149,7 @@ export interface HoverSeriesLike { stepped?: boolean; /** Aggregate series: values are per calendar period, not interpolated. */ periods?: Array<{ start: number; end: number }>; + periodNotes?: string[]; } export interface HoverValueEntry { @@ -1749,7 +1750,8 @@ export function attachLineChartHover( value = point ? point[1] : null; if (index !== -1) { const { start, end } = seriesItem.periods[index]; - periodLabel = `${formatTooltipDateTimeFromMs(start)} – ${formatTooltipDateTimeFromMs(end)}`; + const note = seriesItem.periodNotes?.[index]; + periodLabel = `${formatTooltipDateTimeFromMs(start)} – ${formatTooltipDateTimeFromMs(end)}${note ? ` · ${note}` : ""}`; } } else { value = renderer._interpolateValue( diff --git a/custom_components/hass_datapoints/src/lib/domain/aggregation/__tests__/aggregation.spec.ts b/custom_components/hass_datapoints/src/lib/domain/aggregation/__tests__/aggregation.spec.ts index 443489e3..c40de722 100644 --- a/custom_components/hass_datapoints/src/lib/domain/aggregation/__tests__/aggregation.spec.ts +++ b/custom_components/hass_datapoints/src/lib/domain/aggregation/__tests__/aggregation.spec.ts @@ -330,3 +330,22 @@ describe("GIVEN readings 1, 2, 3, 9 in one hour", () => { expect(result.presentation).toBe("line"); }); }); + +describe("GIVEN HA statistics reporting hourly consumption", () => { + describe("WHEN summing a day", () => { + it("THEN uses the statistics change values directly", () => { + expect.assertions(1); + const base = dayInput({ + samples: [0.5, 1.5, 2].map((change, i) => ({ + time: start + i * hour, + change, + })), + }); + const result = calculateAggregate({ + ...base, + definition: { ...base.definition, operation: "consumption" }, + }); + expect(result.points[0].value).toBe(4); + }); + }); +}); diff --git a/custom_components/hass_datapoints/src/lib/domain/aggregation/calculate.ts b/custom_components/hass_datapoints/src/lib/domain/aggregation/calculate.ts index 29b20f9d..194d8589 100644 --- a/custom_components/hass_datapoints/src/lib/domain/aggregation/calculate.ts +++ b/custom_components/hass_datapoints/src/lib/domain/aggregation/calculate.ts @@ -219,11 +219,24 @@ function calculateConsumption( input: AggregateInput, periods: AggregatePeriod[] ): Bucket[] { + const deltas: { time: number; value: number; reason?: string }[] = []; + // HA statistics supply per-period consumption directly (reset-aware). + for (const s of input.samples) { + if ( + typeof s.change === "number" && + Number.isFinite(s.change) && + s.time >= input.range.start && + s.time < input.range.end + ) { + deltas.push({ time: s.time, value: s.change }); + } + } const readings = sortSamples(input.samples).filter((s) => { const v = s.sum ?? s.value; - return typeof v === "number" && Number.isFinite(v); + return ( + s.change === undefined && typeof v === "number" && Number.isFinite(v) + ); }); - const deltas: { time: number; value: number; reason?: string }[] = []; readings.forEach((curr, i) => { const prev = readings[i - 1]; if ( diff --git a/custom_components/hass_datapoints/src/lib/domain/aggregation/labels.ts b/custom_components/hass_datapoints/src/lib/domain/aggregation/labels.ts index 49905c9e..5a7801b7 100644 --- a/custom_components/hass_datapoints/src/lib/domain/aggregation/labels.ts +++ b/custom_components/hass_datapoints/src/lib/domain/aggregation/labels.ts @@ -116,6 +116,19 @@ export const MEASUREMENT_OPERATIONS: { { value: "last", label: "Last reading" }, ]; +/** Everything offered on a numeric row: measurement summaries plus consumption. */ +export const NUMERIC_AGGREGATE_OPERATIONS: { + value: AggregateOperation; + label: string; +}[] = [ + ...MEASUREMENT_OPERATIONS, + { value: "consumption", label: "Consumption" }, +]; + +export function isNumericAggregateOperation(operation: string): boolean { + return NUMERIC_AGGREGATE_OPERATIONS.some((o) => o.value === operation); +} + const MEASUREMENT_VALUES = new Set( MEASUREMENT_OPERATIONS.map((o) => o.value) ); diff --git a/custom_components/hass_datapoints/src/lib/domain/aggregation/types.ts b/custom_components/hass_datapoints/src/lib/domain/aggregation/types.ts index 41472b63..8e8c9816 100644 --- a/custom_components/hass_datapoints/src/lib/domain/aggregation/types.ts +++ b/custom_components/hass_datapoints/src/lib/domain/aggregation/types.ts @@ -32,6 +32,8 @@ export interface AggregateSample { eventType?: string; eventTime?: number; sum?: number | null; + /** Consumption already computed by HA statistics for the period starting at `time`. */ + change?: number | null; lastReset?: number; boundary?: boolean; } diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts index 58444eff..0e816010 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts @@ -10,7 +10,7 @@ import { BINARY_AGGREGATE_KINDS, type BinaryAggregateKind, applyBinaryAggregateKind, - MEASUREMENT_OPERATIONS, + NUMERIC_AGGREGATE_OPERATIONS, createBinaryAggregate, createEventAggregate, createMeasurementAggregate, @@ -98,7 +98,7 @@ export class AnalysisAggregateGroup extends AnalysisGroupMixin(LitElement) { } private _renderMeasurementDefinition(definition: AggregateDefinition) { - const available = MEASUREMENT_OPERATIONS.filter( + const available = NUMERIC_AGGREGATE_OPERATIONS.filter( (o) => this.operations.includes(o.value) || o.value === definition.operation ); diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts index c37ac68b..ff4e7743 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts @@ -31,4 +31,8 @@ export const translations: ComponentTranslations = { "First reading": "Erster Messwert", "Last reading": "Letzter Messwert", "All event types": "Alle Ereignistypen", + Consumption: "Verbrauch", + "Meter reset detected": "Zählerrücksetzung erkannt", + "Ambiguous meter reset, not counted": + "Unklare Zählerrücksetzung, nicht gezählt", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts index eab60a64..0ce02c7b 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts @@ -31,4 +31,8 @@ export const translations: ComponentTranslations = { "First reading": "Primera lectura", "Last reading": "Última lectura", "All event types": "Todos los tipos de evento", + Consumption: "Consumo", + "Meter reset detected": "Reinicio del contador detectado", + "Ambiguous meter reset, not counted": + "Reinicio del contador ambiguo, no contabilizado", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts index d649a99f..72415550 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts @@ -31,4 +31,8 @@ export const translations: ComponentTranslations = { "First reading": "Ensimmäinen lukema", "Last reading": "Viimeisin lukema", "All event types": "Kaikki tapahtumatyypit", + Consumption: "Kulutus", + "Meter reset detected": "Mittarin nollaus havaittu", + "Ambiguous meter reset, not counted": + "Epäselvä mittarin nollaus, ei laskettu", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts index 6a4702fc..5976facc 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts @@ -31,4 +31,8 @@ export const translations: ComponentTranslations = { "First reading": "Première mesure", "Last reading": "Dernière mesure", "All event types": "Tous les types d'événement", + Consumption: "Consommation", + "Meter reset detected": "Réinitialisation du compteur détectée", + "Ambiguous meter reset, not counted": + "Réinitialisation du compteur ambiguë, non comptée", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts index 95128674..4c17f7d6 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts @@ -31,4 +31,8 @@ export const translations: ComponentTranslations = { "First reading": "Primeira leitura", "Last reading": "Última leitura", "All event types": "Todos os tipos de evento", + Consumption: "Consumo", + "Meter reset detected": "Reposição do contador detetada", + "Ambiguous meter reset, not counted": + "Reposição do contador ambígua, não contabilizada", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts index 1b0ab69b..83a2bf73 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts @@ -31,4 +31,7 @@ export const translations: ComponentTranslations = { "First reading": "首次读数", "Last reading": "最后读数", "All event types": "所有事件类型", + Consumption: "用量", + "Meter reset detected": "检测到电表重置", + "Ambiguous meter reset, not counted": "重置情况不明确,未计入", }; diff --git a/custom_components/hass_datapoints/src/molecules/target-row/target-row.ts b/custom_components/hass_datapoints/src/molecules/target-row/target-row.ts index fca5327a..283312a3 100644 --- a/custom_components/hass_datapoints/src/molecules/target-row/target-row.ts +++ b/custom_components/hass_datapoints/src/molecules/target-row/target-row.ts @@ -5,7 +5,7 @@ import { localized, msg } from "@/lib/i18n/localize"; import { styles } from "./target-row.styles"; import type { HassLike, HassState } from "@/lib/types"; import { getAggregateOperations } from "@/lib/domain/aggregation/aggregation"; -import { isMeasurementOperation } from "@/lib/domain/aggregation/labels"; +import { isNumericAggregateOperation } from "@/lib/domain/aggregation/labels"; import { type BinaryDisplayMode, isBinarySensorEntity, @@ -429,7 +429,7 @@ export class TargetRow extends LitElement { entity_id: this._entityId, state: this.stateObj?.state, attributes: this.stateObj?.attributes, - }).filter(isMeasurementOperation)} + }).filter(isNumericAggregateOperation)} @dp-group-analysis-change=${this._onGroupAnalysisChange} > Date: Sat, 24 Oct 2026 22:47:31 +0300 Subject: [PATCH 10/17] feat(aggregation): cumulative mode with optional zoom baseline reset Refs #95 Co-Authored-By: Claude Sonnet 5.5 --- .../hass_datapoints/hass-datapoints-cards.js | 92 ++++++++++++++++--- .../history/history-chart/history-chart.ts | 4 + .../chart/__tests__/chart-aggregates.spec.ts | 53 +++++++++++ .../src/lib/chart/chart-aggregates.ts | 42 ++++++++- .../analysis-aggregate-group.spec.ts | 55 +++++++++++ .../analysis-aggregate-group.ts | 52 +++++++++++ .../analysis-aggregate-group/i18n/de.ts | 2 + .../analysis-aggregate-group/i18n/es.ts | 2 + .../analysis-aggregate-group/i18n/fi.ts | 2 + .../analysis-aggregate-group/i18n/fr.ts | 2 + .../analysis-aggregate-group/i18n/pt.ts | 2 + .../analysis-aggregate-group/i18n/zh-hans.ts | 2 + 12 files changed, 293 insertions(+), 17 deletions(-) diff --git a/custom_components/hass_datapoints/hass-datapoints-cards.js b/custom_components/hass_datapoints/hass-datapoints-cards.js index a483862d..a59c7285 100644 --- a/custom_components/hass_datapoints/hass-datapoints-cards.js +++ b/custom_components/hass_datapoints/hass-datapoints-cards.js @@ -2541,7 +2541,9 @@ "All event types": "Kaikki tapahtumatyypit", Consumption: "Kulutus", "Meter reset detected": "Mittarin nollaus havaittu", - "Ambiguous meter reset, not counted": "Epäselvä mittarin nollaus, ei laskettu" + "Ambiguous meter reset, not counted": "Epäselvä mittarin nollaus, ei laskettu", + Cumulative: "Kumulatiivinen", + "Reset total to zoomed range": "Nollaa kokonaismäärä zoomattuun alueeseen" }; })); //#endregion @@ -3246,7 +3248,9 @@ "All event types": "Tous les types d'événement", Consumption: "Consommation", "Meter reset detected": "Réinitialisation du compteur détectée", - "Ambiguous meter reset, not counted": "Réinitialisation du compteur ambiguë, non comptée" + "Ambiguous meter reset, not counted": "Réinitialisation du compteur ambiguë, non comptée", + Cumulative: "Cumulé", + "Reset total to zoomed range": "Réinitialiser le total sur la plage zoomée" }; })); //#endregion @@ -3951,7 +3955,9 @@ "All event types": "Alle Ereignistypen", Consumption: "Verbrauch", "Meter reset detected": "Zählerrücksetzung erkannt", - "Ambiguous meter reset, not counted": "Unklare Zählerrücksetzung, nicht gezählt" + "Ambiguous meter reset, not counted": "Unklare Zählerrücksetzung, nicht gezählt", + Cumulative: "Kumulativ", + "Reset total to zoomed range": "Summe auf den gezoomten Bereich zurücksetzen" }; })); //#endregion @@ -4656,7 +4662,9 @@ "All event types": "Todos los tipos de evento", Consumption: "Consumo", "Meter reset detected": "Reinicio del contador detectado", - "Ambiguous meter reset, not counted": "Reinicio del contador ambiguo, no contabilizado" + "Ambiguous meter reset, not counted": "Reinicio del contador ambiguo, no contabilizado", + Cumulative: "Acumulado", + "Reset total to zoomed range": "Reiniciar el total al rango ampliado" }; })); //#endregion @@ -5361,7 +5369,9 @@ "All event types": "Todos os tipos de evento", Consumption: "Consumo", "Meter reset detected": "Reposição do contador detetada", - "Ambiguous meter reset, not counted": "Reposição do contador ambígua, não contabilizada" + "Ambiguous meter reset, not counted": "Reposição do contador ambígua, não contabilizada", + Cumulative: "Acumulado", + "Reset total to zoomed range": "Repor o total no intervalo ampliado" }; })); //#endregion @@ -6066,7 +6076,9 @@ "All event types": "所有事件类型", Consumption: "用量", "Meter reset detected": "检测到电表重置", - "Ambiguous meter reset, not counted": "重置情况不明确,未计入" + "Ambiguous meter reset, not counted": "重置情况不明确,未计入", + Cumulative: "累计", + "Reset total to zoomed range": "将累计值重置为缩放范围" }; })); //#endregion @@ -9223,6 +9235,10 @@ //#endregion //#region custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts var UNKNOWN_STATES = /* @__PURE__ */ new Set(["unknown", "unavailable"]); + /** Append the cumulative marker so the legend tells totals from per-period bars. */ + function withCumulativeSuffix(definition, label) { + return definition.mode === "cumulative" ? `${label} · ${msg("Cumulative")}` : label; + } function getAggregateSeriesId(entityId, definitionId) { return `aggregate:${entityId}:${definitionId}`; } @@ -9269,6 +9285,7 @@ definition, samples, range: args.range, + zoom: args.zoom, timeZone: args.timeZone, coverage }); @@ -9283,7 +9300,7 @@ legendEntityId: id, sourceEntityId: args.entityId, definitionId: definition.id, - label: definition.label || `${args.entityLabel} · ${describe(definition, stateLabel)}`, + label: definition.label || withCumulativeSuffix(definition, `${args.entityLabel} · ${describe(definition, stateLabel)}`), unit: result.unit, pts, periods: valued.map((p) => ({ @@ -9312,6 +9329,7 @@ definition, samples, range: args.range, + zoom: args.zoom, timeZone: args.timeZone, unit: args.unit, coverage: [args.range] @@ -9326,7 +9344,7 @@ legendEntityId: id, sourceEntityId: args.entityId, definitionId: definition.id, - label: definition.label || `${args.entityLabel} · ${intervalLabel} · ${operationLabel}`, + label: definition.label || withCumulativeSuffix(definition, `${args.entityLabel} · ${intervalLabel} · ${operationLabel}`), unit: args.unit, pts: valued.map((p) => [(p.start + p.end) / 2, p.value]), periods: valued.map((p) => ({ @@ -9372,6 +9390,7 @@ definition, samples, range: args.range, + zoom: args.zoom, timeZone: args.timeZone, coverage: [args.range] }); @@ -9385,7 +9404,7 @@ legendEntityId: id, sourceEntityId: args.entityId, definitionId: definition.id, - label: definition.label || `${args.entityLabel} · ${intervalLabel} · ${typesLabel}`, + label: definition.label || withCumulativeSuffix(definition, `${args.entityLabel} · ${intervalLabel} · ${typesLabel}`), unit: result.unit, pts: valued.map((p) => [(p.start + p.end) / 2, p.value]), periods: valued.map((p) => ({ @@ -9439,6 +9458,7 @@ definition, samples, range: args.range, + zoom: args.zoom, timeZone: args.timeZone, stateClass: args.stateClass, unit: args.unit, @@ -9453,7 +9473,7 @@ legendEntityId: id, sourceEntityId: args.entityId, definitionId: definition.id, - label: definition.label || `${args.entityLabel} · ${intervalLabel} · ${msg("Consumption")}`, + label: definition.label || withCumulativeSuffix(definition, `${args.entityLabel} · ${intervalLabel} · ${msg("Consumption")}`), unit: args.unit, pts: valued.map((p) => [(p.start + p.end) / 2, p.value]), periods: valued.map((p) => ({ @@ -16115,7 +16135,8 @@ start: t0, end: t1 }, - timeZone: this._getTimeZone() + timeZone: this._getTimeZone(), + zoom: this._zoomRange ?? void 0 }); for (const aggregate of aggregateSeries) { let axis = axisMap.get(aggregate.axisKey); @@ -16157,7 +16178,8 @@ start: t0, end: t1 }, - timeZone: this._getTimeZone() + timeZone: this._getTimeZone(), + zoom: this._zoomRange ?? void 0 })) { let axis = axisMap.get(aggregate.axisKey); if (!axis) { @@ -16217,6 +16239,7 @@ end: t1 }, timeZone: this._getTimeZone(), + zoom: this._zoomRange ?? void 0, unit, axisKey }); @@ -16235,7 +16258,8 @@ start: t0, end: t1 }, - timeZone: this._getTimeZone() + timeZone: this._getTimeZone(), + zoom: this._zoomRange ?? void 0 }); for (const aggregate of consumptionSeries) { let consumptionAxis = axisMap.get(aggregate.axisKey); @@ -24802,6 +24826,7 @@ interval: this._selectValue(e) }))} > + ${this._renderCumulativeControls(definition)}
+ `; + } + /** Counts, durations and consumption can accumulate; percentages cannot. */ + _canAccumulate(definition) { + return [ + "consumption", + "transition_count", + "state_duration", + "event_count" + ].includes(definition.operation); + } + _renderCumulativeControls(definition) { + if (!this._canAccumulate(definition)) return A; + const cumulative = definition.mode === "cumulative"; + return b` + + ${cumulative ? b`` : A} `; } _onEventTypeToggle(definition, type, checked) { @@ -24862,6 +24926,7 @@ ${type} `)} + ${this._renderCumulativeControls(definition)}
+
`; } disconnectedCallback() { @@ -15416,6 +15523,25 @@ _normalizeStatisticsHistory(entityId, statsData) { return normalizeStatisticsHistory(entityId, statsData); } + /** + * Plain-text explanation of the partial/incomplete markers and of the limits + * of retained history, so the indicators do not rely on colour or shape alone. + */ + _renderAggregateNote(series) { + const noteEl = this.querySelector("#aggregate-note"); + if (!noteEl) return; + const aggregates = series.filter((item) => item.definitionId && item.result); + const flagged = aggregates.some((item) => item.periodFlags?.some((flag) => flag.partial || flag.incomplete)); + const hasCounts = aggregates.some((item) => ["transition_count", "event_count"].includes(item.result.points.length ? this._aggregateOperationOf(item) : "")); + const lines = []; + if (flagged) lines.push(msg("Dashed outlines mark partial periods cut by the selected range; hatched or dotted marks show periods with gaps in recorded history.")); + if (hasCounts) lines.push(msg("Counts only include activity in retained history; earlier activity is not shown.")); + D(b`${lines.map((line) => b`
${line}
`)}`, noteEl); + noteEl.hidden = lines.length === 0; + } + _aggregateOperationOf(item) { + return (this._seriesSettings.find((row) => row.entity_id === item.sourceEntityId)?.analysis?.aggregates?.find((d) => d.id === item.definitionId))?.operation ?? ""; + } /** Render legend items for the given series, binary backgrounds and rows. */ _renderLegend(series, binaryBackgrounds, binaryRows = []) { const legendEl = this.querySelector("#legend"); @@ -15554,7 +15680,15 @@ const showGaps = config?.show_data_gaps !== false; const gapThresholdMs = SAMPLE_INTERVAL_MS[config?.data_gap_threshold || "2h"] ?? 72e5; if (opts.bars === true) { - renderer.drawBars(pts, color, t0, t1, min, max, { fillAlpha: opts.lineOpacity ?? .78 }); + renderer.drawBars(pts, color, t0, t1, min, max, { + fillAlpha: opts.lineOpacity ?? .78, + pointStyles: opts.periodFlags + }); + return; + } + if (opts.periodFlags) { + r.drawLine(pts, color, t0, t1, min, max, opts); + renderer.drawPeriodMarkers(pts, opts.periodFlags, color, t0, t1, min, max); return; } if (!showGaps || pts.length < 2 || !Number.isFinite(gapThresholdMs)) { @@ -16535,6 +16669,7 @@ zoomOutButton.onclick = () => this._clearZoomRange(); } this._renderLegend(series, binaryBackgrounds, binaryRows); + this._renderAggregateNote(series); await this._drawSplitChart({ visibleSeries, binaryBackgrounds, @@ -17075,7 +17210,8 @@ lineOpacity: mainSeriesHoverOpacity, lineWidth: this._config?.comparison_hover_active === true ? 1.25 : void 0, stepped: (analysisMap.get(s.entityId) || normalizeHistorySeriesAnalysis(null)).stepped_series === true, - bars: s.bars === true + bars: s.bars === true, + periodFlags: s.periodFlags }); } const trendHoverSeries = trendSeries.map((seriesItem) => { @@ -17244,6 +17380,7 @@ binaryRowLaneCenters }); this._renderLegend(series, binaryBackgrounds, binaryRows); + this._renderAggregateNote(series); const eventValueMap = new Map(eventHits.map((hit) => [hit.event.id, hit])); const enrichedEvents = chartEvents.map((event) => { const hit = eventValueMap.get(event.id || ""); @@ -17596,7 +17733,8 @@ lineWidth: comparisonPreviewActive ? 1.25 : 1.75, lineOpacity: mainSeriesOpacity, stepped: rowAnalysis.stepped_series === true, - bars: seriesItem.bars === true + bars: seriesItem.bars === true, + periodFlags: seriesItem.periodFlags }); for (const win of drawableComparisonResults || []) { const winPts = await this._resolveComparisonWindowPoints(seriesItem.entityId, win, rowAnalysis, renderT0, renderT1); diff --git a/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.styles.ts b/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.styles.ts index ed96ba57..e18ff7a8 100644 --- a/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.styles.ts +++ b/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.styles.ts @@ -412,6 +412,16 @@ export const styles = ` background: color-mix(in srgb, var(--primary-color, #03a9f4) 18%, var(--card-background-color, #fff)); outline: none; } + .aggregate-note { + padding: var(--dp-spacing-xs, 4px) var(--dp-spacing-md); + padding-left: calc(var(--dp-spacing-md) + var(--dp-chart-axis-left-width, 0px)); + font-size: 0.74em; + color: var(--secondary-text-color); + border-top: 1px solid color-mix(in srgb, var(--divider-color, rgba(0, 0, 0, 0.12)) 88%, transparent); + } + .aggregate-note[hidden] { + display: none; + } .legend { display: flex; flex-wrap: nowrap; diff --git a/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.ts b/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.ts index e9d3dab2..aa9a7c6c 100644 --- a/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.ts +++ b/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.ts @@ -311,6 +311,7 @@ export class HistoryChart extends HTMLElement {
+
`; } @@ -873,6 +874,58 @@ export class HistoryChart extends HTMLElement { return normalizeStatisticsHistory(entityId, statsData); } + /** + * Plain-text explanation of the partial/incomplete markers and of the limits + * of retained history, so the indicators do not rely on colour or shape alone. + */ + _renderAggregateNote(series: unknown[]): void { + const noteEl = this.querySelector( + "#aggregate-note" + ) as Nullable; + if (!noteEl) { + return; + } + const aggregates = (series as AggregateChartSeries[]).filter( + (item) => item.definitionId && item.result + ); + const flagged = aggregates.some((item) => + item.periodFlags?.some((flag) => flag.partial || flag.incomplete) + ); + const hasCounts = aggregates.some((item) => + ["transition_count", "event_count"].includes( + item.result.points.length ? this._aggregateOperationOf(item) : "" + ) + ); + const lines: string[] = []; + if (flagged) { + lines.push( + msg( + "Dashed outlines mark partial periods cut by the selected range; hatched or dotted marks show periods with gaps in recorded history." + ) + ); + } + if (hasCounts) { + lines.push( + msg( + "Counts only include activity in retained history; earlier activity is not shown." + ) + ); + } + render(html`${lines.map((line) => html`
${line}
`)}`, noteEl); + noteEl.hidden = lines.length === 0; + } + + _aggregateOperationOf(item: AggregateChartSeries): string { + const rows = this._seriesSettings as Array<{ + entity_id: string; + analysis?: { aggregates?: AggregateDefinition[] }; + }>; + const definition = rows + .find((row) => row.entity_id === item.sourceEntityId) + ?.analysis?.aggregates?.find((d) => d.id === item.definitionId); + return definition?.operation ?? ""; + } + /** Render legend items for the given series, binary backgrounds and rows. */ _renderLegend( series: unknown[], @@ -1119,9 +1172,29 @@ export class HistoryChart extends HTMLElement { } ).drawBars(pts, color, t0, t1, min, max, { fillAlpha: (opts.lineOpacity as number | undefined) ?? 0.78, + pointStyles: opts.periodFlags, }); return; } + if (opts.periodFlags) { + // Aggregate lines have one point per calendar period, so the data-gap + // splitter (tuned for raw samples) must not break them up. + r.drawLine(pts, color, t0, t1, min, max, opts); + ( + renderer as { + drawPeriodMarkers: ( + pts: [number, number][], + styles: unknown, + color: string, + t0: number, + t1: number, + min: number, + max: number + ) => void; + } + ).drawPeriodMarkers(pts, opts.periodFlags, color, t0, t1, min, max); + return; + } if (!showGaps || pts.length < 2 || !Number.isFinite(gapThresholdMs)) { r.drawLine(pts, color, t0, t1, min, max, opts); @@ -2537,6 +2610,7 @@ export class HistoryChart extends HTMLElement { stepped?: boolean; bars?: boolean; periods?: { start: number; end: number }[]; + periodFlags?: { partial: boolean; incomplete: boolean }[]; }> = []; const axes: Array<{ key: string; @@ -3242,6 +3316,7 @@ export class HistoryChart extends HTMLElement { zoomOutButton.onclick = () => this._clearZoomRange(); } this._renderLegend(series, binaryBackgrounds, binaryRows); + this._renderAggregateNote(series); await this._drawSplitChart({ visibleSeries, binaryBackgrounds, @@ -4227,6 +4302,7 @@ export class HistoryChart extends HTMLElement { normalizeHistorySeriesAnalysis(null) ).stepped_series === true, bars: s.bars === true, + periodFlags: (s as { periodFlags?: unknown }).periodFlags, } ); } @@ -4605,6 +4681,7 @@ export class HistoryChart extends HTMLElement { ); this._renderLegend(series, binaryBackgrounds, binaryRows); + this._renderAggregateNote(series); const eventValueMap = new Map(eventHits.map((hit) => [hit.event.id, hit])); const enrichedEvents: ChartEventRecord[] = chartEvents.map( @@ -5489,6 +5566,7 @@ export class HistoryChart extends HTMLElement { lineOpacity: mainSeriesOpacity, stepped: rowAnalysis.stepped_series === true, bars: seriesItem.bars === true, + periodFlags: seriesItem.periodFlags, } ); } diff --git a/custom_components/hass_datapoints/src/cards/history/history-chart/stories/history-chart.stories.ts b/custom_components/hass_datapoints/src/cards/history/history-chart/stories/history-chart.stories.ts index bbe414c6..9f234e2a 100644 --- a/custom_components/hass_datapoints/src/cards/history/history-chart/stories/history-chart.stories.ts +++ b/custom_components/hass_datapoints/src/cards/history/history-chart/stories/history-chart.stories.ts @@ -373,6 +373,14 @@ export const BinaryAggregateBars: Story = { "Front Door", ]) ); + // The 24h window cuts both days short, so the partial-period explanation + // and the retained-history limit are shown as text, not only as styling. + const note = el.querySelector("#aggregate-note") as HTMLElement; + await waitFor(() => { + expect(note.hidden).toBe(false); + expect(note.textContent).toContain("partial periods"); + expect(note.textContent).toContain("retained history"); + }); }, }; diff --git a/custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts b/custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts index 97cfb841..b6fa0e98 100644 --- a/custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts +++ b/custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts @@ -425,3 +425,56 @@ describe("aggregates for a comparison window", () => { }); }); }); + +describe("coverage flags", () => { + const stateList = [ + { s: "off", lu: sec(start + 6 * hour) }, + { s: "on", lu: sec(start + 8 * hour) }, + { s: "unavailable", lu: sec(start + 12 * hour) }, + { s: "on", lu: sec(start + 16 * hour) }, + ]; + const run = (range: { start: number; end: number }) => + buildBinaryAggregateSeries({ + entityId: "binary_sensor.front_door", + entityLabel: "Front Door", + color: "#ff9800", + deviceClass: "door", + definitions: [definition({ operation: "state_duration" })], + stateList, + range, + timeZone: "UTC", + })[0]; + + describe("GIVEN a range cutting the day short and an unavailable gap", () => { + describe("WHEN calculated", () => { + it("THEN partial and incomplete are flagged separately with tooltip notes", () => { + expect.assertions(4); + const series = run({ start, end: start + 20 * hour }); + const [flags] = series.periodFlags!; + expect(flags.partial).toBe(true); + expect(flags.incomplete).toBe(true); + expect(series.periodNotes![0]).toContain("Partial period"); + expect(series.periodNotes![0]).toContain("Incomplete history"); + }); + }); + describe("WHEN the range covers the whole day with no gaps", () => { + it("THEN the period is neither partial nor incomplete", () => { + expect.assertions(2); + const series = buildBinaryAggregateSeries({ + entityId: "binary_sensor.front_door", + entityLabel: "Front Door", + color: "#ff9800", + definitions: [definition({ operation: "state_duration" })], + stateList: [{ s: "on", lu: sec(start) }], + range: { start, end: start + day }, + timeZone: "UTC", + })[0]; + expect(series.periodFlags![0]).toStrictEqual({ + partial: false, + incomplete: false, + }); + expect(series.periodNotes![0]).toBe(""); + }); + }); + }); +}); diff --git a/custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts b/custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts index f4b3e9db..36a68902 100644 --- a/custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts +++ b/custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts @@ -32,6 +32,8 @@ export interface AggregateChartSeries { periods: { start: number; end: number }[]; /** Localized caveats per point (e.g. a detected meter reset), aligned with `pts`. */ periodNotes?: string[]; + /** Per-point coverage: edge-clipped (partial) vs gaps in history (incomplete). */ + periodFlags?: { partial: boolean; incomplete: boolean }[]; color: string; axisKey: string; bars: boolean; @@ -62,6 +64,34 @@ function withCumulativeSuffix( : label; } +interface ValuedPoint { + partial: boolean; + incomplete: boolean; + reasons: string[]; +} + +/** Coverage flags and tooltip notes for each plotted period. */ +function periodDetails( + valued: ValuedPoint[], + reasonNotes?: (reasons: string[]) => string +) { + return { + periodFlags: valued.map((p) => ({ + partial: p.partial, + incomplete: p.incomplete, + })), + periodNotes: valued.map((p) => + [ + p.partial ? msg("Partial period") : "", + p.incomplete ? msg("Incomplete history") : "", + reasonNotes ? reasonNotes(p.reasons) : "", + ] + .filter(Boolean) + .join(", ") + ), + }; +} + export function getAggregateSeriesId( entityId: string, definitionId: string @@ -166,6 +196,7 @@ export function buildBinaryAggregateSeries( unit: result.unit, pts, periods: valued.map((p) => ({ start: p.start, end: p.end })), + ...periodDetails(valued), color: definition.color || args.color, axisKey: `aggregate:${result.unit || "count"}`, bars: result.presentation === "bar", @@ -254,6 +285,7 @@ export function buildMeasurementAggregateSeries( p.value, ]), periods: valued.map((p) => ({ start: p.start, end: p.end })), + ...periodDetails(valued), color: definition.color || args.colors[index % args.colors.length], axisKey: args.axisKey, bars: result.presentation === "bar", @@ -370,6 +402,7 @@ export function buildEventAggregateSeries( p.value, ]), periods: valued.map((p) => ({ start: p.start, end: p.end })), + ...periodDetails(valued), color: definition.color || args.color, axisKey: `aggregate:${result.unit || "count"}`, bars: result.presentation === "bar", @@ -489,7 +522,7 @@ export function buildConsumptionAggregateSeries( p.value, ]), periods: valued.map((p) => ({ start: p.start, end: p.end })), - periodNotes: valued.map((p) => describeConsumptionReasons(p.reasons)), + ...periodDetails(valued, describeConsumptionReasons), color: definition.color || args.color, axisKey: `aggregate:${args.unit || "count"}`, bars: result.presentation === "bar", diff --git a/custom_components/hass_datapoints/src/lib/chart/chart-renderer.ts b/custom_components/hass_datapoints/src/lib/chart/chart-renderer.ts index eaa95997..d361bfd8 100644 --- a/custom_components/hass_datapoints/src/lib/chart/chart-renderer.ts +++ b/custom_components/hass_datapoints/src/lib/chart/chart-renderer.ts @@ -81,9 +81,14 @@ type DrawLineOptions = { lineWidth?: number; }; +/** Coverage of one aggregate period: clipped by the range edge, or with gaps in history. */ +export type PeriodStyle = { partial: boolean; incomplete: boolean }; + type DrawBarsOptions = { fillAlpha?: number; widthFactor?: number; + /** Aligned with the points; partial bars are dashed, incomplete bars hatched. */ + pointStyles?: PeriodStyle[]; }; type DrawAnnotationsOptions = { @@ -850,11 +855,78 @@ export class ChartRenderer { const top = Math.min(y, baselineY); const height = Math.max(1, Math.abs(baselineY - y)); const left = x - barWidth / 2; + const style = options.pointStyles?.[i]; + if (style?.partial) { + ctx.fillStyle = hexToRgba(color, fillAlpha * 0.5); + } else { + ctx.fillStyle = hexToRgba(color, fillAlpha); + } ctx.fillRect(left, top, barWidth, height); + if (style?.incomplete) { + ctx.save(); + ctx.beginPath(); + ctx.rect(left, top, barWidth, height); + ctx.clip(); + ctx.strokeStyle = color; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let d = -height; d < barWidth; d += 5) { + ctx.moveTo(left + d, top + height); + ctx.lineTo(left + d + height, top); + } + ctx.stroke(); + ctx.restore(); + } + if (style?.partial || style?.incomplete) { + ctx.save(); + ctx.setLineDash(style.partial ? [4, 3] : [1, 2]); + ctx.strokeStyle = color; + ctx.lineWidth = 1; + ctx.strokeRect(left + 0.5, top + 0.5, barWidth - 1, height - 1); + ctx.restore(); + } } ctx.restore(); } + /** + * Point markers for an aggregate line: filled when the period is complete, + * hollow when partial, dotted ring when history is incomplete. + */ + drawPeriodMarkers( + points: ChartPoint[], + styles: PeriodStyle[], + color: string, + t0: number, + t1: number, + vMin: number, + vMax: number + ): void { + const { ctx } = this; + ctx.save(); + ctx.strokeStyle = color; + ctx.fillStyle = color; + ctx.lineWidth = 1.5; + points.forEach(([t, v], i) => { + const style = styles[i]; + const x = this.xOf(t, t0, t1); + const y = this.yOf(v, vMin, vMax); + ctx.beginPath(); + ctx.arc(x, y, 3.5, 0, Math.PI * 2); + if (style?.partial || style?.incomplete) { + ctx.setLineDash(style.partial ? [] : [1.5, 1.5]); + ctx.fillStyle = hexToRgba(color, 0.15); + ctx.fill(); + ctx.stroke(); + } else { + ctx.setLineDash([]); + ctx.fillStyle = color; + ctx.fill(); + } + }); + ctx.restore(); + } + drawStateBands( spans: Nullable> | undefined, t0: number, diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts index bd492032..b8363629 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts @@ -37,4 +37,10 @@ export const translations: ComponentTranslations = { "Unklare Zählerrücksetzung, nicht gezählt", Cumulative: "Kumulativ", "Reset total to zoomed range": "Summe auf den gezoomten Bereich zurücksetzen", + "Partial period": "Teilperiode", + "Incomplete history": "Unvollständiger Verlauf", + "Dashed outlines mark partial periods cut by the selected range; hatched or dotted marks show periods with gaps in recorded history.": + "Gestrichelte Umrandungen kennzeichnen Teilperioden am Rand des gewählten Bereichs; schraffierte oder gepunktete Markierungen zeigen Zeiträume mit Lücken im aufgezeichneten Verlauf.", + "Counts only include activity in retained history; earlier activity is not shown.": + "Zählungen enthalten nur Aktivität im gespeicherten Verlauf; frühere Aktivität wird nicht angezeigt.", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts index cb7eb8f7..1a6b5b7d 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts @@ -37,4 +37,10 @@ export const translations: ComponentTranslations = { "Reinicio del contador ambiguo, no contabilizado", Cumulative: "Acumulado", "Reset total to zoomed range": "Reiniciar el total al rango ampliado", + "Partial period": "Periodo parcial", + "Incomplete history": "Historial incompleto", + "Dashed outlines mark partial periods cut by the selected range; hatched or dotted marks show periods with gaps in recorded history.": + "Los contornos discontinuos marcan los periodos parciales cortados por el rango seleccionado; las marcas rayadas o punteadas indican periodos con huecos en el historial registrado.", + "Counts only include activity in retained history; earlier activity is not shown.": + "Los recuentos solo incluyen la actividad del historial conservado; la actividad anterior no se muestra.", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts index f42ec8a4..fde442b1 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts @@ -37,4 +37,10 @@ export const translations: ComponentTranslations = { "Epäselvä mittarin nollaus, ei laskettu", Cumulative: "Kumulatiivinen", "Reset total to zoomed range": "Nollaa kokonaismäärä zoomattuun alueeseen", + "Partial period": "Osittainen jakso", + "Incomplete history": "Puutteellinen historia", + "Dashed outlines mark partial periods cut by the selected range; hatched or dotted marks show periods with gaps in recorded history.": + "Katkoviivareunat osoittavat valitun aikavälin reunoilla katkenneet osittaiset jaksot; viivoitetut tai pisteviivamerkinnät osoittavat jaksot, joiden tallennetussa historiassa on aukkoja.", + "Counts only include activity in retained history; earlier activity is not shown.": + "Laskennat sisältävät vain säilytetyn historian toiminnan; aiempaa toimintaa ei näytetä.", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts index d8200511..2371d36e 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts @@ -37,4 +37,10 @@ export const translations: ComponentTranslations = { "Réinitialisation du compteur ambiguë, non comptée", Cumulative: "Cumulé", "Reset total to zoomed range": "Réinitialiser le total sur la plage zoomée", + "Partial period": "Période partielle", + "Incomplete history": "Historique incomplet", + "Dashed outlines mark partial periods cut by the selected range; hatched or dotted marks show periods with gaps in recorded history.": + "Les contours en pointillés signalent les périodes partielles coupées par la plage sélectionnée ; les marques hachurées ou pointillées indiquent des périodes avec des lacunes dans l'historique enregistré.", + "Counts only include activity in retained history; earlier activity is not shown.": + "Les décomptes n'incluent que l'activité de l'historique conservé ; l'activité antérieure n'est pas affichée.", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts index ab414775..eb1c026c 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts @@ -37,4 +37,10 @@ export const translations: ComponentTranslations = { "Reposição do contador ambígua, não contabilizada", Cumulative: "Acumulado", "Reset total to zoomed range": "Repor o total no intervalo ampliado", + "Partial period": "Período parcial", + "Incomplete history": "Histórico incompleto", + "Dashed outlines mark partial periods cut by the selected range; hatched or dotted marks show periods with gaps in recorded history.": + "Os contornos tracejados indicam períodos parciais cortados pelo intervalo selecionado; as marcas tracejadas finas ou pontilhadas indicam períodos com lacunas no histórico registado.", + "Counts only include activity in retained history; earlier activity is not shown.": + "As contagens só incluem a atividade do histórico retido; a atividade anterior não é mostrada.", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts index 51f518a9..9f8aa7e9 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts @@ -36,4 +36,10 @@ export const translations: ComponentTranslations = { "Ambiguous meter reset, not counted": "重置情况不明确,未计入", Cumulative: "累计", "Reset total to zoomed range": "将累计值重置为缩放范围", + "Partial period": "部分时段", + "Incomplete history": "历史记录不完整", + "Dashed outlines mark partial periods cut by the selected range; hatched or dotted marks show periods with gaps in recorded history.": + "虚线轮廓表示被所选范围截断的部分时段;阴影或点状标记表示已记录历史中存在缺口的时段。", + "Counts only include activity in retained history; earlier activity is not shown.": + "计数仅包含保留历史中的活动;更早的活动不会显示。", }; From 0d5e039647cbce2645caeb8a5d0e140ed4be29c8 Mon Sep 17 00:00:00 2001 From: Ollie Date: Sat, 24 Oct 2026 22:57:36 +0300 Subject: [PATCH 13/17] feat(aggregation): verify mixed-unit aggregates across layouts Refs #98 Co-Authored-By: Claude Sonnet 5.5 --- .../stories/history-chart.stories.ts | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/custom_components/hass_datapoints/src/cards/history/history-chart/stories/history-chart.stories.ts b/custom_components/hass_datapoints/src/cards/history/history-chart/stories/history-chart.stories.ts index 9f234e2a..9c53c4b2 100644 --- a/custom_components/hass_datapoints/src/cards/history/history-chart/stories/history-chart.stories.ts +++ b/custom_components/hass_datapoints/src/cards/history/history-chart/stories/history-chart.stories.ts @@ -514,3 +514,110 @@ export const BinaryAggregateComparison: Story = { ); }, }; + +// ── (g) Mixed units ───────────────────────────────────────────────────────────── +const mixedSettings = () => [ + { + entity_id: "sensor.temperature", + analysis: { + aggregates: [ + { + id: "mean", + operation: "mean", + interval: "hour", + mode: "interval", + resetOnZoom: false, + }, + ], + }, + }, + { + entity_id: "binary_sensor.front_door", + analysis: { + aggregates: [ + dailyOpens, + { + ...dailyOpens, + id: "open-time", + operation: "state_duration", + }, + ], + }, + }, +]; + +const mixedHistory = () => ({ + "sensor.temperature": numericHistory([ + { at: 0, v: 19 }, + { at: 0.5, v: 21 }, + { at: 1, v: 20 }, + ]), + "binary_sensor.front_door": doorToggles, +}); + +/** + * Counts, minutes and temperature never share a value axis: each unit gets its + * own axis, while the mean line reuses the temperature axis. + */ +export const MixedUnitAggregatesCombined: Story = { + render: () => makeChart(), + play: async ({ canvasElement }) => { + const el = canvasElement.querySelector( + "hass-datapoints-history-chart" + ) as DrawableChart; + el.draw(drawArgs({ series_settings: mixedSettings() }, mixedHistory())); + + await waitFor(() => expect(legendLabels(el)).toHaveLength(5)); + const axisUnits = () => + Array.from(el.querySelectorAll(".chart-axis-unit")).map((node) => + node.textContent?.trim() + ); + // Temperature and its mean share one axis; minutes get their own, so the + // two units are never drawn against a single scale. + await waitFor(() => { + expect(axisUnits()).toContain("min"); + expect(axisUnits().filter((unit) => unit === "min")).toHaveLength(1); + expect(axisUnits().length).toBeGreaterThanOrEqual(2); + }); + + // Legend toggles hide an aggregate like any other series. + const toggles = () => + Array.from( + el.querySelectorAll("#legend .legend-toggle") + ); + const minutesToggle = toggles().find((button) => + button.textContent?.includes("Time in a state") + )!; + await userEvent.click(minutesToggle); + await waitFor(() => + expect( + toggles() + .find((button) => button.textContent?.includes("Time in a state")) + ?.getAttribute("aria-pressed") + ).toBe("false") + ); + }, +}; + +/** In split view each aggregate series gets its own row with its own axis. */ +export const MixedUnitAggregatesSplit: Story = { + render: () => makeChart(), + play: async ({ canvasElement }) => { + const el = canvasElement.querySelector( + "hass-datapoints-history-chart" + ) as DrawableChart; + el.draw( + drawArgs( + { split_view: true, series_settings: mixedSettings() }, + mixedHistory() + ) + ); + + await waitFor(() => expect(legendLabels(el)).toHaveLength(5)); + await waitFor(() => + expect( + el.querySelectorAll(".split-series-row").length + ).toBeGreaterThanOrEqual(4) + ); + }, +}; From c7efcb9b7ccc9e43006f5cbf3a08be077acb213f Mon Sep 17 00:00:00 2001 From: Ollie Date: Sat, 24 Oct 2026 23:00:54 +0300 Subject: [PATCH 14/17] test(history-chart): give chart stories a realistic wait window Refs #99 Co-Authored-By: Claude Sonnet 5.5 --- .../history/history-chart/stories/history-chart.stories.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/custom_components/hass_datapoints/src/cards/history/history-chart/stories/history-chart.stories.ts b/custom_components/hass_datapoints/src/cards/history/history-chart/stories/history-chart.stories.ts index 9c53c4b2..d21506ce 100644 --- a/custom_components/hass_datapoints/src/cards/history/history-chart/stories/history-chart.stories.ts +++ b/custom_components/hass_datapoints/src/cards/history/history-chart/stories/history-chart.stories.ts @@ -14,10 +14,15 @@ * HA connection. */ import type { Meta, StoryObj } from "@storybook/web-components-vite"; -import { expect, userEvent, waitFor } from "storybook/test"; +import { expect, userEvent, waitFor as baseWaitFor } from "storybook/test"; import { createMockHass } from "@/test-support/mock-hass"; import "../history-chart"; +// The chart draws asynchronously (analysis, downsampling, layout); under a full +// parallel storybook run the default 1s window is too tight. +const waitFor = (callback: () => T | Promise) => + baseWaitFor(callback, { timeout: 5000 }); + type DrawableChart = HTMLElement & { hass: unknown; draw(model: Record): void; From 8b774dd198fe6b3b7fd6ef6beb047d4095d5ea8f Mon Sep 17 00:00:00 2001 From: Ollie Date: Sun, 25 Oct 2026 09:51:24 +0200 Subject: [PATCH 15/17] refactor(aggregation): control aggregates via nested checkboxes like anomalies Refs #67 Co-Authored-By: Claude Sonnet 5.5 --- .../hass_datapoints/hass-datapoints-cards.js | 470 ++++++++---------- .../analysis-aggregate-group.spec.ts | 428 ++++++++-------- .../analysis-aggregate-group.styles.ts | 54 +- .../analysis-aggregate-group.ts | 392 +++++++-------- .../analysis-aggregate-group/i18n/de.ts | 8 +- .../analysis-aggregate-group/i18n/es.ts | 8 +- .../analysis-aggregate-group/i18n/fi.ts | 8 +- .../analysis-aggregate-group/i18n/fr.ts | 8 +- .../analysis-aggregate-group/i18n/pt.ts | 8 +- .../analysis-aggregate-group/i18n/zh-hans.ts | 8 +- .../analysis-aggregate-group.stories.ts | 44 +- 11 files changed, 669 insertions(+), 767 deletions(-) diff --git a/custom_components/hass_datapoints/hass-datapoints-cards.js b/custom_components/hass_datapoints/hass-datapoints-cards.js index 54e0f641..55f6578a 100644 --- a/custom_components/hass_datapoints/hass-datapoints-cards.js +++ b/custom_components/hass_datapoints/hass-datapoints-cards.js @@ -2510,11 +2510,6 @@ var init_fi$19 = __esmMin((() => { translations$163 = { "Aggregate series": "Koosteet", - "Add aggregate": "Lisää koostesarja", - "Remove aggregate": "Poista koostesarja", - "Aggregate operation": "Koosteen toiminto", - "Aggregate state": "Koosteen tila", - "Aggregate interval": "Koosteen jakso", "Count changes to a state": "Laske muutokset tilaan", "Count all state changes": "Laske kaikki tilamuutokset", "Time in a state": "Aika tilassa", @@ -2547,7 +2542,10 @@ "Partial period": "Osittainen jakso", "Incomplete history": "Puutteellinen historia", "Dashed outlines mark partial periods cut by the selected range; hatched or dotted marks show periods with gaps in recorded history.": "Katkoviivareunat osoittavat valitun aikavälin reunoilla katkenneet osittaiset jaksot; viivoitetut tai pisteviivamerkinnät osoittavat jaksot, joiden tallennetussa historiassa on aukkoja.", - "Counts only include activity in retained history; earlier activity is not shown.": "Laskennat sisältävät vain säilytetyn historian toiminnan; aiempaa toimintaa ei näytetä." + "Counts only include activity in retained history; earlier activity is not shown.": "Laskennat sisältävät vain säilytetyn historian toiminnan; aiempaa toimintaa ei näytetä.", + "Count events": "Laske tapahtumat", + Interval: "Jakso", + State: "Tila" }; })); //#endregion @@ -3221,11 +3219,6 @@ var init_fr$19 = __esmMin((() => { translations$134 = { "Aggregate series": "Séries agrégées", - "Add aggregate": "Ajouter un agrégat", - "Remove aggregate": "Supprimer l'agrégat", - "Aggregate operation": "Opération de l'agrégat", - "Aggregate state": "État de l'agrégat", - "Aggregate interval": "Intervalle de l'agrégat", "Count changes to a state": "Compter les passages à un état", "Count all state changes": "Compter tous les changements d'état", "Time in a state": "Durée dans un état", @@ -3258,7 +3251,10 @@ "Partial period": "Période partielle", "Incomplete history": "Historique incomplet", "Dashed outlines mark partial periods cut by the selected range; hatched or dotted marks show periods with gaps in recorded history.": "Les contours en pointillés signalent les périodes partielles coupées par la plage sélectionnée ; les marques hachurées ou pointillées indiquent des périodes avec des lacunes dans l'historique enregistré.", - "Counts only include activity in retained history; earlier activity is not shown.": "Les décomptes n'incluent que l'activité de l'historique conservé ; l'activité antérieure n'est pas affichée." + "Counts only include activity in retained history; earlier activity is not shown.": "Les décomptes n'incluent que l'activité de l'historique conservé ; l'activité antérieure n'est pas affichée.", + "Count events": "Compter les événements", + Interval: "Intervalle", + State: "État" }; })); //#endregion @@ -3932,11 +3928,6 @@ var init_de$19 = __esmMin((() => { translations$105 = { "Aggregate series": "Aggregierte Serien", - "Add aggregate": "Aggregat hinzufügen", - "Remove aggregate": "Aggregat entfernen", - "Aggregate operation": "Aggregatoperation", - "Aggregate state": "Aggregatzustand", - "Aggregate interval": "Aggregatintervall", "Count changes to a state": "Wechsel in einen Zustand zählen", "Count all state changes": "Alle Zustandswechsel zählen", "Time in a state": "Zeit in einem Zustand", @@ -3969,7 +3960,10 @@ "Partial period": "Teilperiode", "Incomplete history": "Unvollständiger Verlauf", "Dashed outlines mark partial periods cut by the selected range; hatched or dotted marks show periods with gaps in recorded history.": "Gestrichelte Umrandungen kennzeichnen Teilperioden am Rand des gewählten Bereichs; schraffierte oder gepunktete Markierungen zeigen Zeiträume mit Lücken im aufgezeichneten Verlauf.", - "Counts only include activity in retained history; earlier activity is not shown.": "Zählungen enthalten nur Aktivität im gespeicherten Verlauf; frühere Aktivität wird nicht angezeigt." + "Counts only include activity in retained history; earlier activity is not shown.": "Zählungen enthalten nur Aktivität im gespeicherten Verlauf; frühere Aktivität wird nicht angezeigt.", + "Count events": "Ereignisse zählen", + Interval: "Intervall", + State: "Zustand" }; })); //#endregion @@ -4643,11 +4637,6 @@ var init_es$19 = __esmMin((() => { translations$76 = { "Aggregate series": "Series agregadas", - "Add aggregate": "Añadir agregado", - "Remove aggregate": "Quitar agregado", - "Aggregate operation": "Operación del agregado", - "Aggregate state": "Estado del agregado", - "Aggregate interval": "Intervalo del agregado", "Count changes to a state": "Contar cambios a un estado", "Count all state changes": "Contar todos los cambios de estado", "Time in a state": "Tiempo en un estado", @@ -4680,7 +4669,10 @@ "Partial period": "Periodo parcial", "Incomplete history": "Historial incompleto", "Dashed outlines mark partial periods cut by the selected range; hatched or dotted marks show periods with gaps in recorded history.": "Los contornos discontinuos marcan los periodos parciales cortados por el rango seleccionado; las marcas rayadas o punteadas indican periodos con huecos en el historial registrado.", - "Counts only include activity in retained history; earlier activity is not shown.": "Los recuentos solo incluyen la actividad del historial conservado; la actividad anterior no se muestra." + "Counts only include activity in retained history; earlier activity is not shown.": "Los recuentos solo incluyen la actividad del historial conservado; la actividad anterior no se muestra.", + "Count events": "Contar eventos", + Interval: "Intervalo", + State: "Estado" }; })); //#endregion @@ -5354,11 +5346,6 @@ var init_pt$19 = __esmMin((() => { translations$47 = { "Aggregate series": "Séries agregadas", - "Add aggregate": "Adicionar agregado", - "Remove aggregate": "Remover agregado", - "Aggregate operation": "Operação do agregado", - "Aggregate state": "Estado do agregado", - "Aggregate interval": "Intervalo do agregado", "Count changes to a state": "Contar mudanças para um estado", "Count all state changes": "Contar todas as mudanças de estado", "Time in a state": "Tempo num estado", @@ -5391,7 +5378,10 @@ "Partial period": "Período parcial", "Incomplete history": "Histórico incompleto", "Dashed outlines mark partial periods cut by the selected range; hatched or dotted marks show periods with gaps in recorded history.": "Os contornos tracejados indicam períodos parciais cortados pelo intervalo selecionado; as marcas tracejadas finas ou pontilhadas indicam períodos com lacunas no histórico registado.", - "Counts only include activity in retained history; earlier activity is not shown.": "As contagens só incluem a atividade do histórico retido; a atividade anterior não é mostrada." + "Counts only include activity in retained history; earlier activity is not shown.": "As contagens só incluem a atividade do histórico retido; a atividade anterior não é mostrada.", + "Count events": "Contar eventos", + Interval: "Intervalo", + State: "Estado" }; })); //#endregion @@ -6065,11 +6055,6 @@ var init_zh_hans$19 = __esmMin((() => { translations$18 = { "Aggregate series": "聚合序列", - "Add aggregate": "添加聚合", - "Remove aggregate": "移除聚合", - "Aggregate operation": "聚合操作", - "Aggregate state": "聚合状态", - "Aggregate interval": "聚合间隔", "Count changes to a state": "统计变为某状态的次数", "Count all state changes": "统计所有状态变化次数", "Time in a state": "处于某状态的时长", @@ -6102,7 +6087,10 @@ "Partial period": "部分时段", "Incomplete history": "历史记录不完整", "Dashed outlines mark partial periods cut by the selected range; hatched or dotted marks show periods with gaps in recorded history.": "虚线轮廓表示被所选范围截断的部分时段;阴影或点状标记表示已记录历史中存在缺口的时段。", - "Counts only include activity in retained history; earlier activity is not shown.": "计数仅包含保留历史中的活动;更早的活动不会显示。" + "Counts only include activity in retained history; earlier activity is not shown.": "计数仅包含保留历史中的活动;更早的活动不会显示。", + "Count events": "统计事件数", + Interval: "间隔", + State: "状态" }; })); //#endregion @@ -8829,7 +8817,7 @@ //#region custom_components/hass_datapoints/src/lib/domain/aggregation/calculate.ts var MINUTE_MS$1 = 6e4; var UNKNOWN_STATES$1 = /* @__PURE__ */ new Set(["unknown", "unavailable"]); - var CUMULATIVE_OPERATIONS = /* @__PURE__ */ new Set([ + var CUMULATIVE_OPERATIONS$1 = /* @__PURE__ */ new Set([ "consumption", "transition_count", "state_duration", @@ -9043,7 +9031,7 @@ } function calculateAggregate(input) { const { definition } = input; - const cumulative = definition.mode === "cumulative" && CUMULATIVE_OPERATIONS.has(definition.operation); + const cumulative = definition.mode === "cumulative" && CUMULATIVE_OPERATIONS$1.has(definition.operation); if (cumulative && definition.resetOnZoom && input.zoom) { const start = Math.max(input.range.start, input.zoom.start); const end = Math.min(input.range.end, input.zoom.end); @@ -22251,7 +22239,7 @@ var _title_accessor_storage$1 = /* @__PURE__ */ new WeakMap(); var _subtitle_accessor_storage$1 = /* @__PURE__ */ new WeakMap(); var _collapsible_accessor_storage$5 = /* @__PURE__ */ new WeakMap(); - var _open_accessor_storage$9 = /* @__PURE__ */ new WeakMap(); + var _open_accessor_storage$10 = /* @__PURE__ */ new WeakMap(); var _i18n_accessor_storage$6 = /* @__PURE__ */ new WeakMap(); var SidebarSectionHeader = class extends i$2 { constructor(..._args) { @@ -22259,7 +22247,7 @@ _classPrivateFieldInitSpec(this, _title_accessor_storage$1, ""); _classPrivateFieldInitSpec(this, _subtitle_accessor_storage$1, ""); _classPrivateFieldInitSpec(this, _collapsible_accessor_storage$5, false); - _classPrivateFieldInitSpec(this, _open_accessor_storage$9, true); + _classPrivateFieldInitSpec(this, _open_accessor_storage$10, true); _classPrivateFieldInitSpec(this, _i18n_accessor_storage$6, DEFAULT_I18N$5); } get title() { @@ -22281,10 +22269,10 @@ _classPrivateFieldSet2(_collapsible_accessor_storage$5, this, value); } get open() { - return _classPrivateFieldGet2(_open_accessor_storage$9, this); + return _classPrivateFieldGet2(_open_accessor_storage$10, this); } set open(value) { - _classPrivateFieldSet2(_open_accessor_storage$9, this, value); + _classPrivateFieldSet2(_open_accessor_storage$10, this, value); } get i18n() { return _classPrivateFieldGet2(_i18n_accessor_storage$6, this); @@ -22356,7 +22344,7 @@ var _title_accessor_storage = /* @__PURE__ */ new WeakMap(); var _subtitle_accessor_storage = /* @__PURE__ */ new WeakMap(); var _collapsible_accessor_storage$4 = /* @__PURE__ */ new WeakMap(); - var _open_accessor_storage$8 = /* @__PURE__ */ new WeakMap(); + var _open_accessor_storage$9 = /* @__PURE__ */ new WeakMap(); var _i18n_accessor_storage$5 = /* @__PURE__ */ new WeakMap(); var SidebarOptionsSection = class extends i$2 { constructor(..._args) { @@ -22364,7 +22352,7 @@ _classPrivateFieldInitSpec(this, _title_accessor_storage, ""); _classPrivateFieldInitSpec(this, _subtitle_accessor_storage, ""); _classPrivateFieldInitSpec(this, _collapsible_accessor_storage$4, false); - _classPrivateFieldInitSpec(this, _open_accessor_storage$8, true); + _classPrivateFieldInitSpec(this, _open_accessor_storage$9, true); _classPrivateFieldInitSpec(this, _i18n_accessor_storage$5, EMPTY_I18N); } get title() { @@ -22386,10 +22374,10 @@ _classPrivateFieldSet2(_collapsible_accessor_storage$4, this, value); } get open() { - return _classPrivateFieldGet2(_open_accessor_storage$8, this); + return _classPrivateFieldGet2(_open_accessor_storage$9, this); } set open(value) { - _classPrivateFieldSet2(_open_accessor_storage$8, this, value); + _classPrivateFieldSet2(_open_accessor_storage$9, this, value); } get i18n() { return _classPrivateFieldGet2(_i18n_accessor_storage$5, this); @@ -22527,7 +22515,7 @@ var _SidebarDatapointsSection; var _datapointScope_accessor_storage$3; var _collapsible_accessor_storage$3; - var _open_accessor_storage$7; + var _open_accessor_storage$8; var DATAPOINT_SCOPE_OPTIONS = [ { value: "linked", @@ -22542,12 +22530,12 @@ label: "Hide datapoints" } ]; - var SidebarDatapointsSection = (_datapointScope_accessor_storage$3 = /* @__PURE__ */ new WeakMap(), _collapsible_accessor_storage$3 = /* @__PURE__ */ new WeakMap(), _open_accessor_storage$7 = /* @__PURE__ */ new WeakMap(), _SidebarDatapointsSection = class SidebarDatapointsSection extends i$2 { + var SidebarDatapointsSection = (_datapointScope_accessor_storage$3 = /* @__PURE__ */ new WeakMap(), _collapsible_accessor_storage$3 = /* @__PURE__ */ new WeakMap(), _open_accessor_storage$8 = /* @__PURE__ */ new WeakMap(), _SidebarDatapointsSection = class SidebarDatapointsSection extends i$2 { constructor(..._args) { super(..._args); _classPrivateFieldInitSpec(this, _datapointScope_accessor_storage$3, "linked"); _classPrivateFieldInitSpec(this, _collapsible_accessor_storage$3, false); - _classPrivateFieldInitSpec(this, _open_accessor_storage$7, true); + _classPrivateFieldInitSpec(this, _open_accessor_storage$8, true); } get datapointScope() { return _classPrivateFieldGet2(_datapointScope_accessor_storage$3, this); @@ -22562,10 +22550,10 @@ _classPrivateFieldSet2(_collapsible_accessor_storage$3, this, value); } get open() { - return _classPrivateFieldGet2(_open_accessor_storage$7, this); + return _classPrivateFieldGet2(_open_accessor_storage$8, this); } set open(value) { - _classPrivateFieldSet2(_open_accessor_storage$7, this, value); + _classPrivateFieldSet2(_open_accessor_storage$8, this, value); } _onScopeChange(e) { this.dispatchEvent(new CustomEvent("dp-scope-change", { @@ -22689,14 +22677,14 @@ var _showIcons_accessor_storage$2 = /* @__PURE__ */ new WeakMap(); var _showLines_accessor_storage$2 = /* @__PURE__ */ new WeakMap(); var _collapsible_accessor_storage$2 = /* @__PURE__ */ new WeakMap(); - var _open_accessor_storage$6 = /* @__PURE__ */ new WeakMap(); + var _open_accessor_storage$7 = /* @__PURE__ */ new WeakMap(); var SidebarDatapointDisplaySection = (_SidebarDatapointDisplaySection = class SidebarDatapointDisplaySection extends i$2 { constructor(..._args) { super(..._args); _classPrivateFieldInitSpec(this, _showIcons_accessor_storage$2, true); _classPrivateFieldInitSpec(this, _showLines_accessor_storage$2, true); _classPrivateFieldInitSpec(this, _collapsible_accessor_storage$2, false); - _classPrivateFieldInitSpec(this, _open_accessor_storage$6, true); + _classPrivateFieldInitSpec(this, _open_accessor_storage$7, true); } get showIcons() { return _classPrivateFieldGet2(_showIcons_accessor_storage$2, this); @@ -22717,10 +22705,10 @@ _classPrivateFieldSet2(_collapsible_accessor_storage$2, this, value); } get open() { - return _classPrivateFieldGet2(_open_accessor_storage$6, this); + return _classPrivateFieldGet2(_open_accessor_storage$7, this); } set open(value) { - _classPrivateFieldSet2(_open_accessor_storage$6, this, value); + _classPrivateFieldSet2(_open_accessor_storage$7, this, value); } _onCheckboxChange(e) { const { value: name, checked } = e.detail; @@ -22804,7 +22792,7 @@ var _showCorrelatedAnomalies_accessor_storage$3; var _anyAnomaliesEnabled_accessor_storage$2; var _collapsible_accessor_storage$1; - var _open_accessor_storage$5; + var _open_accessor_storage$6; var ANALYSIS_ANOMALY_OVERLAP_MODE_OPTIONS$1 = [{ value: "all", label: "Show all anomalies" @@ -22812,14 +22800,14 @@ value: "only", label: "Overlaps only" }]; - var SidebarAnalysisSection = (_anomalyOverlapMode_accessor_storage$2 = /* @__PURE__ */ new WeakMap(), _showCorrelatedAnomalies_accessor_storage$3 = /* @__PURE__ */ new WeakMap(), _anyAnomaliesEnabled_accessor_storage$2 = /* @__PURE__ */ new WeakMap(), _collapsible_accessor_storage$1 = /* @__PURE__ */ new WeakMap(), _open_accessor_storage$5 = /* @__PURE__ */ new WeakMap(), _SidebarAnalysisSection = class SidebarAnalysisSection extends i$2 { + var SidebarAnalysisSection = (_anomalyOverlapMode_accessor_storage$2 = /* @__PURE__ */ new WeakMap(), _showCorrelatedAnomalies_accessor_storage$3 = /* @__PURE__ */ new WeakMap(), _anyAnomaliesEnabled_accessor_storage$2 = /* @__PURE__ */ new WeakMap(), _collapsible_accessor_storage$1 = /* @__PURE__ */ new WeakMap(), _open_accessor_storage$6 = /* @__PURE__ */ new WeakMap(), _SidebarAnalysisSection = class SidebarAnalysisSection extends i$2 { constructor(..._args) { super(..._args); _classPrivateFieldInitSpec(this, _anomalyOverlapMode_accessor_storage$2, "all"); _classPrivateFieldInitSpec(this, _showCorrelatedAnomalies_accessor_storage$3, false); _classPrivateFieldInitSpec(this, _anyAnomaliesEnabled_accessor_storage$2, false); _classPrivateFieldInitSpec(this, _collapsible_accessor_storage$1, false); - _classPrivateFieldInitSpec(this, _open_accessor_storage$5, true); + _classPrivateFieldInitSpec(this, _open_accessor_storage$6, true); } get anomalyOverlapMode() { return _classPrivateFieldGet2(_anomalyOverlapMode_accessor_storage$2, this); @@ -22846,10 +22834,10 @@ _classPrivateFieldSet2(_collapsible_accessor_storage$1, this, value); } get open() { - return _classPrivateFieldGet2(_open_accessor_storage$5, this); + return _classPrivateFieldGet2(_open_accessor_storage$6, this); } set open(value) { - _classPrivateFieldSet2(_open_accessor_storage$5, this, value); + _classPrivateFieldSet2(_open_accessor_storage$6, this, value); } _localizedOptions(options) { return options.map((opt) => ({ @@ -22983,7 +22971,7 @@ var _yAxisMode_accessor_storage$2; var _hoverSnapMode_accessor_storage$2; var _collapsible_accessor_storage; - var _open_accessor_storage$4; + var _open_accessor_storage$5; var DATA_GAP_THRESHOLD_OPTIONS$1 = [ { value: "auto", @@ -23043,7 +23031,7 @@ value: "snap_to_data_points", label: "Snap to data points" }]; - var SidebarChartDisplaySection = (_showTooltips_accessor_storage$2 = /* @__PURE__ */ new WeakMap(), _showHoverGuides_accessor_storage$2 = /* @__PURE__ */ new WeakMap(), _showDataGaps_accessor_storage$3 = /* @__PURE__ */ new WeakMap(), _dataGapThreshold_accessor_storage$3 = /* @__PURE__ */ new WeakMap(), _yAxisMode_accessor_storage$2 = /* @__PURE__ */ new WeakMap(), _hoverSnapMode_accessor_storage$2 = /* @__PURE__ */ new WeakMap(), _collapsible_accessor_storage = /* @__PURE__ */ new WeakMap(), _open_accessor_storage$4 = /* @__PURE__ */ new WeakMap(), _SidebarChartDisplaySection = class SidebarChartDisplaySection extends i$2 { + var SidebarChartDisplaySection = (_showTooltips_accessor_storage$2 = /* @__PURE__ */ new WeakMap(), _showHoverGuides_accessor_storage$2 = /* @__PURE__ */ new WeakMap(), _showDataGaps_accessor_storage$3 = /* @__PURE__ */ new WeakMap(), _dataGapThreshold_accessor_storage$3 = /* @__PURE__ */ new WeakMap(), _yAxisMode_accessor_storage$2 = /* @__PURE__ */ new WeakMap(), _hoverSnapMode_accessor_storage$2 = /* @__PURE__ */ new WeakMap(), _collapsible_accessor_storage = /* @__PURE__ */ new WeakMap(), _open_accessor_storage$5 = /* @__PURE__ */ new WeakMap(), _SidebarChartDisplaySection = class SidebarChartDisplaySection extends i$2 { constructor(..._args) { super(..._args); _classPrivateFieldInitSpec(this, _showTooltips_accessor_storage$2, true); @@ -23053,7 +23041,7 @@ _classPrivateFieldInitSpec(this, _yAxisMode_accessor_storage$2, "combined"); _classPrivateFieldInitSpec(this, _hoverSnapMode_accessor_storage$2, "follow_series"); _classPrivateFieldInitSpec(this, _collapsible_accessor_storage, false); - _classPrivateFieldInitSpec(this, _open_accessor_storage$4, true); + _classPrivateFieldInitSpec(this, _open_accessor_storage$5, true); } get showTooltips() { return _classPrivateFieldGet2(_showTooltips_accessor_storage$2, this); @@ -23098,10 +23086,10 @@ _classPrivateFieldSet2(_collapsible_accessor_storage, this, value); } get open() { - return _classPrivateFieldGet2(_open_accessor_storage$4, this); + return _classPrivateFieldGet2(_open_accessor_storage$5, this); } set open(value) { - _classPrivateFieldSet2(_open_accessor_storage$4, this, value); + _classPrivateFieldSet2(_open_accessor_storage$5, this, value); } _localizedOptions(options) { return options.map((opt) => ({ @@ -24896,74 +24884,38 @@ //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.styles.ts var styles$39 = i$5` - .heading { - font-size: 0.84rem; - font-weight: 600; - color: var(--primary-text-color); - margin-bottom: var(--dp-spacing-xs); - } - - .list { + .method-list { display: grid; - gap: var(--dp-spacing-sm); - } - - .definition { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--dp-spacing-sm); - padding: var(--dp-spacing-sm); - border-radius: 10px; - border: 1px solid - color-mix( - in srgb, - var(--divider-color, rgba(0, 0, 0, 0.12)) 88%, - transparent - ); - } - - .button { - display: inline-flex; - align-items: center; - gap: var(--dp-spacing-xs); - padding: var(--dp-spacing-xs) var(--dp-spacing-sm); - border-radius: 8px; - border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.12)); - background: transparent; - color: var(--primary-text-color); - font: inherit; - font-size: 0.8rem; - cursor: pointer; - } - - .button:focus-visible { - outline: 2px solid var(--primary-color, #03a9f4); - outline-offset: 2px; - } - - .remove { - margin-left: auto; + gap: calc(var(--dp-spacing-sm, 8px) * 0.75); } - .add { - margin-top: var(--dp-spacing-sm); + .method-item { + display: grid; + gap: 6px; } `; //#endregion //#region custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts var _AnalysisAggregateGroup; - var _deviceClass_accessor_storage = /* @__PURE__ */ new WeakMap(); - var _variant_accessor_storage = /* @__PURE__ */ new WeakMap(); - var _operations_accessor_storage = /* @__PURE__ */ new WeakMap(); - var _eventTypes_accessor_storage = /* @__PURE__ */ new WeakMap(); - var AnalysisAggregateGroup = (_AnalysisAggregateGroup = class AnalysisAggregateGroup extends AnalysisGroupMixin(i$2) { + var _deviceClass_accessor_storage; + var _variant_accessor_storage; + var _operations_accessor_storage; + var _eventTypes_accessor_storage; + var _open_accessor_storage$4; + var CUMULATIVE_OPERATIONS = [ + "consumption", + "transition_count", + "state_duration", + "event_count" + ]; + var AnalysisAggregateGroup = (_deviceClass_accessor_storage = /* @__PURE__ */ new WeakMap(), _variant_accessor_storage = /* @__PURE__ */ new WeakMap(), _operations_accessor_storage = /* @__PURE__ */ new WeakMap(), _eventTypes_accessor_storage = /* @__PURE__ */ new WeakMap(), _open_accessor_storage$4 = /* @__PURE__ */ new WeakMap(), _AnalysisAggregateGroup = class AnalysisAggregateGroup extends AnalysisGroupMixin(i$2) { constructor(..._args) { super(..._args); _classPrivateFieldInitSpec(this, _deviceClass_accessor_storage, ""); _classPrivateFieldInitSpec(this, _variant_accessor_storage, "binary"); _classPrivateFieldInitSpec(this, _operations_accessor_storage, []); _classPrivateFieldInitSpec(this, _eventTypes_accessor_storage, []); + _classPrivateFieldInitSpec(this, _open_accessor_storage$4, false); } get deviceClass() { return _classPrivateFieldGet2(_deviceClass_accessor_storage, this); @@ -24989,83 +24941,104 @@ set eventTypes(value) { _classPrivateFieldSet2(_eventTypes_accessor_storage, this, value); } + get _open() { + return _classPrivateFieldGet2(_open_accessor_storage$4, this); + } + set _open(value) { + _classPrivateFieldSet2(_open_accessor_storage$4, this, value); + } get _definitions() { return this.analysis.aggregates ?? []; } _commit(next) { this._emit("aggregates", next); } - _update(id, patch) { - this._commit(this._definitions.map((d) => d.id === id ? patch(d) : d)); + _choices() { + if (this.variant === "event") return [{ + key: "event_count", + label: msg("Count events") + }]; + if (this.variant === "measurement") return NUMERIC_AGGREGATE_OPERATIONS.filter((o) => this.operations.includes(o.value)).map((o) => ({ + key: o.value, + label: msg(o.label) + })); + return BINARY_AGGREGATE_KINDS.map((k) => ({ + key: k.value, + label: msg(k.label) + })); } - _onAdd() { - if (this.variant === "event") { - this._commit([...this._definitions, createEventAggregate(this._definitions.map((d) => d.id))]); - return; - } - if (this.variant === "measurement") { - const ids = this._definitions.map((d) => d.id); - this._commit([...this._definitions, createMeasurementAggregate(ids, this.operations[0] ?? "mean")]); - return; - } - const defaultState = getBinaryStateOptions(this.deviceClass)[0].value; - this._commit([...this._definitions, createBinaryAggregate(this._definitions.map((d) => d.id), defaultState)]); + _keyOf(definition) { + if (this.variant === "binary") return getBinaryAggregateKind(definition); + return definition.operation; } - _onRemove(id) { - this._commit(this._definitions.filter((d) => d.id !== id)); + _definitionFor(key) { + return this._definitions.find((d) => this._keyOf(d) === key); + } + _defaultState() { + return getBinaryStateOptions(this.deviceClass)[0].value; + } + _create(key) { + const ids = this._definitions.map((d) => d.id); + let created; + if (this.variant === "event") created = createEventAggregate(ids); + else if (this.variant === "measurement") created = createMeasurementAggregate(ids, key); + else created = applyBinaryAggregateKind(createBinaryAggregate(ids, this._defaultState()), key, this._defaultState()); + return { + ...created, + id: ids.includes(key) ? created.id : key + }; + } + _onGroupChange(e) { + const checked = e.detail.checked; + this._open = checked; + if (!checked) this._commit([]); + } + _onToggle(key, checked) { + this._commit(checked ? [...this._definitions, this._create(key)] : this._definitions.filter((d) => this._keyOf(d) !== key)); + } + _update(id, patch) { + this._commit(this._definitions.map((d) => d.id === id ? patch(d) : d)); } _selectValue(e) { return e.detail.value; } - _renderMeasurementDefinition(definition) { - const available = NUMERIC_AGGREGATE_OPERATIONS.filter((o) => this.operations.includes(o.value) || o.value === definition.operation); + _renderIntervalField(definition) { return b` -
+ + `; + } + _renderStateField(definition) { + const options = getBinaryStateOptions(this.deviceClass).map((o) => ({ + value: o.value, + label: msg(o.label) + })); + return b` +
+ `; } /** Counts, durations and consumption can accumulate; percentages cannot. */ - _canAccumulate(definition) { - return [ - "consumption", - "transition_count", - "state_duration", - "event_count" - ].includes(definition.operation); - } - _renderCumulativeControls(definition) { - if (!this._canAccumulate(definition)) return A; + _renderCumulativeFields(definition) { + if (!CUMULATIVE_OPERATIONS.includes(definition.operation)) return A; const cumulative = definition.mode === "cumulative"; return b` + ${allTypes ? A : this.eventTypes.map((type) => b` + + `)} `; } - _renderDefinition(definition) { - if (this.variant === "event") return this._renderEventDefinition(definition); - if (this.variant === "measurement") return this._renderMeasurementDefinition(definition); - const kind = getBinaryAggregateKind(definition); - const stateOptions = getBinaryStateOptions(this.deviceClass).map((o) => ({ - value: o.value, - label: msg(o.label) - })); - const showState = kind !== "all_changes"; + _renderSubopts(key, definition) { return b` -
- this._update(definition.id, (d) => applyBinaryAggregateKind(d, this._selectValue(e), stateOptions[0].value))} - > - ${showState ? b` this._update(definition.id, (d) => ({ - ...d, - states: [this._selectValue(e)] - }))} - >` : A} - this._update(definition.id, (d) => ({ - ...d, - interval: this._selectValue(e) - }))} - > - ${this._renderCumulativeControls(definition)} - -
+ + ${this.variant === "binary" && key !== "all_changes" ? this._renderStateField(definition) : A} + ${this._renderIntervalField(definition)} + ${this.variant === "event" ? this._renderEventTypeFields(definition) : A} + ${this._renderCumulativeFields(definition)} + `; } render() { + const choices = this._choices(); return b` -
${msg("Aggregate series")}
-
- ${this._definitions.map((d) => this._renderDefinition(d))} -
- + 0} + @dp-group-change=${this._onGroupChange} + > +
+ ${choices.map((choice) => { + const definition = this._definitionFor(choice.key); + return b` +
+ + ${definition ? this._renderSubopts(choice.key, definition) : A} +
+ `; + })} +
+
`; } }, _defineProperty(_AnalysisAggregateGroup, "styles", [sharedStyles, styles$39]), _AnalysisAggregateGroup); @@ -25232,6 +25159,7 @@ type: Array, attribute: false })], AnalysisAggregateGroup.prototype, "eventTypes", null); + __decorate([r$2()], AnalysisAggregateGroup.prototype, "_open", null); AnalysisAggregateGroup = __decorate([localized()], AnalysisAggregateGroup); customElements.define("analysis-aggregate-group", AnalysisAggregateGroup); //#endregion diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/__tests__/analysis-aggregate-group.spec.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/__tests__/analysis-aggregate-group.spec.ts index 1acf7b70..656059d5 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/__tests__/analysis-aggregate-group.spec.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/__tests__/analysis-aggregate-group.spec.ts @@ -1,19 +1,25 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import "../analysis-aggregate-group"; -import type { AggregateDefinition } from "@/lib/domain/aggregation/types"; +import type { + AggregateDefinition, + AggregateOperation, +} from "@/lib/domain/aggregation/types"; import type { NormalizedAnalysis } from "../../target-row/types"; type GroupElement = HTMLElement & { analysis: NormalizedAnalysis; entityId: string; deviceClass: string; + variant: string; + operations: AggregateOperation[]; + eventTypes: string[]; updateComplete: Promise; }; const definition = ( overrides: Partial = {} ): AggregateDefinition => ({ - id: "a1", + id: "transition_count", operation: "transition_count", interval: "day", mode: "interval", @@ -22,24 +28,37 @@ const definition = ( ...overrides, }); -async function mount(aggregates: AggregateDefinition[], deviceClass = "door") { +async function mount( + aggregates: AggregateDefinition[], + props: Partial = {} +) { const el = document.createElement("analysis-aggregate-group") as GroupElement; - el.analysis = { aggregates } as NormalizedAnalysis; - el.entityId = "binary_sensor.front_door"; - el.deviceClass = deviceClass; + Object.assign(el, { + analysis: { aggregates } as NormalizedAnalysis, + entityId: "binary_sensor.front_door", + deviceClass: "door", + ...props, + }); document.body.appendChild(el); await el.updateComplete; return el; } -function selects(el: GroupElement) { - return [ - ...el.shadowRoot!.querySelectorAll("inline-select"), - ] as (HTMLElement & { +const choice = (el: GroupElement, key: string) => + el.shadowRoot!.querySelector( + `.aggregate-choice[data-key="${key}"]` + ) as HTMLInputElement; + +const check = (input: HTMLInputElement, checked: boolean) => { + input.checked = checked; + input.dispatchEvent(new Event("change")); +}; + +const selects = (el: GroupElement) => + [...el.shadowRoot!.querySelectorAll("inline-select")] as (HTMLElement & { value: string; options: { value: string; label: string }[]; })[]; -} function choose(select: HTMLElement, value: string) { select.dispatchEvent( @@ -51,37 +70,113 @@ function choose(select: HTMLElement, value: string) { ); } +const lastValue = (handler: ReturnType) => + handler.mock.calls.at(-1)![0].detail.value as AggregateDefinition[]; + describe("analysis-aggregate-group", () => { let el: GroupElement; afterEach(() => el?.remove()); - describe("GIVEN no aggregates on a door sensor", () => { - describe("WHEN the add button is activated", () => { - it("THEN emits the full list with one new daily transition aggregate for the open state", async () => { - expect.assertions(4); + describe("GIVEN a door sensor with no aggregates", () => { + describe("WHEN the group is rendered", () => { + it("THEN the group is closed", async () => { + expect.assertions(1); + el = await mount([]); + const group = el.shadowRoot!.querySelector( + "analysis-group" + ) as HTMLElement & { checked: boolean }; + expect(group.checked).toBe(false); + }); + }); + + describe("WHEN the group is switched on", () => { + it("THEN the four binary choices appear unchecked", async () => { + expect.assertions(2); el = await mount([]); + el.shadowRoot!.querySelector("analysis-group")!.dispatchEvent( + new CustomEvent("dp-group-change", { + detail: { checked: true }, + bubbles: true, + composed: true, + }) + ); + await el.updateComplete; + const boxes = [ + ...el.shadowRoot!.querySelectorAll(".aggregate-choice"), + ] as HTMLInputElement[]; + expect(boxes.map((b) => b.dataset.key)).toStrictEqual([ + "transition_count", + "all_changes", + "state_duration", + "state_percentage", + ]); + expect(boxes.every((b) => !b.checked)).toBe(true); + }); + }); + }); + + describe("GIVEN an enabled group on a door sensor", () => { + describe("WHEN Count changes to a state is checked", () => { + it("THEN a daily aggregate for the open state is added", async () => { + expect.assertions(2); + el = await mount([ + definition({ id: "state_duration", operation: "state_duration" }), + ]); const handler = vi.fn(); el.addEventListener("dp-group-analysis-change", handler); - (el.shadowRoot!.querySelector(".add") as HTMLButtonElement).click(); - const { key, value, entityId } = handler.mock.calls[0][0].detail; - expect(key).toBe("aggregates"); - expect(entityId).toBe("binary_sensor.front_door"); - expect(value).toHaveLength(1); - expect(value[0]).toMatchObject({ + check(choice(el, "transition_count"), true); + const added = lastValue(handler).find( + (d) => d.id === "transition_count" + ); + expect(added).toMatchObject({ operation: "transition_count", interval: "day", states: ["on"], }); + expect(lastValue(handler)).toHaveLength(2); + }); + }); + + describe("WHEN a checked aggregate is unchecked", () => { + it("THEN only that aggregate is removed", async () => { + expect.assertions(1); + el = await mount([ + definition(), + definition({ id: "state_duration", operation: "state_duration" }), + ]); + const handler = vi.fn(); + el.addEventListener("dp-group-analysis-change", handler); + check(choice(el, "transition_count"), false); + expect(lastValue(handler).map((d) => d.id)).toStrictEqual([ + "state_duration", + ]); + }); + }); + + describe("WHEN the master checkbox is switched off", () => { + it("THEN every aggregate is cleared", async () => { + expect.assertions(1); + el = await mount([definition()]); + const handler = vi.fn(); + el.addEventListener("dp-group-analysis-change", handler); + el.shadowRoot!.querySelector("analysis-group")!.dispatchEvent( + new CustomEvent("dp-group-change", { + detail: { checked: false }, + bubbles: true, + composed: true, + }) + ); + expect(lastValue(handler)).toStrictEqual([]); }); }); }); - describe("GIVEN an aggregate on a door sensor", () => { - describe("WHEN rendered", () => { + describe("GIVEN a checked changes-to-state aggregate", () => { + describe("WHEN its settings are shown", () => { it("THEN state choices use device labels but store canonical values", async () => { expect.assertions(2); el = await mount([definition()]); - const stateSelect = selects(el)[1]; + const [stateSelect] = selects(el); expect(stateSelect.options.map((o) => o.label)).toStrictEqual([ "Open", "Closed", @@ -99,71 +194,61 @@ describe("analysis-aggregate-group", () => { el = await mount([definition()]); const handler = vi.fn(); el.addEventListener("dp-group-analysis-change", handler); - choose(selects(el)[1], "off"); - expect(handler.mock.calls[0][0].detail.value[0].states).toStrictEqual([ - "off", - ]); + choose(selects(el)[0], "off"); + expect(lastValue(handler)[0].states).toStrictEqual(["off"]); }); }); - describe("WHEN the operation is changed to all state changes", () => { - it("THEN states are cleared and the state selector is hidden", async () => { + describe("WHEN the interval is changed", () => { + it("THEN only that aggregate changes", async () => { expect.assertions(2); - el = await mount([definition()]); + el = await mount([ + definition(), + definition({ id: "state_duration", operation: "state_duration" }), + ]); const handler = vi.fn(); el.addEventListener("dp-group-analysis-change", handler); - choose(selects(el)[0], "all_changes"); - expect(handler.mock.calls[0][0].detail.value[0]).toMatchObject({ - operation: "transition_count", - states: [], - }); - el.analysis = { - aggregates: handler.mock.calls[0][0].detail.value, - } as NormalizedAnalysis; - await el.updateComplete; - expect(selects(el)).toHaveLength(2); + choose(selects(el)[1], "month"); + const next = lastValue(handler); + expect(next[0].interval).toBe("month"); + expect(next[1].interval).toBe("day"); }); }); + }); - describe("WHEN the operation is changed to percentage of time", () => { - it("THEN a cumulative aggregate falls back to per-interval mode", async () => { - expect.assertions(1); - el = await mount([definition({ mode: "cumulative" })]); - const handler = vi.fn(); - el.addEventListener("dp-group-analysis-change", handler); - choose(selects(el)[0], "state_percentage"); - expect(handler.mock.calls[0][0].detail.value[0]).toMatchObject({ - operation: "state_percentage", - mode: "interval", - }); - }); + describe("GIVEN a checked all-state-changes aggregate", () => { + it("THEN no state selector is shown, only the interval", async () => { + expect.assertions(2); + el = await mount([definition({ id: "all_changes", states: [] })]); + expect(selects(el)).toHaveLength(1); + expect(selects(el)[0].value).toBe("day"); }); + }); - describe("WHEN the interval is changed", () => { - it("THEN only that definition changes", async () => { - expect.assertions(2); - el = await mount([definition(), definition({ id: "a2" })]); - const handler = vi.fn(); - el.addEventListener("dp-group-analysis-change", handler); - choose(selects(el)[2], "month"); - const next = handler.mock.calls[0][0].detail.value; - expect(next[0].interval).toBe("month"); - expect(next[1].interval).toBe("day"); - }); + describe("GIVEN a percentage-of-time aggregate", () => { + it("THEN no cumulative option is offered", async () => { + expect.assertions(1); + el = await mount([ + definition({ id: "state_percentage", operation: "state_percentage" }), + ]); + expect(el.shadowRoot!.querySelector(".cumulative")).toBeNull(); }); + }); - describe("WHEN the remove button is activated", () => { - it("THEN emits the list without that definition", async () => { - expect.assertions(1); - el = await mount([definition(), definition({ id: "a2" })]); + describe("GIVEN a checked count aggregate", () => { + describe("WHEN Cumulative is checked", () => { + it("THEN the mode becomes cumulative and the zoom reset option appears", async () => { + expect.assertions(3); + el = await mount([definition()]); + expect(el.shadowRoot!.querySelector(".reset-on-zoom")).toBeNull(); const handler = vi.fn(); el.addEventListener("dp-group-analysis-change", handler); - (el.shadowRoot!.querySelector(".remove") as HTMLButtonElement).click(); - expect( - handler.mock.calls[0][0].detail.value.map( - (d: AggregateDefinition) => d.id - ) - ).toStrictEqual(["a2"]); + check(el.shadowRoot!.querySelector(".cumulative")!, true); + expect(lastValue(handler)[0].mode).toBe("cumulative"); + el.analysis = { aggregates: lastValue(handler) } as NormalizedAnalysis; + await el.updateComplete; + check(el.shadowRoot!.querySelector(".reset-on-zoom")!, true); + expect(lastValue(handler)[0].resetOnZoom).toBe(true); }); }); }); @@ -173,187 +258,120 @@ describe("analysis-aggregate-group measurement variant", () => { let el: GroupElement; afterEach(() => el?.remove()); - async function mountMeasurement(aggregates: AggregateDefinition[]) { - el = document.createElement("analysis-aggregate-group") as GroupElement; - Object.assign(el, { + const mountMeasurement = (aggregates: AggregateDefinition[]) => + mount(aggregates, { variant: "measurement", - operations: ["mean", "min", "max"], - analysis: { aggregates } as NormalizedAnalysis, - entityId: "sensor.temp", + operations: ["mean", "min", "consumption"], + entityId: "sensor.energy", }); - document.body.appendChild(el); - await el.updateComplete; - return el; - } - describe("GIVEN a numeric sensor suggesting mean, min and max", () => { - describe("WHEN an aggregate is added", () => { - it("THEN a daily mean is created without states", async () => { - expect.assertions(2); - await mountMeasurement([]); + describe("GIVEN a meter suggesting mean, min and consumption", () => { + describe("WHEN an aggregate is checked", () => { + it("THEN only suggested operations are offered and the definition has no states", async () => { + expect.assertions(3); + el = await mountMeasurement([ + definition({ id: "mean", operation: "mean", states: undefined }), + ]); + const keys = [ + ...el.shadowRoot!.querySelectorAll(".aggregate-choice"), + ].map((b) => (b as HTMLInputElement).dataset.key); + expect(keys).toStrictEqual(["mean", "min", "consumption"]); const handler = vi.fn(); el.addEventListener("dp-group-analysis-change", handler); - (el.shadowRoot!.querySelector(".add") as HTMLButtonElement).click(); - const [created] = handler.mock.calls[0][0].detail.value; - expect(created).toMatchObject({ operation: "mean", interval: "day" }); - expect(created.states).toBeUndefined(); + check(choice(el, "min"), true); + const added = lastValue(handler).find((d) => d.id === "min"); + expect(added).toMatchObject({ operation: "min", interval: "day" }); + expect(added!.states).toBeUndefined(); }); }); - describe("WHEN the operation select is rendered", () => { - it("THEN offers only the suggested operations", async () => { + + describe("WHEN consumption is checked", () => { + it("THEN its settings offer a cumulative option", async () => { expect.assertions(1); - await mountMeasurement([ - definition({ operation: "mean", states: undefined }), - ]); - expect(selects(el)[0].options.map((o) => o.value)).toStrictEqual([ - "mean", - "min", - "max", + el = await mountMeasurement([ + definition({ + id: "consumption", + operation: "consumption", + states: undefined, + }), ]); + expect(el.shadowRoot!.querySelector(".cumulative")).not.toBeNull(); }); }); - describe("WHEN the operation is changed to max", () => { - it("THEN the definition keeps its id and interval", async () => { + + describe("WHEN a mean aggregate is checked", () => { + it("THEN no cumulative option is offered", async () => { expect.assertions(1); - await mountMeasurement([ - definition({ - operation: "mean", - interval: "week", - states: undefined, - }), + el = await mountMeasurement([ + definition({ id: "mean", operation: "mean", states: undefined }), ]); - const handler = vi.fn(); - el.addEventListener("dp-group-analysis-change", handler); - choose(selects(el)[0], "max"); - expect(handler.mock.calls[0][0].detail.value[0]).toMatchObject({ - id: "a1", - operation: "max", - interval: "week", - }); + expect(el.shadowRoot!.querySelector(".cumulative")).toBeNull(); }); }); }); }); describe("analysis-aggregate-group event variant", () => { - let el: GroupElement & { eventTypes: string[]; variant: string }; + let el: GroupElement; afterEach(() => el?.remove()); - async function mountEvent(aggregates: AggregateDefinition[]) { - el = document.createElement("analysis-aggregate-group") as typeof el; - Object.assign(el, { + const mountEvent = (aggregates: AggregateDefinition[]) => + mount(aggregates, { variant: "event", eventTypes: ["single_press", "double_press"], - analysis: { aggregates } as NormalizedAnalysis, entityId: "event.button", }); - document.body.appendChild(el); - await el.updateComplete; - } const eventDefinition = (eventTypes: string[]) => - definition({ operation: "event_count", states: undefined, eventTypes }); + definition({ + id: "event_count", + operation: "event_count", + states: undefined, + eventTypes, + }); describe("GIVEN a button reporting single and double presses", () => { - describe("WHEN an aggregate is added", () => { + describe("WHEN Count events is checked", () => { it("THEN it counts all event types by default", async () => { expect.assertions(1); - await mountEvent([]); + el = await mountEvent([eventDefinition([])]); + check(choice(el, "event_count"), false); const handler = vi.fn(); el.addEventListener("dp-group-analysis-change", handler); - (el.shadowRoot!.querySelector(".add") as HTMLButtonElement).click(); - expect(handler.mock.calls[0][0].detail.value[0]).toMatchObject({ + check(choice(el, "event_count"), true); + expect(lastValue(handler)[0]).toMatchObject({ operation: "event_count", eventTypes: [], }); }); }); + describe("WHEN All event types is unchecked", () => { - it("THEN the first type is selected and type choices appear", async () => { - expect.assertions(2); - await mountEvent([eventDefinition([])]); + it("THEN the first type is selected", async () => { + expect.assertions(1); + el = await mountEvent([eventDefinition([])]); const handler = vi.fn(); el.addEventListener("dp-group-analysis-change", handler); - const all = el.shadowRoot!.querySelector( - ".all-types" - ) as HTMLInputElement; - all.checked = false; - all.dispatchEvent(new Event("change")); - expect( - handler.mock.calls[0][0].detail.value[0].eventTypes - ).toStrictEqual(["single_press"]); - expect(el.shadowRoot!.querySelectorAll(".event-type")).toHaveLength(0); + check(el.shadowRoot!.querySelector(".all-types")!, false); + expect(lastValue(handler)[0].eventTypes).toStrictEqual([ + "single_press", + ]); }); }); + describe("WHEN specific types are selected", () => { it("THEN each type is individually toggleable", async () => { expect.assertions(2); - await mountEvent([eventDefinition(["single_press"])]); + el = await mountEvent([eventDefinition(["single_press"])]); const boxes = el.shadowRoot!.querySelectorAll(".event-type"); expect(boxes).toHaveLength(2); const handler = vi.fn(); el.addEventListener("dp-group-analysis-change", handler); - const second = boxes[1] as HTMLInputElement; - second.checked = true; - second.dispatchEvent(new Event("change")); - expect( - handler.mock.calls[0][0].detail.value[0].eventTypes - ).toStrictEqual(["single_press", "double_press"]); - }); - }); - }); -}); - -describe("analysis-aggregate-group cumulative controls", () => { - let el: GroupElement; - afterEach(() => el?.remove()); - - const toggle = (selector: string, checked: boolean) => { - const box = el.shadowRoot!.querySelector(selector) as HTMLInputElement; - box.checked = checked; - box.dispatchEvent(new Event("change")); - }; - - describe("GIVEN a per-interval transition count", () => { - describe("WHEN Cumulative is checked", () => { - it("THEN the mode becomes cumulative and the zoom reset option stays off", async () => { - expect.assertions(2); - el = await mount([definition()]); - const handler = vi.fn(); - el.addEventListener("dp-group-analysis-change", handler); - toggle(".cumulative", true); - expect(handler.mock.calls[0][0].detail.value[0].mode).toBe( - "cumulative" - ); - expect(handler.mock.calls[0][0].detail.value[0].resetOnZoom).toBe( - false - ); - }); - }); - it("THEN the zoom reset option is hidden until cumulative is on", async () => { - expect.assertions(1); - el = await mount([definition()]); - expect(el.shadowRoot!.querySelector(".reset-on-zoom")).toBeNull(); - }); - }); - - describe("GIVEN a cumulative aggregate", () => { - describe("WHEN Reset total to zoomed range is checked", () => { - it("THEN resetOnZoom is stored", async () => { - expect.assertions(1); - el = await mount([definition({ mode: "cumulative" })]); - const handler = vi.fn(); - el.addEventListener("dp-group-analysis-change", handler); - toggle(".reset-on-zoom", true); - expect(handler.mock.calls[0][0].detail.value[0].resetOnZoom).toBe(true); + check(boxes[1] as HTMLInputElement, true); + expect(lastValue(handler)[0].eventTypes).toStrictEqual([ + "single_press", + "double_press", + ]); }); }); }); - - describe("GIVEN a percentage-of-time aggregate", () => { - it("THEN no cumulative option is offered", async () => { - expect.assertions(1); - el = await mount([definition({ operation: "state_percentage" })]); - expect(el.shadowRoot!.querySelector(".cumulative")).toBeNull(); - }); - }); }); diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.styles.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.styles.ts index 983c7d4b..8eb73fff 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.styles.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.styles.ts @@ -1,57 +1,13 @@ import { css } from "lit"; export const styles = css` - .heading { - font-size: 0.84rem; - font-weight: 600; - color: var(--primary-text-color); - margin-bottom: var(--dp-spacing-xs); - } - - .list { + .method-list { display: grid; - gap: var(--dp-spacing-sm); - } - - .definition { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--dp-spacing-sm); - padding: var(--dp-spacing-sm); - border-radius: 10px; - border: 1px solid - color-mix( - in srgb, - var(--divider-color, rgba(0, 0, 0, 0.12)) 88%, - transparent - ); - } - - .button { - display: inline-flex; - align-items: center; - gap: var(--dp-spacing-xs); - padding: var(--dp-spacing-xs) var(--dp-spacing-sm); - border-radius: 8px; - border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.12)); - background: transparent; - color: var(--primary-text-color); - font: inherit; - font-size: 0.8rem; - cursor: pointer; + gap: calc(var(--dp-spacing-sm, 8px) * 0.75); } - .button:focus-visible { - outline: 2px solid var(--primary-color, #03a9f4); - outline-offset: 2px; - } - - .remove { - margin-left: auto; - } - - .add { - margin-top: var(--dp-spacing-sm); + .method-item { + display: grid; + gap: 6px; } `; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts index 44b7621c..5f4a832b 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/analysis-aggregate-group.ts @@ -1,5 +1,5 @@ -import { LitElement, html, nothing } from "lit"; -import { property } from "lit/decorators.js"; +import { LitElement, html, nothing, type TemplateResult } from "lit"; +import { property, state } from "lit/decorators.js"; import { localized, msg } from "@/lib/i18n/localize"; import { AnalysisGroupMixin } from "../analysis-group-shared/analysis-group.mixin"; @@ -8,9 +8,8 @@ import { styles } from "./analysis-aggregate-group.styles"; import { AGGREGATE_INTERVAL_OPTIONS, BINARY_AGGREGATE_KINDS, - type BinaryAggregateKind, - applyBinaryAggregateKind, NUMERIC_AGGREGATE_OPERATIONS, + applyBinaryAggregateKind, createBinaryAggregate, createEventAggregate, createMeasurementAggregate, @@ -22,10 +21,27 @@ import type { AggregateInterval, AggregateOperation, } from "@/lib/domain/aggregation/types"; +import "@/atoms/analysis/analysis-group/analysis-group"; +import "@/atoms/analysis/analysis-method-subopts/analysis-method-subopts"; import "@/atoms/form/inline-select/inline-select"; +interface AggregateChoice { + /** Stable key; also the id of the definition this choice creates. */ + key: string; + label: string; +} + +const CUMULATIVE_OPERATIONS: AggregateOperation[] = [ + "consumption", + "transition_count", + "state_duration", + "event_count", +]; + /** - * Add, edit and remove aggregate series definitions for a binary row. + * Aggregate series for a row. Like the anomaly group, each aggregate is a + * checkbox and its settings (state, interval, cumulative, ...) nest beneath it + * while it is checked. One definition exists per checked choice. * @fires dp-group-analysis-change - `{ entityId, key: "aggregates", value: AggregateDefinition[] }` — the full next list of definitions */ @localized() @@ -34,11 +50,11 @@ export class AnalysisAggregateGroup extends AnalysisGroupMixin(LitElement) { @property({ type: String, attribute: "device-class" }) accessor deviceClass: string = ""; - /** `binary` offers state operations; `measurement` numeric summaries; `event` event counts. */ + /** `binary` offers state operations; `measurement` numeric ones; `event` event counts. */ @property({ type: String }) accessor variant: "binary" | "measurement" | "event" = "binary"; - /** Measurement operations suggested for the entity; the user still chooses. */ + /** Numeric operations suggested for the entity; the user still chooses. */ @property({ type: Array, attribute: false }) accessor operations: AggregateOperation[] = []; @@ -46,6 +62,9 @@ export class AnalysisAggregateGroup extends AnalysisGroupMixin(LitElement) { @property({ type: Array, attribute: false }) accessor eventTypes: string[] = []; + /** Keeps the group open right after it is switched on, before any choice is checked. */ + @state() private accessor _open = false; + static styles = [sharedStyles, styles]; private get _definitions(): AggregateDefinition[] { @@ -56,103 +75,121 @@ export class AnalysisAggregateGroup extends AnalysisGroupMixin(LitElement) { this._emit("aggregates", next); } - private _update( - id: string, - patch: (d: AggregateDefinition) => AggregateDefinition - ) { - this._commit(this._definitions.map((d) => (d.id === id ? patch(d) : d))); + private _choices(): AggregateChoice[] { + if (this.variant === "event") { + return [{ key: "event_count", label: msg("Count events") }]; + } + if (this.variant === "measurement") { + return NUMERIC_AGGREGATE_OPERATIONS.filter((o) => + this.operations.includes(o.value) + ).map((o) => ({ key: o.value, label: msg(o.label) })); + } + return BINARY_AGGREGATE_KINDS.map((k) => ({ + key: k.value, + label: msg(k.label), + })); + } + + private _keyOf(definition: AggregateDefinition): string { + if (this.variant === "binary") { + return getBinaryAggregateKind(definition); + } + return definition.operation; + } + + private _definitionFor(key: string): AggregateDefinition | undefined { + return this._definitions.find((d) => this._keyOf(d) === key); } - private _onAdd() { + private _defaultState(): string { + return getBinaryStateOptions(this.deviceClass)[0].value; + } + + private _create(key: string): AggregateDefinition { + const ids = this._definitions.map((d) => d.id); + let created: AggregateDefinition; if (this.variant === "event") { - this._commit([ - ...this._definitions, - createEventAggregate(this._definitions.map((d) => d.id)), - ]); - return; + created = createEventAggregate(ids); + } else if (this.variant === "measurement") { + created = createMeasurementAggregate(ids, key as AggregateOperation); + } else { + created = applyBinaryAggregateKind( + createBinaryAggregate(ids, this._defaultState()), + key as ReturnType, + this._defaultState() + ); } - if (this.variant === "measurement") { - const ids = this._definitions.map((d) => d.id); - this._commit([ - ...this._definitions, - createMeasurementAggregate(ids, this.operations[0] ?? "mean"), - ]); - return; + return { ...created, id: ids.includes(key) ? created.id : key }; + } + + private _onGroupChange(e: CustomEvent) { + const checked = (e.detail as { checked: boolean }).checked; + this._open = checked; + if (!checked) { + this._commit([]); } - const defaultState = getBinaryStateOptions(this.deviceClass)[0].value; - this._commit([ - ...this._definitions, - createBinaryAggregate( - this._definitions.map((d) => d.id), - defaultState - ), - ]); } - private _onRemove(id: string) { - this._commit(this._definitions.filter((d) => d.id !== id)); + private _onToggle(key: string, checked: boolean) { + this._commit( + checked + ? [...this._definitions, this._create(key)] + : this._definitions.filter((d) => this._keyOf(d) !== key) + ); + } + + private _update( + id: string, + patch: (d: AggregateDefinition) => AggregateDefinition + ) { + this._commit(this._definitions.map((d) => (d.id === id ? patch(d) : d))); } private _selectValue(e: Event): string { return (e as CustomEvent<{ type: "select"; value: string }>).detail.value; } - private _renderMeasurementDefinition(definition: AggregateDefinition) { - const available = NUMERIC_AGGREGATE_OPERATIONS.filter( - (o) => - this.operations.includes(o.value) || o.value === definition.operation - ); + private _renderIntervalField(definition: AggregateDefinition) { return html` -
+ + `; + } + + private _renderStateField(definition: AggregateDefinition) { + const options = getBinaryStateOptions(this.deviceClass).map((o) => ({ + value: o.value, + label: msg(o.label), + })); + return html` +
+ `; } /** Counts, durations and consumption can accumulate; percentages cannot. */ - private _canAccumulate(definition: AggregateDefinition): boolean { - return [ - "consumption", - "transition_count", - "state_duration", - "event_count", - ].includes(definition.operation); - } - - private _renderCumulativeControls(definition: AggregateDefinition) { - if (!this._canAccumulate(definition)) { + private _renderCumulativeFields(definition: AggregateDefinition) { + if (!CUMULATIVE_OPERATIONS.includes(definition.operation)) { return nothing; } const cumulative = definition.mode === "cumulative"; @@ -203,150 +240,101 @@ export class AnalysisAggregateGroup extends AnalysisGroupMixin(LitElement) { this._update(definition.id, (d) => ({ ...d, eventTypes: next })); } - private _renderEventDefinition(definition: AggregateDefinition) { + private _renderEventTypeFields(definition: AggregateDefinition) { const selected = definition.eventTypes ?? []; const allTypes = selected.length === 0; return html` -
- + - - ${allTypes - ? nothing - : this.eventTypes.map( - (type) => html` - - ` - )} - ${this._renderCumulativeControls(definition)} - -
+ /> + ${msg("All event types")} + + ${allTypes + ? nothing + : this.eventTypes.map( + (type) => html` + + ` + )} `; } - private _renderDefinition(definition: AggregateDefinition) { - if (this.variant === "event") { - return this._renderEventDefinition(definition); - } - if (this.variant === "measurement") { - return this._renderMeasurementDefinition(definition); - } - const kind = getBinaryAggregateKind(definition); - const stateOptions = getBinaryStateOptions(this.deviceClass).map((o) => ({ - value: o.value, - label: msg(o.label), - })); - const showState = kind !== "all_changes"; + private _renderSubopts( + key: string, + definition: AggregateDefinition + ): TemplateResult { return html` -
- - this._update(definition.id, (d) => - applyBinaryAggregateKind( - d, - this._selectValue(e) as BinaryAggregateKind, - stateOptions[0].value - ) - )} - > - ${showState - ? html` - this._update(definition.id, (d) => ({ - ...d, - states: [this._selectValue(e)], - }))} - >` + + ${this.variant === "binary" && key !== "all_changes" + ? this._renderStateField(definition) : nothing} - - this._update(definition.id, (d) => ({ - ...d, - interval: this._selectValue(e) as AggregateInterval, - }))} - > - ${this._renderCumulativeControls(definition)} - -
+ ${this._renderIntervalField(definition)} + ${this.variant === "event" + ? this._renderEventTypeFields(definition) + : nothing} + ${this._renderCumulativeFields(definition)} + `; } render() { + const choices = this._choices(); return html` -
${msg("Aggregate series")}
-
- ${this._definitions.map((d) => this._renderDefinition(d))} -
- + 0} + @dp-group-change=${this._onGroupChange} + > +
+ ${choices.map((choice) => { + const definition = this._definitionFor(choice.key); + return html` +
+ + ${definition + ? this._renderSubopts(choice.key, definition) + : nothing} +
+ `; + })} +
+
`; } } diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts index b8363629..f1d0ed06 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts @@ -2,11 +2,6 @@ import type { ComponentTranslations } from "@/lib/i18n/types"; export const translations: ComponentTranslations = { "Aggregate series": "Aggregierte Serien", - "Add aggregate": "Aggregat hinzufügen", - "Remove aggregate": "Aggregat entfernen", - "Aggregate operation": "Aggregatoperation", - "Aggregate state": "Aggregatzustand", - "Aggregate interval": "Aggregatintervall", "Count changes to a state": "Wechsel in einen Zustand zählen", "Count all state changes": "Alle Zustandswechsel zählen", "Time in a state": "Zeit in einem Zustand", @@ -43,4 +38,7 @@ export const translations: ComponentTranslations = { "Gestrichelte Umrandungen kennzeichnen Teilperioden am Rand des gewählten Bereichs; schraffierte oder gepunktete Markierungen zeigen Zeiträume mit Lücken im aufgezeichneten Verlauf.", "Counts only include activity in retained history; earlier activity is not shown.": "Zählungen enthalten nur Aktivität im gespeicherten Verlauf; frühere Aktivität wird nicht angezeigt.", + "Count events": "Ereignisse zählen", + Interval: "Intervall", + State: "Zustand", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts index 1a6b5b7d..d60c3c64 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts @@ -2,11 +2,6 @@ import type { ComponentTranslations } from "@/lib/i18n/types"; export const translations: ComponentTranslations = { "Aggregate series": "Series agregadas", - "Add aggregate": "Añadir agregado", - "Remove aggregate": "Quitar agregado", - "Aggregate operation": "Operación del agregado", - "Aggregate state": "Estado del agregado", - "Aggregate interval": "Intervalo del agregado", "Count changes to a state": "Contar cambios a un estado", "Count all state changes": "Contar todos los cambios de estado", "Time in a state": "Tiempo en un estado", @@ -43,4 +38,7 @@ export const translations: ComponentTranslations = { "Los contornos discontinuos marcan los periodos parciales cortados por el rango seleccionado; las marcas rayadas o punteadas indican periodos con huecos en el historial registrado.", "Counts only include activity in retained history; earlier activity is not shown.": "Los recuentos solo incluyen la actividad del historial conservado; la actividad anterior no se muestra.", + "Count events": "Contar eventos", + Interval: "Intervalo", + State: "Estado", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts index fde442b1..83835a91 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts @@ -2,11 +2,6 @@ import type { ComponentTranslations } from "@/lib/i18n/types"; export const translations: ComponentTranslations = { "Aggregate series": "Koosteet", - "Add aggregate": "Lisää koostesarja", - "Remove aggregate": "Poista koostesarja", - "Aggregate operation": "Koosteen toiminto", - "Aggregate state": "Koosteen tila", - "Aggregate interval": "Koosteen jakso", "Count changes to a state": "Laske muutokset tilaan", "Count all state changes": "Laske kaikki tilamuutokset", "Time in a state": "Aika tilassa", @@ -43,4 +38,7 @@ export const translations: ComponentTranslations = { "Katkoviivareunat osoittavat valitun aikavälin reunoilla katkenneet osittaiset jaksot; viivoitetut tai pisteviivamerkinnät osoittavat jaksot, joiden tallennetussa historiassa on aukkoja.", "Counts only include activity in retained history; earlier activity is not shown.": "Laskennat sisältävät vain säilytetyn historian toiminnan; aiempaa toimintaa ei näytetä.", + "Count events": "Laske tapahtumat", + Interval: "Jakso", + State: "Tila", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts index 2371d36e..5a648cf1 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts @@ -2,11 +2,6 @@ import type { ComponentTranslations } from "@/lib/i18n/types"; export const translations: ComponentTranslations = { "Aggregate series": "Séries agrégées", - "Add aggregate": "Ajouter un agrégat", - "Remove aggregate": "Supprimer l'agrégat", - "Aggregate operation": "Opération de l'agrégat", - "Aggregate state": "État de l'agrégat", - "Aggregate interval": "Intervalle de l'agrégat", "Count changes to a state": "Compter les passages à un état", "Count all state changes": "Compter tous les changements d'état", "Time in a state": "Durée dans un état", @@ -43,4 +38,7 @@ export const translations: ComponentTranslations = { "Les contours en pointillés signalent les périodes partielles coupées par la plage sélectionnée ; les marques hachurées ou pointillées indiquent des périodes avec des lacunes dans l'historique enregistré.", "Counts only include activity in retained history; earlier activity is not shown.": "Les décomptes n'incluent que l'activité de l'historique conservé ; l'activité antérieure n'est pas affichée.", + "Count events": "Compter les événements", + Interval: "Intervalle", + State: "État", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts index eb1c026c..50f821f5 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts @@ -2,11 +2,6 @@ import type { ComponentTranslations } from "@/lib/i18n/types"; export const translations: ComponentTranslations = { "Aggregate series": "Séries agregadas", - "Add aggregate": "Adicionar agregado", - "Remove aggregate": "Remover agregado", - "Aggregate operation": "Operação do agregado", - "Aggregate state": "Estado do agregado", - "Aggregate interval": "Intervalo do agregado", "Count changes to a state": "Contar mudanças para um estado", "Count all state changes": "Contar todas as mudanças de estado", "Time in a state": "Tempo num estado", @@ -43,4 +38,7 @@ export const translations: ComponentTranslations = { "Os contornos tracejados indicam períodos parciais cortados pelo intervalo selecionado; as marcas tracejadas finas ou pontilhadas indicam períodos com lacunas no histórico registado.", "Counts only include activity in retained history; earlier activity is not shown.": "As contagens só incluem a atividade do histórico retido; a atividade anterior não é mostrada.", + "Count events": "Contar eventos", + Interval: "Intervalo", + State: "Estado", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts index 9f8aa7e9..5a0a64fe 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts @@ -2,11 +2,6 @@ import type { ComponentTranslations } from "@/lib/i18n/types"; export const translations: ComponentTranslations = { "Aggregate series": "聚合序列", - "Add aggregate": "添加聚合", - "Remove aggregate": "移除聚合", - "Aggregate operation": "聚合操作", - "Aggregate state": "聚合状态", - "Aggregate interval": "聚合间隔", "Count changes to a state": "统计变为某状态的次数", "Count all state changes": "统计所有状态变化次数", "Time in a state": "处于某状态的时长", @@ -42,4 +37,7 @@ export const translations: ComponentTranslations = { "虚线轮廓表示被所选范围截断的部分时段;阴影或点状标记表示已记录历史中存在缺口的时段。", "Counts only include activity in retained history; earlier activity is not shown.": "计数仅包含保留历史中的活动;更早的活动不会显示。", + "Count events": "统计事件数", + Interval: "间隔", + State: "状态", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/stories/analysis-aggregate-group.stories.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/stories/analysis-aggregate-group.stories.ts index 6cc07063..80a8c3b9 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/stories/analysis-aggregate-group.stories.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/stories/analysis-aggregate-group.stories.ts @@ -11,8 +11,8 @@ export default { }, }; -const door: AggregateDefinition = { - id: "door-opens", +const opens: AggregateDefinition = { + id: "transition_count", operation: "transition_count", interval: "day", mode: "interval", @@ -20,7 +20,7 @@ const door: AggregateDefinition = { resetOnZoom: false, }; -export const Empty = { +export const Off = { render: () => html` html` events.push(e as CustomEvent) ); - const add = group.shadowRoot!.querySelector(".add") as HTMLElement; - add.focus(); - await userEvent.keyboard("{Enter}"); - await expect(events[0].detail.value).toHaveLength(3); + // Keyboard: focus an unchecked choice and press space to enable it. + const percentage = group.shadowRoot!.querySelector( + '.aggregate-choice[data-key="state_percentage"]' + ) as HTMLInputElement; + percentage.focus(); + await userEvent.keyboard(" "); + await expect(events.at(-1)!.detail.value).toHaveLength(3); }, }; + +export const NumericMeter = { + render: () => html` + + `, +}; From 7881abe8ebd76a821401a781fd0f2da3c51a3fb7 Mon Sep 17 00:00:00 2001 From: Ollie Date: Sun, 25 Oct 2026 09:57:17 +0200 Subject: [PATCH 16/17] feat(aggregation): add sub-hour intervals and style lines by type Adds minute, 15 minute and 30 minute intervals. Aggregate lines keep the source colour with a dash pattern per aggregation type and no point markers. Refs #67 Co-Authored-By: Claude Sonnet 5.5 --- .../hass_datapoints/hass-datapoints-cards.js | 259 ++++++++++++------ .../history/history-chart/history-chart.ts | 52 ++-- .../chart/__tests__/chart-aggregates.spec.ts | 39 ++- .../src/lib/chart/chart-aggregates.ts | 14 +- .../src/lib/chart/chart-renderer.ts | 38 --- .../aggregation/__tests__/calendar.spec.ts | 44 +++ .../src/lib/domain/aggregation/aggregation.ts | 3 +- .../src/lib/domain/aggregation/calculate.ts | 97 +++++-- .../src/lib/domain/aggregation/calendar.ts | 21 +- .../src/lib/domain/aggregation/labels.ts | 27 ++ .../src/lib/domain/aggregation/types.ts | 11 +- .../analysis-aggregate-group/i18n/de.ts | 3 + .../analysis-aggregate-group/i18n/es.ts | 3 + .../analysis-aggregate-group/i18n/fi.ts | 3 + .../analysis-aggregate-group/i18n/fr.ts | 3 + .../analysis-aggregate-group/i18n/pt.ts | 3 + .../analysis-aggregate-group/i18n/zh-hans.ts | 3 + 17 files changed, 451 insertions(+), 172 deletions(-) diff --git a/custom_components/hass_datapoints/hass-datapoints-cards.js b/custom_components/hass_datapoints/hass-datapoints-cards.js index 55f6578a..155e68b4 100644 --- a/custom_components/hass_datapoints/hass-datapoints-cards.js +++ b/custom_components/hass_datapoints/hass-datapoints-cards.js @@ -2545,7 +2545,10 @@ "Counts only include activity in retained history; earlier activity is not shown.": "Laskennat sisältävät vain säilytetyn historian toiminnan; aiempaa toimintaa ei näytetä.", "Count events": "Laske tapahtumat", Interval: "Jakso", - State: "Tila" + State: "Tila", + "Every minute": "Joka minuutti", + "Every 15 minutes": "Joka 15. minuutti", + "Every 30 minutes": "Joka 30. minuutti" }; })); //#endregion @@ -3254,7 +3257,10 @@ "Counts only include activity in retained history; earlier activity is not shown.": "Les décomptes n'incluent que l'activité de l'historique conservé ; l'activité antérieure n'est pas affichée.", "Count events": "Compter les événements", Interval: "Intervalle", - State: "État" + State: "État", + "Every minute": "Chaque minute", + "Every 15 minutes": "Toutes les 15 minutes", + "Every 30 minutes": "Toutes les 30 minutes" }; })); //#endregion @@ -3963,7 +3969,10 @@ "Counts only include activity in retained history; earlier activity is not shown.": "Zählungen enthalten nur Aktivität im gespeicherten Verlauf; frühere Aktivität wird nicht angezeigt.", "Count events": "Ereignisse zählen", Interval: "Intervall", - State: "Zustand" + State: "Zustand", + "Every minute": "Jede Minute", + "Every 15 minutes": "Alle 15 Minuten", + "Every 30 minutes": "Alle 30 Minuten" }; })); //#endregion @@ -4672,7 +4681,10 @@ "Counts only include activity in retained history; earlier activity is not shown.": "Los recuentos solo incluyen la actividad del historial conservado; la actividad anterior no se muestra.", "Count events": "Contar eventos", Interval: "Intervalo", - State: "Estado" + State: "Estado", + "Every minute": "Cada minuto", + "Every 15 minutes": "Cada 15 minutos", + "Every 30 minutes": "Cada 30 minutos" }; })); //#endregion @@ -5381,7 +5393,10 @@ "Counts only include activity in retained history; earlier activity is not shown.": "As contagens só incluem a atividade do histórico retido; a atividade anterior não é mostrada.", "Count events": "Contar eventos", Interval: "Intervalo", - State: "Estado" + State: "Estado", + "Every minute": "A cada minuto", + "Every 15 minutes": "A cada 15 minutos", + "Every 30 minutes": "A cada 30 minutos" }; })); //#endregion @@ -6090,7 +6105,10 @@ "Counts only include activity in retained history; earlier activity is not shown.": "计数仅包含保留历史中的活动;更早的活动不会显示。", "Count events": "统计事件数", Interval: "间隔", - State: "状态" + State: "状态", + "Every minute": "每分钟", + "Every 15 minutes": "每 15 分钟", + "Every 30 minutes": "每 30 分钟" }; })); //#endregion @@ -8732,6 +8750,14 @@ //#endregion //#region custom_components/hass_datapoints/src/lib/domain/aggregation/calendar.ts var HOUR_MS$2 = 36e5; + var MINUTE_MS$2 = 6e4; + /** Sub-day intervals of fixed length, aligned to the local wall clock. */ + var FIXED_INTERVAL_MS = { + minute: MINUTE_MS$2, + "15min": 15 * MINUTE_MS$2, + "30min": 30 * MINUTE_MS$2, + hour: HOUR_MS$2 + }; var formatters = /* @__PURE__ */ new Map(); function getFormatter(timeZone) { let formatter = formatters.get(timeZone); @@ -8775,16 +8801,19 @@ } function getPeriodStart(ts, interval, timeZone) { const p = getLocalParts(ts, timeZone); - if (interval === "hour") { + const fixedMs = FIXED_INTERVAL_MS[interval]; + if (fixedMs) { const ms = (ts % 1e3 + 1e3) % 1e3; - return ts - ((p.minute * 60 + p.second) * 1e3 + ms); + const offsetMinutes = p.minute % (fixedMs / MINUTE_MS$2); + return ts - ms - p.second * 1e3 - offsetMinutes * MINUTE_MS$2; } if (interval === "day") return localMidnight(p.year, p.month, p.day, timeZone); if (interval === "week") return localMidnight(p.year, p.month, p.day - (p.weekday + 6) % 7, timeZone); return localMidnight(p.year, p.month, 1, timeZone); } function getNextPeriodStart(periodStart, interval, timeZone) { - if (interval === "hour") return periodStart + HOUR_MS$2; + const fixedMs = FIXED_INTERVAL_MS[interval]; + if (fixedMs) return periodStart + fixedMs; const p = getLocalParts(periodStart + HOUR_MS$2 * 2, timeZone); if (interval === "day") return localMidnight(p.year, p.month, p.day + 1, timeZone); if (interval === "week") return localMidnight(p.year, p.month, p.day + 7, timeZone); @@ -8829,15 +8858,36 @@ function overlap(a, b) { return Math.max(0, Math.min(a.end, b.end) - Math.max(a.start, b.start)); } - function inPeriod(time, period) { - return time >= period.start && time < period.end; + /** + * Bucket time-ordered items into periods in one sweep. Minute-level intervals + * over long ranges create tens of thousands of periods, so per-period filters + * over every item would be quadratic. + */ + function groupByPeriod(items, periods, timeOf) { + const sorted = [...items].sort((a, b) => timeOf(a) - timeOf(b)); + const groups = periods.map(() => []); + let cursor = 0; + sorted.forEach((item) => { + const time = timeOf(item); + while (cursor < periods.length && time >= periods[cursor].end) cursor += 1; + if (cursor < periods.length && time >= periods[cursor].start) groups[cursor].push(item); + }); + return groups; } function sortSamples(samples) { return [...samples].sort((a, b) => a.time - b.time); } - function knownDurationOf(period, coverage) { - if (!coverage) return period.end - period.start; - return coverage.reduce((sum, c) => sum + overlap(period, c), 0); + /** Known (covered) time within each period, in one sweep over the coverage. */ + function knownDurationsOf(periods, coverage) { + if (!coverage) return periods.map((period) => period.end - period.start); + const sorted = [...coverage].sort((a, b) => a.start - b.start); + let first = 0; + return periods.map((period) => { + while (first < sorted.length && sorted[first].end <= period.start) first += 1; + let known = 0; + for (let i = first; i < sorted.length && sorted[i].start < period.end; i += 1) known += overlap(period, sorted[i]); + return known; + }); } /** Known-state spans over `range`, seeded by the last sample at/before start. */ function buildStateSpans(samples, range, coverage) { @@ -8884,9 +8934,9 @@ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; } function calculateMeasurement(input, periods) { - const readings = sortSamples(input.samples).filter((s) => !s.boundary && typeof s.value === "number" && Number.isFinite(s.value)); - return periods.map((period) => { - const values = readings.filter((s) => inPeriod(s.time, period)).map((s) => s.value); + const grouped = groupByPeriod(sortSamples(input.samples).filter((s) => !s.boundary && typeof s.value === "number" && Number.isFinite(s.value)), periods, (s) => s.time); + return periods.map((_period, index) => { + const values = grouped[index].map((s) => s.value); if (!values.length) return { value: null, reasons: ["no-data"] @@ -8914,8 +8964,9 @@ if (!prev || curr.time <= input.range.start || curr.time >= input.range.end || !isKnownState(prev.state) || !isKnownState(curr.state) || prev.state === curr.state || selected.length > 0 && !selected.includes(curr.state)) return; times.push(curr.time); }); - return periods.map((period) => ({ - value: times.filter((t) => inPeriod(t, period)).length, + const grouped = groupByPeriod(times, periods, (t) => t); + return periods.map((_period, index) => ({ + value: grouped[index].length, reasons: [] })); } @@ -8925,14 +8976,16 @@ value: null, reasons: ["no-state-selected"] })); - const spans = buildStateSpans(input.samples, input.range, input.coverage); + const spans = buildStateSpans(input.samples, input.range, input.coverage).sort((a, b) => a.start - b.start); + let first = 0; return periods.map((period) => { let known = 0; let matching = 0; - for (const span of spans) { - const ms = overlap(period, span); + while (first < spans.length && spans[first].end <= period.start) first += 1; + for (let i = first; i < spans.length && spans[i].start < period.end; i += 1) { + const ms = overlap(period, spans[i]); known += ms; - if (selected.includes(span.state)) matching += ms; + if (selected.includes(spans[i].state)) matching += ms; } if (percentage) return known > 0 ? { value: matching / known * 100, @@ -8952,9 +9005,9 @@ } function calculateEvents(input, periods) { const types = input.definition.eventTypes ?? []; - const times = input.samples.filter((s) => !s.boundary && typeof s.eventType === "string" && (types.length === 0 || types.includes(s.eventType))).map((s) => s.eventTime ?? s.time); - return periods.map((period) => ({ - value: times.filter((t) => inPeriod(t, period)).length, + const grouped = groupByPeriod(input.samples.filter((s) => !s.boundary && typeof s.eventType === "string" && (types.length === 0 || types.includes(s.eventType))).map((s) => s.eventTime ?? s.time), periods, (t) => t); + return periods.map((_period, index) => ({ + value: grouped[index].length, reasons: [] })); } @@ -8993,8 +9046,9 @@ reason: "ambiguous-reset" }); }); - return periods.map((period) => { - const inside = deltas.filter((d) => inPeriod(d.time, period)); + const grouped = groupByPeriod(deltas, periods, (d) => d.time); + return periods.map((_period, index) => { + const inside = grouped[index]; if (!inside.length) return { value: null, reasons: ["no-data"] @@ -9067,9 +9121,10 @@ const result = toCumulative(op, periods, buckets); ({buckets, periods, started} = result); } + const knownDurations = knownDurationsOf(periods, input.coverage); const points = periods.map((period, i) => { const totalDuration = period.end - period.start; - const knownDuration = knownDurationOf(period, input.coverage); + const knownDuration = knownDurations[i]; const reasons = [...buckets[i].reasons]; const incomplete = knownDuration < totalDuration; if (incomplete) reasons.push("incomplete-coverage"); @@ -9119,6 +9174,18 @@ } ]; var AGGREGATE_INTERVAL_OPTIONS = [ + { + value: "minute", + label: "Every minute" + }, + { + value: "15min", + label: "Every 15 minutes" + }, + { + value: "30min", + label: "Every 30 minutes" + }, { value: "hour", label: "Hourly" @@ -9244,6 +9311,53 @@ eventTypes }; } + /** + * Dash pattern (px on/off) per aggregation type. Aggregate lines keep the + * source series' colour, so the pattern is what tells them apart. + */ + var DASH_PATTERNS = { + mean: [10, 4], + min: [2, 4], + max: [ + 2, + 4, + 10, + 4 + ], + median: [ + 6, + 3, + 2, + 3 + ], + first: [1, 5], + last: [ + 1, + 3, + 1, + 3, + 8, + 3 + ], + consumption: [10, 4], + transition_count: [10, 4], + state_duration: [2, 4], + state_percentage: [ + 2, + 4, + 10, + 4 + ], + event_count: [ + 6, + 3, + 2, + 3 + ] + }; + function getAggregateDashPattern(operation) { + return DASH_PATTERNS[operation] ?? [6, 3]; + } //#endregion //#region custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts var UNKNOWN_STATES = /* @__PURE__ */ new Set(["unknown", "unavailable"]); @@ -9335,6 +9449,7 @@ })), ...periodDetails(valued), color: definition.color || args.color, + dashPattern: getAggregateDashPattern(definition.operation), axisKey: `aggregate:${result.unit || "count"}`, bars: result.presentation === "bar", result @@ -9351,7 +9466,7 @@ }]; }); if (!samples.length) return []; - return args.definitions.filter((definition) => definition.visible !== false && isMeasurementOperation(definition.operation)).flatMap((definition, index) => { + return args.definitions.filter((definition) => definition.visible !== false && isMeasurementOperation(definition.operation)).flatMap((definition) => { const result = calculateAggregate({ definition, samples, @@ -9379,7 +9494,8 @@ end: p.end })), ...periodDetails(valued), - color: definition.color || args.colors[index % args.colors.length], + color: definition.color || args.color, + dashPattern: getAggregateDashPattern(definition.operation), axisKey: args.axisKey, bars: result.presentation === "bar", result @@ -9441,6 +9557,7 @@ })), ...periodDetails(valued), color: definition.color || args.color, + dashPattern: getAggregateDashPattern(definition.operation), axisKey: `aggregate:${result.unit || "count"}`, bars: result.presentation === "bar", result @@ -9511,6 +9628,7 @@ })), ...periodDetails(valued, describeConsumptionReasons), color: definition.color || args.color, + dashPattern: getAggregateDashPattern(definition.operation), axisKey: `aggregate:${args.unit || "count"}`, bars: result.presentation === "bar", result @@ -10299,35 +10417,6 @@ } ctx.restore(); } - /** - * Point markers for an aggregate line: filled when the period is complete, - * hollow when partial, dotted ring when history is incomplete. - */ - drawPeriodMarkers(points, styles, color, t0, t1, vMin, vMax) { - const { ctx } = this; - ctx.save(); - ctx.strokeStyle = color; - ctx.fillStyle = color; - ctx.lineWidth = 1.5; - points.forEach(([t, v], i) => { - const style = styles[i]; - const x = this.xOf(t, t0, t1); - const y = this.yOf(v, vMin, vMax); - ctx.beginPath(); - ctx.arc(x, y, 3.5, 0, Math.PI * 2); - if (style?.partial || style?.incomplete) { - ctx.setLineDash(style.partial ? [] : [1.5, 1.5]); - ctx.fillStyle = hexToRgba(color, .15); - ctx.fill(); - ctx.stroke(); - } else { - ctx.setLineDash([]); - ctx.fillStyle = color; - ctx.fill(); - } - }); - ctx.restore(); - } drawStateBands(spans, t0, t1, color = "#03a9f4", alpha = .12) { if (!spans?.length) return; const { ctx, pad } = this; @@ -12545,18 +12634,22 @@ "state_percentage", "event_count" ]; + var AGGREGATE_INTERVALS = [ + "minute", + "15min", + "30min", + "hour", + "day", + "week", + "month" + ]; //#endregion //#region custom_components/hass_datapoints/src/lib/domain/aggregation/aggregation.ts function normalizeAggregateDefinitions(value) { if (!Array.isArray(value)) return []; const ids = /* @__PURE__ */ new Set(); return value.flatMap((item) => { - if (!item || typeof item !== "object" || typeof item.id !== "string" || !item.id || ids.has(item.id) || !AGGREGATE_OPERATIONS.includes(item.operation) || ![ - "hour", - "day", - "week", - "month" - ].includes(item.interval)) return []; + if (!item || typeof item !== "object" || typeof item.id !== "string" || !item.id || ids.has(item.id) || !AGGREGATE_OPERATIONS.includes(item.operation) || !AGGREGATE_INTERVALS.includes(item.interval)) return []; ids.add(item.id); const definition = { id: item.id, @@ -14910,6 +15003,18 @@ var HISTORY_CHART_MAX_ZOOM_MULTIPLIER = 365; var HISTORY_CHART_BINARY_LANE_HEIGHT_PX = 22; var HISTORY_CHART_BINARY_LANE_GAP_PX = 6; + /** + * Aggregate lines are drawn in the source colour; their dash pattern (per + * aggregation type) tells them apart. Non-aggregate series get no extra style. + */ + function aggregateLineStyle(series) { + if (!series.periodFlags || series.bars === true) return {}; + return { + aggregateLine: true, + dashed: true, + dashPattern: series.dashPattern + }; + } var HistoryChart$1 = class extends HTMLElement { constructor(..._args) { super(..._args); @@ -15519,7 +15624,7 @@ const noteEl = this.querySelector("#aggregate-note"); if (!noteEl) return; const aggregates = series.filter((item) => item.definitionId && item.result); - const flagged = aggregates.some((item) => item.periodFlags?.some((flag) => flag.partial || flag.incomplete)); + const flagged = aggregates.some((item) => item.bars && item.periodFlags?.some((flag) => flag.partial || flag.incomplete)); const hasCounts = aggregates.some((item) => ["transition_count", "event_count"].includes(item.result.points.length ? this._aggregateOperationOf(item) : "")); const lines = []; if (flagged) lines.push(msg("Dashed outlines mark partial periods cut by the selected range; hatched or dotted marks show periods with gaps in recorded history.")); @@ -15635,12 +15740,7 @@ return [...buildMeasurementAggregateSeries({ entityId, entityLabel: args.label, - colors: [ - 1, - 2, - 3, - 4 - ].map((offset) => COLORS[(args.index + offset) % COLORS.length]), + color: args.color, definitions, stateList, range, @@ -15674,9 +15774,8 @@ }); return; } - if (opts.periodFlags) { + if (opts.aggregateLine) { r.drawLine(pts, color, t0, t1, min, max, opts); - renderer.drawPeriodMarkers(pts, opts.periodFlags, color, t0, t1, min, max); return; } if (!showGaps || pts.length < 2 || !Number.isFinite(gapThresholdMs)) { @@ -17171,8 +17270,8 @@ else renderer.drawLine(shiftedPts, aggregate.color, renderT0, renderT1, aggregateAxis.min, aggregateAxis.max, { lineOpacity: style.lineOpacity, lineWidth: style.lineWidth, - dashed: style.dashed, - dashPattern: style.dashPattern + dashed: true, + dashPattern: aggregate.dashPattern }); comparisonHoverSeries.push({ entityId: `${win.id}:${aggregate.entityId}`, @@ -17199,7 +17298,8 @@ lineWidth: this._config?.comparison_hover_active === true ? 1.25 : void 0, stepped: (analysisMap.get(s.entityId) || normalizeHistorySeriesAnalysis(null)).stepped_series === true, bars: s.bars === true, - periodFlags: s.periodFlags + periodFlags: s.periodFlags, + ...aggregateLineStyle(s) }); } const trendHoverSeries = trendSeries.map((seriesItem) => { @@ -17722,7 +17822,8 @@ lineOpacity: mainSeriesOpacity, stepped: rowAnalysis.stepped_series === true, bars: seriesItem.bars === true, - periodFlags: seriesItem.periodFlags + periodFlags: seriesItem.periodFlags, + ...aggregateLineStyle(seriesItem) }); for (const win of drawableComparisonResults || []) { const winPts = await this._resolveComparisonWindowPoints(seriesItem.entityId, win, rowAnalysis, renderT0, renderT1); diff --git a/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.ts b/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.ts index aa9a7c6c..4c6c08cf 100644 --- a/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.ts +++ b/custom_components/hass_datapoints/src/cards/history/history-chart/history-chart.ts @@ -241,6 +241,25 @@ export interface HistoryChartViewModel { // ── Component ───────────────────────────────────────────────────────────────── +/** + * Aggregate lines are drawn in the source colour; their dash pattern (per + * aggregation type) tells them apart. Non-aggregate series get no extra style. + */ +function aggregateLineStyle(series: { + periodFlags?: unknown; + dashPattern?: number[]; + bars?: boolean; +}): RecordWithUnknownValues { + if (!series.periodFlags || series.bars === true) { + return {}; + } + return { + aggregateLine: true, + dashed: true, + dashPattern: series.dashPattern, + }; +} + export class HistoryChart extends HTMLElement { // ── No shadow DOM ─────────────────────────────────────────────────────────── // Renders synchronously into its own children so the canvas and legend are @@ -888,8 +907,12 @@ export class HistoryChart extends HTMLElement { const aggregates = (series as AggregateChartSeries[]).filter( (item) => item.definitionId && item.result ); - const flagged = aggregates.some((item) => - item.periodFlags?.some((flag) => flag.partial || flag.incomplete) + // Only bars carry the dashed/hatched markers; lines report partial and + // incomplete periods in their tooltips. + const flagged = aggregates.some( + (item) => + item.bars && + item.periodFlags?.some((flag) => flag.partial || flag.incomplete) ); const hasCounts = aggregates.some((item) => ["transition_count", "event_count"].includes( @@ -1091,9 +1114,7 @@ export class HistoryChart extends HTMLElement { ...buildMeasurementAggregateSeries({ entityId, entityLabel: args.label, - colors: [1, 2, 3, 4].map( - (offset) => COLORS[(args.index + offset) % COLORS.length] - ), + color: args.color, definitions, stateList, range, @@ -1176,23 +1197,10 @@ export class HistoryChart extends HTMLElement { }); return; } - if (opts.periodFlags) { + if (opts.aggregateLine) { // Aggregate lines have one point per calendar period, so the data-gap // splitter (tuned for raw samples) must not break them up. r.drawLine(pts, color, t0, t1, min, max, opts); - ( - renderer as { - drawPeriodMarkers: ( - pts: [number, number][], - styles: unknown, - color: string, - t0: number, - t1: number, - min: number, - max: number - ) => void; - } - ).drawPeriodMarkers(pts, opts.periodFlags, color, t0, t1, min, max); return; } @@ -4247,8 +4255,8 @@ export class HistoryChart extends HTMLElement { { lineOpacity: style.lineOpacity, lineWidth: style.lineWidth, - dashed: style.dashed, - dashPattern: style.dashPattern, + dashed: true, + dashPattern: aggregate.dashPattern, } ); } @@ -4303,6 +4311,7 @@ export class HistoryChart extends HTMLElement { ).stepped_series === true, bars: s.bars === true, periodFlags: (s as { periodFlags?: unknown }).periodFlags, + ...aggregateLineStyle(s), } ); } @@ -5567,6 +5576,7 @@ export class HistoryChart extends HTMLElement { stepped: rowAnalysis.stepped_series === true, bars: seriesItem.bars === true, periodFlags: seriesItem.periodFlags, + ...aggregateLineStyle(seriesItem), } ); } diff --git a/custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts b/custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts index b6fa0e98..95bcc0b1 100644 --- a/custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts +++ b/custom_components/hass_datapoints/src/lib/chart/__tests__/chart-aggregates.spec.ts @@ -113,7 +113,7 @@ describe("buildMeasurementAggregateSeries", () => { buildMeasurementAggregateSeries({ entityId: "sensor.temp", entityLabel: "Temperature", - colors: ["#111111", "#222222"], + color: "#111111", definitions: [ definition({ operation, states: undefined, interval: "day" }), ], @@ -147,7 +147,7 @@ describe("buildMeasurementAggregateSeries", () => { const out = buildMeasurementAggregateSeries({ entityId: "sensor.temp", entityLabel: "Temperature", - colors: ["#111111"], + color: "#111111", definitions: [definition({ operation: "mean", states: undefined })], stateList: [{ s: "5", lu: sec(start + hour) }], range: { start, end: start + 2 * day }, @@ -478,3 +478,38 @@ describe("coverage flags", () => { }); }); }); + +describe("aggregate line styling", () => { + const readings = [2, 3].map((v, i) => ({ + s: String(v), + lu: sec(start + i * hour), + })); + const run = (operation: AggregateDefinition["operation"]) => + buildMeasurementAggregateSeries({ + entityId: "sensor.temp", + entityLabel: "Temperature", + color: "#ff9800", + definitions: [definition({ operation, states: undefined })], + stateList: readings, + range: { start, end: start + day }, + timeZone: "UTC", + unit: "°C", + axisKey: "°C", + })[0]; + + describe("GIVEN aggregates of different types on the same row", () => { + describe("WHEN built", () => { + it("THEN they share the source colour but have distinct dash patterns", () => { + expect.assertions(2); + const ops = ["mean", "min", "max", "median", "first", "last"] as const; + const series = ops.map(run); + expect(new Set(series.map((s) => s.color))).toStrictEqual( + new Set(["#ff9800"]) + ); + expect( + new Set(series.map((s) => JSON.stringify(s.dashPattern))).size + ).toBe(ops.length); + }); + }); + }); +}); diff --git a/custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts b/custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts index 36a68902..a699a282 100644 --- a/custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts +++ b/custom_components/hass_datapoints/src/lib/chart/chart-aggregates.ts @@ -4,6 +4,7 @@ import { AGGREGATE_INTERVAL_OPTIONS, BINARY_AGGREGATE_KINDS, getBinaryAggregateKind, + getAggregateDashPattern, getBinaryStateOptions, MEASUREMENT_OPERATIONS, isMeasurementOperation, @@ -34,6 +35,8 @@ export interface AggregateChartSeries { periodNotes?: string[]; /** Per-point coverage: edge-clipped (partial) vs gaps in history (incomplete). */ periodFlags?: { partial: boolean; incomplete: boolean }[]; + /** Line dash pattern for this aggregation type (ignored for bars). */ + dashPattern: number[]; color: string; axisKey: string; bars: boolean; @@ -198,6 +201,7 @@ export function buildBinaryAggregateSeries( periods: valued.map((p) => ({ start: p.start, end: p.end })), ...periodDetails(valued), color: definition.color || args.color, + dashPattern: getAggregateDashPattern(definition.operation), axisKey: `aggregate:${result.unit || "count"}`, bars: result.presentation === "bar", result, @@ -209,8 +213,7 @@ export function buildBinaryAggregateSeries( export interface BuildMeasurementAggregateSeriesArgs { entityId: string; entityLabel: string; - /** Colours cycled across this row's aggregates so they differ from the source line. */ - colors: string[]; + color: string; definitions: AggregateDefinition[]; stateList: { s: string; lu: number }[]; range: AggregateRange; @@ -241,7 +244,7 @@ export function buildMeasurementAggregateSeries( definition.visible !== false && isMeasurementOperation(definition.operation) ) - .flatMap((definition, index) => { + .flatMap((definition) => { const result = calculateAggregate({ definition, samples, @@ -286,7 +289,8 @@ export function buildMeasurementAggregateSeries( ]), periods: valued.map((p) => ({ start: p.start, end: p.end })), ...periodDetails(valued), - color: definition.color || args.colors[index % args.colors.length], + color: definition.color || args.color, + dashPattern: getAggregateDashPattern(definition.operation), axisKey: args.axisKey, bars: result.presentation === "bar", result, @@ -404,6 +408,7 @@ export function buildEventAggregateSeries( periods: valued.map((p) => ({ start: p.start, end: p.end })), ...periodDetails(valued), color: definition.color || args.color, + dashPattern: getAggregateDashPattern(definition.operation), axisKey: `aggregate:${result.unit || "count"}`, bars: result.presentation === "bar", result, @@ -524,6 +529,7 @@ export function buildConsumptionAggregateSeries( periods: valued.map((p) => ({ start: p.start, end: p.end })), ...periodDetails(valued, describeConsumptionReasons), color: definition.color || args.color, + dashPattern: getAggregateDashPattern(definition.operation), axisKey: `aggregate:${args.unit || "count"}`, bars: result.presentation === "bar", result, diff --git a/custom_components/hass_datapoints/src/lib/chart/chart-renderer.ts b/custom_components/hass_datapoints/src/lib/chart/chart-renderer.ts index d361bfd8..5b56ded5 100644 --- a/custom_components/hass_datapoints/src/lib/chart/chart-renderer.ts +++ b/custom_components/hass_datapoints/src/lib/chart/chart-renderer.ts @@ -889,44 +889,6 @@ export class ChartRenderer { ctx.restore(); } - /** - * Point markers for an aggregate line: filled when the period is complete, - * hollow when partial, dotted ring when history is incomplete. - */ - drawPeriodMarkers( - points: ChartPoint[], - styles: PeriodStyle[], - color: string, - t0: number, - t1: number, - vMin: number, - vMax: number - ): void { - const { ctx } = this; - ctx.save(); - ctx.strokeStyle = color; - ctx.fillStyle = color; - ctx.lineWidth = 1.5; - points.forEach(([t, v], i) => { - const style = styles[i]; - const x = this.xOf(t, t0, t1); - const y = this.yOf(v, vMin, vMax); - ctx.beginPath(); - ctx.arc(x, y, 3.5, 0, Math.PI * 2); - if (style?.partial || style?.incomplete) { - ctx.setLineDash(style.partial ? [] : [1.5, 1.5]); - ctx.fillStyle = hexToRgba(color, 0.15); - ctx.fill(); - ctx.stroke(); - } else { - ctx.setLineDash([]); - ctx.fillStyle = color; - ctx.fill(); - } - }); - ctx.restore(); - } - drawStateBands( spans: Nullable> | undefined, t0: number, diff --git a/custom_components/hass_datapoints/src/lib/domain/aggregation/__tests__/calendar.spec.ts b/custom_components/hass_datapoints/src/lib/domain/aggregation/__tests__/calendar.spec.ts index a4104bd4..56898cb4 100644 --- a/custom_components/hass_datapoints/src/lib/domain/aggregation/__tests__/calendar.spec.ts +++ b/custom_components/hass_datapoints/src/lib/domain/aggregation/__tests__/calendar.spec.ts @@ -91,3 +91,47 @@ describe("GIVEN a range crossing February", () => { }); }); }); + +describe("GIVEN sub-hour intervals", () => { + const range = { + start: Date.parse("2026-01-01T10:07:30Z"), + end: Date.parse("2026-01-01T11:00:00Z"), + }; + + describe("WHEN splitting into 15 minute periods", () => { + it("THEN periods align to :00/:15/:30/:45 and the first is partial", () => { + expect.assertions(3); + const periods = getPeriods(range, "15min", "UTC"); + expect( + periods.map((p) => new Date(p.periodStart).getUTCMinutes()) + ).toStrictEqual([0, 15, 30, 45]); + expect(periods[0].partial).toBe(true); + expect(periods[1].partial).toBe(false); + }); + }); + + describe("WHEN splitting into minutes", () => { + it("THEN there is one period per started minute", () => { + expect.assertions(1); + expect(getPeriods(range, "minute", "UTC")).toHaveLength(53); + }); + }); + + describe("WHEN splitting into 30 minute periods in a half-hour offset zone", () => { + it("THEN boundaries follow local :00/:30", () => { + expect.assertions(1); + // Kolkata is UTC+5:30, so local :00/:30 are UTC :30/:00. + const [first] = getPeriods( + { + start: Date.parse("2026-01-01T10:10:00Z"), + end: Date.parse("2026-01-01T11:00:00Z"), + }, + "30min", + "Asia/Kolkata" + ); + expect(new Date(first.periodStart).toISOString()).toBe( + "2026-01-01T10:00:00.000Z" + ); + }); + }); +}); diff --git a/custom_components/hass_datapoints/src/lib/domain/aggregation/aggregation.ts b/custom_components/hass_datapoints/src/lib/domain/aggregation/aggregation.ts index f73dd877..73340d3f 100644 --- a/custom_components/hass_datapoints/src/lib/domain/aggregation/aggregation.ts +++ b/custom_components/hass_datapoints/src/lib/domain/aggregation/aggregation.ts @@ -1,4 +1,5 @@ import { + AGGREGATE_INTERVALS, AGGREGATE_OPERATIONS, type AggregateDefinition, type AggregateOperation, @@ -19,7 +20,7 @@ export function normalizeAggregateDefinitions( !item.id || ids.has(item.id) || !AGGREGATE_OPERATIONS.includes(item.operation) || - !["hour", "day", "week", "month"].includes(item.interval) + !AGGREGATE_INTERVALS.includes(item.interval) ) { return []; } diff --git a/custom_components/hass_datapoints/src/lib/domain/aggregation/calculate.ts b/custom_components/hass_datapoints/src/lib/domain/aggregation/calculate.ts index 194d8589..81afb4c9 100644 --- a/custom_components/hass_datapoints/src/lib/domain/aggregation/calculate.ts +++ b/custom_components/hass_datapoints/src/lib/domain/aggregation/calculate.ts @@ -34,22 +34,59 @@ function overlap(a: AggregateRange, b: AggregateRange): number { return Math.max(0, Math.min(a.end, b.end) - Math.max(a.start, b.start)); } -function inPeriod(time: number, period: AggregatePeriod): boolean { - return time >= period.start && time < period.end; +/** + * Bucket time-ordered items into periods in one sweep. Minute-level intervals + * over long ranges create tens of thousands of periods, so per-period filters + * over every item would be quadratic. + */ +function groupByPeriod( + items: T[], + periods: AggregatePeriod[], + timeOf: (item: T) => number +): T[][] { + const sorted = [...items].sort((a, b) => timeOf(a) - timeOf(b)); + const groups: T[][] = periods.map(() => []); + let cursor = 0; + sorted.forEach((item) => { + const time = timeOf(item); + while (cursor < periods.length && time >= periods[cursor].end) { + cursor += 1; + } + if (cursor < periods.length && time >= periods[cursor].start) { + groups[cursor].push(item); + } + }); + return groups; } function sortSamples(samples: AggregateSample[]): AggregateSample[] { return [...samples].sort((a, b) => a.time - b.time); } -function knownDurationOf( - period: AggregatePeriod, +/** Known (covered) time within each period, in one sweep over the coverage. */ +function knownDurationsOf( + periods: AggregatePeriod[], coverage: AggregateRange[] | undefined -): number { +): number[] { if (!coverage) { - return period.end - period.start; + return periods.map((period) => period.end - period.start); } - return coverage.reduce((sum, c) => sum + overlap(period, c), 0); + const sorted = [...coverage].sort((a, b) => a.start - b.start); + let first = 0; + return periods.map((period) => { + while (first < sorted.length && sorted[first].end <= period.start) { + first += 1; + } + let known = 0; + for ( + let i = first; + i < sorted.length && sorted[i].start < period.end; + i += 1 + ) { + known += overlap(period, sorted[i]); + } + return known; + }); } /** Known-state spans over `range`, seeded by the last sample at/before start. */ @@ -109,10 +146,9 @@ function calculateMeasurement( (s) => !s.boundary && typeof s.value === "number" && Number.isFinite(s.value) ); - return periods.map((period) => { - const values = readings - .filter((s) => inPeriod(s.time, period)) - .map((s) => s.value as number); + const grouped = groupByPeriod(readings, periods, (s) => s.time); + return periods.map((_period, index) => { + const values = grouped[index].map((s) => s.value as number); if (!values.length) { return { value: null, reasons: ["no-data"] }; } @@ -159,8 +195,9 @@ function calculateTransitions( } times.push(curr.time); }); - return periods.map((period) => ({ - value: times.filter((t) => inPeriod(t, period)).length, + const grouped = groupByPeriod(times, periods, (t) => t); + return periods.map((_period, index) => ({ + value: grouped[index].length, reasons: [], })); } @@ -174,14 +211,27 @@ function calculateDurations( if (!selected.length) { return periods.map(() => ({ value: null, reasons: ["no-state-selected"] })); } - const spans = buildStateSpans(input.samples, input.range, input.coverage); + const spans = buildStateSpans( + input.samples, + input.range, + input.coverage + ).sort((a, b) => a.start - b.start); + let first = 0; return periods.map((period) => { let known = 0; let matching = 0; - for (const span of spans) { - const ms = overlap(period, span); + // Spans are ordered and non-overlapping, so skip those already behind us. + while (first < spans.length && spans[first].end <= period.start) { + first += 1; + } + for ( + let i = first; + i < spans.length && spans[i].start < period.end; + i += 1 + ) { + const ms = overlap(period, spans[i]); known += ms; - if (selected.includes(span.state)) { + if (selected.includes(spans[i].state)) { matching += ms; } } @@ -209,8 +259,9 @@ function calculateEvents( (types.length === 0 || types.includes(s.eventType)) ) .map((s) => s.eventTime ?? s.time); - return periods.map((period) => ({ - value: times.filter((t) => inPeriod(t, period)).length, + const grouped = groupByPeriod(times, periods, (t) => t); + return periods.map((_period, index) => ({ + value: grouped[index].length, reasons: [], })); } @@ -264,8 +315,9 @@ function calculateConsumption( deltas.push({ time: curr.time, value: 0, reason: "ambiguous-reset" }); } }); - return periods.map((period) => { - const inside = deltas.filter((d) => inPeriod(d.time, period)); + const grouped = groupByPeriod(deltas, periods, (d) => d.time); + return periods.map((_period, index) => { + const inside = grouped[index]; if (!inside.length) { return { value: null, reasons: ["no-data"] }; } @@ -348,9 +400,10 @@ export function calculateAggregate(input: AggregateInput): AggregateResult { ({ buckets, periods, started } = result); } + const knownDurations = knownDurationsOf(periods, input.coverage); const points: AggregatePoint[] = periods.map((period, i) => { const totalDuration = period.end - period.start; - const knownDuration = knownDurationOf(period, input.coverage); + const knownDuration = knownDurations[i]; const reasons = [...buckets[i].reasons]; const incomplete = knownDuration < totalDuration; if (incomplete) { diff --git a/custom_components/hass_datapoints/src/lib/domain/aggregation/calendar.ts b/custom_components/hass_datapoints/src/lib/domain/aggregation/calendar.ts index 88d8465e..1af15e2c 100644 --- a/custom_components/hass_datapoints/src/lib/domain/aggregation/calendar.ts +++ b/custom_components/hass_datapoints/src/lib/domain/aggregation/calendar.ts @@ -5,6 +5,15 @@ import type { } from "@/lib/domain/aggregation/types"; const HOUR_MS = 3600000; +const MINUTE_MS = 60000; + +/** Sub-day intervals of fixed length, aligned to the local wall clock. */ +const FIXED_INTERVAL_MS: Partial> = { + minute: MINUTE_MS, + "15min": 15 * MINUTE_MS, + "30min": 30 * MINUTE_MS, + hour: HOUR_MS, +}; const formatters = new Map(); function getFormatter(timeZone: string): Intl.DateTimeFormat { @@ -91,9 +100,12 @@ export function getPeriodStart( timeZone: string ): number { const p = getLocalParts(ts, timeZone); - if (interval === "hour") { + const fixedMs = FIXED_INTERVAL_MS[interval]; + if (fixedMs) { const ms = ((ts % 1000) + 1000) % 1000; - return ts - ((p.minute * 60 + p.second) * 1000 + ms); + // Align on local minutes so half-hour offset zones still land on :00/:15/:30. + const offsetMinutes = p.minute % (fixedMs / MINUTE_MS); + return ts - ms - p.second * 1000 - offsetMinutes * MINUTE_MS; } if (interval === "day") { return localMidnight(p.year, p.month, p.day, timeZone); @@ -114,8 +126,9 @@ export function getNextPeriodStart( interval: AggregateInterval, timeZone: string ): number { - if (interval === "hour") { - return periodStart + HOUR_MS; + const fixedMs = FIXED_INTERVAL_MS[interval]; + if (fixedMs) { + return periodStart + fixedMs; } const p = getLocalParts(periodStart + HOUR_MS * 2, timeZone); if (interval === "day") { diff --git a/custom_components/hass_datapoints/src/lib/domain/aggregation/labels.ts b/custom_components/hass_datapoints/src/lib/domain/aggregation/labels.ts index 5a7801b7..d55dada5 100644 --- a/custom_components/hass_datapoints/src/lib/domain/aggregation/labels.ts +++ b/custom_components/hass_datapoints/src/lib/domain/aggregation/labels.ts @@ -32,6 +32,9 @@ export const AGGREGATE_INTERVAL_OPTIONS: { value: AggregateInterval; label: string; }[] = [ + { value: "minute", label: "Every minute" }, + { value: "15min", label: "Every 15 minutes" }, + { value: "30min", label: "Every 30 minutes" }, { value: "hour", label: "Hourly" }, { value: "day", label: "Daily" }, { value: "week", label: "Weekly" }, @@ -157,3 +160,27 @@ export function createEventAggregate( eventTypes, }; } + +/** + * Dash pattern (px on/off) per aggregation type. Aggregate lines keep the + * source series' colour, so the pattern is what tells them apart. + */ +const DASH_PATTERNS: Partial> = { + mean: [10, 4], + min: [2, 4], + max: [2, 4, 10, 4], + median: [6, 3, 2, 3], + first: [1, 5], + last: [1, 3, 1, 3, 8, 3], + consumption: [10, 4], + transition_count: [10, 4], + state_duration: [2, 4], + state_percentage: [2, 4, 10, 4], + event_count: [6, 3, 2, 3], +}; + +export function getAggregateDashPattern( + operation: AggregateOperation +): number[] { + return DASH_PATTERNS[operation] ?? [6, 3]; +} diff --git a/custom_components/hass_datapoints/src/lib/domain/aggregation/types.ts b/custom_components/hass_datapoints/src/lib/domain/aggregation/types.ts index 8e8c9816..1b9503f4 100644 --- a/custom_components/hass_datapoints/src/lib/domain/aggregation/types.ts +++ b/custom_components/hass_datapoints/src/lib/domain/aggregation/types.ts @@ -12,7 +12,16 @@ export const AGGREGATE_OPERATIONS = [ "event_count", ] as const; export type AggregateOperation = (typeof AGGREGATE_OPERATIONS)[number]; -export type AggregateInterval = "hour" | "day" | "week" | "month"; +export const AGGREGATE_INTERVALS = [ + "minute", + "15min", + "30min", + "hour", + "day", + "week", + "month", +] as const; +export type AggregateInterval = (typeof AGGREGATE_INTERVALS)[number]; export interface AggregateDefinition { id: string; operation: AggregateOperation; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts index f1d0ed06..543f57c4 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/de.ts @@ -41,4 +41,7 @@ export const translations: ComponentTranslations = { "Count events": "Ereignisse zählen", Interval: "Intervall", State: "Zustand", + "Every minute": "Jede Minute", + "Every 15 minutes": "Alle 15 Minuten", + "Every 30 minutes": "Alle 30 Minuten", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts index d60c3c64..bbb71af6 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/es.ts @@ -41,4 +41,7 @@ export const translations: ComponentTranslations = { "Count events": "Contar eventos", Interval: "Intervalo", State: "Estado", + "Every minute": "Cada minuto", + "Every 15 minutes": "Cada 15 minutos", + "Every 30 minutes": "Cada 30 minutos", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts index 83835a91..2deabf46 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fi.ts @@ -41,4 +41,7 @@ export const translations: ComponentTranslations = { "Count events": "Laske tapahtumat", Interval: "Jakso", State: "Tila", + "Every minute": "Joka minuutti", + "Every 15 minutes": "Joka 15. minuutti", + "Every 30 minutes": "Joka 30. minuutti", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts index 5a648cf1..6374a5bd 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/fr.ts @@ -41,4 +41,7 @@ export const translations: ComponentTranslations = { "Count events": "Compter les événements", Interval: "Intervalle", State: "État", + "Every minute": "Chaque minute", + "Every 15 minutes": "Toutes les 15 minutes", + "Every 30 minutes": "Toutes les 30 minutes", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts index 50f821f5..a859231f 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/pt.ts @@ -41,4 +41,7 @@ export const translations: ComponentTranslations = { "Count events": "Contar eventos", Interval: "Intervalo", State: "Estado", + "Every minute": "A cada minuto", + "Every 15 minutes": "A cada 15 minutos", + "Every 30 minutes": "A cada 30 minutos", }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts index 5a0a64fe..4a399505 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-aggregate-group/i18n/zh-hans.ts @@ -40,4 +40,7 @@ export const translations: ComponentTranslations = { "Count events": "统计事件数", Interval: "间隔", State: "状态", + "Every minute": "每分钟", + "Every 15 minutes": "每 15 分钟", + "Every 30 minutes": "每 30 分钟", }; From 32ddf5ee59546b64ddf007e3f84d9bca28ba88ed Mon Sep 17 00:00:00 2001 From: Ollie Date: Sun, 25 Oct 2026 10:02:02 +0200 Subject: [PATCH 17/17] fix(aggregation): hide aggregate series with their row's visibility switch Refs #67 Co-Authored-By: Claude Sonnet 5.5 --- .../hass_datapoints/hass-datapoints-cards.js | 13 ++++++--- .../history/history-chart/history-chart.ts | 24 ++++++++++++++--- .../stories/history-chart.stories.ts | 27 +++++++++++++++++++ 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/custom_components/hass_datapoints/hass-datapoints-cards.js b/custom_components/hass_datapoints/hass-datapoints-cards.js index 155e68b4..69a923ff 100644 --- a/custom_components/hass_datapoints/hass-datapoints-cards.js +++ b/custom_components/hass_datapoints/hass-datapoints-cards.js @@ -15651,7 +15651,7 @@