fix(country-brief): display bugs — slugs, self-imports, N/A, HS labels (#3032)

* fix(country-brief): display bugs — slugs, self-imports, N/A, HS labels (#2970)

Six user-facing display fixes that individually looked minor but together
eroded trust in the Country Brief panel.

1. Incorrect chokepoint attribution per supplier. Intra-regional pairs
   (e.g. Greek/Italian refined petroleum to Turkey) overlapped on long
   pass-through routes like gulf-europe-oil, attributing Hormuz and Bab
   el-Mandeb to Mediterranean trade. Added a coastSide-based filter: when
   exporter and importer share the same coast, transit chokepoints are
   restricted to a regional whitelist (e.g. med -> bosphorus, gibraltar,
   suez only).

2. Self-imports. Rows where partnerIso2 equals the importer ISO2 are now
   filtered out of Product Imports.

3. "N/A" supplier rows. Unresolved ISO2 codes (seeder emits partnerIso2
   = '' when a UN code does not map) are now dropped from the render
   instead of surfacing as "N/A" at 14-16% share.

4. Raw slug "hormuz_strait" in shock-scenario prose. buildAssessment()
   now resolves chokepoint IDs to their display names ("Strait of Hormuz",
   "Suez Canal", etc.) via a small local map.

5. Raw ISO2 "TR can bridge" in shock-scenario prose. buildAssessment()
   now uses Intl.DisplayNames to render country names, with a raw-code
   fallback if instantiation fails.

6. HS chapter numbers instead of sector names in Cost Shock table. The
   empty-skeleton branch of /api/supply-chain/v1/multi-sector-cost-shock
   was returning hs2Label = hs2 (raw code); it now uses
   MULTI_SECTOR_HS2_LABELS. Frontend also adds an HS2_SHORT_LABELS
   fallback so the table never shows raw codes even if hs2Label is empty.

All 4973 data-suite tests pass. Closes #2970.

* fix(country-brief): apply supplier filter to recommendations + empty state (#2970)

Address PR #3032 review (P2):
- The supplier filter (drop self-imports + unmapped ISO2) only reached
  the table; the recommendation pane still iterated the unfiltered
  enriched array, so hidden rows could still produce recommendation
  text and safeAlternative pointers.
- Build a single visibleEnriched list and use it for both the row table
  and the recommendation pane.
- Short-circuit to an explicit "No external suppliers in available
  trade data" empty state when filtering removes every row, so the
  detail area never goes silently blank.
- Skip safeAlternative suggestions that would point at filtered-out
  partners (self or unmapped).

* test(country-brief): defensive ISO2 assertion for ICU variation (#2970)

Address PR #3032 review (P2): CLDR behaviour for unrecognised 2-letter
codes like 'XZ' varies across ICU versions; allow either raw 'XZ' or
the resolved 'Unknown Region' form.
This commit is contained in:
Elie Habib
2026-04-12 22:41:44 +04:00
committed by GitHub
parent e228ca8e88
commit d19b32708c
7 changed files with 92 additions and 26 deletions

View File

@@ -7,6 +7,7 @@ import {
aggregateAnnualImportsByHs2, aggregateAnnualImportsByHs2,
clampClosureDays, clampClosureDays,
computeMultiSectorShocks, computeMultiSectorShocks,
MULTI_SECTOR_HS2_LABELS,
SEEDED_HS2_CODES, SEEDED_HS2_CODES,
type MultiSectorCostShock, type MultiSectorCostShock,
type SeededProduct, type SeededProduct,
@@ -111,7 +112,7 @@ export default async function handler(req: Request): Promise<Response> {
// Still emit the empty sector skeleton so the UI can render rows at 0. // Still emit the empty sector skeleton so the UI can render rows at 0.
sectors: SEEDED_HS2_CODES.map(hs2 => ({ sectors: SEEDED_HS2_CODES.map(hs2 => ({
hs2, hs2,
hs2Label: hs2, hs2Label: MULTI_SECTOR_HS2_LABELS[hs2] ?? `HS ${hs2}`,
importValueAnnual: 0, importValueAnnual: 0,
freightAddedPctPerTon: 0, freightAddedPctPerTon: 0,
warRiskPremiumBps: 0, warRiskPremiumBps: 0,

View File

@@ -2,6 +2,31 @@ export const GULF_PARTNER_CODES = new Set(['682', '784', '368', '414', '364']);
export const VALID_CHOKEPOINTS = new Set(['hormuz_strait', 'malacca_strait', 'suez', 'bab_el_mandeb']); export const VALID_CHOKEPOINTS = new Set(['hormuz_strait', 'malacca_strait', 'suez', 'bab_el_mandeb']);
const CHOKEPOINT_DISPLAY_NAMES: Record<string, string> = {
hormuz_strait: 'Strait of Hormuz',
malacca_strait: 'Strait of Malacca',
suez: 'Suez Canal',
bab_el_mandeb: 'Bab el-Mandeb',
};
function chokepointLabel(id: string): string {
return CHOKEPOINT_DISPLAY_NAMES[id] ?? id.replace(/_/g, ' ');
}
// Intl.DisplayNames is available in Node 20+ and Vercel edge runtime; fall back to the raw ISO2
// if instantiation fails (unexpected locales, etc.) so assessment text never crashes.
let regionNames: { of(code: string): string | undefined } | null = null;
try {
regionNames = new Intl.DisplayNames(['en'], { type: 'region' });
} catch {
regionNames = null;
}
function countryLabel(iso2: string): string {
const resolved = regionNames?.of(iso2);
return resolved && resolved !== iso2 ? resolved : iso2;
}
export const CHOKEPOINT_EXPOSURE: Record<string, number> = { export const CHOKEPOINT_EXPOSURE: Record<string, number> = {
hormuz_strait: 1.0, hormuz_strait: 1.0,
bab_el_mandeb: 1.0, bab_el_mandeb: 1.0,
@@ -102,14 +127,16 @@ export function buildAssessment(
ieaStocksCoverage?: boolean, ieaStocksCoverage?: boolean,
comtradeCoverage?: boolean, comtradeCoverage?: boolean,
): string { ): string {
const country = countryLabel(code);
const cp = chokepointLabel(chokepointId);
if (coverageLevel === 'unsupported' || !dataAvailable) { if (coverageLevel === 'unsupported' || !dataAvailable) {
return `Insufficient import data for ${code} to model ${chokepointId} exposure.`; return `Insufficient import data for ${country} to model ${cp} exposure.`;
} }
if (effectiveCoverDays === -1) { if (effectiveCoverDays === -1) {
return `${code} is a net oil exporter; ${chokepointId} disruption affects export revenue, not domestic supply.`; return `${country} is a net oil exporter; ${cp} disruption affects export revenue, not domestic supply.`;
} }
if (gulfCrudeShare < 0.1 && comtradeCoverage !== false) { if (gulfCrudeShare < 0.1 && comtradeCoverage !== false) {
return `${code} has low Gulf crude dependence (${Math.round(gulfCrudeShare * 100)}%); ${chokepointId} disruption has limited direct impact.`; return `${country} has low Gulf crude dependence (${Math.round(gulfCrudeShare * 100)}%); ${cp} disruption has limited direct impact.`;
} }
const degradedNote = degraded ? ' (live flow data unavailable, using historical baseline)' : ''; const degradedNote = degraded ? ' (live flow data unavailable, using historical baseline)' : '';
const ieaCoverText = ieaStocksCoverage === false ? 'unknown' : `${daysOfCover} days`; const ieaCoverText = ieaStocksCoverage === false ? 'unknown' : `${daysOfCover} days`;
@@ -117,7 +144,7 @@ export function buildAssessment(
return `With ${daysOfCover} days IEA cover, ${code} is indefinitely bridgeable against a ${disruptionPct}% ${chokepointId} disruption at this deficit rate${degradedNote}.`; return `With ${daysOfCover} days IEA cover, ${code} is indefinitely bridgeable against a ${disruptionPct}% ${chokepointId} disruption at this deficit rate${degradedNote}.`;
} }
if (effectiveCoverDays > 90) { if (effectiveCoverDays > 90) {
return `With ${daysOfCover} days IEA cover, ${code} can bridge a ${disruptionPct}% ${chokepointId} disruption for ~${effectiveCoverDays} days${degradedNote}.`; return `With ${daysOfCover} days IEA cover, ${country} can bridge a ${disruptionPct}% ${cp} disruption for ~${effectiveCoverDays} days${degradedNote}.`;
} }
const worst = products.reduce<{ product: string; deficitPct: number }>( const worst = products.reduce<{ product: string; deficitPct: number }>(
(best, p) => (p.deficitPct > best.deficitPct ? p : best), (best, p) => (p.deficitPct > best.deficitPct ? p : best),
@@ -126,7 +153,7 @@ export function buildAssessment(
const worstDeficit = worst.deficitPct; const worstDeficit = worst.deficitPct;
const worstProduct = worst.product.toLowerCase(); const worstProduct = worst.product.toLowerCase();
const proxyNote = comtradeCoverage === false ? '. Gulf share proxied at 40%' : ''; const proxyNote = comtradeCoverage === false ? '. Gulf share proxied at 40%' : '';
return `${code} faces ${worstDeficit.toFixed(1)}% ${worstProduct} deficit under ${disruptionPct}% ${chokepointId} disruption; IEA cover: ${ieaCoverText}${proxyNote}${degradedNote}.`; return `${country} faces ${worstDeficit.toFixed(1)}% ${worstProduct} deficit under ${disruptionPct}% ${cp} disruption; IEA cover: ${ieaCoverText}${proxyNote}${degradedNote}.`;
} }
export const CHOKEPOINT_LNG_EXPOSURE: Record<string, number> = { export const CHOKEPOINT_LNG_EXPOSURE: Record<string, number> = {

View File

@@ -39,7 +39,7 @@ import type {
MultiSectorShockResponse, MultiSectorShockResponse,
MultiSectorShock, MultiSectorShock,
} from '@/services/supply-chain'; } from '@/services/supply-chain';
import { fetchMultiSectorCostShock } from '@/services/supply-chain'; import { fetchMultiSectorCostShock, HS2_SHORT_LABELS } from '@/services/supply-chain';
import type { MapContainer } from './MapContainer'; import type { MapContainer } from './MapContainer';
import { ResilienceWidget } from './ResilienceWidget'; import { ResilienceWidget } from './ResilienceWidget';
@@ -700,7 +700,7 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
let total = 0; let total = 0;
for (const s of sorted) { for (const s of sorted) {
const tr = this.el('tr', 'cdp-cost-shock-calc-row'); const tr = this.el('tr', 'cdp-cost-shock-calc-row');
const labelCell = this.el('td', 'cdp-cost-shock-calc-sector', s.hs2Label || `HS${s.hs2}`); const labelCell = this.el('td', 'cdp-cost-shock-calc-sector', s.hs2Label || HS2_SHORT_LABELS[s.hs2] || `HS${s.hs2}`);
const costCell = this.el('td', 'cdp-cost-shock-calc-cost', this.formatMoney(s.totalCostShock)); const costCell = this.el('td', 'cdp-cost-shock-calc-cost', this.formatMoney(s.totalCostShock));
if (s.totalCostShock === 0) costCell.classList.add('cdp-cost-shock-calc-cost--zero'); if (s.totalCostShock === 0) costCell.classList.add('cdp-cost-shock-calc-cost--zero');
tr.append(labelCell, costCell); tr.append(labelCell, costCell);
@@ -1763,18 +1763,31 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
tbody.replaceChildren(); tbody.replaceChildren();
recsMount.replaceChildren(); recsMount.replaceChildren();
const rows: ExporterRow[] = enriched ?? product.topExporters.map(exp => ({ const importerCode = this.currentCode;
const rawRows: ExporterRow[] = enriched ?? product.topExporters.map(exp => ({
partnerIso2: exp.partnerIso2, partnerIso2: exp.partnerIso2,
share: exp.share, share: exp.share,
value: exp.value, value: exp.value,
risk: null, risk: null,
})); }));
// Drop self-imports (receiver = supplier) and rows with unresolved partner ISO2 codes;
// the seeder emits partnerIso2='' when a UN code can't be mapped, which surfaced as "N/A" rows.
const isVisible = (iso2: string) => Boolean(iso2) && iso2 !== importerCode;
const rows = rawRows.filter(r => isVisible(r.partnerIso2));
const visibleEnriched = enriched ? enriched.filter(e => isVisible(e.partnerIso2)) : null;
if (rows.length === 0) {
const empty = this.el('div', 'cdp-recommendation-item');
empty.textContent = '\u2139 No external suppliers in available trade data.';
recsMount.append(empty);
return;
}
for (const exp of rows) { for (const exp of rows) {
const tr = this.el('tr'); const tr = this.el('tr');
const supplierTd = this.el('td', 'cdp-product-supplier'); const supplierTd = this.el('td', 'cdp-product-supplier');
const flag = exp.partnerIso2 ? CountryDeepDivePanel.toFlagEmoji(exp.partnerIso2) : ''; const flag = exp.partnerIso2 ? CountryDeepDivePanel.toFlagEmoji(exp.partnerIso2) : '';
supplierTd.textContent = `${flag} ${exp.partnerIso2 || 'N/A'}`; supplierTd.textContent = `${flag} ${exp.partnerIso2}`;
tr.append(supplierTd); tr.append(supplierTd);
const shareTd = this.el('td', 'cdp-product-share'); const shareTd = this.el('td', 'cdp-product-share');
@@ -1812,13 +1825,13 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
tbody.append(tr); tbody.append(tr);
} }
if (enriched) { if (visibleEnriched) {
const hasCritical = enriched.some(e => e.risk.riskLevel === 'critical'); const hasCritical = visibleEnriched.some(e => e.risk.riskLevel === 'critical');
const hasAtRisk = enriched.some(e => e.risk.riskLevel === 'at_risk'); const hasAtRisk = visibleEnriched.some(e => e.risk.riskLevel === 'at_risk');
const hasUnknown = enriched.some(e => e.risk.riskLevel === 'unknown'); const hasUnknown = visibleEnriched.some(e => e.risk.riskLevel === 'unknown');
const hasSafe = enriched.some(e => e.risk.riskLevel === 'safe'); const hasSafe = visibleEnriched.some(e => e.risk.riskLevel === 'safe');
if (hasCritical || hasAtRisk) { if (hasCritical || hasAtRisk) {
for (const exp of enriched) { for (const exp of visibleEnriched) {
if (exp.risk.riskLevel === 'safe' || exp.risk.riskLevel === 'unknown') continue; if (exp.risk.riskLevel === 'safe' || exp.risk.riskLevel === 'unknown') continue;
const recCls = exp.risk.riskLevel === 'critical' ? 'cdp-recommendation-critical' : 'cdp-recommendation-warn'; const recCls = exp.risk.riskLevel === 'critical' ? 'cdp-recommendation-critical' : 'cdp-recommendation-warn';
const item = this.el('div', `cdp-recommendation-item ${recCls}`); const item = this.el('div', `cdp-recommendation-item ${recCls}`);
@@ -1827,8 +1840,8 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
if (exp.risk.transitChokepoints.length === 0) continue; if (exp.risk.transitChokepoints.length === 0) continue;
const worstCp = exp.risk.transitChokepoints.reduce((a, b) => a.disruptionScore > b.disruptionScore ? a : b); const worstCp = exp.risk.transitChokepoints.reduce((a, b) => a.disruptionScore > b.disruptionScore ? a : b);
text += ` ${worstCp.chokepointName} (disruption ${worstCp.disruptionScore}/100).`; text += ` ${worstCp.chokepointName} (disruption ${worstCp.disruptionScore}/100).`;
if (exp.safeAlternative) { if (exp.safeAlternative && isVisible(exp.safeAlternative)) {
const alt = enriched.find(e => e.partnerIso2 === exp.safeAlternative); const alt = visibleEnriched.find(e => e.partnerIso2 === exp.safeAlternative);
const altPct = alt ? Math.round(alt.share * 100) : 0; const altPct = alt ? Math.round(alt.share * 100) : 0;
const altFlag = CountryDeepDivePanel.toFlagEmoji(exp.safeAlternative); const altFlag = CountryDeepDivePanel.toFlagEmoji(exp.safeAlternative);
text += ` ${altFlag} ${exp.safeAlternative} supplies ${altPct}% via routes avoiding this chokepoint.`; text += ` ${altFlag} ${exp.safeAlternative} supplies ${altPct}% via routes avoiding this chokepoint.`;
@@ -1841,8 +1854,8 @@ export class CountryDeepDivePanel implements CountryBriefPanel {
item.textContent = '\u2139 No modeled maritime route data available for these suppliers. Risk cannot be assessed.'; item.textContent = '\u2139 No modeled maritime route data available for these suppliers. Risk cannot be assessed.';
recsMount.append(item); recsMount.append(item);
} else if (hasUnknown && hasSafe) { } else if (hasUnknown && hasSafe) {
const safeCount = enriched.filter(e => e.risk.riskLevel === 'safe').length; const safeCount = visibleEnriched.filter(e => e.risk.riskLevel === 'safe').length;
const unknownCount = enriched.filter(e => e.risk.riskLevel === 'unknown').length; const unknownCount = visibleEnriched.filter(e => e.risk.riskLevel === 'unknown').length;
const item = this.el('div', 'cdp-recommendation-item'); const item = this.el('div', 'cdp-recommendation-item');
item.textContent = `\u2139 ${safeCount} supplier(s) verified safe. ${unknownCount} supplier(s) have no modeled route data.`; item.textContent = `\u2139 ${safeCount} supplier(s) verified safe. ${unknownCount} supplier(s) have no modeled route data.`;
recsMount.append(item); recsMount.append(item);

View File

@@ -46,6 +46,16 @@ for (const cp of CHOKEPOINT_REGISTRY) {
chokepointNameMap.set(cp.id, cp.displayName); chokepointNameMap.set(cp.id, cp.displayName);
} }
// Chokepoints plausibly traversed for intra-regional trade within a coastSide.
// When both exporter and importer share the same coastSide, drop waypoints outside this set
// so routes like gulf-europe-oil don't attribute Hormuz/Bab el-Mandeb to GR→TR refined petroleum.
const INTRA_REGIONAL_CHOKEPOINTS: Record<string, Set<string>> = {
med: new Set(['bosphorus', 'gibraltar', 'suez']),
atlantic: new Set(['panama', 'gibraltar', 'dover_strait', 'cape_of_good_hope']),
pacific: new Set(['panama', 'malacca_strait', 'taiwan_strait', 'korea_strait', 'lombok_strait']),
indian: new Set(['malacca_strait', 'bab_el_mandeb', 'hormuz_strait', 'cape_of_good_hope', 'lombok_strait']),
};
function getCluster(iso2: string): ClusterEntry | undefined { function getCluster(iso2: string): ClusterEntry | undefined {
const entry = clusters[iso2]; const entry = clusters[iso2];
if (!entry || typeof entry === 'string') return undefined; if (!entry || typeof entry === 'string') return undefined;
@@ -113,11 +123,21 @@ export function computeSupplierRouteRisk(
importerIso2: string, importerIso2: string,
chokepointScores: ChokepointScoreMap, chokepointScores: ChokepointScoreMap,
): SupplierRouteRisk { ): SupplierRouteRisk {
const hasExporterCluster = !!getCluster(exporterIso2); const exporterCluster = getCluster(exporterIso2);
const hasImporterCluster = !!getCluster(importerIso2); const importerCluster = getCluster(importerIso2);
const hasExporterCluster = !!exporterCluster;
const hasImporterCluster = !!importerCluster;
const routeIds = findOverlappingRoutes(exporterIso2, importerIso2); const routeIds = findOverlappingRoutes(exporterIso2, importerIso2);
const hasRouteData = hasExporterCluster && hasImporterCluster && routeIds.length > 0; const hasRouteData = hasExporterCluster && hasImporterCluster && routeIds.length > 0;
const transitChokepoints = collectTransitChokepoints(routeIds, chokepointScores); let transitChokepoints = collectTransitChokepoints(routeIds, chokepointScores);
// For intra-regional pairs (same coastSide), overlapping "pass-through" routes like
// gulf-europe-oil falsely attribute distant waypoints. Restrict transit to chokepoints
// that plausibly sit on an intra-regional path.
const sharedCoast = exporterCluster?.coastSide === importerCluster?.coastSide ? exporterCluster?.coastSide : null;
if (sharedCoast && INTRA_REGIONAL_CHOKEPOINTS[sharedCoast]) {
const allowed = INTRA_REGIONAL_CHOKEPOINTS[sharedCoast]!;
transitChokepoints = transitChokepoints.filter(cp => allowed.has(cp.chokepointId));
}
const riskLevel = determineRiskLevel(transitChokepoints, hasRouteData); const riskLevel = determineRiskLevel(transitChokepoints, hasRouteData);
const maxDisruptionScore = transitChokepoints.length > 0 const maxDisruptionScore = transitChokepoints.length > 0
? Math.max(...transitChokepoints.map(cp => cp.disruptionScore)) ? Math.max(...transitChokepoints.map(cp => cp.disruptionScore))

View File

@@ -204,8 +204,11 @@ describe('energy shock scenario computation', () => {
it('uses insufficient data message when dataAvailable is false', () => { it('uses insufficient data message when dataAvailable is false', () => {
const assessment = buildAssessment('XZ', 'suez', false, 0, 0, 0, 50, []); const assessment = buildAssessment('XZ', 'suez', false, 0, 0, 0, 50, []);
assert.ok(assessment.includes('Insufficient import data')); assert.ok(assessment.includes('Insufficient import data'));
assert.ok(assessment.includes('XZ')); // CLDR behaviour for unrecognised codes varies across ICU versions;
assert.ok(assessment.includes('suez')); // most return the raw code, but some may resolve to "Unknown Region".
assert.ok(assessment.includes('XZ') || assessment.includes('Unknown Region'));
// chokepoint id is resolved to its display name ("Suez Canal")
assert.ok(assessment.includes('Suez'));
}); });
it('uses net-exporter branch when effectiveCoverDays === -1', () => { it('uses net-exporter branch when effectiveCoverDays === -1', () => {

View File

@@ -73,7 +73,8 @@ describe('buildAssessment — unsupported country', () => {
it('returns structured insufficient data message for unsupported country', () => { it('returns structured insufficient data message for unsupported country', () => {
const msg = buildAssessment('ZZ', 'hormuz', false, 0, 0, 0, 50, [], 'unsupported', false); const msg = buildAssessment('ZZ', 'hormuz', false, 0, 0, 0, 50, [], 'unsupported', false);
assert.ok(msg.includes('Insufficient import data')); assert.ok(msg.includes('Insufficient import data'));
assert.ok(msg.includes('ZZ')); // ZZ resolves to "Unknown Region" via Intl.DisplayNames; hormuz falls back to "hormuz" (no underscore)
assert.ok(msg.includes('Unknown Region') || msg.includes('ZZ'));
assert.ok(msg.includes('hormuz')); assert.ok(msg.includes('hormuz'));
}); });

View File

@@ -104,6 +104,7 @@ async function loadCountryDeepDivePanel() {
export function getCountryChokepointIndex() { return null; } export function getCountryChokepointIndex() { return null; }
export function fetchChokepointStatus() { return Promise.resolve({ chokepoints: [], fetchedAt: '', upstreamUnavailable: false }); } export function fetchChokepointStatus() { return Promise.resolve({ chokepoints: [], fetchedAt: '', upstreamUnavailable: false }); }
export function fetchMultiSectorCostShock() { return Promise.resolve({ iso2: '', chokepointId: '', closureDays: 30, warRiskTier: 'WAR_RISK_TIER_UNSPECIFIED', sectors: [], totalAddedCost: 0, fetchedAt: '', unavailableReason: '' }); } export function fetchMultiSectorCostShock() { return Promise.resolve({ iso2: '', chokepointId: '', closureDays: 30, warRiskTier: 'WAR_RISK_TIER_UNSPECIFIED', sectors: [], totalAddedCost: 0, fetchedAt: '', unavailableReason: '' }); }
export const HS2_SHORT_LABELS = { '27': 'Energy', '84': 'Machinery', '85': 'Electronics', '87': 'Vehicles', '30': 'Pharma', '72': 'Iron & Steel', '39': 'Plastics', '29': 'Chemicals', '10': 'Cereals', '62': 'Apparel' };
`], `],
['runtime-stub', ` ['runtime-stub', `
export function toApiUrl(path) { return path; } export function toApiUrl(path) { return path; }