From 7ffd56f5408717535de5f8265dbf2390c7228608 Mon Sep 17 00:00:00 2001 From: Oliver Lillie Date: Fri, 10 Apr 2026 08:24:58 +0300 Subject: [PATCH 1/5] feat(trends): Wire anomaly-trend override, fix CI Python, expand websocket schema doc(Agents): Updates agents internationalision instructions, add bulk-action checklist to AGENTS.md chore(storybook): Updates storybook package json scripts to sb short form chore(tests): Adds test:watch and per test project splits fix(trends): Fixes backend anomaly trends validation --- .github/workflows/ci.yml | 2 +- AGENTS.md | 55 +++++--- README.md | 4 +- .../hass_datapoints/hass-datapoints-cards.js | 81 ++++++++++- .../history/history-chart/history-chart.ts | 30 +++- .../src/lib/domain/__tests__/domain.spec.ts | 2 + .../domain/__tests__/history-series.spec.ts | 39 ++++++ .../src/lib/domain/history-series.ts | 11 ++ .../__tests__/analysis-anomaly-group.spec.ts | 129 ++++++++++++++++++ .../analysis-anomaly-group.ts | 68 +++++++++ .../analysis-anomaly-group/i18n/de.ts | 3 + .../analysis-anomaly-group/i18n/es.ts | 3 + .../analysis-anomaly-group/i18n/fi.ts | 3 + .../analysis-anomaly-group/i18n/fr.ts | 3 + .../analysis-anomaly-group/i18n/pt.ts | 3 + .../analysis-anomaly-group/i18n/zh-hans.ts | 3 + .../stories/analysis-anomaly-group.stories.ts | 123 +++++++++++++++++ .../stories/analysis-delta-group.stories.ts | 2 + .../stories/analysis-rate-group.stories.ts | 2 + .../stories/analysis-sample-group.stories.ts | 2 + .../stories/analysis-summary-group.stories.ts | 2 + .../analysis-threshold-group.stories.ts | 2 + .../stories/analysis-trend-group.stories.ts | 2 + .../stories/target-row-list.stories.ts | 2 + .../target-row/stories/target-row.stories.ts | 2 + .../src/molecules/target-row/types.ts | 2 + .../hass_datapoints/websocket_api.py | 8 +- package.json | 8 +- 28 files changed, 562 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d3ec3c5..78f5eb88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -194,7 +194,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Build Storybook - run: pnpm build-storybook --stats-json + run: pnpm sb:build --stats-json - name: Run Chromatic visual tests uses: chromaui/action@v11 diff --git a/AGENTS.md b/AGENTS.md index f12709e1..2dde0e3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ This file is the working project guide for contributors and coding agents. Use i 1. `pnpm build` 2. `pnpm test` 3. `pnpm vitest run ` -4. `pnpm build-storybook` +4. `pnpm sb:build` When making scoped changes, run focused Vitest first, then `pnpm build`, then broader verification as needed. @@ -313,11 +313,11 @@ The old `PANEL_HISTORY_STYLE` block still matters as a source of truth for many ## i18n Guidance -The frontend uses `@lit/localize` in **runtime mode**. Source locale is English. The only translated locale is Finnish (`fi`). +The frontend uses `@lit/localize` in **runtime mode**. Source locale is English. Supported translated locales: **de, es, fi, fr, pt, zh-hans**. ### How it works -`src/lib/i18n/localize.ts` configures the localization runtime once. When the HA user's locale is Finnish, `setLocale("fi")` is called and the Finnish locale chunk is loaded asynchronously. Components decorated with `@localized()` re-render automatically when the locale changes. +`src/lib/i18n/localize.ts` configures the localization runtime once. When the HA user's locale matches a supported locale, `setLocale("")` is called and the locale chunk is loaded asynchronously. Components decorated with `@localized()` re-render automatically when the locale changes. ### String wrapping rules @@ -355,22 +355,26 @@ t("Anomaly at {0} with severity {1}", time, severity); // Wrong — msg() not yet active when the module loads const OPTIONS = [{ label: msg("Hour"), value: "hour" }]; -// Correct — msg() is called at render time -get; -_localizedOptions(); -{ +// Correct — msg() is called at render time via _localizedOptions() +private _localizedOptions() { return [{ label: msg("Hour"), value: "hour" }]; } ``` ### Co-located translation files -Each component owns its Finnish strings in a `*.i18n.fi.ts` file placed next to the component source: +Each component owns its translations in an `i18n/` subdirectory next to the component source, one file per locale: ```text -src/molecules/target-row/ -├── target-row.ts -└── target-row.i18n.fi.ts +src/molecules/analysis-anomaly-group/ +├── analysis-anomaly-group.ts +└── i18n/ + ├── de.ts + ├── es.ts + ├── fi.ts + ├── fr.ts + ├── pt.ts + └── zh-hans.ts ``` Every translation file exports a `translations` object typed as `ComponentTranslations`: @@ -379,18 +383,22 @@ Every translation file exports a `translations` object typed as `ComponentTransl import type { ComponentTranslations } from "@/lib/i18n/types"; export const translations: ComponentTranslations = { - "Show anomalies": "Näytä anomaliat", + "Show anomalies": "Näytä poikkeamat", Sensitivity: "Herkkyys", }; ``` -Keys are the English source strings exactly as they appear in `msg()` calls. Values are the Finnish equivalents. +Keys are the English source strings exactly as they appear in `msg()` calls. Values are the translated equivalents. + +### Adding new strings + +When you add a new `msg("Some string")` call to a component, you **must** add the translation to **all six** locale files in that component's `i18n/` directory. Leave no locale file missing a key — missing keys silently fall back to English but the files should be kept in sync. ### Auto-discovery — no registration needed -`src/lib/i18n/locales/fi.ts` uses `import.meta.glob` to merge every `*.i18n.fi.ts` file across the whole `src/` tree at build time. There is nothing to register: dropping a correctly structured file next to a component is sufficient. +`src/lib/i18n/locales/.ts` uses `import.meta.glob` to merge every `i18n/.ts` file across the whole `src/` tree at build time. There is nothing to register: dropping a correctly structured file into the component's `i18n/` directory is sufficient. -Duplicate keys are resolved by last-writer-wins (`Object.assign`). This is safe because any shared key (e.g. `"Auto"`) carries the same Finnish value regardless of which component declares it. +Duplicate keys are resolved by last-writer-wins (`Object.assign`). This is safe because any shared key (e.g. `"1 hour"`) carries the same translation regardless of which component declares it. ### Adding i18n to a new component @@ -401,10 +409,6 @@ Duplicate keys are resolved by last-writer-wins (`Object.assign`). This is safe 5. Create `.i18n.fi.ts` next to the component source with all Finnish translations. 6. Run `pnpm build` — the file is auto-discovered. -### Finnish translation quality - -The Finnish translations in this project are **machine-generated approximations**. They were produced by automated translation and have not been reviewed by a native Finnish speaker. Do not treat Finnish strings as authoritative phrasing — they exist for functional locale switching, not linguistic correctness. - ### General rules - Source strings live inline in component code, not in a separate strings file. @@ -644,6 +648,19 @@ Do not assume backend support means all frontend chart logic can be removed. Che --- +## Bulk Action Checklist + +After any task that touches multiple files — new features, refactors, adding fields, renaming, adding translations — always run these two commands as the final step before finishing: + +```bash +pnpm format # auto-fixes Prettier and ESLint formatting in one pass +pnpm lint:types # TypeScript type-check across the whole project +``` + +Both must pass clean before the work is considered done. If `lint:types` reports errors, fix them before stopping. If `format` rewrites files, stage the changes. + +--- + ## Final Rule Of Thumb When in doubt: diff --git a/README.md b/README.md index c726f23a..8b745859 100644 --- a/README.md +++ b/README.md @@ -903,8 +903,8 @@ The published Storybook for the `main` branch is available at: To run Storybook locally or build it: ```bash -pnpm storybook -pnpm build-storybook +pnpm sb +pnpm sb:build ``` ### Frontend source layout diff --git a/custom_components/hass_datapoints/hass-datapoints-cards.js b/custom_components/hass_datapoints/hass-datapoints-cards.js index 0291890e..17dad585 100644 --- a/custom_components/hass_datapoints/hass-datapoints-cards.js +++ b/custom_components/hass_datapoints/hass-datapoints-cards.js @@ -2237,6 +2237,9 @@ "Show all anomalies": "Näytä kaikki poikkeamat", "Overlaps only": "Vain päällekkäisyydet", "Computing…": "Lasketaan…", + "Trend method": "Trendimenetelmä", + "Trend window": "Trendiikkuna", + "Same as display trend": "Sama kuin näyttötrendi", "1 hour": "1 tunti", "3 hours": "3 tuntia", "6 hours": "6 tuntia", @@ -2755,6 +2758,9 @@ "Show all anomalies": "Afficher toutes les anomalies", "Overlaps only": "Chevauchements uniquement", "Computing…": "Calcul…", + "Trend method": "Méthode de tendance", + "Trend window": "Fenêtre de tendance", + "Same as display trend": "Identique à la tendance affichée", "1 hour": "1 heure", "3 hours": "3 heures", "6 hours": "6 heures", @@ -3273,6 +3279,9 @@ "Show all anomalies": "Alle Anomalien anzeigen", "Overlaps only": "Nur Überlappungen", "Computing…": "Wird berechnet…", + "Trend method": "Trendmethode", + "Trend window": "Trendfenster", + "Same as display trend": "Wie Anzeigetrend", "1 hour": "1 Stunde", "3 hours": "3 Stunden", "6 hours": "6 Stunden", @@ -3791,6 +3800,9 @@ "Show all anomalies": "Mostrar todas las anomalías", "Overlaps only": "Solo solapamientos", "Computing…": "Calculando…", + "Trend method": "Método de tendencia", + "Trend window": "Ventana de tendencia", + "Same as display trend": "Igual que la tendencia de visualización", "1 hour": "1 hora", "3 hours": "3 horas", "6 hours": "6 horas", @@ -4309,6 +4321,9 @@ "Show all anomalies": "Mostrar todas as anomalias", "Overlaps only": "Apenas sobreposições", "Computing…": "A calcular…", + "Trend method": "Método de tendência", + "Trend window": "Janela de tendência", + "Same as display trend": "Igual à tendência de apresentação", "1 hour": "1 hora", "3 hours": "3 horas", "6 hours": "6 horas", @@ -4827,6 +4842,9 @@ "Show all anomalies": "显示所有异常", "Overlaps only": "仅重叠项", "Computing…": "计算中…", + "Trend method": "趋势方法", + "Trend window": "趋势窗口", + "Same as display trend": "与显示趋势相同", "1 hour": "1小时", "3 hours": "3小时", "6 hours": "6小时", @@ -9983,6 +10001,8 @@ anomaly_zscore_window: typeof source.anomaly_zscore_window === "string" && source.anomaly_zscore_window ? source.anomaly_zscore_window : "24h", anomaly_persistence_window: typeof source.anomaly_persistence_window === "string" && source.anomaly_persistence_window ? source.anomaly_persistence_window : "1h", anomaly_comparison_window_id: typeof source.anomaly_comparison_window_id === "string" && source.anomaly_comparison_window_id ? source.anomaly_comparison_window_id : null, + anomaly_trend_method: typeof source.anomaly_trend_method === "string" ? source.anomaly_trend_method : "", + anomaly_trend_window: typeof source.anomaly_trend_window === "string" && source.anomaly_trend_window ? source.anomaly_trend_window : "24h", show_delta_analysis: source.show_delta_analysis === true, show_delta_tooltip: source.show_delta_tooltip !== false, show_delta_lines: source.show_delta_lines === true, @@ -11730,8 +11750,14 @@ anomaly_rate_window: typeof analysis.anomaly_rate_window === "string" ? analysis.anomaly_rate_window : void 0, anomaly_zscore_window: typeof analysis.anomaly_zscore_window === "string" ? analysis.anomaly_zscore_window : void 0, anomaly_persistence_window: typeof analysis.anomaly_persistence_window === "string" ? analysis.anomaly_persistence_window : void 0, - trend_method: typeof analysis.trend_method === "string" ? analysis.trend_method : void 0, - trend_window: typeof analysis.trend_window === "string" ? analysis.trend_window : void 0, + trend_method: (() => { + if (typeof analysis.anomaly_trend_method === "string" && analysis.anomaly_trend_method) return analysis.anomaly_trend_method; + return typeof analysis.trend_method === "string" ? analysis.trend_method : void 0; + })(), + trend_window: (() => { + if (typeof analysis.anomaly_trend_method === "string" && analysis.anomaly_trend_method && typeof analysis.anomaly_trend_window === "string" && analysis.anomaly_trend_window) return analysis.anomaly_trend_window; + return typeof analysis.trend_window === "string" ? analysis.trend_window : void 0; + })(), anomaly_use_sampled_data: analysis.anomaly_use_sampled_data !== false }; if (analysis.anomaly_use_sampled_data !== false) { @@ -13571,7 +13597,9 @@ "anomaly_zscore_window", "anomaly_persistence_window", "anomaly_comparison_window_id", - "anomaly_use_sampled_data" + "anomaly_use_sampled_data", + "anomaly_trend_method", + "anomaly_trend_window" ]; return `${t0}:${t1}|${visibleSeries.map((s) => { const a = analysisMap.get(s.entityId) || normalizeHistorySeriesAnalysis(null); @@ -17729,6 +17757,53 @@ })); } _renderMethodSubopts(opt, a) { + if (opt.value === "trend_residual") { + const anomalyMethod = a.anomaly_trend_method || ""; + const methodOptions = [{ + value: "", + label: msg("Same as display trend") + }, ...this._localizedOptions(ANALYSIS_TREND_METHOD_OPTIONS)]; + const windowOptions = this._localizedOptions(ANALYSIS_TREND_WINDOW_OPTIONS); + const showWindow = [ + "rolling_average", + "ema", + "lowess" + ].includes(anomalyMethod); + return b` + + + ${showWindow ? b` + + ` : A} + + `; + } if (opt.value === "rate_of_change") return b` ${[ "rolling_average", @@ -17144,7 +17216,11 @@ ].includes(a.trend_method) ? b` ` : A} @@ -17285,18 +17361,6 @@ label: msg(opt.label) })); } - _renderSelect(key, options, value) { - return b` - - `; - } _onGroupChange(e) { this._emit("show_rate_of_change", e.detail.checked); } @@ -17321,7 +17385,11 @@ `; @@ -17382,18 +17450,6 @@ label: msg(opt.label) })); } - _renderSelect(key, options, value) { - return b` - - `; - } _onGroupChange(e) { this._emit("show_threshold_analysis", e.detail.checked); } @@ -17437,13 +17493,17 @@ ${a.show_threshold_shading ? b` ` : A} @@ -17734,18 +17794,6 @@ composed: true })); } - _renderSelect(key, options, value) { - return b` - - `; - } _onGroupChange(e) { this._emit("show_anomalies", e.detail.checked); } @@ -17758,47 +17806,38 @@ } _renderMethodSubopts(opt, a) { if (opt.value === "trend_residual") { - const anomalyMethod = a.anomaly_trend_method || ""; + const storedMethod = a.anomaly_trend_method || ""; + const trendLinesEnabled = a.show_trend_lines === true; + const effectiveMethod = !trendLinesEnabled && storedMethod === "" ? ANALYSIS_TREND_METHOD_OPTIONS[0]?.value ?? "rolling_average" : storedMethod; const methodOptions = [{ value: "", - label: msg("Same as display trend") + label: msg("Same as display trend"), + disabled: !trendLinesEnabled }, ...this._localizedOptions(ANALYSIS_TREND_METHOD_OPTIONS)]; const windowOptions = this._localizedOptions(ANALYSIS_TREND_WINDOW_OPTIONS); const showWindow = [ "rolling_average", "ema", "lowess" - ].includes(anomalyMethod); + ].includes(effectiveMethod); return b` ${showWindow ? b` ` : A} @@ -17808,7 +17847,11 @@ `; @@ -17816,7 +17859,11 @@ `; @@ -17824,40 +17871,42 @@ `; - if (opt.value === "comparison_window") return b` + if (opt.value === "comparison_window") { + const comparisonOptions = [{ + value: "", + label: msg("— select window —") + }, ...this.comparisonWindows.map((win) => ({ + value: win.id, + label: win.label || win.id + }))]; + return b` `; + } return A; } render() { const a = this.analysis; const sensitivityOptions = this._localizedOptions(ANALYSIS_ANOMALY_SENSITIVITY_OPTIONS); - const methodOptions = this._localizedOptions(ANALYSIS_ANOMALY_METHOD_OPTIONS); const overlapOptions = this._localizedOptions(ANALYSIS_ANOMALY_OVERLAP_MODE_OPTIONS$2); + const methodOptions = this._localizedOptions(ANALYSIS_ANOMALY_METHOD_OPTIONS); return b` ${a.sample_interval && a.sample_interval !== "raw" ? b` @@ -18979,7 +19036,7 @@ this._emitDisplay(name, checked); } _onGapThresholdChange(e) { - this._emitDisplay("data_gap_threshold", e.target.value); + this._emitDisplay("data_gap_threshold", e.detail.value); } _onYAxisModeChange(e) { this._emitDisplay("y_axis_mode", e.detail.value); @@ -19016,20 +19073,12 @@ @dp-item-change=${this._onCheckboxChange} >
- + ${msg("Gap threshold")}
@@ -19402,7 +19451,7 @@ `; } }; - _defineProperty(HassRecordsHistoryCardEditor, "styles", [EditorBase.styles, styles$51]); + _defineProperty(HassRecordsHistoryCardEditor, "styles", [EditorBase.styles, styles$52]); //#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. */ diff --git a/custom_components/hass_datapoints/src/atoms/form/inline-select/inline-select.ts b/custom_components/hass_datapoints/src/atoms/form/inline-select/inline-select.ts index 1a2352a6..240dff7d 100644 --- a/custom_components/hass_datapoints/src/atoms/form/inline-select/inline-select.ts +++ b/custom_components/hass_datapoints/src/atoms/form/inline-select/inline-select.ts @@ -33,7 +33,11 @@ export class InlineSelect extends LitElement { > ${this.options.map( (opt) => html` - ` diff --git a/custom_components/hass_datapoints/src/lib/types.ts b/custom_components/hass_datapoints/src/lib/types.ts index 963ac479..ac76a1b7 100644 --- a/custom_components/hass_datapoints/src/lib/types.ts +++ b/custom_components/hass_datapoints/src/lib/types.ts @@ -66,6 +66,7 @@ export interface HassLike { export interface SelectOption { label: string; value: string; + disabled?: boolean; } export interface SeriesItem { diff --git a/custom_components/hass_datapoints/src/molecules/analysis-anomaly-group/__tests__/analysis-anomaly-group.spec.ts b/custom_components/hass_datapoints/src/molecules/analysis-anomaly-group/__tests__/analysis-anomaly-group.spec.ts index b9a47092..639d1c87 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-anomaly-group/__tests__/analysis-anomaly-group.spec.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-anomaly-group/__tests__/analysis-anomaly-group.spec.ts @@ -142,10 +142,11 @@ describe("analysis-anomaly-group", () => { }); }); - describe("GIVEN trend_residual method is checked with anomaly_trend_method=''", () => { + describe("GIVEN trend_residual method is checked with anomaly_trend_method='' and show_trend_lines=true", () => { beforeEach(async () => { el = createElement({ analysis: { + show_trend_lines: true, show_anomalies: true, anomaly_methods: ["trend_residual"], anomaly_trend_method: "", @@ -156,39 +157,53 @@ describe("analysis-anomaly-group", () => { }); describe("WHEN rendered", () => { - it("THEN shows a trend method select inside method subopts", () => { + it("THEN shows a trend method inline-select inside method subopts", () => { expect.assertions(1); const subopts = el.shadowRoot!.querySelector("analysis-method-subopts"); expect(subopts).toBeTruthy(); }); - it("THEN the trend method select has 'Same as display trend' selected by default", () => { + it("THEN the trend method inline-select has value='' (Same as display trend)", () => { expect.assertions(1); - const select = el.shadowRoot!.querySelector( - "analysis-method-subopts select" - ) as HTMLSelectElement; - expect(select.value).toBe(""); + const inlineSelect = el.shadowRoot!.querySelector( + "analysis-method-subopts inline-select" + ) as HTMLElement & { value: string }; + expect(inlineSelect.value).toBe(""); }); - it("THEN the trend window select is NOT shown when method is empty", () => { + it("THEN the Same as display trend option is NOT disabled", () => { expect.assertions(1); - const selects = el.shadowRoot!.querySelectorAll( - "analysis-method-subopts select" + const inlineSelect = el.shadowRoot!.querySelector( + "analysis-method-subopts inline-select" + ) as HTMLElement & { options: { value: string; disabled?: boolean }[] }; + const opt = inlineSelect.options.find((o) => o.value === ""); + expect(opt?.disabled).toBeFalsy(); + }); + + it("THEN the trend window inline-select is NOT shown when method is empty", () => { + expect.assertions(1); + const inlineSelects = el.shadowRoot!.querySelectorAll( + "analysis-method-subopts inline-select" ); - expect(selects.length).toBe(1); + expect(inlineSelects.length).toBe(1); }); }); - describe("WHEN the trend method select changes to 'ema'", () => { + describe("WHEN the trend method inline-select changes to 'ema'", () => { it("THEN dispatches dp-group-analysis-change with key=anomaly_trend_method and value=ema", () => { expect.assertions(3); const handler = vi.fn(); el.addEventListener("dp-group-analysis-change", handler); - const select = el.shadowRoot!.querySelector( - "analysis-method-subopts select" - ) as HTMLSelectElement; - select.value = "ema"; - select.dispatchEvent(new Event("change", { bubbles: true })); + const inlineSelect = el.shadowRoot!.querySelector( + "analysis-method-subopts inline-select" + )!; + inlineSelect.dispatchEvent( + new CustomEvent("dp-select-change", { + detail: { value: "ema" }, + bubbles: true, + composed: true, + }) + ); expect(handler).toHaveBeenCalledOnce(); expect(handler.mock.calls[0][0].detail.key).toBe( "anomaly_trend_method" @@ -212,30 +227,35 @@ describe("analysis-anomaly-group", () => { }); describe("WHEN rendered", () => { - it("THEN shows both trend method and trend window selects", () => { + it("THEN shows both trend method and trend window inline-selects", () => { expect.assertions(2); - const selects = el.shadowRoot!.querySelectorAll( - "analysis-method-subopts select" + const inlineSelects = el.shadowRoot!.querySelectorAll( + "analysis-method-subopts inline-select" ); - expect(selects.length).toBe(2); - // Verify the window select contains the expected option value - // (jsdom does not reliably reflect ?selected on non-first options) - const windowSelect = selects[1] as HTMLSelectElement; - expect(windowSelect.querySelector('option[value="6h"]')).not.toBeNull(); + expect(inlineSelects.length).toBe(2); + const windowSelect = inlineSelects[1] as HTMLElement & { + value: string; + }; + expect(windowSelect.value).toBe("6h"); }); }); - describe("WHEN the trend window select changes", () => { + describe("WHEN the trend window inline-select changes", () => { it("THEN dispatches dp-group-analysis-change with key=anomaly_trend_window", () => { expect.assertions(3); const handler = vi.fn(); el.addEventListener("dp-group-analysis-change", handler); - const selects = el.shadowRoot!.querySelectorAll( - "analysis-method-subopts select" + const inlineSelects = el.shadowRoot!.querySelectorAll( + "analysis-method-subopts inline-select" + ); + const windowSelect = inlineSelects[1]!; + windowSelect.dispatchEvent( + new CustomEvent("dp-select-change", { + detail: { value: "24h" }, + bubbles: true, + composed: true, + }) ); - const windowSelect = selects[1] as HTMLSelectElement; - windowSelect.value = "24h"; - windowSelect.dispatchEvent(new Event("change", { bubbles: true })); expect(handler).toHaveBeenCalledOnce(); expect(handler.mock.calls[0][0].detail.key).toBe( "anomaly_trend_window" @@ -259,12 +279,54 @@ describe("analysis-anomaly-group", () => { }); describe("WHEN rendered", () => { - it("THEN does NOT show a trend window select (linear trend has no window)", () => { + it("THEN does NOT show a trend window inline-select (linear trend has no window)", () => { + expect.assertions(1); + const inlineSelects = el.shadowRoot!.querySelectorAll( + "analysis-method-subopts inline-select" + ); + expect(inlineSelects.length).toBe(1); + }); + }); + }); + + describe("GIVEN trend_residual method is checked with anomaly_trend_method='' and show_trend_lines=false", () => { + beforeEach(async () => { + el = createElement({ + analysis: { + show_trend_lines: false, + show_anomalies: true, + anomaly_methods: ["trend_residual"], + anomaly_trend_method: "", + anomaly_trend_window: "24h", + }, + }); + await el.updateComplete; + }); + + describe("WHEN rendered", () => { + it("THEN the Same as display trend option is disabled", () => { + expect.assertions(1); + const inlineSelect = el.shadowRoot!.querySelector( + "analysis-method-subopts inline-select" + ) as HTMLElement & { options: { value: string; disabled?: boolean }[] }; + const opt = inlineSelect.options.find((o) => o.value === ""); + expect(opt?.disabled).toBe(true); + }); + + it("THEN the inline-select value falls back to the first real method (rolling_average)", () => { + expect.assertions(1); + const inlineSelect = el.shadowRoot!.querySelector( + "analysis-method-subopts inline-select" + ) as HTMLElement & { value: string }; + expect(inlineSelect.value).toBe("rolling_average"); + }); + + it("THEN the trend window inline-select IS shown because rolling_average needs a window", () => { expect.assertions(1); - const selects = el.shadowRoot!.querySelectorAll( - "analysis-method-subopts select" + const inlineSelects = el.shadowRoot!.querySelectorAll( + "analysis-method-subopts inline-select" ); - expect(selects.length).toBe(1); + expect(inlineSelects.length).toBe(2); }); }); }); diff --git a/custom_components/hass_datapoints/src/molecules/analysis-anomaly-group/analysis-anomaly-group.ts b/custom_components/hass_datapoints/src/molecules/analysis-anomaly-group/analysis-anomaly-group.ts index bf04fa95..6537b4ee 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-anomaly-group/analysis-anomaly-group.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-anomaly-group/analysis-anomaly-group.ts @@ -10,6 +10,7 @@ import type { } from "@/molecules/target-row/types"; import "@/atoms/analysis/analysis-group/analysis-group"; import "@/atoms/analysis/analysis-method-subopts/analysis-method-subopts"; +import "@/atoms/form/inline-select/inline-select"; import { ANALYSIS_TREND_METHOD_OPTIONS, ANALYSIS_TREND_WINDOW_OPTIONS, @@ -116,27 +117,6 @@ export class AnalysisAnomalyGroup extends LitElement { ); } - private _renderSelect( - key: string, - options: { value: string; label: string }[], - value: string - ): TemplateResult { - return html` - - `; - } - private _onGroupChange(e: CustomEvent) { this._emit("show_anomalies", e.detail.checked); } @@ -156,63 +136,56 @@ export class AnalysisAnomalyGroup extends LitElement { a: NormalizedAnalysis ): TemplateResult | typeof nothing { if (opt.value === "trend_residual") { - const anomalyMethod = a.anomaly_trend_method || ""; + const storedMethod = a.anomaly_trend_method || ""; + const trendLinesEnabled = a.show_trend_lines === true; + // When "Same as display trend" is disabled (trend lines off) and the + // stored value is "" (follow display), fall back to the first real method + // so the select is never stuck on a disabled option. + const effectiveMethod = + !trendLinesEnabled && storedMethod === "" + ? (ANALYSIS_TREND_METHOD_OPTIONS[0]?.value ?? "rolling_average") + : storedMethod; const methodOptions = [ - { value: "", label: msg("Same as display trend") }, + { + value: "", + label: msg("Same as display trend"), + disabled: !trendLinesEnabled, + }, ...this._localizedOptions(ANALYSIS_TREND_METHOD_OPTIONS), ]; const windowOptions = this._localizedOptions( ANALYSIS_TREND_WINDOW_OPTIONS ); const showWindow = ["rolling_average", "ema", "lowess"].includes( - anomalyMethod + effectiveMethod ); return html` ${showWindow ? html` ` : nothing} @@ -224,11 +197,17 @@ export class AnalysisAnomalyGroup extends LitElement { `; @@ -238,11 +217,17 @@ export class AnalysisAnomalyGroup extends LitElement { `; @@ -252,44 +237,42 @@ export class AnalysisAnomalyGroup extends LitElement { `; } if (opt.value === "comparison_window") { + const comparisonOptions = [ + { value: "", label: msg("— select window —") }, + ...this.comparisonWindows.map((win) => ({ + value: win.id, + label: win.label || win.id, + })), + ]; return html` `; @@ -302,12 +285,12 @@ export class AnalysisAnomalyGroup extends LitElement { const sensitivityOptions = this._localizedOptions( ANALYSIS_ANOMALY_SENSITIVITY_OPTIONS ); - const methodOptions = this._localizedOptions( - ANALYSIS_ANOMALY_METHOD_OPTIONS - ); const overlapOptions = this._localizedOptions( ANALYSIS_ANOMALY_OVERLAP_MODE_OPTIONS ); + const methodOptions = this._localizedOptions( + ANALYSIS_ANOMALY_METHOD_OPTIONS + ); return html` ${a.sample_interval && a.sample_interval !== "raw" ? html` @@ -404,11 +391,15 @@ export class AnalysisAnomalyGroup extends LitElement { ? html` ` : nothing} diff --git a/custom_components/hass_datapoints/src/molecules/analysis-anomaly-group/stories/analysis-anomaly-group.stories.ts b/custom_components/hass_datapoints/src/molecules/analysis-anomaly-group/stories/analysis-anomaly-group.stories.ts index 638af2cd..e780001e 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-anomaly-group/stories/analysis-anomaly-group.stories.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-anomaly-group/stories/analysis-anomaly-group.stories.ts @@ -124,6 +124,7 @@ export const TrendDeviationDefaultTrend = { render: () => html` ; + }; + expect(methodSelect.value).toBe(""); + expect( + methodSelect.options.some((o) => o.label === "Same as display trend") + ).toBe(true); }, }; @@ -165,17 +172,17 @@ export const TrendDeviationOverriddenWithEma = { const el = canvasElement.querySelector( "analysis-anomaly-group" ) as HTMLElement & { shadowRoot: ShadowRoot }; - // Both method and window selects should be visible - const selects = el.shadowRoot.querySelectorAll( - "analysis-method-subopts select" + // Both method and window inline-selects should be visible + const inlineSelects = el.shadowRoot.querySelectorAll( + "analysis-method-subopts inline-select" + ); + expect(inlineSelects.length).toBe(2); + expect((inlineSelects[0] as HTMLElement & { value: string }).value).toBe( + "ema" + ); + expect((inlineSelects[1] as HTMLElement & { value: string }).value).toBe( + "6h" ); - expect(selects.length).toBe(2); - expect( - (selects[0] as HTMLSelectElement).querySelector('option[value="ema"]') - ).toBeTruthy(); - expect( - (selects[1] as HTMLSelectElement).querySelector('option[value="6h"]') - ).toBeTruthy(); }, }; @@ -213,11 +220,11 @@ export const TrendDeviationOverriddenWithLinear = { const el = canvasElement.querySelector( "analysis-anomaly-group" ) as HTMLElement & { shadowRoot: ShadowRoot }; - // Linear trend has no window — only the method select should render - const selects = el.shadowRoot.querySelectorAll( - "analysis-method-subopts select" + // Linear trend has no window — only the method inline-select should render + const inlineSelects = el.shadowRoot.querySelectorAll( + "analysis-method-subopts inline-select" ); - expect(selects.length).toBe(1); + expect(inlineSelects.length).toBe(1); }, }; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-rate-group/analysis-rate-group.ts b/custom_components/hass_datapoints/src/molecules/analysis-rate-group/analysis-rate-group.ts index 8b87cd0d..88b2f286 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-rate-group/analysis-rate-group.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-rate-group/analysis-rate-group.ts @@ -1,12 +1,12 @@ import { LitElement, html } from "lit"; import { property } from "lit/decorators.js"; -import type { TemplateResult } from "lit"; import { localized, msg } from "@/lib/i18n/localize"; import { sharedStyles } from "../analysis-group-shared/analysis-group-shared.styles"; import { styles } from "./analysis-rate-group.styles"; import type { NormalizedAnalysis } from "@/molecules/target-row/types"; import "@/atoms/analysis/analysis-group/analysis-group"; +import "@/atoms/form/inline-select/inline-select"; export const ANALYSIS_RATE_WINDOW_OPTIONS = [ { value: "point_to_point", label: "Point to point" }, @@ -44,27 +44,6 @@ export class AnalysisRateGroup extends LitElement { })); } - private _renderSelect( - key: string, - options: { value: string; label: string }[], - value: string - ): TemplateResult { - return html` - - `; - } - private _onGroupChange(e: CustomEvent) { this._emit("show_rate_of_change", e.detail.checked); } @@ -91,11 +70,15 @@ export class AnalysisRateGroup extends LitElement { `; diff --git a/custom_components/hass_datapoints/src/molecules/analysis-sample-group/__tests__/analysis-sample-group.spec.ts b/custom_components/hass_datapoints/src/molecules/analysis-sample-group/__tests__/analysis-sample-group.spec.ts index 7b4a1324..50e37a0d 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-sample-group/__tests__/analysis-sample-group.spec.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-sample-group/__tests__/analysis-sample-group.spec.ts @@ -68,19 +68,18 @@ describe("analysis-sample-group", () => { }); describe("WHEN rendered", () => { - it("THEN interval select shows raw as selected value", () => { - const selects = el.shadowRoot!.querySelectorAll("select"); - const intervalSelect = selects[0] as HTMLSelectElement; - const selectedOpt = [...intervalSelect.options].find((o) => - o.hasAttribute("selected") - ); - expect(selectedOpt?.value).toBe("raw"); + it("THEN interval inline-select shows raw as value", () => { + const inlineSelects = el.shadowRoot!.querySelectorAll("inline-select"); + const intervalSelect = inlineSelects[0] as HTMLElement & { + value: string; + }; + expect(intervalSelect.value).toBe("raw"); }); - it("THEN aggregate select is not rendered", () => { - const selects = el.shadowRoot!.querySelectorAll("select"); - // Only one select (interval) should be present when disabled - expect(selects.length).toBe(1); + it("THEN aggregate inline-select is not rendered", () => { + const inlineSelects = el.shadowRoot!.querySelectorAll("inline-select"); + // Only one inline-select (interval) should be present when disabled + expect(inlineSelects.length).toBe(1); }); }); }); @@ -94,18 +93,17 @@ describe("analysis-sample-group", () => { }); describe("WHEN rendered", () => { - it("THEN interval select shows 1m as selected value", () => { - const selects = el.shadowRoot!.querySelectorAll("select"); - const intervalSelect = selects[0] as HTMLSelectElement; - const selectedOpt = [...intervalSelect.options].find((o) => - o.hasAttribute("selected") - ); - expect(selectedOpt?.value).toBe("1m"); + it("THEN interval inline-select shows 1m as value", () => { + const inlineSelects = el.shadowRoot!.querySelectorAll("inline-select"); + const intervalSelect = inlineSelects[0] as HTMLElement & { + value: string; + }; + expect(intervalSelect.value).toBe("1m"); }); - it("THEN aggregate select is rendered", () => { - const selects = el.shadowRoot!.querySelectorAll("select"); - expect(selects.length).toBe(2); + it("THEN aggregate inline-select is rendered", () => { + const inlineSelects = el.shadowRoot!.querySelectorAll("inline-select"); + expect(inlineSelects.length).toBe(2); }); }); }); @@ -119,16 +117,18 @@ describe("analysis-sample-group", () => { await el.updateComplete; }); - describe("WHEN interval select changes to 5m", () => { + describe("WHEN interval inline-select emits dp-select-change to 5m", () => { it("THEN fires dp-group-analysis-change with key=sample_interval and value=5m", () => { expect.assertions(3); const handler = vi.fn(); el.addEventListener("dp-group-analysis-change", handler); - const selects = el.shadowRoot!.querySelectorAll("select"); - const intervalSelect = selects[0] as HTMLSelectElement; - intervalSelect.value = "5m"; - intervalSelect.dispatchEvent( - new Event("change", { bubbles: true, composed: true }) + const inlineSelects = el.shadowRoot!.querySelectorAll("inline-select"); + inlineSelects[0].dispatchEvent( + new CustomEvent("dp-select-change", { + detail: { value: "5m" }, + bubbles: true, + composed: true, + }) ); expect(handler).toHaveBeenCalledOnce(); expect(handler.mock.calls[0][0].detail.key).toBe("sample_interval"); @@ -136,16 +136,18 @@ describe("analysis-sample-group", () => { }); }); - describe("WHEN aggregate select changes to max", () => { + describe("WHEN aggregate inline-select emits dp-select-change to max", () => { it("THEN fires dp-group-analysis-change with key=sample_aggregate and value=max", () => { expect.assertions(3); const handler = vi.fn(); el.addEventListener("dp-group-analysis-change", handler); - const selects = el.shadowRoot!.querySelectorAll("select"); - const aggregateSelect = selects[1] as HTMLSelectElement; - aggregateSelect.value = "max"; - aggregateSelect.dispatchEvent( - new Event("change", { bubbles: true, composed: true }) + const inlineSelects = el.shadowRoot!.querySelectorAll("inline-select"); + inlineSelects[1].dispatchEvent( + new CustomEvent("dp-select-change", { + detail: { value: "max" }, + bubbles: true, + composed: true, + }) ); expect(handler).toHaveBeenCalledOnce(); expect(handler.mock.calls[0][0].detail.key).toBe("sample_aggregate"); diff --git a/custom_components/hass_datapoints/src/molecules/analysis-sample-group/analysis-sample-group.ts b/custom_components/hass_datapoints/src/molecules/analysis-sample-group/analysis-sample-group.ts index 963ec225..6245c77e 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-sample-group/analysis-sample-group.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-sample-group/analysis-sample-group.ts @@ -1,12 +1,12 @@ import { LitElement, html, nothing } from "lit"; import { property } from "lit/decorators.js"; -import type { TemplateResult } from "lit"; import { localized, msg } from "@/lib/i18n/localize"; import { sharedStyles } from "../analysis-group-shared/analysis-group-shared.styles"; import { styles } from "./analysis-sample-group.styles"; import type { NormalizedAnalysis } from "@/molecules/target-row/types"; import "@/atoms/analysis/analysis-group/analysis-group"; +import "@/atoms/form/inline-select/inline-select"; export const SAMPLE_INTERVAL_OPTIONS = [ { value: "raw", label: "Raw (no sampling)" }, @@ -67,27 +67,6 @@ export class AnalysisSampleGroup extends LitElement { })); } - private _renderSelect( - key: string, - options: { value: string; label: string }[], - value: string - ): TemplateResult { - return html` - - `; - } - private _onGroupChange(e: CustomEvent) { // Toggling the group on/off switches between "raw" and "5m" as default. const enabled = e.detail.checked; @@ -106,21 +85,29 @@ export class AnalysisSampleGroup extends LitElement { > ${isEnabled ? html` ` : nothing} diff --git a/custom_components/hass_datapoints/src/molecules/analysis-threshold-group/analysis-threshold-group.ts b/custom_components/hass_datapoints/src/molecules/analysis-threshold-group/analysis-threshold-group.ts index d671c718..d4b037fa 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-threshold-group/analysis-threshold-group.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-threshold-group/analysis-threshold-group.ts @@ -1,12 +1,12 @@ import { LitElement, html, nothing } from "lit"; import { property } from "lit/decorators.js"; -import type { TemplateResult } from "lit"; import { localized, msg } from "@/lib/i18n/localize"; import { sharedStyles } from "../analysis-group-shared/analysis-group-shared.styles"; import { styles } from "./analysis-threshold-group.styles"; import type { NormalizedAnalysis } from "@/molecules/target-row/types"; import "@/atoms/analysis/analysis-group/analysis-group"; +import "@/atoms/form/inline-select/inline-select"; @localized() export class AnalysisThresholdGroup extends LitElement { @@ -39,27 +39,6 @@ export class AnalysisThresholdGroup extends LitElement { })); } - private _renderSelect( - key: string, - options: { value: string; label: string }[], - value: string - ): TemplateResult { - return html` - - `; - } - private _onGroupChange(e: CustomEvent) { this._emit("show_threshold_analysis", e.detail.checked); } @@ -108,14 +87,18 @@ export class AnalysisThresholdGroup extends LitElement { ? html` ` : nothing} diff --git a/custom_components/hass_datapoints/src/molecules/analysis-trend-group/analysis-trend-group.ts b/custom_components/hass_datapoints/src/molecules/analysis-trend-group/analysis-trend-group.ts index eae24f95..b4f73a37 100644 --- a/custom_components/hass_datapoints/src/molecules/analysis-trend-group/analysis-trend-group.ts +++ b/custom_components/hass_datapoints/src/molecules/analysis-trend-group/analysis-trend-group.ts @@ -1,12 +1,12 @@ import { LitElement, html, nothing } from "lit"; import { property } from "lit/decorators.js"; -import type { TemplateResult } from "lit"; import { localized, msg } from "@/lib/i18n/localize"; import { sharedStyles } from "../analysis-group-shared/analysis-group-shared.styles"; import { styles } from "./analysis-trend-group.styles"; import type { NormalizedAnalysis } from "@/molecules/target-row/types"; import "@/atoms/analysis/analysis-group/analysis-group"; +import "@/atoms/form/inline-select/inline-select"; export const ANALYSIS_TREND_METHOD_OPTIONS = [ { value: "rolling_average", label: "Rolling average" }, @@ -58,27 +58,6 @@ export class AnalysisTrendGroup extends LitElement { })); } - private _renderSelect( - key: string, - options: { value: string; label: string }[], - value: string - ): TemplateResult { - return html` - - `; - } - private _onGroupChange(e: CustomEvent) { this._emit("show_trend_lines", e.detail.checked); } @@ -106,21 +85,31 @@ export class AnalysisTrendGroup extends LitElement { ${["rolling_average", "ema", "lowess"].includes(a.trend_method) ? html` ` : nothing} diff --git a/custom_components/hass_datapoints/src/molecules/sidebar-options/sections/__tests__/sidebar-chart-display-section.spec.ts b/custom_components/hass_datapoints/src/molecules/sidebar-options/sections/__tests__/sidebar-chart-display-section.spec.ts index a4239833..165f362f 100644 --- a/custom_components/hass_datapoints/src/molecules/sidebar-options/sections/__tests__/sidebar-chart-display-section.spec.ts +++ b/custom_components/hass_datapoints/src/molecules/sidebar-options/sections/__tests__/sidebar-chart-display-section.spec.ts @@ -57,7 +57,9 @@ describe("sidebar-chart-display-section", () => { it("THEN renders the gap threshold select", () => { expect.assertions(1); - expect(el.shadowRoot!.querySelector(".gap-select")).not.toBeNull(); + expect( + el.shadowRoot!.querySelector(".is-subopt inline-select") + ).not.toBeNull(); }); it("THEN renders the y-axis radio-group", () => { @@ -74,11 +76,12 @@ describe("sidebar-chart-display-section", () => { }); describe("WHEN rendered", () => { - it("THEN the gap threshold select is not disabled", () => { + it("THEN the gap threshold inline-select is not disabled", () => { expect.assertions(1); - const select = - el.shadowRoot!.querySelector(".gap-select")!; - expect(select.disabled).toBe(false); + const inlineSelect = el.shadowRoot!.querySelector< + HTMLElement & { disabled: boolean } + >(".is-subopt inline-select")!; + expect(inlineSelect.disabled).toBe(false); }); it("THEN the subopt wrapper does not have is-disabled class", () => { @@ -96,11 +99,12 @@ describe("sidebar-chart-display-section", () => { }); describe("WHEN rendered", () => { - it("THEN the gap threshold select is disabled", () => { + it("THEN the gap threshold inline-select is disabled", () => { expect.assertions(1); - const select = - el.shadowRoot!.querySelector(".gap-select")!; - expect(select.disabled).toBe(true); + const inlineSelect = el.shadowRoot!.querySelector< + HTMLElement & { disabled: boolean } + >(".is-subopt inline-select")!; + expect(inlineSelect.disabled).toBe(true); }); it("THEN the subopt wrapper has is-disabled class", () => { @@ -169,15 +173,21 @@ describe("sidebar-chart-display-section", () => { }); }); - describe("WHEN the gap threshold select changes", () => { + describe("WHEN the gap threshold inline-select emits dp-select-change", () => { it("THEN dispatches dp-display-change with kind=data_gap_threshold and the new value", () => { expect.assertions(3); const handler = vi.fn(); el.addEventListener("dp-display-change", handler); - const select = - el.shadowRoot!.querySelector(".gap-select")!; - select.value = "6h"; - select.dispatchEvent(new Event("change")); + const inlineSelect = el.shadowRoot!.querySelector( + ".is-subopt inline-select" + )!; + inlineSelect.dispatchEvent( + new CustomEvent("dp-select-change", { + detail: { value: "6h" }, + bubbles: true, + composed: true, + }) + ); expect(handler).toHaveBeenCalledOnce(); expect(handler.mock.calls[0][0].detail.kind).toBe("data_gap_threshold"); expect(handler.mock.calls[0][0].detail.value).toBe("6h"); diff --git a/custom_components/hass_datapoints/src/molecules/sidebar-options/sections/sidebar-chart-display-section.ts b/custom_components/hass_datapoints/src/molecules/sidebar-options/sections/sidebar-chart-display-section.ts index 96bc49f1..29128797 100644 --- a/custom_components/hass_datapoints/src/molecules/sidebar-options/sections/sidebar-chart-display-section.ts +++ b/custom_components/hass_datapoints/src/molecules/sidebar-options/sections/sidebar-chart-display-section.ts @@ -5,6 +5,7 @@ import { localized, msg } from "@/lib/i18n/localize"; import { styles } from "./sidebar-chart-display-section.styles"; import "@/atoms/display/sidebar-options-section/sidebar-options-section"; import "@/atoms/form/checkbox-list/checkbox-list"; +import "@/atoms/form/inline-select/inline-select"; import "@/atoms/form/radio-group/radio-group"; export const DATA_GAP_THRESHOLD_OPTIONS = [ @@ -83,7 +84,7 @@ export class SidebarChartDisplaySection extends LitElement { private _onGapThresholdChange(e: Event) { this._emitDisplay( "data_gap_threshold", - (e.target as HTMLSelectElement).value + (e as CustomEvent<{ value: string }>).detail.value ); } @@ -126,22 +127,12 @@ export class SidebarChartDisplaySection extends LitElement { @dp-item-change=${this._onCheckboxChange} >
- + ${msg("Gap threshold")}
From 456f35695e5bdd79af499f9d4d231b58538dab0a Mon Sep 17 00:00:00 2001 From: Oliver Lillie Date: Fri, 10 Apr 2026 08:59:28 +0300 Subject: [PATCH 3/5] fix(layout): reset min-width on collapsed sidebar at tablet breakpoint At <=900px the .page-sidebar rule sets min-width: min(380px, 85vw) for the overlay state. The .page-sidebar.collapsed override set width: auto but never cleared min-width, so the collapsed 52px pill sidebar retained a ~380px minimum width, overflowed its grid column, and disappeared behind the chart stacking context. Adding min-width: 0 to the collapsed rule at that breakpoint lets the sidebar correctly constrain to its 52px grid column. doc: Removed statistics card reference from README and github templates --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 - .github/ISSUE_TEMPLATE/feature_request.yml | 1 - README.md | 14 -------------- .../hass_datapoints/hass-datapoints-cards.js | 3 ++- custom_components/hass_datapoints/manifest.json | 2 +- .../components/panel-shell/panel-shell.styles.ts | 1 + package.json | 2 +- 7 files changed, 5 insertions(+), 19 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 73c3bef2..162ae1aa 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -19,7 +19,6 @@ body: - History card - List card - Sensor card - - Statistics card - Dev tool card - Datapoints panel - Python backend / automations diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 4a3436e5..ac9cbc99 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -19,7 +19,6 @@ body: - History card - List card - Sensor card - - Statistics card - Dev tool card - Datapoints panel - Python backend / automations / services diff --git a/README.md b/README.md index 8b745859..d6715a7b 100644 --- a/README.md +++ b/README.md @@ -380,20 +380,6 @@ entities: hours_to_show: 72 ``` -### Statistics card - -Use this when the entity is best viewed through long-term statistics. - -```yaml -type: custom:hass-datapoints-statistics-card -title: Daily energy -entity: sensor.daily_energy -hours_to_show: 168 -period: hour -stat_types: - - mean -``` - ### Dev tool card Use this for seeding and cleanup workflows: diff --git a/custom_components/hass_datapoints/hass-datapoints-cards.js b/custom_components/hass_datapoints/hass-datapoints-cards.js index 5264370a..da42cc6b 100644 --- a/custom_components/hass_datapoints/hass-datapoints-cards.js +++ b/custom_components/hass_datapoints/hass-datapoints-cards.js @@ -25024,6 +25024,7 @@ .page-sidebar.collapsed { position: relative; width: auto; + min-width: 0; box-shadow: none; border-right: 1px solid color-mix( @@ -36719,7 +36720,7 @@ ].forEach((card) => { if (!registeredTypes.has(card.type)) window.customCards?.push(card); }); - console.groupCollapsed(`%c hass-datapoints %c v0.5.0 loaded `, "color:#fff;background:#03a9f4;font-weight:bold;padding:2px 6px;border-radius:3px 0 0 3px", "color:#03a9f4;background:#fff;font-weight:bold;padding:2px 6px;border:1px solid #03a9f4;border-radius:0 3px 3px 0", ...[]); + console.groupCollapsed(`%c hass-datapoints %c v0.5.1 loaded `, "color:#fff;background:#03a9f4;font-weight:bold;padding:2px 6px;border-radius:3px 0 0 3px", "color:#03a9f4;background:#fff;font-weight:bold;padding:2px 6px;border:1px solid #03a9f4;border-radius:0 3px 3px 0", ...[]); console.log("Enable debug logging by setting %cwindow.__HASS_DATAPOINTS_DEV__ = true", "color:#333;background:#eee;border:1px solid #777;padding:2px 6px;border-radius:5px; font-family: Courier"); console.groupEnd(); //#endregion diff --git a/custom_components/hass_datapoints/manifest.json b/custom_components/hass_datapoints/manifest.json index 3a85f9cf..8b2dc47b 100644 --- a/custom_components/hass_datapoints/manifest.json +++ b/custom_components/hass_datapoints/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "issue_tracker": "https://github.com/buggedcom/hass-datapoints/issues", "requirements": [], - "version": "0.5.0" + "version": "0.5.1" } diff --git a/custom_components/hass_datapoints/src/panels/datapoints/components/panel-shell/panel-shell.styles.ts b/custom_components/hass_datapoints/src/panels/datapoints/components/panel-shell/panel-shell.styles.ts index e9915e01..417be27c 100644 --- a/custom_components/hass_datapoints/src/panels/datapoints/components/panel-shell/panel-shell.styles.ts +++ b/custom_components/hass_datapoints/src/panels/datapoints/components/panel-shell/panel-shell.styles.ts @@ -357,6 +357,7 @@ export const styles = css` .page-sidebar.collapsed { position: relative; width: auto; + min-width: 0; box-shadow: none; border-right: 1px solid color-mix( diff --git a/package.json b/package.json index 33f58f3d..7ee67b4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hass-datapoints", - "version": "0.5.0", + "version": "0.5.1", "private": true, "description": "Build and development entrypoints for the hass-datapoints Home Assistant integration.", "packageManager": "pnpm@10.33.0", From 3eb595fc653a249cd4770b06a0e271c760eebbc7 Mon Sep 17 00:00:00 2001 From: Oliver Lillie Date: Fri, 10 Apr 2026 09:06:17 +0300 Subject: [PATCH 4/5] docs: split README into focused docs with overview linking to each MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the full README into four topic documents: - docs/cards.md — all six cards, visual editors, panel description, YAML configs - docs/recording-datapoints.md — record action, fields, automation patterns, examples - docs/history-and-analysis.md — chart features, all trend methods, anomaly detection - docs/development.md — setup, build, i18n deep dive, remote HA dev, WebSocket API, CI README becomes a concise overview with badges, screenshots, why/what summary, a docs table linking to each topic, translations note, roadmap, installation, setup, and a quick-start snippet. --- README.md | 997 ++--------------------------------- docs/cards.md | 117 ++++ docs/development.md | 270 ++++++++++ docs/history-and-analysis.md | 284 ++++++++++ docs/recording-datapoints.md | 242 +++++++++ 5 files changed, 953 insertions(+), 957 deletions(-) create mode 100644 docs/cards.md create mode 100644 docs/development.md create mode 100644 docs/history-and-analysis.md create mode 100644 docs/recording-datapoints.md diff --git a/README.md b/README.md index d6715a7b..cbf528a4 100644 --- a/README.md +++ b/README.md @@ -20,32 +20,9 @@ --- -## Table of Contents - -- [Overview](#overview) -- [Why Data Points is useful](#why-data-points-is-useful) -- [What Data Points provides](#what-data-points-provides) -- [Included UI](#included-ui) -- [Translations](#translations) -- [Roadmap](#roadmap) -- [Installation](#installation) -- [Setup](#setup) -- [Recording datapoints](#recording-datapoints) -- [How datapoints appear](#how-datapoints-appear) -- [Cards in practice](#cards-in-practice) -- [History chart and page features](#history-chart-and-page-features) -- [Trend analysis](#trend-analysis) -- [Anomaly detection](#anomaly-detection) -- [Using automations to create useful analytical datapoints](#using-automations-to-create-useful-analytical-datapoints) -- [WebSocket API](#websocket-api) -- [Development](#development) -- [Release and CI notes](#release-and-ci-notes) - ---- - ## Overview -Data Points is a Home Assistant integration for recording timestamped events and then using them as analytical context across charts, lists, and a dedicated history page. +Data Points is a Home Assistant integration for recording timestamped events and using them as analytical context across charts, lists, and a dedicated history page. It helps you answer questions like: @@ -61,9 +38,7 @@ The integration bundles its Lovelace cards and panel frontend automatically. No ## Why Data Points is useful -A plain chart tells you what changed. - -Data Points helps you understand why it changed by combining: +A plain chart tells you what changed. Data Points helps you understand **why** it changed by combining: - raw measurements - long-term statistics @@ -72,66 +47,31 @@ Data Points helps you understand why it changed by combining: - anomaly detection - historical date-window comparison -That makes it much easier to investigate: - -- heating behavior -- energy usage -- sensor faults -- maintenance effects -- occupancy-driven changes -- environmental anomalies -- operational regressions over time +That makes it much easier to investigate heating behavior, energy usage, sensor faults, maintenance effects, occupancy-driven changes, and operational regressions over time. --- ## What Data Points provides -Data Points lets you: - -- record custom datapoints from automations, scripts, dashboards, and Developer Tools -- attach datapoints to entities, devices, areas, or labels -- render datapoints directly on history, statistics, and sensor charts -- browse, search, edit, delete, and hide datapoints in a dedicated list card -- investigate entity history with target rows, target-specific options, and date-window comparisons -- create chart annotations directly from the chart while you are exploring data -- use backend-powered anomaly detection to highlight suspicious behavior in data series -- compare a current period against saved historical windows to find drift and regressions +- Record custom datapoints from automations, scripts, dashboards, and Developer Tools +- Attach datapoints to entities, devices, areas, or labels +- Render datapoints directly on history, statistics, and sensor charts +- Browse, search, edit, delete, and hide datapoints in a dedicated list card +- Investigate entity history with target rows, per-target analysis options, and date-window comparisons +- Create chart annotations directly from the chart while exploring data +- Backend-powered anomaly detection to highlight suspicious behavior +- Compare a current period against saved historical windows to find drift and regressions --- -## Included UI - -### Cards - -| Card | Purpose | -| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| `hass-datapoints-action-card` | Full recording form with message, annotation, icon, color, and related items. | -| `hass-datapoints-quick-card` | Lightweight card for quick operational notes. | -| `hass-datapoints-list-card` | Searchable, editable, hide/show capable datapoint list. | -| `hass-datapoints-dev-tool-card` | Generate useful development datapoints from HA history and clean up development datapoints. | -| `hass-datapoints-history-card` | Multi-series analysis chart with target rows, anomaly overlays, date windows, zoom, timeline slider, and chart-created datapoints. | -| `hass-datapoints-sensor-card` | Sensor-focused chart with inline datapoint markers. | - -All bundled cards now include Lovelace visual editors. The dev-tool editor is intentionally minimal because the card itself does not expose configurable options. - -The visual editor surface is complete across the bundled cards, so the typical setup flow is now: +## Documentation -1. Add the card in Lovelace. -2. Configure it entirely through the visual editor. -3. Drop into YAML only if you prefer hand-tuned configuration. - -### Dedicated history page / panel - -The integration also provides a full history page experience with: - -- target rows for each visible series -- per-target analysis controls -- collapsible options sidebar -- collapsed target rail with add-target and preferences controls -- date-window tab bar above the chart -- timeline slider with zoom highlight synchronization -- resizable chart/list split panes -- chart-created datapoints and hover-driven comparison preview +| Topic | Description | +| ---------------------------------------------------- | ------------------------------------------------------------------ | +| [Cards & UI](docs/cards.md) | All six cards, the history panel, and YAML configuration examples | +| [Recording Datapoints](docs/recording-datapoints.md) | The `record` action, fields, automation patterns, and examples | +| [History & Analysis](docs/history-and-analysis.md) | Chart features, trend methods, anomaly detection, and date windows | +| [Development](docs/development.md) | Setup, build, tests, i18n, remote HA dev, WebSocket API, and CI | --- @@ -149,62 +89,31 @@ Data Points ships with both Home Assistant integration translations and frontend - Portuguese 🤖 - Simplified Chinese 🤖 -### Translation quality - -English is the source language for the project. Finnish translations were written by a native speaker. - -The other bundled non-English locales are currently machine 🤖 translated. They are included so the UI is immediately usable in more Home Assistant setups without forcing everyone back to English, but they should still be treated as sensible defaults rather than fully reviewed product translations. +English is the source language. Finnish translations were written by a non-native speaker. All other bundled locales are machine-translated — they are usable defaults rather than fully reviewed translations. Improvements are very welcome. -If you spot awkward wording in any locale, translation improvements are very welcome. - -### Where translations live - -- Home Assistant integration/service strings live in: - `custom_components/hass_datapoints/translations/*.json` -- Frontend component/card/panel strings live next to each surface in local `i18n/` directories under `custom_components/hass_datapoints/src/**/i18n/` +- Integration/service strings: `custom_components/hass_datapoints/translations/*.json` +- Frontend component strings: `custom_components/hass_datapoints/src/**/i18n/` --- ## Roadmap -The current integration already covers recording, browsing, chart overlays, comparison windows, and anomaly analysis. The next planned areas build on those foundations rather than replacing them. - -### Planned next features - -- **Multiple saved views / save files** - Persist more than one named chart-and-panel state so users can keep reusable investigation setups for different entities, labels, areas, and analysis workflows. - -- **Automatic historical period matching** - Find similar historical periods automatically for selected targets so the panel can suggest or create date windows without requiring manual range hunting. +### Planned features -- **Chart-driven anomaly automation creation** - Turn chart analysis settings into Home Assistant automations so anomaly recognition can move from exploratory analysis into live monitoring. +- **Multiple saved views** — persist more than one named chart-and-panel state for reusable investigation setups. +- **Automatic historical period matching** — find similar historical periods automatically so the panel can suggest or create date windows. +- **Chart-driven anomaly automation creation** — turn chart analysis settings into Home Assistant automations for live monitoring. +- **Automatic anomaly-to-datapoint generation** — generate datapoints when configured anomaly conditions are met. +- **Backfilling tools** — create datapoints from recent history and long-term statistics after the fact. +- **Anomalies summary card** — dedicated card for highlighting current anomalies with deep-links into the history view. +- **Drop-in replacements for HA sensor and statistics cards** — equivalents that support datapoint overlays and richer contextual controls. -- **Automatic anomaly-to-datapoint generation** - Generate datapoints automatically when configured anomaly conditions are met, making anomalies first-class timeline events that can be reviewed, filtered, and linked back to entities. +### Themes -- **Backfilling tools for datapoint generation** - Add tools for creating datapoints from recent history and long-term statistics so important historical changes can be reconstructed after the fact. - -- **Anomalies summary card** - Provide a dedicated card for highlighting current or recent anomalies and deep-linking directly into the full datapoints history view for investigation. - -- **Drop-in replacements for HA sensor and statistics cards** - Continue polishing the lightweight chart-card story so users can replace common Home Assistant sensor/statistics cards with equivalents that support datapoint overlays, deep linking, and richer contextual controls by default. - -### Roadmap themes - -- **Operational memory** - Make saved investigative contexts and reconstructed historical events easier to preserve and reuse. - -- **Assisted comparison** - Reduce the manual work needed to find meaningful historical baselines for the current chart range. - -- **From analysis to action** - Let anomaly configuration graduate into automations, alerts, and auto-generated datapoints. - -- **Dashboard-native investigation** - Bring anomaly surfacing and datapoint-aware charting into smaller cards that work well in everyday dashboards. +- **Operational memory** — preserve and reuse saved investigative contexts. +- **Assisted comparison** — reduce the manual work needed to find meaningful historical baselines. +- **From analysis to action** — let anomaly configuration graduate into automations and auto-generated datapoints. +- **Dashboard-native investigation** — bring anomaly surfacing into smaller cards for everyday dashboards. --- @@ -234,48 +143,20 @@ Then restart Home Assistant. Add the integration from: -**Settings -> Devices & Services -> Add Integration -> Data Points** +**Settings → Devices & Services → Add Integration → Data Points** No YAML setup is required. --- -## Recording datapoints - -Use the `hass_datapoints.record` action from: - -- automations -- scripts -- dashboards -- Developer Tools -> Actions - -### Action fields - -| Field | Required | Description | -| ------------ | -------- | -------------------------------------------------------------------------------------- | -| `message` | Yes | Short label shown in lists, chips, chart tooltips, and the logbook. | -| `annotation` | No | Longer note or context. Defaults to the message when omitted. | -| `entity_ids` | No | Entities related to the datapoint. These are the most useful links for chart analysis. | -| `icon` | No | MDI icon used for the datapoint marker and related UI. | -| `color` | No | Marker color. Accepts a hex string or an RGB list. | +## Quick start -### Minimal example - -```yaml -action: hass_datapoints.record -data: - message: "Something happened" -``` - -### Full example +Record a datapoint from Developer Tools → Actions: ```yaml action: hass_datapoints.record data: message: "Heating schedule changed" - annotation: >- - Switched the house to the weekday daytime profile after school pickup. - This was done manually because the normal automation was paused. entity_ids: - climate.living_room - sensor.living_room_temperature @@ -283,812 +164,14 @@ data: color: "#ff5722" ``` -### RGB color example - -```yaml -action: hass_datapoints.record -data: - message: "Critical alert" - color: - - 255 - - 0 - - 0 -``` - ---- - -## How datapoints appear - -When a datapoint is recorded: - -1. It is stored in `.storage/hass_datapoints.events`. -2. It is emitted on the HA event bus as `hass_datapoints_event_recorded`. -3. It appears in the Home Assistant logbook. -4. It becomes available to the cards and history page. - -### Chart placement behavior - -On history and statistics charts, datapoints are placed: - -- on the related visible series when linked to a visible target -- on the chart baseline when linked to something not currently charted -- on a fallback position when they are global and have no explicit series link - -On the sensor card, datapoints are drawn directly on the sensor series. - ---- - -## Cards in practice - -> **Card editors are in development.** The cards are functional but still have certain layout and styling issues. Most functionality is fine but there are still some rough edges. - -### Action card - -Use this when you want a complete form for recording rich operational notes. - -```yaml -type: custom:hass-datapoints-action-card -title: Record event -``` - -### Quick card - -Use this for lightweight logging such as: - -- maintenance notes -- household observations -- manual interventions -- quick analytical breadcrumbs - -```yaml -type: custom:hass-datapoints-quick-card -title: Quick note -icon: mdi:bookmark -color: "#ff9800" -``` - -### List card - -Use this to browse, search, and hide datapoints. Admin users also see edit and delete buttons for each record. - -```yaml -type: custom:hass-datapoints-list-card -title: All datapoints -page_size: 20 -``` - -### Sensor card - -Use this for a single entity with inline datapoint markers. - -```yaml -type: custom:hass-datapoints-sensor-card -entity: sensor.living_room_temperature -hours_to_show: 24 -``` - -### History card - -Use this for multi-series exploration, date-window comparison, anomaly review, and chart-driven datapoint creation. +Then add the history card to a dashboard: ```yaml type: custom:hass-datapoints-history-card -title: Room temperatures +title: Living room entities: - sensor.living_room_temperature - - sensor.bedroom_temperature hours_to_show: 72 ``` -### Dev tool card - -Use this for seeding and cleanup workflows: - -- generate development datapoints from HA history -- create repeatable analytical markers for testing -- bulk delete development datapoints - ---- - -## History chart and page features - -The history surfaces are the most powerful part of the integration. - -### Target rows - -Each target row controls one visible chart series and supports: - -- visibility on or off -- color selection -- drag-to-reorder -- analysis expansion -- chart participation for datapoints and anomaly overlays - -When a target is hidden, it can be restored from the same row without losing its configuration. - -### Datapoint visibility modes - -The history chart can show: - -- datapoints linked to selected targets -- all datapoints -- no datapoints - -### Chart display options - -The history chart and page support: - -- tooltips -- emphasized hover guides -- correlated anomaly highlighting -- data-gap rendering -- shared vs split y-axis -- split series into rows -- hover mode: follow the series vs snap to datapoints - -### Date windows - -Date windows let you save named historical periods and then: - -- preview them from tabs above the chart -- compare the current period against a known baseline -- investigate seasonal or maintenance-driven changes -- support comparison-based anomaly detection - -Useful date windows include: - -- `Last week` -- `Before maintenance` -- `Heating baseline` -- `After insulation` -- `Last cold snap` - -### Zoom and timeline controls - -The history chart supports: - -- drag-to-zoom directly on the chart -- a timeline slider for the full available range -- zoom highlight synchronization between chart and timeline -- zoom-out control -- timeline drag handles for precise range control - -### Create datapoints from the chart - -The chart `+` action can create a datapoint at the inspected time. The dialog can prefill related items from the currently visible target rows so that the note is immediately linked to the right series. - ---- - -## Trend analysis - -Trend analysis overlays a computed curve on top of the raw sensor data in the chart. Each method answers a different question about your data, so choosing the right one depends on what you are investigating. - -Enable trend lines from the analysis panel for each target row. The trend window selector controls how much history the smoothing methods use to compute each point. - -### Linear trend - -A straight line fitted to all visible points using least-squares regression. - -**What it shows:** The overall direction of the data — whether the value is rising, falling, or flat across the whole window. - -**Use it when:** - -- You want to confirm a slow long-term drift (e.g. sensor calibration drift, gradual battery discharge) -- You are comparing the slope between two time windows to detect a change in behavior -- The data is noisy but you only care about the broad direction, not local variation - -**Avoid it when:** The signal is clearly non-linear (curved, periodic, or mean-reverting). A straight line will misrepresent those patterns. - -### Rolling average - -A sliding-window mean that replaces each point with the average of all points within the preceding time window. - -**What it shows:** The local level of the data, smoothed to remove high-frequency noise. The resulting curve lags the true signal — the tighter the window, the less lag; the wider the window, the smoother the result. - -**Use it when:** - -- You want to see the general level of a noisy sensor (temperature, humidity, energy) -- You are trying to compare two smoothed series to spot divergence -- The window length roughly matches the natural timescale of the change you are investigating (e.g. a 1h window for heating dynamics, a 24h window for daily patterns) - -**Avoid it when:** You need responsiveness to recent changes. Because the window weights all points equally, a sharp step up will only be fully reflected in the average after the window has fully moved past the step. - -### Exponential moving average (EMA) - -A weighted average where recent points contribute more than older ones. The `alpha` parameter controls responsiveness: values near 1 track the signal closely with little smoothing; values near 0 produce a heavily smoothed curve that responds slowly. - -The window selector maps to alpha values tuned for typical HA data cadences: -`30m → 0.97`, `1h → 0.92`, `6h → 0.75`, `24h → 0.50`, `7d → 0.25`, `14d → 0.15`, `21d → 0.10`, `28d → 0.07`. - -**What it shows:** The local level of the data, like rolling average, but with less lag. A step change will begin appearing in the EMA immediately; a rolling average of equivalent width will not reflect it until the window moves past the old values. - -**Use it when:** - -- You want smoothing similar to rolling average but with faster response to real changes -- You are investigating whether a recent change represents a new pattern or a transient spike -- The data has an irregular update cadence (EMA is computed point-to-point, so it does not require evenly spaced samples) - -**Avoid it when:** You need a precise, interpretable window like "the average over the last hour". EMA is adaptive and does not have a hard time boundary, so its output at any point blends all past data with exponentially decaying weight. - -### Polynomial trend (quadratic) - -A quadratic (degree-2) curve fitted globally to all visible points using least-squares regression. - -**What it shows:** The overall shape of the data — whether it is arcing upward, bending back down, or following a U or inverted-U curve. A linear trend can only say "up" or "down"; the polynomial trend can also say "accelerating" or "decelerating". - -**Use it when:** - -- You suspect a non-linear drift — for example a battery whose discharge rate changes over time, or a room that heats quickly then tapers off -- You want to see whether a recovery is complete or still in progress -- Seasonal effects within the window create a visible curve - -**Avoid it when:** - -- The data is periodic or highly variable — the polynomial fit covers the entire window and will be distorted by extreme values at either end -- You only need a directional signal; use linear trend instead as it is easier to interpret - -### LOWESS (Locally Weighted Scatterplot Smoothing) - -A non-parametric smoother that computes a weighted local linear regression at each point, using only nearby data within a bandwidth window. The tricubic weight function gives maximum influence to very close neighbors and smoothly reduces weight toward the bandwidth boundary. - -**What it shows:** The underlying shape of the data without assuming any global functional form. LOWESS can follow curves, plateaus, transitions, and reversals that would require a high-degree polynomial to approximate analytically. - -**Use it when:** - -- The signal has a complex or unknown shape — for example temperature that rises, plateaus during occupancy, then drops overnight -- You want a visually clean, intuitive curve that roughly follows the "center" of the data at every local region -- You are investigating whether a specific period deviates from the local pattern (compare the LOWESS curve to the raw signal) -- The window selector controls locality: a 1h bandwidth tracks rapid changes; a 24h bandwidth gives a broad global shape - -**Avoid it when:** - -- The series is very short (fewer than 5–10 points) — local regression needs enough neighbors to be meaningful -- You need a mathematically interpretable output; LOWESS is empirical and does not produce slope or intercept values - -### Rate of change - -Computes the per-hour rate of change between each point and a lookback comparison. In point-to-point mode, each point is compared to the immediately preceding one. In windowed mode (e.g. 1h), each point is compared to the nearest point that is at least one window-width earlier. - -**What it shows:** How fast the value is changing, expressed in units per hour. A flat original series produces a rate near zero. A sharp spike appears as a large positive or negative value. - -**Use it when:** - -- You want to confirm whether a temperature is rising or falling fast enough to be significant -- You are investigating an abrupt event — an open window, a power surge, a pump starting — that shows up as a spike in rate of change -- You are comparing rate-of-change between two periods to detect whether the dynamics have changed (e.g. heating slower than it used to be) -- Point-to-point mode is useful for fine-grained detection; windowed mode reduces noise from rapid oscillations - -**Avoid it when:** The sensor updates irregularly or has long gaps — rate of change over a large gap can produce misleadingly large or small values. Use a windowed mode with a window wider than typical gaps to reduce this. - ---- - -## Anomaly detection - -Anomaly detection is designed to help you spot suspicious patterns in time series without having to inspect every line manually. - -The anomaly results are provided by the backend and rendered in the frontend chart and panel UI. - -### What anomaly detection helps you find - -Use anomaly detection to spot: - -- stuck or flat-lined sensors -- sudden spikes and drops -- values drifting away from their normal trend -- unusual rate-of-change behavior -- differences between the current period and a known-good date window -- suspicious clusters of events or repeated abnormal periods - -### Available anomaly methods - -Depending on the target and configuration, anomaly analysis supports methods such as: - -- trend deviation -- rate of change -- IQR / statistical outlier detection -- rolling Z-score -- persistence / flat-line detection -- comparison-window anomalies - -### How to use anomaly detection - -1. Open the history page or history card. -2. Add one or more target entities. -3. Expand a target row's analysis options. -4. Enable **Show anomalies** for that target. -5. Choose one or more anomaly methods. -6. Tune sensitivity and method-specific windows. -7. Hover highlighted regions and compare them with datapoints and related context. - -### A practical anomaly workflow - -For a single series: - -1. Start with one visible target. -2. Enable `trend deviation` or `rolling Z-score` first. -3. Add `persistence` if you suspect the sensor stopped updating. -4. Use `rate of change` for abrupt transitions such as open windows or sudden heating. -5. Add a date window from a known-good period if you want to compare behavior against a baseline. -6. If multiple targets are visible, switch to split rows to reduce visual density. - -### Example use cases - -#### Detect a stuck sensor - -Use: - -- persistence -- medium or high sensitivity -- a window that matches the expected update cadence - -This is useful for: - -- room temperature sensors -- humidity sensors -- power sensors that silently stop updating - -#### Investigate abnormal heating behavior - -Use: - -- comparison window -- a date window from a normal day or week -- threshold and trend analysis alongside anomalies - -This is useful for: - -- rooms that heat too slowly -- delayed radiator response -- unexplained overnight heating - -#### Find unusual environmental spikes - -Use: - -- rolling Z-score -- rate of change - -This is useful for: - -- windows opening -- hot water usage spikes -- unexpected ventilation events -- sensor glitches - -### Reading anomaly output effectively - -Anomaly markers are most useful when paired with datapoints that explain likely causes. - -For example: - -- `Boiler serviced` -- `Window left open` -- `Heating mode changed` -- `Fan speed manually increased` -- `Dehumidifier moved` - -The ideal workflow is: - -1. let anomaly detection tell you where to look -2. use datapoints to record likely causes -3. compare against date windows to see whether the anomaly is new or expected - -That turns anomalies from a visual warning into an actionable investigative tool. - ---- - -## Using automations to create useful analytical datapoints - -Datapoints are most valuable when they explain future chart behavior. - -The best automations record changes in state, intent, or operating mode rather than just mirroring every raw metric update. - -### Good datapoints to automate - -Automate datapoints for events like: - -- heating mode changes -- occupancy transitions -- windows or doors open for long periods -- maintenance actions -- manual overrides -- pump, fan, HVAC, or schedule changes -- tariff or price mode changes -- threshold crossings that explain later anomalies -- weather-driven operational mode changes - -### Good analytical habits - -Prefer datapoints that answer: - -- what changed -- why it changed -- what system it affected -- whether it was manual or automatic -- what later chart behavior it might explain - -### Recommended message style - -Keep `message` short and scan-friendly: - -- `Heating switched to away mode` -- `Bedroom window opened` -- `Filter replaced` -- `Boiler restarted` - -Put the detailed context in `annotation`: - -- who triggered it -- why it happened -- expected duration -- what behavior to compare against later - -### Best practice for related items - -When an automation records a datapoint for analysis: - -- link it to the exact entities you expect to inspect later -- include the primary measured series plus the controlling entity when possible -- use a clear icon and color to make patterns easy to spot in charts and lists - -For example, if you are investigating heating behavior, link the datapoint to: - -- the climate entity -- the relevant room temperature sensor -- any related window or valve entity - -### Automation examples - -#### Record when a window stays open - -```yaml -automation: - - alias: Record long window opening - triggers: - - trigger: state - entity_id: binary_sensor.bedroom_window - to: "on" - for: "00:15:00" - actions: - - action: hass_datapoints.record - data: - message: "Bedroom window open > 15 min" - annotation: "May explain a temperature drop or radiator compensation." - entity_ids: - - binary_sensor.bedroom_window - - sensor.bedroom_temperature - icon: mdi:window-open-variant - color: "#f59e0b" -``` - -#### Record heating profile changes - -```yaml -automation: - - alias: Record heating schedule change - triggers: - - trigger: state - entity_id: input_select.heating_mode - actions: - - action: hass_datapoints.record - data: - message: "Heating mode changed to {{ trigger.to_state.state }}" - annotation: "Captured automatically to explain later temperature and energy trends." - entity_ids: - - climate.living_room - - sensor.living_room_temperature - - sensor.daily_energy - icon: mdi:radiator - color: "#ef4444" -``` - -#### Record maintenance - -```yaml -automation: - - alias: Record HVAC maintenance completion - triggers: - - trigger: event - event_type: hvac_filter_replaced - actions: - - action: hass_datapoints.record - data: - message: "HVAC filter replaced" - annotation: "Use this to compare airflow, temperature stability, and energy use before and after service." - entity_ids: - - climate.downstairs - - sensor.daily_energy - icon: mdi:wrench - color: "#10b981" -``` - -#### Record threshold crossings that explain anomalies later - -```yaml -automation: - - alias: Record high humidity period - triggers: - - trigger: numeric_state - entity_id: sensor.bathroom_humidity - above: 75 - actions: - - action: hass_datapoints.record - data: - message: "Bathroom humidity above 75%" - annotation: "Useful for comparing ventilation response and recovery time." - entity_ids: - - sensor.bathroom_humidity - - fan.bathroom_extract - icon: mdi:water-percent - color: "#3b82f6" -``` - -### Automation patterns that pair well with anomaly detection - -The most useful combination is: - -1. automate datapoints for state changes or interventions -2. use anomaly detection to find unusual sensor behavior -3. compare the anomaly regions against your recorded datapoints -4. save date windows around known good and bad periods for future comparison - -That gives you both the signal and the likely explanation. - ---- - -## WebSocket API - -The frontend uses the following WebSocket commands: - -| Type | Purpose | -| ------------------------------- | ------------------------------------ | -| `hass_datapoints/events` | Fetch recorded datapoints/events | -| `hass_datapoints/events/update` | Update an existing datapoint (admin) | -| `hass_datapoints/events/delete` | Delete a datapoint (admin) | -| `hass_datapoints/history` | Fetch history/downsampled chart data | -| `hass_datapoints/anomalies` | Fetch backend anomaly results | - -Events are stored in: - -```text -.storage/hass_datapoints.events -``` - ---- - -## Development - -### Setup - -```bash -git clone https://github.com/buggedcom/HASS-Data-Points.git -cd HASS-Data-Points -corepack enable -pnpm install -pnpm hooks:install -``` - -### Build - -```bash -pnpm build -``` - -### Tests - -```bash -pnpm test -pnpm vitest run -``` - -### Storybook - -The published Storybook for the `main` branch is available at: -**** - -To run Storybook locally or build it: - -```bash -pnpm sb -pnpm sb:build -``` - -### Frontend source layout - -The frontend lives in: - -```text -custom_components/hass_datapoints/src/ -``` - -Current top-level structure: - -```text -src/ -├── atoms/ -├── cards/ -├── charts/ -├── components/ -├── lib/ -├── molecules/ -├── panels/ -└── test-support/ -``` - -Highlights: - -- `atoms/` - reusable UI primitives -- `molecules/` - composed reusable UI units -- `cards/` - feature cards such as action, quick, list, dev tool, history, statistics, and sensor -- `charts/` - shared chart infrastructure such as base classes, DOM helpers, and interaction utilities -- `panels/datapoints/` - the dedicated datapoints history page -- `lib/` - shared chart logic, HA helpers, domain logic, workers, i18n, and utilities - -### Internationalisation (i18n) - -The frontend uses [`@lit/localize`](https://lit.dev/docs/localization/overview/) in **runtime mode**. - -**Source locale:** English (`en`) — all user-visible strings in the source code are written in English. -**Supported translated locales:** German (`de`), Spanish (`es`), Finnish (`fi`), French (`fr`), Portuguese (`pt`), Simplified Chinese (`zh-Hans`). - -The canonical list of supported locales is maintained in a single file: - -``` -src/lib/i18n/supported-locales.json -``` - -Both `localize.ts` and the translation coverage tests read from this file, so adding a locale there is the only registration step needed. - -#### How the runtime works - -`src/lib/i18n/localize.ts` calls `configureLocalization` once at startup. The user's Home Assistant UI language is read from `hass.locale.language` (falling back to `hass.language`) and normalised to the nearest supported locale — for example `fr-CA` resolves to `fr`. The matching locale chunk is then lazy-loaded and components decorated with `@localized()` re-render automatically. - -Every user-visible string is wrapped with `msg()`: - -```typescript -import { msg, localized } from "@/lib/i18n/localize"; - -@localized() -class MyElement extends LitElement { - render() { - return html`${msg("Save page state")}`; - } -} -``` - -Interpolated strings that contain runtime values cannot be passed directly to `msg()`. Use numbered placeholders and a `t()` helper instead: - -```typescript -function t(key: string, ...values: string[]): string { - let s = msg(key, { id: key }); - values.forEach((v, i) => { - s = s.replace(new RegExp(`\\{${i}\\}`, "g"), v); - }); - return s; -} - -// Usage -t("Anomaly at {0} with value {1}", formattedTime, formattedValue); -``` - -#### Co-located translation files - -Translations are **not** in a single central locale file. Each component that has translatable strings owns an `i18n/` subdirectory containing one file per locale: - -```text -src/molecules/target-row/ -├── target-row.ts -└── i18n/ - ├── de.ts - ├── es.ts - ├── fi.ts - ├── fr.ts - ├── pt.ts - └── zh-hans.ts -``` - -Every locale file exports a `translations` object typed as `ComponentTranslations`: - -```typescript -import type { ComponentTranslations } from "@/lib/i18n/types"; - -export const translations: ComponentTranslations = { - "Show anomalies": "Näytä anomaliat", - Sensitivity: "Herkkyys", -}; -``` - -#### Auto-discovery at build time - -Each `src/lib/i18n/locales/.ts` file uses `import.meta.glob` to discover and merge every matching `i18n/.ts` file across the entire source tree: - -```typescript -// src/lib/i18n/locales/fi.ts -const modules = import.meta.glob<{ translations: Record }>( - "../../../**/i18n/fi.ts", - { eager: true } -); - -const merged: Record = {}; -for (const mod of Object.values(modules)) { - Object.assign(merged, mod.translations); -} - -export const templates = merged satisfies LocaleModule["templates"]; -``` - -No manual registration is needed. Creating an `i18n/fi.ts` file anywhere under `src/` is sufficient for its strings to be included in the built locale chunk. - -Duplicate keys are resolved by last-writer-wins (`Object.assign`). This is safe because any shared key (e.g. `"Auto"`) carries the same translated value regardless of which component declares it. - -#### Translation coverage tests - -`src/lib/i18n/__tests__/translations-coverage.spec.ts` enforces three rules across every component `i18n/` directory automatically: - -1. **Locale presence** — every supported locale file must exist. -2. **Key completeness** — every locale file must contain exactly the same set of keys (no missing translations, no stale extras left over after a key is renamed or removed). -3. **Value completeness** — every translated value must differ from its English source key, unless the string is listed in `UNTRANSLATED_WHITELIST` (reserved for technical terms, proper nouns, and abbreviations that are genuinely the same across languages). - -Run the tests with `pnpm test` to catch any gaps before committing. - -#### Adding translations to a new component - -1. Create an `i18n/` subdirectory next to the component source file. -2. Add a `.ts` file for **each** locale listed in `supported-locales.json`. -3. Each file exports a `translations` object with the same keys (English source string → translated value). -4. Wrap every user-visible string in the component with `msg()` and add `@localized()` to the class. -5. Run `pnpm test` — the coverage tests will fail immediately if any locale file is missing or has mismatched keys. - -#### Adding a new locale - -1. Add the locale code to `src/lib/i18n/supported-locales.json`. -2. Create `src/lib/i18n/locales/.ts` with the `import.meta.glob` pattern above, substituting the new locale code. -3. Add a `case` for the new locale in the `loadLocale` switch in `localize.ts`. -4. Add a normalisation branch in `normalizeLocale` in `localize.ts` to map BCP 47 variants (e.g. `pt-BR`) to the canonical code. -5. Add an `i18n/.ts` file to every component directory that already has an `i18n/` subdirectory — the coverage tests will list exactly which ones are missing. - -#### Non-English translations - -The English translations were written by a native speaker. All other bundled locales are currently **machine-translated** — they are included so the UI is usable out of the box in more Home Assistant setups, but they should be treated as reasonable defaults rather than fully reviewed translations. - -Translation improvements for any locale are welcome — edit the relevant `i18n/.ts` and json files and open a pull request. - ---- - -### Remote Home Assistant development - -If you use a remote HA instance for development: - -1. Copy the example env file: - -```bash -cp .env.dev.example .env.dev -``` - -2. Fill in the remote host details. -3. Sync manually: - -```bash -pnpm dev:sync -``` - -4. Or run watch mode: - -```bash -pnpm dev:watch -``` - ---- - -## Release and CI notes - -- CI checks build correctness and integration metadata. -- The built frontend bundle is committed as `custom_components/hass_datapoints/hass-datapoints-cards.js`. -- Pre-commit hooks format staged files, lint package.json versions, validate frontend types, and rebuild the frontend when needed. -- Pre-push hooks run tests, lint checks, package.json version linting, and frontend type validation before pushing. +See [Recording Datapoints](docs/recording-datapoints.md) and [Cards & UI](docs/cards.md) for the full reference. diff --git a/docs/cards.md b/docs/cards.md new file mode 100644 index 00000000..42fe1bc1 --- /dev/null +++ b/docs/cards.md @@ -0,0 +1,117 @@ +# Cards & UI + +Data Points ships six Lovelace cards and a dedicated history panel. All cards include visual editors — no YAML is required for typical setups. + +--- + +## Available cards + +| Card | Purpose | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `hass-datapoints-action-card` | Full recording form with message, annotation, icon, color, and related items. | +| `hass-datapoints-quick-card` | Lightweight card for quick operational notes. | +| `hass-datapoints-list-card` | Searchable, editable, hide/show capable datapoint list. | +| `hass-datapoints-dev-tool-card` | Generate useful development datapoints from HA history and clean up development datapoints. | +| `hass-datapoints-history-card` | Multi-series analysis chart with target rows, anomaly overlays, date windows, zoom, timeline slider, and chart-created datapoints. | +| `hass-datapoints-sensor-card` | Sensor-focused chart with inline datapoint markers. | + +### Visual editors + +All bundled cards include Lovelace visual editors. The dev-tool editor is intentionally minimal because the card itself does not expose configurable options. + +The typical setup flow is: + +1. Add the card in Lovelace. +2. Configure it entirely through the visual editor. +3. Drop into YAML only if you prefer hand-tuned configuration. + +--- + +## Card configurations + +### Action card + +Use this when you want a complete form for recording rich operational notes. + +```yaml +type: custom:hass-datapoints-action-card +title: Record event +``` + +### Quick card + +Use this for lightweight logging such as: + +- maintenance notes +- household observations +- manual interventions +- quick analytical breadcrumbs + +```yaml +type: custom:hass-datapoints-quick-card +title: Quick note +icon: mdi:bookmark +color: "#ff9800" +``` + +### List card + +Use this to browse, search, and hide datapoints. Admin users also see edit and delete buttons for each record. + +```yaml +type: custom:hass-datapoints-list-card +title: All datapoints +page_size: 20 +``` + +### Sensor card + +Use this for a single entity with inline datapoint markers. + +```yaml +type: custom:hass-datapoints-sensor-card +entity: sensor.living_room_temperature +hours_to_show: 24 +``` + +### History card + +Use this for multi-series exploration, date-window comparison, anomaly review, and chart-driven datapoint creation. + +```yaml +type: custom:hass-datapoints-history-card +title: Room temperatures +entities: + - sensor.living_room_temperature + - sensor.bedroom_temperature +hours_to_show: 72 +``` + +### Dev tool card + +Use this for seeding and cleanup workflows: + +- generate development datapoints from HA history +- create repeatable analytical markers for testing +- bulk delete development datapoints + +```yaml +type: custom:hass-datapoints-dev-tool-card +``` + +--- + +## Dedicated history panel + +The integration also provides a full history page experience (accessible from the sidebar) with: + +- target rows for each visible series +- per-target analysis controls +- collapsible options sidebar +- collapsed target rail with add-target and preferences controls +- date-window tab bar above the chart +- timeline slider with zoom highlight synchronization +- resizable chart/list split panes +- chart-created datapoints and hover-driven comparison preview + +For details on analysis features available in the history card and panel, see [History & Analysis](./history-and-analysis.md). diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 00000000..839f35cc --- /dev/null +++ b/docs/development.md @@ -0,0 +1,270 @@ +# Development + +This document covers local development setup, the frontend source layout, internationalisation, remote HA development, the WebSocket API, and CI/release notes. + +--- + +## Setup + +```bash +git clone https://github.com/buggedcom/HASS-Data-Points.git +cd HASS-Data-Points +corepack enable +pnpm install +pnpm hooks:install +``` + +--- + +## Build + +```bash +pnpm build +``` + +The built frontend bundle is committed to the repo as: + +```text +custom_components/hass_datapoints/hass-datapoints-cards.js +``` + +--- + +## Tests + +```bash +pnpm test +pnpm vitest run +``` + +--- + +## Storybook + +The published Storybook for the `main` branch is available at: +**** + +To run Storybook locally or build it: + +```bash +pnpm sb +pnpm sb:build +``` + +--- + +## Frontend source layout + +The frontend lives in: + +```text +custom_components/hass_datapoints/src/ +``` + +Current top-level structure: + +```text +src/ +├── atoms/ +├── cards/ +├── charts/ +├── components/ +├── lib/ +├── molecules/ +├── panels/ +└── test-support/ +``` + +| Directory | Contents | +| -------------------- | ------------------------------------------------------------------------------ | +| `atoms/` | Reusable UI primitives | +| `molecules/` | Composed reusable UI units | +| `cards/` | Feature cards — action, quick, list, dev tool, history, statistics, sensor | +| `charts/` | Shared chart infrastructure — base classes, DOM helpers, interaction utilities | +| `panels/datapoints/` | The dedicated Datapoints history page | +| `lib/` | Shared chart logic, HA helpers, domain logic, workers, i18n, and utilities | + +--- + +## Internationalisation (i18n) + +The frontend uses [`@lit/localize`](https://lit.dev/docs/localization/overview/) in **runtime mode**. + +**Source locale:** English (`en`) — all user-visible strings in the source code are written in English. +**Supported translated locales:** German (`de`), Spanish (`es`), Finnish (`fi`), French (`fr`), Portuguese (`pt`), Simplified Chinese (`zh-Hans`). + +The canonical list of supported locales is maintained in a single file: + +``` +src/lib/i18n/supported-locales.json +``` + +Both `localize.ts` and the translation coverage tests read from this file, so adding a locale there is the only registration step needed. + +### How the runtime works + +`src/lib/i18n/localize.ts` calls `configureLocalization` once at startup. The user's Home Assistant UI language is read from `hass.locale.language` (falling back to `hass.language`) and normalised to the nearest supported locale — for example `fr-CA` resolves to `fr`. The matching locale chunk is then lazy-loaded and components decorated with `@localized()` re-render automatically. + +Every user-visible string is wrapped with `msg()`: + +```typescript +import { msg, localized } from "@/lib/i18n/localize"; + +@localized() +class MyElement extends LitElement { + render() { + return html`${msg("Save page state")}`; + } +} +``` + +Interpolated strings that contain runtime values cannot be passed directly to `msg()`. Use numbered placeholders and a `t()` helper instead: + +```typescript +function t(key: string, ...values: string[]): string { + let s = msg(key, { id: key }); + values.forEach((v, i) => { + s = s.replace(new RegExp(`\\{${i}\\}`, "g"), v); + }); + return s; +} + +// Usage +t("Anomaly at {0} with value {1}", formattedTime, formattedValue); +``` + +### Co-located translation files + +Translations are **not** in a single central locale file. Each component that has translatable strings owns an `i18n/` subdirectory containing one file per locale: + +```text +src/molecules/target-row/ +├── target-row.ts +└── i18n/ + ├── de.ts + ├── es.ts + ├── fi.ts + ├── fr.ts + ├── pt.ts + └── zh-hans.ts +``` + +Every locale file exports a `translations` object typed as `ComponentTranslations`: + +```typescript +import type { ComponentTranslations } from "@/lib/i18n/types"; + +export const translations: ComponentTranslations = { + "Show anomalies": "Näytä anomaliat", + Sensitivity: "Herkkyys", +}; +``` + +### Auto-discovery at build time + +Each `src/lib/i18n/locales/.ts` file uses `import.meta.glob` to discover and merge every matching `i18n/.ts` file across the entire source tree: + +```typescript +// src/lib/i18n/locales/fi.ts +const modules = import.meta.glob<{ translations: Record }>( + "../../../**/i18n/fi.ts", + { eager: true } +); + +const merged: Record = {}; +for (const mod of Object.values(modules)) { + Object.assign(merged, mod.translations); +} + +export const templates = merged satisfies LocaleModule["templates"]; +``` + +No manual registration is needed. Creating an `i18n/fi.ts` file anywhere under `src/` is sufficient for its strings to be included in the built locale chunk. + +Duplicate keys are resolved by last-writer-wins (`Object.assign`). This is safe because any shared key (e.g. `"Auto"`) carries the same translated value regardless of which component declares it. + +### Translation coverage tests + +`src/lib/i18n/__tests__/translations-coverage.spec.ts` enforces three rules across every component `i18n/` directory automatically: + +1. **Locale presence** — every supported locale file must exist. +2. **Key completeness** — every locale file must contain exactly the same set of keys (no missing translations, no stale extras left over after a key is renamed or removed). +3. **Value completeness** — every translated value must differ from its English source key, unless the string is listed in `UNTRANSLATED_WHITELIST` (reserved for technical terms, proper nouns, and abbreviations that are genuinely the same across languages). + +Run the tests with `pnpm test` to catch any gaps before committing. + +### Adding translations to a new component + +1. Create an `i18n/` subdirectory next to the component source file. +2. Add a `.ts` file for **each** locale listed in `supported-locales.json`. +3. Each file exports a `translations` object with the same keys (English source string → translated value). +4. Wrap every user-visible string in the component with `msg()` and add `@localized()` to the class. +5. Run `pnpm test` — the coverage tests will fail immediately if any locale file is missing or has mismatched keys. + +### Adding a new locale + +1. Add the locale code to `src/lib/i18n/supported-locales.json`. +2. Create `src/lib/i18n/locales/.ts` with the `import.meta.glob` pattern above, substituting the new locale code. +3. Add a `case` for the new locale in the `loadLocale` switch in `localize.ts`. +4. Add a normalisation branch in `normalizeLocale` in `localize.ts` to map BCP 47 variants (e.g. `pt-BR`) to the canonical code. +5. Add an `i18n/.ts` file to every component directory that already has an `i18n/` subdirectory — the coverage tests will list exactly which ones are missing. + +### Translation quality note + +The English translations were written by a native speaker. Finnish translations were written by a non-native speaker. All other bundled locales are currently **machine-translated** — they are included so the UI is usable out of the box in more Home Assistant setups, but they should be treated as reasonable defaults rather than fully reviewed translations. + +Translation improvements for any locale are welcome — edit the relevant `i18n/.ts` and json files and open a pull request. + +--- + +## Remote Home Assistant development + +If you use a remote HA instance for development: + +1. Copy the example env file: + +```bash +cp .env.dev.example .env.dev +``` + +2. Fill in the remote host details. +3. Sync manually: + +```bash +pnpm dev:sync +``` + +4. Or run watch mode: + +```bash +pnpm dev:watch +``` + +--- + +## WebSocket API + +The frontend uses the following WebSocket commands: + +| Type | Purpose | +| ------------------------------- | ------------------------------------ | +| `hass_datapoints/events` | Fetch recorded datapoints/events | +| `hass_datapoints/events/update` | Update an existing datapoint (admin) | +| `hass_datapoints/events/delete` | Delete a datapoint (admin) | +| `hass_datapoints/history` | Fetch history/downsampled chart data | +| `hass_datapoints/anomalies` | Fetch backend anomaly results | + +Events are stored in: + +```text +.storage/hass_datapoints.events +``` + +--- + +## Release and CI notes + +- CI checks build correctness and integration metadata. +- The built frontend bundle is committed as `custom_components/hass_datapoints/hass-datapoints-cards.js`. +- Pre-commit hooks format staged files, lint package.json versions, validate frontend types, and rebuild the frontend when needed. +- Pre-push hooks run tests, lint checks, package.json version linting, and frontend type validation before pushing. diff --git a/docs/history-and-analysis.md b/docs/history-and-analysis.md new file mode 100644 index 00000000..cccdbf2e --- /dev/null +++ b/docs/history-and-analysis.md @@ -0,0 +1,284 @@ +# History Chart & Analysis + +The history surfaces are the most powerful part of the integration. This document covers the chart controls, all analysis methods, and anomaly detection. + +--- + +## History chart features + +### Target rows + +Each target row controls one visible chart series and supports: + +- visibility on or off +- color selection +- drag-to-reorder +- analysis expansion +- chart participation for datapoints and anomaly overlays + +When a target is hidden, it can be restored from the same row without losing its configuration. + +### Datapoint visibility modes + +The history chart can show: + +- datapoints linked to selected targets +- all datapoints +- no datapoints + +### Chart display options + +- tooltips +- emphasized hover guides +- correlated anomaly highlighting +- data-gap rendering +- shared vs split y-axis +- split series into rows +- hover mode: follow the series vs snap to datapoints + +### Date windows + +Date windows let you save named historical periods and then: + +- preview them from tabs above the chart +- compare the current period against a known baseline +- investigate seasonal or maintenance-driven changes +- support comparison-based anomaly detection + +Useful date windows include: + +- `Last week` +- `Before maintenance` +- `Heating baseline` +- `After insulation` +- `Last cold snap` + +### Zoom and timeline controls + +- drag-to-zoom directly on the chart +- a timeline slider for the full available range +- zoom highlight synchronization between chart and timeline +- zoom-out control +- timeline drag handles for precise range control + +### Creating datapoints from the chart + +The chart `+` action creates a datapoint at the inspected time. The dialog can prefill related items from the currently visible target rows so the note is immediately linked to the right series. + +--- + +## Trend analysis + +Trend analysis overlays a computed curve on top of the raw sensor data. Each method answers a different question, so choosing the right one depends on what you are investigating. + +Enable trend lines from the analysis panel for each target row. The trend window selector controls how much history the smoothing methods use to compute each point. + +### Linear trend + +A straight line fitted to all visible points using least-squares regression. + +**What it shows:** The overall direction of the data — whether the value is rising, falling, or flat across the whole window. + +**Use it when:** + +- You want to confirm a slow long-term drift (e.g. sensor calibration drift, gradual battery discharge) +- You are comparing the slope between two time windows to detect a change in behavior +- The data is noisy but you only care about the broad direction, not local variation + +**Avoid it when:** The signal is clearly non-linear (curved, periodic, or mean-reverting). A straight line will misrepresent those patterns. + +--- + +### Rolling average + +A sliding-window mean that replaces each point with the average of all points within the preceding time window. + +**What it shows:** The local level of the data, smoothed to remove high-frequency noise. The resulting curve lags the true signal — the tighter the window, the less lag; the wider the window, the smoother the result. + +**Use it when:** + +- You want to see the general level of a noisy sensor (temperature, humidity, energy) +- You are trying to compare two smoothed series to spot divergence +- The window length roughly matches the natural timescale of the change you are investigating (e.g. a 1h window for heating dynamics, a 24h window for daily patterns) + +**Avoid it when:** You need responsiveness to recent changes. Because the window weights all points equally, a sharp step up will only be fully reflected in the average after the window has fully moved past the step. + +--- + +### Exponential moving average (EMA) + +A weighted average where recent points contribute more than older ones. The `alpha` parameter controls responsiveness: values near 1 track the signal closely with little smoothing; values near 0 produce a heavily smoothed curve that responds slowly. + +The window selector maps to alpha values tuned for typical HA data cadences: +`30m → 0.97`, `1h → 0.92`, `6h → 0.75`, `24h → 0.50`, `7d → 0.25`, `14d → 0.15`, `21d → 0.10`, `28d → 0.07`. + +**What it shows:** The local level of the data, like rolling average, but with less lag. A step change will begin appearing in the EMA immediately; a rolling average of equivalent width will not reflect it until the window moves past the old values. + +**Use it when:** + +- You want smoothing similar to rolling average but with faster response to real changes +- You are investigating whether a recent change represents a new pattern or a transient spike +- The data has an irregular update cadence (EMA is computed point-to-point, so it does not require evenly spaced samples) + +**Avoid it when:** You need a precise, interpretable window like "the average over the last hour". EMA is adaptive and does not have a hard time boundary, so its output at any point blends all past data with exponentially decaying weight. + +--- + +### Polynomial trend (quadratic) + +A quadratic (degree-2) curve fitted globally to all visible points using least-squares regression. + +**What it shows:** The overall shape of the data — whether it is arcing upward, bending back down, or following a U or inverted-U curve. A linear trend can only say "up" or "down"; the polynomial trend can also say "accelerating" or "decelerating". + +**Use it when:** + +- You suspect a non-linear drift — for example a battery whose discharge rate changes over time, or a room that heats quickly then tapers off +- You want to see whether a recovery is complete or still in progress +- Seasonal effects within the window create a visible curve + +**Avoid it when:** + +- The data is periodic or highly variable — the polynomial fit covers the entire window and will be distorted by extreme values at either end +- You only need a directional signal; use linear trend instead as it is easier to interpret + +--- + +### LOWESS (Locally Weighted Scatterplot Smoothing) + +A non-parametric smoother that computes a weighted local linear regression at each point, using only nearby data within a bandwidth window. The tricubic weight function gives maximum influence to very close neighbors and smoothly reduces weight toward the bandwidth boundary. + +**What it shows:** The underlying shape of the data without assuming any global functional form. LOWESS can follow curves, plateaus, transitions, and reversals that would require a high-degree polynomial to approximate analytically. + +**Use it when:** + +- The signal has a complex or unknown shape — for example temperature that rises, plateaus during occupancy, then drops overnight +- You want a visually clean, intuitive curve that roughly follows the "center" of the data at every local region +- You are investigating whether a specific period deviates from the local pattern (compare the LOWESS curve to the raw signal) +- The window selector controls locality: a 1h bandwidth tracks rapid changes; a 24h bandwidth gives a broad global shape + +**Avoid it when:** + +- The series is very short (fewer than 5–10 points) — local regression needs enough neighbors to be meaningful +- You need a mathematically interpretable output; LOWESS is empirical and does not produce slope or intercept values + +--- + +### Rate of change + +Computes the per-hour rate of change between each point and a lookback comparison. In point-to-point mode, each point is compared to the immediately preceding one. In windowed mode (e.g. 1h), each point is compared to the nearest point that is at least one window-width earlier. + +**What it shows:** How fast the value is changing, expressed in units per hour. A flat original series produces a rate near zero. A sharp spike appears as a large positive or negative value. + +**Use it when:** + +- You want to confirm whether a temperature is rising or falling fast enough to be significant +- You are investigating an abrupt event — an open window, a power surge, a pump starting — that shows up as a spike in rate of change +- You are comparing rate-of-change between two periods to detect whether the dynamics have changed (e.g. heating slower than it used to be) +- Point-to-point mode is useful for fine-grained detection; windowed mode reduces noise from rapid oscillations + +**Avoid it when:** The sensor updates irregularly or has long gaps — rate of change over a large gap can produce misleadingly large or small values. Use a windowed mode with a window wider than typical gaps to reduce this. + +--- + +## Anomaly detection + +Anomaly detection helps you spot suspicious patterns in time series without having to inspect every line manually. Results are computed by the backend and rendered as highlighted regions in the chart. + +### What anomaly detection helps you find + +- stuck or flat-lined sensors +- sudden spikes and drops +- values drifting away from their normal trend +- unusual rate-of-change behavior +- differences between the current period and a known-good date window +- suspicious clusters of events or repeated abnormal periods + +### Available methods + +| Method | What it detects | +| ------------------------- | ------------------------------------------------------------------------- | +| Trend deviation | Points that deviate significantly from a fitted trend line. | +| Sudden change | Unusually fast rises or drops compared to the typical rate of change. | +| Statistical outlier (IQR) | Values far outside the interquartile range of the series. | +| Rolling Z-score | Readings unusual relative to a rolling mean and standard deviation. | +| Flat-line / stuck value | A sensor reporting nearly the same value for an unusually long time. | +| Comparison window | Differences between the current period and a saved reference date window. | + +### How to enable anomaly detection + +1. Open the history page or history card. +2. Add one or more target entities. +3. Expand a target row's analysis options. +4. Enable **Show anomalies** for that target. +5. Choose one or more anomaly methods. +6. Tune sensitivity and method-specific windows. +7. Hover highlighted regions and compare them with datapoints and related context. + +### Practical workflow + +For a single series: + +1. Start with one visible target. +2. Enable **Trend deviation** or **Rolling Z-score** first. +3. Add **Flat-line** if you suspect the sensor stopped updating. +4. Use **Sudden change** for abrupt transitions such as open windows or sudden heating. +5. Add a date window from a known-good period if you want to compare behavior against a baseline. +6. If multiple targets are visible, switch to split rows to reduce visual density. + +### Example use cases + +#### Detect a stuck sensor + +Use: + +- Flat-line / stuck value +- medium or high sensitivity +- a window that matches the expected update cadence + +Useful for room temperature sensors, humidity sensors, and power sensors that silently stop updating. + +#### Investigate abnormal heating behavior + +Use: + +- Comparison window against a normal day or week +- Threshold and trend analysis alongside anomalies + +Useful for rooms that heat too slowly, delayed radiator response, or unexplained overnight heating. + +#### Find unusual environmental spikes + +Use: + +- Rolling Z-score +- Sudden change + +Useful for windows opening, hot water usage spikes, unexpected ventilation events, and sensor glitches. + +### Reading anomaly output effectively + +Anomaly markers are most useful when paired with datapoints that explain likely causes. + +For example: + +- `Boiler serviced` +- `Window left open` +- `Heating mode changed` +- `Fan speed manually increased` +- `Dehumidifier moved` + +The ideal workflow is: + +1. Let anomaly detection tell you where to look. +2. Use datapoints to record likely causes. +3. Compare against date windows to see whether the anomaly is new or expected. + +That turns anomalies from a visual warning into an actionable investigative tool. + +--- + +## Anomaly trend override + +When **Trend deviation** is enabled, it uses the same trend method as the chart display by default. You can override this independently — for example to detect trend-deviation anomalies using LOWESS while the chart itself shows a linear trend. The override is set in the trend method subopts within the anomaly panel. + +The "Same as display trend" option is only available when trend lines are enabled for the series. diff --git a/docs/recording-datapoints.md b/docs/recording-datapoints.md new file mode 100644 index 00000000..cb0156e9 --- /dev/null +++ b/docs/recording-datapoints.md @@ -0,0 +1,242 @@ +# Recording Datapoints + +Datapoints are timestamped annotations you create from automations, scripts, dashboards, or Developer Tools. They appear as markers on charts and in the list card, giving you a record of what changed and why. + +--- + +## The `hass_datapoints.record` action + +Use this action wherever you can call a Home Assistant action: + +- automations +- scripts +- dashboards +- **Developer Tools → Actions** + +### Action fields + +| Field | Required | Description | +| ------------ | -------- | -------------------------------------------------------------------------------------- | +| `message` | Yes | Short label shown in lists, chips, chart tooltips, and the logbook. | +| `annotation` | No | Longer note or context. Defaults to the message when omitted. | +| `entity_ids` | No | Entities related to the datapoint. These are the most useful links for chart analysis. | +| `icon` | No | MDI icon used for the datapoint marker and related UI. | +| `color` | No | Marker color. Accepts a hex string or an RGB list. | + +### Minimal example + +```yaml +action: hass_datapoints.record +data: + message: "Something happened" +``` + +### Full example + +```yaml +action: hass_datapoints.record +data: + message: "Heating schedule changed" + annotation: >- + Switched the house to the weekday daytime profile after school pickup. + This was done manually because the normal automation was paused. + entity_ids: + - climate.living_room + - sensor.living_room_temperature + icon: mdi:radiator + color: "#ff5722" +``` + +### RGB color example + +```yaml +action: hass_datapoints.record +data: + message: "Critical alert" + color: + - 255 + - 0 + - 0 +``` + +--- + +## How datapoints appear + +When a datapoint is recorded: + +1. It is stored in `.storage/hass_datapoints.events`. +2. It is emitted on the HA event bus as `hass_datapoints_event_recorded`. +3. It appears in the Home Assistant logbook. +4. It becomes available to the cards and history page. + +### Chart placement behavior + +On history and statistics charts, datapoints are placed: + +- on the related visible series when linked to a visible target +- on the chart baseline when linked to something not currently charted +- on a fallback position when they are global and have no explicit series link + +On the sensor card, datapoints are drawn directly on the sensor series. + +--- + +## Using automations to create useful analytical datapoints + +Datapoints are most valuable when they explain future chart behavior. + +The best automations record changes in state, intent, or operating mode rather than just mirroring every raw metric update. + +### Good datapoints to automate + +Automate datapoints for events like: + +- heating mode changes +- occupancy transitions +- windows or doors open for long periods +- maintenance actions +- manual overrides +- pump, fan, HVAC, or schedule changes +- tariff or price mode changes +- threshold crossings that explain later anomalies +- weather-driven operational mode changes + +### Good analytical habits + +Prefer datapoints that answer: + +- what changed +- why it changed +- what system it affected +- whether it was manual or automatic +- what later chart behavior it might explain + +### Recommended message style + +Keep `message` short and scan-friendly: + +- `Heating switched to away mode` +- `Bedroom window opened` +- `Filter replaced` +- `Boiler restarted` + +Put the detailed context in `annotation`: + +- who triggered it +- why it happened +- expected duration +- what behavior to compare against later + +### Best practice for related items + +When an automation records a datapoint for analysis: + +- link it to the exact entities you expect to inspect later +- include the primary measured series plus the controlling entity when possible +- use a clear icon and color to make patterns easy to spot in charts and lists + +For example, if you are investigating heating behavior, link the datapoint to: + +- the climate entity +- the relevant room temperature sensor +- any related window or valve entity + +--- + +## Automation examples + +### Record when a window stays open + +```yaml +automation: + - alias: Record long window opening + triggers: + - trigger: state + entity_id: binary_sensor.bedroom_window + to: "on" + for: "00:15:00" + actions: + - action: hass_datapoints.record + data: + message: "Bedroom window open > 15 min" + annotation: "May explain a temperature drop or radiator compensation." + entity_ids: + - binary_sensor.bedroom_window + - sensor.bedroom_temperature + icon: mdi:window-open-variant + color: "#f59e0b" +``` + +### Record heating profile changes + +```yaml +automation: + - alias: Record heating schedule change + triggers: + - trigger: state + entity_id: input_select.heating_mode + actions: + - action: hass_datapoints.record + data: + message: "Heating mode changed to {{ trigger.to_state.state }}" + annotation: "Captured automatically to explain later temperature and energy trends." + entity_ids: + - climate.living_room + - sensor.living_room_temperature + - sensor.daily_energy + icon: mdi:radiator + color: "#ef4444" +``` + +### Record maintenance + +```yaml +automation: + - alias: Record HVAC maintenance completion + triggers: + - trigger: event + event_type: hvac_filter_replaced + actions: + - action: hass_datapoints.record + data: + message: "HVAC filter replaced" + annotation: "Use this to compare airflow, temperature stability, and energy use before and after service." + entity_ids: + - climate.downstairs + - sensor.daily_energy + icon: mdi:wrench + color: "#10b981" +``` + +### Record threshold crossings that explain anomalies later + +```yaml +automation: + - alias: Record high humidity period + triggers: + - trigger: numeric_state + entity_id: sensor.bathroom_humidity + above: 75 + actions: + - action: hass_datapoints.record + data: + message: "Bathroom humidity above 75%" + annotation: "Useful for comparing ventilation response and recovery time." + entity_ids: + - sensor.bathroom_humidity + - fan.bathroom_extract + icon: mdi:water-percent + color: "#3b82f6" +``` + +### Combining automations with anomaly detection + +The most useful combination is: + +1. automate datapoints for state changes or interventions +2. use anomaly detection to find unusual sensor behavior +3. compare the anomaly regions against your recorded datapoints +4. save date windows around known good and bad periods for future comparison + +That gives you both the signal and the likely explanation. See [History & Analysis](./history-and-analysis.md) for more on anomaly detection and date windows. From 836171336cc609e32934ec091f5a782673b97086 Mon Sep 17 00:00:00 2001 From: Oliver Lillie Date: Fri, 10 Apr 2026 09:21:04 +0300 Subject: [PATCH 5/5] chore(tooling): Allows fixup/squash commit messages --- scripts/prepare-commit-msg | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/prepare-commit-msg b/scripts/prepare-commit-msg index 504d3ac0..1a7eebfd 100755 --- a/scripts/prepare-commit-msg +++ b/scripts/prepare-commit-msg @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Appends the GitHub issue number from the branch name to the commit message. # Branch format: (branch|hotfix)/XXXX-description → appends "#XXXX" -# Skips merge commits, squash commits, and messages that already contain the ref. +# Skips merge commits, squash commits, fixup commits, and messages that already contain the ref. COMMIT_MSG_FILE="$1" COMMIT_SOURCE="$2" @@ -11,6 +11,13 @@ if [ "$COMMIT_SOURCE" = "merge" ] || [ "$COMMIT_SOURCE" = "squash" ]; then exit 0 fi +# fixup! and squash! prefixes are used by git rebase --autosquash. +# Appending an issue ref would break subject-line matching, so skip them. +FIRST_LINE=$(head -n1 "$COMMIT_MSG_FILE") +if echo "$FIRST_LINE" | grep -qE "^(fixup|squash)! "; then + exit 0 +fi + BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null) # Extract issue number from branch|hotfix/XXXX-...