mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* 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.
159 lines
5.1 KiB
TypeScript
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',
|
|
},
|
|
},
|
|
);
|
|
}
|