import { Panel } from './Panel'; import { escapeHtml, unsafeRawHtml } from '@/utils/sanitize'; import { createLazyClient, getRpcBaseUrl, rpcFetch } from '@/services/rpc-client'; import { attributionFooterHtml, ATTRIBUTION_FOOTER_CSS } from '@/utils/attribution-footer'; import type { ListEnergyDisruptionsResponse, EnergyDisruptionEntry, } from '@/generated/client/worldmonitor/supply_chain/v1/service_client'; import { formatEventWindow, formatCapacityOffline, statusForEvent, type DisruptionStatus, } from '@/shared/disruption-timeline'; import { SupplyChainServiceClient } from '@/services/generated-rpc-clients'; const getSupplyChainClient = createLazyClient(() => new SupplyChainServiceClient(getRpcBaseUrl(), { fetch: rpcFetch, })); // One glyph per event type so readers can scan the timeline by nature of // disruption. Kept terse โ€” the type string itself is shown next to the glyph. const EVENT_GLYPH: Record = { sabotage: '๐Ÿ’ฅ', sanction: '๐Ÿšซ', maintenance: '๐Ÿ”ง', mechanical: 'โš™๏ธ', weather: '๐ŸŒ€', commercial: '๐Ÿ’ผ', war: 'โš”๏ธ', other: 'โ€ข', }; const STATUS_COLOR: Record = { ongoing: '#e74c3c', resolved: '#7f8c8d', unknown: '#95a5a6', }; const EVENT_TYPE_FILTERS: Array<{ key: string; label: string }> = [ { key: '', label: 'All events' }, { key: 'sabotage', label: 'Sabotage' }, { key: 'sanction', label: 'Sanction' }, { key: 'mechanical', label: 'Mechanical' }, { key: 'maintenance', label: 'Maintenance' }, { key: 'war', label: 'War' }, { key: 'weather', label: 'Weather' }, { key: 'commercial', label: 'Commercial' }, { key: 'other', label: 'Other' }, ]; function statusChip(status: DisruptionStatus): string { const color = STATUS_COLOR[status] ?? STATUS_COLOR.unknown; const label = status.charAt(0).toUpperCase() + status.slice(1); return `${escapeHtml(label)}`; } /** * Standalone reverse-chronological timeline of every disruption event in * `energy:disruptions:v1`. Per plan ยงL #4 this panel is the primary * disruption surface โ€” PipelineStatusPanel and StorageFacilityMapPanel * each render an *asset-scoped* slice of the same events in their drawer, * but neither lists the global log. * * Click-through dispatches the same events those panels already listen * for (`energy:open-pipeline-detail` / `energy:open-storage-facility-detail`), * so the event-routing contract stays the same as CountryDeepDivePanel's * disruption row โ€” no new panel-open protocol introduced. */ export class EnergyDisruptionsPanel extends Panel { private data: ListEnergyDisruptionsResponse | null = null; private activeTypeFilter = ''; private ongoingOnly = false; constructor() { super({ id: 'energy-disruptions', title: 'Energy Disruptions Log', defaultRowSpan: 2, infoTooltip: 'Curated log of disruption events affecting oil & gas pipelines and ' + 'storage facilities โ€” sabotage, sanctions, maintenance, mechanical, ' + 'weather, war, commercial. Each event ties back to a seeded asset; ' + 'click a row to jump to the pipeline / storage panel with that event ' + 'highlighted. See /docs/methodology/disruptions for the schema.', }); // Event delegation on the persistent `content` element. Panel.setContent // debounces the DOM write by 150ms (see Panel.ts:1025), so attaching // listeners immediately after setContent() in render() would target the // stale DOM โ€” chips, rows, and the ongoing-toggle button would all be // silently non-interactive. Codex P1 on PR #3378. // // Delegating from the persistent parent sidesteps the debounce entirely: // the handler uses `closest(...)` on the clicked element to route by // data-attributes, so it works regardless of whether the DOM has // flushed yet or has been re-rendered since the last filter change. this.content.addEventListener('click', this.handleContentClick); } private handleContentClick = (e: Event): void => { const target = e.target as HTMLElement | null; if (!target) return; const filterBtn = target.closest('[data-filter-type]'); if (filterBtn) { this.setTypeFilter(filterBtn.dataset.filterType ?? ''); return; } const ongoingBtn = target.closest('[data-toggle-ongoing]'); if (ongoingBtn) { this.toggleOngoingOnly(); return; } const row = target.closest('tr.ed-row'); if (row) { const eventId = row.dataset.eventId; const assetId = row.dataset.assetId; const assetType = row.dataset.assetType; if (eventId && assetId && assetType) { this.dispatchOpenAsset(eventId, assetId, assetType); } } }; public async fetchData(): Promise { try { const live = await getSupplyChainClient().listEnergyDisruptions({ assetId: '', assetType: '', ongoingOnly: false, }); if (!this.element?.isConnected) return; // Distinguish upstream unavailability from a healthy empty result. // The server contract (see list-energy-disruptions.ts) returns // `upstreamUnavailable: true` only when Redis itself can't be // read; an empty `events` array with `upstreamUnavailable: false` // is a valid response shape the UI should render as "no events // match" rather than as an error. Conflating the two previously // showed a retry button on what was a legitimate empty state. if (live.upstreamUnavailable) { this.showError('Energy disruptions log unavailable', () => void this.fetchData()); return; } this.data = live; this.render(); } catch (err) { if (this.isAbortError(err)) return; if (!this.element?.isConnected) return; this.showError('Energy disruptions log error', () => void this.fetchData()); } } private setTypeFilter(type: string): void { this.activeTypeFilter = type; this.render(); } private toggleOngoingOnly(): void { this.ongoingOnly = !this.ongoingOnly; this.render(); } private filterEvents(): EnergyDisruptionEntry[] { if (!this.data) return []; let events = this.data.events; if (this.activeTypeFilter) { events = events.filter(e => e.eventType === this.activeTypeFilter); } if (this.ongoingOnly) { events = events.filter(e => !e.endAt); } // Newest first. Server already sorts by startAt DESC but defensive // sort keeps the UI stable when filters reshuffle. return [...events].sort((a, b) => b.startAt.localeCompare(a.startAt)); } private render(): void { if (!this.data) return; // Compute once โ€” previously filterEvents() ran twice per render, once // for the row HTML and again for filteredCount. Trivial for 52 events // but the redundant sort on every render was noise. Codex P2. const filtered = this.filterEvents(); const rows = filtered.map(e => this.renderRow(e)).join(''); const totalCount = this.data.events.length; const ongoingCount = this.data.events.filter(e => !e.endAt).length; const filteredCount = filtered.length; const summary = this.activeTypeFilter || this.ongoingOnly ? `${filteredCount} shown ยท ${totalCount} total ยท ${ongoingCount} ongoing` : `${totalCount} events ยท ${ongoingCount} ongoing`; const typeButtons = EVENT_TYPE_FILTERS.map(f => { const active = f.key === this.activeTypeFilter; return ``; }).join(''); const ongoingBtn = ``; const attribution = attributionFooterHtml({ sourceType: 'classifier', method: 'curated event log', sampleSize: totalCount, sampleLabel: 'disruption events', updatedAt: this.data.fetchedAt, classifierVersion: this.data.classifierVersion, creditName: 'Operator press + regulator filings + OFAC/EU sanctions + major wire', creditUrl: '/docs/methodology/disruptions', }); this.setSafeContent(unsafeRawHtml(`
${escapeHtml(summary)}
${typeButtons}${ongoingBtn}
${rows || ``}
Event Asset Window Offline Status
No events match the current filter.
${attribution}
${ATTRIBUTION_FOOTER_CSS} `, 'legacy Panel.setContent() migration')); // No inline listener attachment โ€” the constructor registers a single // delegated click handler on `this.content` that routes by data- // attribute via `closest(...)`. Attaching listeners here would target // the stale DOM because Panel.setContent() debounces by 150ms. } private dispatchOpenAsset(_eventId: string, assetId: string, assetType: string): void { // Dispatch only the {pipelineId, facilityId} field the receiving // drawers actually consume today (see PipelineStatusPanel and // StorageFacilityMapPanel `openDetailHandler`). The row click // jumps the user to the asset โ€” they see the full disruption // timeline for that asset and can locate the specific event // visually. A future PR can add a `highlightEventId` contract // with matching drawer-side rendering (scroll-into-view + visual // emphasis); until then, emitting an unread field was a misleading // API surface (Codex P2). const detail = assetType === 'storage' ? { facilityId: assetId } : { pipelineId: assetId }; const eventName = assetType === 'storage' ? 'energy:open-storage-facility-detail' : 'energy:open-pipeline-detail'; window.dispatchEvent(new CustomEvent(eventName, { detail })); } private renderRow(e: EnergyDisruptionEntry): string { const glyph = EVENT_GLYPH[e.eventType] ?? 'โ€ข'; const status = statusForEvent({ startAt: e.startAt, endAt: e.endAt || undefined }); const eventWindow = formatEventWindow(e.startAt, e.endAt || undefined); const offline = formatCapacityOffline(e.capacityOfflineBcmYr, e.capacityOfflineMbd); const causeChain = e.causeChain.join(' โ†’ ') || 'โ€”'; return `
${glyph} ${escapeHtml(e.eventType)}
${escapeHtml(e.shortDescription)}
${escapeHtml(causeChain)}
${escapeHtml(e.assetType)} ${escapeHtml(e.assetId)} ${escapeHtml(eventWindow)} ${escapeHtml(offline || 'โ€”')} ${statusChip(status)} `; } }