From 9d9da96bc08fb1a0f36c8747f1835f0839bf0ce8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Feb 2026 09:51:10 +0000 Subject: [PATCH 1/3] feat: add WTO trade policy service with 4 RPC endpoints and TradePolicyPanel Adds a new `trade` RPC domain backed by the WTO API (apiportal.wto.org) for trade policy intelligence: quantitative restrictions, tariff timeseries, bilateral trade flows, and SPS/TBT barrier notifications. New files: 6 protos, generated server/client, 4 server handlers + shared WTO fetch utility, client service with circuit breakers, TradePolicyPanel (4 tabs), and full API key infrastructure (Rust keychain, sidecar, runtime config). Panel registered for FULL and FINANCE variants with data loader integration, command palette entry, status panel tracking, data freshness monitoring, and i18n across all 17 locale files. https://claude.ai/code/session_01HZXyoQp6xK3TX8obDzv6Ye --- api/[domain]/v1/[rpc].ts | 3 + docs/DESKTOP_CONFIGURATION.md | 3 +- .../trade/v1/get_tariff_trends.proto | 27 ++ .../trade/v1/get_trade_barriers.proto | 25 ++ .../trade/v1/get_trade_flows.proto | 25 ++ .../trade/v1/get_trade_restrictions.proto | 23 ++ proto/worldmonitor/trade/v1/service.proto | 34 ++ proto/worldmonitor/trade/v1/trade_data.proto | 85 +++++ server/worldmonitor/trade/v1/_shared.ts | 74 ++++ .../trade/v1/get-tariff-trends.ts | 127 +++++++ .../trade/v1/get-trade-barriers.ts | 124 +++++++ .../worldmonitor/trade/v1/get-trade-flows.ts | 178 ++++++++++ .../trade/v1/get-trade-restrictions.ts | 117 +++++++ server/worldmonitor/trade/v1/handler.ts | 13 + src-tauri/sidecar/local-api-server.mjs | 5 +- src-tauri/src/main.rs | 3 +- src/app/data-loader.ts | 44 +++ src/app/panel-layout.ts | 6 + src/components/RuntimeConfigPanel.ts | 1 + src/components/StatusPanel.ts | 2 +- src/components/TradePolicyPanel.ts | 236 +++++++++++++ src/components/index.ts | 1 + src/config/commands.ts | 1 + src/config/panels.ts | 6 +- .../worldmonitor/trade/v1/service_client.ts | 255 ++++++++++++++ .../worldmonitor/trade/v1/service_server.ts | 326 ++++++++++++++++++ src/locales/ar.json | 24 +- src/locales/de.json | 24 +- src/locales/el.json | 24 +- src/locales/en.json | 24 +- src/locales/es.json | 24 +- src/locales/fr.json | 24 +- src/locales/it.json | 24 +- src/locales/ja.json | 24 +- src/locales/nl.json | 24 +- src/locales/pl.json | 24 +- src/locales/pt.json | 24 +- src/locales/ru.json | 24 +- src/locales/sv.json | 24 +- src/locales/th.json | 24 +- src/locales/tr.json | 24 +- src/locales/vi.json | 24 +- src/locales/zh.json | 24 +- src/services/analytics.ts | 1 + src/services/data-freshness.ts | 5 +- src/services/index.ts | 1 + src/services/runtime-config.ts | 14 +- src/services/trade/index.ts | 83 +++++ vite.config.ts | 4 + 49 files changed, 2234 insertions(+), 26 deletions(-) create mode 100644 proto/worldmonitor/trade/v1/get_tariff_trends.proto create mode 100644 proto/worldmonitor/trade/v1/get_trade_barriers.proto create mode 100644 proto/worldmonitor/trade/v1/get_trade_flows.proto create mode 100644 proto/worldmonitor/trade/v1/get_trade_restrictions.proto create mode 100644 proto/worldmonitor/trade/v1/service.proto create mode 100644 proto/worldmonitor/trade/v1/trade_data.proto create mode 100644 server/worldmonitor/trade/v1/_shared.ts create mode 100644 server/worldmonitor/trade/v1/get-tariff-trends.ts create mode 100644 server/worldmonitor/trade/v1/get-trade-barriers.ts create mode 100644 server/worldmonitor/trade/v1/get-trade-flows.ts create mode 100644 server/worldmonitor/trade/v1/get-trade-restrictions.ts create mode 100644 server/worldmonitor/trade/v1/handler.ts create mode 100644 src/components/TradePolicyPanel.ts create mode 100644 src/generated/client/worldmonitor/trade/v1/service_client.ts create mode 100644 src/generated/server/worldmonitor/trade/v1/service_server.ts create mode 100644 src/services/trade/index.ts diff --git a/api/[domain]/v1/[rpc].ts b/api/[domain]/v1/[rpc].ts index 8dde8c5a0..0b352c51a 100644 --- a/api/[domain]/v1/[rpc].ts +++ b/api/[domain]/v1/[rpc].ts @@ -50,6 +50,8 @@ import { createPositiveEventsServiceRoutes } from '../../../src/generated/server import { positiveEventsHandler } from '../../../server/worldmonitor/positive-events/v1/handler'; import { createGivingServiceRoutes } from '../../../src/generated/server/worldmonitor/giving/v1/service_server'; 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 type { ServerOptions } from '../../../src/generated/server/worldmonitor/seismology/v1/service_server'; @@ -75,6 +77,7 @@ const allRoutes = [ ...createMilitaryServiceRoutes(militaryHandler, serverOptions), ...createPositiveEventsServiceRoutes(positiveEventsHandler, serverOptions), ...createGivingServiceRoutes(givingHandler, serverOptions), + ...createTradeServiceRoutes(tradeHandler, serverOptions), ]; const router = createRouter(allRoutes); diff --git a/docs/DESKTOP_CONFIGURATION.md b/docs/DESKTOP_CONFIGURATION.md index c284689b7..29507ba09 100644 --- a/docs/DESKTOP_CONFIGURATION.md +++ b/docs/DESKTOP_CONFIGURATION.md @@ -4,7 +4,7 @@ World Monitor desktop now uses a runtime configuration schema with per-feature t ## Secret keys -The desktop vault schema (Rust `SUPPORTED_SECRET_KEYS`) supports the following 21 keys: +The desktop vault schema (Rust `SUPPORTED_SECRET_KEYS`) supports the following 22 keys: - `GROQ_API_KEY` - `OPENROUTER_API_KEY` @@ -27,6 +27,7 @@ The desktop vault schema (Rust `SUPPORTED_SECRET_KEYS`) supports the following 2 - `OLLAMA_API_URL` - `OLLAMA_MODEL` - `WORLDMONITOR_API_KEY` — gates cloud fallback access (min 16 chars) +- `WTO_API_KEY` Note: `UC_DP_KEY` exists in the TypeScript `RuntimeSecretKey` union but is not in the desktop Rust keychain or sidecar. diff --git a/proto/worldmonitor/trade/v1/get_tariff_trends.proto b/proto/worldmonitor/trade/v1/get_tariff_trends.proto new file mode 100644 index 000000000..f5176d05a --- /dev/null +++ b/proto/worldmonitor/trade/v1/get_tariff_trends.proto @@ -0,0 +1,27 @@ +syntax = "proto3"; + +package worldmonitor.trade.v1; + +import "worldmonitor/trade/v1/trade_data.proto"; + +// Request for tariff timeseries data. +message GetTariffTrendsRequest { + // WTO member code of reporting country (e.g. "840" = US). + string reporting_country = 1; + // WTO member code of partner country (e.g. "156" = China). + string partner_country = 2; + // Product sector filter (HS chapter). Empty = aggregate. + string product_sector = 3; + // Number of years to look back (default 10, max 30). + int32 years = 4; +} + +// Response containing tariff trend datapoints. +message GetTariffTrendsResponse { + // Tariff data points ordered by year ascending. + repeated TariffDataPoint datapoints = 1; + // ISO 8601 timestamp when data was fetched from WTO. + string fetched_at = 2; + // True if upstream fetch failed and results may be stale/empty. + bool upstream_unavailable = 3; +} diff --git a/proto/worldmonitor/trade/v1/get_trade_barriers.proto b/proto/worldmonitor/trade/v1/get_trade_barriers.proto new file mode 100644 index 000000000..327287bf1 --- /dev/null +++ b/proto/worldmonitor/trade/v1/get_trade_barriers.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package worldmonitor.trade.v1; + +import "worldmonitor/trade/v1/trade_data.proto"; + +// Request for SPS/TBT trade barrier notifications. +message GetTradeBarriersRequest { + // WTO member codes to filter by. Empty = all. + repeated string countries = 1; + // Filter by measure type: "SPS", "TBT", or empty for both. + string measure_type = 2; + // Max results to return (server caps at 100). + int32 limit = 3; +} + +// Response containing trade barrier notifications. +message GetTradeBarriersResponse { + // List of SPS/TBT barrier notifications. + repeated TradeBarrier barriers = 1; + // ISO 8601 timestamp when data was fetched from WTO. + string fetched_at = 2; + // True if upstream fetch failed and results may be stale/empty. + bool upstream_unavailable = 3; +} diff --git a/proto/worldmonitor/trade/v1/get_trade_flows.proto b/proto/worldmonitor/trade/v1/get_trade_flows.proto new file mode 100644 index 000000000..2761fcad2 --- /dev/null +++ b/proto/worldmonitor/trade/v1/get_trade_flows.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package worldmonitor.trade.v1; + +import "worldmonitor/trade/v1/trade_data.proto"; + +// Request for bilateral trade flow data. +message GetTradeFlowsRequest { + // WTO member code of reporting country. + string reporting_country = 1; + // WTO member code of partner country. + string partner_country = 2; + // Number of years to look back (default 10, max 30). + int32 years = 3; +} + +// Response containing trade flow records. +message GetTradeFlowsResponse { + // Trade flow records ordered by year ascending. + repeated TradeFlowRecord flows = 1; + // ISO 8601 timestamp when data was fetched from WTO. + string fetched_at = 2; + // True if upstream fetch failed and results may be stale/empty. + bool upstream_unavailable = 3; +} diff --git a/proto/worldmonitor/trade/v1/get_trade_restrictions.proto b/proto/worldmonitor/trade/v1/get_trade_restrictions.proto new file mode 100644 index 000000000..b7f9b435c --- /dev/null +++ b/proto/worldmonitor/trade/v1/get_trade_restrictions.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; + +package worldmonitor.trade.v1; + +import "worldmonitor/trade/v1/trade_data.proto"; + +// Request for quantitative restriction data. +message GetTradeRestrictionsRequest { + // WTO member codes to filter by. Empty = all. + repeated string countries = 1; + // Max results to return (server caps at 100). + int32 limit = 2; +} + +// Response containing trade restrictions and fetch metadata. +message GetTradeRestrictionsResponse { + // List of trade restrictions. + repeated TradeRestriction restrictions = 1; + // ISO 8601 timestamp when data was fetched from WTO. + string fetched_at = 2; + // True if upstream fetch failed and results may be stale/empty. + bool upstream_unavailable = 3; +} diff --git a/proto/worldmonitor/trade/v1/service.proto b/proto/worldmonitor/trade/v1/service.proto new file mode 100644 index 000000000..d094abab6 --- /dev/null +++ b/proto/worldmonitor/trade/v1/service.proto @@ -0,0 +1,34 @@ +syntax = "proto3"; + +package worldmonitor.trade.v1; + +import "sebuf/http/annotations.proto"; +import "worldmonitor/trade/v1/get_trade_restrictions.proto"; +import "worldmonitor/trade/v1/get_tariff_trends.proto"; +import "worldmonitor/trade/v1/get_trade_flows.proto"; +import "worldmonitor/trade/v1/get_trade_barriers.proto"; + +// Trade policy intelligence from WTO data sources. +service TradeService { + option (sebuf.http.service_config) = {base_path: "/api/trade/v1"}; + + // Get quantitative restrictions and export controls. + rpc GetTradeRestrictions(GetTradeRestrictionsRequest) returns (GetTradeRestrictionsResponse) { + option (sebuf.http.config) = {path: "/get-trade-restrictions"}; + } + + // Get tariff rate timeseries for a country pair. + rpc GetTariffTrends(GetTariffTrendsRequest) returns (GetTariffTrendsResponse) { + option (sebuf.http.config) = {path: "/get-tariff-trends"}; + } + + // Get bilateral merchandise trade flows. + rpc GetTradeFlows(GetTradeFlowsRequest) returns (GetTradeFlowsResponse) { + option (sebuf.http.config) = {path: "/get-trade-flows"}; + } + + // Get SPS/TBT barrier notifications. + rpc GetTradeBarriers(GetTradeBarriersRequest) returns (GetTradeBarriersResponse) { + option (sebuf.http.config) = {path: "/get-trade-barriers"}; + } +} diff --git a/proto/worldmonitor/trade/v1/trade_data.proto b/proto/worldmonitor/trade/v1/trade_data.proto new file mode 100644 index 000000000..64eca07ba --- /dev/null +++ b/proto/worldmonitor/trade/v1/trade_data.proto @@ -0,0 +1,85 @@ +syntax = "proto3"; + +package worldmonitor.trade.v1; + +// Quantitative restriction or export control measure notified to WTO. +message TradeRestriction { + // Unique restriction identifier from WTO. + string id = 1; + // ISO 3166-1 alpha-3 or WTO member code of reporting country. + string reporting_country = 2; + // Country affected by the restriction. + string affected_country = 3; + // Product sector or HS chapter description. + string product_sector = 4; + // Measure classification: "QR", "EXPORT_BAN", "IMPORT_BAN", "LICENSING". + string measure_type = 5; + // Human-readable description of the measure. + string description = 6; + // Current status: "IN_FORCE", "TERMINATED", "NOTIFIED". + string status = 7; + // ISO 8601 date when measure was notified. + string notified_at = 8; + // WTO source document URL (must be http/https protocol). + string source_url = 9; +} + +// Single tariff data point for a reporter-partner-product combination. +message TariffDataPoint { + // WTO member code of reporting country. + string reporting_country = 1; + // WTO member code of partner country. + string partner_country = 2; + // Product sector or HS chapter. + string product_sector = 3; + // Year of observation. + int32 year = 4; + // Applied MFN tariff rate (percentage). + double tariff_rate = 5; + // WTO bound tariff rate (percentage). + double bound_rate = 6; + // WTO indicator code used for this datapoint. + string indicator_code = 7; +} + +// Bilateral trade flow record for a reporting-partner pair. +message TradeFlowRecord { + // WTO member code of reporting country. + string reporting_country = 1; + // WTO member code of partner country. + string partner_country = 2; + // Year of observation. + int32 year = 3; + // Merchandise export value in millions USD. + double export_value_usd = 4; + // Merchandise import value in millions USD. + double import_value_usd = 5; + // Year-over-year export change (percentage). + double yoy_export_change = 6; + // Year-over-year import change (percentage). + double yoy_import_change = 7; + // Product sector or HS chapter. + string product_sector = 8; +} + +// SPS or TBT trade barrier notification. +message TradeBarrier { + // Unique barrier notification identifier. + string id = 1; + // Country that notified the measure. + string notifying_country = 2; + // Title of the notification. + string title = 3; + // Measure classification: "SPS" or "TBT". + string measure_type = 4; + // Product description or affected goods. + string product_description = 5; + // Stated objective of the measure. + string objective = 6; + // Status of the notification. + string status = 7; + // ISO 8601 date when notification was distributed. + string date_distributed = 8; + // WTO source document URL (must be http/https protocol). + string source_url = 9; +} diff --git a/server/worldmonitor/trade/v1/_shared.ts b/server/worldmonitor/trade/v1/_shared.ts new file mode 100644 index 000000000..4fc3a72fd --- /dev/null +++ b/server/worldmonitor/trade/v1/_shared.ts @@ -0,0 +1,74 @@ +/** + * Shared helpers for the trade domain RPCs. + * WTO Timeseries API integration. + */ + +declare const process: { env: Record }; + +import { CHROME_UA } from '../../../_shared/constants'; + +/** WTO Timeseries API base URL. */ +export const WTO_API_BASE = 'https://api.wto.org/timeseries/v1'; + +/** Merchandise exports (total). */ +export const ITS_MTV_AX = 'ITS_MTV_AX'; +/** Merchandise imports (total). */ +export const ITS_MTV_AM = 'ITS_MTV_AM'; +/** Applied tariff — HS simple average. */ +export const HS_M_0010 = 'HS_M_0010'; + +/** + * WTO member numeric codes → human-readable names. + */ +export const WTO_MEMBER_CODES: Record = { + '840': 'United States', + '156': 'China', + '276': 'Germany', + '392': 'Japan', + '826': 'United Kingdom', + '250': 'France', + '356': 'India', + '643': 'Russia', + '076': 'Brazil', + '410': 'South Korea', + '036': 'Australia', + '124': 'Canada', + '484': 'Mexico', + '380': 'Italy', + '528': 'Netherlands', + '000': 'World', +}; + +/** + * Fetch JSON from the WTO Timeseries API. + * Returns parsed JSON on success, or null if the API key is missing or the request fails. + */ +export async function wtoFetch( + path: string, + params?: Record, +): Promise { + const apiKey = process.env.WTO_API_KEY; + if (!apiKey) return null; + + try { + const url = new URL(`${WTO_API_BASE}${path}`); + if (params) { + for (const [k, v] of Object.entries(params)) { + url.searchParams.set(k, v); + } + } + + const res = await fetch(url.toString(), { + headers: { + 'Ocp-Apim-Subscription-Key': apiKey, + 'User-Agent': CHROME_UA, + }, + signal: AbortSignal.timeout(15000), + }); + + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} diff --git a/server/worldmonitor/trade/v1/get-tariff-trends.ts b/server/worldmonitor/trade/v1/get-tariff-trends.ts new file mode 100644 index 000000000..8c6ff0ffb --- /dev/null +++ b/server/worldmonitor/trade/v1/get-tariff-trends.ts @@ -0,0 +1,127 @@ +/** + * RPC: getTariffTrends -- WTO applied tariff trend data + * Fetches HS simple average applied tariff rates over time. + */ + +declare const process: { env: Record }; + +import type { + ServerContext, + GetTariffTrendsRequest, + GetTariffTrendsResponse, + TariffDataPoint, +} from '../../../../src/generated/server/worldmonitor/trade/v1/service_server'; + +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; +import { wtoFetch, WTO_MEMBER_CODES, HS_M_0010 } from './_shared'; + +const REDIS_CACHE_TTL = 21600; // 6h + +/** + * Validate a country/sector code string — alphanumeric, max 10 chars. + */ +function isValidCode(c: string): boolean { + return /^[a-zA-Z0-9]{1,10}$/.test(c); +} + +/** + * Transform a raw WTO data row into a TariffDataPoint. + */ +function toDataPoint(row: any, reporter: string, partner: string): TariffDataPoint | null { + if (!row) return null; + const year = parseInt(row.Year ?? row.year ?? row.Period ?? '', 10); + const tariffRate = parseFloat(row.Value ?? row.value ?? ''); + if (isNaN(year) || isNaN(tariffRate)) return null; + + return { + reportingCountry: + WTO_MEMBER_CODES[reporter] ?? String(row.ReportingEconomy ?? row.reportingEconomy ?? reporter), + partnerCountry: + WTO_MEMBER_CODES[partner] ?? String(row.PartnerEconomy ?? row.partnerEconomy ?? partner), + productSector: String(row.ProductOrSector ?? row.productOrSector ?? 'Total'), + year, + tariffRate: Math.round(tariffRate * 100) / 100, + boundRate: parseFloat(row.BoundRate ?? row.boundRate ?? '0') || 0, + indicatorCode: String(row.IndicatorCode ?? row.indicatorCode ?? HS_M_0010), + }; +} + +async function fetchTariffTrends( + reporter: string, + partner: string, + productSector: string, + years: number, +): Promise<{ datapoints: TariffDataPoint[]; ok: boolean }> { + const currentYear = new Date().getFullYear(); + const startYear = currentYear - years; + + const params: Record = { + i: HS_M_0010, + r: reporter, + p: partner || '000', + ps: `${startYear}-${currentYear}`, + fmt: 'json', + mode: 'full', + max: '500', + }; + + if (productSector) { + params.pc = productSector; + } + + const data = await wtoFetch('/data', params); + if (!data) return { datapoints: [], ok: false }; + + const dataset: any[] = Array.isArray(data) ? data : data?.Dataset ?? data?.dataset ?? []; + const datapoints = dataset + .map((row) => toDataPoint(row, reporter, partner || '000')) + .filter((d): d is TariffDataPoint => d !== null) + .sort((a, b) => a.year - b.year); + + return { datapoints, ok: true }; +} + +export async function getTariffTrends( + _ctx: ServerContext, + req: GetTariffTrendsRequest, +): Promise { + try { + // Input validation + const reporter = isValidCode(req.reportingCountry) ? req.reportingCountry : '840'; + const partner = isValidCode(req.partnerCountry) ? req.partnerCountry : '000'; + const productSector = isValidCode(req.productSector) ? req.productSector : ''; + const years = Math.max(1, Math.min(req.years > 0 ? req.years : 10, 30)); + + const cacheKey = `trade:tariffs:v1:${reporter}:${partner}:${productSector || 'all'}:${years}`; + const cached = (await getCachedJson(cacheKey)) as GetTariffTrendsResponse | null; + if (cached?.datapoints?.length) return cached; + + const { datapoints, ok } = await fetchTariffTrends(reporter, partner, productSector, years); + + if (!ok) { + return { + datapoints: cached?.datapoints ?? [], + fetchedAt: new Date().toISOString(), + upstreamUnavailable: true, + }; + } + + const result: GetTariffTrendsResponse = { + datapoints, + fetchedAt: new Date().toISOString(), + upstreamUnavailable: false, + }; + + if (datapoints.length > 0) { + setCachedJson(cacheKey, result, REDIS_CACHE_TTL).catch(() => {}); + } + + return result; + } catch { + return { + datapoints: [], + fetchedAt: new Date().toISOString(), + upstreamUnavailable: true, + }; + } +} diff --git a/server/worldmonitor/trade/v1/get-trade-barriers.ts b/server/worldmonitor/trade/v1/get-trade-barriers.ts new file mode 100644 index 000000000..ea40b227d --- /dev/null +++ b/server/worldmonitor/trade/v1/get-trade-barriers.ts @@ -0,0 +1,124 @@ +/** + * RPC: getTradeBarriers -- WTO SPS/TBT barrier notifications + * Fetches sanitary/phytosanitary and technical barrier notifications. + */ + +declare const process: { env: Record }; + +import type { + ServerContext, + GetTradeBarriersRequest, + GetTradeBarriersResponse, + TradeBarrier, +} from '../../../../src/generated/server/worldmonitor/trade/v1/service_server'; + +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; +import { wtoFetch, WTO_MEMBER_CODES } from './_shared'; + +const REDIS_CACHE_TTL = 21600; // 6h + +/** Valid measure types for barrier queries. */ +const VALID_MEASURE_TYPES = ['SPS', 'TBT', 'ALL']; + +/** + * Validate a country code string — alphanumeric, max 10 chars. + */ +function isValidCountry(c: string): boolean { + return /^[a-zA-Z0-9]{1,10}$/.test(c); +} + +/** + * Transform a raw WTO data row into a TradeBarrier. + */ +function toBarrier(row: any): TradeBarrier | null { + if (!row) return null; + return { + id: String(row.id ?? row.Id ?? row.DocumentSymbol ?? ''), + notifyingCountry: + WTO_MEMBER_CODES[String(row.ReportingEconomyCode ?? row.reportingEconomyCode ?? '')] ?? + String(row.ReportingEconomy ?? row.reportingEconomy ?? row.Member ?? ''), + title: String(row.Title ?? row.title ?? row.Value ?? row.value ?? ''), + measureType: String(row.IndicatorCategory ?? row.indicatorCategory ?? row.Indicator ?? ''), + productDescription: String(row.ProductOrSector ?? row.productOrSector ?? ''), + objective: String(row.Objective ?? row.objective ?? ''), + status: String(row.ValueFlag ?? row.valueFlag ?? row.Status ?? 'notified'), + dateDistributed: String(row.Year ?? row.year ?? row.DateDistributed ?? ''), + sourceUrl: 'https://www.wto.org', + }; +} + +async function fetchBarriers( + countries: string[], + measureType: string, + limit: number, +): Promise<{ barriers: TradeBarrier[]; ok: boolean }> { + // Determine indicator code based on measure type + const indicator = measureType === 'SPS' ? 'SPS' : measureType === 'TBT' ? 'TBT' : 'SPS,TBT'; + + const params: Record = { + i: indicator, + r: countries.length > 0 ? countries.join(',') : '000', + ps: 'all', + max: String(limit), + fmt: 'json', + mode: 'full', + }; + + const data = await wtoFetch('/data', params); + if (!data) return { barriers: [], ok: false }; + + const dataset: any[] = Array.isArray(data) ? data : data?.Dataset ?? data?.dataset ?? []; + const barriers = dataset + .map(toBarrier) + .filter((b): b is TradeBarrier => b !== null) + .slice(0, limit); + + return { barriers, ok: true }; +} + +export async function getTradeBarriers( + _ctx: ServerContext, + req: GetTradeBarriersRequest, +): Promise { + try { + // Input validation + const countries = (req.countries ?? []).filter(isValidCountry); + const measureType = + VALID_MEASURE_TYPES.includes((req.measureType ?? '').toUpperCase()) + ? (req.measureType ?? '').toUpperCase() + : 'ALL'; + const limit = Math.max(1, Math.min(req.limit > 0 ? req.limit : 50, 100)); + + const cacheKey = `trade:barriers:v1:${countries.sort().join(',') || 'all'}:${measureType}:${limit}`; + const cached = (await getCachedJson(cacheKey)) as GetTradeBarriersResponse | null; + if (cached?.barriers?.length) return cached; + + const { barriers, ok } = await fetchBarriers(countries, measureType, limit); + + if (!ok) { + return { + barriers: cached?.barriers ?? [], + fetchedAt: new Date().toISOString(), + upstreamUnavailable: true, + }; + } + + const result: GetTradeBarriersResponse = { + barriers, + fetchedAt: new Date().toISOString(), + upstreamUnavailable: false, + }; + + if (barriers.length > 0) { + setCachedJson(cacheKey, result, REDIS_CACHE_TTL).catch(() => {}); + } + + return result; + } catch { + return { + barriers: [], + fetchedAt: new Date().toISOString(), + upstreamUnavailable: true, + }; + } +} diff --git a/server/worldmonitor/trade/v1/get-trade-flows.ts b/server/worldmonitor/trade/v1/get-trade-flows.ts new file mode 100644 index 000000000..6792082dd --- /dev/null +++ b/server/worldmonitor/trade/v1/get-trade-flows.ts @@ -0,0 +1,178 @@ +/** + * RPC: getTradeFlows -- WTO merchandise trade flow data + * Fetches bilateral export/import values and computes YoY changes. + */ + +declare const process: { env: Record }; + +import type { + ServerContext, + GetTradeFlowsRequest, + GetTradeFlowsResponse, + TradeFlowRecord, +} from '../../../../src/generated/server/worldmonitor/trade/v1/service_server'; + +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; +import { wtoFetch, WTO_MEMBER_CODES, ITS_MTV_AX, ITS_MTV_AM } from './_shared'; + +const REDIS_CACHE_TTL = 21600; // 6h + +/** + * Validate a country code string — alphanumeric, max 10 chars. + */ +function isValidCode(c: string): boolean { + return /^[a-zA-Z0-9]{1,10}$/.test(c); +} + +interface RawFlowRow { + year: number; + indicator: string; + value: number; +} + +/** + * Parse raw WTO rows into a flat list of { year, indicator, value }. + */ +function parseRows(data: any): RawFlowRow[] { + const dataset: any[] = Array.isArray(data) ? data : data?.Dataset ?? data?.dataset ?? []; + const rows: RawFlowRow[] = []; + + for (const row of dataset) { + const year = parseInt(row.Year ?? row.year ?? row.Period ?? '', 10); + const value = parseFloat(row.Value ?? row.value ?? ''); + const indicator = String(row.IndicatorCode ?? row.indicatorCode ?? ''); + if (!isNaN(year) && !isNaN(value) && indicator) { + rows.push({ year, indicator, value }); + } + } + + return rows; +} + +/** + * Build trade flow records from export + import rows, computing YoY changes. + */ +function buildFlowRecords( + rows: RawFlowRow[], + reporter: string, + partner: string, +): TradeFlowRecord[] { + // Group by year + const byYear = new Map(); + + for (const row of rows) { + if (!byYear.has(row.year)) { + byYear.set(row.year, { exports: 0, imports: 0 }); + } + const entry = byYear.get(row.year)!; + if (row.indicator === ITS_MTV_AX) { + entry.exports = row.value; + } else if (row.indicator === ITS_MTV_AM) { + entry.imports = row.value; + } + } + + // Sort by year ascending + const sortedYears = Array.from(byYear.keys()).sort((a, b) => a - b); + + const records: TradeFlowRecord[] = []; + for (let i = 0; i < sortedYears.length; i++) { + const year = sortedYears[i]!; + const current = byYear.get(year)!; + const prev = i > 0 ? byYear.get(sortedYears[i - 1]!) : null; + + let yoyExportChange = 0; + let yoyImportChange = 0; + + if (prev && prev.exports > 0) { + yoyExportChange = Math.round(((current.exports - prev.exports) / prev.exports) * 10000) / 100; + } + if (prev && prev.imports > 0) { + yoyImportChange = Math.round(((current.imports - prev.imports) / prev.imports) * 10000) / 100; + } + + records.push({ + reportingCountry: WTO_MEMBER_CODES[reporter] ?? reporter, + partnerCountry: WTO_MEMBER_CODES[partner] ?? partner, + year, + exportValueUsd: current.exports, + importValueUsd: current.imports, + yoyExportChange, + yoyImportChange, + productSector: 'Total merchandise', + }); + } + + return records; +} + +async function fetchTradeFlows( + reporter: string, + partner: string, + years: number, +): Promise<{ flows: TradeFlowRecord[]; ok: boolean }> { + const currentYear = new Date().getFullYear(); + const startYear = currentYear - years; + + const params: Record = { + i: `${ITS_MTV_AX},${ITS_MTV_AM}`, + r: reporter, + p: partner || '000', + ps: `${startYear}-${currentYear}`, + fmt: 'json', + mode: 'full', + max: '500', + }; + + const data = await wtoFetch('/data', params); + if (!data) return { flows: [], ok: false }; + + const rows = parseRows(data); + const flows = buildFlowRecords(rows, reporter, partner || '000'); + + return { flows, ok: true }; +} + +export async function getTradeFlows( + _ctx: ServerContext, + req: GetTradeFlowsRequest, +): Promise { + try { + // Input validation + const reporter = isValidCode(req.reportingCountry) ? req.reportingCountry : '840'; + const partner = isValidCode(req.partnerCountry) ? req.partnerCountry : '000'; + const years = Math.max(1, Math.min(req.years > 0 ? req.years : 10, 30)); + + const cacheKey = `trade:flows:v1:${reporter}:${partner}:${years}`; + const cached = (await getCachedJson(cacheKey)) as GetTradeFlowsResponse | null; + if (cached?.flows?.length) return cached; + + const { flows, ok } = await fetchTradeFlows(reporter, partner, years); + + if (!ok) { + return { + flows: cached?.flows ?? [], + fetchedAt: new Date().toISOString(), + upstreamUnavailable: true, + }; + } + + const result: GetTradeFlowsResponse = { + flows, + fetchedAt: new Date().toISOString(), + upstreamUnavailable: false, + }; + + if (flows.length > 0) { + setCachedJson(cacheKey, result, REDIS_CACHE_TTL).catch(() => {}); + } + + return result; + } catch { + return { + flows: [], + fetchedAt: new Date().toISOString(), + upstreamUnavailable: true, + }; + } +} diff --git a/server/worldmonitor/trade/v1/get-trade-restrictions.ts b/server/worldmonitor/trade/v1/get-trade-restrictions.ts new file mode 100644 index 000000000..ec5ae871e --- /dev/null +++ b/server/worldmonitor/trade/v1/get-trade-restrictions.ts @@ -0,0 +1,117 @@ +/** + * RPC: getTradeRestrictions -- WTO trade restriction/QR notifications + * Fetches quantitative restriction and related trade measure data. + */ + +declare const process: { env: Record }; + +import type { + ServerContext, + GetTradeRestrictionsRequest, + GetTradeRestrictionsResponse, + TradeRestriction, +} from '../../../../src/generated/server/worldmonitor/trade/v1/service_server'; + +import { getCachedJson, setCachedJson } from '../../../_shared/redis'; +import { wtoFetch, WTO_MEMBER_CODES } from './_shared'; + +const REDIS_CACHE_KEY = 'trade:restrictions:v1'; +const REDIS_CACHE_TTL = 21600; // 6h + +/** + * Validate a country code string — alphanumeric, max 10 chars. + */ +function isValidCountry(c: string): boolean { + return /^[a-zA-Z0-9]{1,10}$/.test(c); +} + +/** + * Transform a raw WTO data row into a TradeRestriction. + */ +function toRestriction(row: any): TradeRestriction | null { + if (!row) return null; + return { + id: String(row.id ?? row.Id ?? ''), + reportingCountry: + WTO_MEMBER_CODES[String(row.ReportingEconomyCode ?? row.reportingEconomyCode ?? '')] ?? + String(row.ReportingEconomy ?? row.reportingEconomy ?? ''), + affectedCountry: + WTO_MEMBER_CODES[String(row.PartnerEconomyCode ?? row.partnerEconomyCode ?? '')] ?? + String(row.PartnerEconomy ?? row.partnerEconomy ?? ''), + productSector: String(row.ProductOrSector ?? row.productOrSector ?? ''), + measureType: String(row.IndicatorCategory ?? row.indicatorCategory ?? row.Indicator ?? ''), + description: String(row.Value ?? row.value ?? ''), + status: String(row.ValueFlag ?? row.valueFlag ?? 'active'), + notifiedAt: String(row.Year ?? row.year ?? row.Period ?? ''), + sourceUrl: 'https://www.wto.org', + }; +} + +async function fetchRestrictions( + countries: string[], + limit: number, +): Promise<{ restrictions: TradeRestriction[]; ok: boolean }> { + const params: Record = { + i: 'QR', // Quantitative restrictions indicator group + r: countries.length > 0 ? countries.join(',') : '000', + ps: 'all', + max: String(limit), + fmt: 'json', + mode: 'full', + }; + + const data = await wtoFetch('/data', params); + if (!data) return { restrictions: [], ok: false }; + + const dataset: any[] = Array.isArray(data) ? data : data?.Dataset ?? data?.dataset ?? []; + const restrictions = dataset + .map(toRestriction) + .filter((r): r is TradeRestriction => r !== null) + .slice(0, limit); + + return { restrictions, ok: true }; +} + +export async function getTradeRestrictions( + _ctx: ServerContext, + req: GetTradeRestrictionsRequest, +): Promise { + try { + // Input validation + const countries = (req.countries ?? []).filter(isValidCountry); + const limit = Math.max(1, Math.min(req.limit > 0 ? req.limit : 50, 100)); + + const cacheKey = `${REDIS_CACHE_KEY}:${countries.sort().join(',') || 'all'}:${limit}`; + const cached = (await getCachedJson(cacheKey)) as GetTradeRestrictionsResponse | null; + if (cached?.restrictions?.length) return cached; + + const { restrictions, ok } = await fetchRestrictions(countries, limit); + + if (!ok) { + // Upstream unavailable — return stale cache or empty + return { + restrictions: cached?.restrictions ?? [], + fetchedAt: new Date().toISOString(), + upstreamUnavailable: true, + }; + } + + const result: GetTradeRestrictionsResponse = { + restrictions, + fetchedAt: new Date().toISOString(), + upstreamUnavailable: false, + }; + + if (restrictions.length > 0) { + setCachedJson(cacheKey, result, REDIS_CACHE_TTL).catch(() => {}); + } + + return result; + } catch { + return { + restrictions: [], + fetchedAt: new Date().toISOString(), + upstreamUnavailable: true, + }; + } +} diff --git a/server/worldmonitor/trade/v1/handler.ts b/server/worldmonitor/trade/v1/handler.ts new file mode 100644 index 000000000..81a5b65af --- /dev/null +++ b/server/worldmonitor/trade/v1/handler.ts @@ -0,0 +1,13 @@ +import type { TradeServiceHandler } from '../../../../src/generated/server/worldmonitor/trade/v1/service_server'; + +import { getTradeRestrictions } from './get-trade-restrictions'; +import { getTariffTrends } from './get-tariff-trends'; +import { getTradeFlows } from './get-trade-flows'; +import { getTradeBarriers } from './get-trade-barriers'; + +export const tradeHandler: TradeServiceHandler = { + getTradeRestrictions, + getTariffTrends, + getTradeFlows, + getTradeBarriers, +}; diff --git a/src-tauri/sidecar/local-api-server.mjs b/src-tauri/sidecar/local-api-server.mjs index 576c1f4e7..498f8d37f 100644 --- a/src-tauri/sidecar/local-api-server.mjs +++ b/src-tauri/sidecar/local-api-server.mjs @@ -104,7 +104,7 @@ const ALLOWED_ENV_KEYS = new Set([ 'OTX_API_KEY', 'ABUSEIPDB_API_KEY', 'WINGBITS_API_KEY', 'WS_RELAY_URL', 'VITE_OPENSKY_RELAY_URL', 'OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET', 'AISSTREAM_API_KEY', 'VITE_WS_RELAY_URL', 'FINNHUB_API_KEY', 'NASA_FIRMS_API_KEY', - 'OLLAMA_API_URL', 'OLLAMA_MODEL', 'WORLDMONITOR_API_KEY', + 'OLLAMA_API_URL', 'OLLAMA_MODEL', 'WORLDMONITOR_API_KEY', 'WTO_API_KEY', ]); const CHROME_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; @@ -895,6 +895,9 @@ async function validateSecretAgainstProvider(key, rawValue, context = {}) { case 'AISSTREAM_API_KEY': return ok('AISSTREAM key stored (live verification not available in sidecar)'); + case 'WTO_API_KEY': + return ok('WTO API key stored (live verification not available in sidecar)'); + default: return ok('Key stored'); } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2552fbf0a..bd7f67ff6 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -27,7 +27,7 @@ const MENU_HELP_GITHUB_ID: &str = "help.github"; #[cfg(feature = "devtools")] const MENU_HELP_DEVTOOLS_ID: &str = "help.devtools"; const TRUSTED_WINDOWS: [&str; 3] = ["main", "settings", "live-channels"]; -const SUPPORTED_SECRET_KEYS: [&str; 21] = [ +const SUPPORTED_SECRET_KEYS: [&str; 22] = [ "GROQ_API_KEY", "OPENROUTER_API_KEY", "FRED_API_KEY", @@ -49,6 +49,7 @@ const SUPPORTED_SECRET_KEYS: [&str; 21] = [ "OLLAMA_API_URL", "OLLAMA_MODEL", "WORLDMONITOR_API_KEY", + "WTO_API_KEY", ]; #[derive(Default)] diff --git a/src/app/data-loader.ts b/src/app/data-loader.ts index 2c140242a..0693d369e 100644 --- a/src/app/data-loader.ts +++ b/src/app/data-loader.ts @@ -47,6 +47,10 @@ import { fetchOilAnalytics, fetchCyberThreats, drainTrendingSignals, + fetchTradeRestrictions, + fetchTariffTrends, + fetchTradeFlows, + fetchTradeBarriers, } from '@/services'; import { mlWorker } from '@/services/ml-worker'; import { clusterNewsHybrid } from '@/services/clustering'; @@ -85,6 +89,7 @@ import { DisplacementPanel, ClimateAnomalyPanel, PopulationExposurePanel, + TradePolicyPanel, } from '@/components'; import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel'; import { classifyNewsItem } from '@/services/positive-classifier'; @@ -158,6 +163,11 @@ export class DataLoaderManager implements AppModule { tasks.push({ name: 'fred', task: runGuarded('fred', () => this.loadFredData()) }); tasks.push({ name: 'oil', task: runGuarded('oil', () => this.loadOilAnalytics()) }); tasks.push({ name: 'spending', task: runGuarded('spending', () => this.loadGovernmentSpending()) }); + + // Trade policy data (FULL and FINANCE only) + if (SITE_VARIANT === 'full' || SITE_VARIANT === 'finance') { + tasks.push({ name: 'tradePolicy', task: runGuarded('tradePolicy', () => this.loadTradePolicy()) }); + } } // Progress charts data (happy variant only) @@ -1481,6 +1491,40 @@ export class DataLoaderManager implements AppModule { } } + async loadTradePolicy(): Promise { + const tradePanel = this.ctx.panels['trade-policy'] as TradePolicyPanel | undefined; + if (!tradePanel) return; + + try { + const [restrictions, tariffs, flows, barriers] = await Promise.all([ + fetchTradeRestrictions([], 50), + fetchTariffTrends('840', '156', '', 10), + fetchTradeFlows('840', '156', 10), + fetchTradeBarriers([], '', 50), + ]); + + tradePanel.updateRestrictions(restrictions); + tradePanel.updateTariffs(tariffs); + tradePanel.updateFlows(flows); + tradePanel.updateBarriers(barriers); + + const totalItems = restrictions.restrictions.length + tariffs.datapoints.length + flows.flows.length + barriers.barriers.length; + const anyUnavailable = restrictions.upstreamUnavailable || tariffs.upstreamUnavailable || flows.upstreamUnavailable || barriers.upstreamUnavailable; + + this.ctx.statusPanel?.updateApi('WTO', { status: anyUnavailable ? 'warning' : totalItems > 0 ? 'ok' : 'error' }); + + if (totalItems > 0) { + dataFreshness.recordUpdate('wto_trade', totalItems); + } else if (anyUnavailable) { + dataFreshness.recordError('wto_trade', 'WTO upstream temporarily unavailable'); + } + } catch (e) { + console.error('[App] Trade policy failed:', e); + this.ctx.statusPanel?.updateApi('WTO', { status: 'error' }); + dataFreshness.recordError('wto_trade', String(e)); + } + } + updateMonitorResults(): void { const monitorPanel = this.ctx.panels['monitors'] as MonitorPanel; monitorPanel.renderResults(this.ctx.allNews); diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index fd7598f12..297ff90f1 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -31,6 +31,7 @@ import { ClimateAnomalyPanel, PopulationExposurePanel, InvestmentsPanel, + TradePolicyPanel, } from '@/components'; import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel'; import { PositiveNewsFeedPanel } from '@/components/PositiveNewsFeedPanel'; @@ -447,6 +448,11 @@ export class PanelLayoutManager implements AppModule { const economicPanel = new EconomicPanel(); this.ctx.panels['economic'] = economicPanel; + if (SITE_VARIANT === 'full' || SITE_VARIANT === 'finance') { + const tradePolicyPanel = new TradePolicyPanel(); + this.ctx.panels['trade-policy'] = tradePolicyPanel; + } + const africaPanel = new NewsPanel('africa', t('panels.africa')); this.attachRelatedAssetHandlers(africaPanel); this.ctx.newsPanels['africa'] = africaPanel; diff --git a/src/components/RuntimeConfigPanel.ts b/src/components/RuntimeConfigPanel.ts index aa07db2dc..e4cf17d19 100644 --- a/src/components/RuntimeConfigPanel.ts +++ b/src/components/RuntimeConfigPanel.ts @@ -40,6 +40,7 @@ const SIGNUP_URLS: Partial> = { UC_DP_KEY: 'https://ucdp.uu.se/downloads/', OLLAMA_API_URL: 'https://ollama.com/download', OLLAMA_MODEL: 'https://ollama.com/library', + WTO_API_KEY: 'https://apiportal.wto.org/', }; const PLAINTEXT_KEYS = new Set([ diff --git a/src/components/StatusPanel.ts b/src/components/StatusPanel.ts index a6988d0e1..09104fa96 100644 --- a/src/components/StatusPanel.ts +++ b/src/components/StatusPanel.ts @@ -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' + 'Cyber Threats API', 'WTO' ]); import { t } from '../services/i18n'; diff --git a/src/components/TradePolicyPanel.ts b/src/components/TradePolicyPanel.ts new file mode 100644 index 000000000..7dded197a --- /dev/null +++ b/src/components/TradePolicyPanel.ts @@ -0,0 +1,236 @@ +import { Panel } from './Panel'; +import type { + GetTradeRestrictionsResponse, + GetTariffTrendsResponse, + GetTradeFlowsResponse, + GetTradeBarriersResponse, +} from '@/services/trade'; +import { t } from '@/services/i18n'; +import { escapeHtml } from '@/utils/sanitize'; +import { isFeatureAvailable } from '@/services/runtime-config'; +import { isDesktopRuntime } from '@/services/runtime'; + +type TabId = 'restrictions' | 'tariffs' | 'flows' | 'barriers'; + +export class TradePolicyPanel extends Panel { + private restrictionsData: GetTradeRestrictionsResponse | null = null; + private tariffsData: GetTariffTrendsResponse | null = null; + private flowsData: GetTradeFlowsResponse | null = null; + private barriersData: GetTradeBarriersResponse | null = null; + private activeTab: TabId = 'restrictions'; + + constructor() { + super({ id: 'trade-policy', title: t('panels.tradePolicy') }); + } + + public updateRestrictions(data: GetTradeRestrictionsResponse): void { + this.restrictionsData = data; + this.render(); + } + + public updateTariffs(data: GetTariffTrendsResponse): void { + this.tariffsData = data; + this.render(); + } + + public updateFlows(data: GetTradeFlowsResponse): void { + this.flowsData = data; + this.render(); + } + + public updateBarriers(data: GetTradeBarriersResponse): void { + this.barriersData = data; + this.render(); + } + + private render(): void { + // Check for API key + if (isDesktopRuntime() && !isFeatureAvailable('wtoTrade')) { + this.setContent(`
${t('components.tradePolicy.apiKeyMissing')}
`); + return; + } + + const hasTariffs = this.tariffsData && this.tariffsData.datapoints.length > 0; + const hasFlows = this.flowsData && this.flowsData.flows.length > 0; + const hasBarriers = this.barriersData && this.barriersData.barriers.length > 0; + + const tabsHtml = ` +
+ + ${hasTariffs ? `` : ''} + ${hasFlows ? `` : ''} + ${hasBarriers ? `` : ''} +
+ `; + + // Check for upstream unavailable across all data sources + const anyUnavailable = [this.restrictionsData, this.tariffsData, this.flowsData, this.barriersData] + .some(d => d?.upstreamUnavailable); + const unavailableBanner = anyUnavailable + ? `
${t('components.tradePolicy.upstreamUnavailable')}
` + : ''; + + let contentHtml = ''; + switch (this.activeTab) { + case 'restrictions': contentHtml = this.renderRestrictions(); break; + case 'tariffs': contentHtml = this.renderTariffs(); break; + case 'flows': contentHtml = this.renderFlows(); break; + case 'barriers': contentHtml = this.renderBarriers(); break; + } + + this.setContent(` + ${tabsHtml} + ${unavailableBanner} +
${contentHtml}
+ + `); + + // Event delegation on this.content for tab switching (survives setContent debounce) + 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(); + } + }); + } + + private renderRestrictions(): string { + if (!this.restrictionsData || this.restrictionsData.restrictions.length === 0) { + return `
${t('components.tradePolicy.noRestrictions')}
`; + } + + return `
+ ${this.restrictionsData.restrictions.map(r => { + const statusClass = r.status === 'IN_FORCE' ? 'status-active' : r.status === 'TERMINATED' ? 'status-terminated' : 'status-notified'; + const statusLabel = r.status === 'IN_FORCE' ? t('components.tradePolicy.inForce') : r.status === 'TERMINATED' ? t('components.tradePolicy.terminated') : t('components.tradePolicy.notified'); + const sourceLink = this.renderSourceUrl(r.sourceUrl); + return `
+
+ ${escapeHtml(r.reportingCountry)} + ${escapeHtml(r.measureType)} + ${statusLabel} +
+
+
${escapeHtml(r.productSector)}
+ ${r.description ? `
${escapeHtml(r.description)}
` : ''} + ${r.affectedCountry ? `
Affects: ${escapeHtml(r.affectedCountry)}
` : ''} +
+ +
`; + }).join('')} +
`; + } + + private renderTariffs(): string { + if (!this.tariffsData || this.tariffsData.datapoints.length === 0) { + return `
${t('components.tradePolicy.noTariffData')}
`; + } + + const rows = this.tariffsData.datapoints.map(d => + ` + ${d.year} + ${d.tariffRate.toFixed(1)}% + ${d.boundRate.toFixed(1)}% + ${escapeHtml(d.productSector || '—')} + ` + ).join(''); + + return `
+ + + + + + + + + + ${rows} +
Year${t('components.tradePolicy.appliedRate')}${t('components.tradePolicy.boundRate')}Sector
+
`; + } + + private renderFlows(): string { + if (!this.flowsData || this.flowsData.flows.length === 0) { + return `
${t('components.tradePolicy.noFlowData')}
`; + } + + return `
+ ${this.flowsData.flows.map(f => { + const exportArrow = f.yoyExportChange >= 0 ? '▲' : '▼'; + const importArrow = f.yoyImportChange >= 0 ? '▲' : '▼'; + const exportClass = f.yoyExportChange >= 0 ? 'change-positive' : 'change-negative'; + const importClass = f.yoyImportChange >= 0 ? 'change-positive' : 'change-negative'; + return `
+
${f.year}
+
+
+ ${t('components.tradePolicy.exports')} + $${f.exportValueUsd.toFixed(0)}M + ${exportArrow} ${Math.abs(f.yoyExportChange).toFixed(1)}% +
+
+ ${t('components.tradePolicy.imports')} + $${f.importValueUsd.toFixed(0)}M + ${importArrow} ${Math.abs(f.yoyImportChange).toFixed(1)}% +
+
+
`; + }).join('')} +
`; + } + + private renderBarriers(): string { + if (!this.barriersData || this.barriersData.barriers.length === 0) { + return `
${t('components.tradePolicy.noBarriers')}
`; + } + + return `
+ ${this.barriersData.barriers.map(b => { + const sourceLink = this.renderSourceUrl(b.sourceUrl); + return `
+
+ ${escapeHtml(b.notifyingCountry)} + ${escapeHtml(b.measureType)} +
+
+
${escapeHtml(b.title)}
+ ${b.productDescription ? `
${escapeHtml(b.productDescription)}
` : ''} + ${b.objective ? `
${escapeHtml(b.objective)}
` : ''} +
+ +
`; + }).join('')} +
`; + } + + private renderSourceUrl(url: string): string { + if (!url) return ''; + try { + const parsed = new URL(url); + if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { + return `Source`; + } + } catch { /* invalid URL */ } + return ''; + } +} diff --git a/src/components/index.ts b/src/components/index.ts index bc49773a3..c30dcd5bd 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -39,3 +39,4 @@ export * from './ClimateAnomalyPanel'; export * from './PopulationExposurePanel'; export * from './InvestmentsPanel'; export * from './UnifiedSettings'; +export * from './TradePolicyPanel'; diff --git a/src/config/commands.ts b/src/config/commands.ts index e3c7d644d..586251e58 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -82,6 +82,7 @@ export const COMMANDS: Command[] = [ { id: 'panel:commodities', keywords: ['commodities', 'gold', 'silver'], label: 'Jump to Commodities', icon: '\u{1F4E6}', category: 'panels' }, { 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: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' }, diff --git a/src/config/panels.ts b/src/config/panels.ts index eb8d413f6..bf1c07147 100644 --- a/src/config/panels.ts +++ b/src/config/panels.ts @@ -32,6 +32,7 @@ const FULL_PANELS: Record = { commodities: { name: 'Commodities', enabled: true, priority: 1 }, markets: { name: 'Markets', enabled: true, priority: 1 }, economic: { name: 'Economic Indicators', enabled: true, priority: 1 }, + 'trade-policy': { name: 'Trade Policy', 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 }, @@ -300,6 +301,7 @@ const FINANCE_PANELS: Record = { 'crypto-news': { name: 'Crypto News', enabled: true, priority: 2 }, 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 }, '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 }, @@ -573,7 +575,7 @@ export const PANEL_CATEGORY_MAP: Record; +} + +export interface TradeServiceCallOptions { + headers?: Record; + signal?: AbortSignal; +} + +export class TradeServiceClient { + private baseURL: string; + private fetchFn: typeof fetch; + private defaultHeaders: Record; + + constructor(baseURL: string, options?: TradeServiceClientOptions) { + this.baseURL = baseURL.replace(/\/+$/, ""); + this.fetchFn = options?.fetch ?? globalThis.fetch; + this.defaultHeaders = { ...options?.defaultHeaders }; + } + + async getTradeRestrictions(req: GetTradeRestrictionsRequest, options?: TradeServiceCallOptions): Promise { + let path = "/api/trade/v1/get-trade-restrictions"; + const url = this.baseURL + path; + + const headers: Record = { + "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 GetTradeRestrictionsResponse; + } + + async getTariffTrends(req: GetTariffTrendsRequest, options?: TradeServiceCallOptions): Promise { + let path = "/api/trade/v1/get-tariff-trends"; + const url = this.baseURL + path; + + const headers: Record = { + "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 GetTariffTrendsResponse; + } + + async getTradeFlows(req: GetTradeFlowsRequest, options?: TradeServiceCallOptions): Promise { + let path = "/api/trade/v1/get-trade-flows"; + const url = this.baseURL + path; + + const headers: Record = { + "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 GetTradeFlowsResponse; + } + + async getTradeBarriers(req: GetTradeBarriersRequest, options?: TradeServiceCallOptions): Promise { + let path = "/api/trade/v1/get-trade-barriers"; + const url = this.baseURL + path; + + const headers: Record = { + "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 GetTradeBarriersResponse; + } + + private async handleError(resp: Response): Promise { + 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); + } +} diff --git a/src/generated/server/worldmonitor/trade/v1/service_server.ts b/src/generated/server/worldmonitor/trade/v1/service_server.ts new file mode 100644 index 000000000..697c9575d --- /dev/null +++ b/src/generated/server/worldmonitor/trade/v1/service_server.ts @@ -0,0 +1,326 @@ +// Code generated by protoc-gen-ts-server. DO NOT EDIT. +// source: worldmonitor/trade/v1/service.proto + +export interface TradeRestriction { + id: string; + reportingCountry: string; + affectedCountry: string; + productSector: string; + measureType: string; + description: string; + status: string; + notifiedAt: string; + sourceUrl: string; +} + +export interface TariffDataPoint { + reportingCountry: string; + partnerCountry: string; + productSector: string; + year: number; + tariffRate: number; + boundRate: number; + indicatorCode: string; +} + +export interface TradeFlowRecord { + reportingCountry: string; + partnerCountry: string; + year: number; + exportValueUsd: number; + importValueUsd: number; + yoyExportChange: number; + yoyImportChange: number; + productSector: string; +} + +export interface TradeBarrier { + id: string; + notifyingCountry: string; + title: string; + measureType: string; + productDescription: string; + objective: string; + status: string; + dateDistributed: string; + sourceUrl: string; +} + +export interface GetTradeRestrictionsRequest { + countries: string[]; + limit: number; +} + +export interface GetTradeRestrictionsResponse { + restrictions: TradeRestriction[]; + fetchedAt: string; + upstreamUnavailable: boolean; +} + +export interface GetTariffTrendsRequest { + reportingCountry: string; + partnerCountry: string; + productSector: string; + years: number; +} + +export interface GetTariffTrendsResponse { + datapoints: TariffDataPoint[]; + fetchedAt: string; + upstreamUnavailable: boolean; +} + +export interface GetTradeFlowsRequest { + reportingCountry: string; + partnerCountry: string; + years: number; +} + +export interface GetTradeFlowsResponse { + flows: TradeFlowRecord[]; + fetchedAt: string; + upstreamUnavailable: boolean; +} + +export interface GetTradeBarriersRequest { + countries: string[]; + measureType: string; + limit: number; +} + +export interface GetTradeBarriersResponse { + barriers: TradeBarrier[]; + 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; + headers: Record; +} + +export interface ServerOptions { + onError?: (error: unknown, req: Request) => Response | Promise; + validateRequest?: (methodName: string, body: unknown) => FieldViolation[] | undefined; +} + +export interface RouteDescriptor { + method: string; + path: string; + handler: (req: Request) => Promise; +} + +export interface TradeServiceHandler { + getTradeRestrictions(ctx: ServerContext, req: GetTradeRestrictionsRequest): Promise; + getTariffTrends(ctx: ServerContext, req: GetTariffTrendsRequest): Promise; + getTradeFlows(ctx: ServerContext, req: GetTradeFlowsRequest): Promise; + getTradeBarriers(ctx: ServerContext, req: GetTradeBarriersRequest): Promise; +} + +export function createTradeServiceRoutes( + handler: TradeServiceHandler, + options?: ServerOptions, +): RouteDescriptor[] { + return [ + { + method: "POST", + path: "/api/trade/v1/get-trade-restrictions", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const body = await req.json() as GetTradeRestrictionsRequest; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("getTradeRestrictions", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.getTradeRestrictions(ctx, body); + return new Response(JSON.stringify(result as GetTradeRestrictionsResponse), { + 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/trade/v1/get-tariff-trends", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const body = await req.json() as GetTariffTrendsRequest; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("getTariffTrends", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.getTariffTrends(ctx, body); + return new Response(JSON.stringify(result as GetTariffTrendsResponse), { + 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/trade/v1/get-trade-flows", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const body = await req.json() as GetTradeFlowsRequest; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("getTradeFlows", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.getTradeFlows(ctx, body); + return new Response(JSON.stringify(result as GetTradeFlowsResponse), { + 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/trade/v1/get-trade-barriers", + handler: async (req: Request): Promise => { + try { + const pathParams: Record = {}; + const body = await req.json() as GetTradeBarriersRequest; + if (options?.validateRequest) { + const bodyViolations = options.validateRequest("getTradeBarriers", body); + if (bodyViolations) { + throw new ValidationError(bodyViolations); + } + } + + const ctx: ServerContext = { + request: req, + pathParams, + headers: Object.fromEntries(req.headers.entries()), + }; + + const result = await handler.getTradeBarriers(ctx, body); + return new Response(JSON.stringify(result as GetTradeBarriersResponse), { + 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" }, + }); + } + }, + }, + ]; +} diff --git a/src/locales/ar.json b/src/locales/ar.json index f91c50d39..efe2af783 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -137,7 +137,8 @@ "panelCatCryptoDigital": "العملات الرقمية", "panelCatCentralBanks": "البنوك المركزية والاقتصاد", "panelCatDeals": "الصفقات والمؤسسات", - "panelCatGulfMena": "الخليج والشرق الأوسط" + "panelCatGulfMena": "الخليج والشرق الأوسط", + "panelCatTradePolicy": "السياسة التجارية" }, "panels": { "liveNews": "أخبار مباشرة", @@ -166,6 +167,7 @@ "polymarket": "التنبؤات", "commodities": "السلع", "economic": "المؤشرات الاقتصادية", + "tradePolicy": "السياسة التجارية", "finance": "المالية", "tech": "التقنية", "crypto": "العملات الرقمية", @@ -525,6 +527,26 @@ "vsPreviousWeek": "مقارنة بالأسبوع السابق", "in": "في" }, + "tradePolicy": { + "restrictions": "القيود", + "tariffs": "التعريفات الجمركية", + "flows": "التدفقات التجارية", + "barriers": "الحواجز", + "noRestrictions": "لا توجد قيود تجارية نشطة", + "noTariffData": "لا تتوفر بيانات التعريفات الجمركية", + "noFlowData": "لا تتوفر بيانات التدفقات التجارية", + "noBarriers": "لم يتم الإبلاغ عن حواجز تجارية", + "apiKeyMissing": "مفتاح WTO API مطلوب — أضفه في الإعدادات", + "upstreamUnavailable": "بيانات WTO غير متاحة مؤقتاً — عرض البيانات المخزنة", + "appliedRate": "المعدل المطبق", + "boundRate": "المعدل الملزم", + "exports": "الصادرات", + "imports": "الواردات", + "yoyChange": "التغير السنوي", + "inForce": "ساري المفعول", + "notified": "تم الإخطار", + "terminated": "منتهي" + }, "gdelt": { "empty": "لا توجد مقالات حديثة لهذا الموضوع" }, diff --git a/src/locales/de.json b/src/locales/de.json index 70c40c9db..4e0283391 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -137,7 +137,8 @@ "panelCatCryptoDigital": "Krypto & Digital", "panelCatCentralBanks": "Zentralbanken & Wirtschaft", "panelCatDeals": "Deals & Institutionell", - "panelCatGulfMena": "Golf & MENA" + "panelCatGulfMena": "Golf & MENA", + "panelCatTradePolicy": "Handelspolitik" }, "panels": { "liveNews": "Live-Nachrichten", @@ -165,6 +166,7 @@ "polymarket": "Vorhersagen", "commodities": "Rohstoffe", "economic": "Wirtschaftsindikatoren", + "tradePolicy": "Handelspolitik", "finance": "Finanzen", "tech": "Technologie", "crypto": "Krypto", @@ -525,6 +527,26 @@ "vsPreviousWeek": "im Vergleich zur Vorwoche", "in": "In" }, + "tradePolicy": { + "restrictions": "Beschränkungen", + "tariffs": "Zölle", + "flows": "Handelsströme", + "barriers": "Handelsbarrieren", + "noRestrictions": "Keine aktiven Handelsbeschränkungen", + "noTariffData": "Keine Zolldaten verfügbar", + "noFlowData": "Keine Handelsstromdaten verfügbar", + "noBarriers": "Keine Handelsbarrieren gemeldet", + "apiKeyMissing": "WTO-API-Schlüssel erforderlich — in den Einstellungen hinzufügen", + "upstreamUnavailable": "WTO-Daten vorübergehend nicht verfügbar — zwischengespeicherte Daten werden angezeigt", + "appliedRate": "Angewandter Zollsatz", + "boundRate": "Gebundener Zollsatz", + "exports": "Exporte", + "imports": "Importe", + "yoyChange": "Veränderung zum Vorjahr", + "inForce": "In Kraft", + "notified": "Notifiziert", + "terminated": "Beendet" + }, "gdelt": { "empty": "Keine aktuellen Artikel zu diesem Thema" }, diff --git a/src/locales/el.json b/src/locales/el.json index 46bbfed1f..346402a9e 100644 --- a/src/locales/el.json +++ b/src/locales/el.json @@ -137,7 +137,8 @@ "panelCatCryptoDigital": "Κρύπτο & Ψηφιακά", "panelCatCentralBanks": "Κεντρικές Τράπεζες & Οικονομία", "panelCatDeals": "Συμφωνίες & Θεσμικά", - "panelCatGulfMena": "Κόλπος & ΜΕΝΑ" + "panelCatGulfMena": "Κόλπος & ΜΕΝΑ", + "panelCatTradePolicy": "Εμπορική Πολιτική" }, "panels": { "liveNews": "Ζωντανές Ειδήσεις", @@ -166,6 +167,7 @@ "polymarket": "Προβλέψεις", "commodities": "Εμπορεύματα", "economic": "Οικονομικοί Δείκτες", + "tradePolicy": "Εμπορική Πολιτική", "finance": "Χρηματοοικονομικά", "tech": "Τεχνολογία", "crypto": "Crypto", @@ -553,6 +555,26 @@ "vsPreviousWeek": "σε σχέση με την προηγούμενη εβδομάδα", "in": "σε" }, + "tradePolicy": { + "restrictions": "Περιορισμοί", + "tariffs": "Δασμοί", + "flows": "Εμπορικές Ροές", + "barriers": "Εμπόδια", + "noRestrictions": "Δεν υπάρχουν ενεργοί εμπορικοί περιορισμοί", + "noTariffData": "Δεν υπάρχουν διαθέσιμα δεδομένα δασμών", + "noFlowData": "Δεν υπάρχουν διαθέσιμα δεδομένα εμπορικών ροών", + "noBarriers": "Δεν αναφέρθηκαν εμπορικά εμπόδια", + "apiKeyMissing": "Απαιτείται κλειδί WTO API — προσθέστε το στις Ρυθμίσεις", + "upstreamUnavailable": "Δεδομένα WTO προσωρινά μη διαθέσιμα — εμφάνιση αποθηκευμένων δεδομένων", + "appliedRate": "Εφαρμοζόμενος Συντελεστής", + "boundRate": "Δεσμευμένος Συντελεστής", + "exports": "Εξαγωγές", + "imports": "Εισαγωγές", + "yoyChange": "Ετήσια Μεταβολή", + "inForce": "Σε Ισχύ", + "notified": "Κοινοποιημένο", + "terminated": "Τερματισμένο" + }, "gdelt": { "empty": "Δεν υπάρχουν πρόσφατα άρθρα για αυτό το θέμα" }, diff --git a/src/locales/en.json b/src/locales/en.json index 63cb0ef84..87889caa3 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -137,7 +137,8 @@ "panelCatCryptoDigital": "Crypto & Digital", "panelCatCentralBanks": "Central Banks & Econ", "panelCatDeals": "Deals & Institutional", - "panelCatGulfMena": "Gulf & MENA" + "panelCatGulfMena": "Gulf & MENA", + "panelCatTradePolicy": "Trade Policy" }, "panels": { "liveNews": "Live News", @@ -166,6 +167,7 @@ "polymarket": "Predictions", "commodities": "Commodities", "economic": "Economic Indicators", + "tradePolicy": "Trade Policy", "finance": "Financial", "tech": "Technology", "crypto": "Crypto", @@ -554,6 +556,26 @@ "vsPreviousWeek": "vs previous week", "in": "in" }, + "tradePolicy": { + "restrictions": "Restrictions", + "tariffs": "Tariffs", + "flows": "Trade Flows", + "barriers": "Barriers", + "noRestrictions": "No active trade restrictions", + "noTariffData": "No tariff data available", + "noFlowData": "No trade flow data available", + "noBarriers": "No trade barriers reported", + "apiKeyMissing": "WTO API key required — add it in Settings", + "upstreamUnavailable": "WTO data temporarily unavailable — showing cached data", + "appliedRate": "Applied Rate", + "boundRate": "Bound Rate", + "exports": "Exports", + "imports": "Imports", + "yoyChange": "YoY Change", + "inForce": "In Force", + "notified": "Notified", + "terminated": "Terminated" + }, "gdelt": { "empty": "No recent articles for this topic" }, diff --git a/src/locales/es.json b/src/locales/es.json index 73a622a82..dbf881218 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -137,7 +137,8 @@ "panelCatCryptoDigital": "Cripto y Digital", "panelCatCentralBanks": "Bancos Centrales y Economía", "panelCatDeals": "Operaciones e Institucional", - "panelCatGulfMena": "Golfo y MENA" + "panelCatGulfMena": "Golfo y MENA", + "panelCatTradePolicy": "Política Comercial" }, "panels": { "liveNews": "Noticias en vivo", @@ -165,6 +166,7 @@ "polymarket": "Predicciones", "commodities": "Materias primas", "economic": "Indicadores económicos", + "tradePolicy": "Política Comercial", "finance": "Finanzas", "tech": "Tecnología", "crypto": "Cripto", @@ -525,6 +527,26 @@ "vsPreviousWeek": "vs semana anterior", "in": "en" }, + "tradePolicy": { + "restrictions": "Restricciones", + "tariffs": "Aranceles", + "flows": "Flujos Comerciales", + "barriers": "Barreras", + "noRestrictions": "No hay restricciones comerciales activas", + "noTariffData": "No hay datos arancelarios disponibles", + "noFlowData": "No hay datos de flujos comerciales disponibles", + "noBarriers": "No se han reportado barreras comerciales", + "apiKeyMissing": "Se requiere clave API de la OMC — agréguela en Configuración", + "upstreamUnavailable": "Datos de la OMC temporalmente no disponibles — mostrando datos en caché", + "appliedRate": "Tasa Aplicada", + "boundRate": "Tasa Consolidada", + "exports": "Exportaciones", + "imports": "Importaciones", + "yoyChange": "Cambio Interanual", + "inForce": "En Vigor", + "notified": "Notificado", + "terminated": "Terminado" + }, "gdelt": { "empty": "No hay artículos recientes para este tema." }, diff --git a/src/locales/fr.json b/src/locales/fr.json index 0780d1c38..dc0d19394 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -137,7 +137,8 @@ "panelCatCryptoDigital": "Crypto & Digital", "panelCatCentralBanks": "Banques Centrales & Économie", "panelCatDeals": "Transactions & Institutionnel", - "panelCatGulfMena": "Golfe & MENA" + "panelCatGulfMena": "Golfe & MENA", + "panelCatTradePolicy": "Politique Commerciale" }, "panels": { "liveNews": "Actualités en direct", @@ -165,6 +166,7 @@ "polymarket": "Prédictions", "commodities": "Matières premières", "economic": "Indicateurs économiques", + "tradePolicy": "Politique Commerciale", "finance": "Finance", "tech": "Technologie", "crypto": "Crypto", @@ -525,6 +527,26 @@ "vsPreviousWeek": "par rapport à la semaine précédente", "in": "dans" }, + "tradePolicy": { + "restrictions": "Restrictions", + "tariffs": "Droits de douane", + "flows": "Flux commerciaux", + "barriers": "Barrières", + "noRestrictions": "Aucune restriction commerciale active", + "noTariffData": "Aucune donnée tarifaire disponible", + "noFlowData": "Aucune donnée de flux commercial disponible", + "noBarriers": "Aucune barrière commerciale signalée", + "apiKeyMissing": "Clé API OMC requise — ajoutez-la dans les Paramètres", + "upstreamUnavailable": "Données OMC temporairement indisponibles — affichage des données en cache", + "appliedRate": "Taux appliqué", + "boundRate": "Taux consolidé", + "exports": "Exportations", + "imports": "Importations", + "yoyChange": "Variation annuelle", + "inForce": "En vigueur", + "notified": "Notifié", + "terminated": "Terminé" + }, "gdelt": { "empty": "Aucun article récent pour ce sujet" }, diff --git a/src/locales/it.json b/src/locales/it.json index f85069fc6..3cb03e4b7 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -137,7 +137,8 @@ "panelCatCryptoDigital": "Crypto & Digitale", "panelCatCentralBanks": "Banche Centrali & Economia", "panelCatDeals": "Operazioni & Istituzionale", - "panelCatGulfMena": "Golfo & MENA" + "panelCatGulfMena": "Golfo & MENA", + "panelCatTradePolicy": "Politica Commerciale" }, "panels": { "liveNews": "Notizie in diretta", @@ -165,6 +166,7 @@ "polymarket": "Previsioni", "commodities": "Materie prime", "economic": "Indicatori economici", + "tradePolicy": "Politica Commerciale", "finance": "Finanza", "tech": "Tecnologia", "crypto": "Cripto", @@ -525,6 +527,26 @@ "vsPreviousWeek": "rispetto alla settimana precedente", "in": "In" }, + "tradePolicy": { + "restrictions": "Restrizioni", + "tariffs": "Dazi", + "flows": "Flussi Commerciali", + "barriers": "Barriere", + "noRestrictions": "Nessuna restrizione commerciale attiva", + "noTariffData": "Nessun dato tariffario disponibile", + "noFlowData": "Nessun dato sui flussi commerciali disponibile", + "noBarriers": "Nessuna barriera commerciale segnalata", + "apiKeyMissing": "Chiave API WTO necessaria — aggiungila nelle Impostazioni", + "upstreamUnavailable": "Dati WTO temporaneamente non disponibili — visualizzazione dati memorizzati", + "appliedRate": "Tasso Applicato", + "boundRate": "Tasso Consolidato", + "exports": "Esportazioni", + "imports": "Importazioni", + "yoyChange": "Variazione Annua", + "inForce": "In Vigore", + "notified": "Notificato", + "terminated": "Terminato" + }, "gdelt": { "empty": "Nessun articolo recente per questo argomento" }, diff --git a/src/locales/ja.json b/src/locales/ja.json index 466a56b52..188cfcb22 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -137,7 +137,8 @@ "panelCatCryptoDigital": "暗号通貨・デジタル", "panelCatCentralBanks": "中央銀行・経済", "panelCatDeals": "ディール・機関投資", - "panelCatGulfMena": "湾岸・中東" + "panelCatGulfMena": "湾岸・中東", + "panelCatTradePolicy": "通商政策" }, "panels": { "liveNews": "ライブニュース", @@ -166,6 +167,7 @@ "polymarket": "予測", "commodities": "コモディティ", "economic": "経済指標", + "tradePolicy": "通商政策", "finance": "金融", "tech": "テクノロジー", "crypto": "暗号資産", @@ -525,6 +527,26 @@ "vsPreviousWeek": "前週比", "in": "内" }, + "tradePolicy": { + "restrictions": "規制", + "tariffs": "関税", + "flows": "貿易フロー", + "barriers": "貿易障壁", + "noRestrictions": "有効な貿易規制なし", + "noTariffData": "関税データなし", + "noFlowData": "貿易フローデータなし", + "noBarriers": "貿易障壁の報告なし", + "apiKeyMissing": "WTO APIキーが必要です — 設定で追加してください", + "upstreamUnavailable": "WTOデータ一時利用不可 — キャッシュデータを表示中", + "appliedRate": "実行関税率", + "boundRate": "譲許税率", + "exports": "輸出", + "imports": "輸入", + "yoyChange": "前年比", + "inForce": "発効中", + "notified": "通報済み", + "terminated": "終了" + }, "gdelt": { "empty": "このトピックの最新記事なし" }, diff --git a/src/locales/nl.json b/src/locales/nl.json index 60a6cb916..557b7b823 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -41,6 +41,7 @@ "energy": "Energie en hulpbronnen", "etfFlows": "BTC ETF-tracker", "economic": "Economische indicatoren", + "tradePolicy": "Handelsbeleid", "macroSignals": "Marktradar", "commodities": "Grondstoffen", "monitors": "Mijn monitoren", @@ -770,6 +771,26 @@ "noData": "Geen economische gegevens beschikbaar", "noOilData": "Oliegegevens niet beschikbaar" }, + "tradePolicy": { + "restrictions": "Beperkingen", + "tariffs": "Tarieven", + "flows": "Handelsstromen", + "barriers": "Handelsbarrières", + "noRestrictions": "Geen actieve handelsbeperkingen", + "noTariffData": "Geen tariefgegevens beschikbaar", + "noFlowData": "Geen handelsstroomgegevens beschikbaar", + "noBarriers": "Geen handelsbarrières gemeld", + "apiKeyMissing": "WTO API-sleutel vereist — voeg deze toe in Instellingen", + "upstreamUnavailable": "WTO-gegevens tijdelijk niet beschikbaar — gecachete gegevens worden weergegeven", + "appliedRate": "Toegepast Tarief", + "boundRate": "Gebonden Tarief", + "exports": "Export", + "imports": "Import", + "yoyChange": "Verandering op Jaarbasis", + "inForce": "Van Kracht", + "notified": "Gemeld", + "terminated": "Beëindigd" + }, "satelliteFires": { "region": "Regio", "fires": "Branden", @@ -1812,7 +1833,8 @@ "panelCatCryptoDigital": "Crypto & Digitaal", "panelCatCentralBanks": "Centrale Banken & Economie", "panelCatDeals": "Deals & Institutioneel", - "panelCatGulfMena": "Golf & MENA" + "panelCatGulfMena": "Golf & MENA", + "panelCatTradePolicy": "Handelsbeleid" }, "app": { "title": "World Monitor", diff --git a/src/locales/pl.json b/src/locales/pl.json index c45cbe8ff..7b96d692d 100644 --- a/src/locales/pl.json +++ b/src/locales/pl.json @@ -137,7 +137,8 @@ "panelCatCryptoDigital": "Krypto & Cyfrowe", "panelCatCentralBanks": "Banki Centralne & Gospodarka", "panelCatDeals": "Transakcje & Instytucjonalne", - "panelCatGulfMena": "Zatoka & MENA" + "panelCatGulfMena": "Zatoka & MENA", + "panelCatTradePolicy": "Polityka Handlowa" }, "panels": { "liveNews": "Wiadomości na żywo", @@ -165,6 +166,7 @@ "polymarket": "Prognozy", "commodities": "Towary", "economic": "Wskaźniki ekonomiczne", + "tradePolicy": "Polityka Handlowa", "finance": "Finanse", "tech": "Technologia", "crypto": "Krypto", @@ -525,6 +527,26 @@ "vsPreviousWeek": "w porównaniu z poprzednim tygodniem", "in": "W" }, + "tradePolicy": { + "restrictions": "Ograniczenia", + "tariffs": "Cła", + "flows": "Przepływy Handlowe", + "barriers": "Bariery", + "noRestrictions": "Brak aktywnych ograniczeń handlowych", + "noTariffData": "Brak dostępnych danych celnych", + "noFlowData": "Brak dostępnych danych o przepływach handlowych", + "noBarriers": "Nie zgłoszono barier handlowych", + "apiKeyMissing": "Wymagany klucz API WTO — dodaj go w Ustawieniach", + "upstreamUnavailable": "Dane WTO tymczasowo niedostępne — wyświetlanie danych z pamięci podręcznej", + "appliedRate": "Stawka Stosowana", + "boundRate": "Stawka Związana", + "exports": "Eksport", + "imports": "Import", + "yoyChange": "Zmiana Roczna", + "inForce": "Obowiązuje", + "notified": "Zgłoszone", + "terminated": "Zakończone" + }, "gdelt": { "empty": "Brak najnowszych artykułów na ten temat" }, diff --git a/src/locales/pt.json b/src/locales/pt.json index 03893ad29..eb7260d5b 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -41,6 +41,7 @@ "energy": "Energia e Recursos", "etfFlows": "Rastreador ETF BTC", "economic": "Indicadores Econômicos", + "tradePolicy": "Política Comercial", "macroSignals": "Radar de Mercado", "commodities": "Mercadorias", "monitors": "Meus monitores", @@ -770,6 +771,26 @@ "noData": "Nenhum dado econômico disponível", "noOilData": "Dados de petróleo não disponíveis" }, + "tradePolicy": { + "restrictions": "Restrições", + "tariffs": "Tarifas", + "flows": "Fluxos Comerciais", + "barriers": "Barreiras", + "noRestrictions": "Sem restrições comerciais ativas", + "noTariffData": "Sem dados tarifários disponíveis", + "noFlowData": "Sem dados de fluxos comerciais disponíveis", + "noBarriers": "Nenhuma barreira comercial reportada", + "apiKeyMissing": "Chave API da OMC necessária — adicione nas Configurações", + "upstreamUnavailable": "Dados da OMC temporariamente indisponíveis — exibindo dados em cache", + "appliedRate": "Taxa Aplicada", + "boundRate": "Taxa Consolidada", + "exports": "Exportações", + "imports": "Importações", + "yoyChange": "Variação Anual", + "inForce": "Em Vigor", + "notified": "Notificado", + "terminated": "Encerrado" + }, "satelliteFires": { "region": "Região", "fires": "Incêndios", @@ -1812,7 +1833,8 @@ "panelCatCryptoDigital": "Cripto & Digital", "panelCatCentralBanks": "Bancos Centrais & Economia", "panelCatDeals": "Negócios & Institucional", - "panelCatGulfMena": "Golfo & MENA" + "panelCatGulfMena": "Golfo & MENA", + "panelCatTradePolicy": "Política Comercial" }, "app": { "title": "World Monitor", diff --git a/src/locales/ru.json b/src/locales/ru.json index 1ec681418..9927e7381 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -137,7 +137,8 @@ "panelCatCryptoDigital": "Крипто и цифровые активы", "panelCatCentralBanks": "Центробанки и экономика", "panelCatDeals": "Сделки и институционалы", - "panelCatGulfMena": "Залив и БВСА" + "panelCatGulfMena": "Залив и БВСА", + "panelCatTradePolicy": "Торговая политика" }, "panels": { "liveNews": "Новости в реальном времени", @@ -166,6 +167,7 @@ "polymarket": "Прогнозы", "commodities": "Сырьевые товары", "economic": "Экономические индикаторы", + "tradePolicy": "Торговая политика", "finance": "Финансы", "tech": "Технологии", "crypto": "Криптовалюты", @@ -525,6 +527,26 @@ "vsPreviousWeek": "к предыдущей неделе", "in": "в" }, + "tradePolicy": { + "restrictions": "Ограничения", + "tariffs": "Тарифы", + "flows": "Торговые потоки", + "barriers": "Барьеры", + "noRestrictions": "Нет активных торговых ограничений", + "noTariffData": "Данные о тарифах недоступны", + "noFlowData": "Данные о торговых потоках недоступны", + "noBarriers": "Торговые барьеры не зарегистрированы", + "apiKeyMissing": "Требуется ключ API ВТО — добавьте его в Настройках", + "upstreamUnavailable": "Данные ВТО временно недоступны — отображаются кэшированные данные", + "appliedRate": "Применяемая ставка", + "boundRate": "Связанная ставка", + "exports": "Экспорт", + "imports": "Импорт", + "yoyChange": "Изменение за год", + "inForce": "Действует", + "notified": "Уведомлено", + "terminated": "Прекращено" + }, "gdelt": { "empty": "Нет свежих статей по этой теме" }, diff --git a/src/locales/sv.json b/src/locales/sv.json index 71f44d20a..21a768b6c 100644 --- a/src/locales/sv.json +++ b/src/locales/sv.json @@ -41,6 +41,7 @@ "energy": "Energi & resurser", "etfFlows": "BTC ETF-spårare", "economic": "Ekonomiska indikatorer", + "tradePolicy": "Handelspolitik", "macroSignals": "Marknadsradar", "commodities": "Handelsvaror", "monitors": "Mina monitorer", @@ -770,6 +771,26 @@ "noData": "Inga ekonomiska data tillgängliga", "noOilData": "Oljedata ej tillgänglig" }, + "tradePolicy": { + "restrictions": "Restriktioner", + "tariffs": "Tullar", + "flows": "Handelsflöden", + "barriers": "Handelsbarriärer", + "noRestrictions": "Inga aktiva handelsrestriktioner", + "noTariffData": "Inga tulldata tillgängliga", + "noFlowData": "Inga handelsflödesdata tillgängliga", + "noBarriers": "Inga handelsbarriärer rapporterade", + "apiKeyMissing": "WTO API-nyckel krävs — lägg till den i Inställningar", + "upstreamUnavailable": "WTO-data tillfälligt otillgänglig — visar cachad data", + "appliedRate": "Tillämpad Tullsats", + "boundRate": "Bunden Tullsats", + "exports": "Export", + "imports": "Import", + "yoyChange": "Förändring på Årsbasis", + "inForce": "I Kraft", + "notified": "Anmäld", + "terminated": "Avslutad" + }, "satelliteFires": { "region": "Område", "fires": "Bränder", @@ -1812,7 +1833,8 @@ "panelCatCryptoDigital": "Krypto & Digitalt", "panelCatCentralBanks": "Centralbanker & Ekonomi", "panelCatDeals": "Affärer & Institutionellt", - "panelCatGulfMena": "Gulfen & MENA" + "panelCatGulfMena": "Gulfen & MENA", + "panelCatTradePolicy": "Handelspolitik" }, "app": { "title": "World Monitor", diff --git a/src/locales/th.json b/src/locales/th.json index 29893a320..60f35bc21 100644 --- a/src/locales/th.json +++ b/src/locales/th.json @@ -137,7 +137,8 @@ "panelCatCryptoDigital": "คริปโต & ดิจิทัล", "panelCatCentralBanks": "ธนาคารกลาง & เศรษฐกิจ", "panelCatDeals": "ดีล & สถาบัน", - "panelCatGulfMena": "อ่าวเปอร์เซีย & MENA" + "panelCatGulfMena": "อ่าวเปอร์เซีย & MENA", + "panelCatTradePolicy": "นโยบายการค้า" }, "panels": { "liveNews": "ข่าวสด", @@ -166,6 +167,7 @@ "polymarket": "การพยากรณ์", "commodities": "สินค้าโภคภัณฑ์", "economic": "ตัวชี้วัดเศรษฐกิจ", + "tradePolicy": "นโยบายการค้า", "finance": "การเงิน", "tech": "เทคโนโลยี", "crypto": "คริปโต", @@ -525,6 +527,26 @@ "vsPreviousWeek": "เทียบกับสัปดาห์ก่อน", "in": "ใน" }, + "tradePolicy": { + "restrictions": "ข้อจำกัด", + "tariffs": "ภาษีศุลกากร", + "flows": "กระแสการค้า", + "barriers": "อุปสรรคทางการค้า", + "noRestrictions": "ไม่มีข้อจำกัดทางการค้าที่ใช้งานอยู่", + "noTariffData": "ไม่มีข้อมูลภาษีศุลกากร", + "noFlowData": "ไม่มีข้อมูลกระแสการค้า", + "noBarriers": "ไม่มีรายงานอุปสรรคทางการค้า", + "apiKeyMissing": "ต้องใช้คีย์ API ของ WTO — เพิ่มในการตั้งค่า", + "upstreamUnavailable": "ข้อมูล WTO ไม่พร้อมใช้งานชั่วคราว — แสดงข้อมูลแคช", + "appliedRate": "อัตราที่ใช้", + "boundRate": "อัตราผูกพัน", + "exports": "การส่งออก", + "imports": "การนำเข้า", + "yoyChange": "เปลี่ยนแปลงรายปี", + "inForce": "มีผลบังคับใช้", + "notified": "แจ้งแล้ว", + "terminated": "สิ้นสุด" + }, "gdelt": { "empty": "ไม่มีบทความล่าสุดสำหรับหัวข้อนี้" }, diff --git a/src/locales/tr.json b/src/locales/tr.json index fb3899200..e42b1db78 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -137,7 +137,8 @@ "panelCatCryptoDigital": "Kripto & Dijital", "panelCatCentralBanks": "Merkez Bankaları & Ekonomi", "panelCatDeals": "Anlaşmalar & Kurumsal", - "panelCatGulfMena": "Körfez & MENA" + "panelCatGulfMena": "Körfez & MENA", + "panelCatTradePolicy": "Ticaret Politikası" }, "panels": { "liveNews": "Canli Haberler", @@ -166,6 +167,7 @@ "polymarket": "Tahminler", "commodities": "Emtialar", "economic": "Ekonomik Gostergeler", + "tradePolicy": "Ticaret Politikası", "finance": "Finansal", "tech": "Teknoloji", "crypto": "Kripto", @@ -525,6 +527,26 @@ "vsPreviousWeek": "onceki haftaya gore", "in": "icinde" }, + "tradePolicy": { + "restrictions": "Kısıtlamalar", + "tariffs": "Gümrük Tarifeleri", + "flows": "Ticaret Akışları", + "barriers": "Engeller", + "noRestrictions": "Aktif ticaret kısıtlaması yok", + "noTariffData": "Tarife verisi mevcut değil", + "noFlowData": "Ticaret akışı verisi mevcut değil", + "noBarriers": "Ticaret engeli bildirilmedi", + "apiKeyMissing": "WTO API anahtarı gerekli — Ayarlar'dan ekleyin", + "upstreamUnavailable": "WTO verileri geçici olarak kullanılamıyor — önbellek verileri gösteriliyor", + "appliedRate": "Uygulanan Oran", + "boundRate": "Bağlı Oran", + "exports": "İhracat", + "imports": "İthalat", + "yoyChange": "Yıllık Değişim", + "inForce": "Yürürlükte", + "notified": "Bildirildi", + "terminated": "Sonlandırıldı" + }, "gdelt": { "empty": "Bu konu icin son makale yok" }, diff --git a/src/locales/vi.json b/src/locales/vi.json index 40068691c..55bd7b2c8 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -137,7 +137,8 @@ "panelCatCryptoDigital": "Tiền mã hóa & Kỹ thuật số", "panelCatCentralBanks": "Ngân hàng Trung ương & Kinh tế", "panelCatDeals": "Giao dịch & Tổ chức", - "panelCatGulfMena": "Vùng Vịnh & MENA" + "panelCatGulfMena": "Vùng Vịnh & MENA", + "panelCatTradePolicy": "Chính sách Thương mại" }, "panels": { "liveNews": "Tin tức Trực tiếp", @@ -166,6 +167,7 @@ "polymarket": "Dự đoán", "commodities": "Hàng hóa", "economic": "Chỉ số Kinh tế", + "tradePolicy": "Chính sách Thương mại", "finance": "Tài chính", "tech": "Công nghệ", "crypto": "Tiền mã hóa", @@ -525,6 +527,26 @@ "vsPreviousWeek": "so với tuần trước", "in": "trong" }, + "tradePolicy": { + "restrictions": "Hạn chế", + "tariffs": "Thuế quan", + "flows": "Dòng chảy Thương mại", + "barriers": "Rào cản", + "noRestrictions": "Không có hạn chế thương mại đang hoạt động", + "noTariffData": "Không có dữ liệu thuế quan", + "noFlowData": "Không có dữ liệu dòng chảy thương mại", + "noBarriers": "Không có rào cản thương mại được báo cáo", + "apiKeyMissing": "Cần khóa API WTO — thêm trong Cài đặt", + "upstreamUnavailable": "Dữ liệu WTO tạm thời không khả dụng — hiển thị dữ liệu đã lưu", + "appliedRate": "Thuế suất Áp dụng", + "boundRate": "Thuế suất Ràng buộc", + "exports": "Xuất khẩu", + "imports": "Nhập khẩu", + "yoyChange": "Thay đổi theo Năm", + "inForce": "Có hiệu lực", + "notified": "Đã thông báo", + "terminated": "Đã chấm dứt" + }, "gdelt": { "empty": "Không có bài viết gần đây cho chủ đề này" }, diff --git a/src/locales/zh.json b/src/locales/zh.json index fd270265e..259986552 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -137,7 +137,8 @@ "panelCatCryptoDigital": "加密与数字资产", "panelCatCentralBanks": "央行与经济", "panelCatDeals": "交易与机构", - "panelCatGulfMena": "海湾与中东" + "panelCatGulfMena": "海湾与中东", + "panelCatTradePolicy": "贸易政策" }, "panels": { "liveNews": "实时新闻", @@ -166,6 +167,7 @@ "polymarket": "预测", "commodities": "大宗商品", "economic": "经济指标", + "tradePolicy": "贸易政策", "finance": "金融", "tech": "科技", "crypto": "加密货币", @@ -525,6 +527,26 @@ "vsPreviousWeek": "与上周相比", "in": "于" }, + "tradePolicy": { + "restrictions": "限制措施", + "tariffs": "关税", + "flows": "贸易流量", + "barriers": "贸易壁垒", + "noRestrictions": "无有效贸易限制", + "noTariffData": "无可用关税数据", + "noFlowData": "无可用贸易流量数据", + "noBarriers": "未报告贸易壁垒", + "apiKeyMissing": "需要WTO API密钥 — 请在设置中添加", + "upstreamUnavailable": "WTO数据暂时不可用 — 显示缓存数据", + "appliedRate": "适用税率", + "boundRate": "约束税率", + "exports": "出口", + "imports": "进口", + "yoyChange": "同比变化", + "inForce": "生效中", + "notified": "已通报", + "terminated": "已终止" + }, "gdelt": { "empty": "该主题暂无近期文章" }, diff --git a/src/services/analytics.ts b/src/services/analytics.ts index a74d43cdd..2cb8382e4 100644 --- a/src/services/analytics.ts +++ b/src/services/analytics.ts @@ -53,6 +53,7 @@ const SECRET_ANALYTICS_NAMES: Record = { OLLAMA_API_URL: 'ollama_url', OLLAMA_MODEL: 'ollama_model', WORLDMONITOR_API_KEY: 'worldmonitor', + WTO_API_KEY: 'wto', }; // ── Typed event schemas (allowlisted properties per event) ── diff --git a/src/services/data-freshness.ts b/src/services/data-freshness.ts index 0b7799383..b25024640 100644 --- a/src/services/data-freshness.ts +++ b/src/services/data-freshness.ts @@ -32,7 +32,8 @@ export type DataSourceId = | 'unhcr' // UNHCR displacement data | 'climate' // Climate anomaly data (Open-Meteo) | 'worldpop' // WorldPop population exposure - | 'giving'; // Global giving activity data + | 'giving' // Global giving activity data + | 'wto_trade'; // WTO trade policy data export type FreshnessStatus = 'fresh' | 'stale' | 'very_stale' | 'no_data' | 'disabled' | 'error'; @@ -95,6 +96,7 @@ const SOURCE_METADATA: Record = { climate: 'Climate anomaly data unavailable—extreme weather patterns undetected', worldpop: 'Population exposure data unavailable—affected population unknown', giving: 'Global giving activity data unavailable', + wto_trade: 'Trade policy intelligence unavailable—WTO data not updating', }; /** diff --git a/src/services/index.ts b/src/services/index.ts index d1797081b..0502103eb 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -37,3 +37,4 @@ export * from './data-freshness'; export * from './usa-spending'; export { generateSummary, translateText } from './summarization'; export * from './cached-theater-posture'; +export * from './trade'; diff --git a/src/services/runtime-config.ts b/src/services/runtime-config.ts index ee33375da..385ae7720 100644 --- a/src/services/runtime-config.ts +++ b/src/services/runtime-config.ts @@ -22,7 +22,8 @@ export type RuntimeSecretKey = | 'UC_DP_KEY' | 'OLLAMA_API_URL' | 'OLLAMA_MODEL' - | 'WORLDMONITOR_API_KEY'; + | 'WORLDMONITOR_API_KEY' + | 'WTO_API_KEY'; export type RuntimeFeatureId = | 'aiGroq' @@ -39,7 +40,8 @@ export type RuntimeFeatureId = | 'openskyRelay' | 'finnhubMarkets' | 'nasaFirms' - | 'aiOllama'; + | 'aiOllama' + | 'wtoTrade'; export interface RuntimeFeatureDefinition { id: RuntimeFeatureId; @@ -80,6 +82,7 @@ const defaultToggles: Record = { finnhubMarkets: true, nasaFirms: true, aiOllama: true, + wtoTrade: true, }; export const RUNTIME_FEATURES: RuntimeFeatureDefinition[] = [ @@ -190,6 +193,13 @@ export const RUNTIME_FEATURES: RuntimeFeatureDefinition[] = [ requiredSecrets: ['NASA_FIRMS_API_KEY'], fallback: 'FIRMS fire layer uses public VIIRS feed.', }, + { + id: 'wtoTrade', + name: 'WTO trade policy data', + description: 'Trade restrictions, tariff trends, barriers, and flows from WTO.', + requiredSecrets: ['WTO_API_KEY'], + fallback: 'Trade policy panel shows disabled state.', + }, ]; function readEnvSecret(key: RuntimeSecretKey): string { diff --git a/src/services/trade/index.ts b/src/services/trade/index.ts new file mode 100644 index 000000000..59199093a --- /dev/null +++ b/src/services/trade/index.ts @@ -0,0 +1,83 @@ +/** + * Trade policy intelligence service -- WTO data sources. + * Trade restrictions, tariff trends, trade flows, and SPS/TBT barriers. + */ + +import { + TradeServiceClient, + type GetTradeRestrictionsResponse, + type GetTariffTrendsResponse, + type GetTradeFlowsResponse, + type GetTradeBarriersResponse, + type TradeRestriction, + type TariffDataPoint, + type TradeFlowRecord, + type TradeBarrier, +} from '@/generated/client/worldmonitor/trade/v1/service_client'; +import { createCircuitBreaker } from '@/utils'; +import { isFeatureAvailable } from '../runtime-config'; + +// Re-export types for consumers +export type { TradeRestriction, TariffDataPoint, TradeFlowRecord, TradeBarrier }; +export type { + GetTradeRestrictionsResponse, + GetTariffTrendsResponse, + GetTradeFlowsResponse, + GetTradeBarriersResponse, +}; + +const client = new TradeServiceClient('', { fetch: (...args) => globalThis.fetch(...args) }); + +const restrictionsBreaker = createCircuitBreaker({ name: 'WTO Restrictions', cacheTtlMs: 30 * 60 * 1000, persistCache: true }); +const tariffsBreaker = createCircuitBreaker({ name: 'WTO Tariffs', cacheTtlMs: 30 * 60 * 1000, persistCache: true }); +const flowsBreaker = createCircuitBreaker({ name: 'WTO Flows', cacheTtlMs: 30 * 60 * 1000, persistCache: true }); +const barriersBreaker = createCircuitBreaker({ name: 'WTO Barriers', cacheTtlMs: 30 * 60 * 1000, persistCache: true }); + +const emptyRestrictions: GetTradeRestrictionsResponse = { restrictions: [], fetchedAt: '', upstreamUnavailable: false }; +const emptyTariffs: GetTariffTrendsResponse = { datapoints: [], fetchedAt: '', upstreamUnavailable: false }; +const emptyFlows: GetTradeFlowsResponse = { flows: [], fetchedAt: '', upstreamUnavailable: false }; +const emptyBarriers: GetTradeBarriersResponse = { barriers: [], fetchedAt: '', upstreamUnavailable: false }; + +export async function fetchTradeRestrictions(countries: string[] = [], limit = 50): Promise { + if (!isFeatureAvailable('wtoTrade')) return emptyRestrictions; + try { + return await restrictionsBreaker.execute(async () => { + return client.getTradeRestrictions({ countries, limit }); + }, emptyRestrictions); + } catch { + return emptyRestrictions; + } +} + +export async function fetchTariffTrends(reportingCountry: string, partnerCountry: string, productSector = '', years = 10): Promise { + if (!isFeatureAvailable('wtoTrade')) return emptyTariffs; + try { + return await tariffsBreaker.execute(async () => { + return client.getTariffTrends({ reportingCountry, partnerCountry, productSector, years }); + }, emptyTariffs); + } catch { + return emptyTariffs; + } +} + +export async function fetchTradeFlows(reportingCountry: string, partnerCountry: string, years = 10): Promise { + if (!isFeatureAvailable('wtoTrade')) return emptyFlows; + try { + return await flowsBreaker.execute(async () => { + return client.getTradeFlows({ reportingCountry, partnerCountry, years }); + }, emptyFlows); + } catch { + return emptyFlows; + } +} + +export async function fetchTradeBarriers(countries: string[] = [], measureType = '', limit = 50): Promise { + if (!isFeatureAvailable('wtoTrade')) return emptyBarriers; + try { + return await barriersBreaker.execute(async () => { + return client.getTradeBarriers({ countries, measureType, limit }); + }, emptyBarriers); + } catch { + return emptyBarriers; + } +} diff --git a/vite.config.ts b/vite.config.ts index 7dd6469df..85e539b64 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -284,6 +284,7 @@ function sebufApiPlugin(): Plugin { militaryServerMod, militaryHandlerMod, positiveEventsServerMod, positiveEventsHandlerMod, givingServerMod, givingHandlerMod, + tradeServerMod, tradeHandlerMod, ] = await Promise.all([ import('./server/router'), import('./server/cors'), @@ -326,6 +327,8 @@ function sebufApiPlugin(): Plugin { import('./server/worldmonitor/positive-events/v1/handler'), import('./src/generated/server/worldmonitor/giving/v1/service_server'), import('./server/worldmonitor/giving/v1/handler'), + import('./src/generated/server/worldmonitor/trade/v1/service_server'), + import('./server/worldmonitor/trade/v1/handler'), ]); const serverOptions = { onError: errorMod.mapErrorToResponse }; @@ -349,6 +352,7 @@ function sebufApiPlugin(): Plugin { ...militaryServerMod.createMilitaryServiceRoutes(militaryHandlerMod.militaryHandler, serverOptions), ...positiveEventsServerMod.createPositiveEventsServiceRoutes(positiveEventsHandlerMod.positiveEventsHandler, serverOptions), ...givingServerMod.createGivingServiceRoutes(givingHandlerMod.givingHandler, serverOptions), + ...tradeServerMod.createTradeServiceRoutes(tradeHandlerMod.tradeHandler, serverOptions), ]; cachedCorsMod = corsMod; return routerMod.createRouter(allRoutes); From 40818f1f66fa999b398e92ea6bef6983d362a099 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Feb 2026 09:52:04 +0000 Subject: [PATCH 2/3] chore: update package-lock.json https://claude.ai/code/session_01HZXyoQp6xK3TX8obDzv6Ye --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3a11ee5ca..264011630 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "world-monitor", - "version": "2.5.6", + "version": "2.5.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "world-monitor", - "version": "2.5.6", + "version": "2.5.8", "license": "AGPL-3.0-only", "dependencies": { "@deck.gl/aggregation-layers": "^9.2.6", From 845009062ba092c0b7258128d32e3f8889f30fb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Feb 2026 10:10:24 +0000 Subject: [PATCH 3/3] fix: move tab click listener to constructor to prevent leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delegated click handler was added inside render(), which runs on every data update (4× per load cycle). Since the listener targets this.content (a persistent container), each call stacked a duplicate handler. Moving it to the constructor binds it exactly once. https://claude.ai/code/session_01HZXyoQp6xK3TX8obDzv6Ye --- src/components/TradePolicyPanel.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/components/TradePolicyPanel.ts b/src/components/TradePolicyPanel.ts index 7dded197a..43fdd4f01 100644 --- a/src/components/TradePolicyPanel.ts +++ b/src/components/TradePolicyPanel.ts @@ -21,6 +21,15 @@ export class TradePolicyPanel extends Panel { constructor() { super({ id: 'trade-policy', title: t('panels.tradePolicy') }); + 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 updateRestrictions(data: GetTradeRestrictionsResponse): void { @@ -95,16 +104,6 @@ export class TradePolicyPanel extends Panel { `); - // Event delegation on this.content for tab switching (survives setContent debounce) - 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(); - } - }); } private renderRestrictions(): string {