feat(energy-atlas): EnergyDisruptionsPanel standalone timeline (§L #4)#3378
Conversation
Closes gap #4 from docs/internal/energy-atlas-registry-expansion.md §L. Before this PR, the 52 disruption events in `energy:disruptions:v1` were only reachable by drilling into a specific pipeline or storage facility — PipelineStatusPanel and StorageFacilityMapPanel each render an asset-scoped slice of the log inside their drawers, but no surface listed the global event log. This panel makes the full log first-class. Shape: - Reverse-chronological table (newest first) of every event. - Filter chips: event type (sabotage, sanction, maintenance, mechanical, weather, war, commercial, other) + "ongoing only" toggle. - Row click dispatches the existing `energy:open-pipeline-detail` or `energy:open-storage-facility-detail` CustomEvent with `{assetId, highlightEventId}` — no new open-panel protocol introduced. Mirrors the CountryDeepDivePanel disruption row contract from PR #3377. - Uses `src/shared/disruption-timeline.ts` formatters (formatEventWindow, formatCapacityOffline, statusForEvent) that PipelineStatus/StorageFacilityMap already use — consistent UI across all three disruption surfaces. Wiring: - `src/components/EnergyDisruptionsPanel.ts` — new (~230 lines). - `src/components/index.ts` — export. - `src/app/panel-layout.ts` — `this.createPanel('energy-disruptions', () => new EnergyDisruptionsPanel())` alongside the other three atlas panels at :892. - `src/config/panels.ts` — add to `FULL_PANELS` (priority 2, next to fuel-shortages) + `ENERGY_PANELS` (priority 1, top tier) + `PANEL_CATEGORY_MAP.marketsFinance` list alongside the other atlas panels. - `src/config/commands.ts` — CMD+K entry `panel:energy-disruptions` with keywords matching the user vocabulary (sabotage, sanctions events, force majeure, drone strike, nord stream sabotage). Not done in this PR: - No new map pin layer — per plan §Q (Codex approved), disruptions stay a tabular/timeline surface; map assets (pipelines + storage) already show disruption markers on click. - No direct globe-mode or SVG-fallback rendering needs — panel is pure DOM, not a map layer. Test plan: - [x] npm run typecheck (clean) - [x] npm run test:data (6694/6694 pass) - [ ] Manual: CMD+K "disruption log" → panel opens with 52 events, newest first. Click "Sabotage" chip → narrows to sabotage events only. Click a Nord Stream row → PipelineStatusPanel opens with that event highlighted.
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Greptile SummaryThis PR adds
Confidence Score: 3/5Not safe to merge — the primary user-facing feature (filter chips, ongoing toggle, row click-through) is completely non-functional due to the setContent debounce / event-listener timing bug. A single P1 defect causes every interactive element in the new panel to silently fail. Visually the panel renders correctly, but the core advertised behaviors (filter by event type, toggle ongoing, click a row to open the asset panel) are all broken. The wiring changes are clean and correct; the bug is isolated to EnergyDisruptionsPanel.ts and has a straightforward fix via event delegation. src/components/EnergyDisruptionsPanel.ts — the post-setContent event-listener attachment block (lines 193–214) needs to be replaced with delegation on this.content. Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant EnergyDisruptionsPanel
participant PanelBase
participant SupplyChainServiceClient
participant PipelineStatusPanel
participant StorageFacilityMapPanel
User->>EnergyDisruptionsPanel: CMD+K disruption log
EnergyDisruptionsPanel->>PanelBase: showLoading()
EnergyDisruptionsPanel->>SupplyChainServiceClient: listEnergyDisruptions
SupplyChainServiceClient-->>EnergyDisruptionsPanel: ListEnergyDisruptionsResponse (52 events)
EnergyDisruptionsPanel->>PanelBase: setContent(html) [150ms debounce]
Note over EnergyDisruptionsPanel: querySelector() runs NOW on old DOM — no listeners attached
Note over PanelBase: 150ms later innerHTML updated — chips and rows rendered but unclickable
User->>EnergyDisruptionsPanel: Click filter chip
Note over User: No response — listener never attached
Note over EnergyDisruptionsPanel: With event delegation fix
User->>EnergyDisruptionsPanel: Click filter chip
EnergyDisruptionsPanel->>EnergyDisruptionsPanel: setTypeFilter -> render()
User->>EnergyDisruptionsPanel: Click table row
EnergyDisruptionsPanel->>PipelineStatusPanel: window.dispatchEvent energy:open-pipeline-detail
EnergyDisruptionsPanel->>StorageFacilityMapPanel: window.dispatchEvent energy:open-storage-facility-detail
Reviews (1): Last reviewed commit: "feat(energy-atlas): EnergyDisruptionsPan..." | Re-trigger Greptile |
| .ed-row { cursor: pointer; } | ||
| .ed-row:hover td { background: rgba(255,255,255,0.03); } | ||
| .ed-event { font-weight: 600; color: var(--text, #eee); } | ||
| .ed-sub { font-size: 9px; color: var(--text-dim, #888); text-transform: uppercase; letter-spacing: 0.04em; } | ||
| .ed-asset-type { display: inline-block; padding: 1px 6px; border-radius: 8px; font-size: 8px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; background: rgba(255,255,255,0.08); color: var(--text-dim, #aaa); margin-right: 4px; } | ||
| .ed-badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 9px; font-weight: 700; color: #fff; text-transform: uppercase; letter-spacing: 0.04em; } | ||
| .ed-empty { text-align: center; color: var(--text-dim, #888); padding: 20px; font-style: italic; } | ||
| .ed-offline { font-family: monospace; font-size: 10px; color: var(--text, #eee); } | ||
| </style> | ||
| `); | ||
|
|
||
| const table = this.element?.querySelector('.ed-table') as HTMLTableElement | null; | ||
| table?.querySelectorAll<HTMLTableRowElement>('tr.ed-row').forEach(tr => { | ||
| const eventId = tr.dataset.eventId; | ||
| const assetId = tr.dataset.assetId; | ||
| const assetType = tr.dataset.assetType; | ||
| if (!eventId || !assetId || !assetType) return; | ||
| tr.addEventListener('click', () => this.dispatchOpenAsset(eventId, assetId, assetType)); | ||
| }); | ||
|
|
||
| this.element?.querySelectorAll<HTMLButtonElement>('[data-filter-type]').forEach(btn => { | ||
| btn.addEventListener('click', () => this.setTypeFilter(btn.dataset.filterType ?? '')); |
There was a problem hiding this comment.
Event listeners attached before DOM update, all interactions silently broken
setContent() in the Panel base class has a 150 ms debounce — it stores the HTML in pendingContentHtml and only calls setContentImmediate (which actually writes innerHTML) after the timer fires. The querySelector calls that immediately follow setContent() here execute synchronously against the old DOM (the loading-spinner state on first render, or the previous filter result on re-renders), so .ed-table, [data-filter-type], and [data-toggle-ongoing] are never found and no listeners are ever attached. Visually the panel renders correctly after 150 ms, but every user interaction — filter chips, ongoing toggle, row click-through — is permanently non-functional.
The fix is to use event delegation on the persistent this.content element, matching the same pattern Panel uses for its retry button:
// In constructor (or once after first render):
this.content.addEventListener('click', (e) => {
const filterBtn = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-filter-type]');
if (filterBtn) { this.setTypeFilter(filterBtn.dataset.filterType ?? ''); return; }
const ongoingBtn = (e.target as HTMLElement).closest<HTMLButtonElement>('[data-toggle-ongoing]');
if (ongoingBtn) { this.toggleOngoingOnly(); return; }
const row = (e.target as HTMLElement).closest<HTMLTableRowElement>('tr.ed-row');
if (row?.dataset.eventId && row.dataset.assetId && row.dataset.assetType) {
this.dispatchOpenAsset(row.dataset.eventId, row.dataset.assetId, row.dataset.assetType);
}
});This listener sits on a persistent DOM node and works correctly regardless of when setContent flushes.
| const rows = this.filterEvents().map(e => this.renderRow(e)).join(''); | ||
| const totalCount = this.data.events.length; | ||
| const ongoingCount = this.data.events.filter(e => !e.endAt).length; | ||
| const filteredCount = this.filterEvents().length; |
There was a problem hiding this comment.
filterEvents() called twice per render
filterEvents() is called once to build rows and a second time to compute filteredCount. With 52 events this is negligible, but both calls do the same filtering + sort — capturing the result once avoids the redundancy.
| const rows = this.filterEvents().map(e => this.renderRow(e)).join(''); | |
| const totalCount = this.data.events.length; | |
| const ongoingCount = this.data.events.filter(e => !e.endAt).length; | |
| const filteredCount = this.filterEvents().length; | |
| 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; |
…te (review P2)
Two Codex P2 findings on this PR:
1. Row click dispatched `highlightEventId` but neither
PipelineStatusPanel nor StorageFacilityMapPanel consumes it. The
UI's implicit promise (event-specific highlighting) wasn't
delivered — clickthrough was asset-generic, and the extra field
on the wire was a misleading API surface.
Fix: drop `highlightEventId` from the dispatched detail. Row click
now opens the asset drawer with just {pipelineId, facilityId}, the
fields the receivers actually consume. User sees the full
disruption timeline for that asset and locates the event visually.
A future PR can add real highlight support by:
- drawers accept `highlightEventId` in their openDetailHandler
- loadDetail stores it and renderDisruptionTimeline scrolls +
emphasises the matching event
- re-add `highlightEventId` to the dispatch here, symmetrically
in CountryDeepDivePanel (which has the same wire emission)
The internal `_eventId` parameter is kept as a plumb-through so
that future work is a drawer-side change, not a re-plumb.
2. `events.length === 0` was conflated with `upstreamUnavailable` and
triggered the error UI. The server contract (list-energy-disruptions
handler) returns `upstreamUnavailable: false` with an empty events
array when Redis is up but has no entries matching the filter — a
legitimate empty state, not a fetch failure.
Fix: gate `showError` on `upstreamUnavailable` alone. Empty results
fall through to the normal render, where the table's
`No events match the current filter` row already handles the case.
Typecheck clean, test:data 6694/6694 pass.
Codex P2: loadDisruptionsForCountry dispatched `highlightEventId` but
neither PipelineStatusPanel nor StorageFacilityMapPanel consumes it
(the openDetailHandler reads only pipelineId / facilityId). The UI's
implicit promise (event-specific highlighting) wasn't delivered —
clickthrough was asset-generic, and the extra wire field was a
misleading API surface.
Fix: emit only {pipelineId, facilityId} in the dispatched detail.
Row click opens the asset drawer; user sees the full per-asset
disruption timeline and locates the event visually.
Symmetric fix for PR #3378's EnergyDisruptionsPanel — both emitters
now match the drawer contract exactly. Re-add `highlightEventId`
here when the drawer panels ship matching consumer code
(openDetailHandler accepts it, loadDetail stores it,
renderDisruptionTimeline scrolls + emphasises the matching event).
Typecheck clean, test:data 6698/6698 pass.
Codex P1: Panel.setContent() debounces the DOM write by 150ms (see
Panel.ts:1025), so attaching listeners in render() via
`this.element.querySelector(...)` targets the STALE DOM — chips,
rows, and the ongoing-toggle button are silently non-interactive.
Visually the panel renders correctly after the debounce fires, but
every click is permanently dead.
Fix: register a single delegated click handler on `this.content`
(persistent element) in the constructor. The handler uses
`closest('[data-filter-type]')`, `closest('[data-toggle-ongoing]')`,
and `closest('tr.ed-row')` to route by data-attribute. Works
regardless of when setContent flushes or how many times render()
re-rewrites the inner HTML.
Also fixes Codex P2 on the same PR: filterEvents() was called twice
per render (once for row HTML, again for filteredCount). Now computed
once, reused. Trivial for 52 events but eliminates the redundant sort.
Typecheck clean.
Two events referenced pipeline ids that do not exist in scripts/data/pipelines-oil.json: - cpc-force-majeure-2022: assetId "cpc-pipeline" → "cpc" - pdvsa-designation-2019: assetId "ve-petrol-2026-q1" → "venezuela-anzoategui-puerto-la-cruz" Without this, clicking those rows in EnergyDisruptionsPanel dead-ends at "Pipeline detail unavailable", so the panel shipped with broken navigation on real data. Mirrors the same fix on PR #3377 (gap #5a registry); applying it on this branch as well so PR #3378 is independently correct regardless of merge order. The two changes will dedupe cleanly on rebase since the edits are byte-identical.
Codex P2: loadDisruptionsForCountry dispatched `highlightEventId` but
neither PipelineStatusPanel nor StorageFacilityMapPanel consumes it
(the openDetailHandler reads only pipelineId / facilityId). The UI's
implicit promise (event-specific highlighting) wasn't delivered —
clickthrough was asset-generic, and the extra wire field was a
misleading API surface.
Fix: emit only {pipelineId, facilityId} in the dispatched detail.
Row click opens the asset drawer; user sees the full per-asset
disruption timeline and locates the event visually.
Symmetric fix for PR #3378's EnergyDisruptionsPanel — both emitters
now match the drawer contract exactly. Re-add `highlightEventId`
here when the drawer panels ship matching consumer code
(openDetailHandler accepts it, loadDetail stores it,
renderDisruptionTimeline scrolls + emphasises the matching event).
Typecheck clean, test:data 6698/6698 pass.
…ntryDeepDive row (§R #5 = B) (#3377) * feat(energy-atlas): seed-side countries[] denorm + CountryDeepDive row (§R #5 = B) Per plan §R/#5 decision B: denormalise countries[] at seed time on each disruption event so CountryDeepDivePanel can filter events per country without an asset-registry round trip. Schema join (pipeline/storage → event.assetId) happens once in the weekly cron, not on every panel render. The alternative (client-side join) was rejected because it couples UI logic to asset-registry internals and duplicates the join for every surface that wants a per-country filter. Changes: - `proto/.../list_energy_disruptions.proto`: add `repeated string countries = 15` to EnergyDisruptionEntry with doc comment tying it to the plan decision and the always-non-empty invariant. - `scripts/_energy-disruption-registry.mjs`: • Load pipeline-gas + pipeline-oil + storage-facilities registries once per seed cycle; index by id. • `deriveCountriesForEvent()` resolves assetId to {fromCountry, toCountry, transitCountries} (pipeline) or {country} (storage), deduped + alpha-sorted so byte-diff stability holds. • `buildPayload()` attaches the computed countries[] to every event before writing. • `validateRegistry()` now requires non-empty countries[] of ISO2 codes. Combined with the seeder's `emptyDataIsFailure: true`, this surfaces orphaned assetIds loudly — the next cron tick fails validation and seed-meta stays stale, tripping health alarms. - `scripts/data/energy-disruptions.json`: fix two orphaned assetIds that the new join caught: • `cpc-force-majeure-2022`: `cpc-pipeline` → `cpc` (matches the entry in pipelines-oil.json). • `pdvsa-designation-2019`: `ve-petrol-2026-q1` (non-existent) → `venezuela-anzoategui-puerto-la-cruz`. - `server/.../list-energy-disruptions.ts`: project countries[] into the RPC response via coerceStringArray. Legacy pre-denorm rows surface as empty array (always present on wire, length 0 => old). - `src/components/CountryDeepDivePanel.ts`: add 4th Atlas row — "Energy disruptions in {iso2}" — filtered by `iso2 ∈ countries[]`. Failure is silent; EnergyDisruptionsPanel (upcoming) is the primary disruption surface. - `tests/energy-disruptions-registry.test.mts`: switch to validating the buildPayload output (post-denorm), add §R #5 B invariant tests, plus a raw-JSON invariant ensuring curators don't hand-edit countries[] (it's derived, not declared). Proto regen note: `make generate` currently fails with a duplicate openapi plugin collision in buf.gen.yaml (unrelated bug — 3 plugin entries emit to the same out dir). Worked around by temporarily trimming buf.gen.yaml to just the TS plugins for this regen. Added only the `countries: string[]` wire field to both service_client and service_server; no other generated-file drift in this PR. * chore(proto): regenerate openapi specs for countries[] field Runs `make generate` with the sebuf v0.11.1 plugin now correctly resolved via the PATH fix (cherry-picked from fix/makefile-generate-path-prefix). The new `countries` field on EnergyDisruptionEntry propagates into: - docs/api/SupplyChainService.openapi.yaml (primary per-service spec) - docs/api/SupplyChainService.openapi.json (machine-readable variant) - docs/api/worldmonitor.openapi.yaml (consolidated bundle) No TypeScript drift beyond the already-committed service_client.ts / service_server.ts updates in 80797e7. * fix(energy-atlas): drop highlightEventId emission (review P2) Codex P2: loadDisruptionsForCountry dispatched `highlightEventId` but neither PipelineStatusPanel nor StorageFacilityMapPanel consumes it (the openDetailHandler reads only pipelineId / facilityId). The UI's implicit promise (event-specific highlighting) wasn't delivered — clickthrough was asset-generic, and the extra wire field was a misleading API surface. Fix: emit only {pipelineId, facilityId} in the dispatched detail. Row click opens the asset drawer; user sees the full per-asset disruption timeline and locates the event visually. Symmetric fix for PR #3378's EnergyDisruptionsPanel — both emitters now match the drawer contract exactly. Re-add `highlightEventId` here when the drawer panels ship matching consumer code (openDetailHandler accepts it, loadDetail stores it, renderDisruptionTimeline scrolls + emphasises the matching event). Typecheck clean, test:data 6698/6698 pass. * fix(energy-atlas): collision detection + abort signal + label clamp (review P2) Three Codex P2 findings on PR #3377: 1. `loadAssetRegistries()` spread-merged gas + oil pipelines, silently overwriting entries on id collision. No collision today, but a curator adding a pipeline under the same id to both files would cause `deriveCountriesForEvent` to return wrong-commodity country data with no test flagging it. Fix: explicit merge loop that throws on duplicate id. The next cron tick fails validation, seed-meta stays stale, health alarms fire — same loud-failure pattern the rest of the seeder uses. 2. `loadDisruptionsForCountry` didn't thread `this.signal` through the RPC fetch shim. The stale-closure guard (`currentCode !== iso2`) discarded stale RESULTS, but the in-flight request couldn't be cancelled when the user switched countries or closed the panel. Fix: wrap globalThis.fetch with { signal: this.signal } in the client factory, matching the signal lifecycle the rest of the panel already uses. 3. `shortDescription` values up to 200 chars rendered without ellipsis in the compact Atlas row, overflowing the row layout. Fix: new `truncateDisruptionLabel` helper clamps to 80 chars with ellipsis. Full text still accessible via click-through to the asset drawer. Typecheck clean, test:data 6698/6698 pass.
…ly fetch (#3386) Root cause: `App.ts::primeDataForVisiblePanels` is the sole near-viewport kickoff path for panel `fetchData()` calls. Panel's constructor only calls `showLoading()`; nothing else in the app triggers fetchData on attach. Every panel that does real work has a corresponding `if (shouldPrime('<key>')) primeTask(...)` entry in that table. All 4 Energy Atlas panels were missing their entries: - pipeline-status → PipelineStatusPanel - storage-facility-map → StorageFacilityMapPanel - fuel-shortages → FuelShortagePanel - energy-disruptions → EnergyDisruptionsPanel User-visible symptom: the 4 panels shipped as part of #3366 / #3378 / the Energy Atlas PR chain rendered their headers + spinner but never left "Loading…". Verified all four upstream RPCs return data: - /api/supply-chain/v1/list-pipelines?commodityType= → 100 KB - /api/supply-chain/v1/list-storage-facilities → 115 KB - /api/supply-chain/v1/list-fuel-shortages → 21 KB - /api/supply-chain/v1/list-energy-disruptions → 36 KB and /api/health reports all 5 backing Redis keys as OK with the expected record counts (75 gas + 75 oil + 200 storage + 29 shortages + 52 disruptions). Fix: 4 primeTask entries mirroring the pattern already used for energy-crisis, hormuz-tracker, oil-inventories, etc. Each panel already implements the full fetch path (bootstrap-cache-first, RPC fallback, setCached... back-propagation, error states); the entries just give App.ts a reason to call it. Placement: immediately after oil-inventories, grouping the energy surface together.
Summary
Closes gap #4 in `docs/internal/energy-atlas-registry-expansion.md` §L — the 52 disruption events in `energy:disruptions:v1` were only reachable by drilling into a specific pipeline or storage facility. This PR makes the full log a first-class surface.
Shape
Wiring
Not in this PR (explicitly scoped out)
Test plan
Related
Stack note: this PR works standalone and does not depend on #3377's proto change — uses the existing wire shape. When #3377 merges, no rebase needed here.