diff --git a/src/app/app-context.ts b/src/app/app-context.ts index 352968ac9b8..3be9e0b2021 100644 --- a/src/app/app-context.ts +++ b/src/app/app-context.ts @@ -1,6 +1,7 @@ import type { NewsItem, Monitor, PanelConfig, MapLayers, InternetOutage, SocialUnrestEvent, MilitaryFlight, MilitaryFlightCluster, MilitaryVessel, MilitaryVesselCluster, CyberThreat, USNIFleetReport } from '@/types'; import type { AirportDelayAlert } from '@/services/aviation'; import type { IranEvent } from '@/generated/client/worldmonitor/conflict/v1/service_client'; +import type { SecurityAdvisory } from '@/services/security-advisories'; import type { MapContainer, Panel, NewsPanel, SignalModal, StatusPanel, SearchModal } from '@/components'; import type { IntelligenceGapBadge } from '@/components'; import type { MarketData, ClusteredEvent } from '@/types'; @@ -38,6 +39,8 @@ export interface CountryBriefSignals { orefSirens: number; orefHistory24h: number; aviationDisruptions: number; + travelAdvisories: number; + travelAdvisoryMaxLevel: string | null; isTier1: boolean; } @@ -50,6 +53,7 @@ export interface IntelligenceCache { usniFleet?: USNIFleetReport; iranEvents?: IranEvent[]; orefAlerts?: { alertCount: number; historyCount24h: number }; + advisories?: SecurityAdvisory[]; } export interface AppModule { diff --git a/src/app/country-intel.ts b/src/app/country-intel.ts index c27ee64a47c..bbc688d7231 100644 --- a/src/app/country-intel.ts +++ b/src/app/country-intel.ts @@ -198,6 +198,13 @@ export class CountryIntelManager implements AppModule { context.regionalConvergence = convergences.map((r) => r.description); } + if (this.ctx.intelligenceCache.advisories) { + const countryAdvisories = this.ctx.intelligenceCache.advisories.filter(a => a.country === code); + if (countryAdvisories.length > 0) { + context.travelAdvisories = countryAdvisories.map(a => ({ source: a.source, level: a.level, title: a.title })); + } + } + const headlines = filteredNews.slice(0, 15).map((n) => n.title); if (headlines.length) context.headlines = headlines; @@ -237,6 +244,8 @@ export class CountryIntelManager implements AppModule { if (signals.militaryFlights > 0) lines.push(t('countryBrief.fallback.aircraftTracked', { count: String(signals.militaryFlights) })); if (signals.militaryVessels > 0) lines.push(t('countryBrief.fallback.vesselsTracked', { count: String(signals.militaryVessels) })); if (signals.activeStrikes > 0) lines.push(t('countryBrief.fallback.activeStrikes', { count: String(signals.activeStrikes) })); + if (signals.travelAdvisoryMaxLevel === 'do-not-travel') lines.push(`⚠️ Travel advisory: Do Not Travel (${signals.travelAdvisories} source${signals.travelAdvisories > 1 ? 's' : ''})`); + else if (signals.travelAdvisoryMaxLevel === 'reconsider') lines.push(`⚠️ Travel advisory: Reconsider Travel (${signals.travelAdvisories} source${signals.travelAdvisories > 1 ? 's' : ''})`); if (signals.outages > 0) lines.push(t('countryBrief.fallback.internetOutages', { count: String(signals.outages) })); if (signals.earthquakes > 0) lines.push(t('countryBrief.fallback.recentEarthquakes', { count: String(signals.earthquakes) })); if (context.stockIndex) lines.push(t('countryBrief.fallback.stockIndex', { value: context.stockIndex })); @@ -402,6 +411,19 @@ export class CountryIntelManager implements AppModule { orefHistory24h = this.ctx.intelligenceCache.orefAlerts.historyCount24h; } + let travelAdvisories = 0; + let travelAdvisoryMaxLevel: string | null = null; + const advisoryLevelRank: Record = { 'do-not-travel': 4, 'reconsider': 3, 'caution': 2, 'normal': 1, 'info': 0 }; + if (this.ctx.intelligenceCache.advisories) { + const countryAdvisories = this.ctx.intelligenceCache.advisories.filter(a => a.country === code); + travelAdvisories = countryAdvisories.length; + for (const a of countryAdvisories) { + if (a.level && (advisoryLevelRank[a.level] || 0) > (advisoryLevelRank[travelAdvisoryMaxLevel || ''] || 0)) { + travelAdvisoryMaxLevel = a.level; + } + } + } + return { protests, militaryFlights, @@ -415,6 +437,8 @@ export class CountryIntelManager implements AppModule { orefSirens, orefHistory24h, aviationDisruptions, + travelAdvisories, + travelAdvisoryMaxLevel, isTier1, }; } diff --git a/src/app/data-loader.ts b/src/app/data-loader.ts index fd5960d23cd..9109130ed4f 100644 --- a/src/app/data-loader.ts +++ b/src/app/data-loader.ts @@ -66,7 +66,7 @@ import { updateAndCheck } from '@/services/temporal-baseline'; import { fetchAllFires, flattenFires, computeRegionStats, toMapFires } from '@/services/wildfires'; import { analyzeFlightsForSurge, surgeAlertToSignal, detectForeignMilitaryPresence, foreignPresenceToSignal, type TheaterPostureSummary } from '@/services/military-surge'; import { fetchCachedTheaterPosture } from '@/services/cached-theater-posture'; -import { ingestProtestsForCII, ingestMilitaryForCII, ingestNewsForCII, ingestOutagesForCII, ingestConflictsForCII, ingestUcdpForCII, ingestHapiForCII, ingestDisplacementForCII, ingestClimateForCII, ingestStrikesForCII, ingestOrefForCII, ingestAviationForCII, isInLearningMode } from '@/services/country-instability'; +import { ingestProtestsForCII, ingestMilitaryForCII, ingestNewsForCII, ingestOutagesForCII, ingestConflictsForCII, ingestUcdpForCII, ingestHapiForCII, ingestDisplacementForCII, ingestClimateForCII, ingestStrikesForCII, ingestOrefForCII, ingestAviationForCII, ingestAdvisoriesForCII, isInLearningMode } from '@/services/country-instability'; import { dataFreshness, type DataSourceId } from '@/services/data-freshness'; import { fetchConflictEvents, fetchUcdpClassifications, fetchHapiSummary, fetchUcdpEvents, deduplicateAgainstAcled, fetchIranEvents } from '@/services/conflict'; import { fetchUnhcrPopulation } from '@/services/displacement'; @@ -1940,6 +1940,8 @@ export class DataLoaderManager implements AppModule { const result = await fetchSecurityAdvisories(); if (result.ok) { (this.ctx.panels['security-advisories'] as SecurityAdvisoriesPanel)?.setData(result.advisories); + this.ctx.intelligenceCache.advisories = result.advisories; + ingestAdvisoriesForCII(result.advisories); } } catch (error) { console.error('[App] Security advisories fetch failed:', error); diff --git a/src/components/CountryBriefPage.ts b/src/components/CountryBriefPage.ts index 0c10300d44b..6334e8c36d4 100644 --- a/src/components/CountryBriefPage.ts +++ b/src/components/CountryBriefPage.ts @@ -182,6 +182,15 @@ export class CountryBriefPage { if (signals.climateStress > 0) chips.push(`🌡️ ${t('modals.countryBrief.signals.climate')}`); if (signals.conflictEvents > 0) chips.push(`⚔️ ${signals.conflictEvents} ${t('modals.countryBrief.signals.conflictEvents')}`); if (signals.activeStrikes > 0) chips.push(`\u{1F4A5} ${signals.activeStrikes} ${t('modals.countryBrief.signals.activeStrikes')}`); + if (signals.travelAdvisories > 0 && signals.travelAdvisoryMaxLevel) { + const advisoryClass = signals.travelAdvisoryMaxLevel === 'do-not-travel' ? 'conflict' + : signals.travelAdvisoryMaxLevel === 'reconsider' ? 'outage' + : 'military'; + const advisoryLabel = signals.travelAdvisoryMaxLevel === 'do-not-travel' ? 'Do Not Travel' + : signals.travelAdvisoryMaxLevel === 'reconsider' ? 'Reconsider Travel' + : 'Exercise Caution'; + chips.push(`\u26A0\uFE0F ${signals.travelAdvisories} Advisory: ${advisoryLabel}`); + } if (signals.orefSirens > 0) chips.push(`\u{1F6A8} ${signals.orefSirens} Active Sirens`); if (signals.aviationDisruptions > 0) chips.push(`\u{1F6AB} ${signals.aviationDisruptions} ${t('modals.countryBrief.signals.aviationDisruptions')}`); chips.push(`📈 ${t('modals.countryBrief.loadingIndex')}`); @@ -613,6 +622,8 @@ export class CountryBriefPage { orefSirens: this.currentSignals.orefSirens, orefHistory24h: this.currentSignals.orefHistory24h, aviationDisruptions: this.currentSignals.aviationDisruptions, + travelAdvisories: this.currentSignals.travelAdvisories, + travelAdvisoryMaxLevel: this.currentSignals.travelAdvisoryMaxLevel, }; } if (this.currentBrief) data.brief = this.currentBrief; diff --git a/src/services/country-instability.ts b/src/services/country-instability.ts index 9e942fc9941..4bdb84e3246 100644 --- a/src/services/country-instability.ts +++ b/src/services/country-instability.ts @@ -1,5 +1,6 @@ import type { SocialUnrestEvent, MilitaryFlight, MilitaryVessel, ClusteredEvent, InternetOutage } from '@/types'; import type { AirportDelayAlert } from '@/services/aviation'; +import type { SecurityAdvisory } from '@/services/security-advisories'; import { tokenizeForMatch, matchKeyword } from '@/utils/keyword-match'; import { INTEL_HOTSPOTS, CONFLICT_ZONES, STRATEGIC_WATERWAYS } from '@/config/geo'; import { CURATED_COUNTRIES, DEFAULT_BASELINE_RISK, DEFAULT_EVENT_MULTIPLIER, getHotspotCountries } from '@/config/countries'; @@ -42,6 +43,9 @@ interface CountryData { climateStress: number; orefAlertCount: number; orefHistoryCount24h: number; + advisoryMaxLevel: SecurityAdvisory['level'] | null; + advisoryCount: number; + advisorySources: Set; } export { TIER1_COUNTRIES } from '@/config/countries'; @@ -123,7 +127,7 @@ const countryDataMap = new Map(); const previousScores = new Map(); function initCountryData(): CountryData { - return { protests: [], conflicts: [], ucdpStatus: null, hapiSummary: null, militaryFlights: [], militaryVessels: [], newsEvents: [], outages: [], strikes: [], aviationDisruptions: [], displacementOutflow: 0, climateStress: 0, orefAlertCount: 0, orefHistoryCount24h: 0 }; + return { protests: [], conflicts: [], ucdpStatus: null, hapiSummary: null, militaryFlights: [], militaryVessels: [], newsEvents: [], outages: [], strikes: [], aviationDisruptions: [], displacementOutflow: 0, climateStress: 0, orefAlertCount: 0, orefHistoryCount24h: 0, advisoryMaxLevel: null, advisoryCount: 0, advisorySources: new Set() }; } const newsEventIndexMap = new Map>(); @@ -445,6 +449,52 @@ export function ingestAviationForCII(alerts: AirportDelayAlert[]): void { } } +const TRAVEL_ADVISORY_SOURCES = new Set(['US', 'AU', 'UK', 'NZ']); +const ADVISORY_LEVEL_RANK: Record = { 'do-not-travel': 4, 'reconsider': 3, 'caution': 2, 'normal': 1, 'info': 0 }; + +export function ingestAdvisoriesForCII(advisories: SecurityAdvisory[]): void { + for (const data of countryDataMap.values()) { + data.advisoryMaxLevel = null; + data.advisoryCount = 0; + data.advisorySources = new Set(); + } + + const travelAdvisories = advisories.filter(a => + a.country && TRAVEL_ADVISORY_SOURCES.has(a.sourceCountry) && a.level && a.level !== 'info' + ); + + for (const a of travelAdvisories) { + const code = a.country!; + if (!countryDataMap.has(code)) countryDataMap.set(code, initCountryData()); + const data = countryDataMap.get(code)!; + data.advisoryCount++; + data.advisorySources.add(a.sourceCountry); + const currentRank = ADVISORY_LEVEL_RANK[data.advisoryMaxLevel || ''] || 0; + const newRank = ADVISORY_LEVEL_RANK[a.level!] || 0; + if (newRank > currentRank) data.advisoryMaxLevel = a.level!; + } +} + +function getAdvisoryBoost(data: CountryData): number { + if (!data.advisoryMaxLevel) return 0; + let boost = 0; + switch (data.advisoryMaxLevel) { + case 'do-not-travel': boost = 15; break; + case 'reconsider': boost = 10; break; + case 'caution': boost = 5; break; + default: return 0; + } + if (data.advisorySources.size >= 3) boost += 5; + else if (data.advisorySources.size >= 2) boost += 3; + return boost; +} + +function getAdvisoryFloor(data: CountryData): number { + if (data.advisoryMaxLevel === 'do-not-travel') return 60; + if (data.advisoryMaxLevel === 'reconsider') return 50; + return 0; +} + function calcUnrestScore(data: CountryData, countryCode: string): number { const protestCount = data.protests.length; const multiplier = CURATED_COUNTRIES[countryCode]?.eventMultiplier ?? DEFAULT_EVENT_MULTIPLIER; @@ -662,9 +712,10 @@ export function calculateCII(): CountryScore[] { : 0; const climateBoost = data.climateStress; - const blendedScore = baselineRisk * 0.4 + eventScore * 0.6 + hotspotBoost + newsUrgencyBoost + focalBoost + displacementBoost + climateBoost + getOrefBlendBoost(code, data); + const advisoryBoost = getAdvisoryBoost(data); + const blendedScore = baselineRisk * 0.4 + eventScore * 0.6 + hotspotBoost + newsUrgencyBoost + focalBoost + displacementBoost + climateBoost + getOrefBlendBoost(code, data) + advisoryBoost; - const floor = getUcdpFloor(data); + const floor = Math.max(getUcdpFloor(data), getAdvisoryFloor(data)); const score = Math.round(Math.min(100, Math.max(floor, blendedScore))); const prev = previousScores.get(code) ?? score; @@ -715,8 +766,9 @@ export function getCountryScore(code: string): number | null { : data.displacementOutflow >= 100_000 ? 4 : 0; const climateBoost = data.climateStress; - const blendedScore = baselineRisk * 0.4 + eventScore * 0.6 + hotspotBoost + newsUrgencyBoost + focalBoost + displacementBoost + climateBoost + getOrefBlendBoost(code, data); + const advisoryBoost = getAdvisoryBoost(data); + const blendedScore = baselineRisk * 0.4 + eventScore * 0.6 + hotspotBoost + newsUrgencyBoost + focalBoost + displacementBoost + climateBoost + getOrefBlendBoost(code, data) + advisoryBoost; - const floor = getUcdpFloor(data); + const floor = Math.max(getUcdpFloor(data), getAdvisoryFloor(data)); return Math.round(Math.min(100, Math.max(floor, blendedScore))); } diff --git a/src/services/security-advisories.ts b/src/services/security-advisories.ts index cd93315637e..7fb0b176589 100644 --- a/src/services/security-advisories.ts +++ b/src/services/security-advisories.ts @@ -2,6 +2,7 @@ import { isDesktopRuntime } from '@/services/runtime'; import { proxyUrl } from '@/utils'; import { getPersistentCache, setPersistentCache } from './persistent-cache'; import { dataFreshness } from './data-freshness'; +import { nameToCountryCode, matchCountryNamesInText } from './country-geometry'; function advisoryFeedUrl(feedUrl: string): string { if (isDesktopRuntime()) return proxyUrl(feedUrl); @@ -29,6 +30,7 @@ interface AdvisoryFeed { sourceCountry: string; url: string; parseLevel?: (title: string) => SecurityAdvisory['level']; + targetCountry?: string; } const US_LEVEL_RE = /Level (\d)/i; @@ -96,19 +98,19 @@ const ADVISORY_FEEDS: AdvisoryFeed[] = [ url: 'https://www.gov.uk/foreign-travel-advice.atom', }, // US Embassy security alerts (per-country) - { name: 'US Embassy Thailand', sourceCountry: 'US', url: 'https://th.usembassy.gov/category/alert/feed/' }, - { name: 'US Embassy UAE', sourceCountry: 'US', url: 'https://ae.usembassy.gov/category/alert/feed/' }, - { name: 'US Embassy Germany', sourceCountry: 'US', url: 'https://de.usembassy.gov/category/alert/feed/' }, - { name: 'US Embassy Ukraine', sourceCountry: 'US', url: 'https://ua.usembassy.gov/category/alert/feed/' }, - { name: 'US Embassy Mexico', sourceCountry: 'US', url: 'https://mx.usembassy.gov/category/alert/feed/' }, - { name: 'US Embassy India', sourceCountry: 'US', url: 'https://in.usembassy.gov/category/alert/feed/' }, - { name: 'US Embassy Pakistan', sourceCountry: 'US', url: 'https://pk.usembassy.gov/category/alert/feed/' }, - { name: 'US Embassy Colombia', sourceCountry: 'US', url: 'https://co.usembassy.gov/category/alert/feed/' }, - { name: 'US Embassy Poland', sourceCountry: 'US', url: 'https://pl.usembassy.gov/category/alert/feed/' }, - { name: 'US Embassy Bangladesh', sourceCountry: 'US', url: 'https://bd.usembassy.gov/category/alert/feed/' }, - { name: 'US Embassy Italy', sourceCountry: 'US', url: 'https://it.usembassy.gov/category/alert/feed/' }, - { name: 'US Embassy Dominican Republic', sourceCountry: 'US', url: 'https://do.usembassy.gov/category/alert/feed/' }, - { name: 'US Embassy Myanmar', sourceCountry: 'US', url: 'https://mm.usembassy.gov/category/alert/feed/' }, + { name: 'US Embassy Thailand', sourceCountry: 'US', url: 'https://th.usembassy.gov/category/alert/feed/', targetCountry: 'TH' }, + { name: 'US Embassy UAE', sourceCountry: 'US', url: 'https://ae.usembassy.gov/category/alert/feed/', targetCountry: 'AE' }, + { name: 'US Embassy Germany', sourceCountry: 'US', url: 'https://de.usembassy.gov/category/alert/feed/', targetCountry: 'DE' }, + { name: 'US Embassy Ukraine', sourceCountry: 'US', url: 'https://ua.usembassy.gov/category/alert/feed/', targetCountry: 'UA' }, + { name: 'US Embassy Mexico', sourceCountry: 'US', url: 'https://mx.usembassy.gov/category/alert/feed/', targetCountry: 'MX' }, + { name: 'US Embassy India', sourceCountry: 'US', url: 'https://in.usembassy.gov/category/alert/feed/', targetCountry: 'IN' }, + { name: 'US Embassy Pakistan', sourceCountry: 'US', url: 'https://pk.usembassy.gov/category/alert/feed/', targetCountry: 'PK' }, + { name: 'US Embassy Colombia', sourceCountry: 'US', url: 'https://co.usembassy.gov/category/alert/feed/', targetCountry: 'CO' }, + { name: 'US Embassy Poland', sourceCountry: 'US', url: 'https://pl.usembassy.gov/category/alert/feed/', targetCountry: 'PL' }, + { name: 'US Embassy Bangladesh', sourceCountry: 'US', url: 'https://bd.usembassy.gov/category/alert/feed/', targetCountry: 'BD' }, + { name: 'US Embassy Italy', sourceCountry: 'US', url: 'https://it.usembassy.gov/category/alert/feed/', targetCountry: 'IT' }, + { name: 'US Embassy Dominican Republic', sourceCountry: 'US', url: 'https://do.usembassy.gov/category/alert/feed/', targetCountry: 'DO' }, + { name: 'US Embassy Myanmar', sourceCountry: 'US', url: 'https://mm.usembassy.gov/category/alert/feed/', targetCountry: 'MM' }, // Health advisories { name: 'CDC Travel Notices', sourceCountry: 'US', url: 'https://wwwnc.cdc.gov/travel/rss/notices.xml' }, { name: 'ECDC Epidemiological Updates', sourceCountry: 'EU', url: 'https://www.ecdc.europa.eu/en/taxonomy/term/1310/feed' }, @@ -120,6 +122,19 @@ const ADVISORY_FEEDS: AdvisoryFeed[] = [ { name: 'WHO Africa Emergencies', sourceCountry: 'INT', url: 'https://www.afro.who.int/rss/emergencies.xml' }, ]; +function extractTargetCountry(title: string, feed: AdvisoryFeed): string | undefined { + if (feed.targetCountry) return feed.targetCountry; + if (feed.sourceCountry === 'EU' || feed.sourceCountry === 'INT') return undefined; + const parts = title.split(/\s*[–—-]\s*/); + const firstPart = parts[0]; + if (parts.length >= 2 && firstPart) { + const code = nameToCountryCode(firstPart.trim().toLowerCase()); + if (code) return code; + } + const matches = matchCountryNamesInText(title.toLowerCase()); + return matches[0] || undefined; +} + const CACHE_KEY = 'security-advisories'; const CACHE_TTL = 15 * 60 * 1000; // 15 minutes let lastFetch = 0; @@ -157,6 +172,7 @@ function parseFeedXml( const pubDate = Number.isNaN(parsed.getTime()) ? new Date() : parsed; const level = feed.parseLevel ? feed.parseLevel(title) : 'info'; + const country = extractTargetCountry(title, feed); return { title, @@ -165,6 +181,7 @@ function parseFeedXml( source: feed.name, sourceCountry: feed.sourceCountry, level, + ...(country ? { country } : {}), }; }); } diff --git a/src/utils/export.ts b/src/utils/export.ts index 9b12962d1a2..0a76da55eb7 100644 --- a/src/utils/export.ts +++ b/src/utils/export.ts @@ -75,7 +75,7 @@ export interface CountryBriefExport { level?: string; trend?: string; components?: { unrest: number; conflict: number; security: number; information: number }; - signals?: Record; + signals?: Record; brief?: string; headlines?: Array<{ title: string; source: string; link: string; pubDate?: string }>; generatedAt: string;