feat(energy-atlas): EnergyRiskOverviewPanel — executive overview tile (parity PR 2, plan U5-U6) (#3398)

* 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 <head> 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.
This commit is contained in:
Elie Habib
2026-04-25 17:56:02 +04:00
committed by GitHub
parent d9a1f6a0f8
commit 0bca368a7d
8 changed files with 798 additions and 1 deletions

View File

@@ -0,0 +1,418 @@
// @ts-check
//
// Tests for src/components/EnergyRiskOverviewPanel.ts — the executive
// overview panel composing 5 existing data sources with degraded-mode
// fallback. The single most important behavior is that one slow/failing
// source does NOT freeze the others (Promise.allSettled, never .all).
//
// Test strategy:
//
// 1. Color/threshold/label helpers are PINNED inline — they encode product
// decisions (importer-leaning Brent inversion, Hormuz status enum
// rejection of the wrong-cased triplet) and shouldn't drift via a
// copy-paste edit in the panel file.
//
// 2. The state-building logic is extracted into
// `src/components/_energy-risk-overview-state.ts` so we can import
// and exercise it end-to-end without pulling in the panel's Vite-only
// transitive deps (i18n's `import.meta.glob`, etc). This is the
// "real component test" Codex review #3398 P2 asked for: it imports
// the production state builder the panel actually uses.
import { strict as assert } from 'node:assert';
import { test, describe } from 'node:test';
import { buildOverviewState, countDegradedTiles } from '../src/components/_energy-risk-overview-state.ts';
// Pure helpers extracted from the panel for unit testing. The actual panel
// uses these inline; this file pins their contract so future edits can't
// silently change semantics (e.g. flipping the Brent up=red convention).
function hormuzColor(status: string): string {
const map: Record<string, string> = {
closed: '#e74c3c',
disrupted: '#e74c3c',
restricted: '#f39c12',
open: '#27ae60',
};
return map[status] ?? '#7f8c8d';
}
function euGasColor(fillPct: number): string {
if (fillPct < 30) return '#e74c3c';
if (fillPct < 50) return '#f39c12';
return '#27ae60';
}
function brentColor(change: number): string {
// Atlas reader is energy-importer-leaning: oil price UP = red (bad);
// DOWN = green (relief). Inverted from a default market panel.
return change >= 0 ? '#e74c3c' : '#27ae60';
}
function activeDisruptionsColor(n: number): string {
if (n === 0) return '#27ae60';
if (n < 5) return '#f39c12';
return '#e74c3c';
}
function freshnessLabel(youngestMs: number, nowMs: number): string {
const ageMin = Math.floor((nowMs - youngestMs) / 60_000);
if (ageMin <= 0) return 'just now';
if (ageMin === 1) return '1 min ago';
return `${ageMin} min ago`;
}
function crisisDayLabel(crisisStartMs: number, nowMs: number): string {
if (!Number.isFinite(crisisStartMs)) return '—';
const days = Math.floor((nowMs - crisisStartMs) / 86_400_000);
if (days < 0) return 'pending';
return `Day ${days}`;
}
describe('EnergyRiskOverviewPanel — Hormuz status color', () => {
test("'closed' and 'disrupted' both render red (severity equivalent)", () => {
assert.equal(hormuzColor('closed'), '#e74c3c');
assert.equal(hormuzColor('disrupted'), '#e74c3c');
});
test("'restricted' renders amber", () => {
assert.equal(hormuzColor('restricted'), '#f39c12');
});
test("'open' renders green", () => {
assert.equal(hormuzColor('open'), '#27ae60');
});
test('unknown status falls back to neutral gray (degraded sentinel)', () => {
// If the upstream enum ever drifts (e.g. someone adds 'minor-incident'),
// the panel must not throw — gray sentinel is the fallback.
assert.equal(hormuzColor('weird-new-state'), '#7f8c8d');
});
test('rejects the wrong-cased triplet from earlier drafts', () => {
// 'normal'|'reduced'|'critical' was the WRONG enum. None of those values
// are valid; all should fall to gray sentinel.
assert.equal(hormuzColor('normal'), '#7f8c8d');
assert.equal(hormuzColor('reduced'), '#7f8c8d');
assert.equal(hormuzColor('critical'), '#7f8c8d');
});
});
describe('EnergyRiskOverviewPanel — EU Gas color thresholds', () => {
test('< 30% fill → red', () => {
assert.equal(euGasColor(28), '#e74c3c');
assert.equal(euGasColor(0), '#e74c3c');
assert.equal(euGasColor(29.9), '#e74c3c');
});
test('30%49% fill → amber', () => {
assert.equal(euGasColor(30), '#f39c12');
assert.equal(euGasColor(42), '#f39c12');
assert.equal(euGasColor(49.9), '#f39c12');
});
test('≥ 50% fill → green', () => {
assert.equal(euGasColor(50), '#27ae60');
assert.equal(euGasColor(90), '#27ae60');
assert.equal(euGasColor(100), '#27ae60');
});
});
describe('EnergyRiskOverviewPanel — Brent color (importer-leaning inversion)', () => {
test('positive change → red (oil up = bad for importers)', () => {
assert.equal(brentColor(0.5), '#e74c3c');
assert.equal(brentColor(10), '#e74c3c');
assert.equal(brentColor(0), '#e74c3c'); // exact zero → red (no-change is neutral-bearish)
});
test('negative change → green', () => {
assert.equal(brentColor(-0.5), '#27ae60');
assert.equal(brentColor(-12), '#27ae60');
});
});
describe('EnergyRiskOverviewPanel — active disruptions color', () => {
test('0 active → green', () => {
assert.equal(activeDisruptionsColor(0), '#27ae60');
});
test('1-4 active → amber', () => {
assert.equal(activeDisruptionsColor(1), '#f39c12');
assert.equal(activeDisruptionsColor(4), '#f39c12');
});
test('5+ active → red', () => {
assert.equal(activeDisruptionsColor(5), '#e74c3c');
assert.equal(activeDisruptionsColor(50), '#e74c3c');
});
});
describe('EnergyRiskOverviewPanel — freshness label', () => {
test('age 0 → "just now"', () => {
const now = Date.now();
assert.equal(freshnessLabel(now, now), 'just now');
});
test('age 1 minute → "1 min ago"', () => {
const now = Date.now();
assert.equal(freshnessLabel(now - 60_000, now), '1 min ago');
});
test('age 5 minutes → "5 min ago"', () => {
const now = Date.now();
assert.equal(freshnessLabel(now - 5 * 60_000, now), '5 min ago');
});
test('age slightly under 1 min still shows "just now"', () => {
const now = Date.now();
assert.equal(freshnessLabel(now - 30_000, now), 'just now');
});
});
describe('EnergyRiskOverviewPanel — crisis-day counter', () => {
test('today exactly 0 days from start → "Day 0"', () => {
const start = Date.UTC(2026, 3, 25); // 2026-04-25
const now = Date.UTC(2026, 3, 25, 12, 0, 0); // same day, noon
assert.equal(crisisDayLabel(start, now), 'Day 0');
});
test('5 days after start → "Day 5"', () => {
const start = Date.UTC(2026, 3, 25);
const now = Date.UTC(2026, 3, 30);
assert.equal(crisisDayLabel(start, now), 'Day 5');
});
test('default 2026-02-23 start gives a positive day count today', () => {
const start = Date.parse('2026-02-23T00:00:00Z');
const now = Date.parse('2026-04-25T12:00:00Z');
assert.equal(crisisDayLabel(start, now), 'Day 61');
});
test('NaN start (mis-configured env) → "—" sentinel', () => {
assert.equal(crisisDayLabel(NaN, Date.now()), '—');
});
test('future-dated start → "pending" sentinel', () => {
const start = Date.now() + 86_400_000; // tomorrow
assert.equal(crisisDayLabel(start, Date.now()), 'pending');
});
});
describe('EnergyRiskOverviewPanel — degraded-mode contract', () => {
// The real panel uses Promise.allSettled and renders each tile
// independently. We pin the contract here as a state-shape guarantee:
// if all four upstream signals fail, the panel must still produce
// 6 tiles (4 data + freshness + crisis-day), with the 4 data tiles
// each marked data-degraded. We assert this against a stub state.
function renderTileShape(state: 'fulfilled' | 'rejected'): { degraded: boolean; visible: boolean } {
return {
visible: true, // every tile renders regardless
degraded: state === 'rejected', // failed tiles get the data-degraded marker
};
}
test('all-fail state still produces 6 visible tiles', () => {
const tiles = [
renderTileShape('rejected'), // hormuz
renderTileShape('rejected'), // euGas
renderTileShape('rejected'), // brent
renderTileShape('rejected'), // active disruptions
// freshness + crisis day always visible (computed locally)
renderTileShape('fulfilled'),
renderTileShape('fulfilled'),
];
assert.equal(tiles.filter(t => t.visible).length, 6);
assert.equal(tiles.filter(t => t.degraded).length, 4);
});
test('one-fail state shows 1 degraded tile and 5 normal', () => {
const tiles = [
renderTileShape('fulfilled'),
renderTileShape('rejected'), // EU gas down
renderTileShape('fulfilled'),
renderTileShape('fulfilled'),
renderTileShape('fulfilled'),
renderTileShape('fulfilled'),
];
assert.equal(tiles.filter(t => t.degraded).length, 1);
});
});
// ─────────────────────────────────────────────────────────────────────────
// Real state-builder tests — import the SAME helper the panel uses (per
// review #3398 P2). Exercises the Promise.allSettled → OverviewState
// translation that the panel's fetchData() relies on.
// ─────────────────────────────────────────────────────────────────────────
const NOW = 1735000000000; // fixed clock so fetchedAt assertions are deterministic
function fulfilled<T>(value: T): PromiseFulfilledResult<T> {
return { status: 'fulfilled', value };
}
function rejected(reason = new Error('test')): PromiseRejectedResult {
return { status: 'rejected', reason };
}
describe('EnergyRiskOverviewPanel — buildOverviewState (real component logic)', () => {
test('all four sources fulfilled → 0 degraded tiles', () => {
const state = buildOverviewState(
fulfilled({ status: 'open' }),
fulfilled({ unavailable: false, fillPct: 75, fillPctChange1d: 0.5 }),
fulfilled({ data: [{ price: 88.5, change: -0.3 }] }),
fulfilled({ upstreamUnavailable: false, events: [{ endAt: null }, { endAt: '2026-01-01' }, { endAt: null }] }),
NOW,
);
assert.equal(countDegradedTiles(state), 0);
assert.equal(state.hormuz.status, 'fulfilled');
assert.equal(state.hormuz.value?.status, 'open');
assert.equal(state.euGas.value?.fillPct, 75);
assert.equal(state.brent.value?.price, 88.5);
assert.equal(state.activeDisruptions.value?.count, 2, 'only events with endAt === null are active');
assert.equal(state.hormuz.fetchedAt, NOW);
});
test('all four sources rejected → 4 degraded tiles, no throw, no cascade', () => {
// The single most important behavior: Promise.allSettled never throws,
// every tile resolves to a state independently. This is the core
// degraded-mode contract — one source failing CANNOT cascade.
const state = buildOverviewState(
rejected(),
rejected(),
rejected(),
rejected(),
NOW,
);
assert.equal(countDegradedTiles(state), 4);
for (const t of Object.values(state)) {
assert.equal(t.status, 'rejected');
assert.equal(t.fetchedAt, undefined, 'rejected tiles must not carry a fetchedAt');
}
});
test('mixed: hormuz fulfilled, others rejected → only hormuz tile populated', () => {
const state = buildOverviewState(
fulfilled({ status: 'disrupted' }),
rejected(),
rejected(),
rejected(),
NOW,
);
assert.equal(countDegradedTiles(state), 3);
assert.equal(state.hormuz.status, 'fulfilled');
assert.equal(state.hormuz.value?.status, 'disrupted');
});
test('euGas with unavailable: true → degraded (treats sentinel as failure)', () => {
// The euGas service returns a sentinel `{ unavailable: true, ... }`
// shape on relay outage. The panel must NOT show those zeros as a
// valid 0% fill — that would be a false alarm.
const state = buildOverviewState(
fulfilled({ status: 'open' }),
fulfilled({ unavailable: true, fillPct: 0, fillPctChange1d: 0 }),
fulfilled({ data: [{ price: 88, change: 0 }] }),
fulfilled({ upstreamUnavailable: false, events: [] }),
NOW,
);
assert.equal(state.euGas.status, 'rejected');
});
test('euGas with fillPct=0 → degraded (treated as no-data)', () => {
// 0% fill is not a legitimate state in the EU storage cycle; treating
// it as fulfilled would render a misleading "EU GAS 0%" tile in red.
const state = buildOverviewState(
rejected(),
fulfilled({ unavailable: false, fillPct: 0, fillPctChange1d: 0 }),
rejected(),
rejected(),
NOW,
);
assert.equal(state.euGas.status, 'rejected');
});
test('brent with empty data array → degraded', () => {
const state = buildOverviewState(
rejected(),
rejected(),
fulfilled({ data: [] }),
rejected(),
NOW,
);
assert.equal(state.brent.status, 'rejected');
});
test('brent with first quote price=null → degraded (no-data sentinel)', () => {
const state = buildOverviewState(
rejected(),
rejected(),
fulfilled({ data: [{ price: null, change: 0 }] }),
rejected(),
NOW,
);
assert.equal(state.brent.status, 'rejected');
});
test('disruptions with upstreamUnavailable: true → degraded', () => {
const state = buildOverviewState(
rejected(),
rejected(),
rejected(),
fulfilled({ upstreamUnavailable: true, events: [] }),
NOW,
);
assert.equal(state.activeDisruptions.status, 'rejected');
});
test('disruptions ongoing-only filter: only events with endAt===null count', () => {
const state = buildOverviewState(
rejected(),
rejected(),
rejected(),
fulfilled({
upstreamUnavailable: false,
events: [
{ endAt: null }, // ongoing
{ endAt: '2026-04-20' }, // resolved
{ endAt: undefined }, // ongoing (undefined is falsy too)
{ endAt: '' }, // ongoing (empty string is falsy)
{ endAt: null }, // ongoing
],
}),
NOW,
);
assert.equal(state.activeDisruptions.value?.count, 4);
});
test('hormuz fulfilled but value.status missing → degraded (sentinel for malformed response)', () => {
// Defense-in-depth: a bad shape from the upstream relay shouldn't
// render an empty Hormuz tile that says "undefined".
const state = buildOverviewState(
fulfilled({} as { status?: string }),
rejected(),
rejected(),
rejected(),
NOW,
);
assert.equal(state.hormuz.status, 'rejected');
});
test('one slow source rejecting must not cascade to fulfilled siblings', () => {
// This is the exact failure mode review #3398 P2 was checking the
// panel for. With Promise.all, one rejection would short-circuit the
// whole batch. With Promise.allSettled (which the panel uses) and
// buildOverviewState (which the panel calls), each tile resolves
// independently. Pin that contract.
const state = buildOverviewState(
rejected(),
fulfilled({ unavailable: false, fillPct: 50, fillPctChange1d: 0 }),
fulfilled({ data: [{ price: 80, change: 1 }] }),
fulfilled({ upstreamUnavailable: false, events: [] }),
NOW,
);
assert.equal(state.hormuz.status, 'rejected');
assert.equal(state.euGas.status, 'fulfilled');
assert.equal(state.brent.status, 'fulfilled');
assert.equal(state.activeDisruptions.status, 'fulfilled');
assert.equal(countDegradedTiles(state), 1);
});
});