Skip to content

Phase 3.5 — Dashboard rewrite (multi-plan ranked view)#81

Closed
Artic0din wants to merge 7 commits into
phase-3.3-period-rollupsfrom
phase-3.5-dashboard-rewrite
Closed

Phase 3.5 — Dashboard rewrite (multi-plan ranked view)#81
Artic0din wants to merge 7 commits into
phase-3.3-period-rollupsfrom
phase-3.5-dashboard-rewrite

Conversation

@Artic0din
Copy link
Copy Markdown
Owner

Summary

Phase 3.5 dashboard rewrite. Originally PR #76, auto-closed when stacked base was deleted on #73 merge. Stacked on new Phase 3.3+3.4 PR.

Changes

  • Full rewrite of custom_components/pricehawk/www/dashboard.html (~1257 LOC, down from 2447)
  • Hero / period tabs / ranked alts table / drill-in card / data health footer
  • Wires 16 sensors (15 rollups + ranked_alternatives + backfill_status)
  • CR rounds 1-2 fixes applied; CR approved at round 2

Test plan

  • HTML parses cleanly + JS mock-DOM harness exercises all branches
  • Deploy + UAT after merge

🤖 Generated with Claude Code

Artic0din and others added 7 commits May 18, 2026 11:27
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>
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>
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>
…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>
…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>
…GELOG 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>
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>
Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @Artic0din, you have reached your weekly rate limit of 1500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 18, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (3)
  • main
  • develop
  • dev

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b4ad62fb-423c-4959-855b-23528320b936

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch phase-3.5-dashboard-rewrite
  • 🛠️ scrub-secrets
  • 🛠️ no-hardcoded-rates
  • 🛠️ amber-api-limits
  • 🛠️ dashboard-protocol-safety

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

@Artic0din Artic0din deleted the branch phase-3.3-period-rollups May 18, 2026 02:39
@Artic0din Artic0din closed this May 18, 2026
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