mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user