Files
worldmonitor/api/supply-chain/v1/multi-sector-cost-shock.ts
Elie Habib d19b32708c 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.
2026-04-12 22:41:44 +04:00

159 lines
5.1 KiB
TypeScript

export const config = { runtime: 'edge' };
import { isCallerPremium } from '../../../server/_shared/premium-check';
import { CHOKEPOINT_REGISTRY } from '../../../server/_shared/chokepoint-registry';
import { CHOKEPOINT_STATUS_KEY } from '../../../server/_shared/cache-keys';
import {
aggregateAnnualImportsByHs2,
clampClosureDays,
computeMultiSectorShocks,
MULTI_SECTOR_HS2_LABELS,
SEEDED_HS2_CODES,
type MultiSectorCostShock,
type SeededProduct,
} from '../../../server/worldmonitor/supply-chain/v1/_multi-sector-shock';
// @ts-expect-error — JS module, no declaration file
import { readJsonFromUpstash } from '../../_upstash-json.js';
interface ChokepointStatusCache {
chokepoints?: Array<{ id: string; warRiskTier?: string }>;
}
interface CountryProductsCache {
iso2: string;
products?: SeededProduct[];
fetchedAt?: string;
}
export interface MultiSectorCostShockResponse {
iso2: string;
chokepointId: string;
closureDays: number;
warRiskTier: string;
sectors: MultiSectorCostShock[];
totalAddedCost: number;
fetchedAt: string;
unavailableReason: string;
}
function emptyResponse(
iso2: string,
chokepointId: string,
closureDays: number,
reason = '',
): MultiSectorCostShockResponse {
return {
iso2,
chokepointId,
closureDays,
warRiskTier: 'WAR_RISK_TIER_UNSPECIFIED',
sectors: [],
totalAddedCost: 0,
fetchedAt: new Date().toISOString(),
unavailableReason: reason,
};
}
export default async function handler(req: Request): Promise<Response> {
if (req.method !== 'GET') {
return new Response('', { status: 405 });
}
const { searchParams } = new URL(req.url);
const iso2 = (searchParams.get('iso2') ?? '').toUpperCase();
const chokepointId = (searchParams.get('chokepointId') ?? '').trim().toLowerCase();
const rawDays = Number(searchParams.get('closureDays') ?? '30');
const closureDays = clampClosureDays(rawDays);
if (!/^[A-Z]{2}$/.test(iso2)) {
return new Response(
JSON.stringify({ error: 'Invalid or missing iso2 parameter' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
);
}
if (!chokepointId) {
return new Response(
JSON.stringify({ error: 'Invalid or missing chokepointId parameter' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
);
}
if (!CHOKEPOINT_REGISTRY.some(c => c.id === chokepointId)) {
return new Response(
JSON.stringify({ error: `Unknown chokepointId: ${chokepointId}` }),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
);
}
const isPro = await isCallerPremium(req);
if (!isPro) {
return new Response(
JSON.stringify({ error: 'PRO subscription required' }),
{ status: 403, headers: { 'Content-Type': 'application/json' } },
);
}
// Parallel Redis reads: country products + chokepoint status (for war risk tier).
const productsKey = `comtrade:bilateral-hs4:${iso2}:v1`;
const [productsCache, statusCache] = await Promise.all([
readJsonFromUpstash(productsKey, 5_000).catch(() => null) as Promise<CountryProductsCache | null>,
readJsonFromUpstash(CHOKEPOINT_STATUS_KEY, 5_000).catch(() => null) as Promise<ChokepointStatusCache | null>,
]);
const products = Array.isArray(productsCache?.products) ? productsCache.products : [];
const importsByHs2 = aggregateAnnualImportsByHs2(products);
const hasAnyImports = Object.values(importsByHs2).some(v => v > 0);
const warRiskTier = statusCache?.chokepoints?.find(c => c.id === chokepointId)?.warRiskTier
?? 'WAR_RISK_TIER_NORMAL';
if (!hasAnyImports) {
return new Response(
JSON.stringify({
...emptyResponse(iso2, chokepointId, closureDays, 'No seeded import data available for this country'),
// Still emit the empty sector skeleton so the UI can render rows at 0.
sectors: SEEDED_HS2_CODES.map(hs2 => ({
hs2,
hs2Label: MULTI_SECTOR_HS2_LABELS[hs2] ?? `HS ${hs2}`,
importValueAnnual: 0,
freightAddedPctPerTon: 0,
warRiskPremiumBps: 0,
addedTransitDays: 0,
totalCostShockPerDay: 0,
totalCostShock30Days: 0,
totalCostShock90Days: 0,
totalCostShock: 0,
closureDays,
})),
warRiskTier,
} satisfies MultiSectorCostShockResponse),
{ status: 200, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } },
);
}
const sectors = computeMultiSectorShocks(importsByHs2, chokepointId, warRiskTier, closureDays);
const totalAddedCost = sectors.reduce((sum, s) => sum + s.totalCostShock, 0);
const response: MultiSectorCostShockResponse = {
iso2,
chokepointId,
closureDays,
warRiskTier,
sectors,
totalAddedCost,
fetchedAt: new Date().toISOString(),
unavailableReason: '',
};
return new Response(
JSON.stringify(response),
{
status: 200,
headers: {
'Content-Type': 'application/json',
// Closure duration is user-controlled, so cache is private + short.
'Cache-Control': 'private, max-age=60',
'Vary': 'Authorization, Cookie, X-WorldMonitor-Key',
},
},
);
}