From 0bca368a7dfad522494ed8ec4f03152e914cd2fd Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Sat, 25 Apr 2026 17:56:02 +0400 Subject: [PATCH] =?UTF-8?q?feat(energy-atlas):=20EnergyRiskOverviewPanel?= =?UTF-8?q?=20=E2=80=94=20executive=20overview=20tile=20(parity=20PR=202,?= =?UTF-8?q?=20plan=20U5-U6)=20(#3398)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(energy-atlas): EnergyRiskOverviewPanel — executive overview tile (PR 2, plan U5-U6) Lands the consolidated "first fold" surface that peer reference energy-intel sites use as their executive overview. Six tiles in a single panel: 1. Strait of Hormuz status (closed/disrupted/restricted/open) 2. EU gas storage fill % (red <30, amber 30-49, green ≥50) 3. Brent crude price + 1-day change (importer-leaning: up=red, down=green) 4. Active disruption count (filtered to endAt === null) 5. Data freshness ("X min ago" from youngest fetchedAt) 6. Hormuz crisis day counter (default 2026-02-23 start, env-overridable) Per docs/plans/2026-04-25-003-feat-energy-parity-pushup-plan.md PR 2. U5 — Component (src/components/EnergyRiskOverviewPanel.ts): - Composes 5 existing services via Promise.allSettled — never .all. One slow or failing source CANNOT freeze the panel; failed tiles render '—' and carry data-degraded="true" for QA inspection. Single most important behavior — guards against the recurrence of the #3386 panel-stuck bug. - Uses the actual Hormuz status enum 'closed'|'disrupted'|'restricted'|'open' (NOT 'normal'/'reduced'/'critical' — that triplet was a misread in earlier drafts). Test suite explicitly rejects the wrong triplet via the gray sentinel fallback. - Brent color inverted from a default market panel: oil price UP = red (bad for energy importers, the dominant Atlas reader); DOWN = green. - Crisis-day counter sourced from VITE_HORMUZ_CRISIS_START_DATE env (default 2026-02-23). NaN/future-dated values handled with explicit '—' / 'pending' sentinels so the tile never renders 'Day NaN'. - 60s setInterval re-renders the freshness tile only — no new RPCs fire on tick. setInterval cleared in destroy() so panel teardown is clean. - Tests: 24 in tests/energy-risk-overview-panel.test.mts cover Hormuz color enum (including the wrong-triplet rejection), EU gas thresholds, Brent inversion, active-disruption color bands, freshness label formatting, crisis-day counter (today/5-days/NaN/future), and the degraded-mode contract (all-fail still renders 6 tiles with 4 marked data-degraded). U6 — Wiring (5 sites per skill panel-stuck-loading-means-missing-primetask): - src/components/index.ts: barrel export - src/app/panel-layout.ts: import + createPanel('energy-risk-overview', ...) - src/config/panels.ts: priority-1 entry in ENERGY_PANELS (top-of-grid), priority-2 entry in FULL_PANELS (CMD+K-discoverable, default disabled), panelKey added to PANEL_CATEGORY_MAP marketsFinance category - src/App.ts: import type + primeTask kickoff (between energy-disruptions and climate-news in the existing ordering convention) - src/config/commands.ts: panel:energy-risk-overview command with keywords for 'risk overview', 'executive overview', 'hormuz status', 'crisis day' No new RPCs (preserves agent-native parity — every metric the panel shows is already exposed via existing Connect-RPC handlers and bootstrap-cache keys; agents can answer the same questions through the same surface). Tests: typecheck clean; 24 unit tests pass on the panel's pure helpers. Manual visual QA pending PR merge + deploy. Plan section §M effort estimate: ~1.5d. Codex-approved through 8 review rounds against origin/main @ 050073354. * fix(energy-atlas): extract Risk Overview state-builder + real component test (PR2 review) P2 — tests duplicated helper logic instead of testing the real panel (energy-risk-overview-panel.test.mts:10): - The original tests pinned color/threshold helpers but didn't import the panel's actual state-building logic, so the panel could ship with a broken Promise.allSettled wiring while the tests stayed green. Refactor: - Extract the state-building logic into a NEW Vite-free module: src/components/_energy-risk-overview-state.ts. Exports buildOverviewState(hormuz, euGas, brent, disruptions, now) and a countDegradedTiles() helper for tests. - The panel now imports and calls buildOverviewState() directly inside fetchData(); no logic duplication. The Hormuz tile renderer narrows status with an explicit cast at use site. - Why a new module: the panel transitively imports `import.meta.glob` via the i18n service, which doesn't resolve under node:test even with tsx loader. Extracting the testable logic into a Vite-dependency-free module is the cleanest way to exercise the production code from tests, per skill panel-stuck-loading-means- missing-primetask's emphasis on "test the actual production logic, not a copy-paste of it". Tests added (11 real-component cases via the new module): - All four sources fulfilled → 0 degraded. - All four sources rejected → 4 degraded, no throw, no cascade. - Mixed (1 fulfilled, 3 rejected) → only one tile populated. - euGas with `unavailable: true` sentinel → degraded. - euGas with fillPct=0 → degraded (treats as no-data, not "0% red"). - brent empty data array → degraded. - brent first-quote price=null → degraded. - disruptions upstreamUnavailable=true → degraded. - disruptions ongoing filter: counts only endAt-falsy events. - Malformed hormuz response (missing status field) → degraded sentinel. - One rejected source MUST NOT cascade to fulfilled siblings (the core degraded-mode contract — pinned explicitly). Total: 35 tests in this file (was 24; +11 real-component cases). typecheck clean. * fix(energy-atlas): server-side disruptions filter + once-only style + panel name parity (PR2 review) Three Greptile P2 findings on PR #3398: - listEnergyDisruptions called with ongoingOnly:true so the server filters the historical 52-event payload server-side. The state builder still re-filters as defense-in-depth. - RISK_OVERVIEW_CSS injected once into via injectRiskOverviewStylesOnce instead of being emitted into setContent on every render. The 60s freshness setInterval was tearing out and re-inserting the style tag every minute. - FULL_PANELS entry renamed from "Energy Risk Overview" to "Global Energy Risk Overview" to match ENERGY_PANELS and the CMD+K command. --- src/App.ts | 5 + src/app/panel-layout.ts | 2 + src/components/EnergyRiskOverviewPanel.ts | 284 ++++++++++++ src/components/_energy-risk-overview-state.ts | 84 ++++ src/components/index.ts | 1 + src/config/commands.ts | 1 + src/config/panels.ts | 4 +- tests/energy-risk-overview-panel.test.mts | 418 ++++++++++++++++++ 8 files changed, 798 insertions(+), 1 deletion(-) create mode 100644 src/components/EnergyRiskOverviewPanel.ts create mode 100644 src/components/_energy-risk-overview-state.ts create mode 100644 tests/energy-risk-overview-panel.test.mts diff --git a/src/App.ts b/src/App.ts index 9afdfaa13..7ecdd8cba 100644 --- a/src/App.ts +++ b/src/App.ts @@ -44,6 +44,7 @@ import type { PipelineStatusPanel } from '@/components/PipelineStatusPanel'; import type { StorageFacilityMapPanel } from '@/components/StorageFacilityMapPanel'; import type { FuelShortagePanel } from '@/components/FuelShortagePanel'; import type { EnergyDisruptionsPanel } from '@/components/EnergyDisruptionsPanel'; +import type { EnergyRiskOverviewPanel } from '@/components/EnergyRiskOverviewPanel'; import type { ClimateNewsPanel } from '@/components/ClimateNewsPanel'; import type { ConsumerPricesPanel } from '@/components/ConsumerPricesPanel'; import type { DefensePatentsPanel } from '@/components/DefensePatentsPanel'; @@ -351,6 +352,10 @@ export class App { const panel = this.state.panels['energy-disruptions'] as EnergyDisruptionsPanel | undefined; if (panel) primeTask('energy-disruptions', () => panel.fetchData()); } + if (shouldPrime('energy-risk-overview')) { + const panel = this.state.panels['energy-risk-overview'] as EnergyRiskOverviewPanel | undefined; + if (panel) primeTask('energy-risk-overview', () => panel.fetchData()); + } if (shouldPrime('climate-news')) { const panel = this.state.panels['climate-news'] as ClimateNewsPanel | undefined; if (panel) primeTask('climate-news', () => panel.fetchData()); diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index 1527251ac..ccbf1e13b 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -70,6 +70,7 @@ import { StorageFacilityMapPanel, FuelShortagePanel, EnergyDisruptionsPanel, + EnergyRiskOverviewPanel, MacroTilesPanel, FSIPanel, YieldCurvePanel, @@ -891,6 +892,7 @@ export class PanelLayoutManager implements AppModule { this.createPanel('storage-facility-map', () => new StorageFacilityMapPanel()); this.createPanel('fuel-shortages', () => new FuelShortagePanel()); this.createPanel('energy-disruptions', () => new EnergyDisruptionsPanel()); + this.createPanel('energy-risk-overview', () => new EnergyRiskOverviewPanel()); this.createPanel('polymarket', () => new PredictionPanel()); this.createNewsPanel('gov', 'panels.gov'); diff --git a/src/components/EnergyRiskOverviewPanel.ts b/src/components/EnergyRiskOverviewPanel.ts new file mode 100644 index 000000000..23f3543a3 --- /dev/null +++ b/src/components/EnergyRiskOverviewPanel.ts @@ -0,0 +1,284 @@ +// Energy Risk Overview Panel +// +// One consolidated executive surface composing five existing data sources: +// 1. Hormuz status (vessels/day + status from src/services/hormuz-tracker.ts) +// 2. EU Gas storage fill % (bootstrap-cached `euGasStorage` + RPC fallback) +// 3. Brent crude price + 1-day delta (BZ=F via fetchCommodityQuotes) +// 4. Active disruptions count (listEnergyDisruptions filtered to endAt === null) +// 5. Data freshness (now - youngest fetchedAt across the four upstream signals) +// +// Plus a "Day N of crisis" counter computed at render time from a configurable +// pinned start date. NOT an editorial issue counter — we don't ship weekly +// briefings yet — but the same surface area at the top of the energy variant +// grid that peer reference sites use as their first-fold consolidator. +// +// Degraded-mode contract: every tile renders independently. If one of the five +// fetches rejects, that tile shows "—" and a `data-degraded="true"` attribute +// for QA inspection; the others render normally. Promise.allSettled — never +// Promise.all. This is the single most important behavior of the panel: a +// stuck Hormuz tracker must not freeze the whole executive overview. + +import { Panel } from './Panel'; +import { escapeHtml } from '@/utils/sanitize'; +import { getRpcBaseUrl } from '@/services/rpc-client'; +import { fetchHormuzTracker, type HormuzTrackerData } from '@/services/hormuz-tracker'; +import { getEuGasStorageData } from '@/services/economic'; +import { fetchCommodityQuotes } from '@/services/market'; +import { SupplyChainServiceClient } from '@/generated/client/worldmonitor/supply_chain/v1/service_client'; +import { buildOverviewState, type OverviewState } from './_energy-risk-overview-state'; + +const supplyChain = new SupplyChainServiceClient(getRpcBaseUrl(), { + fetch: (...args: Parameters) => globalThis.fetch(...args), +}); + +const BRENT_SYMBOL = 'BZ=F'; +const BRENT_META = [{ symbol: BRENT_SYMBOL, name: 'Brent Crude', display: 'BRENT' }]; + +// Default pinned crisis-start date for the running Hormuz situation. Overridable +// via VITE_HORMUZ_CRISIS_START_DATE so the date can be re-pinned without a +// redeploy when the editorial framing shifts. +const DEFAULT_CRISIS_START_DATE = '2026-02-23'; +const CRISIS_START_DATE: string = + (import.meta.env?.VITE_HORMUZ_CRISIS_START_DATE as string | undefined) || + DEFAULT_CRISIS_START_DATE; +const CRISIS_START_MS = Date.parse(`${CRISIS_START_DATE}T00:00:00Z`); + +// Map Hormuz status enum → severity color. Values come from +// src/services/hormuz-tracker.ts:20: 'closed' | 'disrupted' | 'restricted' | 'open'. +// NOT 'normal'/'reduced'/'critical' — that triplet was a misread in earlier +// drafts and would silently render as undefined. +const HORMUZ_STATUS_COLOR: Record = { + closed: '#e74c3c', // red — passage closed + disrupted: '#e74c3c', // red — significant disruption + restricted: '#f39c12', // amber — partial constraints + open: '#27ae60', // green — flowing normally +}; +const HORMUZ_STATUS_LABEL: Record = { + closed: 'Closed', + disrupted: 'Disrupted', + restricted: 'Restricted', + open: 'Open', +}; + +// State shape lives in _energy-risk-overview-state.ts so it can be tested +// under node:test without pulling in Vite-only modules. The panel's +// `state` field is typed loosely (just OverviewState) — the per-tile +// renderers cast `value` based on the tile they're rendering. The only +// downside is the Hormuz tile loses its enum literal type from +// HormuzTrackerData['status']; renderers narrow it again at use site. + +const EMPTY_STATE: OverviewState = { + hormuz: { status: 'pending' }, + euGas: { status: 'pending' }, + brent: { status: 'pending' }, + activeDisruptions: { status: 'pending' }, +}; + +export class EnergyRiskOverviewPanel extends Panel { + private state: OverviewState = EMPTY_STATE; + private freshnessTickHandle: ReturnType | null = null; + + constructor() { + super({ + id: 'energy-risk-overview', + title: 'Global Energy Risk Overview', + defaultRowSpan: 1, + infoTooltip: + 'Consolidated executive view: Strait of Hormuz vessel status, EU gas ' + + 'storage fill, Brent crude price + 1-day change, active disruption ' + + 'count, data freshness, and a configurable crisis-day counter. Each ' + + 'tile renders independently; one source failing does not block the ' + + 'others.', + }); + } + + public destroy(): void { + if (this.freshnessTickHandle !== null) { + clearInterval(this.freshnessTickHandle); + this.freshnessTickHandle = null; + } + super.destroy?.(); + } + + public async fetchData(): Promise { + const [hormuz, euGas, brent, disruptions] = await Promise.allSettled([ + fetchHormuzTracker(), + getEuGasStorageData(), + fetchCommodityQuotes(BRENT_META), + // ongoingOnly=true: the panel only ever shows the count of active + // disruptions, so let the server filter rather than ship the full + // historical 52-event payload to be filtered client-side. This was + // a Greptile P2 finding (over-fetch); buildOverviewState's count + // calculation handles either response (the redundant client-side + // filter remains as defense-in-depth in the state builder). + supplyChain.listEnergyDisruptions({ assetId: '', assetType: '', ongoingOnly: true }), + ]); + this.state = buildOverviewState(hormuz, euGas, brent, disruptions, Date.now()); + + if (!this.element?.isConnected) return; + this.render(); + + // Once we have data, kick a 60s freshness re-render so the "X minutes ago" + // string ticks live. No new RPCs — this only updates the freshness label. + if (this.freshnessTickHandle === null) { + this.freshnessTickHandle = setInterval(() => { + if (this.element?.isConnected) this.render(); + }, 60_000); + } + } + + private render(): void { + injectRiskOverviewStylesOnce(); + const html = ` +
+ ${this.renderHormuzTile()} + ${this.renderEuGasTile()} + ${this.renderBrentTile()} + ${this.renderActiveDisruptionsTile()} + ${this.renderFreshnessTile()} + ${this.renderCrisisDayTile()} +
+ `; + this.setContent(html); + } + + private renderHormuzTile(): string { + const t = this.state.hormuz; + if (t.status !== 'fulfilled' || !t.value) { + return tileHtml('Hormuz', '—', '#7f8c8d', 'data-degraded="true"'); + } + // After extracting state-builder into a Vite-free module, the Hormuz + // tile's value.status is typed as plain string (not the enum literal + // union). Cast at use site so the lookup tables index correctly. + const status = t.value.status as HormuzTrackerData['status']; + const color = HORMUZ_STATUS_COLOR[status] ?? '#7f8c8d'; + const label = HORMUZ_STATUS_LABEL[status] ?? t.value.status; + return tileHtml('Hormuz', label, color); + } + + private renderEuGasTile(): string { + const t = this.state.euGas; + if (t.status !== 'fulfilled' || !t.value) { + return tileHtml('EU Gas', '—', '#7f8c8d', 'data-degraded="true"'); + } + const fill = t.value.fillPct.toFixed(0); + // Below 30% during refill season is critical; below 50% is amber. + const color = t.value.fillPct < 30 ? '#e74c3c' : t.value.fillPct < 50 ? '#f39c12' : '#27ae60'; + return tileHtml('EU Gas', `${fill}%`, color); + } + + private renderBrentTile(): string { + const t = this.state.brent; + if (t.status !== 'fulfilled' || !t.value) { + return tileHtml('Brent', '—', '#7f8c8d', 'data-degraded="true"'); + } + const price = `$${t.value.price.toFixed(2)}`; + const change = t.value.change; + const sign = change >= 0 ? '+' : ''; + const deltaText = `${sign}${change.toFixed(2)}%`; + // Oil price up = bad for energy importers (the dominant Atlas reader). + // Up = red. Down = green. Inverted from a usual market panel. + const color = change >= 0 ? '#e74c3c' : '#27ae60'; + return tileHtml('Brent', price, color, '', deltaText); + } + + private renderActiveDisruptionsTile(): string { + const t = this.state.activeDisruptions; + if (t.status !== 'fulfilled' || !t.value) { + return tileHtml('Active disruptions', '—', '#7f8c8d', 'data-degraded="true"'); + } + const n = t.value.count; + const color = n === 0 ? '#27ae60' : n < 5 ? '#f39c12' : '#e74c3c'; + return tileHtml('Active disruptions', String(n), color); + } + + private renderFreshnessTile(): string { + // Youngest fetchedAt across all 4 upstream signals. + const tiles = [this.state.hormuz, this.state.euGas, this.state.brent, this.state.activeDisruptions]; + const fetchedAts = tiles + .map(t => t.fetchedAt) + .filter((v): v is number => typeof v === 'number'); + if (fetchedAts.length === 0) { + return tileHtml('Updated', '—', '#7f8c8d', 'data-degraded="true"'); + } + const youngest = Math.max(...fetchedAts); + const ageMin = Math.floor((Date.now() - youngest) / 60_000); + const label = ageMin <= 0 ? 'just now' : ageMin === 1 ? '1 min ago' : `${ageMin} min ago`; + return tileHtml('Updated', label, '#7f8c8d'); + } + + private renderCrisisDayTile(): string { + if (!Number.isFinite(CRISIS_START_MS)) { + // Mis-configured env (Date.parse returned NaN). Fail loudly via "—" + // rather than rendering "Day NaN" or "Day -50". + return tileHtml('Hormuz crisis', '—', '#7f8c8d', 'data-degraded="true"'); + } + const days = Math.floor((Date.now() - CRISIS_START_MS) / 86_400_000); + if (days < 0) { + // Future-dated start: still render but with a sentinel value. + return tileHtml('Hormuz crisis', 'pending', '#7f8c8d'); + } + return tileHtml('Hormuz crisis', `Day ${days}`, '#7f8c8d'); + } +} + +function tileHtml(label: string, value: string, color: string, attrs = '', sub = ''): string { + const subHtml = sub ? `
${escapeHtml(sub)}
` : ''; + return ` +
+
${escapeHtml(label)}
+
${escapeHtml(value)}
+ ${subHtml} +
+ `; +} + +// CSS is injected once into rather than emitted into the panel body. +// Pre-fix, the freshness setInterval re-rendered every 60s and called +// setContent(html +