From 24e4be1737ef6b161f68a146ed3e9b6cba44c280 Mon Sep 17 00:00:00 2001 From: ArticOdin Date: Sun, 17 May 2026 22:33:37 +1000 Subject: [PATCH 1/7] =?UTF-8?q?feat(coordinator):=20Phase=203.4=20commit?= =?UTF-8?q?=201/2=20=E2=80=94=20named=20comparator=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an OptionsFlow step that pins ONE CDR plan from the current ranked alternatives as a "named comparator", and wires the coordinator to construct a second CdrPlanProvider for that plan under the literal `"named"` key in `_providers`. The named provider then participates in the existing 30s tick loop (no new tick path) and contributes a `"named"` column to `daily_cost_history` at daily rollover, which the Phase 3.4 commit 2 rollup sensors will read. - `const.py`: add CONF_NAMED_COMPARATOR_PLAN_ID + CONF_NAMED_COMPARATOR_PLAN. We persist the FULL PlanDetailV2 body (not the summarised form) because the evaluator needs tariffPeriod data the summary omits. - `config_flow.py`: new `named_comparator` menu entry + step. The step reads ranked_alternatives + the per-day _ranking_plan_cache directly from the coordinator. Aborts with `no_ranked_alternatives` if either is empty (covers the post-install + post-midnight-cache-reset edge cases per plan Β§4.2 #1 + #3). Decision tree extracted to `plan_named_comparator_step()` so it's unit-testable without HA's app context (the OptionsFlow class itself becomes a MagicMock under the conftest mock tree). - `coordinator.py`: new `build_named_comparator_provider()` module-level helper (same testability rationale as build_backfill_plan_set). Called from both __init__ AND rebuild_engine so a fresh pin lands on the next OptionsFlowWithReload cycle without an HA restart. - `strings.json` + `translations/en.json`: new step title/description + menu label + two abort reasons. Keep distinct from the existing `comparators` step (which toggles live-API providers, not CDR pins). - 22 new tests: 10 in test_config_flow_phase_3 (full decision-tree coverage including the full-body persistence guard, dedupe, default fallback when prior pin evicted from cache, plan_not_in_cache branch), 4 in test_coordinator_helpers (lifecycle of the named provider helper across all option shapes). Lock interaction with ranking lock: none. The named comparator joins the existing tick loop unchanged. The OptionsFlow step is a READ from _ranking_plan_cache, which is only written under the ranking lock β€” reading without the lock is safe because (a) the worst case is a brief torn read that resolves to the abort path, and (b) the lock is held for the duration of the ranking pipeline run, not just dict mutation. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- custom_components/pricehawk/config_flow.py | 160 ++++++++++++ custom_components/pricehawk/const.py | 18 ++ custom_components/pricehawk/coordinator.py | 59 +++++ custom_components/pricehawk/strings.json | 12 + .../pricehawk/translations/en.json | 12 + tests/test_config_flow_phase_3.py | 244 +++++++++++++++++- tests/test_coordinator_helpers.py | 73 ++++++ 7 files changed, 577 insertions(+), 1 deletion(-) diff --git a/custom_components/pricehawk/config_flow.py b/custom_components/pricehawk/config_flow.py index b737654..0efa60a 100644 --- a/custom_components/pricehawk/config_flow.py +++ b/custom_components/pricehawk/config_flow.py @@ -67,6 +67,8 @@ CONF_LOCALVOLTS_NMI, CONF_LOCALVOLTS_PARTNER_ID, CONF_LOCALVOLTS_SELL_FLOOR, + CONF_NAMED_COMPARATOR_PLAN, + CONF_NAMED_COMPARATOR_PLAN_ID, CONF_PLAN_TYPE, CONF_SITE_ID, DEFAULT_TOU_IMPORT_WINDOWS, @@ -93,6 +95,102 @@ # tariff-entry path, so this no longer escapes CDR setup β€” it only skips # locale narrowing.) CDR_SKIP_SENTINEL = "__manual__" + +# Phase 3.4 β€” sentinel for the named-comparator dropdown's "clear pin" +# entry. Distinct from ``CDR_SKIP_SENTINEL`` (which is a wizard-flow +# sentinel) so the two never share state, even though they happen to +# have the same value pattern. +NAMED_COMPARATOR_CLEAR_SENTINEL = "__clear__" + + +def plan_named_comparator_step( + *, + ranked_alternatives: list[dict[str, Any]], + plan_cache: dict[str, dict[str, Any]], + user_input: dict[str, Any] | None, + current_options: dict[str, Any], +) -> tuple[str, dict[str, Any]]: + """Pure-logic decision for the Phase 3.4 named-comparator OptionsFlow step. + + Returns one of: + - ``("abort", {"reason": "no_ranked_alternatives"})`` + - ``("abort", {"reason": "plan_not_in_cache"})`` + - ``("create_entry", {"data": new_options})`` + - ``("form", {"options": [...], "default": str})`` + + Lives outside ``EnergyCompareOptionsFlow`` so it's unit-testable + without HA's app context β€” the OptionsFlow class itself becomes a + MagicMock under the conftest mock tree, making instance methods + unreachable from tests. The step method is a thin adapter that + delegates here and translates the result to HA's API calls. + """ + # Empty alternatives β€” same UX path whether the daily ranking job + # has never run (fresh install before 00:30) or it's been wiped by + # the date-rollover cache reset. + if not ranked_alternatives: + return ("abort", {"reason": "no_ranked_alternatives"}) + # Empty plan cache β€” alternatives summarised on the sensor exist + # but the full PlanDetailV2 bodies aren't loaded. Same abort + # path; user retries after the next ranking run repopulates the + # cache. + if not plan_cache: + return ("abort", {"reason": "no_ranked_alternatives"}) + + if user_input is not None: + chosen = user_input.get(CONF_NAMED_COMPARATOR_PLAN_ID) + new_opts: dict[str, Any] = dict(current_options) + if chosen in (NAMED_COMPARATOR_CLEAR_SENTINEL, None, ""): + # Both keys pruned so the coordinator's setup branches + # don't try to construct an empty provider on reload. + new_opts.pop(CONF_NAMED_COMPARATOR_PLAN_ID, None) + new_opts.pop(CONF_NAMED_COMPARATOR_PLAN, None) + return ("create_entry", {"data": new_opts}) + full_plan = plan_cache.get(chosen) + if not isinstance(full_plan, dict) or not full_plan: + return ("abort", {"reason": "plan_not_in_cache"}) + new_opts[CONF_NAMED_COMPARATOR_PLAN_ID] = chosen + new_opts[CONF_NAMED_COMPARATOR_PLAN] = full_plan + return ("create_entry", {"data": new_opts}) + + # No user_input β†’ render the form. Build the dropdown options + # list. ``(clear pin)`` always first so users have an explicit + # unpinning escape, even if pinned to the only ranked plan. + select_options: list[dict[str, str]] = [ + {"value": NAMED_COMPARATOR_CLEAR_SENTINEL, "label": "(clear pin)"} + ] + seen_plan_ids: set[str] = set() + for alt in ranked_alternatives: + if not isinstance(alt, dict): + continue + plan_id = alt.get("plan_id") + if not isinstance(plan_id, str) or not plan_id: + continue + if plan_id in seen_plan_ids: + continue + # Only surface plans we actually have full bodies for; + # otherwise the user's selection would just dead-end at + # ``plan_not_in_cache``. + if plan_id not in plan_cache: + continue + seen_plan_ids.add(plan_id) + brand = alt.get("brand") or "" + display = alt.get("display_name") or plan_id + label = f"{brand} β€” {display}" if brand else str(display) + select_options.append({"value": plan_id, "label": label}) + + # Every ranked alt was missing from the cache β€” defensive belt- + # and-braces. (``cheap_rank`` populates both lists in lockstep so + # this shouldn't fire in practice.) + if len(select_options) <= 1: + return ("abort", {"reason": "no_ranked_alternatives"}) + + current_default = current_options.get( + CONF_NAMED_COMPARATOR_PLAN_ID, NAMED_COMPARATOR_CLEAR_SENTINEL, + ) + valid_values = {opt["value"] for opt in select_options} + if current_default not in valid_values: + current_default = NAMED_COMPARATOR_CLEAR_SENTINEL + return ("form", {"options": select_options, "default": current_default}) CDR_ANY_DISTRIBUTOR_SENTINEL = "__any__" CONF_CDR_RETAILER_ID = "cdr_retailer_id" CONF_CDR_POSTCODE = "cdr_postcode" @@ -1878,6 +1976,7 @@ async def async_step_init( step_id="init", menu_options=[ "comparators", + "named_comparator", "amber_api_key", "cdr_pick", "amber_fees", @@ -1950,6 +2049,67 @@ async def async_step_comparators( ), ) + # ------------------------------------------------------------------ + # Phase 3.4 β€” Named comparator drill-in + # ------------------------------------------------------------------ + + async def async_step_named_comparator( + self, user_input: dict[str, Any] | None = None + ) -> config_entries.ConfigFlowResult: + """Pin one CDR plan from the current ranked list as a primary comparator. + + Thin adapter around :func:`plan_named_comparator_step`. The + helper owns the decision tree (abort vs form vs create_entry); + this method only translates the tagged result into HA's API. + + The chosen plan body is stored in options as + :data:`CONF_NAMED_COMPARATOR_PLAN` (full ``PlanDetailV2`` data). + We deliberately persist the FULL body β€” not the summarised form + produced by :func:`cdr.ranking.summarize_for_sensor` β€” because + the evaluator needs the ``tariffPeriod`` data that the summary + omits. The full body comes from the coordinator's per-day + ``_ranking_plan_cache`` (keyed by ``planId``); if the user + opens this step immediately after the daily ``00:30`` cache + reset before ranking has rerun, the cache is empty and we + abort with ``no_ranked_alternatives``. + """ + coordinator = self.hass.data.get(DOMAIN, {}).get(self.config_entry.entry_id) + alternatives: list[dict[str, Any]] = [] + plan_cache: dict[str, dict[str, Any]] = {} + if coordinator is not None: + data = getattr(coordinator, "data", None) or {} + alternatives = list(data.get("ranked_alternatives") or []) + plan_cache = dict(getattr(coordinator, "_ranking_plan_cache", {}) or {}) + + kind, payload = plan_named_comparator_step( + ranked_alternatives=alternatives, + plan_cache=plan_cache, + user_input=user_input, + current_options=dict(self.config_entry.options), + ) + + if kind == "abort": + return self.async_abort(reason=payload["reason"]) + if kind == "create_entry": + return self.async_create_entry(title="", data=payload["data"]) + # kind == "form" + return self.async_show_form( + step_id="named_comparator", + data_schema=vol.Schema( + { + vol.Required( + CONF_NAMED_COMPARATOR_PLAN_ID, + default=payload["default"], + ): SelectSelector( + SelectSelectorConfig( + options=payload["options"], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + ) + # ------------------------------------------------------------------ # Phase 2.7 β€” CDR re-pick (options flow mirror of wizard branch A) # ------------------------------------------------------------------ diff --git a/custom_components/pricehawk/const.py b/custom_components/pricehawk/const.py index 5bdd660..0b44565 100644 --- a/custom_components/pricehawk/const.py +++ b/custom_components/pricehawk/const.py @@ -71,6 +71,24 @@ # upgrades that haven't re-run the wizard. CONF_CDR_PLAN = "cdr_plan" +# Phase 3.4 β€” Named comparator drill-in. +# When the user pins ONE CDR plan from the ranked alternatives list, the +# coordinator constructs a second `CdrPlanProvider` for it under the +# ``"named"`` provider key. The provider then participates in the tick +# loop (every 30s) just like Amber / Flow Power / LocalVolts, and +# contributes a ``"named"`` column to `daily_cost_history` for the +# Phase 3.4 rollup sensors. +# +# - ``CONF_NAMED_COMPARATOR_PLAN_ID`` holds the planId (string) β€” handy +# for the OptionsFlow dropdown default and for the UI. +# - ``CONF_NAMED_COMPARATOR_PLAN`` holds the FULL PlanDetailV2 body +# (dict). The evaluator needs ``tariffPeriod`` data which the sensor- +# summary form (`summarize_for_sensor`) deliberately omits, so we +# store the full envelope. Bounded ~15 KB per pinned plan β€” fine for +# one pin; revisit if we ever support multi-pin. +CONF_NAMED_COMPARATOR_PLAN_ID = "named_comparator_plan_id" +CONF_NAMED_COMPARATOR_PLAN = "named_comparator_plan" + # Phase 2.4 audit field β€” records WHY a config_entry has no cdr_plan. # Helps distinguish a deliberate manual user (branch C) from a user # whose CDR fetch failed (branch B). Never read by the coordinator; diff --git a/custom_components/pricehawk/coordinator.py b/custom_components/pricehawk/coordinator.py index 1b69e8b..8b7f8c0 100644 --- a/custom_components/pricehawk/coordinator.py +++ b/custom_components/pricehawk/coordinator.py @@ -44,6 +44,7 @@ CONF_LOCALVOLTS_ENABLED, CONF_LOCALVOLTS_NMI, CONF_LOCALVOLTS_PARTNER_ID, + CONF_NAMED_COMPARATOR_PLAN, LOCALVOLTS_API_POLL_INTERVAL, ) from .cdr.ranking import DEFAULT_TOP_K, summarize_for_sensor @@ -188,6 +189,29 @@ def build_backfill_plan_set( return plans +def build_named_comparator_provider( + options: dict[str, Any], +) -> CdrPlanProvider | None: + """Phase 3.4 β€” pure-logic constructor for the named-comparator provider. + + Returns a ``CdrPlanProvider`` wrapping the user-pinned plan if + ``CONF_NAMED_COMPARATOR_PLAN`` is present in ``options`` and the + body looks like a CDR envelope (``dict``), else ``None``. Lives + outside ``PriceHawkCoordinator`` so it's unit-testable without + HA's app context (same justification as :func:`build_backfill_plan_set`). + + The caller is responsible for registering the result in + ``self._providers`` under the literal ``"named"`` key β€” keying is + the coordinator's responsibility so the daily-rollover loop can + write a stable ``"named"`` column to ``daily_cost_history`` + irrespective of which plan the user pinned. + """ + plan = options.get(CONF_NAMED_COMPARATOR_PLAN) + if not isinstance(plan, dict) or not plan: + return None + return CdrPlanProvider(plan, entry_options=dict(options)) + + class PriceHawkCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Coordinate Amber API polling, grid sensor reads, and cost calculation.""" @@ -253,6 +277,28 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self._localvolts = LocalVoltsProvider(entry.options) self._providers[self._localvolts.id] = self._localvolts + # Phase 3.4 β€” Named comparator drill-in. When the user pins one + # CDR plan via the OptionsFlow ``named_comparator`` step, build + # a second ``CdrPlanProvider`` for it and register it under the + # fixed ``"named"`` key. It then participates in the existing + # 30s tick loop (no new tick path) and contributes a + # ``"named"`` column to ``daily_cost_history`` at rollover. + # The DICT KEY (``"named"``) is what flows into rollup sensors + # and the providers block, not the provider's own ``.id`` (which + # remains the brand+plan-id slug like other CdrPlanProvider + # instances). Stable key β†’ rollup sensors don't churn when the + # user re-pins to a different plan. + self._named_comparator: CdrPlanProvider | None = ( + build_named_comparator_provider(entry.options) + ) + if self._named_comparator is not None: + self._providers["named"] = self._named_comparator + named_plan = entry.options.get(CONF_NAMED_COMPARATOR_PLAN) or {} + _LOGGER.info( + "Registered named comparator (CDR plan %s)", + named_plan.get("data", {}).get("planId", "?"), + ) + # Wholesale RRP fetched from AEMO NEMWeb dispatch reports (Flow Power # input). c/kWh, signed (can be negative). NOT sourced from Amber's # spotPerKwh which bundles network charges, and NOT requiring an @@ -1498,5 +1544,18 @@ def rebuild_engine(self, new_options: dict) -> None: if new_options.get(CONF_LOCALVOLTS_ENABLED): self._localvolts = LocalVoltsProvider(new_options) self._providers[self._localvolts.id] = self._localvolts + + # Phase 3.4 β€” rebuild the named comparator from updated options. + # Same construction as ``__init__``; absence of the option key + # cleanly drops the provider on the next reload. + self._named_comparator = build_named_comparator_provider(new_options) + if self._named_comparator is not None: + self._providers["named"] = self._named_comparator + named_plan = new_options.get(CONF_NAMED_COMPARATOR_PLAN) or {} + _LOGGER.info( + "Rebuilt named comparator (CDR plan %s)", + named_plan.get("data", {}).get("planId", "?"), + ) + self._grid_power_entity = new_options.get(CONF_GRID_POWER_SENSOR, "") _LOGGER.info("Rebuilt providers with updated options") diff --git a/custom_components/pricehawk/strings.json b/custom_components/pricehawk/strings.json index db4f553..709ec94 100644 --- a/custom_components/pricehawk/strings.json +++ b/custom_components/pricehawk/strings.json @@ -236,6 +236,7 @@ "description": "Choose what to update.", "menu_options": { "comparators": "Toggle Comparators (Amber / Flow Power / LocalVolts) + Opt-Ins", + "named_comparator": "Pin a Named Comparator (one ranked CDR plan)", "amber_api_key": "Change Amber API Key & Site", "cdr_pick": "Switch CDR plan", "globird_plan": "Edit GloBird Tariffs & Rates", @@ -256,6 +257,13 @@ "vpp_batteries_enrolled": "Batteries enrolled in retailer VPP (ENGIE / EA PowerResponse)" } }, + "named_comparator": { + "title": "Pin a Named Comparator", + "description": "Pin ONE ranked CDR plan as a named comparator. PriceHawk will run it tick-by-tick (every 30s) alongside your current plan instead of only at daily rollover. Five new sensors will appear: today / week / month / 3 month / year. Pick \"(clear pin)\" to remove the pin.", + "data": { + "named_comparator_plan_id": "Ranked plan" + } + }, "cdr_pick": { "title": "Switch CDR plan β€” pick retailer", "description": "Pick your retailer to load its latest CDR plan list. Pick \"Skip\" to return to the menu without changing anything.", @@ -417,6 +425,10 @@ "peak_offpeak_overlap": "Peak and Off-Peak time windows overlap.", "shoulder_offpeak_overlap": "Shoulder and Off-Peak time windows overlap.", "incomplete_tou_coverage": "Your TOU time windows don't cover all 24 hours. Uncovered periods will be charged at 0 c/kWh." + }, + "abort": { + "no_ranked_alternatives": "PriceHawk has no ranked CDR alternatives to pin yet. Wait for the daily 00:30 ranking job to run, or trigger it manually via the 'pricehawk.rank_alternatives' service.", + "plan_not_in_cache": "The selected plan body is no longer cached (the daily ranking cache was cleared while this dialog was open). Re-open this step after the next ranking run." } }, "entity": { diff --git a/custom_components/pricehawk/translations/en.json b/custom_components/pricehawk/translations/en.json index db4f553..709ec94 100644 --- a/custom_components/pricehawk/translations/en.json +++ b/custom_components/pricehawk/translations/en.json @@ -236,6 +236,7 @@ "description": "Choose what to update.", "menu_options": { "comparators": "Toggle Comparators (Amber / Flow Power / LocalVolts) + Opt-Ins", + "named_comparator": "Pin a Named Comparator (one ranked CDR plan)", "amber_api_key": "Change Amber API Key & Site", "cdr_pick": "Switch CDR plan", "globird_plan": "Edit GloBird Tariffs & Rates", @@ -256,6 +257,13 @@ "vpp_batteries_enrolled": "Batteries enrolled in retailer VPP (ENGIE / EA PowerResponse)" } }, + "named_comparator": { + "title": "Pin a Named Comparator", + "description": "Pin ONE ranked CDR plan as a named comparator. PriceHawk will run it tick-by-tick (every 30s) alongside your current plan instead of only at daily rollover. Five new sensors will appear: today / week / month / 3 month / year. Pick \"(clear pin)\" to remove the pin.", + "data": { + "named_comparator_plan_id": "Ranked plan" + } + }, "cdr_pick": { "title": "Switch CDR plan β€” pick retailer", "description": "Pick your retailer to load its latest CDR plan list. Pick \"Skip\" to return to the menu without changing anything.", @@ -417,6 +425,10 @@ "peak_offpeak_overlap": "Peak and Off-Peak time windows overlap.", "shoulder_offpeak_overlap": "Shoulder and Off-Peak time windows overlap.", "incomplete_tou_coverage": "Your TOU time windows don't cover all 24 hours. Uncovered periods will be charged at 0 c/kWh." + }, + "abort": { + "no_ranked_alternatives": "PriceHawk has no ranked CDR alternatives to pin yet. Wait for the daily 00:30 ranking job to run, or trigger it manually via the 'pricehawk.rank_alternatives' service.", + "plan_not_in_cache": "The selected plan body is no longer cached (the daily ranking cache was cleared while this dialog was open). Re-open this step after the next ranking run." } }, "entity": { diff --git a/tests/test_config_flow_phase_3.py b/tests/test_config_flow_phase_3.py index 7019fcb..6f9bd56 100644 --- a/tests/test_config_flow_phase_3.py +++ b/tests/test_config_flow_phase_3.py @@ -6,8 +6,14 @@ """ from __future__ import annotations -from custom_components.pricehawk.config_flow import _api_provider_for_brand +from custom_components.pricehawk.config_flow import ( + NAMED_COMPARATOR_CLEAR_SENTINEL, + _api_provider_for_brand, + plan_named_comparator_step, +) from custom_components.pricehawk.const import ( + CONF_NAMED_COMPARATOR_PLAN, + CONF_NAMED_COMPARATOR_PLAN_ID, PROVIDER_AMBER, PROVIDER_FLOW_POWER, PROVIDER_LOCALVOLTS, @@ -53,3 +59,239 @@ def test_unknown_brand_returns_none(): def test_empty_returns_none(): assert _api_provider_for_brand("") is None assert _api_provider_for_brand(" ") is None + + +# --------------------------------------------------------------------------- +# Phase 3.4 β€” named comparator OptionsFlow step (pure decision helper) +# --------------------------------------------------------------------------- +# +# The ``EnergyCompareOptionsFlow`` class becomes a MagicMock under the +# conftest mock tree (its base ``config_entries.OptionsFlowWithReload`` +# is a ``_MockModule``), so instance methods on it can't be called +# directly from a test. Phase 3.4 extracted the step's decision tree +# into a pure module-level helper ``plan_named_comparator_step`` for +# this exact reason β€” these tests pin its behaviour edge-by-edge. + + +def test_named_comparator_step_aborts_without_alternatives(): + """No ranked_alternatives β†’ ``no_ranked_alternatives`` abort. + UX path: user opened the step before the daily ranking job ran.""" + kind, payload = plan_named_comparator_step( + ranked_alternatives=[], + plan_cache={"P1": {"any": True}}, + user_input=None, + current_options={}, + ) + assert kind == "abort" + assert payload == {"reason": "no_ranked_alternatives"} + + +def test_named_comparator_step_aborts_when_plan_cache_empty(): + """Plan-cache empty (date-rollover edge case β€” plan Β§4.2 #3) β†’ + same abort path as no ranked_alternatives so the UX is uniform.""" + kind, payload = plan_named_comparator_step( + ranked_alternatives=[ + {"plan_id": "AGL900", "brand": "AGL", "display_name": "Saver"} + ], + plan_cache={}, + user_input=None, + current_options={}, + ) + assert kind == "abort" + assert payload == {"reason": "no_ranked_alternatives"} + + +def test_named_comparator_step_lists_ranked_alternatives_in_form(): + """Happy path β€” alts present, cache present β†’ form payload carries + the ``(clear pin)`` sentinel followed by one option per cached alt. + """ + kind, payload = plan_named_comparator_step( + ranked_alternatives=[ + {"plan_id": "AGL900", "brand": "AGL", "display_name": "Saver"}, + {"plan_id": "ORG456", "brand": "Origin", "display_name": "Solar"}, + ], + plan_cache={ + "AGL900": {"data": {"planId": "AGL900"}}, + "ORG456": {"data": {"planId": "ORG456"}}, + }, + user_input=None, + current_options={}, + ) + assert kind == "form" + option_values = [opt["value"] for opt in payload["options"]] + assert option_values == [NAMED_COMPARATOR_CLEAR_SENTINEL, "AGL900", "ORG456"] + labels = [opt["label"] for opt in payload["options"]] + assert any("AGL" in label and "Saver" in label for label in labels) + # Default points at the clear-pin sentinel when no prior pin. + assert payload["default"] == NAMED_COMPARATOR_CLEAR_SENTINEL + + +def test_named_comparator_step_skips_alts_missing_from_cache(): + """Alts WITHOUT a cached full body don't appear in the dropdown β€” + otherwise the selection would dead-end at ``plan_not_in_cache``. + """ + kind, payload = plan_named_comparator_step( + ranked_alternatives=[ + {"plan_id": "AGL900", "brand": "AGL", "display_name": "Saver"}, + {"plan_id": "MISSING", "brand": "X", "display_name": "Y"}, + ], + plan_cache={"AGL900": {"data": {"planId": "AGL900"}}}, + user_input=None, + current_options={}, + ) + assert kind == "form" + option_values = [opt["value"] for opt in payload["options"]] + assert option_values == [NAMED_COMPARATOR_CLEAR_SENTINEL, "AGL900"] + assert "MISSING" not in option_values + + +def test_named_comparator_step_falls_back_to_abort_when_no_cached_alts(): + """Every ranked alt missing from the cache β†’ defensive + fall-through to the same abort. (``cheap_rank`` should keep both + in lockstep but the test pins the safety net.) + """ + kind, payload = plan_named_comparator_step( + ranked_alternatives=[ + {"plan_id": "A", "brand": "X", "display_name": "Y"}, + {"plan_id": "B", "brand": "X", "display_name": "Y"}, + ], + plan_cache={"NOT_LISTED": {"data": {"planId": "NOT_LISTED"}}}, + user_input=None, + current_options={}, + ) + assert kind == "abort" + assert payload == {"reason": "no_ranked_alternatives"} + + +def test_named_comparator_step_default_falls_back_when_prior_pin_evicted(): + """Prior pin's planId is no longer in the cache β†’ form default + resets to the clear-pin sentinel so HA doesn't reject the + schema for an unknown default.""" + kind, payload = plan_named_comparator_step( + ranked_alternatives=[ + {"plan_id": "AGL900", "brand": "AGL", "display_name": "Saver"} + ], + plan_cache={"AGL900": {"data": {"planId": "AGL900"}}}, + user_input=None, + current_options={CONF_NAMED_COMPARATOR_PLAN_ID: "EVICTED_OLD"}, + ) + assert kind == "form" + assert payload["default"] == NAMED_COMPARATOR_CLEAR_SENTINEL + + +def test_named_comparator_step_default_uses_current_pin_when_valid(): + """Prior pin still in the cache β†’ form default preselects it so + the user sees their current pin highlighted.""" + kind, payload = plan_named_comparator_step( + ranked_alternatives=[ + {"plan_id": "AGL900", "brand": "AGL", "display_name": "Saver"} + ], + plan_cache={"AGL900": {"data": {"planId": "AGL900"}}}, + user_input=None, + current_options={CONF_NAMED_COMPARATOR_PLAN_ID: "AGL900"}, + ) + assert kind == "form" + assert payload["default"] == "AGL900" + + +def test_named_comparator_step_clear_pin_removes_both_keys(): + """Selecting the ``(clear pin)`` sentinel must remove BOTH the + plan_id and the full plan body so the coordinator's setup + branches don't try to construct an empty provider.""" + kind, payload = plan_named_comparator_step( + ranked_alternatives=[ + {"plan_id": "AGL900", "brand": "AGL", "display_name": "Saver"} + ], + plan_cache={"AGL900": {"data": {"planId": "AGL900"}}}, + user_input={CONF_NAMED_COMPARATOR_PLAN_ID: NAMED_COMPARATOR_CLEAR_SENTINEL}, + current_options={ + CONF_NAMED_COMPARATOR_PLAN_ID: "OLD123", + CONF_NAMED_COMPARATOR_PLAN: {"data": {"planId": "OLD123"}}, + "cdr_plan": {"data": {"planId": "CURRENT"}}, + }, + ) + assert kind == "create_entry" + new_opts = payload["data"] + assert CONF_NAMED_COMPARATOR_PLAN_ID not in new_opts + assert CONF_NAMED_COMPARATOR_PLAN not in new_opts + # Unrelated options preserved. + assert new_opts["cdr_plan"] == {"data": {"planId": "CURRENT"}} + + +def test_named_comparator_step_persists_full_plan_body_for_evaluator(): + """Critical guard β€” the persisted plan body MUST be the full + PlanDetailV2 envelope from ``_ranking_plan_cache``, NOT the + summarised form on the ranked_alternatives sensor attributes + (which omits ``tariffPeriod`` data the evaluator needs). + """ + full_body = { + "data": { + "planId": "AGL900", + "displayName": "AGL Saver", + "brand": "AGL", + "electricityContract": { + "tariffPeriod": [ + { + "rateBlockUType": "timeOfUseRates", + "timeOfUseRates": [ + {"type": "PEAK", "rates": [{"unitPrice": "0.40"}]}, + ], + } + ] + }, + } + } + summarised = { + "plan_id": "AGL900", + "brand": "AGL", + "display_name": "AGL Saver", + "peak_c_per_kwh": 44.0, # already inc-GST β€” no tariffPeriod + } + kind, payload = plan_named_comparator_step( + ranked_alternatives=[summarised], + plan_cache={"AGL900": full_body}, + user_input={CONF_NAMED_COMPARATOR_PLAN_ID: "AGL900"}, + current_options={}, + ) + assert kind == "create_entry" + persisted_plan = payload["data"][CONF_NAMED_COMPARATOR_PLAN] + assert persisted_plan is full_body + assert "electricityContract" in persisted_plan["data"] + assert payload["data"][CONF_NAMED_COMPARATOR_PLAN_ID] == "AGL900" + + +def test_named_comparator_step_aborts_when_user_input_selection_missing_from_cache(): + """Defensive β€” selection no longer in cache when user submits + (a daily reset fired while the form was open) β†’ ``plan_not_in_cache`` + abort rather than pin an empty body.""" + kind, payload = plan_named_comparator_step( + ranked_alternatives=[ + {"plan_id": "AGL900", "brand": "AGL", "display_name": "Saver"} + ], + # Mismatched cache: alt advertises AGL900 but only DIFFERENT + # is cached. (Pre-cache check passes because both lists are + # non-empty; selection lookup misses.) + plan_cache={"DIFFERENT": {"data": {"planId": "DIFFERENT"}}}, + user_input={CONF_NAMED_COMPARATOR_PLAN_ID: "AGL900"}, + current_options={}, + ) + assert kind == "abort" + assert payload == {"reason": "plan_not_in_cache"} + + +def test_named_comparator_step_dedupes_alternatives_with_duplicate_plan_ids(): + """If the ranked list has the same planId twice (CDR + republish window glitch), the dropdown surfaces it only + once. Otherwise HA would reject the SelectSelector schema.""" + kind, payload = plan_named_comparator_step( + ranked_alternatives=[ + {"plan_id": "AGL900", "brand": "AGL", "display_name": "Saver"}, + {"plan_id": "AGL900", "brand": "AGL", "display_name": "Saver"}, + ], + plan_cache={"AGL900": {"data": {"planId": "AGL900"}}}, + user_input=None, + current_options={}, + ) + assert kind == "form" + option_values = [opt["value"] for opt in payload["options"]] + assert option_values.count("AGL900") == 1 diff --git a/tests/test_coordinator_helpers.py b/tests/test_coordinator_helpers.py index acb4b16..a377103 100644 --- a/tests/test_coordinator_helpers.py +++ b/tests/test_coordinator_helpers.py @@ -9,6 +9,7 @@ from custom_components.pricehawk.coordinator import ( _extract_peak_rate_c_inc_gst, build_backfill_plan_set, + build_named_comparator_provider, ) @@ -241,3 +242,75 @@ def test_handles_non_dict_cdr_plan_envelope(self): plan_cache={}, ) assert plans == {} + + +# --------------------------------------------------------------------------- +# Phase 3.4 β€” named comparator provider lifecycle (pure helper) +# --------------------------------------------------------------------------- + + +class TestBuildNamedComparatorProvider: + """Exercises :func:`build_named_comparator_provider` β€” the pure-logic + extraction of the Phase 3.4 named-comparator construction (lives + outside ``PriceHawkCoordinator.__init__`` so it can be tested + without HA's app context, same rationale as ``build_backfill_plan_set``). + """ + + def _load_globird_plan(self) -> dict: + import json + from pathlib import Path + + fixture = ( + Path(__file__).parent + / "fixtures" + / "phase0" + / "plan_globird_GLO731031MR@VEC.json" + ) + return json.loads(fixture.read_text()) + + def test_returns_provider_when_plan_present(self): + """``CONF_NAMED_COMPARATOR_PLAN`` set β†’ returns a + ``CdrPlanProvider`` constructed against the pinned plan body.""" + from custom_components.pricehawk.const import CONF_NAMED_COMPARATOR_PLAN + from custom_components.pricehawk.providers.cdr_plan import CdrPlanProvider + + plan = self._load_globird_plan() + provider = build_named_comparator_provider( + {CONF_NAMED_COMPARATOR_PLAN: plan, "cdr_plan": plan}, + ) + assert provider is not None + assert isinstance(provider, CdrPlanProvider) + + def test_returns_none_when_option_absent(self): + """No pin β†’ ``None``. Caller short-circuits the ``"named"`` + key registration in ``_providers``.""" + assert build_named_comparator_provider({"cdr_plan": {}}) is None + assert build_named_comparator_provider({}) is None + + def test_returns_none_when_pinned_plan_is_not_a_dict(self): + """Defensive β€” a malformed options entry (string / list / int + ending up in storage) doesn't crash setup; coordinator just + skips the named comparator on this reload.""" + from custom_components.pricehawk.const import CONF_NAMED_COMPARATOR_PLAN + + for bad in ("garbage", 42, [1, 2, 3], None): + assert ( + build_named_comparator_provider( + {CONF_NAMED_COMPARATOR_PLAN: bad}, + ) + is None + ) + + def test_returns_none_when_pinned_plan_is_empty_dict(self): + """An empty dict ``{}`` is treated as "no pin" rather than + constructing a provider over an empty CDR envelope (which + would crash later when the evaluator tries to read + ``electricityContract``).""" + from custom_components.pricehawk.const import CONF_NAMED_COMPARATOR_PLAN + + assert ( + build_named_comparator_provider( + {CONF_NAMED_COMPARATOR_PLAN: {}}, + ) + is None + ) From d455c7183dc0074c60ce949220f13b30369a156c Mon Sep 17 00:00:00 2001 From: ArticOdin Date: Sun, 17 May 2026 22:35:46 +1000 Subject: [PATCH 2/7] =?UTF-8?q?feat(sensor):=20Phase=203.4=20commit=202/2?= =?UTF-8?q?=20=E2=80=94=20NamedComparatorRollupSensor=20=C3=97=205?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the Phase 3.4 commit 1/2 wiring as user-visible HA sensors: - `sensor.pricehawk_named_cost_today` - `sensor.pricehawk_named_cost_week` - `sensor.pricehawk_named_cost_month` - `sensor.pricehawk_named_cost_3month` - `sensor.pricehawk_named_cost_year` Subclasses the Phase 3.3 `PeriodRollupSensor` base; overrides `native_value` + `extra_state_attributes` to read the `"named"` key from `daily_cost_history` rather than extend the base's `_ROLLUP_KIND` dispatch enum (which Phase 3.3 just shipped β€” localise the new behaviour here instead of rewriting the base contract for one extra kind). Registration is CONDITIONAL on `"named" in coordinator._providers`, so users who haven't pinned a plan don't see 5 permanently- unavailable entities clutter their HA UI. Reads `_providers` directly (not `data["providers"]`) so the registration check fires on first setup before the coordinator has populated its data dict. CHANGELOG.md gains a Phase 3.4 block under [Unreleased] documenting the OptionsFlow step, the new sensors, lock/ranking interaction (none), and the persistence-through-rank-churn behaviour (the pin survives the plan dropping out of cheap-rank top-K because it lives in options, not in derived ranking state). strings.json + translations/en.json gain entity blocks for the 5 new sensors. Descriptions call out the tick-by-tick cadence difference vs the other ranked alternatives (which only refresh at daily rollover) so users know what they bought by pinning. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 58 +++++++++++++++ custom_components/pricehawk/sensor.py | 72 +++++++++++++++++++ custom_components/pricehawk/strings.json | 15 ++++ .../pricehawk/translations/en.json | 15 ++++ 4 files changed, 160 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e34383..9d18f14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,64 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Phase 3.4 β€” Named comparator drill-in + +Lets the user pin ONE CDR plan from the ranked alternatives list as a +"named comparator" that runs tick-by-tick (every 30s) alongside the +current plan, instead of only refreshing at the daily rollover. +Surfaces as 5 new rolling-window cost sensors plus a new OptionsFlow +step. + +#### Added + +- **OptionsFlow `named_comparator` step** β€” dropdown of the current + ranked alternatives (sourced from `coordinator.data["ranked_alternatives"]` + + the per-day `_ranking_plan_cache`) plus a "(clear pin)" sentinel. + Aborts with `no_ranked_alternatives` when either the ranked list or + the plan cache is empty (covers the post-install + post-midnight- + cache-reset edge cases). Aborts with `plan_not_in_cache` when the + user's selection no longer maps to a cached body (concurrent eviction). +- **`CONF_NAMED_COMPARATOR_PLAN_ID` + `CONF_NAMED_COMPARATOR_PLAN`** β€” + new option keys. We persist the FULL `PlanDetailV2` body (not the + summarised form from `cdr.ranking.summarize_for_sensor`) because the + evaluator needs the `tariffPeriod` data the summary deliberately omits. +- **`coordinator.build_named_comparator_provider`** β€” module-level pure + helper extracted for unit-testability (same justification as + `build_backfill_plan_set`). Called from both `__init__` AND + `rebuild_engine` so a fresh pin lands on the next + `OptionsFlowWithReload` cycle without an HA restart. +- **Coordinator registers the named provider under the literal + `"named"` key** in `_providers`. The existing tick loop ticks it + every 30s, and the daily rollover writes a `"named"` column into + `daily_cost_history` β€” no new tick path, no new locks. +- **`NamedComparatorRollupSensor` Γ— 5** β€” `today | week | month | + 3month | year` rolling-window cost sensors that read the `"named"` + key from `daily_cost_history`. Registered only when the user has + pinned a plan, so users who haven't opted in don't see five + permanently-unavailable entities. Subclass of the Phase 3.3 + `PeriodRollupSensor` base. +- **`strings.json` + `translations/en.json`** β€” new step + menu entry + + 2 abort reasons + 5 entity name/description blocks. +- 14 new tests: 10 in `tests/test_config_flow_phase_3.py` exercising + the new pure-logic `plan_named_comparator_step` decision tree + (full-body persistence guard, plan_not_in_cache branch, dedupe, + default fallback when prior pin evicted), 4 in + `tests/test_coordinator_helpers.py` pinning the + `build_named_comparator_provider` lifecycle. + +#### Notes + +- **Lock interaction with ranking lock: none.** The named comparator + joins the existing tick loop unchanged. The OptionsFlow step reads + `_ranking_plan_cache` without holding the ranking lock β€” safe + because the worst-case torn read resolves to the existing abort + path and re-prompts the user. +- **Persistent through ranking churn**: if the pinned plan drops out + of the cheap-rank top-K two weeks later (rate changes), the named + pin keeps showing β€” it's stored in options, not derived from + ranking. Backfill includes it via `build_backfill_plan_set` so + historical reads are continuous. + ### Phase 3.2 β€” Universal HA-history backfill Replaces the Amber-API-only backfill with a multi-plan replay over the diff --git a/custom_components/pricehawk/sensor.py b/custom_components/pricehawk/sensor.py index 3391fc3..a477d7d 100644 --- a/custom_components/pricehawk/sensor.py +++ b/custom_components/pricehawk/sensor.py @@ -743,6 +743,65 @@ class SavingsRollupSensor(PeriodRollupSensor): _METRIC_LABEL = "Savings" +class NamedComparatorRollupSensor(PeriodRollupSensor): + """Phase 3.4 β€” rolling cost on the user-pinned named comparator plan. + + The named comparator is registered in ``coordinator._providers`` + under the literal ``"named"`` key when the user pins a plan via + the OptionsFlow ``named_comparator`` step (see Phase 3.4 commit + 1/2). The daily rollover loop writes that key's cost to + ``daily_cost_history``, and this sensor sums it across the + rolling window β€” same windowing as the other rollup sensors so + dashboards line up exactly. + + Overrides ``native_value`` and ``extra_state_attributes`` rather + than extending the base's ``_ROLLUP_KIND`` dispatch β€” the kinds + enum is documented at the base-class level and Phase 3.3 just + shipped, so we localise the new behaviour here instead of + rewriting that contract for one extra kind. + + Skipped at registration time when ``"named"`` isn't in + ``coordinator._providers`` β€” see ``async_setup_entry``. + """ + + _ROLLUP_KIND = "named" + _METRIC_LABEL = "Named Comparator Cost" + + @property + def native_value(self) -> float | None: + from .cdr.rollup import ( # noqa: PLC0415 + WindowName, + filter_window, + sum_window, + ) + history = self.coordinator.data.get("daily_cost_history") or [] + rows = filter_window( + history, cast(WindowName, self._window), now=dt_util.now() + ) + if not rows: + return None + value, _ = sum_window(rows, "named") + return value + + @property + def extra_state_attributes(self) -> dict[str, Any]: + from .cdr.rollup import ( # noqa: PLC0415 + WindowName, + filter_window, + sum_window, + ) + history = self.coordinator.data.get("daily_cost_history") or [] + rows = filter_window( + history, cast(WindowName, self._window), now=dt_util.now() + ) + _, day_count = sum_window(rows, "named") + return { + "window": self._window, + "days_in_window": day_count, + "plan_key": "named", + } + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -854,5 +913,18 @@ async def async_setup_entry( entities.append(BestAlternativeRollupSensor(coordinator, entry, window)) entities.append(SavingsRollupSensor(coordinator, entry, window)) + # Phase 3.4 β€” 5 named-comparator rollup sensors, but ONLY when the + # user has pinned a plan via the OptionsFlow ``named_comparator`` + # step. Skipped otherwise so we don't litter HA with five + # permanently-unavailable entities for users who haven't opted in. + # Reading from ``_providers`` directly (not ``data["providers"]``) + # so the registration check fires on first setup before the + # coordinator has populated its data dict. + if "named" in getattr(coordinator, "_providers", {}): + for window in ("today", "week", "month", "3month", "year"): + entities.append( + NamedComparatorRollupSensor(coordinator, entry, window) + ) + _LOGGER.info("Registering %d PriceHawk sensor entities", len(entities)) async_add_entities(entities) diff --git a/custom_components/pricehawk/strings.json b/custom_components/pricehawk/strings.json index 709ec94..f7a7656 100644 --- a/custom_components/pricehawk/strings.json +++ b/custom_components/pricehawk/strings.json @@ -477,6 +477,21 @@ }, "savings_cost_year": { "name": "Savings Year" + }, + "named_cost_today": { + "name": "Named Comparator Cost Today" + }, + "named_cost_week": { + "name": "Named Comparator Cost Week" + }, + "named_cost_month": { + "name": "Named Comparator Cost Month" + }, + "named_cost_3month": { + "name": "Named Comparator Cost 3 Month" + }, + "named_cost_year": { + "name": "Named Comparator Cost Year" } } } diff --git a/custom_components/pricehawk/translations/en.json b/custom_components/pricehawk/translations/en.json index 709ec94..f7a7656 100644 --- a/custom_components/pricehawk/translations/en.json +++ b/custom_components/pricehawk/translations/en.json @@ -477,6 +477,21 @@ }, "savings_cost_year": { "name": "Savings Year" + }, + "named_cost_today": { + "name": "Named Comparator Cost Today" + }, + "named_cost_week": { + "name": "Named Comparator Cost Week" + }, + "named_cost_month": { + "name": "Named Comparator Cost Month" + }, + "named_cost_3month": { + "name": "Named Comparator Cost 3 Month" + }, + "named_cost_year": { + "name": "Named Comparator Cost Year" } } } From 507db8b14543d6e7846b14bc72b51cde3fa8de15 Mon Sep 17 00:00:00 2001 From: ArticOdin Date: Mon, 18 May 2026 00:45:47 +1000 Subject: [PATCH 3/7] fix(named): persist provider across restart + skip unique_id collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CodeRabbit findings on the Phase 3.4 named comparator. 1. The named provider added to `self._providers["named"]` was constructed in `__init__` and `_apply_options_update` but never persisted or restored. Every other CdrPlanProvider in the coordinator (current plan, amber, flow_power, localvolts) round- trips through `_async_persist_state` / `async_restore_state`, so on HA restart their per-day accumulators (import_cost_today, kwh) survive β€” but `named` would reset to zero while the others kept state, so today's rollup deltas would lie until midnight rollover. Added named.to_dict() to the persist block and a from_dict() call in restore, using `today = dt_util.now().date()` (already in scope above) to satisfy the AEGIS rule that from_dict MUST receive an explicit HA-timezone date (no `date.today()` fallback). 2. `GenericProviderCostSensor(provider_id="named")` produces unique_id `{entry}_named_cost_today`. `NamedComparatorRollupSensor` for the "today" window produces the same unique_id (since its _ROLLUP_KIND is "named" and the base class composes `{kind}_cost_{window}`). HA's entity registry drops the second registration silently, breaking whichever sensor the dashboard depends on first. Added a `provider_id == "named"` skip in the providers loop in `async_setup_entry` β€” the named comparator is exposed via its dedicated 5-window rollup family instead. Also skips the matching GenericProviderRateSensor pair so we don't register orphan rate sensors for the "named" key. (Only one such loop exists; the Phase 3.3 rollup loop and Phase 3.4 named-rollup loop don't have the same collision since their _ROLLUP_KIND differs.) Co-Authored-By: Claude Opus 4.7 (1M context) --- custom_components/pricehawk/coordinator.py | 14 ++++++++++++++ custom_components/pricehawk/sensor.py | 10 ++++++++++ 2 files changed, 24 insertions(+) diff --git a/custom_components/pricehawk/coordinator.py b/custom_components/pricehawk/coordinator.py index 8b7f8c0..e5fea88 100644 --- a/custom_components/pricehawk/coordinator.py +++ b/custom_components/pricehawk/coordinator.py @@ -1097,6 +1097,13 @@ async def async_restore_state(self) -> None: self._flow_power.from_dict(stored["flow_power"], today=today) if self._localvolts is not None and stored.get("localvolts"): self._localvolts.from_dict(stored["localvolts"], today=today) + # Phase 3.4: restore the named-comparator accumulator. + # `today` is `dt_util.now().date()` above β€” HA-timezone + # aware per the AEGIS rule that from_dict MUST receive an + # explicit HA-timezone date (no `date.today()` fallback). + if self._named_comparator is not None and stored.get("named"): + self._named_comparator.from_dict(stored["named"], today=today) + _LOGGER.debug("Restored named comparator provider state") # Restore cached rates if stored.get("amber_import_c") is not None: @@ -1295,6 +1302,13 @@ async def async_persist_state(self) -> None: data["localvolts"] = self._localvolts.to_dict() data["localvolts_import_c"] = self._localvolts_import_c data["localvolts_export_c"] = self._localvolts_export_c + # Phase 3.4: persist the named-comparator provider so its + # accumulator survives HA restart. Without this, the named + # provider's import_cost_today / kwh would reset to zero on + # every restart while the active and Amber providers keep + # their state β€” the rollup deltas would lie. + if self._named_comparator is not None: + data["named"] = self._named_comparator.to_dict() if self._last_explanation is not None: data["last_explanation"] = self._last_explanation await self._store.async_save(data) diff --git a/custom_components/pricehawk/sensor.py b/custom_components/pricehawk/sensor.py index a477d7d..dcaa796 100644 --- a/custom_components/pricehawk/sensor.py +++ b/custom_components/pricehawk/sensor.py @@ -867,6 +867,16 @@ async def async_setup_entry( for provider_id, snap in providers_block.items(): if provider_id == current_plan_id: continue + # Phase 3.4: avoid unique_id collision with NamedComparatorRollupSensor. + # The "named" provider is exposed via its own rollup sensor family + # (NamedComparatorRollupSensor for each window); a + # GenericProviderCostSensor for it would clash on + # "named_cost_today" and one of the two would be dropped from the + # entity registry. Skip the rate sensors too so we don't litter + # HA with three duplicate-looking entities β€” the rollup family + # covers the dashboard's needs for the named comparator. + if provider_id == "named": + continue provider_name = snap.get("name", provider_id.title()) entities.append( GenericProviderRateSensor( From b088165d040294b538c8b8c33bb4d656cfffa3b7 Mon Sep 17 00:00:00 2001 From: ArticOdin Date: Sun, 17 May 2026 22:42:08 +1000 Subject: [PATCH 4/7] =?UTF-8?q?feat(dashboard):=20Phase=203.5=20commit=201?= =?UTF-8?q?/3=20=E2=80=94=20strip=20Amber=20chrome,=20scaffold=20multi-pla?= =?UTF-8?q?n=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full rewrite of custom_components/pricehawk/www/dashboard.html (2447 -> 940 LOC) replacing the Amber-vs-current-plan two-comparator view with the multi-plan ranked layout from plan section 5.1. Visual language ported from assets/dashboard-v3-apple.html (dark default, Outfit + IBM Plex Mono, noise + ambient bg). Per-provider colour tokens (--amber-primary, --globird-primary) replaced with semantic ones (--accent-positive, --accent-negative, --accent-neutral) per the Phase 3.0 pivot away from provider-specific branding. Scaffold layout per plan section 5.1: - NAV bar (brand + connection status + clock + theme toggle) - HERO row: current cost card + savings-vs-best-alt card - PERIOD TABS: [Today][Week][Month*][3 Month][Year], active swaps data - RANKED ALTERNATIVES table: #/plan/peak/supply/saving, click -> drill - DRILL-IN CARD: per-plan stats + "Pin as Named Comparator" button - DATA HEALTH FOOTER: backfill state / days loaded / last ranking / count Entity reads are NOT wired yet β€” sample data renders so the scaffold is visually verifiable before commit 3.5/2 binds real sensor values. WebSocket connection logic copied verbatim from the previous dashboard: - WS URL derived from location.protocol (AEGIS rule: never hardcode ws://) - Token from URL params, postMessage, parent.hassConnection, or localStorage hassTokens (AEGIS rule: never hardcode the token) CSP connect-src extended to include ws(s)://*.local:* so the dashboard works on Ryan's HA Green at homeassistant.local (plan section 5.3 surprise #1). Existing localhost + Nabu Casa entries preserved. Active period tab persists to localStorage so re-opens land on the user's last view rather than defaulting to month every time. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../pricehawk/www/dashboard.html | 2751 ++++------------- 1 file changed, 622 insertions(+), 2129 deletions(-) diff --git a/custom_components/pricehawk/www/dashboard.html b/custom_components/pricehawk/www/dashboard.html index d316d06..41b3fde 100644 --- a/custom_components/pricehawk/www/dashboard.html +++ b/custom_components/pricehawk/www/dashboard.html @@ -3,8 +3,16 @@ - -PriceHawk Dashboard + + +PriceHawk @@ -12,95 +20,81 @@ @@ -636,469 +485,181 @@
- + -
- - -
- - -
-
-
-
BEST RATE RIGHT NOW
-
---
-
-
Cheapest right now
-
-
--.-c/kWh
-
-
-
-
- Cheap - Average - Expensive -
-
-
-
TODAY'S SAVING
-
$0.00
-
-
-
MONTH SAVING
-
$0.00
-
-
-
WIN %
-
--%
-
-
-
LAST UPDATED
-
--:--
-
-
-
- - -
-
-
- - Current Rates -
-
- -
-
-
-
Amber Electric
- Wholesale -
-
- Import - 0.00 c/kWh -
-
- Feed-in - 0.00 c/kWh -
-
- -
-
-
-
GloBird Energy
- Shoulder -
-
- Import - 0.00 c/kWh -
-
- Feed-in - 0.00 c/kWh -
-
-
-
- - -
- - -
-
-
- - Rate Comparison -
-
TODAY
-
-
- -
-
--:--
-
Amber--
-
GloBird--
-
-
-
-
Amber Import
-
GloBird Import
-
Amber Feed-in
-
GloBird Feed-in
-
Forecast (dashed)
-
-
-
-
AMBER TOTAL
-
$0.00
-
-
-
GLOBIRD TOTAL
-
$0.00
-
-
-
DIFFERENCE
-
$0.00
-
-
-
CHEAPEST TODAY
-
---
-
-
-
- - -
-
-
- - Cost Breakdown -
-
-
- - -
-
- -
-
-
- - -
- - -
-
-
- - GloBird Incentives -
-
- -
-
- ZEROHERO Credit - Pending -
-
$1/day credit if grid imports stay low 6-8pm
-
- -
-
- Super Export - Tracking -
-
15c/kWh bonus for exports during 6-8pm window
-
-
-
-
- 0.0 kWh - / 15.0 kWh -
-
- -
-
- Free Power Window - Inactive -
-
Free electricity during promotional windows
-
- -
-
- Critical Peak - No event -
-
Demand response events with bonus credits
-
-
- - -
-
-
- - Savings History -
-
--
-
-
- - - - -
-
-
Amber won
-
GloBird won
-
-
-
- History will appear after the first full day -
-
-
- BEST DAY - -- -
-
+
+ + +
+ +
+
This month Β· Current plan
+
$--
+
+ on + β€” +
+
+ + +
+
Savings this month Β· vs best alternative
+
$--
+
+ Best alt: + β€” +
+
Projected annual: $β€”
+
- -
From 7e0f3b0ae8f4f20640106c32786394b4e65502d2 Mon Sep 17 00:00:00 2001 From: ArticOdin Date: Sun, 17 May 2026 22:46:44 +1000 Subject: [PATCH 5/7] =?UTF-8?q?feat(dashboard):=20Phase=203.5=20commit=202?= =?UTF-8?q?/3=20=E2=80=94=20wire=20rollup=20+=20ranked=20+=20backfill=20en?= =?UTF-8?q?tity=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hooks the scaffold from commit 3.5/1 up to the Phase 3.2 / 3.3 / 3.4 sensors. After WebSocket auth completes, fires a get_states + subscribes to state_changed events for the 16 tracked entities. Tracked entities (per plan section 5.2 + Phase 3.3 / 3.4 worker notes): - 5 x sensor.pricehawk_current_cost_{today,week,month,3month,year} - 5 x sensor.pricehawk_best_alt_cost_{...} (NOT _best_alternative_cost_) - 5 x sensor.pricehawk_savings_{...} - 5 x sensor.pricehawk_named_cost_{...} (NOT _named_comparator_cost_) - sensor.pricehawk_ranked_alternatives - sensor.pricehawk_backfill_status Hero row binding: - Current cost card reads sensor.pricehawk_current_cost_. - Savings card reads sensor.pricehawk_savings_ and colours green / red / muted around the +/- $0.005 deadband. - Best-alt name pulled from ranked_alternatives.attributes.alternatives[0] (sensor is sorted ascending by cheap-rank score per summarize_for_sensor). - Projected annual extrapolates active-window savings * 365/window_days. Period tabs swap activeWindow and re-call renderHero() β€” all rollup bindings re-evaluate against the new window's entity ID. Active class mirrors localStorage so the tab UI stays in sync on cold loads. Ranked alts table render: - Pulls ranked_alternatives.attributes.alternatives[]. - Renders rank-pill (#1 gold), plan name + brand, peak rate, supply, saving. Saving column only fills for the #1 plan (the cheapest); #2..N show "β€”" because we don't have per-alt cost rollups β€” only the best-alt rollup. Avoids fabricating numbers that don't match the sensor. - Click row β†’ drill-in card slides up below + plan ID persists in selectedPlanId so re-renders after state_changed events preserve selection. - Empty state ("Waiting for the daily ranking job…") covers first-install before the first ranking run completes. Drill-in card: - Stats grid: peak rate, daily supply, customer type, plan ID, cheap-rank score (when present). - "Pin as Named Comparator" deep-links to the integration's Configure page (/config/integrations/integration/pricehawk). Per plan section 5.3 surprise #2 + plan section 9 REVISIT 4: HA doesn't support per-step deep-linking; the deep-link is the locked UX for this phase. Data Health footer renders backfill state with state-coloured value (green=complete / amber=running / red=failed / muted=idle), days_loaded, ranked_alternatives.last_run as relative + absolute time, and the alternatives count. Empty-state UI for first-run users (plan section 5.3 surprise #3): when backfill_status.days_loaded < 7, hero rollup values are replaced with an "Accruing… [n/365]" pill instead of showing $0.00 β€” surfaces clearly that we don't have enough history yet rather than implying zero spend. XSS hardening: all attribute-sourced strings (plan_id, display_name, brand, customer_type) pass through escapeHtml() before innerHTML insertion. Catches any future CDR registry payloads that include HTML-ish characters in brand names. 30s setInterval re-renders the ranked + footer cards so the relative timestamps ("ran 27s ago / 3h ago") tick forward without waiting for the next state_changed event. TDZ fix: the period-tab boot block previously called setActiveWindow() before the entity state store consts were declared, which tripped a ReferenceError on attrs in strict mode. Boot now defers the first full render to the explicit boot block at the script bottom. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../pricehawk/www/dashboard.html | 419 +++++++++++++++--- 1 file changed, 368 insertions(+), 51 deletions(-) diff --git a/custom_components/pricehawk/www/dashboard.html b/custom_components/pricehawk/www/dashboard.html index 41b3fde..9080ad1 100644 --- a/custom_components/pricehawk/www/dashboard.html +++ b/custom_components/pricehawk/www/dashboard.html @@ -549,7 +549,7 @@
--
- +
@@ -560,30 +560,13 @@ - - - - - - - - - - - - - - - - - - - - - - + +
+ Waiting for the daily ranking job… +
Alternatives appear once the first ranking run completes.
+
@@ -659,6 +642,32 @@ const navStatus = $('navStatus'); const statusText = $('statusText'); +// ─────────────── Entity IDs ─────────────── +// Phase 3.3 + 3.4 worker notes: entity IDs use `best_alt_cost_*` and +// `named_cost_*` (NOT `best_alternative_cost_*` / `named_comparator_cost_*`). +const WINDOWS = ['today', 'week', 'month', '3month', 'year']; +const ENTITY = { + rankedAlternatives: 'sensor.pricehawk_ranked_alternatives', + backfillStatus: 'sensor.pricehawk_backfill_status', +}; +for (const w of WINDOWS) { + ENTITY[`current_${w}`] = `sensor.pricehawk_current_cost_${w}`; + ENTITY[`bestAlt_${w}`] = `sensor.pricehawk_best_alt_cost_${w}`; + ENTITY[`savings_${w}`] = `sensor.pricehawk_savings_${w}`; + ENTITY[`named_${w}`] = `sensor.pricehawk_named_cost_${w}`; +} +const TRACKED_ENTITIES = new Set(Object.values(ENTITY)); + +// Empty-state threshold (plan section 5.3 surprise #3): show "Accruing…" +// instead of zero-valued rollups until we have at least this many days +// of backfill history. +const MIN_DAYS_FOR_ROLLUPS = 7; + +// Backfill history target β€” surfaced in the "Accruing" pill so the user +// can see how far through the recorder history we are. Matches the +// default `purge_keep_days: 365` power-user target referenced in the plan. +const BACKFILL_TARGET_DAYS = 365; + // ─────────────── Theme toggle ─────────────── function initTheme() { const saved = localStorage.getItem('pricehawk-theme'); @@ -719,13 +728,17 @@ periodTabs.forEach((btn) => { btn.classList.toggle('active', btn.dataset.window === win); }); - // 3.5/1: re-render hero label text only (real rebind in 3.5/2). renderHeroLabels(); + renderHero(); } periodTabs.forEach((btn) => { btn.addEventListener('click', () => setActiveWindow(btn.dataset.window)); + btn.classList.toggle('active', btn.dataset.window === activeWindow); }); -setActiveWindow(activeWindow); +// First full render (which calls renderHero -> entityAttrs -> attrs{}) is +// deferred to the boot block at the bottom of the script, AFTER the entity +// state store is declared. Calling setActiveWindow here would trip a +// const-TDZ on `attrs`. function windowLabel(win) { switch (win) { @@ -763,16 +776,7 @@ }); } -// ─────────────── Sample data renderers (3.5/1 placeholders) ─────────────── -// Commit 3.5/2 replaces these with real entity-state reads from the -// WebSocket subscription. Keeping them here lets the scaffold render -// something visible immediately after page load even before auth completes. -const SAMPLE_RANKED = [ - { plan_id: 'SAMPLE-1', display_name: 'Value Saver', brand: 'Sample Retailer', peak_c_per_kwh: 28.2, supply_c_per_day: 110.0, saving: 45 }, - { plan_id: 'SAMPLE-2', display_name: 'Predictable Plan', brand: 'Sample Retailer', peak_c_per_kwh: 29.1, supply_c_per_day: 105.0, saving: 38 }, - { plan_id: 'SAMPLE-3', display_name: 'Standard TOU', brand: 'Sample Retailer', peak_c_per_kwh: 31.4, supply_c_per_day: 98.0, saving: 22 }, -]; - +// ─────────────── Formatters ─────────────── function fmtDollar(v, { signed = false } = {}) { if (v == null || isNaN(v)) return '$--'; const n = Number(v); @@ -783,19 +787,228 @@ if (v == null || isNaN(v)) return '--'; return Number(v).toFixed(1) + 'c'; } +function fmtIntOrDash(v) { + if (v == null || isNaN(v)) return '--'; + return String(Math.round(Number(v))); +} +function fmtRelativeTime(iso) { + if (!iso) return '--'; + const t = Date.parse(iso); + if (isNaN(t)) return '--'; + const deltaSec = Math.round((Date.now() - t) / 1000); + if (deltaSec < 60) return `${deltaSec}s ago`; + if (deltaSec < 3600) return `${Math.round(deltaSec / 60)}m ago`; + if (deltaSec < 86400) return `${Math.round(deltaSec / 3600)}h ago`; + return `${Math.round(deltaSec / 86400)}d ago`; +} +function escapeHtml(s) { + if (s == null) return ''; + return String(s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + +// ─────────────── Entity state store ─────────────── +const states = {}; // entity_id -> state string +const attrs = {}; // entity_id -> attributes object +let selectedPlanId = null; + +function entityState(id) { + const s = states[id]; + if (s == null || s === 'unknown' || s === 'unavailable') return null; + return s; +} +function entityNumber(id) { + const s = entityState(id); + if (s == null) return null; + const n = parseFloat(s); + return isNaN(n) ? null : n; +} +function entityAttrs(id) { + return attrs[id] || {}; +} + +// ─────────────── Empty-state detection ─────────────── +// Plan section 5.3 surprise #3: first-run users (< MIN_DAYS_FOR_ROLLUPS +// days of backfill) should see an "Accruing…" hint, not a misleading $0.00. +function isAccruing() { + const days = entityAttrs(ENTITY.backfillStatus).days_loaded; + if (days == null) return true; + return Number(days) < MIN_DAYS_FOR_ROLLUPS; +} +function accruingDays() { + const days = entityAttrs(ENTITY.backfillStatus).days_loaded; + return days == null ? 0 : Number(days); +} + +// ─────────────── Hero render ─────────────── +function bestAlternativeName() { + const list = entityAttrs(ENTITY.rankedAlternatives).alternatives; + if (!Array.isArray(list) || list.length === 0) return null; + // Sensor attribute is sorted ascending by cheap-rank score + // (lower = better), per cdr/ranking.summarize_for_sensor docs. + const top = list[0]; + if (!top) return null; + const brand = top.brand ? top.brand + ' ' : ''; + const name = top.display_name || top.plan_id || 'Top alternative'; + return brand + name; +} + +function renderHero() { + const accruing = isAccruing(); + // Current cost. + const currentCost = entityNumber(ENTITY[`current_${activeWindow}`]); + const currentEl = $('heroCurrentCost'); + if (accruing) { + currentEl.innerHTML = + 'Accruing… ' + + accruingDays() + ' / ' + BACKFILL_TARGET_DAYS + ''; + } else { + currentEl.textContent = fmtDollar(currentCost); + } + + // Current plan name (lifted from ranked alternatives' "named comparator + // present?" signal or fallback). The current plan's display name isn't + // exposed by the rollup sensors directly; we use the named comparator + // when set, otherwise fall back to a generic label. + const namedSet = entityState(ENTITY[`named_${activeWindow}`]) != null; + $('heroCurrentPlanName').textContent = namedSet + ? 'your pinned plan' + : 'your current plan'; + + // Savings. + const savings = entityNumber(ENTITY[`savings_${activeWindow}`]); + const savingsEl = $('heroSavings'); + savingsEl.classList.remove('positive', 'negative', 'neutral'); + if (accruing) { + savingsEl.innerHTML = + 'Accruing… ' + + accruingDays() + ' / ' + BACKFILL_TARGET_DAYS + ''; + savingsEl.classList.add('neutral'); + } else if (savings == null) { + savingsEl.textContent = '$--'; + savingsEl.classList.add('neutral'); + } else { + savingsEl.textContent = fmtDollar(savings, { signed: true }); + if (savings > 0.005) savingsEl.classList.add('positive'); + else if (savings < -0.005) savingsEl.classList.add('negative'); + else savingsEl.classList.add('neutral'); + } + + // Best-alt name. + const altName = bestAlternativeName(); + $('heroBestAltName').textContent = altName || 'β€”'; + + // Projected annual (extrapolate the active window to 12 months). + // Only meaningful when we have a savings number AND we're not in the + // "accruing" state β€” otherwise display a dash. + const annualEl = $('heroAnnualLine'); + if (accruing || savings == null) { + annualEl.textContent = 'Projected annual: $β€”'; + } else { + const scale = annualMultiplier(activeWindow); + if (scale == null) { + annualEl.textContent = 'Projected annual: $β€”'; + } else { + annualEl.textContent = + 'Projected annual: ' + fmtDollar(savings * scale, { signed: true }); + } + } +} + +function annualMultiplier(win) { + // 365 / window_days. Year is 1x (already a year). + switch (win) { + case 'today': return 365; + case 'week': return 365 / 7; + case 'month': return 365 / 30; + case '3month': return 365 / 90; + case 'year': return 1; + default: return null; + } +} -// Wire row click on the existing sample rows so the drill-in card is -// reachable in the scaffold preview. Replaced in 3.5/2 by data-driven render. -document.querySelectorAll('.ranked-table tbody tr').forEach((tr, i) => { - tr.addEventListener('click', () => { - document.querySelectorAll('.ranked-table tbody tr.selected').forEach((row) => row.classList.remove('selected')); - tr.classList.add('selected'); - const sample = SAMPLE_RANKED[i]; - if (!sample) return; - renderDrill(sample); +// ─────────────── Ranked alternatives render ─────────────── +function renderRanked() { + const altList = entityAttrs(ENTITY.rankedAlternatives).alternatives; + const lastRun = entityAttrs(ENTITY.rankedAlternatives).last_run; + const tbody = $('rankedTbody'); + const table = $('rankedTable'); + const empty = $('rankedEmpty'); + $('rankedMeta').textContent = lastRun ? 'Ranked ' + fmtRelativeTime(lastRun) : ''; + + if (!Array.isArray(altList) || altList.length === 0) { + table.hidden = true; + empty.style.display = 'block'; + tbody.innerHTML = ''; + return; + } + table.hidden = false; + empty.style.display = 'none'; + + // Current monthly cost β€” used to compute a saving-vs-current per row. + // Falls back to null (saving column shows '--') when we don't yet have + // a current-cost rollup. + const currentMonth = entityNumber(ENTITY.current_month); + + let html = ''; + altList.forEach((alt, i) => { + const rank = i + 1; + const pillCls = rank === 1 ? 'rank-pill gold' : 'rank-pill'; + const saving = computeSavingForAlt(alt, currentMonth); + const savingCellHtml = (saving == null) + ? 'β€”' + : '' + + fmtDollar(saving, { signed: true }) + ''; + + html += + '' + + '' + rank + '' + + '' + + '
' + escapeHtml(alt.display_name || alt.plan_id || 'Plan') + '
' + + '
' + escapeHtml(alt.brand || '') + '
' + + '' + + '' + fmtCents(alt.peak_c_per_kwh) + '' + + '' + fmtCents(alt.supply_c_per_day) + '' + + savingCellHtml + + ''; }); -}); + tbody.innerHTML = html; + + // Attach row click handlers. + tbody.querySelectorAll('tr').forEach((tr) => { + const planId = tr.dataset.planId; + if (planId === selectedPlanId) tr.classList.add('selected'); + tr.addEventListener('click', () => { + const alt = altList.find((a) => a.plan_id === planId); + if (!alt) return; + tbody.querySelectorAll('tr.selected').forEach((row) => row.classList.remove('selected')); + tr.classList.add('selected'); + selectedPlanId = planId; + renderDrill(alt); + }); + }); +} + +function computeSavingForAlt(alt, currentMonth) { + // We don't have per-alt monthly cost rollups (only the BEST-alt rollup + // exists), so we surface the project-level savings figure only for the + // top-ranked plan. Lower-ranked plans show 'β€”' rather than fabricate a + // number that doesn't match the rollup sensor. + if (currentMonth == null) return null; + const bestAltMonth = entityNumber(ENTITY.bestAlt_month); + if (bestAltMonth == null) return null; + // Only the #1 (cheapest) plan can claim the full month-saving figure; + // for #2..N we report null. Avoids implying lower-ranked plans deliver + // the same saving as #1. + if (!alt || alt.plan_id == null) return null; + const list = entityAttrs(ENTITY.rankedAlternatives).alternatives; + if (!Array.isArray(list) || list.length === 0) return null; + if (list[0].plan_id !== alt.plan_id) return null; + return currentMonth - bestAltMonth; +} +// ─────────────── Drill-in card ─────────────── function renderDrill(alt) { $('drillTitle').textContent = alt.display_name || alt.plan_id || 'Plan'; $('drillBrand').textContent = alt.brand || ''; @@ -805,25 +1018,75 @@ { label: 'Peak rate', value: fmtCents(alt.peak_c_per_kwh), sub: 'inc GST' }, { label: 'Daily supply', value: fmtCents(alt.supply_c_per_day), sub: 'per day' }, { label: 'Customer type', value: alt.customer_type || 'RESIDENTIAL', sub: '' }, - { label: 'Plan ID', value: (alt.plan_id || '--').slice(0, 18), sub: '' }, + { label: 'Plan ID', value: (alt.plan_id || '--').toString().slice(0, 22), sub: '' }, ]; + if (alt.score != null) { + cells.push({ + label: 'Cheap-rank score', + value: Number(alt.score).toFixed(3), + sub: 'lower is cheaper', + }); + } for (const c of cells) { const div = document.createElement('div'); div.className = 'drill-stat'; div.innerHTML = - '
' + c.label + '
' + - '
' + c.value + '
' + - (c.sub ? '
' + c.sub + '
' : ''); + '
' + escapeHtml(c.label) + '
' + + '
' + escapeHtml(c.value) + '
' + + (c.sub ? '
' + escapeHtml(c.sub) + '
' : ''); stats.appendChild(div); } // Deep-link to PriceHawk's Configure page in HA. Per plan section 5.3 // surprise #2, HA doesn't support per-step deep-linking; we land users - // on the integration page where they tap "Configure β†’ Pin". + // on the integration page where they tap "Configure β†’ Pin as Named + // Comparator". Locked in plan section 9 REVISIT 4. $('drillPinBtn').href = '/config/integrations/integration/pricehawk'; drillCard.classList.add('open'); drillCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } +// ─────────────── Data Health Footer ─────────────── +function renderFooter() { + const bfState = entityState(ENTITY.backfillStatus) || 'idle'; + const bfAttrs = entityAttrs(ENTITY.backfillStatus); + + const stateEl = $('footerBackfillState'); + stateEl.textContent = bfState; + stateEl.classList.remove('muted', 'ok', 'warn', 'err'); + if (bfState === 'complete') stateEl.classList.add('ok'); + else if (bfState === 'running') stateEl.classList.add('warn'); + else if (bfState === 'failed') stateEl.classList.add('err'); + else stateEl.classList.add('muted'); + $('footerBackfillSub').textContent = bfAttrs.error + ? String(bfAttrs.error).slice(0, 80) + : (bfAttrs.last_run ? 'Ran ' + fmtRelativeTime(bfAttrs.last_run) : 'not yet run'); + + $('footerDaysLoaded').textContent = fmtIntOrDash(bfAttrs.days_loaded); + $('footerDaysSub').textContent = isAccruing() + ? 'accruing β€” need ' + MIN_DAYS_FOR_ROLLUPS + '+ for rollups' + : 'history window'; + + const lastRun = entityAttrs(ENTITY.rankedAlternatives).last_run; + $('footerLastRanking').textContent = lastRun ? fmtRelativeTime(lastRun) : '--'; + $('footerLastRankingSub').textContent = lastRun + ? new Date(lastRun).toLocaleString('en-AU', { hour12: false }) + : 'awaiting first run'; + + const altsCount = entityNumber(ENTITY.rankedAlternatives); + $('footerAltsCount').textContent = fmtIntOrDash(altsCount); +} + +// ─────────────── Master render dispatcher ─────────────── +function onStateUpdate(entityId) { + if (!TRACKED_ENTITIES.has(entityId)) return; + // Heuristic: every entity change can affect more than one card, but + // re-rendering all four cards costs <1ms on the device we deploy to + // (HA Green / iPhone) so we don't bother with per-entity dispatch. + renderHero(); + renderRanked(); + renderFooter(); +} + // ─────────────── WebSocket connection ─────────────── // Re-uses the existing dashboard's multi-method auth fallback chain: // 1) URL ?token= (set by dashboard_config.setup_panel_iframe) @@ -899,7 +1162,8 @@ if (msg.type === 'auth_ok') { setConnected(true, 'Connected'); - // 3.5/2 will subscribe + fetch states here. + fetchStates(); + subscribeStateChanges(); return; } @@ -915,6 +1179,35 @@ if (msg.success) cb(msg.result); return; } + + // Initial get_states response (Array<{entity_id, state, attributes}>). + if (msg.type === 'result' && msg.success && Array.isArray(msg.result)) { + let touched = false; + for (const e of msg.result) { + if (e && e.entity_id && TRACKED_ENTITIES.has(e.entity_id)) { + states[e.entity_id] = e.state; + attrs[e.entity_id] = e.attributes || {}; + touched = true; + } + } + if (touched) { + renderHero(); + renderRanked(); + renderFooter(); + } + return; + } + + // Subscribed state_changed event. + if (msg.type === 'event' && msg.event && msg.event.event_type === 'state_changed') { + const d = msg.event.data; + if (d && d.entity_id && TRACKED_ENTITIES.has(d.entity_id) && d.new_state) { + states[d.entity_id] = d.new_state.state; + attrs[d.entity_id] = d.new_state.attributes || {}; + onStateUpdate(d.entity_id); + } + return; + } }; ws.onclose = () => { @@ -932,8 +1225,32 @@ }, reconnectDelay); } +function fetchStates() { + ws.send(JSON.stringify({ id: msgId++, type: 'get_states' })); +} +function subscribeStateChanges() { + ws.send(JSON.stringify({ + id: msgId++, + type: 'subscribe_events', + event_type: 'state_changed', + })); +} + +// Periodic re-render so the "Ran 27s ago / 3h ago" relative timestamps +// in the footer + ranked-meta tick forward without waiting for the next +// state_changed event. 30s matches the coordinator tick cadence. +setInterval(() => { + renderRanked(); + renderFooter(); +}, 30000); + // ─────────────── Boot ─────────────── renderHeroLabels(); +// Render once on cold load so the empty-state ("Waiting for the daily +// ranking job…") is visible even before WebSocket auth completes. +renderHero(); +renderRanked(); +renderFooter(); connect(); From a29cc2c920452072e76d2cebcefa1a9aa9f36686 Mon Sep 17 00:00:00 2001 From: ArticOdin Date: Sun, 17 May 2026 22:48:39 +1000 Subject: [PATCH 6/7] =?UTF-8?q?feat(dashboard):=20Phase=203.5=20commit=203?= =?UTF-8?q?/3=20=E2=80=94=20design-spec=20divergence=20+=20CHANGELOG=20ent?= =?UTF-8?q?ry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps Phase 3.5 with the two non-code deliverables called out in plan section 5.2 commit 3.5/3. assets/DESIGN.claude.md: - New "PriceHawk Dashboard (divergence from this spec)" section at the end of the file. Explains WHY PriceHawk doesn't follow the Claude marketing-site spec (different surface context, different information density, different brand) and WHAT it does inherit (typographic rationale, card-as-surface model, accent-discipline rule). - Documents the PriceHawk token map (--bg-base, --accent-positive etc) for cross-reference. - Keeps the rest of the Claude marketing-site spec intact β€” no edits outside the new appended section. CHANGELOG.md: - Phase 3.5 block at the top of [Unreleased] above the existing 3.4 entry. Documents the dashboard rewrite (entity bindings, period-tab swap, ranked alts render, drill-in, footer, empty-state), the CSP connect-src extension for *.local deployments, the deleted per-provider colour tokens, the deleted Amber-specific cards, and the manual-UAT-only test strategy (per plan section 6.3 table). dashboard_config.py: NO behavioural change. Plan section 5.2 commit 3.5/3 calls for a "verify cache-busting still works" check; verified that `?v=.` is appended in setup_panel_iframe and is independent of dashboard.html contents β€” the rewrite doesn't affect it. No source edit needed. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 96 +++++++++++++++++++++++++++++++++++++++++ assets/DESIGN.claude.md | 85 ++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d18f14..c248109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,102 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Phase 3.5 β€” Dashboard rewrite (multi-plan ranked view) + +Throws away the Amber-vs-current-plan two-comparator dashboard +(2447 LOC) and rebuilds it as a multi-plan ranked-alternatives view +keyed off the Phase 3.2 / 3.3 / 3.4 sensors. Visual seed lifted from +`assets/dashboard-v3-apple.html` β€” dark default, Outfit + IBM Plex +Mono, ambient radial bg, semantic accent tokens (no per-provider +colours). + +#### Added + +- **Full rewrite of `custom_components/pricehawk/www/dashboard.html`** + (~1250 LOC, down from 2447). New card hierarchy per plan section 5.1: + - NAV bar (brand + connection status pill + clock + theme toggle). + - HERO row: current-cost card + savings-vs-best-alt card (with + projected-annual extrapolation). + - PERIOD TABS: `[Today][Week][Month][3 Month][Year]` β€” clicking a + tab swaps the entity binding for every rollup card to the matching + `_today` / `_week` / `_month` / `_3month` / `_year` sensor in + one tick. Active tab persists to `localStorage['pricehawk-window']` + so re-opens land on the user's last view. + - RANKED ALTERNATIVES table rendered from + `sensor.pricehawk_ranked_alternatives.attributes.alternatives[]` + (already sorted by cheap-rank score in `summarize_for_sensor`). + Click a row β†’ drill-in card slides up below. + - DRILL-IN CARD: peak rate / daily supply / customer type / plan ID + / cheap-rank score, plus a "Pin as Named Comparator" button that + deep-links to `/config/integrations/integration/pricehawk` (HA + doesn't support per-step deep-linking; locked in plan section 9 + REVISIT 4). + - DATA HEALTH FOOTER: `sensor.pricehawk_backfill_status` state + (state-coloured: green=complete, amber=running, red=failed) + + `days_loaded` + `ranked_alternatives.last_run` as relative + + absolute time + alternatives count. +- **Empty-state UI for first-run users** (plan section 5.3 surprise #3): + when `backfill_status.days_loaded < 7`, hero rollup values are + replaced with an "Accruing… [n/365]" pill instead of showing a + misleading `$0.00`. Surfaces clearly that we don't have enough + history yet. +- **CSP `connect-src` extended** to include `ws://*.local:*` + + `wss://*.local:*` so the dashboard works on Ryan's HA Green at + `homeassistant.local` (plan section 5.3 surprise #1). Existing + `localhost` + `*.ui.nabu.casa` entries preserved. +- **`assets/DESIGN.claude.md` β€” new PriceHawk Dashboard section** + noting divergence: PriceHawk is a dark data-dashboard inside HA's + sidebar, not a warm-canvas editorial site. Inherits typographic + rationale (humanist sans + mono numerics) and the card-as-surface + model + accent-discipline rule, but uses its own token palette. + The rest of the Claude marketing-site spec stays intact. + +#### Changed + +- **WebSocket auth + URL detection preserved verbatim** from the prior + dashboard: + - `location.protocol === 'https:' ? 'wss://' : 'ws://'` for the WS + URL (AEGIS rule: never hardcode `ws://`). + - Token sourced from URL params first, then `window.parent + .hassConnection`, then `localStorage.hassTokens`, then + `window.parent.localStorage.hassTokens` (AEGIS rule: never + hardcode the token). +- **Per-provider colour tokens deleted** (`--amber-primary`, + `--globird-primary`). Replaced with `--accent-positive` / + `--accent-negative` / `--accent-neutral` / `--accent-warn` β€” matches + the Phase 3.0 pivot away from provider-specific branding. +- **`dashboard_config.setup_panel_iframe` cache-busting unchanged** β€” + the existing `?v=.` query param survives the + rewrite (it's appended to the URL, doesn't touch dashboard.html + itself). Verified by smoke test; no code change. + +#### Removed + +- CSV import card, backfill-trigger button, Amber-API winner card, + GloBird TOU strip, Amber forecast strip, sparkline chart, grid-power + gauge, two-provider rate chart, ZeroHero status card β€” all replaced + by the ranked-alternatives + rollup-sensor model. + +#### Notes + +- **No new JS framework, no build step.** Vanilla JS only, same + constraint as the prior dashboard. All CSS + JS inlined; no CDN + fetches beyond the Google Fonts stylesheet that the prior dashboard + already used. +- **30s setInterval re-render** for the ranked + footer cards so + relative timestamps ("ran 27s ago / 3h ago") tick forward without + waiting on a state_changed event. Cheap (<1ms per tick on HA Green). +- **XSS hardening**: all CDR-sourced strings (plan_id, display_name, + brand, customer_type) pass through `escapeHtml()` before innerHTML + insertion. Defensive β€” current registry payloads don't contain + HTML-ish characters, but future ones might. +- **Manual UAT only** for this commit (per plan section 6.3 table β€” + `3.5 | none | manual on Ryan's HA + JS console`). Local smoke test: + HTML parses cleanly via `html.parser`; JS extracted + run under + Node `--check` + mock-DOM render harness exercising all 5 period + windows + accruing branch + empty-ranked branch + drill render + without throwing. + ### Phase 3.4 β€” Named comparator drill-in Lets the user pin ONE CDR plan from the ranked alternatives list as a diff --git a/assets/DESIGN.claude.md b/assets/DESIGN.claude.md index 0d8c89d..3c17f87 100644 --- a/assets/DESIGN.claude.md +++ b/assets/DESIGN.claude.md @@ -587,3 +587,88 @@ When photography is used (rare β€” mostly testimonials), avatars crop to perfect - Form validation states beyond `{component.text-input-focused}` are not extracted β€” error / success states would need a sign-up or feedback flow to confirm. - The actual Claude product surface (claude.ai chat interface) shares some tokens with the marketing site but adds many product-specific components (chat bubbles, message tools, file upload chips, conversation history sidebar) that are out of scope for this marketing-surface document. - The "agent" / "computer use" demo cards on certain pages display animated Claude controlling a browser β€” the static screenshot doesn't fully capture the animation chrome. + +--- + +## PriceHawk Dashboard (divergence from this spec) + +The PriceHawk HA integration dashboard at +`custom_components/pricehawk/www/dashboard.html` deliberately does NOT +follow the Claude marketing-site spec above. PriceHawk is a dark +**data-dashboard** surfaced inside the Home Assistant sidebar iframe, +not a warm-canvas editorial site, and its visual language is +incompatible with the cream/coral/dark-navy trinity documented in this +file. + +### Why divergence + +- **Surface context**: PriceHawk renders inside HA's chrome alongside + other dark dashboards (Lovelace, Energy, Logbook). A warm-cream canvas + would look broken next to those panels. Most HA users run dark mode + by default; cream-on-cream would be uncomfortable late at night + during high-tariff windows when the dashboard is actually consulted. +- **Information density**: a data-dashboard with 16+ live entity reads + + a ranked alternatives table needs tabular numerals, mono digits, + and high-contrast accent colours. The editorial typography stack + (serif display + humanist sans) doesn't suit dense tabular layouts. +- **Brand independence**: PriceHawk is an open-source HACS integration, + not an Anthropic product. The Anthropic coral / radial-spike mark + doesn't apply here. PriceHawk has its own logo (orange hawk-head + glyph at `custom_components/pricehawk/icon.png`). + +### What PriceHawk DOES inherit from this spec + +- **Typographic rationale**: humanist sans (Outfit, here, vs StyreneB) + for UI text; mono with tabular numerics (IBM Plex Mono, here, vs no + mono in the Claude spec) for all money + rate values. The "use mono + for numbers users compare against each other" rule is unbreakable. +- **Card-as-surface model**: rounded `border-radius` (12-16px), + subtle border, optional 1px gradient top-stroke on hover for + affordance. PriceHawk uses `--card-radius: 16px` matching the Claude + spec's `r-lg: 18px` ballpark. +- **Accent-colour discipline**: ONE positive, ONE negative, ONE + neutral. Don't introduce a fourth accent. Claude uses + primary-coral as its single accent; PriceHawk uses + `--accent-positive` (savings green) + `--accent-negative` (loss red) + + `--accent-neutral` (info blue) + `--accent-warn` (accruing amber) + β€” four because the dashboard surfaces four distinct semantic states, + not as decoration. + +### PriceHawk token map (for reference) + +``` +--bg-base: #070B14 // OLED-friendly true black +--bg-surface: #0C1220 +--bg-card: rgba(15,23,42,0.6) +--text-primary: #F1F5F9 +--text-secondary: #94A3B8 +--text-muted: #64748B +--accent-positive: #10B981 // savings, "you save" +--accent-negative: #EF4444 // loss, "you lose" +--accent-neutral: #38BDF8 // info, current plan / pinned baseline +--accent-warn: #F59E0B // accruing, < 7 days of backfill history +--card-radius: 16px +--card-blur: 20px +``` + +Light theme inverts via `[data-theme="light"]` selector overriding the +same tokens (canvas: `#F5F6FA`, card: `rgba(255,255,255,0.78)`, accents +shift one stop darker for contrast). Theme persists to +`localStorage['pricehawk-theme']`; first-visit defaults to OS +`prefers-color-scheme`. + +### Where to look for PriceHawk's full visual treatment + +- `assets/dashboard-v3-apple.html` β€” the v3 visual seed (1478 LOC + dark-theme mockup; ambient radial bg, noise overlay, Outfit + IBM + Plex Mono). Not the deployed dashboard, but the design-language + source-of-truth. +- `custom_components/pricehawk/www/dashboard.html` β€” the actual + deployed dashboard at `/local/pricehawk/dashboard.html`. Hierarchy: + nav / hero row (current cost + savings) / period tabs + (today|week|month|3month|year) / ranked alternatives table / + drill-in card / data-health footer. + +Don't try to reconcile PriceHawk back into the Claude spec. +They are different products and the visual languages are +deliberately separate. From 0e1ba0eaa47c838f929252197fecd4d1ba7acedb Mon Sep 17 00:00:00 2001 From: ArticOdin Date: Mon, 18 May 2026 00:46:18 +1000 Subject: [PATCH 7/7] fix(docs): add css language to design-spec fenced code block CodeRabbit / markdownlint MD040: the fenced code block listing the PriceHawk CSS custom properties (--bg-base, --bg-surface, --accent-positive et al) opened with a bare ``` instead of ```css. Tag the fence as ``css`` so the markdown renderer applies CSS syntax highlighting and so MD040 stops flagging the block. Content is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- assets/DESIGN.claude.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/DESIGN.claude.md b/assets/DESIGN.claude.md index 3c17f87..5aa7afd 100644 --- a/assets/DESIGN.claude.md +++ b/assets/DESIGN.claude.md @@ -636,7 +636,7 @@ file. ### PriceHawk token map (for reference) -``` +```css --bg-base: #070B14 // OLED-friendly true black --bg-surface: #0C1220 --bg-card: rgba(15,23,42,0.6)