File size: 11,837 Bytes
9d2d895
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
// 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<HormuzTrackerData['status'], string> = {
  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<HormuzTrackerData['status'], string> = {
  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<typeof setInterval> | 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<void> {
    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 = `
      <div class="ero-grid">
        ${this.renderHormuzTile()}
        ${this.renderEuGasTile()}
        ${this.renderBrentTile()}
        ${this.renderActiveDisruptionsTile()}
        ${this.renderFreshnessTile()}
        ${this.renderCrisisDayTile()}
      </div>
    `;
    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 ? `<div class="ero-tile__sub" style="color:${color}">${escapeHtml(sub)}</div>` : '';
  return `
    <div class="ero-tile" ${attrs}>
      <div class="ero-tile__label">${escapeHtml(label)}</div>
      <div class="ero-tile__value" style="color:${color}">${escapeHtml(value)}</div>
      ${subHtml}
    </div>
  `;
}

// CSS is injected once into <head> rather than emitted into the panel body.
// Pre-fix, the freshness setInterval re-rendered every 60s and called
// setContent(html + <style>...) β€” the style tag was torn out and re-inserted
// on every tick. Now the panel HTML is style-free; the rules live in head.
let _riskOverviewStylesInjected = false;
function injectRiskOverviewStylesOnce(): void {
  if (_riskOverviewStylesInjected) return;
  if (typeof document === 'undefined') return;
  const style = document.createElement('style');
  style.setAttribute('data-ero-styles', '');
  style.textContent = RISK_OVERVIEW_CSS;
  document.head.appendChild(style);
  _riskOverviewStylesInjected = true;
}

const RISK_OVERVIEW_CSS = `
  .ero-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
    gap: 8px;
    padding: 8px;
  }
  .ero-tile {
    background: rgba(255, 255, 255, 0.04);
    border: 1px solid rgba(255, 255, 255, 0.08);
    border-radius: 6px;
    padding: 10px 12px;
    min-height: 64px;
    display: flex;
    flex-direction: column;
    justify-content: center;
  }
  .ero-tile__label {
    font-size: 10px;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: rgba(255, 255, 255, 0.55);
    margin-bottom: 4px;
  }
  .ero-tile__value {
    font-size: 18px;
    font-weight: 600;
    line-height: 1.1;
  }
  .ero-tile__sub {
    font-size: 12px;
    margin-top: 2px;
  }
`;