Skip to content

feat: configurable incentive params, updated GloBird rates, options flow bugfix#1

Merged
Artic0din merged 5 commits into
mainfrom
feat/configurable-incentives-and-bugfix
Apr 6, 2026
Merged

feat: configurable incentive params, updated GloBird rates, options flow bugfix#1
Artic0din merged 5 commits into
mainfrom
feat/configurable-incentives-and-bugfix

Conversation

@Artic0din
Copy link
Copy Markdown
Owner

Summary

  • Fix critical bug: OptionsFlowWithReload + add_update_listener conflict crashes options editing on HA 2026.3+ with ValueError: Config entry update listeners should not be used with OptionsFlowWithReload
  • Make incentive parameters user-configurable: Super Export cap (10 or 15 kWh), window (6-8pm or 6-9pm), and rate; ZEROHERO credit window — all were previously hardcoded in tariff_engine.py
  • Update default rates to April 2026 GloBird fact sheets (GLO731031MR, GLO731660MR, GLO731591MR, GLO731580MR) — pre-fill only, users configure their own rates
  • Add Peak Solar Feed-in to ZEROHERO defaults (2c/kWh 4-11pm per new fact sheet)
  • Backward compatible: existing installs without new config keys fall back to old defaults (10 kWh, 6-8pm)
  • Bump version to 1.2.0

Context

GloBird released new fact sheets on 01-04-2026 and 02-04-2026 with rate changes across all plans. The ZEROHERO plan had significant structural changes:

  • Super Export cap increased from 10 kWh to 15 kWh
  • Super Export and ZEROHERO credit windows extended from 6-8pm to 6-9pm
  • Export tariff changed from Variable FiT TOU to flat 0.00 (incentives replace it)
  • Peak Solar Feed-in added (2c/kWh 4-11pm)

A user reported the options flow crash on Discord (Speegs, 4/4/2026) with HA logs showing the ValueError.

Test plan

  • All 59 tariff engine tests pass
  • Test options flow editing on HA 2026.3+ (no more "Unknown error occurred")
  • Test new setup with ZEROHERO plan — verify super export fields appear with 15kWh/6-9pm defaults
  • Test existing install upgrade — verify backward compat (old 10kWh/6-8pm values preserved)
  • Test custom plan with configurable incentive params

🤖 Generated with Claude Code

Artic0din and others added 4 commits April 3, 2026 13:15
- Add aiohttp>=3.9.0 to manifest.json requirements (used by config_flow.py
  for Amber API validation via async_get_clientsession)
- Add named incentive constants (INCENTIVE_ZEROHERO_CREDIT, INCENTIVE_SUPER_EXPORT,
  INCENTIVE_FREE_POWER, INCENTIVE_CRITICAL_PEAK_EXPORT, INCENTIVE_CRITICAL_PEAK_IMPORT,
  INCENTIVE_PEAK_SOLAR_FEEDIN, INCENTIVE_PROMPT_PAYMENT) to const.py
- Add INCENTIVE_PARAMS dict mapping each incentive to its calculation parameters

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace raw string literals in incentive lists with INCENTIVE_* constants
- Move incentive constant definitions before GLOBIRD_PLAN_DEFAULTS to
  avoid forward references
- All 4 plans (zerohero, four4free, boost, glosave) now use named refs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Restore 4 deleted test files from git history (tariff_engine,
  amber_calculator, coordinator, helpers)
- Remove SSH password from CLAUDE.md
- Fix FOUR4FREE tariff_type: TARIFF_FLAT_STEPPED → TARIFF_TOU to
  match actual two-rate TOU import structure
- Add FOUR4FREE branch to OptionsFlow async_step_incentives so
  users editing options retain their incentive toggles
- Fix INCENTIVE_PARAMS Super Export window: 10am-2pm → 6pm-8pm
  to match tariff_engine.py implementation
- Fix INCENTIVE_PARAMS critical_peak_import: align description
  and value (5c/kWh credit, not 10c surcharge)
- Remove aiohttp from manifest.json requirements (ships with HA)
- Fix MetricsWonSensor TypeError: add None guard for globird_import
- Remove redundant inline dt_util import in ProviderDailyCostSensor
- Add import_cost_today_c and export_earnings_today_c properties to
  TariffEngine, update coordinator to use public API

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…low fix

- Fix OptionsFlowWithReload + add_update_listener conflict that crashed
  options editing on HA 2026.3+ (ValueError)
- Make Super Export cap, window, and rate user-configurable (was hardcoded
  to 10kWh / 6-8pm / 15c — now defaults to 15kWh / 6-9pm / 15c per
  April 2026 fact sheets, backward-compatible for existing installs)
- Make ZEROHERO credit window user-configurable (was hardcoded 6-8pm,
  now defaults to 6-9pm with auto-scaling threshold)
- Update all plan default rates to April 2026 fact sheets:
  ZEROHERO 115.50c/day, FOUR4FREE 103.40c/day, BOOST 110.00c/day,
  GLOSAVE 88.00c/day (pre-fill only, users set their own rates)
- Add Peak Solar Feed-in to ZEROHERO defaults (2c/kWh 4-11pm)
- ZEROHERO export tariff changed to flat 0.00 (incentives replace TOU)
- Fix pre-existing broken tests (supply_charge_today_c, stale date)
- Bump version to 1.2.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 6, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f48b3f36-7590-406b-98e4-8080f162746e

📥 Commits

Reviewing files that changed from the base of the PR and between 9b5627d and 3fbe3e3.

📒 Files selected for processing (5)
  • .github/ISSUE_TEMPLATE/bug_report.yml
  • .github/ISSUE_TEMPLATE/feature_request.yml
  • .github/ISSUE_TEMPLATE/rate_update.yml
  • .gitignore
  • README.md

📝 Walkthrough

Summary by CodeRabbit

Release Notes – v1.2.0

  • New Features

    • Added configurable time windows and parameters for ZEROHERO and Super Export incentives
    • Extended incentive configuration with peak solar feedin and prompt payment options
    • Enhanced configuration flow with editable rate and window settings
  • Bug Fixes

    • Fixed incentive metrics validation to properly check all required pricing inputs
  • Documentation

    • Updated README with new features, supported plans, and incentive details
    • Added GitHub issue templates for bug reports and feature requests
  • Tests

    • Added comprehensive test coverage for pricing calculations and integration logic

Walkthrough

This PR refactors the PriceHawk integration to support configurable incentive parameters (time windows, rates, caps) for ZEROHERO and Super Export trackers. It removes automatic config-update handling, introduces new configuration UI fields, adds comprehensive test coverage across tariff engine and calculator components, updates translations and GitHub templates, and bumps the integration version to 1.2.0.

Changes

Cohort / File(s) Summary
Incentive Parameter Configuration
custom_components/pricehawk/config_flow.py, custom_components/pricehawk/strings.json, custom_components/pricehawk/translations/en.json
Extended incentives schema with configurable time windows (zerohero_window_start/end, super_export_window_start/end), export parameters (super_export_cap_kwh, super_export_rate), and new boolean toggles (peak_solar_feedin). Added corresponding UI labels and descriptions for new fields.
Tariff Engine Refactoring
custom_components/pricehawk/tariff_engine.py
Replaced module-level constants with instance-level configurable parameters for ZeroHeroTracker and SuperExportTracker. Added _parse_time() helper for time string parsing. Introduced public properties import_cost_today_c and export_earnings_today_c. Updated tracker initialization to derive parameters from incentives dict with fallback defaults.
Constants & Incentive Definitions
custom_components/pricehawk/const.py
Added exported incentive identifier constants (INCENTIVE_*) and new INCENTIVE_PARAMS dictionary mapping incentive identifiers to calculation parameter fields. Updated GLOBIRD_PLAN_DEFAULTS with new numeric rates and replaced hardcoded incentive strings with constants.
Config Entry Setup Simplification
custom_components/pricehawk/__init__.py
Removed async_options_updated handler and config entry update listener registration that previously triggered tariff engine rebuilds on options changes.
Data Access Updates
custom_components/pricehawk/coordinator.py
Updated _build_data_dict() to source daily cost/earnings from public engine properties (import_cost_today_c, export_earnings_today_c) instead of internal underscore-prefixed attributes.
Sensor Configuration
custom_components/pricehawk/sensor.py
Modified MetricsWonSensor.native_value fallback to check both amber and globird import rates. Added _unrecorded_attributes to LastUpdatedSensor. Removed redundant import statement in ProviderDailyCostSensor.last_reset.
Manifest & Versioning
custom_components/pricehawk/manifest.json
Version bumped from 1.1.0 to 1.2.0.
Comprehensive Test Suite
tests/test_amber_calculator.py, tests/test_coordinator.py, tests/test_helpers.py, tests/test_tariff_engine.py
Added 1,257 lines of new pytest tests covering AmberCalculator accumulation/serialization, coordinator sensor payloads, TOU period selection, stepped pricing, tracker state machines (ZEROHERO, SuperExport, Demand), daily reset/gap protection, and engine serialization round-trips.
GitHub Issue Templates
.github/ISSUE_TEMPLATE/bug_report.yml, .github/ISSUE_TEMPLATE/feature_request.yml, .github/ISSUE_TEMPLATE/rate_update.yml
Added three structured GitHub issue templates for bug reports (with version/plan/reproduction fields), feature requests (with problem/solution textarea), and rate updates (with fact sheet reference and effective date).
Documentation & Project Files
.gitignore, CLAUDE.md, README.md
Removed ignores for CLAUDE.md and tests/; added ignores for project-overview.html, PROGRESS.md, docs/superpowers/. Added new CLAUDE.md project context file (96 lines). Substantially reorganized README with new badge/tagline, restructured Features/Sensors/Incentives/Dashboard sections, updated Home Assistant minimum to 2024.8.0+, and expanded Contributing section.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related issues

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 68.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the three main changes: configurable incentive parameters, updated GloBird rates, and the options flow bug fix.
Description check ✅ Passed The description thoroughly explains the pull request's purpose, changes, and context, detailing the bug fix, new features, rate updates, and backward compatibility.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/configurable-incentives-and-bugfix

Comment @coderabbitai help to get the list of available commands and usage tips.

Remove docs/superpowers from tracking, update .gitignore to exclude
dev-only files (PDFs, PROGRESS.md, project-overview.html, etc.).
Rewrite README with badges, structured sections, full sensor/plan/incentive
docs. Add GitHub issue templates for bugs, features, and rate updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@Artic0din Artic0din merged commit 0b1f2de into main Apr 6, 2026
2 checks passed
@Artic0din Artic0din deleted the feat/configurable-incentives-and-bugfix branch April 6, 2026 06:28
Artic0din added a commit that referenced this pull request May 17, 2026
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) <noreply@anthropic.com>
Artic0din added a commit that referenced this pull request May 17, 2026
…multi-plan layout

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) <noreply@anthropic.com>
Artic0din added a commit that referenced this pull request May 17, 2026
…ll entity reads

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_<window>.
- Savings card reads sensor.pricehawk_savings_<window> 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) <noreply@anthropic.com>
Artic0din added a commit that referenced this pull request May 17, 2026
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) <noreply@anthropic.com>
Artic0din added a commit that referenced this pull request May 17, 2026
…multi-plan layout

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) <noreply@anthropic.com>
Artic0din added a commit that referenced this pull request May 17, 2026
…ll entity reads

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_<window>.
- Savings card reads sensor.pricehawk_savings_<window> 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) <noreply@anthropic.com>
Artic0din added a commit that referenced this pull request May 17, 2026
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) <noreply@anthropic.com>
Artic0din added a commit that referenced this pull request May 17, 2026
…multi-plan layout

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) <noreply@anthropic.com>
Artic0din added a commit that referenced this pull request May 17, 2026
…ll entity reads

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_<window>.
- Savings card reads sensor.pricehawk_savings_<window> 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) <noreply@anthropic.com>
Artic0din added a commit that referenced this pull request May 18, 2026
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) <noreply@anthropic.com>
Artic0din added a commit that referenced this pull request May 18, 2026
…multi-plan layout

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) <noreply@anthropic.com>
Artic0din added a commit that referenced this pull request May 18, 2026
…ll entity reads

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_<window>.
- Savings card reads sensor.pricehawk_savings_<window> 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) <noreply@anthropic.com>
Artic0din added a commit that referenced this pull request May 18, 2026
* feat(coordinator): Phase 3.4 commit 1/2 — named comparator wiring

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) <noreply@anthropic.com>

* feat(sensor): Phase 3.4 commit 2/2 — NamedComparatorRollupSensor × 5

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) <noreply@anthropic.com>

* fix(named): persist provider across restart + skip unique_id collision

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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Artic0din added a commit that referenced this pull request May 18, 2026
)

* docs(planning): lock Phase 3.2 → 3.5 implementation plan

11-commit execution plan covering universal HA-history backfill (3.2),
period rollup sensors (3.3), named comparator drill-in (3.4), and
dashboard rewrite (3.5). Locks architectural decisions up front
(daily_cost_history as single source of truth, named comparator as
just another CdrPlanProvider, rollup as computed-on-demand) so the
executing model doesn't re-derive them at each commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cdr): Phase 3.2 commit 1/4 — pure-logic HA history replay module

Adds ``cdr/history_replay.py`` with three pure functions that establish
the public API surface Phase 3.2 commit 2 wires the coordinator to.
No HA imports — the module is unit-testable outside the integration
runtime, matching the ``cdr/ranking_job.py`` pattern.

Public API:
  - ``states_to_half_hour_slots`` — converts raw (ts, power_w, unit)
    tuples to evaluator-shaped slot dicts aligned to 30-min boundaries.
    Handles kW→W unit conversion, gap-protection clamping (6 min cap
    matches ``cdr/streaming.py``), and string-vs-float power values
    (HA's recorder serialises some sensor states as strings).
  - ``replay_day_through_plan`` — wraps ``evaluate()`` with the
    standard exception-swallow pattern (mirrors ``deep_rank``).
    Returns None on evaluator exception OR zero slot count.
  - ``fan_out_replay`` — generator yielding (date_str, {plan_key: aud})
    per day. Streaming output keeps peak RAM at ~one day × N plans
    instead of all-days × N plans of full CostBreakdown objects.

25 tests covering boundary alignment, unit conversion, gap protection,
sign handling, plan-failure isolation, date-order preservation, and
opt-in entry_options pass-through. Pattern follows
``tests/test_coordinator_ranking.py``: stdlib only, no pytest-asyncio.

Foundation for Phase 3.2 commit 2 (backfill.py rewrite) and Phase 3.3
(rollup sensors read the daily_cost_history rows this module helps
populate).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(backfill): Phase 3.2 commit 2/4 — rewrite backfill.py as HA-side adapter

Rewrites ``backfill.py`` from a 369-LOC Amber-API-coupled module into
a thin coordination layer over the pure-logic ``cdr.history_replay``
fan-out. New public API:

    backfill_daily_cost_history(hass, grid_sensor_entity, plans,
        *, days_back=30, entry_options=None, existing_history=None)

Internals pull recorder history day-by-day (NOT one big query — a
30-day single ``state_changes_during_period`` on a 1Hz grid sensor
returns 100K+ State objects), convert to evaluator slots, fan out
across N plans via the streaming generator, and merge per-date rows
into the coordinator's ``daily_cost_history``. Final list is capped
at 180 entries (matches live coordinator slice).

Day-by-day queries are deliberate and NOT parallelised. HA's
recorder uses a single executor pool so concurrent queries serialise
anyway and just bloat task count. Per-query memory is bounded; the
SQLite index on ``last_changed`` means 30 small queries are not
meaningfully slower than 1 big one. This is commented inline so CR
doesn't suggest parallelisation.

Legacy ``fetch_amber_price_history`` retained — still used by
``coordinator._replay_amber_today_from_api`` to seed the Amber
accumulator on a fresh install. The Phase 3.2 backfill itself no
longer fetches Amber prices: Amber's role narrowed to a *truth
overlay* written once daily by the live coordinator rollover.

Test rewrite: 14 legacy Amber-API tests deleted (those exercised
``backfill_from_history``, ``_build_amber_price_index``,
``_find_amber_rate``, ``_parse_history_states`` — all removed).
14 new tests cover ``_local_date_string`` (AEST-safe formatting),
``_states_to_tuples`` (State + dict shapes), ``_merge_into_history``
(insert/merge/cap), and ``backfill_daily_cost_history`` end-to-end
with the recorder mocked at the import boundary.

``tests/conftest.py`` extended with ``homeassistant.components``,
``homeassistant.components.recorder``, and
``homeassistant.components.recorder.history`` mocks so the lazy
recorder import inside the backfill resolves under the test harness.

``__init__.py``'s ``handle_backfill`` service updated to call the
new API with the current CDR plan; commit 4 will shrink it further
to a one-line delegate through the coordinator (once the wrapper +
status sensor land).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(coordinator): Phase 3.2 commit 3/4 — async_run_backfill + auto-kickoff

Wires the new universal HA-history backfill into the coordinator and
schedules its first run automatically after the initial ranking job
completes.

Coordinator additions:
  - ``build_backfill_plan_set`` — module-level pure helper (mirrors
    the ``cdr.ranking_job`` pattern) composing the {plan_key:
    plan_body} dict from current plan + top-K ranked alternatives.
    Lives outside the class so it's unit-testable without HA's app
    context (the coordinator's ``DataUpdateCoordinator[T]`` base
    gets mocked away by ``tests/conftest.py``).
  - ``_build_backfill_plan_set`` — thin instance wrapper.
  - ``async_run_backfill`` — coordinator-side run wrapper modelled on
    ``async_run_ranking_job``. Status-tracked via ``_backfill_status``
    state machine (``idle | running | complete | failed``) plus
    ``_backfill_last_run_at``, ``_backfill_days_loaded``,
    ``_backfill_plans_replayed``, ``_backfill_error`` attributes
    that the Phase 3.2 commit 4 status sensor will surface.
  - Reuses ``_ranking_lock`` to serialise against the ranking job —
    both mutate ``_daily_cost_history``. REVISIT: split if contention
    observed in prod (cost of being wrong is brief serialisation of
    two rare operations).
  - Local import of ``backfill_daily_cost_history`` inside
    ``async_run_backfill`` (``# noqa: PLC0415``) so the HA recorder
    isn't loaded at module-import time. Matches the existing pattern
    at ``_replay_amber_today_from_api``.

``__init__.py`` kickoff:
  - After ``async_run_ranking_job`` task is scheduled, schedule a
    second task that AWAITS the ranking lock (so the first ranking
    run finishes and the alternatives list is populated) THEN runs
    ``async_run_backfill(days_back=30)``. Without the wait, the
    first backfill would replay history through only the current
    plan, missing the ``alt_*`` columns.

Tests: 7 new in ``test_coordinator_helpers.py`` covering the pure
helper — current-plan composition, alt_* prefix keying, plan-cache
fallback to alt body, malformed-input skipping, empty/non-dict
``cdr_plan`` graceful return. Pattern matches the existing
``_extract_peak_rate_c_inc_gst`` test block in the same file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sensor): Phase 3.2 commit 4/4 — BackfillStatusSensor + service one-liner

Final Phase 3.2 commit. Adds the status sensor users will read from
their dashboards / automations and collapses the
``pricehawk.backfill_history`` service handler to a one-line delegate
through ``coordinator.async_run_backfill``.

New entity:
  - ``sensor.pricehawk_backfill_status`` — state machine read-through.
    State: ``idle | running | complete | failed``. Attributes:
    ``last_run`` (ISO timestamp), ``days_loaded``, ``plans_replayed``,
    ``error``. All sourced from the coordinator's status attributes
    written by ``async_run_backfill``.

Service handler:
  - ``handle_backfill`` shrunk from a 60-LOC inline pipeline to
    defensive ``days`` coercion + a single
    ``await coordinator.async_run_backfill(days_back=...)`` call.
    Status tracking, recorder pulls, plan composition, and
    persistence all happen inside the coordinator method now;
    failures surface on the sensor rather than getting lost to log
    lines.

Service description: updated ``services.yaml`` to reflect the new
flow (replay-through-CDR-plan, no Amber API, status sensor pointer).
Unused ``CONF_GRID_POWER_SENSOR`` import removed from
``__init__.py`` (the coordinator now owns the lookup).

Tests: 4 new BackfillStatusSensor smoke tests in
``test_review_improvements.py`` exercising the property-read contract
(state defaults to idle, running propagates, datetime → ISO, error
attribute surfaces on failed runs). The sensor class itself can't be
imported under the conftest mock tree (CoordinatorEntity +
SensorEntity multiple inheritance from MagicMocks triggers a
metaclass conflict), so the tests mirror the EXACT property bodies
inline. Integration test on Ryan's HA will catch any drift.

CHANGELOG.md updated under ``[Unreleased]`` documenting the full
Phase 3.2 surface area: new module, rewritten backfill, status
sensor, service signature change, recorder mocks in conftest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(backfill): type-annotate target dict union for pyright

`target = by_date.get(date_str)` infers as `dict[str, Any] | None`
but the subsequent `target[plan_key] = aud` assignment narrows
incorrectly without an explicit annotation. Annotate the union so
pyright accepts both branches (None → fresh dict, dict → mutate).

No behaviour change; pytest 760/760 still passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(backfill): docstring accuracy on plan-set composition + recorder mock

Two docstring corrections flagged by Sourcery — no behaviour changes.

1. `build_backfill_plan_set` previously claimed it returned `{}` when
   the current plan was missing. The implementation actually returns
   alternatives even without a current plan (alts-only backfill is
   intentional so rollup sensors can still surface comparative data).
   Updated the docstring to describe the real contract: callers must
   treat the absence of the current-plan entry as a "no-signal"
   condition for the active plan at that time.

2. `_patch_recorder` claimed a 3-tuple return
   `(get_instance_mock, history_call_mock, dt_util_now_mock)` but the
   helper actually returns a 2-tuple `(get_instance, history_mock)`
   (the dt_util mock was removed earlier when the backfill stopped
   depending on it). Fixed the docstring and added a precise
   `tuple[MagicMock, MagicMock]` return annotation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(backfill): days-loaded delta + test type hints + defang plan-doc

CodeRabbit round-2 fixes for PR #73:

- coordinator.async_run_backfill: ``_backfill_days_loaded`` now reports
  the delta (new days added by THIS run), not the total merged-history
  length. Capture ``prev_len`` before reassignment, compute
  ``new_days = max(0, len(result) - prev_len)``, clamp negatives to
  zero. Return value and log line updated to match. Docstring spells
  out the delta semantics so future callers don't misread the API.
- tests/test_review_improvements.py::TestBackfillStatusSensor: add
  ``-> None`` return type hints to the four public test methods so
  mypy strict-mode is satisfied. Underscored helpers (_coord,
  _native_value, _attrs) left as-is.
- .planning/PHASE-3.2-to-3.5-PLAN.md: section 1.5 was documenting
  ``error_message`` but the implementation uses ``error`` (matches
  sensor.py:571 + tests). Sync the plan to the code. Also defang the
  literal insecure-WebSocket scheme in section 8.4 (``ws-//`` with a
  parenthetical note) so the dashboard-protocol-safety recipe and
  generic CI security scans don't flag the plan doc itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(backfill): defang plan-doc + double-check status before lock

CodeRabbit round-3:
- Replace literal ws://localhost:* token in PHASE-3.2-to-3.5 plan doc
  with descriptive prose so secret/security scanners stop tripping.
- Short-circuit async_run_backfill on _backfill_status BEFORE acquiring
  _ranking_lock so a concurrent caller does not block. Keep the
  re-check inside the lock to close the read/acquire race.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(backfill): repo-relative plan path + arithmetic + failure-path metadata reset

CodeRabbit round-4 fixes on PR #73:

- Plan doc: replace literal absolute `/Users/.../pricehawk/` path with
  `<REPO_ROOT>` placeholder so the planning markdown isn't tied to one
  user's machine layout.
- Plan doc: fix arithmetic typo in commit total. 4+3+2+3 = 12, not 11.
- Coordinator `async_run_backfill`: reset stale success metadata
  (`_backfill_days_loaded`, `_backfill_plans_replayed`) in the failure
  path so the status sensor doesn't surface misleading counts from a
  prior successful run after a failure. Set `_backfill_last_run_at` to
  the time of THIS (failed) run, matching success-path semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(plan): MD040 fence langs + accurate kW description + named-provider lookup

Round-5 CR doc nits on the planning artifact:
- Add `text`/`bash` language tags to 3 fenced code blocks (dependency
  graph, dashboard ASCII layout, pre-push checks) for MD040 compliance.
- Fix test description: kW→W is multiply-by-1000, not "doubles".
- Plan snippet's named-provider check should read `coordinator._providers`
  (the instance dict) not `coordinator.data["providers"]`. Matches the
  shipped code in Phase 3.4's sensor.py registration.

Plan doc only; no shipped code changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cdr): Phase 3.3 commit 1/3 — pure-logic rollup module

Add `cdr/rollup.py` with four pure functions over `daily_cost_history`:
`filter_window` (rolling window selection), `sum_window` (per-key sum
with sparse-row tolerance), `best_alternative_for_window` (lexicographic
tie-break for determinism across ticks), and `savings` (current minus
best, sign-preserving).

No HA imports. Floats throughout. Returns `(None, 0)` rather than
`(0.0, 0)` for missing-data states so the sensor displays `unknown`
rather than misleading `$0.00`. 27 stdlib-only tests covering empty
history, malformed dates, sparse alt presence, string-coerced numerics,
explicit-zero days, ties, and prefix-based alt key scanning.

Wires the source of truth that Phase 3.3 commit 2 will bind 15 rollup
sensors to.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sensor): Phase 3.3 commit 2/3 — PeriodRollupSensor + 15 registrations

Add ``PeriodRollupSensor`` base + three subclasses
(``CurrentCostRollupSensor``, ``BestAlternativeRollupSensor``,
``SavingsRollupSensor``) reading from ``daily_cost_history`` via
``cdr.rollup``. Three kinds × five windows = 15 new entities
(``sensor.pricehawk_{current,best_alt,savings}_cost_{today,week,month,3month,year}``).

Per plan §3.1 "Surprise risk — last_reset": only the ``today`` window
sets ``last_reset`` to midnight. Rolling week/month/3month/year leave
it unset — HA's TOTAL state-class tolerates this for monotonic-with-
occasional-corrections series, and setting an artificial midnight
reset would falsely re-attribute the prior day's value as today's spend.

Per plan §8.6: no ``RollupStrategy`` interface; the three kinds are
dispatched by inline ``if self._ROLLUP_KIND`` in the base class.
Floats throughout (no Decimal). Lazy ``from .cdr.rollup`` import
inside ``native_value`` (``# noqa: PLC0415`` annotated) to keep the
import-time footprint identical to pre-3.3.

3 sensor smoke tests added to ``tests/test_review_improvements.py``,
following the same property-body mirror pattern used by Phase 3.2's
``BackfillStatusSensor`` tests (CoordinatorEntity + SensorEntity
metaclass conflict prevents direct construction under the conftest
mock tree).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(strings): Phase 3.3 commit 3/3 — entity translations + CHANGELOG

Add the ``entity.sensor.*`` block to both ``strings.json`` and
``translations/en.json`` for all 15 Phase 3.3 rollup sensors (3 kinds
× 5 windows). Names + descriptions disambiguate the new
``savings_cost_today`` rollup from the existing real-time
``saving_today`` sensor (different math, both valid).

CHANGELOG entry under [Unreleased] documents:
- The new pure-logic module + 15 sensors.
- ``last_reset`` semantics (today-only midnight reset).
- The distinction from the legacy ``saving_today`` sensor.
- The "sparse data → ``None`` rather than ``$0.00``" contract.

No new tests in this commit — the rollup logic is covered by 27 tests
in ``test_rollup.py`` (commit 1/3) and the sensor dispatch by 3 smoke
tests in ``test_review_improvements.py`` (commit 2/3). Translations
are static JSON.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(rollup): defensive type guards on filter_window + sensor provider deref

Two CodeRabbit findings — both add defensive guards without changing
the happy-path behaviour.

1. `cdr.rollup.filter_window` iterates `history` and calls `.get("date")`
   on each row unconditionally. The typed signature says
   `list[dict[str, Any]]`, but restored state from `.storage` or 3rd-
   party callers can slip in scalars (a corrupted entry, a future
   schema change). A non-dict row raises AttributeError and crashes
   every sensor read that hits this codepath. Skip non-dict rows up
   front; the rest of the parse/window logic is unchanged.

2. `PeriodRollupSensor.native_value` reads
   `self.coordinator._current_plan_provider.id` for the "current" and
   "savings" branches without guarding. The coordinator's __init__
   raises ConfigEntryNotReady when `cdr_plan` is missing, so the
   attribute *should* always exist — but restart races, partial
   restore, or mocked coordinators in tests can briefly land here
   without it. Added an upfront guard that returns None (sensor
   shows `unknown`) instead of raising AttributeError. The "best_alt"
   branch is unchanged — it doesn't deref the provider.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(rollup): drop unsupported strings.json description + provider-guard tests

CodeRabbit round-2 fixes for PR #74:

- strings.json + translations/en.json: removed the ``description`` key
  from all 15 rollup ``entity.sensor`` entries. HA's entity schema
  accepts ``name`` only — ``description`` was silently ignored and
  triggered translation-schema warnings on startup. The descriptive
  copy lives in PLAN.md and CHANGELOG.md where it belongs.
- tests/test_review_improvements.py::TestPeriodRollupSensorSmoke: the
  Phase 3.3 defensive guard added in sensor.py:657-660 (returns
  ``None`` when ``_current_plan_provider`` is missing or has no
  ``id``) had no test coverage. Mirror the guard in the
  ``_native_value`` helper and add two tests:
    - test_current_rollup_returns_none_when_provider_missing
    - test_savings_rollup_returns_none_when_provider_missing
  Both exercise the ``today`` and ``week`` windows so we cover the
  guard regardless of which window the user lands on first after a
  restart race or partial restore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(rollup): empty-plan-id guard + negative-value sum test

CodeRabbit round-3:
- best_alternative_for_window: skip rows where the alt key is the
  bare prefix (``"alt_"`` with no plan id suffix) so a malformed
  history row cannot be ranked as the cheapest plan.
- Add test_sum_window_handles_negative_values to cover FIT credits
  and refund rows summing alongside positive costs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Phase 3.4 — Named comparator drill-in (#75)

* feat(coordinator): Phase 3.4 commit 1/2 — named comparator wiring

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) <noreply@anthropic.com>

* feat(sensor): Phase 3.4 commit 2/2 — NamedComparatorRollupSensor × 5

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) <noreply@anthropic.com>

* fix(named): persist provider across restart + skip unique_id collision

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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Artic0din added a commit that referenced this pull request May 18, 2026
* docs(planning): lock Phase 3.2 → 3.5 implementation plan

11-commit execution plan covering universal HA-history backfill (3.2),
period rollup sensors (3.3), named comparator drill-in (3.4), and
dashboard rewrite (3.5). Locks architectural decisions up front
(daily_cost_history as single source of truth, named comparator as
just another CdrPlanProvider, rollup as computed-on-demand) so the
executing model doesn't re-derive them at each commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cdr): Phase 3.2 commit 1/4 — pure-logic HA history replay module

Adds ``cdr/history_replay.py`` with three pure functions that establish
the public API surface Phase 3.2 commit 2 wires the coordinator to.
No HA imports — the module is unit-testable outside the integration
runtime, matching the ``cdr/ranking_job.py`` pattern.

Public API:
  - ``states_to_half_hour_slots`` — converts raw (ts, power_w, unit)
    tuples to evaluator-shaped slot dicts aligned to 30-min boundaries.
    Handles kW→W unit conversion, gap-protection clamping (6 min cap
    matches ``cdr/streaming.py``), and string-vs-float power values
    (HA's recorder serialises some sensor states as strings).
  - ``replay_day_through_plan`` — wraps ``evaluate()`` with the
    standard exception-swallow pattern (mirrors ``deep_rank``).
    Returns None on evaluator exception OR zero slot count.
  - ``fan_out_replay`` — generator yielding (date_str, {plan_key: aud})
    per day. Streaming output keeps peak RAM at ~one day × N plans
    instead of all-days × N plans of full CostBreakdown objects.

25 tests covering boundary alignment, unit conversion, gap protection,
sign handling, plan-failure isolation, date-order preservation, and
opt-in entry_options pass-through. Pattern follows
``tests/test_coordinator_ranking.py``: stdlib only, no pytest-asyncio.

Foundation for Phase 3.2 commit 2 (backfill.py rewrite) and Phase 3.3
(rollup sensors read the daily_cost_history rows this module helps
populate).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(backfill): Phase 3.2 commit 2/4 — rewrite backfill.py as HA-side adapter

Rewrites ``backfill.py`` from a 369-LOC Amber-API-coupled module into
a thin coordination layer over the pure-logic ``cdr.history_replay``
fan-out. New public API:

    backfill_daily_cost_history(hass, grid_sensor_entity, plans,
        *, days_back=30, entry_options=None, existing_history=None)

Internals pull recorder history day-by-day (NOT one big query — a
30-day single ``state_changes_during_period`` on a 1Hz grid sensor
returns 100K+ State objects), convert to evaluator slots, fan out
across N plans via the streaming generator, and merge per-date rows
into the coordinator's ``daily_cost_history``. Final list is capped
at 180 entries (matches live coordinator slice).

Day-by-day queries are deliberate and NOT parallelised. HA's
recorder uses a single executor pool so concurrent queries serialise
anyway and just bloat task count. Per-query memory is bounded; the
SQLite index on ``last_changed`` means 30 small queries are not
meaningfully slower than 1 big one. This is commented inline so CR
doesn't suggest parallelisation.

Legacy ``fetch_amber_price_history`` retained — still used by
``coordinator._replay_amber_today_from_api`` to seed the Amber
accumulator on a fresh install. The Phase 3.2 backfill itself no
longer fetches Amber prices: Amber's role narrowed to a *truth
overlay* written once daily by the live coordinator rollover.

Test rewrite: 14 legacy Amber-API tests deleted (those exercised
``backfill_from_history``, ``_build_amber_price_index``,
``_find_amber_rate``, ``_parse_history_states`` — all removed).
14 new tests cover ``_local_date_string`` (AEST-safe formatting),
``_states_to_tuples`` (State + dict shapes), ``_merge_into_history``
(insert/merge/cap), and ``backfill_daily_cost_history`` end-to-end
with the recorder mocked at the import boundary.

``tests/conftest.py`` extended with ``homeassistant.components``,
``homeassistant.components.recorder``, and
``homeassistant.components.recorder.history`` mocks so the lazy
recorder import inside the backfill resolves under the test harness.

``__init__.py``'s ``handle_backfill`` service updated to call the
new API with the current CDR plan; commit 4 will shrink it further
to a one-line delegate through the coordinator (once the wrapper +
status sensor land).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(coordinator): Phase 3.2 commit 3/4 — async_run_backfill + auto-kickoff

Wires the new universal HA-history backfill into the coordinator and
schedules its first run automatically after the initial ranking job
completes.

Coordinator additions:
  - ``build_backfill_plan_set`` — module-level pure helper (mirrors
    the ``cdr.ranking_job`` pattern) composing the {plan_key:
    plan_body} dict from current plan + top-K ranked alternatives.
    Lives outside the class so it's unit-testable without HA's app
    context (the coordinator's ``DataUpdateCoordinator[T]`` base
    gets mocked away by ``tests/conftest.py``).
  - ``_build_backfill_plan_set`` — thin instance wrapper.
  - ``async_run_backfill`` — coordinator-side run wrapper modelled on
    ``async_run_ranking_job``. Status-tracked via ``_backfill_status``
    state machine (``idle | running | complete | failed``) plus
    ``_backfill_last_run_at``, ``_backfill_days_loaded``,
    ``_backfill_plans_replayed``, ``_backfill_error`` attributes
    that the Phase 3.2 commit 4 status sensor will surface.
  - Reuses ``_ranking_lock`` to serialise against the ranking job —
    both mutate ``_daily_cost_history``. REVISIT: split if contention
    observed in prod (cost of being wrong is brief serialisation of
    two rare operations).
  - Local import of ``backfill_daily_cost_history`` inside
    ``async_run_backfill`` (``# noqa: PLC0415``) so the HA recorder
    isn't loaded at module-import time. Matches the existing pattern
    at ``_replay_amber_today_from_api``.

``__init__.py`` kickoff:
  - After ``async_run_ranking_job`` task is scheduled, schedule a
    second task that AWAITS the ranking lock (so the first ranking
    run finishes and the alternatives list is populated) THEN runs
    ``async_run_backfill(days_back=30)``. Without the wait, the
    first backfill would replay history through only the current
    plan, missing the ``alt_*`` columns.

Tests: 7 new in ``test_coordinator_helpers.py`` covering the pure
helper — current-plan composition, alt_* prefix keying, plan-cache
fallback to alt body, malformed-input skipping, empty/non-dict
``cdr_plan`` graceful return. Pattern matches the existing
``_extract_peak_rate_c_inc_gst`` test block in the same file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sensor): Phase 3.2 commit 4/4 — BackfillStatusSensor + service one-liner

Final Phase 3.2 commit. Adds the status sensor users will read from
their dashboards / automations and collapses the
``pricehawk.backfill_history`` service handler to a one-line delegate
through ``coordinator.async_run_backfill``.

New entity:
  - ``sensor.pricehawk_backfill_status`` — state machine read-through.
    State: ``idle | running | complete | failed``. Attributes:
    ``last_run`` (ISO timestamp), ``days_loaded``, ``plans_replayed``,
    ``error``. All sourced from the coordinator's status attributes
    written by ``async_run_backfill``.

Service handler:
  - ``handle_backfill`` shrunk from a 60-LOC inline pipeline to
    defensive ``days`` coercion + a single
    ``await coordinator.async_run_backfill(days_back=...)`` call.
    Status tracking, recorder pulls, plan composition, and
    persistence all happen inside the coordinator method now;
    failures surface on the sensor rather than getting lost to log
    lines.

Service description: updated ``services.yaml`` to reflect the new
flow (replay-through-CDR-plan, no Amber API, status sensor pointer).
Unused ``CONF_GRID_POWER_SENSOR`` import removed from
``__init__.py`` (the coordinator now owns the lookup).

Tests: 4 new BackfillStatusSensor smoke tests in
``test_review_improvements.py`` exercising the property-read contract
(state defaults to idle, running propagates, datetime → ISO, error
attribute surfaces on failed runs). The sensor class itself can't be
imported under the conftest mock tree (CoordinatorEntity +
SensorEntity multiple inheritance from MagicMocks triggers a
metaclass conflict), so the tests mirror the EXACT property bodies
inline. Integration test on Ryan's HA will catch any drift.

CHANGELOG.md updated under ``[Unreleased]`` documenting the full
Phase 3.2 surface area: new module, rewritten backfill, status
sensor, service signature change, recorder mocks in conftest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(backfill): type-annotate target dict union for pyright

`target = by_date.get(date_str)` infers as `dict[str, Any] | None`
but the subsequent `target[plan_key] = aud` assignment narrows
incorrectly without an explicit annotation. Annotate the union so
pyright accepts both branches (None → fresh dict, dict → mutate).

No behaviour change; pytest 760/760 still passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(backfill): docstring accuracy on plan-set composition + recorder mock

Two docstring corrections flagged by Sourcery — no behaviour changes.

1. `build_backfill_plan_set` previously claimed it returned `{}` when
   the current plan was missing. The implementation actually returns
   alternatives even without a current plan (alts-only backfill is
   intentional so rollup sensors can still surface comparative data).
   Updated the docstring to describe the real contract: callers must
   treat the absence of the current-plan entry as a "no-signal"
   condition for the active plan at that time.

2. `_patch_recorder` claimed a 3-tuple return
   `(get_instance_mock, history_call_mock, dt_util_now_mock)` but the
   helper actually returns a 2-tuple `(get_instance, history_mock)`
   (the dt_util mock was removed earlier when the backfill stopped
   depending on it). Fixed the docstring and added a precise
   `tuple[MagicMock, MagicMock]` return annotation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(backfill): days-loaded delta + test type hints + defang plan-doc

CodeRabbit round-2 fixes for PR #73:

- coordinator.async_run_backfill: ``_backfill_days_loaded`` now reports
  the delta (new days added by THIS run), not the total merged-history
  length. Capture ``prev_len`` before reassignment, compute
  ``new_days = max(0, len(result) - prev_len)``, clamp negatives to
  zero. Return value and log line updated to match. Docstring spells
  out the delta semantics so future callers don't misread the API.
- tests/test_review_improvements.py::TestBackfillStatusSensor: add
  ``-> None`` return type hints to the four public test methods so
  mypy strict-mode is satisfied. Underscored helpers (_coord,
  _native_value, _attrs) left as-is.
- .planning/PHASE-3.2-to-3.5-PLAN.md: section 1.5 was documenting
  ``error_message`` but the implementation uses ``error`` (matches
  sensor.py:571 + tests). Sync the plan to the code. Also defang the
  literal insecure-WebSocket scheme in section 8.4 (``ws-//`` with a
  parenthetical note) so the dashboard-protocol-safety recipe and
  generic CI security scans don't flag the plan doc itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(backfill): defang plan-doc + double-check status before lock

CodeRabbit round-3:
- Replace literal ws://localhost:* token in PHASE-3.2-to-3.5 plan doc
  with descriptive prose so secret/security scanners stop tripping.
- Short-circuit async_run_backfill on _backfill_status BEFORE acquiring
  _ranking_lock so a concurrent caller does not block. Keep the
  re-check inside the lock to close the read/acquire race.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(backfill): repo-relative plan path + arithmetic + failure-path metadata reset

CodeRabbit round-4 fixes on PR #73:

- Plan doc: replace literal absolute `/Users/.../pricehawk/` path with
  `<REPO_ROOT>` placeholder so the planning markdown isn't tied to one
  user's machine layout.
- Plan doc: fix arithmetic typo in commit total. 4+3+2+3 = 12, not 11.
- Coordinator `async_run_backfill`: reset stale success metadata
  (`_backfill_days_loaded`, `_backfill_plans_replayed`) in the failure
  path so the status sensor doesn't surface misleading counts from a
  prior successful run after a failure. Set `_backfill_last_run_at` to
  the time of THIS (failed) run, matching success-path semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(plan): MD040 fence langs + accurate kW description + named-provider lookup

Round-5 CR doc nits on the planning artifact:
- Add `text`/`bash` language tags to 3 fenced code blocks (dependency
  graph, dashboard ASCII layout, pre-push checks) for MD040 compliance.
- Fix test description: kW→W is multiply-by-1000, not "doubles".
- Plan snippet's named-provider check should read `coordinator._providers`
  (the instance dict) not `coordinator.data["providers"]`. Matches the
  shipped code in Phase 3.4's sensor.py registration.

Plan doc only; no shipped code changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(cdr): Phase 3.3 commit 1/3 — pure-logic rollup module

Add `cdr/rollup.py` with four pure functions over `daily_cost_history`:
`filter_window` (rolling window selection), `sum_window` (per-key sum
with sparse-row tolerance), `best_alternative_for_window` (lexicographic
tie-break for determinism across ticks), and `savings` (current minus
best, sign-preserving).

No HA imports. Floats throughout. Returns `(None, 0)` rather than
`(0.0, 0)` for missing-data states so the sensor displays `unknown`
rather than misleading `$0.00`. 27 stdlib-only tests covering empty
history, malformed dates, sparse alt presence, string-coerced numerics,
explicit-zero days, ties, and prefix-based alt key scanning.

Wires the source of truth that Phase 3.3 commit 2 will bind 15 rollup
sensors to.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sensor): Phase 3.3 commit 2/3 — PeriodRollupSensor + 15 registrations

Add ``PeriodRollupSensor`` base + three subclasses
(``CurrentCostRollupSensor``, ``BestAlternativeRollupSensor``,
``SavingsRollupSensor``) reading from ``daily_cost_history`` via
``cdr.rollup``. Three kinds × five windows = 15 new entities
(``sensor.pricehawk_{current,best_alt,savings}_cost_{today,week,month,3month,year}``).

Per plan §3.1 "Surprise risk — last_reset": only the ``today`` window
sets ``last_reset`` to midnight. Rolling week/month/3month/year leave
it unset — HA's TOTAL state-class tolerates this for monotonic-with-
occasional-corrections series, and setting an artificial midnight
reset would falsely re-attribute the prior day's value as today's spend.

Per plan §8.6: no ``RollupStrategy`` interface; the three kinds are
dispatched by inline ``if self._ROLLUP_KIND`` in the base class.
Floats throughout (no Decimal). Lazy ``from .cdr.rollup`` import
inside ``native_value`` (``# noqa: PLC0415`` annotated) to keep the
import-time footprint identical to pre-3.3.

3 sensor smoke tests added to ``tests/test_review_improvements.py``,
following the same property-body mirror pattern used by Phase 3.2's
``BackfillStatusSensor`` tests (CoordinatorEntity + SensorEntity
metaclass conflict prevents direct construction under the conftest
mock tree).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(strings): Phase 3.3 commit 3/3 — entity translations + CHANGELOG

Add the ``entity.sensor.*`` block to both ``strings.json`` and
``translations/en.json`` for all 15 Phase 3.3 rollup sensors (3 kinds
× 5 windows). Names + descriptions disambiguate the new
``savings_cost_today`` rollup from the existing real-time
``saving_today`` sensor (different math, both valid).

CHANGELOG entry under [Unreleased] documents:
- The new pure-logic module + 15 sensors.
- ``last_reset`` semantics (today-only midnight reset).
- The distinction from the legacy ``saving_today`` sensor.
- The "sparse data → ``None`` rather than ``$0.00``" contract.

No new tests in this commit — the rollup logic is covered by 27 tests
in ``test_rollup.py`` (commit 1/3) and the sensor dispatch by 3 smoke
tests in ``test_review_improvements.py`` (commit 2/3). Translations
are static JSON.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(rollup): defensive type guards on filter_window + sensor provider deref

Two CodeRabbit findings — both add defensive guards without changing
the happy-path behaviour.

1. `cdr.rollup.filter_window` iterates `history` and calls `.get("date")`
   on each row unconditionally. The typed signature says
   `list[dict[str, Any]]`, but restored state from `.storage` or 3rd-
   party callers can slip in scalars (a corrupted entry, a future
   schema change). A non-dict row raises AttributeError and crashes
   every sensor read that hits this codepath. Skip non-dict rows up
   front; the rest of the parse/window logic is unchanged.

2. `PeriodRollupSensor.native_value` reads
   `self.coordinator._current_plan_provider.id` for the "current" and
   "savings" branches without guarding. The coordinator's __init__
   raises ConfigEntryNotReady when `cdr_plan` is missing, so the
   attribute *should* always exist — but restart races, partial
   restore, or mocked coordinators in tests can briefly land here
   without it. Added an upfront guard that returns None (sensor
   shows `unknown`) instead of raising AttributeError. The "best_alt"
   branch is unchanged — it doesn't deref the provider.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(rollup): drop unsupported strings.json description + provider-guard tests

CodeRabbit round-2 fixes for PR #74:

- strings.json + translations/en.json: removed the ``description`` key
  from all 15 rollup ``entity.sensor`` entries. HA's entity schema
  accepts ``name`` only — ``description`` was silently ignored and
  triggered translation-schema warnings on startup. The descriptive
  copy lives in PLAN.md and CHANGELOG.md where it belongs.
- tests/test_review_improvements.py::TestPeriodRollupSensorSmoke: the
  Phase 3.3 defensive guard added in sensor.py:657-660 (returns
  ``None`` when ``_current_plan_provider`` is missing or has no
  ``id``) had no test coverage. Mirror the guard in the
  ``_native_value`` helper and add two tests:
    - test_current_rollup_returns_none_when_provider_missing
    - test_savings_rollup_returns_none_when_provider_missing
  Both exercise the ``today`` and ``week`` windows so we cover the
  guard regardless of which window the user lands on first after a
  restart race or partial restore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(rollup): empty-plan-id guard + negative-value sum test

CodeRabbit round-3:
- best_alternative_for_window: skip rows where the alt key is the
  bare prefix (``"alt_"`` with no plan id suffix) so a malformed
  history row cannot be ranked as the cheapest plan.
- Add test_sum_window_handles_negative_values to cover FIT credits
  and refund rows summing alongside positive costs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(coordinator): Phase 3.4 commit 1/2 — named comparator wiring

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) <noreply@anthropic.com>

* feat(sensor): Phase 3.4 commit 2/2 — NamedComparatorRollupSensor × 5

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) <noreply@anthropic.com>

* fix(named): persist provider across restart + skip unique_id collision

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) <noreply@anthropic.com>

* feat(dashboard): Phase 3.5 commit 1/3 — strip Amber chrome, scaffold multi-plan layout

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) <noreply@anthropic.com>

* feat(dashboard): Phase 3.5 commit 2/3 — wire rollup + ranked + backfill entity reads

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_<window>.
- Savings card reads sensor.pricehawk_savings_<window> 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) <noreply@anthropic.com>

* feat(dashboard): Phase 3.5 commit 3/3 — design-spec divergence + CHANGELOG entry

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=<version>.<epoch>` 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Artic0din added a commit that referenced this pull request May 18, 2026
Two entity-id composition bugs:
- HA auto-inserts underscore between digit + letter, so '3 Month'
  window resolves to entity slug '3_month' not '3month'. Dashboard
  was composing 'sensor.pricehawk_current_cost_3month' which doesn't
  exist; all 3 Month tab cards stuck on accruing.
- Phase 3.5 worker's deviation note claimed entity ids were
  'best_alt_cost_*' and 'named_cost_*'; actual auto-derived ids use
  the full friendly-name slugs 'best_alternative_cost_*' and
  'named_comparator_cost_*'. Dashboard subscribed to non-existent
  entities, all best-alt + named-comparator cards blank.

Fix: WINDOW_SLUG map handles digit-letter underscore; ENTITY composition
uses correct friendly-name slugs.

Also: ranked-alts savings column now estimates per-alt savings for
#2..N by scaling best-alt rollup cost by cheap-rank score ratio.
#1 still gets exact rollup figure. Was previously '—' for #2..N.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant