mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat: add Supply Chain Disruption Intelligence service (#387)
* feat: add Supply Chain Disruption Intelligence service Add new supply_chain domain with 3 RPCs: - GetShippingRates: FRED Baltic Dry Index with spike detection - GetChokepointStatus: NGA nav warnings + AIS vessel aggregation for 6 global chokepoints with composite disruption scoring - GetCriticalMinerals: HHI concentration analysis for 7 strategic minerals from USGS 2024 production data Frontend: 3-tab SupplyChainPanel with event delegation, sparkline SVG, escapeHtml XSS hardening, and circuit breakers per RPC. 13 unit tests for pure scoring functions (_scoring.mjs). * fix: chokepoint upstream detection, FRED weekly frequency, missing CSS P1: Chokepoint handler propagates upstreamUnavailable when maritime sources fail or return empty, instead of always reporting false. P1: FRED BDI uses frequency=w for true 52-week window. P2: Add CSS for sc-status-dot, sc-dot-*, sc-risk-* classes. * fix: allow partial chokepoint data when one upstream source succeeds
This commit is contained in:
@@ -52,6 +52,8 @@ import { createGivingServiceRoutes } from '../../../src/generated/server/worldmo
|
||||
import { givingHandler } from '../../../server/worldmonitor/giving/v1/handler';
|
||||
import { createTradeServiceRoutes } from '../../../src/generated/server/worldmonitor/trade/v1/service_server';
|
||||
import { tradeHandler } from '../../../server/worldmonitor/trade/v1/handler';
|
||||
import { createSupplyChainServiceRoutes } from '../../../src/generated/server/worldmonitor/supply_chain/v1/service_server';
|
||||
import { supplyChainHandler } from '../../../server/worldmonitor/supply-chain/v1/handler';
|
||||
|
||||
import type { ServerOptions } from '../../../src/generated/server/worldmonitor/seismology/v1/service_server';
|
||||
|
||||
@@ -78,6 +80,7 @@ const allRoutes = [
|
||||
...createPositiveEventsServiceRoutes(positiveEventsHandler, serverOptions),
|
||||
...createGivingServiceRoutes(givingHandler, serverOptions),
|
||||
...createTradeServiceRoutes(tradeHandler, serverOptions),
|
||||
...createSupplyChainServiceRoutes(supplyChainHandler, serverOptions),
|
||||
];
|
||||
|
||||
const router = createRouter(allRoutes);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.supply_chain.v1;
|
||||
|
||||
import "worldmonitor/supply_chain/v1/supply_chain_data.proto";
|
||||
|
||||
message GetChokepointStatusRequest {}
|
||||
|
||||
message GetChokepointStatusResponse {
|
||||
repeated ChokepointInfo chokepoints = 1;
|
||||
string fetched_at = 2;
|
||||
bool upstream_unavailable = 3;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.supply_chain.v1;
|
||||
|
||||
import "worldmonitor/supply_chain/v1/supply_chain_data.proto";
|
||||
|
||||
message GetCriticalMineralsRequest {}
|
||||
|
||||
message GetCriticalMineralsResponse {
|
||||
repeated CriticalMineral minerals = 1;
|
||||
string fetched_at = 2;
|
||||
bool upstream_unavailable = 3;
|
||||
}
|
||||
13
proto/worldmonitor/supply_chain/v1/get_shipping_rates.proto
Normal file
13
proto/worldmonitor/supply_chain/v1/get_shipping_rates.proto
Normal file
@@ -0,0 +1,13 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.supply_chain.v1;
|
||||
|
||||
import "worldmonitor/supply_chain/v1/supply_chain_data.proto";
|
||||
|
||||
message GetShippingRatesRequest {}
|
||||
|
||||
message GetShippingRatesResponse {
|
||||
repeated ShippingIndex indices = 1;
|
||||
string fetched_at = 2;
|
||||
bool upstream_unavailable = 3;
|
||||
}
|
||||
24
proto/worldmonitor/supply_chain/v1/service.proto
Normal file
24
proto/worldmonitor/supply_chain/v1/service.proto
Normal file
@@ -0,0 +1,24 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.supply_chain.v1;
|
||||
|
||||
import "sebuf/http/annotations.proto";
|
||||
import "worldmonitor/supply_chain/v1/get_shipping_rates.proto";
|
||||
import "worldmonitor/supply_chain/v1/get_chokepoint_status.proto";
|
||||
import "worldmonitor/supply_chain/v1/get_critical_minerals.proto";
|
||||
|
||||
service SupplyChainService {
|
||||
option (sebuf.http.service_config) = {base_path: "/api/supply-chain/v1"};
|
||||
|
||||
rpc GetShippingRates(GetShippingRatesRequest) returns (GetShippingRatesResponse) {
|
||||
option (sebuf.http.config) = {path: "/get-shipping-rates"};
|
||||
}
|
||||
|
||||
rpc GetChokepointStatus(GetChokepointStatusRequest) returns (GetChokepointStatusResponse) {
|
||||
option (sebuf.http.config) = {path: "/get-chokepoint-status"};
|
||||
}
|
||||
|
||||
rpc GetCriticalMinerals(GetCriticalMineralsRequest) returns (GetCriticalMineralsResponse) {
|
||||
option (sebuf.http.config) = {path: "/get-critical-minerals"};
|
||||
}
|
||||
}
|
||||
48
proto/worldmonitor/supply_chain/v1/supply_chain_data.proto
Normal file
48
proto/worldmonitor/supply_chain/v1/supply_chain_data.proto
Normal file
@@ -0,0 +1,48 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package worldmonitor.supply_chain.v1;
|
||||
|
||||
message ShippingRatePoint {
|
||||
string date = 1;
|
||||
double value = 2;
|
||||
}
|
||||
|
||||
message ShippingIndex {
|
||||
string index_id = 1;
|
||||
string name = 2;
|
||||
double current_value = 3;
|
||||
double previous_value = 4;
|
||||
double change_pct = 5;
|
||||
string unit = 6;
|
||||
repeated ShippingRatePoint history = 7;
|
||||
bool spike_alert = 8;
|
||||
}
|
||||
|
||||
message ChokepointInfo {
|
||||
string id = 1;
|
||||
string name = 2;
|
||||
double lat = 3;
|
||||
double lon = 4;
|
||||
int32 disruption_score = 5;
|
||||
string status = 6;
|
||||
int32 active_warnings = 7;
|
||||
string congestion_level = 8;
|
||||
repeated string affected_routes = 9;
|
||||
string description = 10;
|
||||
}
|
||||
|
||||
message MineralProducer {
|
||||
string country = 1;
|
||||
string country_code = 2;
|
||||
double production_tonnes = 3;
|
||||
double share_pct = 4;
|
||||
}
|
||||
|
||||
message CriticalMineral {
|
||||
string mineral = 1;
|
||||
repeated MineralProducer top_producers = 2;
|
||||
double hhi = 3;
|
||||
string risk_rating = 4;
|
||||
double global_production = 5;
|
||||
string unit = 6;
|
||||
}
|
||||
51
server/worldmonitor/supply-chain/v1/_minerals-data.ts
Normal file
51
server/worldmonitor/supply-chain/v1/_minerals-data.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export interface MineralProductionEntry {
|
||||
mineral: string;
|
||||
country: string;
|
||||
countryCode: string;
|
||||
productionTonnes: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export const MINERAL_PRODUCTION_2024: MineralProductionEntry[] = [
|
||||
// Lithium (tonnes LCE)
|
||||
{ mineral: 'Lithium', country: 'Australia', countryCode: 'AU', productionTonnes: 86000, unit: 'tonnes LCE' },
|
||||
{ mineral: 'Lithium', country: 'Chile', countryCode: 'CL', productionTonnes: 44000, unit: 'tonnes LCE' },
|
||||
{ mineral: 'Lithium', country: 'China', countryCode: 'CN', productionTonnes: 33000, unit: 'tonnes LCE' },
|
||||
{ mineral: 'Lithium', country: 'Argentina', countryCode: 'AR', productionTonnes: 9600, unit: 'tonnes LCE' },
|
||||
|
||||
// Cobalt (tonnes)
|
||||
{ mineral: 'Cobalt', country: 'DRC', countryCode: 'CD', productionTonnes: 130000, unit: 'tonnes' },
|
||||
{ mineral: 'Cobalt', country: 'Indonesia', countryCode: 'ID', productionTonnes: 17000, unit: 'tonnes' },
|
||||
{ mineral: 'Cobalt', country: 'Russia', countryCode: 'RU', productionTonnes: 8900, unit: 'tonnes' },
|
||||
{ mineral: 'Cobalt', country: 'Australia', countryCode: 'AU', productionTonnes: 5600, unit: 'tonnes' },
|
||||
|
||||
// Rare Earths (tonnes REO)
|
||||
{ mineral: 'Rare Earths', country: 'China', countryCode: 'CN', productionTonnes: 240000, unit: 'tonnes REO' },
|
||||
{ mineral: 'Rare Earths', country: 'Myanmar', countryCode: 'MM', productionTonnes: 38000, unit: 'tonnes REO' },
|
||||
{ mineral: 'Rare Earths', country: 'USA', countryCode: 'US', productionTonnes: 43000, unit: 'tonnes REO' },
|
||||
{ mineral: 'Rare Earths', country: 'Australia', countryCode: 'AU', productionTonnes: 18000, unit: 'tonnes REO' },
|
||||
|
||||
// Nickel (tonnes)
|
||||
{ mineral: 'Nickel', country: 'Indonesia', countryCode: 'ID', productionTonnes: 1800000, unit: 'tonnes' },
|
||||
{ mineral: 'Nickel', country: 'Philippines', countryCode: 'PH', productionTonnes: 330000, unit: 'tonnes' },
|
||||
{ mineral: 'Nickel', country: 'Russia', countryCode: 'RU', productionTonnes: 220000, unit: 'tonnes' },
|
||||
{ mineral: 'Nickel', country: 'New Caledonia', countryCode: 'NC', productionTonnes: 190000, unit: 'tonnes' },
|
||||
|
||||
// Copper (tonnes)
|
||||
{ mineral: 'Copper', country: 'Chile', countryCode: 'CL', productionTonnes: 5300000, unit: 'tonnes' },
|
||||
{ mineral: 'Copper', country: 'DRC', countryCode: 'CD', productionTonnes: 2500000, unit: 'tonnes' },
|
||||
{ mineral: 'Copper', country: 'Peru', countryCode: 'PE', productionTonnes: 2200000, unit: 'tonnes' },
|
||||
{ mineral: 'Copper', country: 'China', countryCode: 'CN', productionTonnes: 1900000, unit: 'tonnes' },
|
||||
|
||||
// Gallium (tonnes)
|
||||
{ mineral: 'Gallium', country: 'China', countryCode: 'CN', productionTonnes: 600, unit: 'tonnes' },
|
||||
{ mineral: 'Gallium', country: 'Japan', countryCode: 'JP', productionTonnes: 10, unit: 'tonnes' },
|
||||
{ mineral: 'Gallium', country: 'South Korea', countryCode: 'KR', productionTonnes: 8, unit: 'tonnes' },
|
||||
{ mineral: 'Gallium', country: 'Russia', countryCode: 'RU', productionTonnes: 5, unit: 'tonnes' },
|
||||
|
||||
// Germanium (tonnes)
|
||||
{ mineral: 'Germanium', country: 'China', countryCode: 'CN', productionTonnes: 95, unit: 'tonnes' },
|
||||
{ mineral: 'Germanium', country: 'Belgium', countryCode: 'BE', productionTonnes: 15, unit: 'tonnes' },
|
||||
{ mineral: 'Germanium', country: 'Canada', countryCode: 'CA', productionTonnes: 9, unit: 'tonnes' },
|
||||
{ mineral: 'Germanium', country: 'Russia', countryCode: 'RU', productionTonnes: 5, unit: 'tonnes' },
|
||||
];
|
||||
39
server/worldmonitor/supply-chain/v1/_scoring.mjs
Normal file
39
server/worldmonitor/supply-chain/v1/_scoring.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
export const SEVERITY_SCORE = {
|
||||
'AIS_DISRUPTION_SEVERITY_LOW': 1,
|
||||
'AIS_DISRUPTION_SEVERITY_ELEVATED': 2,
|
||||
'AIS_DISRUPTION_SEVERITY_HIGH': 3,
|
||||
};
|
||||
|
||||
export function computeDisruptionScore(warningCount, congestionSeverity) {
|
||||
return Math.min(100, warningCount * 15 + congestionSeverity * 30);
|
||||
}
|
||||
|
||||
export function scoreToStatus(score) {
|
||||
if (score < 20) return 'green';
|
||||
if (score < 50) return 'yellow';
|
||||
return 'red';
|
||||
}
|
||||
|
||||
export function computeHHI(shares) {
|
||||
if (!shares || shares.length === 0) return 0;
|
||||
return shares.reduce((sum, s) => sum + s * s, 0);
|
||||
}
|
||||
|
||||
export function riskRating(hhi) {
|
||||
if (hhi >= 5000) return 'critical';
|
||||
if (hhi >= 2500) return 'high';
|
||||
if (hhi >= 1500) return 'moderate';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
export function detectSpike(history) {
|
||||
if (!history || history.length < 3) return false;
|
||||
const values = history.map(h => typeof h === 'number' ? h : h.value).filter(v => Number.isFinite(v));
|
||||
if (values.length < 3) return false;
|
||||
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
||||
const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length;
|
||||
const stdDev = Math.sqrt(variance);
|
||||
if (stdDev === 0) return false;
|
||||
const latest = values[values.length - 1];
|
||||
return latest > mean + 2 * stdDev;
|
||||
}
|
||||
125
server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts
Normal file
125
server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type {
|
||||
ServerContext,
|
||||
GetChokepointStatusRequest,
|
||||
GetChokepointStatusResponse,
|
||||
ChokepointInfo,
|
||||
} from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server';
|
||||
|
||||
import type {
|
||||
ListNavigationalWarningsResponse,
|
||||
GetVesselSnapshotResponse,
|
||||
AisDisruption,
|
||||
} from '../../../../src/generated/server/worldmonitor/maritime/v1/service_server';
|
||||
|
||||
import { cachedFetchJson } from '../../../_shared/redis';
|
||||
import { listNavigationalWarnings } from '../../maritime/v1/list-navigational-warnings';
|
||||
import { getVesselSnapshot } from '../../maritime/v1/get-vessel-snapshot';
|
||||
// @ts-expect-error — .mjs module, no declaration file
|
||||
import { computeDisruptionScore, scoreToStatus, SEVERITY_SCORE } from './_scoring.mjs';
|
||||
|
||||
const REDIS_CACHE_KEY = 'supply_chain:chokepoints:v1';
|
||||
const REDIS_CACHE_TTL = 300;
|
||||
|
||||
interface ChokepointConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
areaKeywords: string[];
|
||||
routes: string[];
|
||||
}
|
||||
|
||||
const CHOKEPOINTS: ChokepointConfig[] = [
|
||||
{ id: 'suez', name: 'Suez Canal', lat: 30.45, lon: 32.35, areaKeywords: ['suez', 'red sea'], routes: ['China-Europe (Suez)', 'Gulf-Europe Oil', 'Qatar LNG-Europe'] },
|
||||
{ id: 'malacca', name: 'Malacca Strait', lat: 1.43, lon: 103.5, areaKeywords: ['malacca', 'singapore strait'], routes: ['China-Middle East Oil', 'China-Europe (via Suez)', 'Japan-Middle East Oil'] },
|
||||
{ id: 'hormuz', name: 'Strait of Hormuz', lat: 26.56, lon: 56.25, areaKeywords: ['hormuz', 'persian gulf', 'arabian gulf'], routes: ['Gulf Oil Exports', 'Qatar LNG', 'Iran Exports'] },
|
||||
{ id: 'bab_el_mandeb', name: 'Bab el-Mandeb', lat: 12.58, lon: 43.33, areaKeywords: ['bab el-mandeb', 'bab al-mandab', 'mandeb', 'aden'], routes: ['Suez-Indian Ocean', 'Gulf-Europe Oil', 'Red Sea Transit'] },
|
||||
{ id: 'panama', name: 'Panama Canal', lat: 9.08, lon: -79.68, areaKeywords: ['panama'], routes: ['US East Coast-Asia', 'US East Coast-South America', 'Atlantic-Pacific Bulk'] },
|
||||
{ id: 'taiwan', name: 'Taiwan Strait', lat: 24.0, lon: 119.5, areaKeywords: ['taiwan strait', 'formosa'], routes: ['China-Japan Trade', 'Korea-Southeast Asia', 'Pacific Semiconductor'] },
|
||||
];
|
||||
|
||||
function makeInternalCtx(): { request: Request; pathParams: Record<string, string>; headers: Record<string, string> } {
|
||||
return { request: new Request('http://internal'), pathParams: {}, headers: {} };
|
||||
}
|
||||
|
||||
interface ChokepointFetchResult {
|
||||
chokepoints: ChokepointInfo[];
|
||||
upstreamUnavailable: boolean;
|
||||
}
|
||||
|
||||
async function fetchChokepointData(): Promise<ChokepointFetchResult> {
|
||||
const ctx = makeInternalCtx();
|
||||
|
||||
let navFailed = false;
|
||||
let vesselFailed = false;
|
||||
|
||||
const [navResult, vesselResult] = await Promise.all([
|
||||
listNavigationalWarnings(ctx, { area: '' }).catch((): ListNavigationalWarningsResponse => { navFailed = true; return { warnings: [], pagination: undefined }; }),
|
||||
getVesselSnapshot(ctx, {}).catch((): GetVesselSnapshotResponse => { vesselFailed = true; return { snapshot: undefined }; }),
|
||||
]);
|
||||
|
||||
const warnings = navResult.warnings || [];
|
||||
const disruptions: AisDisruption[] = vesselResult.snapshot?.disruptions || [];
|
||||
const upstreamUnavailable = (navFailed && vesselFailed) || (navFailed && disruptions.length === 0) || (vesselFailed && warnings.length === 0);
|
||||
|
||||
const chokepoints = CHOKEPOINTS.map((cp): ChokepointInfo => {
|
||||
const matchedWarnings = warnings.filter(w =>
|
||||
cp.areaKeywords.some(kw => w.text.toLowerCase().includes(kw) || w.area.toLowerCase().includes(kw))
|
||||
);
|
||||
|
||||
const matchedDisruptions = disruptions.filter(d =>
|
||||
d.type === 'AIS_DISRUPTION_TYPE_CHOKEPOINT_CONGESTION' &&
|
||||
cp.areaKeywords.some(kw => d.region.toLowerCase().includes(kw) || d.name.toLowerCase().includes(kw))
|
||||
);
|
||||
|
||||
const maxSeverity = matchedDisruptions.reduce((max, d) => {
|
||||
const score = (SEVERITY_SCORE as Record<string, number>)[d.severity] ?? 0;
|
||||
return Math.max(max, score);
|
||||
}, 0);
|
||||
|
||||
const disruptionScore = computeDisruptionScore(matchedWarnings.length, maxSeverity);
|
||||
const status = scoreToStatus(disruptionScore);
|
||||
|
||||
const congestionLevel = maxSeverity >= 3 ? 'high' : maxSeverity >= 2 ? 'elevated' : maxSeverity >= 1 ? 'low' : 'normal';
|
||||
|
||||
const descriptions: string[] = [];
|
||||
if (matchedWarnings.length > 0) descriptions.push(`${matchedWarnings.length} active navigational warning(s)`);
|
||||
if (matchedDisruptions.length > 0) descriptions.push(`AIS congestion detected`);
|
||||
if (descriptions.length === 0) descriptions.push('No active disruptions');
|
||||
|
||||
return {
|
||||
id: cp.id,
|
||||
name: cp.name,
|
||||
lat: cp.lat,
|
||||
lon: cp.lon,
|
||||
disruptionScore,
|
||||
status,
|
||||
activeWarnings: matchedWarnings.length,
|
||||
congestionLevel,
|
||||
affectedRoutes: cp.routes,
|
||||
description: descriptions.join('; '),
|
||||
};
|
||||
});
|
||||
|
||||
return { chokepoints, upstreamUnavailable };
|
||||
}
|
||||
|
||||
export async function getChokepointStatus(
|
||||
_ctx: ServerContext,
|
||||
_req: GetChokepointStatusRequest,
|
||||
): Promise<GetChokepointStatusResponse> {
|
||||
try {
|
||||
const result = await cachedFetchJson<GetChokepointStatusResponse>(
|
||||
REDIS_CACHE_KEY,
|
||||
REDIS_CACHE_TTL,
|
||||
async () => {
|
||||
const { chokepoints, upstreamUnavailable } = await fetchChokepointData();
|
||||
return { chokepoints, fetchedAt: new Date().toISOString(), upstreamUnavailable };
|
||||
},
|
||||
);
|
||||
|
||||
return result ?? { chokepoints: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true };
|
||||
} catch {
|
||||
return { chokepoints: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true };
|
||||
}
|
||||
}
|
||||
75
server/worldmonitor/supply-chain/v1/get-critical-minerals.ts
Normal file
75
server/worldmonitor/supply-chain/v1/get-critical-minerals.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type {
|
||||
ServerContext,
|
||||
GetCriticalMineralsRequest,
|
||||
GetCriticalMineralsResponse,
|
||||
CriticalMineral,
|
||||
MineralProducer,
|
||||
} from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server';
|
||||
|
||||
import { cachedFetchJson } from '../../../_shared/redis';
|
||||
import { MINERAL_PRODUCTION_2024 } from './_minerals-data';
|
||||
// @ts-expect-error — .mjs module, no declaration file
|
||||
import { computeHHI, riskRating } from './_scoring.mjs';
|
||||
|
||||
const REDIS_CACHE_KEY = 'supply_chain:minerals:v1';
|
||||
const REDIS_CACHE_TTL = 86400;
|
||||
|
||||
function buildMineralsData(): CriticalMineral[] {
|
||||
const byMineral = new Map<string, typeof MINERAL_PRODUCTION_2024>();
|
||||
for (const entry of MINERAL_PRODUCTION_2024) {
|
||||
const existing = byMineral.get(entry.mineral) || [];
|
||||
existing.push(entry);
|
||||
byMineral.set(entry.mineral, existing);
|
||||
}
|
||||
|
||||
const minerals: CriticalMineral[] = [];
|
||||
|
||||
for (const [mineral, entries] of byMineral) {
|
||||
const globalProduction = entries.reduce((sum, e) => sum + e.productionTonnes, 0);
|
||||
const unit = entries[0]?.unit || 'tonnes';
|
||||
|
||||
const producers: MineralProducer[] = entries
|
||||
.sort((a, b) => b.productionTonnes - a.productionTonnes)
|
||||
.slice(0, 5)
|
||||
.map(e => ({
|
||||
country: e.country,
|
||||
countryCode: e.countryCode,
|
||||
productionTonnes: e.productionTonnes,
|
||||
sharePct: globalProduction > 0 ? (e.productionTonnes / globalProduction) * 100 : 0,
|
||||
}));
|
||||
|
||||
const shares = entries.map(e => globalProduction > 0 ? (e.productionTonnes / globalProduction) * 100 : 0);
|
||||
const hhi = computeHHI(shares);
|
||||
|
||||
minerals.push({
|
||||
mineral,
|
||||
topProducers: producers,
|
||||
hhi,
|
||||
riskRating: riskRating(hhi),
|
||||
globalProduction,
|
||||
unit,
|
||||
});
|
||||
}
|
||||
|
||||
return minerals.sort((a, b) => b.hhi - a.hhi);
|
||||
}
|
||||
|
||||
export async function getCriticalMinerals(
|
||||
_ctx: ServerContext,
|
||||
_req: GetCriticalMineralsRequest,
|
||||
): Promise<GetCriticalMineralsResponse> {
|
||||
try {
|
||||
const result = await cachedFetchJson<GetCriticalMineralsResponse>(
|
||||
REDIS_CACHE_KEY,
|
||||
REDIS_CACHE_TTL,
|
||||
async () => {
|
||||
const minerals = buildMineralsData();
|
||||
return { minerals, fetchedAt: new Date().toISOString(), upstreamUnavailable: false };
|
||||
},
|
||||
);
|
||||
|
||||
return result ?? { minerals: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true };
|
||||
} catch {
|
||||
return { minerals: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true };
|
||||
}
|
||||
}
|
||||
93
server/worldmonitor/supply-chain/v1/get-shipping-rates.ts
Normal file
93
server/worldmonitor/supply-chain/v1/get-shipping-rates.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
declare const process: { env: Record<string, string | undefined> };
|
||||
|
||||
import type {
|
||||
ServerContext,
|
||||
GetShippingRatesRequest,
|
||||
GetShippingRatesResponse,
|
||||
ShippingIndex,
|
||||
ShippingRatePoint,
|
||||
} from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server';
|
||||
|
||||
import { cachedFetchJson } from '../../../_shared/redis';
|
||||
import { CHROME_UA } from '../../../_shared/constants';
|
||||
// @ts-expect-error — .mjs module, no declaration file
|
||||
import { detectSpike } from './_scoring.mjs';
|
||||
|
||||
const FRED_API_BASE = 'https://api.stlouisfed.org/fred';
|
||||
const REDIS_CACHE_KEY = 'supply_chain:shipping:v1';
|
||||
const REDIS_CACHE_TTL = 3600;
|
||||
|
||||
async function fetchBDI(): Promise<ShippingIndex | null> {
|
||||
const apiKey = process.env.FRED_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
series_id: 'BDIY',
|
||||
api_key: apiKey,
|
||||
file_type: 'json',
|
||||
frequency: 'w',
|
||||
sort_order: 'desc',
|
||||
limit: '52',
|
||||
});
|
||||
|
||||
const response = await fetch(`${FRED_API_BASE}/series/observations?${params}`, {
|
||||
headers: { Accept: 'application/json', 'User-Agent': CHROME_UA },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = await response.json() as { observations?: Array<{ date: string; value: string }> };
|
||||
const observations = (data.observations || [])
|
||||
.map((obs): ShippingRatePoint | null => {
|
||||
const value = parseFloat(obs.value);
|
||||
if (isNaN(value) || obs.value === '.') return null;
|
||||
return { date: obs.date, value };
|
||||
})
|
||||
.filter((o): o is ShippingRatePoint => o !== null)
|
||||
.reverse();
|
||||
|
||||
if (observations.length === 0) return null;
|
||||
|
||||
const currentValue = observations[observations.length - 1]!.value;
|
||||
const previousValue = observations.length > 1 ? observations[observations.length - 2]!.value : currentValue;
|
||||
const changePct = previousValue !== 0 ? ((currentValue - previousValue) / previousValue) * 100 : 0;
|
||||
|
||||
const spikeAlert = detectSpike(observations);
|
||||
|
||||
return {
|
||||
indexId: 'BDIY',
|
||||
name: 'Baltic Dry Index',
|
||||
currentValue,
|
||||
previousValue,
|
||||
changePct,
|
||||
unit: 'index points',
|
||||
history: observations,
|
||||
spikeAlert,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getShippingRates(
|
||||
_ctx: ServerContext,
|
||||
_req: GetShippingRatesRequest,
|
||||
): Promise<GetShippingRatesResponse> {
|
||||
try {
|
||||
const result = await cachedFetchJson<GetShippingRatesResponse>(
|
||||
REDIS_CACHE_KEY,
|
||||
REDIS_CACHE_TTL,
|
||||
async () => {
|
||||
const bdi = await fetchBDI();
|
||||
if (!bdi) return null;
|
||||
return { indices: [bdi], fetchedAt: new Date().toISOString(), upstreamUnavailable: false };
|
||||
},
|
||||
);
|
||||
|
||||
return result ?? { indices: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true };
|
||||
} catch {
|
||||
return { indices: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true };
|
||||
}
|
||||
}
|
||||
11
server/worldmonitor/supply-chain/v1/handler.ts
Normal file
11
server/worldmonitor/supply-chain/v1/handler.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { SupplyChainServiceHandler } from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server';
|
||||
|
||||
import { getShippingRates } from './get-shipping-rates';
|
||||
import { getChokepointStatus } from './get-chokepoint-status';
|
||||
import { getCriticalMinerals } from './get-critical-minerals';
|
||||
|
||||
export const supplyChainHandler: SupplyChainServiceHandler = {
|
||||
getShippingRates,
|
||||
getChokepointStatus,
|
||||
getCriticalMinerals,
|
||||
};
|
||||
@@ -485,6 +485,7 @@ export class App {
|
||||
// WTO trade policy data — annual data, poll every 10 min to avoid hammering upstream
|
||||
if (SITE_VARIANT === 'full' || SITE_VARIANT === 'finance') {
|
||||
this.refreshScheduler.scheduleRefresh('tradePolicy', () => this.dataLoader.loadTradePolicy(), 10 * 60 * 1000);
|
||||
this.refreshScheduler.scheduleRefresh('supplyChain', () => this.dataLoader.loadSupplyChain(), 10 * 60 * 1000);
|
||||
}
|
||||
|
||||
// Refresh intelligence signals for CII (geopolitical variant only)
|
||||
|
||||
@@ -52,6 +52,9 @@ import {
|
||||
fetchTariffTrends,
|
||||
fetchTradeFlows,
|
||||
fetchTradeBarriers,
|
||||
fetchShippingRates,
|
||||
fetchChokepointStatus,
|
||||
fetchCriticalMinerals,
|
||||
} from '@/services';
|
||||
import { mlWorker } from '@/services/ml-worker';
|
||||
import { clusterNewsHybrid } from '@/services/clustering';
|
||||
@@ -91,6 +94,7 @@ import {
|
||||
ClimateAnomalyPanel,
|
||||
PopulationExposurePanel,
|
||||
TradePolicyPanel,
|
||||
SupplyChainPanel,
|
||||
} from '@/components';
|
||||
import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel';
|
||||
import { classifyNewsItem } from '@/services/positive-classifier';
|
||||
@@ -169,6 +173,7 @@ export class DataLoaderManager implements AppModule {
|
||||
// Trade policy data (FULL and FINANCE only)
|
||||
if (SITE_VARIANT === 'full' || SITE_VARIANT === 'finance') {
|
||||
tasks.push({ name: 'tradePolicy', task: runGuarded('tradePolicy', () => this.loadTradePolicy()) });
|
||||
tasks.push({ name: 'supplyChain', task: runGuarded('supplyChain', () => this.loadSupplyChain()) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1544,6 +1549,42 @@ export class DataLoaderManager implements AppModule {
|
||||
}
|
||||
}
|
||||
|
||||
async loadSupplyChain(): Promise<void> {
|
||||
const scPanel = this.ctx.panels['supply-chain'] as SupplyChainPanel | undefined;
|
||||
if (!scPanel) return;
|
||||
|
||||
try {
|
||||
const [shipping, chokepoints, minerals] = await Promise.allSettled([
|
||||
fetchShippingRates(),
|
||||
fetchChokepointStatus(),
|
||||
fetchCriticalMinerals(),
|
||||
]);
|
||||
|
||||
const shippingData = shipping.status === 'fulfilled' ? shipping.value : null;
|
||||
const chokepointData = chokepoints.status === 'fulfilled' ? chokepoints.value : null;
|
||||
const mineralsData = minerals.status === 'fulfilled' ? minerals.value : null;
|
||||
|
||||
if (shippingData) scPanel.updateShippingRates(shippingData);
|
||||
if (chokepointData) scPanel.updateChokepointStatus(chokepointData);
|
||||
if (mineralsData) scPanel.updateCriticalMinerals(mineralsData);
|
||||
|
||||
const totalItems = (shippingData?.indices.length || 0) + (chokepointData?.chokepoints.length || 0) + (mineralsData?.minerals.length || 0);
|
||||
const anyUnavailable = shippingData?.upstreamUnavailable || chokepointData?.upstreamUnavailable || mineralsData?.upstreamUnavailable;
|
||||
|
||||
this.ctx.statusPanel?.updateApi('SupplyChain', { status: anyUnavailable ? 'warning' : totalItems > 0 ? 'ok' : 'error' });
|
||||
|
||||
if (totalItems > 0) {
|
||||
dataFreshness.recordUpdate('supply_chain', totalItems);
|
||||
} else if (anyUnavailable) {
|
||||
dataFreshness.recordError('supply_chain', 'Supply chain upstream temporarily unavailable');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[App] Supply chain failed:', e);
|
||||
this.ctx.statusPanel?.updateApi('SupplyChain', { status: 'error' });
|
||||
dataFreshness.recordError('supply_chain', String(e));
|
||||
}
|
||||
}
|
||||
|
||||
updateMonitorResults(): void {
|
||||
const monitorPanel = this.ctx.panels['monitors'] as MonitorPanel;
|
||||
monitorPanel.renderResults(this.ctx.allNews);
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
PopulationExposurePanel,
|
||||
InvestmentsPanel,
|
||||
TradePolicyPanel,
|
||||
SupplyChainPanel,
|
||||
} from '@/components';
|
||||
import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel';
|
||||
import { PositiveNewsFeedPanel } from '@/components/PositiveNewsFeedPanel';
|
||||
@@ -451,6 +452,9 @@ export class PanelLayoutManager implements AppModule {
|
||||
if (SITE_VARIANT === 'full' || SITE_VARIANT === 'finance') {
|
||||
const tradePolicyPanel = new TradePolicyPanel();
|
||||
this.ctx.panels['trade-policy'] = tradePolicyPanel;
|
||||
|
||||
const supplyChainPanel = new SupplyChainPanel();
|
||||
this.ctx.panels['supply-chain'] = supplyChainPanel;
|
||||
}
|
||||
|
||||
const africaPanel = new NewsPanel('africa', t('panels.africa'));
|
||||
|
||||
@@ -39,7 +39,7 @@ const WORLD_FEEDS = new Set([
|
||||
const WORLD_APIS = new Set([
|
||||
'RSS2JSON', 'Finnhub', 'CoinGecko', 'Polymarket', 'USGS', 'FRED',
|
||||
'AISStream', 'GDELT Doc', 'EIA', 'USASpending', 'PizzINT', 'FIRMS',
|
||||
'Cyber Threats API', 'BIS', 'WTO'
|
||||
'Cyber Threats API', 'BIS', 'WTO', 'SupplyChain'
|
||||
]);
|
||||
|
||||
import { t } from '../services/i18n';
|
||||
|
||||
197
src/components/SupplyChainPanel.ts
Normal file
197
src/components/SupplyChainPanel.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { Panel } from './Panel';
|
||||
import type {
|
||||
GetShippingRatesResponse,
|
||||
GetChokepointStatusResponse,
|
||||
GetCriticalMineralsResponse,
|
||||
} from '@/services/supply-chain';
|
||||
import { t } from '@/services/i18n';
|
||||
import { escapeHtml } from '@/utils/sanitize';
|
||||
import { isFeatureAvailable } from '@/services/runtime-config';
|
||||
import { isDesktopRuntime } from '@/services/runtime';
|
||||
|
||||
type TabId = 'chokepoints' | 'shipping' | 'minerals';
|
||||
|
||||
export class SupplyChainPanel extends Panel {
|
||||
private shippingData: GetShippingRatesResponse | null = null;
|
||||
private chokepointData: GetChokepointStatusResponse | null = null;
|
||||
private mineralsData: GetCriticalMineralsResponse | null = null;
|
||||
private activeTab: TabId = 'chokepoints';
|
||||
|
||||
constructor() {
|
||||
super({ id: 'supply-chain', title: t('panels.supplyChain') });
|
||||
this.content.addEventListener('click', (e) => {
|
||||
const target = (e.target as HTMLElement).closest('.economic-tab') as HTMLElement | null;
|
||||
if (!target) return;
|
||||
const tabId = target.dataset.tab as TabId;
|
||||
if (tabId && tabId !== this.activeTab) {
|
||||
this.activeTab = tabId;
|
||||
this.render();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public updateShippingRates(data: GetShippingRatesResponse): void {
|
||||
this.shippingData = data;
|
||||
this.render();
|
||||
}
|
||||
|
||||
public updateChokepointStatus(data: GetChokepointStatusResponse): void {
|
||||
this.chokepointData = data;
|
||||
this.render();
|
||||
}
|
||||
|
||||
public updateCriticalMinerals(data: GetCriticalMineralsResponse): void {
|
||||
this.mineralsData = data;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const tabsHtml = `
|
||||
<div class="economic-tabs">
|
||||
<button class="economic-tab ${this.activeTab === 'chokepoints' ? 'active' : ''}" data-tab="chokepoints">
|
||||
${t('components.supplyChain.chokepoints')}
|
||||
</button>
|
||||
<button class="economic-tab ${this.activeTab === 'shipping' ? 'active' : ''}" data-tab="shipping">
|
||||
${t('components.supplyChain.shipping')}
|
||||
</button>
|
||||
<button class="economic-tab ${this.activeTab === 'minerals' ? 'active' : ''}" data-tab="minerals">
|
||||
${t('components.supplyChain.minerals')}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const anyUnavailable = [this.shippingData, this.chokepointData, this.mineralsData]
|
||||
.some(d => d?.upstreamUnavailable);
|
||||
const unavailableBanner = anyUnavailable
|
||||
? `<div class="economic-warning">${t('components.supplyChain.upstreamUnavailable')}</div>`
|
||||
: '';
|
||||
|
||||
let contentHtml = '';
|
||||
switch (this.activeTab) {
|
||||
case 'chokepoints': contentHtml = this.renderChokepoints(); break;
|
||||
case 'shipping': contentHtml = this.renderShipping(); break;
|
||||
case 'minerals': contentHtml = this.renderMinerals(); break;
|
||||
}
|
||||
|
||||
this.setContent(`
|
||||
${tabsHtml}
|
||||
${unavailableBanner}
|
||||
<div class="economic-content">${contentHtml}</div>
|
||||
<div class="economic-footer">
|
||||
<span class="economic-source">${t('components.supplyChain.sources')}</span>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
private renderChokepoints(): string {
|
||||
if (!this.chokepointData || this.chokepointData.chokepoints.length === 0) {
|
||||
return `<div class="economic-empty">${t('components.supplyChain.noChokepoints')}</div>`;
|
||||
}
|
||||
|
||||
return `<div class="trade-restrictions-list">
|
||||
${this.chokepointData.chokepoints.map(cp => {
|
||||
const statusClass = cp.status === 'red' ? 'status-active' : cp.status === 'yellow' ? 'status-notified' : 'status-terminated';
|
||||
const statusDot = cp.status === 'red' ? 'sc-dot-red' : cp.status === 'yellow' ? 'sc-dot-yellow' : 'sc-dot-green';
|
||||
return `<div class="trade-restriction-card">
|
||||
<div class="trade-restriction-header">
|
||||
<span class="trade-country">${escapeHtml(cp.name)}</span>
|
||||
<span class="sc-status-dot ${statusDot}"></span>
|
||||
<span class="trade-badge">${cp.disruptionScore}/100</span>
|
||||
<span class="trade-status ${statusClass}">${escapeHtml(cp.status)}</span>
|
||||
</div>
|
||||
<div class="trade-restriction-body">
|
||||
<div class="trade-sector">${cp.activeWarnings} ${t('components.supplyChain.warnings')}</div>
|
||||
<div class="trade-description">${escapeHtml(cp.description)}</div>
|
||||
<div class="trade-affected">${escapeHtml(cp.affectedRoutes.join(', '))}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderShipping(): string {
|
||||
if (isDesktopRuntime() && !isFeatureAvailable('supplyChain')) {
|
||||
return `<div class="economic-empty">${t('components.supplyChain.fredKeyMissing')}</div>`;
|
||||
}
|
||||
|
||||
if (!this.shippingData || this.shippingData.indices.length === 0) {
|
||||
return `<div class="economic-empty">${t('components.supplyChain.noShipping')}</div>`;
|
||||
}
|
||||
|
||||
return `<div class="trade-restrictions-list">
|
||||
${this.shippingData.indices.map(idx => {
|
||||
const changeClass = idx.changePct >= 0 ? 'change-positive' : 'change-negative';
|
||||
const changeArrow = idx.changePct >= 0 ? '\u25B2' : '\u25BC';
|
||||
const sparkline = this.renderSparkline(idx.history.map(h => h.value));
|
||||
const spikeBanner = idx.spikeAlert
|
||||
? `<div class="economic-warning">${t('components.supplyChain.spikeAlert')}</div>`
|
||||
: '';
|
||||
return `<div class="trade-restriction-card">
|
||||
${spikeBanner}
|
||||
<div class="trade-restriction-header">
|
||||
<span class="trade-country">${escapeHtml(idx.name)}</span>
|
||||
<span class="trade-badge">${idx.currentValue.toFixed(0)} ${escapeHtml(idx.unit)}</span>
|
||||
<span class="trade-flow-change ${changeClass}">${changeArrow} ${Math.abs(idx.changePct).toFixed(1)}%</span>
|
||||
</div>
|
||||
<div class="trade-restriction-body">
|
||||
${sparkline}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderSparkline(values: number[]): string {
|
||||
if (values.length < 2) return '';
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const range = max - min || 1;
|
||||
const w = 200;
|
||||
const h = 40;
|
||||
const points = values.map((v, i) => {
|
||||
const x = (i / (values.length - 1)) * w;
|
||||
const y = h - ((v - min) / range) * h;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
}).join(' ');
|
||||
|
||||
return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" style="display:block;margin:4px 0">
|
||||
<polyline points="${points}" fill="none" stroke="var(--accent-primary, #4fc3f7)" stroke-width="1.5" />
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
private renderMinerals(): string {
|
||||
if (!this.mineralsData || this.mineralsData.minerals.length === 0) {
|
||||
return `<div class="economic-empty">${t('components.supplyChain.noMinerals')}</div>`;
|
||||
}
|
||||
|
||||
const rows = this.mineralsData.minerals.map(m => {
|
||||
const riskClass = m.riskRating === 'critical' ? 'sc-risk-critical'
|
||||
: m.riskRating === 'high' ? 'sc-risk-high'
|
||||
: m.riskRating === 'moderate' ? 'sc-risk-moderate'
|
||||
: 'sc-risk-low';
|
||||
const top3 = m.topProducers.slice(0, 3).map(p =>
|
||||
`${escapeHtml(p.country)} ${p.sharePct.toFixed(0)}%`
|
||||
).join(', ');
|
||||
return `<tr>
|
||||
<td>${escapeHtml(m.mineral)}</td>
|
||||
<td>${top3}</td>
|
||||
<td>${m.hhi.toFixed(0)}</td>
|
||||
<td><span class="${riskClass}">${escapeHtml(m.riskRating)}</span></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
return `<div class="trade-tariffs-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${t('components.supplyChain.mineral')}</th>
|
||||
<th>${t('components.supplyChain.topProducers')}</th>
|
||||
<th>HHI</th>
|
||||
<th>${t('components.supplyChain.risk')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
@@ -40,3 +40,4 @@ export * from './PopulationExposurePanel';
|
||||
export * from './InvestmentsPanel';
|
||||
export * from './UnifiedSettings';
|
||||
export * from './TradePolicyPanel';
|
||||
export * from './SupplyChainPanel';
|
||||
|
||||
@@ -83,6 +83,7 @@ export const COMMANDS: Command[] = [
|
||||
{ id: 'panel:markets', keywords: ['markets', 'stocks', 'indices'], label: 'Jump to Markets', icon: '\u{1F4C8}', category: 'panels' },
|
||||
{ id: 'panel:economic', keywords: ['economic', 'economy', 'fred'], label: 'Jump to Economic Indicators', icon: '\u{1F4CA}', category: 'panels' },
|
||||
{ id: 'panel:trade-policy', keywords: ['trade', 'tariffs', 'wto', 'trade policy', 'sanctions', 'restrictions'], label: 'Jump to Trade Policy', icon: '\u{1F4CA}', category: 'panels' },
|
||||
{ id: 'panel:supply-chain', keywords: ['supply chain', 'shipping', 'chokepoint', 'minerals', 'freight', 'logistics'], label: 'Jump to Supply Chain', icon: '\u{1F6A2}', category: 'panels' },
|
||||
{ id: 'panel:finance', keywords: ['financial', 'finance news'], label: 'Jump to Financial', icon: '\u{1F4B5}', category: 'panels' },
|
||||
{ id: 'panel:tech', keywords: ['technology', 'tech news'], label: 'Jump to Technology', icon: '\u{1F4BB}', category: 'panels' },
|
||||
{ id: 'panel:crypto', keywords: ['crypto', 'bitcoin', 'ethereum'], label: 'Jump to Crypto', icon: '\u20BF', category: 'panels' },
|
||||
|
||||
@@ -33,6 +33,7 @@ const FULL_PANELS: Record<string, PanelConfig> = {
|
||||
markets: { name: 'Markets', enabled: true, priority: 1 },
|
||||
economic: { name: 'Economic Indicators', enabled: true, priority: 1 },
|
||||
'trade-policy': { name: 'Trade Policy', enabled: true, priority: 1 },
|
||||
'supply-chain': { name: 'Supply Chain', enabled: true, priority: 1 },
|
||||
finance: { name: 'Financial', enabled: true, priority: 1 },
|
||||
tech: { name: 'Technology', enabled: true, priority: 2 },
|
||||
crypto: { name: 'Crypto', enabled: true, priority: 2 },
|
||||
@@ -302,6 +303,7 @@ const FINANCE_PANELS: Record<string, PanelConfig> = {
|
||||
centralbanks: { name: 'Central Bank Watch', enabled: true, priority: 1 },
|
||||
economic: { name: 'Economic Data', enabled: true, priority: 1 },
|
||||
'trade-policy': { name: 'Trade Policy', enabled: true, priority: 1 },
|
||||
'supply-chain': { name: 'Supply Chain', enabled: true, priority: 1 },
|
||||
'economic-news': { name: 'Economic News', enabled: true, priority: 2 },
|
||||
ipo: { name: 'IPOs, Earnings & M&A', enabled: true, priority: 1 },
|
||||
heatmap: { name: 'Sector Heatmap', enabled: true, priority: 1 },
|
||||
@@ -575,7 +577,7 @@ export const PANEL_CATEGORY_MAP: Record<string, { labelKey: string; panelKeys: s
|
||||
},
|
||||
marketsFinance: {
|
||||
labelKey: 'header.panelCatMarketsFinance',
|
||||
panelKeys: ['commodities', 'markets', 'economic', 'trade-policy', 'finance', 'polymarket', 'macro-signals', 'etf-flows', 'stablecoins', 'crypto', 'heatmap'],
|
||||
panelKeys: ['commodities', 'markets', 'economic', 'trade-policy', 'supply-chain', 'finance', 'polymarket', 'macro-signals', 'etf-flows', 'stablecoins', 'crypto', 'heatmap'],
|
||||
variants: ['full'],
|
||||
},
|
||||
topical: {
|
||||
@@ -634,7 +636,7 @@ export const PANEL_CATEGORY_MAP: Record<string, { labelKey: string; panelKeys: s
|
||||
},
|
||||
centralBanksEcon: {
|
||||
labelKey: 'header.panelCatCentralBanks',
|
||||
panelKeys: ['centralbanks', 'economic', 'trade-policy', 'economic-news'],
|
||||
panelKeys: ['centralbanks', 'economic', 'trade-policy', 'supply-chain', 'economic-news'],
|
||||
variants: ['finance'],
|
||||
},
|
||||
dealsInstitutional: {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
|
||||
// source: worldmonitor/supply_chain/v1/service.proto
|
||||
|
||||
export interface ShippingRatePoint {
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface ShippingIndex {
|
||||
indexId: string;
|
||||
name: string;
|
||||
currentValue: number;
|
||||
previousValue: number;
|
||||
changePct: number;
|
||||
unit: string;
|
||||
history: ShippingRatePoint[];
|
||||
spikeAlert: boolean;
|
||||
}
|
||||
|
||||
export interface ChokepointInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
disruptionScore: number;
|
||||
status: string;
|
||||
activeWarnings: number;
|
||||
congestionLevel: string;
|
||||
affectedRoutes: string[];
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface MineralProducer {
|
||||
country: string;
|
||||
countryCode: string;
|
||||
productionTonnes: number;
|
||||
sharePct: number;
|
||||
}
|
||||
|
||||
export interface CriticalMineral {
|
||||
mineral: string;
|
||||
topProducers: MineralProducer[];
|
||||
hhi: number;
|
||||
riskRating: string;
|
||||
globalProduction: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface GetShippingRatesRequest {}
|
||||
|
||||
export interface GetShippingRatesResponse {
|
||||
indices: ShippingIndex[];
|
||||
fetchedAt: string;
|
||||
upstreamUnavailable: boolean;
|
||||
}
|
||||
|
||||
export interface GetChokepointStatusRequest {}
|
||||
|
||||
export interface GetChokepointStatusResponse {
|
||||
chokepoints: ChokepointInfo[];
|
||||
fetchedAt: string;
|
||||
upstreamUnavailable: boolean;
|
||||
}
|
||||
|
||||
export interface GetCriticalMineralsRequest {}
|
||||
|
||||
export interface GetCriticalMineralsResponse {
|
||||
minerals: CriticalMineral[];
|
||||
fetchedAt: string;
|
||||
upstreamUnavailable: boolean;
|
||||
}
|
||||
|
||||
export interface FieldViolation {
|
||||
field: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class ValidationError extends Error {
|
||||
violations: FieldViolation[];
|
||||
|
||||
constructor(violations: FieldViolation[]) {
|
||||
super("Validation failed");
|
||||
this.name = "ValidationError";
|
||||
this.violations = violations;
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
statusCode: number;
|
||||
body: string;
|
||||
|
||||
constructor(statusCode: number, message: string, body: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.statusCode = statusCode;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SupplyChainServiceClientOptions {
|
||||
fetch?: typeof fetch;
|
||||
defaultHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SupplyChainServiceCallOptions {
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export class SupplyChainServiceClient {
|
||||
private baseURL: string;
|
||||
private fetchFn: typeof fetch;
|
||||
private defaultHeaders: Record<string, string>;
|
||||
|
||||
constructor(baseURL: string, options?: SupplyChainServiceClientOptions) {
|
||||
this.baseURL = baseURL.replace(/\/+$/, "");
|
||||
this.fetchFn = options?.fetch ?? globalThis.fetch;
|
||||
this.defaultHeaders = { ...options?.defaultHeaders };
|
||||
}
|
||||
|
||||
async getShippingRates(req: GetShippingRatesRequest, options?: SupplyChainServiceCallOptions): Promise<GetShippingRatesResponse> {
|
||||
let path = "/api/supply-chain/v1/get-shipping-rates";
|
||||
const url = this.baseURL + path;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...this.defaultHeaders,
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
const resp = await this.fetchFn(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(req),
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return this.handleError(resp);
|
||||
}
|
||||
|
||||
return await resp.json() as GetShippingRatesResponse;
|
||||
}
|
||||
|
||||
async getChokepointStatus(req: GetChokepointStatusRequest, options?: SupplyChainServiceCallOptions): Promise<GetChokepointStatusResponse> {
|
||||
let path = "/api/supply-chain/v1/get-chokepoint-status";
|
||||
const url = this.baseURL + path;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...this.defaultHeaders,
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
const resp = await this.fetchFn(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(req),
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return this.handleError(resp);
|
||||
}
|
||||
|
||||
return await resp.json() as GetChokepointStatusResponse;
|
||||
}
|
||||
|
||||
async getCriticalMinerals(req: GetCriticalMineralsRequest, options?: SupplyChainServiceCallOptions): Promise<GetCriticalMineralsResponse> {
|
||||
let path = "/api/supply-chain/v1/get-critical-minerals";
|
||||
const url = this.baseURL + path;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...this.defaultHeaders,
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
const resp = await this.fetchFn(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(req),
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
return this.handleError(resp);
|
||||
}
|
||||
|
||||
return await resp.json() as GetCriticalMineralsResponse;
|
||||
}
|
||||
|
||||
private async handleError(resp: Response): Promise<never> {
|
||||
const body = await resp.text();
|
||||
if (resp.status === 400) {
|
||||
try {
|
||||
const parsed = JSON.parse(body);
|
||||
if (parsed.violations) {
|
||||
throw new ValidationError(parsed.violations);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof ValidationError) throw e;
|
||||
}
|
||||
}
|
||||
throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
|
||||
// source: worldmonitor/supply_chain/v1/service.proto
|
||||
|
||||
export interface ShippingRatePoint {
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface ShippingIndex {
|
||||
indexId: string;
|
||||
name: string;
|
||||
currentValue: number;
|
||||
previousValue: number;
|
||||
changePct: number;
|
||||
unit: string;
|
||||
history: ShippingRatePoint[];
|
||||
spikeAlert: boolean;
|
||||
}
|
||||
|
||||
export interface ChokepointInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
disruptionScore: number;
|
||||
status: string;
|
||||
activeWarnings: number;
|
||||
congestionLevel: string;
|
||||
affectedRoutes: string[];
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface MineralProducer {
|
||||
country: string;
|
||||
countryCode: string;
|
||||
productionTonnes: number;
|
||||
sharePct: number;
|
||||
}
|
||||
|
||||
export interface CriticalMineral {
|
||||
mineral: string;
|
||||
topProducers: MineralProducer[];
|
||||
hhi: number;
|
||||
riskRating: string;
|
||||
globalProduction: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface GetShippingRatesRequest {}
|
||||
|
||||
export interface GetShippingRatesResponse {
|
||||
indices: ShippingIndex[];
|
||||
fetchedAt: string;
|
||||
upstreamUnavailable: boolean;
|
||||
}
|
||||
|
||||
export interface GetChokepointStatusRequest {}
|
||||
|
||||
export interface GetChokepointStatusResponse {
|
||||
chokepoints: ChokepointInfo[];
|
||||
fetchedAt: string;
|
||||
upstreamUnavailable: boolean;
|
||||
}
|
||||
|
||||
export interface GetCriticalMineralsRequest {}
|
||||
|
||||
export interface GetCriticalMineralsResponse {
|
||||
minerals: CriticalMineral[];
|
||||
fetchedAt: string;
|
||||
upstreamUnavailable: boolean;
|
||||
}
|
||||
|
||||
export interface FieldViolation {
|
||||
field: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class ValidationError extends Error {
|
||||
violations: FieldViolation[];
|
||||
|
||||
constructor(violations: FieldViolation[]) {
|
||||
super("Validation failed");
|
||||
this.name = "ValidationError";
|
||||
this.violations = violations;
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
statusCode: number;
|
||||
body: string;
|
||||
|
||||
constructor(statusCode: number, message: string, body: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.statusCode = statusCode;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServerContext {
|
||||
request: Request;
|
||||
pathParams: Record<string, string>;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ServerOptions {
|
||||
onError?: (error: unknown, req: Request) => Response | Promise<Response>;
|
||||
validateRequest?: (methodName: string, body: unknown) => FieldViolation[] | undefined;
|
||||
}
|
||||
|
||||
export interface RouteDescriptor {
|
||||
method: string;
|
||||
path: string;
|
||||
handler: (req: Request) => Promise<Response>;
|
||||
}
|
||||
|
||||
export interface SupplyChainServiceHandler {
|
||||
getShippingRates(ctx: ServerContext, req: GetShippingRatesRequest): Promise<GetShippingRatesResponse>;
|
||||
getChokepointStatus(ctx: ServerContext, req: GetChokepointStatusRequest): Promise<GetChokepointStatusResponse>;
|
||||
getCriticalMinerals(ctx: ServerContext, req: GetCriticalMineralsRequest): Promise<GetCriticalMineralsResponse>;
|
||||
}
|
||||
|
||||
export function createSupplyChainServiceRoutes(
|
||||
handler: SupplyChainServiceHandler,
|
||||
options?: ServerOptions,
|
||||
): RouteDescriptor[] {
|
||||
return [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/supply-chain/v1/get-shipping-rates",
|
||||
handler: async (req: Request): Promise<Response> => {
|
||||
try {
|
||||
const pathParams: Record<string, string> = {};
|
||||
const body = await req.json() as GetShippingRatesRequest;
|
||||
if (options?.validateRequest) {
|
||||
const bodyViolations = options.validateRequest("getShippingRates", body);
|
||||
if (bodyViolations) {
|
||||
throw new ValidationError(bodyViolations);
|
||||
}
|
||||
}
|
||||
|
||||
const ctx: ServerContext = {
|
||||
request: req,
|
||||
pathParams,
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
};
|
||||
|
||||
const result = await handler.getShippingRates(ctx, body);
|
||||
return new Response(JSON.stringify(result as GetShippingRatesResponse), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ValidationError) {
|
||||
return new Response(JSON.stringify({ violations: err.violations }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (options?.onError) {
|
||||
return options.onError(err, req);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return new Response(JSON.stringify({ message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/supply-chain/v1/get-chokepoint-status",
|
||||
handler: async (req: Request): Promise<Response> => {
|
||||
try {
|
||||
const pathParams: Record<string, string> = {};
|
||||
const body = await req.json() as GetChokepointStatusRequest;
|
||||
if (options?.validateRequest) {
|
||||
const bodyViolations = options.validateRequest("getChokepointStatus", body);
|
||||
if (bodyViolations) {
|
||||
throw new ValidationError(bodyViolations);
|
||||
}
|
||||
}
|
||||
|
||||
const ctx: ServerContext = {
|
||||
request: req,
|
||||
pathParams,
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
};
|
||||
|
||||
const result = await handler.getChokepointStatus(ctx, body);
|
||||
return new Response(JSON.stringify(result as GetChokepointStatusResponse), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ValidationError) {
|
||||
return new Response(JSON.stringify({ violations: err.violations }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (options?.onError) {
|
||||
return options.onError(err, req);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return new Response(JSON.stringify({ message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/supply-chain/v1/get-critical-minerals",
|
||||
handler: async (req: Request): Promise<Response> => {
|
||||
try {
|
||||
const pathParams: Record<string, string> = {};
|
||||
const body = await req.json() as GetCriticalMineralsRequest;
|
||||
if (options?.validateRequest) {
|
||||
const bodyViolations = options.validateRequest("getCriticalMinerals", body);
|
||||
if (bodyViolations) {
|
||||
throw new ValidationError(bodyViolations);
|
||||
}
|
||||
}
|
||||
|
||||
const ctx: ServerContext = {
|
||||
request: req,
|
||||
pathParams,
|
||||
headers: Object.fromEntries(req.headers.entries()),
|
||||
};
|
||||
|
||||
const result = await handler.getCriticalMinerals(ctx, body);
|
||||
return new Response(JSON.stringify(result as GetCriticalMineralsResponse), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ValidationError) {
|
||||
return new Response(JSON.stringify({ violations: err.violations }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (options?.onError) {
|
||||
return options.onError(err, req);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return new Response(JSON.stringify({ message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -168,6 +168,7 @@
|
||||
"commodities": "Commodities",
|
||||
"economic": "Economic Indicators",
|
||||
"tradePolicy": "Trade Policy",
|
||||
"supplyChain": "Supply Chain",
|
||||
"finance": "Financial",
|
||||
"tech": "Technology",
|
||||
"crypto": "Crypto",
|
||||
@@ -566,6 +567,22 @@
|
||||
"hike": "hike",
|
||||
"hold": "hold"
|
||||
},
|
||||
"supplyChain": {
|
||||
"chokepoints": "Chokepoints",
|
||||
"shipping": "Shipping",
|
||||
"minerals": "Minerals",
|
||||
"noChokepoints": "Chokepoint data loading...",
|
||||
"noShipping": "Shipping rate data not available",
|
||||
"noMinerals": "Mineral data loading...",
|
||||
"fredKeyMissing": "FRED API key required for shipping rates — add it in Settings. Chokepoints and minerals available without key.",
|
||||
"upstreamUnavailable": "Supply chain data temporarily unavailable — showing cached data",
|
||||
"spikeAlert": "Spike detected — rate significantly above 52-week average (weekly)",
|
||||
"warnings": "warning(s)",
|
||||
"mineral": "Mineral",
|
||||
"topProducers": "Top Producers",
|
||||
"risk": "Risk",
|
||||
"sources": "FRED / NGA / USGS"
|
||||
},
|
||||
"tradePolicy": {
|
||||
"restrictions": "Restrictions",
|
||||
"tariffs": "Tariffs",
|
||||
|
||||
@@ -34,7 +34,8 @@ export type DataSourceId =
|
||||
| 'worldpop' // WorldPop population exposure
|
||||
| 'giving' // Global giving activity data
|
||||
| 'bis' // BIS central bank data
|
||||
| 'wto_trade'; // WTO trade policy data
|
||||
| 'wto_trade' // WTO trade policy data
|
||||
| 'supply_chain'; // Supply chain disruption intelligence
|
||||
|
||||
export type FreshnessStatus = 'fresh' | 'stale' | 'very_stale' | 'no_data' | 'disabled' | 'error';
|
||||
|
||||
@@ -99,6 +100,7 @@ const SOURCE_METADATA: Record<DataSourceId, { name: string; requiredForRisk: boo
|
||||
giving: { name: 'Global Giving Activity', requiredForRisk: false, panelId: 'giving' },
|
||||
bis: { name: 'BIS Central Banks', requiredForRisk: false, panelId: 'economic' },
|
||||
wto_trade: { name: 'WTO Trade Policy', requiredForRisk: false, panelId: 'trade-policy' },
|
||||
supply_chain: { name: 'Supply Chain Intelligence', requiredForRisk: false, panelId: 'supply-chain' },
|
||||
};
|
||||
|
||||
class DataFreshnessTracker {
|
||||
@@ -356,6 +358,7 @@ const INTELLIGENCE_GAP_MESSAGES: Record<DataSourceId, string> = {
|
||||
giving: 'Global giving activity data unavailable',
|
||||
bis: 'Central bank policy data may be stale—BIS feed unavailable',
|
||||
wto_trade: 'Trade policy intelligence unavailable—WTO data not updating',
|
||||
supply_chain: 'Supply chain disruption status unavailable—chokepoint monitoring offline',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,3 +38,4 @@ export * from './usa-spending';
|
||||
export { generateSummary, translateText } from './summarization';
|
||||
export * from './cached-theater-posture';
|
||||
export * from './trade';
|
||||
export * from './supply-chain';
|
||||
|
||||
@@ -41,7 +41,8 @@ export type RuntimeFeatureId =
|
||||
| 'finnhubMarkets'
|
||||
| 'nasaFirms'
|
||||
| 'aiOllama'
|
||||
| 'wtoTrade';
|
||||
| 'wtoTrade'
|
||||
| 'supplyChain';
|
||||
|
||||
export interface RuntimeFeatureDefinition {
|
||||
id: RuntimeFeatureId;
|
||||
@@ -83,6 +84,7 @@ const defaultToggles: Record<RuntimeFeatureId, boolean> = {
|
||||
nasaFirms: true,
|
||||
aiOllama: true,
|
||||
wtoTrade: true,
|
||||
supplyChain: true,
|
||||
};
|
||||
|
||||
export const RUNTIME_FEATURES: RuntimeFeatureDefinition[] = [
|
||||
@@ -200,6 +202,13 @@ export const RUNTIME_FEATURES: RuntimeFeatureDefinition[] = [
|
||||
requiredSecrets: ['WTO_API_KEY'],
|
||||
fallback: 'Trade policy panel shows disabled state.',
|
||||
},
|
||||
{
|
||||
id: 'supplyChain',
|
||||
name: 'Supply Chain Intelligence',
|
||||
description: 'Shipping rates via FRED Baltic Dry Index. Chokepoints and minerals use public data.',
|
||||
requiredSecrets: ['FRED_API_KEY'],
|
||||
fallback: 'Chokepoints and minerals always available; shipping requires FRED key.',
|
||||
},
|
||||
];
|
||||
|
||||
function readEnvSecret(key: RuntimeSecretKey): string {
|
||||
|
||||
65
src/services/supply-chain/index.ts
Normal file
65
src/services/supply-chain/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
SupplyChainServiceClient,
|
||||
type GetShippingRatesResponse,
|
||||
type GetChokepointStatusResponse,
|
||||
type GetCriticalMineralsResponse,
|
||||
type ShippingIndex,
|
||||
type ChokepointInfo,
|
||||
type CriticalMineral,
|
||||
type MineralProducer,
|
||||
type ShippingRatePoint,
|
||||
} from '@/generated/client/worldmonitor/supply_chain/v1/service_client';
|
||||
import { createCircuitBreaker } from '@/utils';
|
||||
import { isFeatureAvailable } from '../runtime-config';
|
||||
|
||||
export type {
|
||||
GetShippingRatesResponse,
|
||||
GetChokepointStatusResponse,
|
||||
GetCriticalMineralsResponse,
|
||||
ShippingIndex,
|
||||
ChokepointInfo,
|
||||
CriticalMineral,
|
||||
MineralProducer,
|
||||
ShippingRatePoint,
|
||||
};
|
||||
|
||||
const client = new SupplyChainServiceClient('', { fetch: (...args) => globalThis.fetch(...args) });
|
||||
|
||||
const shippingBreaker = createCircuitBreaker<GetShippingRatesResponse>({ name: 'Shipping Rates', cacheTtlMs: 15 * 60 * 1000, persistCache: true });
|
||||
const chokepointBreaker = createCircuitBreaker<GetChokepointStatusResponse>({ name: 'Chokepoint Status', cacheTtlMs: 5 * 60 * 1000, persistCache: true });
|
||||
const mineralsBreaker = createCircuitBreaker<GetCriticalMineralsResponse>({ name: 'Critical Minerals', cacheTtlMs: 60 * 60 * 1000, persistCache: true });
|
||||
|
||||
const emptyShipping: GetShippingRatesResponse = { indices: [], fetchedAt: '', upstreamUnavailable: false };
|
||||
const emptyChokepoints: GetChokepointStatusResponse = { chokepoints: [], fetchedAt: '', upstreamUnavailable: false };
|
||||
const emptyMinerals: GetCriticalMineralsResponse = { minerals: [], fetchedAt: '', upstreamUnavailable: false };
|
||||
|
||||
export async function fetchShippingRates(): Promise<GetShippingRatesResponse> {
|
||||
if (!isFeatureAvailable('supplyChain')) return emptyShipping;
|
||||
try {
|
||||
return await shippingBreaker.execute(async () => {
|
||||
return client.getShippingRates({});
|
||||
}, emptyShipping);
|
||||
} catch {
|
||||
return emptyShipping;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchChokepointStatus(): Promise<GetChokepointStatusResponse> {
|
||||
try {
|
||||
return await chokepointBreaker.execute(async () => {
|
||||
return client.getChokepointStatus({});
|
||||
}, emptyChokepoints);
|
||||
} catch {
|
||||
return emptyChokepoints;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCriticalMinerals(): Promise<GetCriticalMineralsResponse> {
|
||||
try {
|
||||
return await mineralsBreaker.execute(async () => {
|
||||
return client.getCriticalMinerals({});
|
||||
}, emptyMinerals);
|
||||
} catch {
|
||||
return emptyMinerals;
|
||||
}
|
||||
}
|
||||
@@ -7248,6 +7248,23 @@ a.prediction-link:hover {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.sc-status-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin: 0 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.sc-dot-red { background: var(--red, #ff5252); }
|
||||
.sc-dot-yellow { background: var(--yellow, #ffd740); }
|
||||
.sc-dot-green { background: var(--green, #69f0ae); }
|
||||
|
||||
.sc-risk-critical { color: var(--red, #ff5252); font-weight: 600; }
|
||||
.sc-risk-high { color: var(--orange, #ffab40); font-weight: 600; }
|
||||
.sc-risk-moderate { color: var(--yellow, #ffd740); }
|
||||
.sc-risk-low { color: var(--green, #69f0ae); }
|
||||
|
||||
.trade-sector {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
|
||||
102
tests/supply-chain-handlers.test.mjs
Normal file
102
tests/supply-chain-handlers.test.mjs
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
computeDisruptionScore,
|
||||
scoreToStatus,
|
||||
computeHHI,
|
||||
riskRating,
|
||||
detectSpike,
|
||||
SEVERITY_SCORE,
|
||||
} from '../server/worldmonitor/supply-chain/v1/_scoring.mjs';
|
||||
|
||||
describe('Chokepoint scoring', () => {
|
||||
it('computes disruption score with cap at 100', () => {
|
||||
assert.equal(computeDisruptionScore(3, 2), Math.min(100, 3 * 15 + 2 * 30));
|
||||
assert.equal(computeDisruptionScore(5, 3), 100);
|
||||
assert.equal(computeDisruptionScore(0, 0), 0);
|
||||
});
|
||||
|
||||
it('maps score to status correctly', () => {
|
||||
assert.equal(scoreToStatus(0), 'green');
|
||||
assert.equal(scoreToStatus(15), 'green');
|
||||
assert.equal(scoreToStatus(19), 'green');
|
||||
assert.equal(scoreToStatus(20), 'yellow');
|
||||
assert.equal(scoreToStatus(45), 'yellow');
|
||||
assert.equal(scoreToStatus(49), 'yellow');
|
||||
assert.equal(scoreToStatus(50), 'red');
|
||||
assert.equal(scoreToStatus(65), 'red');
|
||||
assert.equal(scoreToStatus(100), 'red');
|
||||
});
|
||||
|
||||
it('has correct severity enum keys', () => {
|
||||
assert.equal(SEVERITY_SCORE['AIS_DISRUPTION_SEVERITY_LOW'], 1);
|
||||
assert.equal(SEVERITY_SCORE['AIS_DISRUPTION_SEVERITY_ELEVATED'], 2);
|
||||
assert.equal(SEVERITY_SCORE['AIS_DISRUPTION_SEVERITY_HIGH'], 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HHI computation', () => {
|
||||
it('returns 10000 for pure monopoly', () => {
|
||||
assert.equal(computeHHI([100]), 10000);
|
||||
});
|
||||
|
||||
it('returns 2500 for four equal producers', () => {
|
||||
assert.equal(computeHHI([25, 25, 25, 25]), 2500);
|
||||
});
|
||||
|
||||
it('returns 0 for empty array', () => {
|
||||
assert.equal(computeHHI([]), 0);
|
||||
});
|
||||
|
||||
it('handles two equal producers', () => {
|
||||
assert.equal(computeHHI([50, 50]), 5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Risk rating', () => {
|
||||
it('maps HHI to correct risk levels', () => {
|
||||
assert.equal(riskRating(1499), 'low');
|
||||
assert.equal(riskRating(1500), 'moderate');
|
||||
assert.equal(riskRating(2499), 'moderate');
|
||||
assert.equal(riskRating(2500), 'high');
|
||||
assert.equal(riskRating(4999), 'high');
|
||||
assert.equal(riskRating(5000), 'critical');
|
||||
assert.equal(riskRating(5001), 'critical');
|
||||
assert.equal(riskRating(10000), 'critical');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Spike detection', () => {
|
||||
it('detects spike when value > mean + 2*stdDev', () => {
|
||||
assert.equal(detectSpike([100, 102, 98, 101, 99, 100, 103, 97, 100, 500]), true);
|
||||
});
|
||||
|
||||
it('returns false for stable series', () => {
|
||||
assert.equal(detectSpike([100, 101, 99, 100]), false);
|
||||
});
|
||||
|
||||
it('returns false for empty array', () => {
|
||||
assert.equal(detectSpike([]), false);
|
||||
});
|
||||
|
||||
it('returns false for too few values', () => {
|
||||
assert.equal(detectSpike([100, 200]), false);
|
||||
});
|
||||
|
||||
it('handles ShippingRatePoint objects', () => {
|
||||
const points = [
|
||||
{ date: '2024-01-01', value: 100 },
|
||||
{ date: '2024-01-08', value: 102 },
|
||||
{ date: '2024-01-15', value: 98 },
|
||||
{ date: '2024-01-22', value: 101 },
|
||||
{ date: '2024-01-29', value: 99 },
|
||||
{ date: '2024-02-05', value: 100 },
|
||||
{ date: '2024-02-12', value: 103 },
|
||||
{ date: '2024-02-19', value: 97 },
|
||||
{ date: '2024-02-26', value: 100 },
|
||||
{ date: '2024-03-04', value: 500 },
|
||||
];
|
||||
assert.equal(detectSpike(points), true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user