// 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, unsafeRawHtml } from '@/utils/sanitize'; import { createLazyClient, getRpcBaseUrl, rpcFetch } from '@/services/rpc-client'; import { fetchHormuzTracker, type HormuzTrackerData } from '@/services/hormuz-tracker'; import { getEuGasStorageData } from '@/services/economic'; import { fetchCommodityQuotes } from '@/services/market'; import { buildOverviewState, type OverviewState } from './_energy-risk-overview-state'; import { SupplyChainServiceClient } from '@/services/generated-rpc-clients'; const getSupplyChainClient = createLazyClient(() => new SupplyChainServiceClient(getRpcBaseUrl(), { fetch: rpcFetch, })); 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 = (() => { try { return import.meta.env.VITE_HORMUZ_CRISIS_START_DATE || DEFAULT_CRISIS_START_DATE; } catch { return 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). getSupplyChainClient().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.setSafeContent(unsafeRawHtml(html, 'legacy Panel.setContent() migration')); } 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 +