From b088165d040294b538c8b8c33bb4d656cfffa3b7 Mon Sep 17 00:00:00 2001 From: ArticOdin Date: Sun, 17 May 2026 22:42:08 +1000 Subject: [PATCH 1/4] =?UTF-8?q?feat(dashboard):=20Phase=203.5=20commit=201?= =?UTF-8?q?/3=20=E2=80=94=20strip=20Amber=20chrome,=20scaffold=20multi-pla?= =?UTF-8?q?n=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full rewrite of custom_components/pricehawk/www/dashboard.html (2447 -> 940 LOC) replacing the Amber-vs-current-plan two-comparator view with the multi-plan ranked layout from plan section 5.1. Visual language ported from assets/dashboard-v3-apple.html (dark default, Outfit + IBM Plex Mono, noise + ambient bg). Per-provider colour tokens (--amber-primary, --globird-primary) replaced with semantic ones (--accent-positive, --accent-negative, --accent-neutral) per the Phase 3.0 pivot away from provider-specific branding. Scaffold layout per plan section 5.1: - NAV bar (brand + connection status + clock + theme toggle) - HERO row: current cost card + savings-vs-best-alt card - PERIOD TABS: [Today][Week][Month*][3 Month][Year], active swaps data - RANKED ALTERNATIVES table: #/plan/peak/supply/saving, click -> drill - DRILL-IN CARD: per-plan stats + "Pin as Named Comparator" button - DATA HEALTH FOOTER: backfill state / days loaded / last ranking / count Entity reads are NOT wired yet β€” sample data renders so the scaffold is visually verifiable before commit 3.5/2 binds real sensor values. WebSocket connection logic copied verbatim from the previous dashboard: - WS URL derived from location.protocol (AEGIS rule: never hardcode ws://) - Token from URL params, postMessage, parent.hassConnection, or localStorage hassTokens (AEGIS rule: never hardcode the token) CSP connect-src extended to include ws(s)://*.local:* so the dashboard works on Ryan's HA Green at homeassistant.local (plan section 5.3 surprise #1). Existing localhost + Nabu Casa entries preserved. Active period tab persists to localStorage so re-opens land on the user's last view rather than defaulting to month every time. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../pricehawk/www/dashboard.html | 2751 ++++------------- 1 file changed, 622 insertions(+), 2129 deletions(-) diff --git a/custom_components/pricehawk/www/dashboard.html b/custom_components/pricehawk/www/dashboard.html index d316d06..41b3fde 100644 --- a/custom_components/pricehawk/www/dashboard.html +++ b/custom_components/pricehawk/www/dashboard.html @@ -3,8 +3,16 @@ - -PriceHawk Dashboard + + +PriceHawk @@ -12,95 +20,81 @@ @@ -636,469 +485,181 @@
- + -
- - -
- - -
-
-
-
BEST RATE RIGHT NOW
-
---
-
-
Cheapest right now
-
-
--.-c/kWh
-
-
-
-
- Cheap - Average - Expensive -
-
-
-
TODAY'S SAVING
-
$0.00
-
-
-
MONTH SAVING
-
$0.00
-
-
-
WIN %
-
--%
-
-
-
LAST UPDATED
-
--:--
-
-
-
- - -
-
-
- - Current Rates -
-
- -
-
-
-
Amber Electric
- Wholesale -
-
- Import - 0.00 c/kWh -
-
- Feed-in - 0.00 c/kWh -
-
- -
-
-
-
GloBird Energy
- Shoulder -
-
- Import - 0.00 c/kWh -
-
- Feed-in - 0.00 c/kWh -
-
-
-
- - -
- - -
-
-
- - Rate Comparison -
-
TODAY
-
-
- -
-
--:--
-
Amber--
-
GloBird--
-
-
-
-
Amber Import
-
GloBird Import
-
Amber Feed-in
-
GloBird Feed-in
-
Forecast (dashed)
-
-
-
-
AMBER TOTAL
-
$0.00
-
-
-
GLOBIRD TOTAL
-
$0.00
-
-
-
DIFFERENCE
-
$0.00
-
-
-
CHEAPEST TODAY
-
---
-
-
-
- - -
-
-
- - Cost Breakdown -
-
-
- - -
-
- -
-
-
- - -
- - -
-
-
- - GloBird Incentives -
-
- -
-
- ZEROHERO Credit - Pending -
-
$1/day credit if grid imports stay low 6-8pm
-
- -
-
- Super Export - Tracking -
-
15c/kWh bonus for exports during 6-8pm window
-
-
-
-
- 0.0 kWh - / 15.0 kWh -
-
- -
-
- Free Power Window - Inactive -
-
Free electricity during promotional windows
-
- -
-
- Critical Peak - No event -
-
Demand response events with bonus credits
-
-
- - -
-
-
- - Savings History -
-
--
-
-
- - - - -
-
-
Amber won
-
GloBird won
-
-
-
- History will appear after the first full day -
-
-
- BEST DAY - -- -
-
+
+ + +
+ +
+
This month Β· Current plan
+
$--
+
+ on + β€” +
+
+ + +
+
Savings this month Β· vs best alternative
+
$--
+
+ Best alt: + β€” +
+
Projected annual: $β€”
+
- -
From 7e0f3b0ae8f4f20640106c32786394b4e65502d2 Mon Sep 17 00:00:00 2001 From: ArticOdin Date: Sun, 17 May 2026 22:46:44 +1000 Subject: [PATCH 2/4] =?UTF-8?q?feat(dashboard):=20Phase=203.5=20commit=202?= =?UTF-8?q?/3=20=E2=80=94=20wire=20rollup=20+=20ranked=20+=20backfill=20en?= =?UTF-8?q?tity=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hooks the scaffold from commit 3.5/1 up to the Phase 3.2 / 3.3 / 3.4 sensors. After WebSocket auth completes, fires a get_states + subscribes to state_changed events for the 16 tracked entities. Tracked entities (per plan section 5.2 + Phase 3.3 / 3.4 worker notes): - 5 x sensor.pricehawk_current_cost_{today,week,month,3month,year} - 5 x sensor.pricehawk_best_alt_cost_{...} (NOT _best_alternative_cost_) - 5 x sensor.pricehawk_savings_{...} - 5 x sensor.pricehawk_named_cost_{...} (NOT _named_comparator_cost_) - sensor.pricehawk_ranked_alternatives - sensor.pricehawk_backfill_status Hero row binding: - Current cost card reads sensor.pricehawk_current_cost_. - Savings card reads sensor.pricehawk_savings_ and colours green / red / muted around the +/- $0.005 deadband. - Best-alt name pulled from ranked_alternatives.attributes.alternatives[0] (sensor is sorted ascending by cheap-rank score per summarize_for_sensor). - Projected annual extrapolates active-window savings * 365/window_days. Period tabs swap activeWindow and re-call renderHero() β€” all rollup bindings re-evaluate against the new window's entity ID. Active class mirrors localStorage so the tab UI stays in sync on cold loads. Ranked alts table render: - Pulls ranked_alternatives.attributes.alternatives[]. - Renders rank-pill (#1 gold), plan name + brand, peak rate, supply, saving. Saving column only fills for the #1 plan (the cheapest); #2..N show "β€”" because we don't have per-alt cost rollups β€” only the best-alt rollup. Avoids fabricating numbers that don't match the sensor. - Click row β†’ drill-in card slides up below + plan ID persists in selectedPlanId so re-renders after state_changed events preserve selection. - Empty state ("Waiting for the daily ranking job…") covers first-install before the first ranking run completes. Drill-in card: - Stats grid: peak rate, daily supply, customer type, plan ID, cheap-rank score (when present). - "Pin as Named Comparator" deep-links to the integration's Configure page (/config/integrations/integration/pricehawk). Per plan section 5.3 surprise #2 + plan section 9 REVISIT 4: HA doesn't support per-step deep-linking; the deep-link is the locked UX for this phase. Data Health footer renders backfill state with state-coloured value (green=complete / amber=running / red=failed / muted=idle), days_loaded, ranked_alternatives.last_run as relative + absolute time, and the alternatives count. Empty-state UI for first-run users (plan section 5.3 surprise #3): when backfill_status.days_loaded < 7, hero rollup values are replaced with an "Accruing… [n/365]" pill instead of showing $0.00 β€” surfaces clearly that we don't have enough history yet rather than implying zero spend. XSS hardening: all attribute-sourced strings (plan_id, display_name, brand, customer_type) pass through escapeHtml() before innerHTML insertion. Catches any future CDR registry payloads that include HTML-ish characters in brand names. 30s setInterval re-renders the ranked + footer cards so the relative timestamps ("ran 27s ago / 3h ago") tick forward without waiting for the next state_changed event. TDZ fix: the period-tab boot block previously called setActiveWindow() before the entity state store consts were declared, which tripped a ReferenceError on attrs in strict mode. Boot now defers the first full render to the explicit boot block at the script bottom. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../pricehawk/www/dashboard.html | 419 +++++++++++++++--- 1 file changed, 368 insertions(+), 51 deletions(-) diff --git a/custom_components/pricehawk/www/dashboard.html b/custom_components/pricehawk/www/dashboard.html index 41b3fde..9080ad1 100644 --- a/custom_components/pricehawk/www/dashboard.html +++ b/custom_components/pricehawk/www/dashboard.html @@ -549,7 +549,7 @@
--
- +
@@ -560,30 +560,13 @@ - - - - - - - - - - - - - - - - - - - - - - + +
+ Waiting for the daily ranking job… +
Alternatives appear once the first ranking run completes.
+
@@ -659,6 +642,32 @@ const navStatus = $('navStatus'); const statusText = $('statusText'); +// ─────────────── Entity IDs ─────────────── +// Phase 3.3 + 3.4 worker notes: entity IDs use `best_alt_cost_*` and +// `named_cost_*` (NOT `best_alternative_cost_*` / `named_comparator_cost_*`). +const WINDOWS = ['today', 'week', 'month', '3month', 'year']; +const ENTITY = { + rankedAlternatives: 'sensor.pricehawk_ranked_alternatives', + backfillStatus: 'sensor.pricehawk_backfill_status', +}; +for (const w of WINDOWS) { + ENTITY[`current_${w}`] = `sensor.pricehawk_current_cost_${w}`; + ENTITY[`bestAlt_${w}`] = `sensor.pricehawk_best_alt_cost_${w}`; + ENTITY[`savings_${w}`] = `sensor.pricehawk_savings_${w}`; + ENTITY[`named_${w}`] = `sensor.pricehawk_named_cost_${w}`; +} +const TRACKED_ENTITIES = new Set(Object.values(ENTITY)); + +// Empty-state threshold (plan section 5.3 surprise #3): show "Accruing…" +// instead of zero-valued rollups until we have at least this many days +// of backfill history. +const MIN_DAYS_FOR_ROLLUPS = 7; + +// Backfill history target β€” surfaced in the "Accruing" pill so the user +// can see how far through the recorder history we are. Matches the +// default `purge_keep_days: 365` power-user target referenced in the plan. +const BACKFILL_TARGET_DAYS = 365; + // ─────────────── Theme toggle ─────────────── function initTheme() { const saved = localStorage.getItem('pricehawk-theme'); @@ -719,13 +728,17 @@ periodTabs.forEach((btn) => { btn.classList.toggle('active', btn.dataset.window === win); }); - // 3.5/1: re-render hero label text only (real rebind in 3.5/2). renderHeroLabels(); + renderHero(); } periodTabs.forEach((btn) => { btn.addEventListener('click', () => setActiveWindow(btn.dataset.window)); + btn.classList.toggle('active', btn.dataset.window === activeWindow); }); -setActiveWindow(activeWindow); +// First full render (which calls renderHero -> entityAttrs -> attrs{}) is +// deferred to the boot block at the bottom of the script, AFTER the entity +// state store is declared. Calling setActiveWindow here would trip a +// const-TDZ on `attrs`. function windowLabel(win) { switch (win) { @@ -763,16 +776,7 @@ }); } -// ─────────────── Sample data renderers (3.5/1 placeholders) ─────────────── -// Commit 3.5/2 replaces these with real entity-state reads from the -// WebSocket subscription. Keeping them here lets the scaffold render -// something visible immediately after page load even before auth completes. -const SAMPLE_RANKED = [ - { plan_id: 'SAMPLE-1', display_name: 'Value Saver', brand: 'Sample Retailer', peak_c_per_kwh: 28.2, supply_c_per_day: 110.0, saving: 45 }, - { plan_id: 'SAMPLE-2', display_name: 'Predictable Plan', brand: 'Sample Retailer', peak_c_per_kwh: 29.1, supply_c_per_day: 105.0, saving: 38 }, - { plan_id: 'SAMPLE-3', display_name: 'Standard TOU', brand: 'Sample Retailer', peak_c_per_kwh: 31.4, supply_c_per_day: 98.0, saving: 22 }, -]; - +// ─────────────── Formatters ─────────────── function fmtDollar(v, { signed = false } = {}) { if (v == null || isNaN(v)) return '$--'; const n = Number(v); @@ -783,19 +787,228 @@ if (v == null || isNaN(v)) return '--'; return Number(v).toFixed(1) + 'c'; } +function fmtIntOrDash(v) { + if (v == null || isNaN(v)) return '--'; + return String(Math.round(Number(v))); +} +function fmtRelativeTime(iso) { + if (!iso) return '--'; + const t = Date.parse(iso); + if (isNaN(t)) return '--'; + const deltaSec = Math.round((Date.now() - t) / 1000); + if (deltaSec < 60) return `${deltaSec}s ago`; + if (deltaSec < 3600) return `${Math.round(deltaSec / 60)}m ago`; + if (deltaSec < 86400) return `${Math.round(deltaSec / 3600)}h ago`; + return `${Math.round(deltaSec / 86400)}d ago`; +} +function escapeHtml(s) { + if (s == null) return ''; + return String(s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + +// ─────────────── Entity state store ─────────────── +const states = {}; // entity_id -> state string +const attrs = {}; // entity_id -> attributes object +let selectedPlanId = null; + +function entityState(id) { + const s = states[id]; + if (s == null || s === 'unknown' || s === 'unavailable') return null; + return s; +} +function entityNumber(id) { + const s = entityState(id); + if (s == null) return null; + const n = parseFloat(s); + return isNaN(n) ? null : n; +} +function entityAttrs(id) { + return attrs[id] || {}; +} + +// ─────────────── Empty-state detection ─────────────── +// Plan section 5.3 surprise #3: first-run users (< MIN_DAYS_FOR_ROLLUPS +// days of backfill) should see an "Accruing…" hint, not a misleading $0.00. +function isAccruing() { + const days = entityAttrs(ENTITY.backfillStatus).days_loaded; + if (days == null) return true; + return Number(days) < MIN_DAYS_FOR_ROLLUPS; +} +function accruingDays() { + const days = entityAttrs(ENTITY.backfillStatus).days_loaded; + return days == null ? 0 : Number(days); +} + +// ─────────────── Hero render ─────────────── +function bestAlternativeName() { + const list = entityAttrs(ENTITY.rankedAlternatives).alternatives; + if (!Array.isArray(list) || list.length === 0) return null; + // Sensor attribute is sorted ascending by cheap-rank score + // (lower = better), per cdr/ranking.summarize_for_sensor docs. + const top = list[0]; + if (!top) return null; + const brand = top.brand ? top.brand + ' ' : ''; + const name = top.display_name || top.plan_id || 'Top alternative'; + return brand + name; +} + +function renderHero() { + const accruing = isAccruing(); + // Current cost. + const currentCost = entityNumber(ENTITY[`current_${activeWindow}`]); + const currentEl = $('heroCurrentCost'); + if (accruing) { + currentEl.innerHTML = + 'Accruing… ' + + accruingDays() + ' / ' + BACKFILL_TARGET_DAYS + ''; + } else { + currentEl.textContent = fmtDollar(currentCost); + } + + // Current plan name (lifted from ranked alternatives' "named comparator + // present?" signal or fallback). The current plan's display name isn't + // exposed by the rollup sensors directly; we use the named comparator + // when set, otherwise fall back to a generic label. + const namedSet = entityState(ENTITY[`named_${activeWindow}`]) != null; + $('heroCurrentPlanName').textContent = namedSet + ? 'your pinned plan' + : 'your current plan'; + + // Savings. + const savings = entityNumber(ENTITY[`savings_${activeWindow}`]); + const savingsEl = $('heroSavings'); + savingsEl.classList.remove('positive', 'negative', 'neutral'); + if (accruing) { + savingsEl.innerHTML = + 'Accruing… ' + + accruingDays() + ' / ' + BACKFILL_TARGET_DAYS + ''; + savingsEl.classList.add('neutral'); + } else if (savings == null) { + savingsEl.textContent = '$--'; + savingsEl.classList.add('neutral'); + } else { + savingsEl.textContent = fmtDollar(savings, { signed: true }); + if (savings > 0.005) savingsEl.classList.add('positive'); + else if (savings < -0.005) savingsEl.classList.add('negative'); + else savingsEl.classList.add('neutral'); + } + + // Best-alt name. + const altName = bestAlternativeName(); + $('heroBestAltName').textContent = altName || 'β€”'; + + // Projected annual (extrapolate the active window to 12 months). + // Only meaningful when we have a savings number AND we're not in the + // "accruing" state β€” otherwise display a dash. + const annualEl = $('heroAnnualLine'); + if (accruing || savings == null) { + annualEl.textContent = 'Projected annual: $β€”'; + } else { + const scale = annualMultiplier(activeWindow); + if (scale == null) { + annualEl.textContent = 'Projected annual: $β€”'; + } else { + annualEl.textContent = + 'Projected annual: ' + fmtDollar(savings * scale, { signed: true }); + } + } +} + +function annualMultiplier(win) { + // 365 / window_days. Year is 1x (already a year). + switch (win) { + case 'today': return 365; + case 'week': return 365 / 7; + case 'month': return 365 / 30; + case '3month': return 365 / 90; + case 'year': return 1; + default: return null; + } +} -// Wire row click on the existing sample rows so the drill-in card is -// reachable in the scaffold preview. Replaced in 3.5/2 by data-driven render. -document.querySelectorAll('.ranked-table tbody tr').forEach((tr, i) => { - tr.addEventListener('click', () => { - document.querySelectorAll('.ranked-table tbody tr.selected').forEach((row) => row.classList.remove('selected')); - tr.classList.add('selected'); - const sample = SAMPLE_RANKED[i]; - if (!sample) return; - renderDrill(sample); +// ─────────────── Ranked alternatives render ─────────────── +function renderRanked() { + const altList = entityAttrs(ENTITY.rankedAlternatives).alternatives; + const lastRun = entityAttrs(ENTITY.rankedAlternatives).last_run; + const tbody = $('rankedTbody'); + const table = $('rankedTable'); + const empty = $('rankedEmpty'); + $('rankedMeta').textContent = lastRun ? 'Ranked ' + fmtRelativeTime(lastRun) : ''; + + if (!Array.isArray(altList) || altList.length === 0) { + table.hidden = true; + empty.style.display = 'block'; + tbody.innerHTML = ''; + return; + } + table.hidden = false; + empty.style.display = 'none'; + + // Current monthly cost β€” used to compute a saving-vs-current per row. + // Falls back to null (saving column shows '--') when we don't yet have + // a current-cost rollup. + const currentMonth = entityNumber(ENTITY.current_month); + + let html = ''; + altList.forEach((alt, i) => { + const rank = i + 1; + const pillCls = rank === 1 ? 'rank-pill gold' : 'rank-pill'; + const saving = computeSavingForAlt(alt, currentMonth); + const savingCellHtml = (saving == null) + ? 'β€”' + : '' + + fmtDollar(saving, { signed: true }) + ''; + + html += + '' + + '' + rank + '' + + '' + + '
' + escapeHtml(alt.display_name || alt.plan_id || 'Plan') + '
' + + '
' + escapeHtml(alt.brand || '') + '
' + + '' + + '' + fmtCents(alt.peak_c_per_kwh) + '' + + '' + fmtCents(alt.supply_c_per_day) + '' + + savingCellHtml + + ''; }); -}); + tbody.innerHTML = html; + + // Attach row click handlers. + tbody.querySelectorAll('tr').forEach((tr) => { + const planId = tr.dataset.planId; + if (planId === selectedPlanId) tr.classList.add('selected'); + tr.addEventListener('click', () => { + const alt = altList.find((a) => a.plan_id === planId); + if (!alt) return; + tbody.querySelectorAll('tr.selected').forEach((row) => row.classList.remove('selected')); + tr.classList.add('selected'); + selectedPlanId = planId; + renderDrill(alt); + }); + }); +} + +function computeSavingForAlt(alt, currentMonth) { + // We don't have per-alt monthly cost rollups (only the BEST-alt rollup + // exists), so we surface the project-level savings figure only for the + // top-ranked plan. Lower-ranked plans show 'β€”' rather than fabricate a + // number that doesn't match the rollup sensor. + if (currentMonth == null) return null; + const bestAltMonth = entityNumber(ENTITY.bestAlt_month); + if (bestAltMonth == null) return null; + // Only the #1 (cheapest) plan can claim the full month-saving figure; + // for #2..N we report null. Avoids implying lower-ranked plans deliver + // the same saving as #1. + if (!alt || alt.plan_id == null) return null; + const list = entityAttrs(ENTITY.rankedAlternatives).alternatives; + if (!Array.isArray(list) || list.length === 0) return null; + if (list[0].plan_id !== alt.plan_id) return null; + return currentMonth - bestAltMonth; +} +// ─────────────── Drill-in card ─────────────── function renderDrill(alt) { $('drillTitle').textContent = alt.display_name || alt.plan_id || 'Plan'; $('drillBrand').textContent = alt.brand || ''; @@ -805,25 +1018,75 @@ { label: 'Peak rate', value: fmtCents(alt.peak_c_per_kwh), sub: 'inc GST' }, { label: 'Daily supply', value: fmtCents(alt.supply_c_per_day), sub: 'per day' }, { label: 'Customer type', value: alt.customer_type || 'RESIDENTIAL', sub: '' }, - { label: 'Plan ID', value: (alt.plan_id || '--').slice(0, 18), sub: '' }, + { label: 'Plan ID', value: (alt.plan_id || '--').toString().slice(0, 22), sub: '' }, ]; + if (alt.score != null) { + cells.push({ + label: 'Cheap-rank score', + value: Number(alt.score).toFixed(3), + sub: 'lower is cheaper', + }); + } for (const c of cells) { const div = document.createElement('div'); div.className = 'drill-stat'; div.innerHTML = - '
' + c.label + '
' + - '
' + c.value + '
' + - (c.sub ? '
' + c.sub + '
' : ''); + '
' + escapeHtml(c.label) + '
' + + '
' + escapeHtml(c.value) + '
' + + (c.sub ? '
' + escapeHtml(c.sub) + '
' : ''); stats.appendChild(div); } // Deep-link to PriceHawk's Configure page in HA. Per plan section 5.3 // surprise #2, HA doesn't support per-step deep-linking; we land users - // on the integration page where they tap "Configure β†’ Pin". + // on the integration page where they tap "Configure β†’ Pin as Named + // Comparator". Locked in plan section 9 REVISIT 4. $('drillPinBtn').href = '/config/integrations/integration/pricehawk'; drillCard.classList.add('open'); drillCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } +// ─────────────── Data Health Footer ─────────────── +function renderFooter() { + const bfState = entityState(ENTITY.backfillStatus) || 'idle'; + const bfAttrs = entityAttrs(ENTITY.backfillStatus); + + const stateEl = $('footerBackfillState'); + stateEl.textContent = bfState; + stateEl.classList.remove('muted', 'ok', 'warn', 'err'); + if (bfState === 'complete') stateEl.classList.add('ok'); + else if (bfState === 'running') stateEl.classList.add('warn'); + else if (bfState === 'failed') stateEl.classList.add('err'); + else stateEl.classList.add('muted'); + $('footerBackfillSub').textContent = bfAttrs.error + ? String(bfAttrs.error).slice(0, 80) + : (bfAttrs.last_run ? 'Ran ' + fmtRelativeTime(bfAttrs.last_run) : 'not yet run'); + + $('footerDaysLoaded').textContent = fmtIntOrDash(bfAttrs.days_loaded); + $('footerDaysSub').textContent = isAccruing() + ? 'accruing β€” need ' + MIN_DAYS_FOR_ROLLUPS + '+ for rollups' + : 'history window'; + + const lastRun = entityAttrs(ENTITY.rankedAlternatives).last_run; + $('footerLastRanking').textContent = lastRun ? fmtRelativeTime(lastRun) : '--'; + $('footerLastRankingSub').textContent = lastRun + ? new Date(lastRun).toLocaleString('en-AU', { hour12: false }) + : 'awaiting first run'; + + const altsCount = entityNumber(ENTITY.rankedAlternatives); + $('footerAltsCount').textContent = fmtIntOrDash(altsCount); +} + +// ─────────────── Master render dispatcher ─────────────── +function onStateUpdate(entityId) { + if (!TRACKED_ENTITIES.has(entityId)) return; + // Heuristic: every entity change can affect more than one card, but + // re-rendering all four cards costs <1ms on the device we deploy to + // (HA Green / iPhone) so we don't bother with per-entity dispatch. + renderHero(); + renderRanked(); + renderFooter(); +} + // ─────────────── WebSocket connection ─────────────── // Re-uses the existing dashboard's multi-method auth fallback chain: // 1) URL ?token= (set by dashboard_config.setup_panel_iframe) @@ -899,7 +1162,8 @@ if (msg.type === 'auth_ok') { setConnected(true, 'Connected'); - // 3.5/2 will subscribe + fetch states here. + fetchStates(); + subscribeStateChanges(); return; } @@ -915,6 +1179,35 @@ if (msg.success) cb(msg.result); return; } + + // Initial get_states response (Array<{entity_id, state, attributes}>). + if (msg.type === 'result' && msg.success && Array.isArray(msg.result)) { + let touched = false; + for (const e of msg.result) { + if (e && e.entity_id && TRACKED_ENTITIES.has(e.entity_id)) { + states[e.entity_id] = e.state; + attrs[e.entity_id] = e.attributes || {}; + touched = true; + } + } + if (touched) { + renderHero(); + renderRanked(); + renderFooter(); + } + return; + } + + // Subscribed state_changed event. + if (msg.type === 'event' && msg.event && msg.event.event_type === 'state_changed') { + const d = msg.event.data; + if (d && d.entity_id && TRACKED_ENTITIES.has(d.entity_id) && d.new_state) { + states[d.entity_id] = d.new_state.state; + attrs[d.entity_id] = d.new_state.attributes || {}; + onStateUpdate(d.entity_id); + } + return; + } }; ws.onclose = () => { @@ -932,8 +1225,32 @@ }, reconnectDelay); } +function fetchStates() { + ws.send(JSON.stringify({ id: msgId++, type: 'get_states' })); +} +function subscribeStateChanges() { + ws.send(JSON.stringify({ + id: msgId++, + type: 'subscribe_events', + event_type: 'state_changed', + })); +} + +// Periodic re-render so the "Ran 27s ago / 3h ago" relative timestamps +// in the footer + ranked-meta tick forward without waiting for the next +// state_changed event. 30s matches the coordinator tick cadence. +setInterval(() => { + renderRanked(); + renderFooter(); +}, 30000); + // ─────────────── Boot ─────────────── renderHeroLabels(); +// Render once on cold load so the empty-state ("Waiting for the daily +// ranking job…") is visible even before WebSocket auth completes. +renderHero(); +renderRanked(); +renderFooter(); connect(); From a29cc2c920452072e76d2cebcefa1a9aa9f36686 Mon Sep 17 00:00:00 2001 From: ArticOdin Date: Sun, 17 May 2026 22:48:39 +1000 Subject: [PATCH 3/4] =?UTF-8?q?feat(dashboard):=20Phase=203.5=20commit=203?= =?UTF-8?q?/3=20=E2=80=94=20design-spec=20divergence=20+=20CHANGELOG=20ent?= =?UTF-8?q?ry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps Phase 3.5 with the two non-code deliverables called out in plan section 5.2 commit 3.5/3. assets/DESIGN.claude.md: - New "PriceHawk Dashboard (divergence from this spec)" section at the end of the file. Explains WHY PriceHawk doesn't follow the Claude marketing-site spec (different surface context, different information density, different brand) and WHAT it does inherit (typographic rationale, card-as-surface model, accent-discipline rule). - Documents the PriceHawk token map (--bg-base, --accent-positive etc) for cross-reference. - Keeps the rest of the Claude marketing-site spec intact β€” no edits outside the new appended section. CHANGELOG.md: - Phase 3.5 block at the top of [Unreleased] above the existing 3.4 entry. Documents the dashboard rewrite (entity bindings, period-tab swap, ranked alts render, drill-in, footer, empty-state), the CSP connect-src extension for *.local deployments, the deleted per-provider colour tokens, the deleted Amber-specific cards, and the manual-UAT-only test strategy (per plan section 6.3 table). dashboard_config.py: NO behavioural change. Plan section 5.2 commit 3.5/3 calls for a "verify cache-busting still works" check; verified that `?v=.` is appended in setup_panel_iframe and is independent of dashboard.html contents β€” the rewrite doesn't affect it. No source edit needed. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 96 +++++++++++++++++++++++++++++++++++++++++ assets/DESIGN.claude.md | 85 ++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d18f14..c248109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,102 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Phase 3.5 β€” Dashboard rewrite (multi-plan ranked view) + +Throws away the Amber-vs-current-plan two-comparator dashboard +(2447 LOC) and rebuilds it as a multi-plan ranked-alternatives view +keyed off the Phase 3.2 / 3.3 / 3.4 sensors. Visual seed lifted from +`assets/dashboard-v3-apple.html` β€” dark default, Outfit + IBM Plex +Mono, ambient radial bg, semantic accent tokens (no per-provider +colours). + +#### Added + +- **Full rewrite of `custom_components/pricehawk/www/dashboard.html`** + (~1250 LOC, down from 2447). New card hierarchy per plan section 5.1: + - NAV bar (brand + connection status pill + clock + theme toggle). + - HERO row: current-cost card + savings-vs-best-alt card (with + projected-annual extrapolation). + - PERIOD TABS: `[Today][Week][Month][3 Month][Year]` β€” clicking a + tab swaps the entity binding for every rollup card to the matching + `_today` / `_week` / `_month` / `_3month` / `_year` sensor in + one tick. Active tab persists to `localStorage['pricehawk-window']` + so re-opens land on the user's last view. + - RANKED ALTERNATIVES table rendered from + `sensor.pricehawk_ranked_alternatives.attributes.alternatives[]` + (already sorted by cheap-rank score in `summarize_for_sensor`). + Click a row β†’ drill-in card slides up below. + - DRILL-IN CARD: peak rate / daily supply / customer type / plan ID + / cheap-rank score, plus a "Pin as Named Comparator" button that + deep-links to `/config/integrations/integration/pricehawk` (HA + doesn't support per-step deep-linking; locked in plan section 9 + REVISIT 4). + - DATA HEALTH FOOTER: `sensor.pricehawk_backfill_status` state + (state-coloured: green=complete, amber=running, red=failed) + + `days_loaded` + `ranked_alternatives.last_run` as relative + + absolute time + alternatives count. +- **Empty-state UI for first-run users** (plan section 5.3 surprise #3): + when `backfill_status.days_loaded < 7`, hero rollup values are + replaced with an "Accruing… [n/365]" pill instead of showing a + misleading `$0.00`. Surfaces clearly that we don't have enough + history yet. +- **CSP `connect-src` extended** to include `ws://*.local:*` + + `wss://*.local:*` so the dashboard works on Ryan's HA Green at + `homeassistant.local` (plan section 5.3 surprise #1). Existing + `localhost` + `*.ui.nabu.casa` entries preserved. +- **`assets/DESIGN.claude.md` β€” new PriceHawk Dashboard section** + noting divergence: PriceHawk is a dark data-dashboard inside HA's + sidebar, not a warm-canvas editorial site. Inherits typographic + rationale (humanist sans + mono numerics) and the card-as-surface + model + accent-discipline rule, but uses its own token palette. + The rest of the Claude marketing-site spec stays intact. + +#### Changed + +- **WebSocket auth + URL detection preserved verbatim** from the prior + dashboard: + - `location.protocol === 'https:' ? 'wss://' : 'ws://'` for the WS + URL (AEGIS rule: never hardcode `ws://`). + - Token sourced from URL params first, then `window.parent + .hassConnection`, then `localStorage.hassTokens`, then + `window.parent.localStorage.hassTokens` (AEGIS rule: never + hardcode the token). +- **Per-provider colour tokens deleted** (`--amber-primary`, + `--globird-primary`). Replaced with `--accent-positive` / + `--accent-negative` / `--accent-neutral` / `--accent-warn` β€” matches + the Phase 3.0 pivot away from provider-specific branding. +- **`dashboard_config.setup_panel_iframe` cache-busting unchanged** β€” + the existing `?v=.` query param survives the + rewrite (it's appended to the URL, doesn't touch dashboard.html + itself). Verified by smoke test; no code change. + +#### Removed + +- CSV import card, backfill-trigger button, Amber-API winner card, + GloBird TOU strip, Amber forecast strip, sparkline chart, grid-power + gauge, two-provider rate chart, ZeroHero status card β€” all replaced + by the ranked-alternatives + rollup-sensor model. + +#### Notes + +- **No new JS framework, no build step.** Vanilla JS only, same + constraint as the prior dashboard. All CSS + JS inlined; no CDN + fetches beyond the Google Fonts stylesheet that the prior dashboard + already used. +- **30s setInterval re-render** for the ranked + footer cards so + relative timestamps ("ran 27s ago / 3h ago") tick forward without + waiting on a state_changed event. Cheap (<1ms per tick on HA Green). +- **XSS hardening**: all CDR-sourced strings (plan_id, display_name, + brand, customer_type) pass through `escapeHtml()` before innerHTML + insertion. Defensive β€” current registry payloads don't contain + HTML-ish characters, but future ones might. +- **Manual UAT only** for this commit (per plan section 6.3 table β€” + `3.5 | none | manual on Ryan's HA + JS console`). Local smoke test: + HTML parses cleanly via `html.parser`; JS extracted + run under + Node `--check` + mock-DOM render harness exercising all 5 period + windows + accruing branch + empty-ranked branch + drill render + without throwing. + ### Phase 3.4 β€” Named comparator drill-in Lets the user pin ONE CDR plan from the ranked alternatives list as a diff --git a/assets/DESIGN.claude.md b/assets/DESIGN.claude.md index 0d8c89d..3c17f87 100644 --- a/assets/DESIGN.claude.md +++ b/assets/DESIGN.claude.md @@ -587,3 +587,88 @@ When photography is used (rare β€” mostly testimonials), avatars crop to perfect - Form validation states beyond `{component.text-input-focused}` are not extracted β€” error / success states would need a sign-up or feedback flow to confirm. - The actual Claude product surface (claude.ai chat interface) shares some tokens with the marketing site but adds many product-specific components (chat bubbles, message tools, file upload chips, conversation history sidebar) that are out of scope for this marketing-surface document. - The "agent" / "computer use" demo cards on certain pages display animated Claude controlling a browser β€” the static screenshot doesn't fully capture the animation chrome. + +--- + +## PriceHawk Dashboard (divergence from this spec) + +The PriceHawk HA integration dashboard at +`custom_components/pricehawk/www/dashboard.html` deliberately does NOT +follow the Claude marketing-site spec above. PriceHawk is a dark +**data-dashboard** surfaced inside the Home Assistant sidebar iframe, +not a warm-canvas editorial site, and its visual language is +incompatible with the cream/coral/dark-navy trinity documented in this +file. + +### Why divergence + +- **Surface context**: PriceHawk renders inside HA's chrome alongside + other dark dashboards (Lovelace, Energy, Logbook). A warm-cream canvas + would look broken next to those panels. Most HA users run dark mode + by default; cream-on-cream would be uncomfortable late at night + during high-tariff windows when the dashboard is actually consulted. +- **Information density**: a data-dashboard with 16+ live entity reads + + a ranked alternatives table needs tabular numerals, mono digits, + and high-contrast accent colours. The editorial typography stack + (serif display + humanist sans) doesn't suit dense tabular layouts. +- **Brand independence**: PriceHawk is an open-source HACS integration, + not an Anthropic product. The Anthropic coral / radial-spike mark + doesn't apply here. PriceHawk has its own logo (orange hawk-head + glyph at `custom_components/pricehawk/icon.png`). + +### What PriceHawk DOES inherit from this spec + +- **Typographic rationale**: humanist sans (Outfit, here, vs StyreneB) + for UI text; mono with tabular numerics (IBM Plex Mono, here, vs no + mono in the Claude spec) for all money + rate values. The "use mono + for numbers users compare against each other" rule is unbreakable. +- **Card-as-surface model**: rounded `border-radius` (12-16px), + subtle border, optional 1px gradient top-stroke on hover for + affordance. PriceHawk uses `--card-radius: 16px` matching the Claude + spec's `r-lg: 18px` ballpark. +- **Accent-colour discipline**: ONE positive, ONE negative, ONE + neutral. Don't introduce a fourth accent. Claude uses + primary-coral as its single accent; PriceHawk uses + `--accent-positive` (savings green) + `--accent-negative` (loss red) + + `--accent-neutral` (info blue) + `--accent-warn` (accruing amber) + β€” four because the dashboard surfaces four distinct semantic states, + not as decoration. + +### PriceHawk token map (for reference) + +``` +--bg-base: #070B14 // OLED-friendly true black +--bg-surface: #0C1220 +--bg-card: rgba(15,23,42,0.6) +--text-primary: #F1F5F9 +--text-secondary: #94A3B8 +--text-muted: #64748B +--accent-positive: #10B981 // savings, "you save" +--accent-negative: #EF4444 // loss, "you lose" +--accent-neutral: #38BDF8 // info, current plan / pinned baseline +--accent-warn: #F59E0B // accruing, < 7 days of backfill history +--card-radius: 16px +--card-blur: 20px +``` + +Light theme inverts via `[data-theme="light"]` selector overriding the +same tokens (canvas: `#F5F6FA`, card: `rgba(255,255,255,0.78)`, accents +shift one stop darker for contrast). Theme persists to +`localStorage['pricehawk-theme']`; first-visit defaults to OS +`prefers-color-scheme`. + +### Where to look for PriceHawk's full visual treatment + +- `assets/dashboard-v3-apple.html` β€” the v3 visual seed (1478 LOC + dark-theme mockup; ambient radial bg, noise overlay, Outfit + IBM + Plex Mono). Not the deployed dashboard, but the design-language + source-of-truth. +- `custom_components/pricehawk/www/dashboard.html` β€” the actual + deployed dashboard at `/local/pricehawk/dashboard.html`. Hierarchy: + nav / hero row (current cost + savings) / period tabs + (today|week|month|3month|year) / ranked alternatives table / + drill-in card / data-health footer. + +Don't try to reconcile PriceHawk back into the Claude spec. +They are different products and the visual languages are +deliberately separate. From 0e1ba0eaa47c838f929252197fecd4d1ba7acedb Mon Sep 17 00:00:00 2001 From: ArticOdin Date: Mon, 18 May 2026 00:46:18 +1000 Subject: [PATCH 4/4] fix(docs): add css language to design-spec fenced code block CodeRabbit / markdownlint MD040: the fenced code block listing the PriceHawk CSS custom properties (--bg-base, --bg-surface, --accent-positive et al) opened with a bare ``` instead of ```css. Tag the fence as ``css`` so the markdown renderer applies CSS syntax highlighting and so MD040 stops flagging the block. Content is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- assets/DESIGN.claude.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/DESIGN.claude.md b/assets/DESIGN.claude.md index 3c17f87..5aa7afd 100644 --- a/assets/DESIGN.claude.md +++ b/assets/DESIGN.claude.md @@ -636,7 +636,7 @@ file. ### PriceHawk token map (for reference) -``` +```css --bg-base: #070B14 // OLED-friendly true black --bg-surface: #0C1220 --bg-card: rgba(15,23,42,0.6)