refactor(sanctions): rename panel, restructure layout, fix stale empty cache (#1799)

* refactor(sanctions): rename panel, restructure layout, fix stale empty cache

- Rename panel from "Sanctions Pressure" to "Sanctions & Designations"
- Make countries the hero section; demote programs to bottom
- Remove redundant Top Country/Program headline rows and Source Mix card
- Summary now shows New, Total, Vessels, Aircraft (4 cards instead of 6)
- Fix stale empty-state cache: circuit breaker now skips caching results
  with totalCount=0, so a failed seed no longer persists zeros in
  IndexedDB across reloads
- Single "Sanctions data unavailable." empty state instead of three
  cascading "No X available" messages
- Remove dead CSS for .sanctions-headlines / .sanctions-headline / -label / -value

* fix(circuit-breaker): evict stale cache when shouldCache rejects SWR result

When a background SWR refresh returns a result that shouldCache() rejects,
evict the stale cache entry rather than silently discarding the result.
Without this, a service returning empty after having real data (e.g. OFAC
seed missing from Redis) continues serving the old payload indefinitely via
stale-while-revalidate, up to the 24h persistent-cache ceiling.

After this fix the stale window is bounded to one SWR cycle: the first
foreground load after the feed goes down still serves cached data, the
background refresh evicts it, and the next load surfaces the unavailable state.

* fix(sanctions): move stale-cache eviction to service, revert circuit-breaker change

The circuit-breaker SWR eviction on shouldCache rejection broke the existing
market-quote test (which correctly expects stale prices to survive a transient
empty response). The concerns are legitimately different:

- Market quotes: shouldCache rejection must preserve stale cache (transient blips)
- Sanctions: feed down must surface as unavailable, not serve old designations

Fix: revert circuit-breaker.ts to original SWR behavior, and instead call
breaker.clearCache() explicitly inside the sanctions fn() callback when
totalCount === 0. shouldCache still prevents writing the empty result, and
the explicit clearCache() evicts any stale entry so the next SWR cycle
returns the unavailable state.

* chore(hooks): add unit test suite to pre-push gate

npm run test:data was missing from pre-push — only edge-functions.test.mjs
and mdx-lint.test.mjs ran locally. CI catches all tests/*.test.mjs but the
hook did not, causing a broken circuit-breaker change to reach GitHub before
the regression was detected.
This commit is contained in:
Elie Habib
2026-03-18 10:32:06 +04:00
committed by GitHub
parent 80cb7d5aa7
commit bb92815fe0
4 changed files with 31 additions and 48 deletions

View File

@@ -25,6 +25,9 @@ for f in api/*.js; do
}
done
echo "Running unit tests..."
npm run test:data || exit 1
echo "Running edge function tests..."
node --test tests/edge-functions.test.mjs || exit 1

View File

@@ -8,13 +8,13 @@ export class SanctionsPressurePanel extends Panel {
constructor() {
super({
id: 'sanctions-pressure',
title: 'Sanctions Pressure',
title: 'Sanctions & Designations',
showCount: true,
trackActivity: true,
defaultRowSpan: 2,
infoTooltip: 'Structured OFAC sanctions pressure built from seeded SDN and Consolidated List exports. This panel answers where sanctions pressure is concentrated, what is newly designated, and which programs are driving the latest pressure.',
infoTooltip: 'OFAC sanctions designations from the SDN and Consolidated Lists. Shows which countries face the highest designation pressure, what programs are driving it, and what has been newly added since the last update.',
});
this.showLoading('Loading sanctions pressure...');
this.showLoading('Loading sanctions data...');
}
public setData(data: SanctionsPressureResult): void {
@@ -24,44 +24,33 @@ export class SanctionsPressurePanel extends Panel {
}
private render(): void {
if (!this.data) {
this.setContent('<div class="economic-empty">No sanctions pressure available.</div>');
if (!this.data || this.data.totalCount === 0) {
this.setContent('<div class="economic-empty">Sanctions data unavailable.</div>');
return;
}
const data = this.data;
const topCountry = data.countries[0];
const topProgram = data.programs[0];
const summaryHtml = `
<div class="sanctions-summary">
${this.renderSummaryCard('New', data.newEntryCount, data.newEntryCount > 0 ? 'highlight' : '')}
${this.renderSummaryCard('Countries', data.countries.length)}
${this.renderSummaryCard('Programs', data.programs.length)}
${this.renderSummaryCard('Total', data.totalCount)}
${this.renderSummaryCard('Vessels', data.vesselCount)}
${this.renderSummaryCard('Aircraft', data.aircraftCount)}
${this.renderSummaryCard('Source Mix', `${data.sdnCount}/${data.consolidatedCount}`, 'muted')}
</div>
<div class="sanctions-headlines">
<div class="sanctions-headline">
<span class="sanctions-headline-label">Top country</span>
<span class="sanctions-headline-value">${topCountry ? `${escapeHtml(topCountry.countryName)} (${topCountry.entryCount})` : 'No country attribution'}</span>
</div>
<div class="sanctions-headline">
<span class="sanctions-headline-label">Top program</span>
<span class="sanctions-headline-value">${topProgram ? `${escapeHtml(topProgram.program)} (${topProgram.entryCount})` : 'No program breakdown'}</span>
</div>
</div>
`;
const countriesHtml = data.countries.length > 0
? data.countries.slice(0, 8).map((country) => this.renderCountryRow(country)).join('')
: '<div class="economic-empty">No country pressure breakdown available.</div>';
const programsHtml = data.programs.length > 0
? data.programs.slice(0, 8).map((program) => this.renderProgramRow(program)).join('')
: '<div class="economic-empty">No program pressure breakdown available.</div>';
: '<div class="economic-empty">No country attribution available.</div>';
const entriesHtml = data.entries.length > 0
? data.entries.slice(0, 10).map((entry) => this.renderEntryRow(entry)).join('')
: '<div class="economic-empty">No recent designations available.</div>';
: '<div class="economic-empty">No recent designations.</div>';
const programsHtml = data.programs.length > 0
? data.programs.slice(0, 6).map((program) => this.renderProgramRow(program)).join('')
: '<div class="economic-empty">No program breakdown.</div>';
const footer = [
`Updated ${data.fetchedAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`,
@@ -74,17 +63,17 @@ export class SanctionsPressurePanel extends Panel {
${summaryHtml}
<div class="sanctions-sections">
<div class="sanctions-section">
<div class="sanctions-section-title">Top countries</div>
<div class="sanctions-section-title">Sanctioned countries</div>
<div class="sanctions-list">${countriesHtml}</div>
</div>
<div class="sanctions-section">
<div class="sanctions-section-title">Top programs</div>
<div class="sanctions-list">${programsHtml}</div>
</div>
<div class="sanctions-section">
<div class="sanctions-section-title">Recent designations</div>
<div class="sanctions-list">${entriesHtml}</div>
</div>
<div class="sanctions-section">
<div class="sanctions-section-title">Programs</div>
<div class="sanctions-list">${programsHtml}</div>
</div>
</div>
<div class="economic-footer">${escapeHtml(footer)}</div>
</div>

View File

@@ -163,8 +163,16 @@ export async function fetchSanctionsPressure(): Promise<SanctionsPressureResult>
});
const result = toResult(response);
latestSanctionsPressureResult = result;
if (result.totalCount === 0) {
// Seed is missing or the feed is down. Evict any stale cache so the
// panel surfaces "unavailable" instead of serving old designations
// indefinitely via stale-while-revalidate.
breaker.clearCache();
}
return result;
}, emptyResult);
}, emptyResult, {
shouldCache: (result) => result.totalCount > 0,
});
}
export function getLatestSanctionsPressure(): SanctionsPressureResult | null {

View File

@@ -8743,7 +8743,6 @@ a.prediction-link:hover {
}
.sanctions-summary-label,
.sanctions-headline-label,
.sanctions-section-title {
font-size: 9px;
text-transform: uppercase;
@@ -8770,7 +8769,6 @@ a.prediction-link:hover {
color: var(--accent);
}
.sanctions-headlines,
.sanctions-sections {
display: flex;
flex-direction: column;
@@ -8778,26 +8776,11 @@ a.prediction-link:hover {
padding: 0 8px;
}
.sanctions-headline,
.sanctions-section {
border: 1px solid var(--border);
background: var(--surface);
}
.sanctions-headline {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
align-items: baseline;
}
.sanctions-headline-value {
font-size: 12px;
color: var(--text-secondary);
text-align: right;
}
.sanctions-section-title {
padding: 8px 10px 0;
}