amogaddy's picture
Integra World Monitor (AGPL-3.0, self-hosted) nello Space: pagina, menu, e arricchimento notizie per la AI (part 6)
fa9c65f verified
Raw
History Blame Contribute Delete
119 kB
import type { Monitor, PanelConfig, MapLayers } from '@/types';
import { normalizeExclusiveChoropleths } from '@/components/resilience-choropleth-utils';
import type { AppContext } from '@/app/app-context';
import {
REFRESH_INTERVALS,
DEFAULT_PANELS,
DEFAULT_MAP_LAYERS,
MOBILE_DEFAULT_MAP_LAYERS,
STORAGE_KEYS,
SITE_VARIANT,
ALL_PANELS,
VARIANT_DEFAULTS,
getEffectivePanelConfig,
enforceFreePanelLimit,
restoreFreeMapPanelAccess,
FREE_MAX_PANELS,
FREE_MAX_SOURCES,
} from '@/config';
import { sanitizeLayersForVariant } from '@/config/map-layer-definitions';
import type { MapVariant } from '@/config/map-layer-definitions';
import { getStoredMapModePreference } from '@/services/map-mode-preference';
import {
initDB,
cleanOldSnapshots,
isAisConfigured,
initAisStream,
isOutagesConfigured,
disconnectAisStream,
startFlightHistoryCleanup,
stopFlightHistoryCleanup,
} from '@/services';
import { enableVesselRuntime, stopLoadedVesselHistoryCleanup } from '@/services/military-vessels-lazy';
import { isProUser } from '@/services/widget-store';
import { mlWorker } from '@/services/ml-worker';
import { getAiFlowSettings, subscribeAiFlowChange, isHeadlineMemoryEnabled } from '@/services/ai-flow-settings';
import { startLearning } from '@/services/country-instability';
import { loadFromStorage, parseMapUrlState, saveToStorage, isMobileDevice, showToast } from '@/utils';
import { clearPanelSpans, invalidatePanelStorageCacheForKeys } from '@/utils/panel-storage';
import type { ParsedMapUrlState } from '@/utils';
import { BreakingNewsBanner } from '@/components/BreakingNewsBanner';
import { initBreakingNewsAlerts, destroyBreakingNewsAlerts } from '@/services/breaking-news-alerts';
import { markLcpDebug } from '@/utils/lcp-debug';
import type { ServiceStatusPanel } from '@/components/ServiceStatusPanel';
import type { MonitorPanel } from '@/components/MonitorPanel';
import type { StablecoinPanel } from '@/components/StablecoinPanel';
import type { EnergyCrisisPanel } from '@/components/EnergyCrisisPanel';
import type { ETFFlowsPanel } from '@/components/ETFFlowsPanel';
import type { MacroSignalsPanel } from '@/components/MacroSignalsPanel';
import type { FearGreedPanel } from '@/components/FearGreedPanel';
import type { HormuzPanel } from '@/components/HormuzPanel';
import type { StrategicPosturePanel } from '@/components/StrategicPosturePanel';
import type { StrategicRiskPanel } from '@/components/StrategicRiskPanel';
import type { GulfEconomiesPanel } from '@/components/GulfEconomiesPanel';
import type { GroceryBasketPanel } from '@/components/GroceryBasketPanel';
import type { BigMacPanel } from '@/components/BigMacPanel';
import type { FuelPricesPanel } from '@/components/FuelPricesPanel';
import type { FaoFoodPriceIndexPanel } from '@/components/FaoFoodPriceIndexPanel';
import type { OilInventoriesPanel } from '@/components/OilInventoriesPanel';
import type { PipelineStatusPanel } from '@/components/PipelineStatusPanel';
import type { StorageFacilityMapPanel } from '@/components/StorageFacilityMapPanel';
import type { FuelShortagePanel } from '@/components/FuelShortagePanel';
import type { EnergyDisruptionsPanel } from '@/components/EnergyDisruptionsPanel';
import type { EnergyRiskOverviewPanel } from '@/components/EnergyRiskOverviewPanel';
import type { ChokepointStripPanel } from '@/components/ChokepointStripPanel';
import type { ClimateNewsPanel } from '@/components/ClimateNewsPanel';
import type { ConsumerPricesPanel } from '@/components/ConsumerPricesPanel';
import type { DefensePatentsPanel } from '@/components/DefensePatentsPanel';
import type { MacroTilesPanel } from '@/components/MacroTilesPanel';
import type { FSIPanel } from '@/components/FSIPanel';
import type { YieldCurvePanel } from '@/components/YieldCurvePanel';
import type { EarningsCalendarPanel } from '@/components/EarningsCalendarPanel';
import type { EconomicCalendarPanel } from '@/components/EconomicCalendarPanel';
import type { CotPositioningPanel } from '@/components/CotPositioningPanel';
import type { LiquidityShiftsPanel } from '@/components/LiquidityShiftsPanel';
import type { PositioningPanel } from '@/components/PositioningPanel';
import type { GoldIntelligencePanel } from '@/components/GoldIntelligencePanel';
import { isDesktopRuntime, waitForSidecarReady } from '@/services/runtime';
import { hasPremiumAccess } from '@/services/panel-gating';
import { BETA_MODE } from '@/config/beta';
import { track, trackEvent, trackDeeplinkOpened, initAuthAnalytics } from '@/services/analytics';
import { preloadCountryGeometry, isCountryGeometryLoaded, getCountryNameByCode } from '@/services/country-geometry';
import { initI18n, t, I18N_RESOURCES_LOADED_EVENT, type I18nResourcesLoadedDetail } from '@/services/i18n';
import { initDeferredDashboardFonts } from '@/bootstrap/secondary-startup';
import { computeDefaultDisabledSources, getLocaleBoostedSources, getTotalFeedCount, FEEDS, INTEL_SOURCES } from '@/config/feeds';
import { selectSourcesUnderCap, findFullyDisabledCategories } from '@/services/source-cap';
import {
cancelBootstrapSlowTier,
fetchBootstrapData,
getBootstrapHydrationState,
markBootstrapAsLive,
waitForBootstrapSlowTier,
type BootstrapHydrationState,
} from '@/services/bootstrap';
import { ensureWmSession, installWmSessionFetchInterceptor, WM_SESSION_DEGRADED_EVENT } from '@/services/wm-session';
import { describeFreshness } from '@/services/persistent-cache';
import { DesktopUpdater } from '@/app/desktop-updater';
import { CountryIntelManager } from '@/app/country-intel';
import { registerWebMcpTools } from '@/services/webmcp';
import { refreshDataFreshnessFromHealth } from '@/services/health-freshness';
import { scheduleAfterFirstPaint } from '@/utils/after-paint';
import type { SearchManager } from '@/app/search-manager';
import { RefreshScheduler } from '@/app/refresh-scheduler';
import { PanelLayoutManager } from '@/app/panel-layout';
import { DataLoaderManager } from '@/app/data-loader';
import { EventHandlerManager } from '@/app/event-handlers';
import { replaceRawI18nKeyPlaceholders } from '@/app/i18n-raw-key-healer';
import { startAccountAuthHandoff } from '@/app/account-auth-handoff';
import { resolveUserRegion, resolvePreciseUserCoordinates, type PreciseCoordinates } from '@/utils/user-location';
import { showProBanner } from '@/components/ProBanner';
import { getAuthState, initAuthState, subscribeAuthState } from '@/services/auth-state';
import {
CLOUD_PREFS_APPLIED_EVENT,
install as installCloudPrefsSync,
onSignIn as cloudPrefsSignIn,
onSignOut as cloudPrefsSignOut,
type CloudPrefsAppliedDetail,
} from '@/utils/cloud-prefs-sync';
import {
getConvexClient,
getConvexApi,
invalidateConvexAuthForSignOut,
rebindConvexAuthForWatchHandoff,
waitForConvexAuthForUser,
} from '@/services/convex-client';
import {
assertAccountStillCurrent,
isAccountStillCurrent,
settleAccountOperation,
} from '@/services/account-operation';
import type { Id } from '../convex/_generated/dataModel';
import { initEntitlementSubscription, destroyEntitlementSubscription, resetEntitlementState, onEntitlementChange } from '@/services/entitlements';
import { initSubscriptionWatch, destroySubscriptionWatch } from '@/services/billing';
import {
FREE_TIER_FOLLOW_LIMIT,
WM_FOLLOWED_COUNTRIES_CAP_DROP,
installFollowedCountriesAuthListener,
} from '@/services/followed-countries';
import {
capturePendingCheckoutIntentFromUrl,
initCheckoutWatchers,
resumePendingCheckout,
} from '@/services/checkout';
import {
clearStoredAnonIdentity,
getFreshStoredAnonClaimToken,
getStoredAnonId,
} from '@/services/anonymous-identity-storage';
import { captureReferralFromUrl } from '@/services/referral-capture';
// CorrelationEngine + its 4 adapters are dynamic-imported at the post-loadAllData
// run site (#4486) so the engine bytes stay off the eager boot graph. The TYPE is
// referenced via the inline `import(...)` type in app-context.ts (erased at build).
import type { CorrelationPanel } from '@/components/CorrelationPanel';
const CYBER_LAYER_ENABLED = import.meta.env.VITE_ENABLE_CYBER_LAYER === 'true';
const FREE_MAP_PANEL_ACCESS_KEY = 'worldmonitor-free-map-panel-access-v1';
type SignalModalInstance = import('@/components/SignalModal').SignalModal;
export type { CountryBriefSignals } from '@/app/app-context';
export class App {
private state: AppContext;
private pendingDeepLinkCountry: string | null = null;
private pendingDeepLinkExpanded = false;
private pendingDeepLinkStoryCode: string | null = null;
private pendingDeepLinkChokepoint: string | null = null;
private chokepointDeepLinkTimer: number | null = null;
private panelLayout: PanelLayoutManager;
private dataLoader: DataLoaderManager;
private eventHandlers: EventHandlerManager;
private searchManager: SearchManager | null = null;
private searchManagerLoad: Promise<SearchManager> | null = null;
private signalModalLoad: Promise<SignalModalInstance> | null = null;
// Monotonic epoch: every openSearch() call supersedes earlier in-flight ones.
// searchToggleDesiredOpen accumulates the net intent of rapid Cmd+K presses
// while the lazy chunk loads (XOR: odd → open, even → cancel). (#4403 review)
private openSearchEpoch = 0;
private searchToggleDesiredOpen = false;
private latestSearchAdsb: Parameters<SearchManager['updateFlightSource']>[0] = [];
private latestSearchMilitary: Parameters<SearchManager['updateFlightSource']>[1] = [];
private countryIntel: CountryIntelManager;
private refreshScheduler: RefreshScheduler;
private desktopUpdater: DesktopUpdater;
private modules: { destroy(): void }[] = [];
private unsubAiFlow: (() => void) | null = null;
private unsubFreeTier: (() => void) | null = null;
private unsubEntitlementPremiumLoaders: (() => void) | null = null;
// Resolves once Phase-4 UI modules have initialised so WebMCP bindings can
// await readiness before touching nullable UI targets. Avoids the startup
// race where an agent
// discovers a tool via early registerTool and invokes it before the
// target panel exists.
private uiReady!: Promise<void>;
private resolveUiReady!: () => void;
// Returned by registerWebMcpTools when running in a registerTool-capable
// browser — aborting it unregisters every tool. destroy() triggers it
// so that test harnesses / same-document re-inits don't accumulate
// duplicate registrations.
private webMcpController: AbortController | null = null;
private visiblePanelPrimed = new Set<string>();
private visiblePanelPrimeRaf: number | null = null;
private followedCountriesCapDropToastTimer: number | null = null;
private bootstrapHydrationState: BootstrapHydrationState = getBootstrapHydrationState();
private cachedModeBannerEl: HTMLElement | null = null;
private readonly handleWmSessionDegraded = (): void => {
if (!this.state.isDestroyed) {
showToast('Anonymous data is temporarily unavailable. Check your cookie settings, then reload.');
}
};
private readonly handleViewportPrime = (): void => {
if (this.visiblePanelPrimeRaf !== null) return;
this.visiblePanelPrimeRaf = window.requestAnimationFrame(() => {
this.visiblePanelPrimeRaf = null;
void this.primeVisiblePanelData();
// loadAllData covers panels primeVisiblePanelData does not (news,
// markets, intelligence, fred, …). Now that bootstrap runs with
// forceAll=false, below-fold panels need this re-trigger on scroll
// so their data lands when they enter the viewport. Both are
// viewport-gated and inflight-guarded — repeat invocations are
// cheap.
void this.dataLoader.loadAllData();
});
};
private readonly handleConnectivityChange = (): void => {
this.updateConnectivityUi();
};
private readonly handleI18nResourcesLoaded = (ev: Event): void => {
const language = (ev as CustomEvent<I18nResourcesLoadedDetail>).detail?.language;
if (language !== 'en') return;
// Scope this to the app container: body-level modals are user-opened after
// startup, by which point the full English bundle should already be loaded.
replaceRawI18nKeyPlaceholders(this.state.container, t);
};
private readonly handleFollowedCountriesCapDrop = (ev: Event): void => {
const detail = (ev as CustomEvent<{ kept?: unknown; dropped?: unknown }>).detail;
const dropped = typeof detail?.dropped === 'number' ? detail.dropped : 0;
const kept = typeof detail?.kept === 'number' ? detail.kept : FREE_TIER_FOLLOW_LIMIT;
if (dropped <= 0) return;
this.showFollowedCountriesCapDropToast(kept, dropped);
};
private readonly handleCloudPrefsApplied = (ev: Event): void => {
const keys = (ev as CustomEvent<CloudPrefsAppliedDetail>).detail?.keys ?? [];
this.applyCloudSyncedPrefsToRuntime(keys);
};
private applyCloudSyncedPrefsToRuntime(keys: readonly string[]): void {
if (keys.length === 0) return;
const keySet = new Set(keys);
invalidatePanelStorageCacheForKeys(keys);
if (keySet.has(STORAGE_KEYS.panels)) {
this.state.panelSettings = loadFromStorage<Record<string, PanelConfig>>(
STORAGE_KEYS.panels,
this.state.panelSettings,
);
this.panelLayout.applyPanelSettings();
this.state.unifiedSettings?.refreshPanelToggles();
}
const panelOrderKey = this.state.PANEL_ORDER_KEY;
if (keySet.has(panelOrderKey) || keySet.has(`${panelOrderKey}-bottom-set`)) {
this.panelLayout.applySavedPanelOrder();
}
if (keySet.has(STORAGE_KEYS.mapLayers) && !this.state.initialUrlState?.layers) {
const nextLayers = normalizeExclusiveChoropleths(
sanitizeLayersForVariant(
loadFromStorage<MapLayers>(STORAGE_KEYS.mapLayers, this.state.mapLayers),
SITE_VARIANT as MapVariant,
),
this.state.mapLayers,
);
if (!CYBER_LAYER_ENABLED) nextLayers.cyberThreats = false;
this.state.mapLayers = nextLayers;
this.state.map?.setLayers(nextLayers);
this.dataLoader.syncDataFreshnessWithLayers();
}
if (keySet.has(STORAGE_KEYS.mapMode)) {
const mode = getStoredMapModePreference();
if (mode === 'globe') this.state.map?.switchToGlobe();
else this.state.map?.switchToFlat();
}
if (keySet.has(STORAGE_KEYS.disabledFeeds)) {
this.state.disabledSources = new Set(loadFromStorage<string[]>(STORAGE_KEYS.disabledFeeds, []));
}
if (keySet.has(STORAGE_KEYS.monitors)) {
this.state.monitors = loadFromStorage<Monitor[]>(STORAGE_KEYS.monitors, []);
const monitorPanel = this.state.panels['monitors'] as MonitorPanel | undefined;
monitorPanel?.setMonitors(this.state.monitors);
this.dataLoader.updateMonitorResults();
}
}
private isPanelNearViewport(panelId: string, marginPx = 400): boolean {
const panel = this.state.panels[panelId] as { isNearViewport?: (marginPx?: number) => boolean } | undefined;
return panel?.isNearViewport?.(marginPx) ?? false;
}
private isAnyPanelNearViewport(panelIds: string[], marginPx = 400): boolean {
return panelIds.some((panelId) => this.isPanelNearViewport(panelId, marginPx));
}
private shouldRefreshIntelligence(): boolean {
return this.isAnyPanelNearViewport(['cii', 'strategic-risk', 'strategic-posture'])
|| !!this.state.countryBriefPage?.isVisible();
}
private shouldRefreshFirms(): boolean {
return this.isPanelNearViewport('satellite-fires');
}
private shouldRefreshCorrelation(): boolean {
return this.isAnyPanelNearViewport(['military-correlation', 'escalation-correlation', 'economic-correlation', 'disaster-correlation']);
}
private getCachedBootstrapUpdatedAt(): number | null {
const cachedTierTimestamps = Object.values(this.bootstrapHydrationState.tiers)
.filter((tier) => tier.source === 'cached')
.map((tier) => tier.updatedAt)
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value));
if (cachedTierTimestamps.length === 0) return null;
return Math.min(...cachedTierTimestamps);
}
private updateConnectivityUi(): void {
const statusIndicator = this.state.container.querySelector('.status-indicator');
const statusLabel = statusIndicator?.querySelector('span:last-child');
const online = typeof navigator === 'undefined' ? true : navigator.onLine !== false;
// Only treat a complete cache fallback (no live data at all) as "cached" for UI purposes.
// 'mixed' means live data was partially fetched — showing "Live data unavailable" would be misleading.
const usingCachedBootstrap = this.bootstrapHydrationState.source === 'cached';
const cachedUpdatedAt = this.getCachedBootstrapUpdatedAt();
let statusMode: 'live' | 'cached' | 'unavailable' = 'live';
let bannerMessage: string | null = null;
if (!online) {
// Offline: show banner regardless of mixed/cached (any cached data is better than nothing)
const hasAnyCached = this.bootstrapHydrationState.source === 'cached' || this.bootstrapHydrationState.source === 'mixed';
if (hasAnyCached) {
statusMode = 'cached';
const offlineCachedAt = this.bootstrapHydrationState.tiers
? Math.min(...Object.values(this.bootstrapHydrationState.tiers)
.filter((tier) => tier.source === 'cached' || tier.source === 'mixed')
.map((tier) => tier.updatedAt)
.filter((v): v is number => typeof v === 'number' && Number.isFinite(v)))
: NaN;
const freshness = Number.isFinite(offlineCachedAt) ? describeFreshness(offlineCachedAt) : t('common.cached').toLowerCase();
bannerMessage = t('connectivity.offlineCached', { freshness });
} else {
statusMode = 'unavailable';
bannerMessage = t('connectivity.offlineUnavailable');
}
} else if (usingCachedBootstrap) {
statusMode = 'cached';
const freshness = cachedUpdatedAt ? describeFreshness(cachedUpdatedAt) : t('common.cached').toLowerCase();
bannerMessage = t('connectivity.cachedFallback', { freshness });
}
if (statusIndicator && statusLabel) {
statusIndicator.classList.toggle('status-indicator--cached', statusMode === 'cached');
statusIndicator.classList.toggle('status-indicator--unavailable', statusMode === 'unavailable');
statusLabel.textContent = statusMode === 'live'
? t('header.live')
: statusMode === 'cached'
? t('header.cached')
: t('header.unavailable');
}
if (bannerMessage) {
if (!this.cachedModeBannerEl) {
this.cachedModeBannerEl = document.createElement('div');
// CSS disables pointer events on this status-only container. Keep its descendants
// non-interactive unless the banner interaction model is updated with it.
this.cachedModeBannerEl.className = 'cached-mode-banner';
this.cachedModeBannerEl.setAttribute('role', 'status');
this.cachedModeBannerEl.setAttribute('aria-live', 'polite');
const badge = document.createElement('span');
badge.className = 'cached-mode-banner__badge';
const text = document.createElement('span');
text.className = 'cached-mode-banner__text';
this.cachedModeBannerEl.append(badge, text);
const header = this.state.container.querySelector('.header');
if (header?.parentElement) {
header.insertAdjacentElement('afterend', this.cachedModeBannerEl);
} else {
this.state.container.prepend(this.cachedModeBannerEl);
}
}
this.cachedModeBannerEl.classList.toggle('cached-mode-banner--unavailable', statusMode === 'unavailable');
const badge = this.cachedModeBannerEl.querySelector('.cached-mode-banner__badge')!;
const text = this.cachedModeBannerEl.querySelector('.cached-mode-banner__text')!;
badge.textContent = statusMode === 'cached' ? t('header.cached') : t('header.unavailable');
text.textContent = bannerMessage;
return;
}
this.cachedModeBannerEl?.remove();
this.cachedModeBannerEl = null;
}
private async primeVisiblePanelData(forceAll = false): Promise<void> {
const tasks: Promise<unknown>[] = [];
const primeTask = (key: string, task: () => Promise<unknown>): void => {
if (this.visiblePanelPrimed.has(key) || this.state.inFlight.has(key)) return;
const wrapped = (async () => {
this.state.inFlight.add(key);
try {
await task();
this.visiblePanelPrimed.add(key);
} finally {
this.state.inFlight.delete(key);
}
})();
tasks.push(wrapped);
};
const shouldPrime = (id: string): boolean => forceAll || this.isPanelNearViewport(id);
const shouldPrimeAny = (ids: string[]): boolean => forceAll || this.isAnyPanelNearViewport(ids);
if (shouldPrime('service-status')) {
const panel = this.state.panels['service-status'] as ServiceStatusPanel | undefined;
if (panel) primeTask('service-status', () => panel.fetchStatus());
}
if (shouldPrime('macro-signals')) {
const panel = this.state.panels['macro-signals'] as MacroSignalsPanel | undefined;
if (panel) primeTask('macro-signals', () => panel.fetchData());
}
if (shouldPrime('fear-greed')) {
const panel = this.state.panels['fear-greed'] as FearGreedPanel | undefined;
if (panel) primeTask('fear-greed', () => panel.fetchData());
}
if (shouldPrime('hormuz-tracker')) {
const panel = this.state.panels['hormuz-tracker'] as HormuzPanel | undefined;
if (panel) primeTask('hormuz-tracker', () => panel.fetchData());
}
if (shouldPrime('etf-flows')) {
const panel = this.state.panels['etf-flows'] as ETFFlowsPanel | undefined;
if (panel) primeTask('etf-flows', () => panel.fetchData());
}
if (shouldPrime('stablecoins')) {
const panel = this.state.panels.stablecoins as StablecoinPanel | undefined;
if (panel) primeTask('stablecoins', () => panel.fetchData());
}
if (shouldPrime('energy-crisis')) {
const panel = this.state.panels['energy-crisis'] as EnergyCrisisPanel | undefined;
if (panel) primeTask('energy-crisis', () => panel.fetchData());
}
if (shouldPrime('telegram-intel')) {
primeTask('telegram-intel', () => this.dataLoader.loadTelegramIntel());
}
if (shouldPrime('gulf-economies')) {
const panel = this.state.panels['gulf-economies'] as GulfEconomiesPanel | undefined;
if (panel) primeTask('gulf-economies', () => panel.fetchData());
}
if (shouldPrime('grocery-basket')) {
const panel = this.state.panels['grocery-basket'] as GroceryBasketPanel | undefined;
if (panel) primeTask('grocery-basket', () => panel.fetchData());
}
if (shouldPrime('bigmac')) {
const panel = this.state.panels['bigmac'] as BigMacPanel | undefined;
if (panel) primeTask('bigmac', () => panel.fetchData());
}
if (shouldPrime('fuel-prices')) {
const panel = this.state.panels['fuel-prices'] as FuelPricesPanel | undefined;
if (panel) primeTask('fuel-prices', () => panel.fetchData());
}
if (shouldPrime('fao-food-price-index')) {
const panel = this.state.panels['fao-food-price-index'] as FaoFoodPriceIndexPanel | undefined;
if (panel) primeTask('fao-food-price-index', () => panel.fetchData());
}
if (shouldPrime('oil-inventories')) {
const panel = this.state.panels['oil-inventories'] as OilInventoriesPanel | undefined;
if (panel) primeTask('oil-inventories', () => panel.fetchData());
}
// Energy Atlas panels — each self-fetches via bootstrap cache + RPC fallback
// (scripts/seed-pipelines-{gas,oil}.mjs, seed-storage-facilities.mjs,
// seed-fuel-shortages.mjs, seed-energy-disruptions.mjs). Without these
// primeTask wires the panels sit at showLoading() forever because
// Panel's constructor calls showLoading() but nothing else triggers
// fetchData() on attach — App.ts's primeTask table is the sole
// near-viewport kickoff path.
if (shouldPrime('pipeline-status')) {
const panel = this.state.panels['pipeline-status'] as PipelineStatusPanel | undefined;
if (panel) primeTask('pipeline-status', () => panel.fetchData());
}
if (shouldPrime('storage-facility-map')) {
const panel = this.state.panels['storage-facility-map'] as StorageFacilityMapPanel | undefined;
if (panel) primeTask('storage-facility-map', () => panel.fetchData());
}
if (shouldPrime('fuel-shortages')) {
const panel = this.state.panels['fuel-shortages'] as FuelShortagePanel | undefined;
if (panel) primeTask('fuel-shortages', () => panel.fetchData());
}
if (shouldPrime('energy-disruptions')) {
const panel = this.state.panels['energy-disruptions'] as EnergyDisruptionsPanel | undefined;
if (panel) primeTask('energy-disruptions', () => panel.fetchData());
}
if (shouldPrime('energy-risk-overview')) {
const panel = this.state.panels['energy-risk-overview'] as EnergyRiskOverviewPanel | undefined;
if (panel) primeTask('energy-risk-overview', () => panel.fetchData());
}
if (shouldPrime('chokepoint-strip')) {
// Without this primeTask entry the panel mounts via panel-layout.ts and
// ENERGY_PANELS but its constructor only calls showLoading() — fetchData()
// never fires, so the panel sits at "Loading..." forever. Hard-learned in
// PR #3386; tracked as skill panel-stuck-loading-means-missing-primetask.
const panel = this.state.panels['chokepoint-strip'] as ChokepointStripPanel | undefined;
if (panel) primeTask('chokepoint-strip', () => panel.fetchData());
}
if (shouldPrime('climate-news')) {
const panel = this.state.panels['climate-news'] as ClimateNewsPanel | undefined;
if (panel) primeTask('climate-news', () => panel.fetchData());
}
if (shouldPrime('consumer-prices')) {
const panel = this.state.panels['consumer-prices'] as ConsumerPricesPanel | undefined;
if (panel) primeTask('consumer-prices', () => panel.fetchData());
}
if (shouldPrime('defense-patents')) {
const panel = this.state.panels['defense-patents'] as DefensePatentsPanel | undefined;
if (panel) primeTask('defense-patents', () => { panel.refresh(); return Promise.resolve(); });
}
if (shouldPrime('macro-tiles')) {
const panel = this.state.panels['macro-tiles'] as MacroTilesPanel | undefined;
if (panel) primeTask('macro-tiles', () => panel.fetchData());
}
if (shouldPrime('fsi')) {
const panel = this.state.panels['fsi'] as FSIPanel | undefined;
if (panel) primeTask('fsi', () => panel.fetchData());
}
if (shouldPrime('yield-curve')) {
const panel = this.state.panels['yield-curve'] as YieldCurvePanel | undefined;
if (panel) primeTask('yield-curve', () => panel.fetchData());
}
if (shouldPrime('earnings-calendar')) {
const panel = this.state.panels['earnings-calendar'] as EarningsCalendarPanel | undefined;
if (panel) primeTask('earnings-calendar', () => panel.fetchData());
}
if (shouldPrime('economic-calendar')) {
const panel = this.state.panels['economic-calendar'] as EconomicCalendarPanel | undefined;
if (panel) primeTask('economic-calendar', () => panel.fetchData());
}
if (shouldPrime('cot-positioning')) {
const panel = this.state.panels['cot-positioning'] as CotPositioningPanel | undefined;
if (panel) primeTask('cot-positioning', () => panel.fetchData());
}
if (shouldPrime('liquidity-shifts')) {
const panel = this.state.panels['liquidity-shifts'] as LiquidityShiftsPanel | undefined;
if (panel) primeTask('liquidity-shifts', () => panel.fetchData());
}
if (shouldPrime('positioning-247')) {
const panel = this.state.panels['positioning-247'] as PositioningPanel | undefined;
if (panel) primeTask('positioning-247', () => panel.fetchData());
}
if (shouldPrime('gold-intelligence')) {
const panel = this.state.panels['gold-intelligence'] as GoldIntelligencePanel | undefined;
if (panel) primeTask('gold-intelligence', () => panel.fetchData());
}
if (shouldPrime('aaii-sentiment')) {
primeTask('aaiiSentiment', () => this.dataLoader.loadAaiiSentiment());
}
if (shouldPrime('market-breadth')) {
primeTask('marketBreadth', () => this.dataLoader.loadMarketBreadth());
}
if (shouldPrimeAny(['markets', 'heatmap', 'commodities', 'crypto', 'energy-complex'])) {
primeTask('markets', () => this.dataLoader.loadMarkets());
}
if (shouldPrime('polymarket')) {
primeTask('predictions', () => this.dataLoader.loadPredictions());
}
if (shouldPrime('economic')) {
primeTask('fred', () => this.dataLoader.loadFredData());
primeTask('spending', () => this.dataLoader.loadGovernmentSpending());
primeTask('bis', () => this.dataLoader.loadBisData());
}
if (shouldPrime('global-procurement') && hasPremiumAccess()) {
primeTask('global-tenders', () => this.dataLoader.loadGlobalTenders());
}
if (shouldPrime('energy-complex')) {
primeTask('oil', () => this.dataLoader.loadOilAnalytics());
}
// trade-policy moved into the _wmAccess block below — see fix for
// anonymous 401 bug where loadTradePolicy fired 6 PRO-gated RPCs
// unconditionally on every page load.
if (shouldPrime('supply-chain')) {
primeTask('supplyChain', () => this.dataLoader.loadSupplyChain());
}
if (shouldPrime('china-corridors')) {
primeTask('chinaCorridors', () => this.dataLoader.loadChinaCorridors());
}
if (shouldPrime('china-activity-nowcast')) {
primeTask('chinaActivityNowcast', () => this.dataLoader.loadChinaActivityNowcast());
}
if (shouldPrime('cross-source-signals')) {
primeTask('crossSourceSignals', () => this.dataLoader.loadCrossSourceSignals());
}
const _wmAccess = hasPremiumAccess();
if (_wmAccess) {
if (shouldPrime('trade-policy')) {
primeTask('tradePolicy', () => this.dataLoader.loadTradePolicy());
}
if (shouldPrime('stock-analysis')) {
primeTask('stockAnalysis', () => this.dataLoader.loadStockAnalysis());
}
if (shouldPrime('stock-backtest')) {
primeTask('stockBacktest', () => this.dataLoader.loadStockBacktest());
}
if (shouldPrime('daily-market-brief')) {
primeTask('dailyMarketBrief', () => this.dataLoader.loadDailyMarketBrief());
}
if (shouldPrime('market-implications')) {
primeTask('marketImplications', () => this.dataLoader.loadMarketImplications());
}
}
if (tasks.length > 0) {
await Promise.allSettled(tasks);
}
}
constructor(containerId: string) {
const el = document.getElementById(containerId);
if (!el) throw new Error(`Container ${containerId} not found`);
this.uiReady = new Promise<void>((resolve) => {
this.resolveUiReady = resolve;
});
const PANEL_ORDER_KEY = 'panel-order';
const PANEL_SPANS_KEY = 'worldmonitor-panel-spans';
const isMobile = isMobileDevice();
const isDesktopApp = isDesktopRuntime();
const monitors = loadFromStorage<Monitor[]>(STORAGE_KEYS.monitors, []);
// Use mobile-specific defaults on first load (no saved layers)
const defaultLayers = isMobile ? MOBILE_DEFAULT_MAP_LAYERS : DEFAULT_MAP_LAYERS;
let mapLayers: MapLayers;
let panelSettings: Record<string, PanelConfig>;
// Panels that must survive variant switches: desktop config, user-created widgets, MCP panels.
const isDynamicPanel = (k: string) => !ALL_PANELS[k] && (k === 'runtime-config' || k.startsWith('cw-') || k.startsWith('mcp-'));
const currentVariant = SITE_VARIANT;
let storedVariant: string | null = null;
let storageAvailable = true;
try {
storedVariant = localStorage.getItem('worldmonitor-variant');
const probeKey = 'wm-storage-capability-probe';
localStorage.setItem(probeKey, '1');
localStorage.removeItem(probeKey);
} catch {
storageAvailable = false;
}
// Blocked storage is a supported no-persistence mode. Seed the same
// defaults as a first visit and skip migrations that only mutate storage.
if (!storageAvailable) {
mapLayers = normalizeExclusiveChoropleths(
sanitizeLayersForVariant({ ...defaultLayers }, currentVariant as MapVariant), null,
);
panelSettings = { ...DEFAULT_PANELS };
} else if (storedVariant !== currentVariant) {
// Variant changed - reset all settings to variant defaults.
console.log(`[App] Variant check: stored="${storedVariant}", current="${currentVariant}"`);
// Variant changed — seed new variant's panels, disable panels not in the new variant
console.log('[App] Variant changed - seeding new defaults, disabling cross-variant panels');
localStorage.setItem('worldmonitor-variant', currentVariant);
// Reset map layers for the new variant (map layers are not user-personalized the same way)
localStorage.removeItem(STORAGE_KEYS.mapLayers);
mapLayers = normalizeExclusiveChoropleths(
sanitizeLayersForVariant({ ...defaultLayers }, currentVariant as MapVariant), null,
);
// Load existing panel prefs (if any), disable panels not belonging to the new variant
panelSettings = loadFromStorage<Record<string, PanelConfig>>(STORAGE_KEYS.panels, {});
const newVariantKeys = new Set(VARIANT_DEFAULTS[currentVariant] ?? []);
for (const key of Object.keys(panelSettings)) {
if (!newVariantKeys.has(key) && !isDynamicPanel(key) && panelSettings[key]) {
panelSettings[key] = { ...panelSettings[key]!, enabled: false };
}
}
for (const key of newVariantKeys) {
if (!(key in panelSettings)) {
panelSettings[key] = { ...getEffectivePanelConfig(key, currentVariant) };
}
}
} else {
mapLayers = normalizeExclusiveChoropleths(
sanitizeLayersForVariant(
loadFromStorage<MapLayers>(STORAGE_KEYS.mapLayers, defaultLayers),
currentVariant as MapVariant,
), null,
);
panelSettings = loadFromStorage<Record<string, PanelConfig>>(
STORAGE_KEYS.panels,
DEFAULT_PANELS
);
// One-time migration: preserve user preferences across panel key renames.
const PANEL_KEY_RENAMES_MIGRATION_KEY = 'worldmonitor-panel-key-renames-v2.6.8';
if (!localStorage.getItem(PANEL_KEY_RENAMES_MIGRATION_KEY)) {
let migrated = false;
const keyRenames: Array<[string, string]> = [
['live-youtube', 'live-webcams'],
['pinned-webcams', 'windy-webcams'],
...(SITE_VARIANT === 'finance' ? [['regulation', 'fin-regulation'] as [string, string]] : []),
];
// In non-finance variants, 'regulation' was dead config (no feeds). Just prune it.
if (SITE_VARIANT !== 'finance' && panelSettings['regulation']) {
delete panelSettings['regulation'];
migrated = true;
}
for (const [legacyKey, nextKey] of keyRenames) {
if (!panelSettings[legacyKey] || panelSettings[nextKey]) continue;
panelSettings[nextKey] = {
...DEFAULT_PANELS[nextKey],
...panelSettings[legacyKey],
name: DEFAULT_PANELS[nextKey]?.name ?? panelSettings[legacyKey].name,
};
delete panelSettings[legacyKey];
migrated = true;
}
// Also migrate saved panel order/bottom-set entries for renamed keys
for (const [legacyKey, nextKey] of keyRenames) {
for (const orderKey of [PANEL_ORDER_KEY, PANEL_ORDER_KEY + '-bottom-set', PANEL_ORDER_KEY + '-bottom']) {
try {
const raw = localStorage.getItem(orderKey);
if (!raw) continue;
const arr = JSON.parse(raw);
if (!Array.isArray(arr)) continue;
const idx = arr.indexOf(legacyKey);
if (idx !== -1) { arr[idx] = nextKey; localStorage.setItem(orderKey, JSON.stringify(arr)); migrated = true; }
} catch { /* corrupt storage, skip */ }
}
}
if (migrated) saveToStorage(STORAGE_KEYS.panels, panelSettings);
localStorage.setItem(PANEL_KEY_RENAMES_MIGRATION_KEY, 'done');
}
// Merge in any panels from ALL_PANELS that didn't exist when settings were saved
for (const key of Object.keys(ALL_PANELS)) {
if (!(key in panelSettings)) {
const config = getEffectivePanelConfig(key, SITE_VARIANT);
const isInVariant = (VARIANT_DEFAULTS[SITE_VARIANT] ?? []).includes(key);
panelSettings[key] = { ...config, enabled: isInVariant && config.enabled };
}
}
// One-time migration: expose all panels to existing users (previously variant-gated)
const UNIFIED_MIGRATION_KEY = 'worldmonitor-unified-panels-v1';
if (!localStorage.getItem(UNIFIED_MIGRATION_KEY)) {
const variantDefaults = new Set(VARIANT_DEFAULTS[SITE_VARIANT] ?? []);
for (const key of Object.keys(ALL_PANELS)) {
if (!(key in panelSettings)) {
const config = getEffectivePanelConfig(key, SITE_VARIANT);
panelSettings[key] = { ...config, enabled: variantDefaults.has(key) && config.enabled };
}
}
saveToStorage(STORAGE_KEYS.panels, panelSettings);
localStorage.setItem(UNIFIED_MIGRATION_KEY, 'done');
}
// One-time migration: fix happy variant sessions that got cross-variant panels enabled
// (regression from #1911 unified panel registry which failed to disable non-variant panels on variant switch)
const HAPPY_PANEL_FIX_KEY = 'worldmonitor-happy-panel-fix-v1';
if (SITE_VARIANT === 'happy' && !localStorage.getItem(HAPPY_PANEL_FIX_KEY)) {
const happyKeys = new Set(VARIANT_DEFAULTS['happy'] ?? []);
let fixed = false;
for (const key of Object.keys(panelSettings)) {
if (!happyKeys.has(key) && !isDynamicPanel(key) && panelSettings[key]?.enabled) {
panelSettings[key] = { ...panelSettings[key]!, enabled: false };
fixed = true;
}
}
if (fixed) saveToStorage(STORAGE_KEYS.panels, panelSettings);
localStorage.setItem(HAPPY_PANEL_FIX_KEY, 'done');
}
console.log('[App] Loaded panel settings from storage:', Object.entries(panelSettings).filter(([_, v]) => !v.enabled).map(([k]) => k));
// One-time migration: reorder panels for existing users (v1.9 panel layout)
const PANEL_ORDER_MIGRATION_KEY = 'worldmonitor-panel-order-v1.9';
if (!localStorage.getItem(PANEL_ORDER_MIGRATION_KEY)) {
const savedOrder = localStorage.getItem(PANEL_ORDER_KEY);
if (savedOrder) {
try {
const order: string[] = JSON.parse(savedOrder);
const priorityPanels = ['insights', 'strategic-posture', 'cii', 'strategic-risk'];
const filtered = order.filter(k => !priorityPanels.includes(k) && k !== 'live-news');
const liveNewsIdx = order.indexOf('live-news');
const newOrder = liveNewsIdx !== -1 ? ['live-news'] : [];
newOrder.push(...priorityPanels.filter(p => order.includes(p)));
newOrder.push(...filtered);
localStorage.setItem(PANEL_ORDER_KEY, JSON.stringify(newOrder));
console.log('[App] Migrated panel order to v1.9 layout');
} catch {
// Invalid saved order, will use defaults
}
}
localStorage.setItem(PANEL_ORDER_MIGRATION_KEY, 'done');
}
// Tech variant migration: move insights to top (after live-news)
if (currentVariant === 'tech') {
const TECH_INSIGHTS_MIGRATION_KEY = 'worldmonitor-tech-insights-top-v1';
if (!localStorage.getItem(TECH_INSIGHTS_MIGRATION_KEY)) {
const savedOrder = localStorage.getItem(PANEL_ORDER_KEY);
if (savedOrder) {
try {
const order: string[] = JSON.parse(savedOrder);
const filtered = order.filter(k => k !== 'insights' && k !== 'live-news');
const newOrder: string[] = [];
if (order.includes('live-news')) newOrder.push('live-news');
if (order.includes('insights')) newOrder.push('insights');
newOrder.push(...filtered);
localStorage.setItem(PANEL_ORDER_KEY, JSON.stringify(newOrder));
console.log('[App] Tech variant: Migrated insights panel to top');
} catch {
// Invalid saved order, will use defaults
}
}
localStorage.setItem(TECH_INSIGHTS_MIGRATION_KEY, 'done');
}
}
}
if (storageAvailable) {
// One-time migration: prune removed panel keys from stored settings and order
const PANEL_PRUNE_KEY = 'worldmonitor-panel-prune-v1';
if (!localStorage.getItem(PANEL_PRUNE_KEY)) {
const validKeys = new Set(Object.keys(ALL_PANELS));
let pruned = false;
for (const key of Object.keys(panelSettings)) {
if (!validKeys.has(key) && key !== 'runtime-config') {
delete panelSettings[key];
pruned = true;
}
}
if (pruned) saveToStorage(STORAGE_KEYS.panels, panelSettings);
for (const orderKey of [PANEL_ORDER_KEY, PANEL_ORDER_KEY + '-bottom-set', PANEL_ORDER_KEY + '-bottom']) {
try {
const raw = localStorage.getItem(orderKey);
if (!raw) continue;
const arr = JSON.parse(raw);
if (!Array.isArray(arr)) continue;
const filtered = arr.filter((k: string) => validKeys.has(k));
if (filtered.length !== arr.length) localStorage.setItem(orderKey, JSON.stringify(filtered));
} catch { localStorage.removeItem(orderKey); }
}
localStorage.setItem(PANEL_PRUNE_KEY, 'done');
}
// One-time migration: clear stale panel ordering and sizing state
const LAYOUT_RESET_MIGRATION_KEY = 'worldmonitor-layout-reset-v2.5';
if (!localStorage.getItem(LAYOUT_RESET_MIGRATION_KEY)) {
const hadSavedOrder = !!localStorage.getItem(PANEL_ORDER_KEY);
const hadSavedSpans = !!localStorage.getItem(PANEL_SPANS_KEY);
if (hadSavedOrder || hadSavedSpans) {
localStorage.removeItem(PANEL_ORDER_KEY);
localStorage.removeItem(PANEL_ORDER_KEY + '-bottom');
localStorage.removeItem(PANEL_ORDER_KEY + '-bottom-set');
clearPanelSpans();
console.log('[App] Applied layout reset migration (v2.5): cleared panel order/spans');
}
localStorage.setItem(LAYOUT_RESET_MIGRATION_KEY, 'done');
}
}
// Desktop key management panel must always remain accessible in Tauri.
if (isDesktopApp) {
if (!panelSettings['runtime-config'] || !panelSettings['runtime-config'].enabled) {
panelSettings['runtime-config'] = {
...panelSettings['runtime-config'],
name: panelSettings['runtime-config']?.name ?? 'Desktop Configuration',
enabled: true,
priority: panelSettings['runtime-config']?.priority ?? 2,
};
saveToStorage(STORAGE_KEYS.panels, panelSettings);
}
}
const initialUrlState: ParsedMapUrlState | null = parseMapUrlState(window.location.search, mapLayers);
if (initialUrlState.layers) {
mapLayers = normalizeExclusiveChoropleths(
sanitizeLayersForVariant(initialUrlState.layers, currentVariant as MapVariant), null,
);
initialUrlState.layers = mapLayers;
}
if (!CYBER_LAYER_ENABLED) {
mapLayers.cyberThreats = false;
}
// One-time migration: reduce default-enabled sources (full variant only)
if (currentVariant === 'full' && storageAvailable) {
const baseKey = 'worldmonitor-sources-reduction-v3';
if (!localStorage.getItem(baseKey)) {
const defaultDisabled = computeDefaultDisabledSources();
saveToStorage(STORAGE_KEYS.disabledFeeds, defaultDisabled);
localStorage.setItem(baseKey, 'done');
const total = getTotalFeedCount();
console.log(`[App] Sources reduction: ${defaultDisabled.length} disabled, ${total - defaultDisabled.length} enabled`);
}
// Locale boost: additively enable locale-matched sources (runs once per locale).
// Reads the explicit-choice key (`wm-locale-explicit`, written by Settings →
// Language) before falling back to navigator. Mirrors the i18n.ts:99
// `wmExplicit` detector — without this, a user whose browser is en-US who
// picks Magyar in Settings never gets the locale boost (the migration's
// first run with `userLang='en'` sets `worldmonitor-locale-boost-en` and
// the `userLang !== 'en'` short-circuit means the boost block never re-fires
// for any subsequent locale choice). Direct localStorage read because
// i18next isn't initialized yet here in the constructor — `initI18n()` is
// called later inside `init()`.
let explicitLocale = '';
try { explicitLocale = localStorage.getItem('wm-locale-explicit') || ''; } catch { /* private mode */ }
const userLang = ((explicitLocale || navigator.language || 'en').split('-')[0] ?? 'en').toLowerCase();
const localeKey = `worldmonitor-locale-boost-${userLang}`;
if (userLang !== 'en' && !localStorage.getItem(localeKey)) {
const boosted = getLocaleBoostedSources(userLang);
if (boosted.size > 0) {
const current = loadFromStorage<string[]>(STORAGE_KEYS.disabledFeeds, []);
const updated = current.filter(name => !boosted.has(name));
saveToStorage(STORAGE_KEYS.disabledFeeds, updated);
console.log(`[App] Locale boost (${userLang}): enabled ${current.length - updated.length} sources`);
}
localStorage.setItem(localeKey, 'done');
}
}
const disabledSources = new Set(loadFromStorage<string[]>(STORAGE_KEYS.disabledFeeds, []));
// Build shared state object
this.state = {
map: null,
isMobile,
isDesktopApp,
container: el,
panels: {},
newsPanels: {},
newsCategoryPanelKeys: new Map(),
panelSettings,
mapLayers,
allNews: [],
newsByCategory: {},
latestMarkets: [],
latestPredictions: [],
latestTechEvents: [],
latestClusters: [],
intelligenceCache: {},
cyberThreatsCache: null,
disabledSources,
currentTimeRange: '7d',
inFlight: new Set(),
seenGeoAlerts: new Set(),
monitors,
signalModal: null,
ensureSignalModal: () => this.ensureSignalModal(),
statusPanel: null,
searchModal: null,
findingsBadge: null,
breakingBanner: null,
playbackControl: null,
exportPanel: null,
unifiedSettings: null,
pizzintIndicator: null,
correlationEngine: null,
llmStatusIndicator: null,
countryBriefPage: null,
countryTimeline: null,
positivePanel: null,
countersPanel: null,
progressPanel: null,
breakthroughsPanel: null,
heroPanel: null,
digestPanel: null,
speciesPanel: null,
renewablePanel: null,
authModal: null,
authHeaderWidget: null,
tvMode: null,
happyAllItems: [],
isDestroyed: false,
isPlaybackMode: false,
isIdle: false,
initialLoadComplete: false,
resolvedLocation: 'global',
activeChokepoint: initialUrlState.chokepoint ?? null,
initialUrlState,
PANEL_ORDER_KEY,
PANEL_SPANS_KEY,
};
// Instantiate modules (callbacks wired after all modules exist)
this.refreshScheduler = new RefreshScheduler(this.state);
this.countryIntel = new CountryIntelManager(this.state);
this.desktopUpdater = new DesktopUpdater(this.state);
this.dataLoader = new DataLoaderManager(this.state, {
renderCriticalBanner: (postures) => this.panelLayout.renderCriticalBanner(postures),
refreshOpenCountryBrief: () => this.countryIntel.refreshOpenBrief(),
});
this.panelLayout = new PanelLayoutManager(this.state, {
openCountryStory: (code, name) => {
void this.countryIntel.openCountryStory(code, name).catch((err) => {
console.error('[CountryStory] Failed to open story:', err);
showToast('Country story failed to open. Please try again.');
});
},
openCountryBrief: (code) => {
const name = CountryIntelManager.resolveCountryName(code);
void this.countryIntel.openCountryBriefByCode(code, name).catch((err) => {
console.error('[CountryBrief] Failed to open country brief:', err);
this.state.map?.setRenderPaused(false);
showToast('Country brief failed to open. Please try again.');
});
},
openSearch: () => {
track('search-open', { source: 'pro-onboarding' });
void this.openSearch();
},
loadAllData: () => this.dataLoader.loadAllData(),
updateMonitorResults: () => this.dataLoader.updateMonitorResults(),
loadSecurityAdvisories: () => this.dataLoader.loadSecurityAdvisories(),
applyMapLayerChange: (layer, enabled, source) => this.eventHandlers.applyMapLayerChange(layer, enabled, source),
});
this.eventHandlers = new EventHandlerManager(this.state, {
openSearch: (options) => { void this.openSearch(options); },
updateSearchIndex: () => this.updateSearchIndexIfReady(),
loadAllData: () => this.dataLoader.loadAllData(),
invalidateNewsHydration: () => this.dataLoader.invalidateNewsHydration(),
flushStaleRefreshes: () => this.refreshScheduler.flushStaleRefreshes(),
setHiddenSince: (ts) => this.refreshScheduler.setHiddenSince(ts),
loadDataForLayer: (layer) => { void this.dataLoader.loadDataForLayer(layer as keyof MapLayers); },
waitForAisData: () => this.dataLoader.waitForAisData(),
syncDataFreshnessWithLayers: () => this.dataLoader.syncDataFreshnessWithLayers(),
ensureCorrectZones: () => this.panelLayout.ensureCorrectZones(),
applySavedPanelOrder: (panelOrder?: string[]) => this.panelLayout.applySavedPanelOrder(panelOrder),
refreshCiiAfterFocalPointsReady: () => this.dataLoader.refreshCiiAfterFocalPointsReady(),
stopLayerActivity: (layer) => this.dataLoader.stopLayerActivity(layer),
mountLiveNewsIfReady: () => this.panelLayout.mountLiveNewsIfReady(),
updateFlightSource: (adsb, military) => this.updateFlightSourceIfReady(adsb, military),
});
// Wire cross-module callback: DataLoader → SearchManager
this.dataLoader.updateSearchIndex = () => this.updateSearchIndexIfReady();
// Track destroy order (reverse of init)
this.modules = [
this.desktopUpdater,
this.panelLayout,
this.countryIntel,
this.dataLoader,
this.refreshScheduler,
this.eventHandlers,
];
}
private ensureSignalModal(): Promise<SignalModalInstance> {
if (this.state.signalModal) return Promise.resolve(this.state.signalModal);
if (this.signalModalLoad) return this.signalModalLoad;
this.signalModalLoad = import('@/components/SignalModal')
.then(({ SignalModal }) => {
if (this.state.isDestroyed) {
throw new Error('App destroyed before signal modal loaded');
}
const signalModal = new SignalModal();
signalModal.setLocationClickHandler((lat, lon) => {
this.state.map?.setCenter(lat, lon, 4);
});
this.state.signalModal = signalModal;
return signalModal;
})
.catch((err) => {
this.signalModalLoad = null;
throw err;
});
return this.signalModalLoad;
}
private ensureSearchManager(): Promise<SearchManager> {
if (this.searchManager) return Promise.resolve(this.searchManager);
if (this.searchManagerLoad) return this.searchManagerLoad;
this.searchManagerLoad = import('@/app/search-manager')
.then(({ SearchManager }) => {
if (this.state.isDestroyed) {
throw new Error('App destroyed before search manager loaded');
}
const manager = new SearchManager(this.state, {
openCountryBriefByCode: (code, country) => {
void this.countryIntel.openCountryBriefByCode(code, country).catch((err) => {
console.error('[CountryBrief] Failed to open country brief:', err);
this.state.map?.setRenderPaused(false);
showToast('Country brief failed to open. Please try again.');
});
},
enablePanel: (panelId) => this.eventHandlers.enablePanelById(panelId),
});
manager.init();
manager.updateFlightSource(this.latestSearchAdsb, this.latestSearchMilitary);
this.searchManager = manager;
this.modules.push(manager);
return manager;
})
.finally(() => {
this.searchManagerLoad = null;
});
return this.searchManagerLoad;
}
private updateSearchIndexIfReady(): void {
this.searchManager?.updateSearchIndex();
}
private updateFlightSourceIfReady(
adsb: Parameters<SearchManager['updateFlightSource']>[0],
military: Parameters<SearchManager['updateFlightSource']>[1],
): void {
this.latestSearchAdsb = adsb;
this.latestSearchMilitary = military;
this.searchManager?.updateFlightSource(adsb, military);
}
private async openSearch(options: { toggle?: boolean; throwOnFailure?: boolean } = {}): Promise<void> {
// Concurrency model: each press registers its intent, then claims a
// monotonic epoch. After the lazy load resolves, only the latest epoch acts
// — superseded presses bail. This yields one deterministic modal.open() for
// any Cmd+K / button interleaving during the first load (replacing the prior
// two-field pending-toggle bookkeeping), while preserving net-toggle parity:
// the XOR flip happens BEFORE the epoch claim so every rapid Cmd+K still
// counts (odd → open, even → cancel), even the ones that get superseded.
let epoch = this.openSearchEpoch;
try {
await this.waitForUiReady();
const existingModal = this.state.searchModal;
if (options.toggle && existingModal?.isOpen()) {
existingModal.close();
return;
}
const togglingBeforeLoad = Boolean(options.toggle) && !this.searchManager;
if (togglingBeforeLoad) {
this.searchToggleDesiredOpen = !this.searchToggleDesiredOpen;
}
epoch = ++this.openSearchEpoch;
const manager = await this.ensureSearchManager();
if (this.openSearchEpoch !== epoch) return;
const wantOpen = togglingBeforeLoad ? this.searchToggleDesiredOpen : true;
if (!wantOpen) return;
manager.updateSearchIndex();
const modal = this.state.searchModal;
if (!modal) throw new Error('Search modal is not initialised');
modal.open();
} catch (error) {
if (!this.state.isDestroyed) {
console.warn('[search] Failed to load search manager:', error);
if (!options.throwOnFailure) showToast('Search failed to load. Please try again.');
}
if (options.throwOnFailure) throw error;
} finally {
// Reset the toggle accumulator once the latest press settles.
if (this.openSearchEpoch === epoch) this.searchToggleDesiredOpen = false;
}
}
private async waitForSlowBootstrapCheckpoint(): Promise<void> {
markLcpDebug('wm:data:slow-tier-wait-start');
try {
const settled = await waitForBootstrapSlowTier(isDesktopRuntime() ? 8_500 : 3_500);
markLcpDebug('wm:data:slow-tier-wait-end', { settled });
if (this.state.isDestroyed) return;
this.bootstrapHydrationState = getBootstrapHydrationState();
this.updateConnectivityUi();
} catch {
markLcpDebug('wm:data:slow-tier-wait-error');
}
}
private async preloadCountryGeometryForPostLcpWork(): Promise<void> {
markLcpDebug('wm:data:country-geometry-start');
try {
await preloadCountryGeometry();
markLcpDebug('wm:data:country-geometry-ready');
} catch {
markLcpDebug('wm:data:country-geometry-error');
}
}
private startPostLcpIntelligence(countryGeometryReady: Promise<void>, geometryAlreadyApplied: boolean): void {
void countryGeometryReady.finally(() => {
if (this.state.isDestroyed) return;
// Replay geometry-dependent CII only when the fan-out ingested before
// precision geometry was ready; otherwise the first-pass attribution is
// already correct and a replay is a redundant compute + repaint (#4512).
if (!geometryAlreadyApplied) {
this.dataLoader.refreshGeometryDependentCiiAfterCountryGeometry();
}
// Correlation and country-learning use precision geometry/name matching,
// but they are post-initial-data work and should not hold the LCP path.
void this.loadInitialCorrelationEngine();
startLearning();
});
}
private async loadInitialCorrelationEngine(): Promise<void> {
try {
const {
CorrelationEngine,
militaryAdapter,
escalationAdapter,
economicAdapter,
disasterAdapter,
} = await import('@/services/correlation-engine');
if (this.state.isDestroyed) return;
const engine = new CorrelationEngine();
engine.registerAdapter(militaryAdapter);
engine.registerAdapter(escalationAdapter);
engine.registerAdapter(economicAdapter);
engine.registerAdapter(disasterAdapter);
this.state.correlationEngine = engine;
await engine.run(this.state);
if (this.state.isDestroyed) return;
for (const domain of ['military', 'escalation', 'economic', 'disaster'] as const) {
const panel = this.state.panels[`${domain}-correlation`] as CorrelationPanel | undefined;
panel?.updateCards(engine.getCards(domain));
}
} catch (error) {
console.warn('[CorrelationEngine] Initial lazy load/run failed:', error);
}
}
public async init(): Promise<void> {
const initStart = performance.now();
markLcpDebug('wm:boot:app-init-start');
// WebMCP — register synchronously before any init awaits so agent
// scanners (isitagentready.com, in-browser agents) find the tools on
// their first probe. No-op in browsers without navigator.modelContext.
// Bindings await `this.uiReady` (resolves after Phase-4 UI init) so
// a tool invoked during the startup window waits for the target
// panel to exist instead of throwing. A 10s timeout keeps a genuinely
// broken state from hanging the caller. Store the returned controller
// so destroy() can unregister every tool on teardown.
this.webMcpController = registerWebMcpTools({
openCountryBriefByCode: async (code, country) => {
await this.waitForUiReady();
if (!this.state.countryBriefPage) {
throw new Error('Country brief panel is not initialised');
}
await this.countryIntel.openCountryBriefByCode(code, country);
},
resolveCountryName: (code) => CountryIntelManager.resolveCountryName(code),
openSearch: async () => {
// openSearch() awaits UI readiness internally and throws on failure when
// throwOnFailure is set, so the agent receives a real success/failure.
// (Re-checking searchModal here would spuriously throw if a concurrent
// Cmd+K closed it between open and the check — #4403 review ADV-4.)
await this.openSearch({ throwOnFailure: true });
},
});
window.addEventListener(I18N_RESOURCES_LOADED_EVENT, this.handleI18nResourcesLoaded);
await initDB();
startFlightHistoryCleanup();
// Re-arm the lazy vessel runtime (a no-op on first boot; matters on a
// same-document re-init after a prior App.destroy() disarmed it). The
// history-cleanup interval itself still starts lazily on first vessel use.
enableVesselRuntime();
await initI18n();
markLcpDebug('wm:boot:i18n-ready');
initDeferredDashboardFonts();
// Localize the static index.html shell — <title>, meta description, and
// the accessible <h1> are baked in English before the app boots; once i18n
// is ready we swap them to the user's locale.
document.title = t('shell.documentTitle');
const setMeta = (sel: string, val: string) => {
const el = document.querySelector(sel);
if (el) el.setAttribute('content', val);
};
setMeta('meta[name="description"]', t('shell.metaDescription'));
setMeta('meta[property="og:title"]', t('shell.documentTitle'));
setMeta('meta[property="og:description"]', t('shell.metaDescription'));
setMeta('meta[name="twitter:title"]', t('shell.documentTitle'));
setMeta('meta[name="twitter:description"]', t('shell.metaDescription'));
// Mirror of OG_LOCALE in pro-test/src/i18n.ts. The two packages have
// separate Vite roots and bundlers and can't share an import — keep the
// tables aligned by hand when adding a locale here OR there.
const ogLocaleMap: Record<string, string> = {
en: 'en_US', bg: 'bg_BG', cs: 'cs_CZ', fr: 'fr_FR', de: 'de_DE', el: 'el_GR',
es: 'es_ES', hr: 'hr_HR', hu: 'hu_HU', it: 'it_IT', pl: 'pl_PL', pt: 'pt_BR',
nl: 'nl_NL', sv: 'sv_SE', ru: 'ru_RU', ar: 'ar_SA', fa: 'fa_IR', zh: 'zh_CN',
ja: 'ja_JP', ko: 'ko_KR', ro: 'ro_RO', tr: 'tr_TR', th: 'th_TH', vi: 'vi_VN',
hi: 'hi_IN',
};
const baseLang = (document.documentElement.lang || 'en').split('-')[0] || 'en';
setMeta('meta[property="og:locale"]', ogLocaleMap[baseLang] || `${baseLang}_${baseLang.toUpperCase()}`);
const srH1 = document.querySelector('body > h1');
if (srH1) srH1.textContent = t('shell.documentTitle');
const aiFlow = getAiFlowSettings();
if (aiFlow.browserModel || isDesktopRuntime()) {
await mlWorker.init();
if (BETA_MODE) mlWorker.loadModel('summarization-beta').catch(() => { });
}
// Headline Memory requires Browser Local Model to be ON — `isHeadlineMemoryEnabled()`
// ANDs both flags. Without this gate, leaving Headline Memory on while turning
// Browser Local Model off would silently download/run an embeddings model the user
// opted out of via the parent toggle.
if (isHeadlineMemoryEnabled()) {
mlWorker.init().then(ok => {
if (ok) mlWorker.loadModel('embeddings').catch(() => { });
}).catch(() => { });
}
this.unsubAiFlow = subscribeAiFlowChange((key) => {
if (key === 'browserModel') {
const s = getAiFlowSettings();
if (s.browserModel) {
mlWorker.init().then(ok => {
// Re-honor Headline Memory's persisted value on parent re-enable.
if (ok && isHeadlineMemoryEnabled()) {
mlWorker.loadModel('embeddings').catch(() => { });
}
}).catch(() => { });
} else if (!isDesktopRuntime()) {
// Browser Local Model is the parent toggle for ALL local-model use,
// including Headline Memory. Terminate unconditionally on web —
// any persisted Headline Memory value is now non-effective.
mlWorker.terminate();
}
}
if (key === 'headlineMemory') {
if (isHeadlineMemoryEnabled()) {
mlWorker.init().then(ok => {
if (ok) mlWorker.loadModel('embeddings').catch(() => { });
}).catch(() => { });
} else {
mlWorker.unloadModel('embeddings').catch(() => { });
const s = getAiFlowSettings();
if (!s.browserModel && !isDesktopRuntime()) {
mlWorker.terminate();
}
}
}
});
// Check AIS configuration before init
if (!isAisConfigured()) {
this.state.mapLayers.ais = false;
} else if (this.state.mapLayers.ais) {
initAisStream();
}
// Wait for sidecar readiness on desktop so bootstrap hits a live server
if (isDesktopRuntime()) {
await waitForSidecarReady(3000);
markLcpDebug('wm:boot:sidecar-ready');
}
// Anonymous browser session token (issue #3541). Server's validateApiKey
// no longer trusts header-only signals (Origin / Referer / Sec-Fetch-Site
// are all forgeable). Install a fetch interceptor ONCE, then mint a
// wms_-prefixed HMAC token before the first API call. Desktop has its own
// API key path and doesn't need this; Clerk-authenticated users will pass
// their JWT in a Bearer header and the interceptor steps aside.
if (!isDesktopRuntime()) {
window.addEventListener(WM_SESSION_DEGRADED_EVENT, this.handleWmSessionDegraded);
installWmSessionFetchInterceptor();
await ensureWmSession();
markLcpDebug('wm:boot:session-ready');
}
// Hydrate in-memory cache from bootstrap endpoint. Awaits only the fast tier; the slow
// tier loads in the background (off the first-paint critical path, #4488) and calls back
// when it lands so the connectivity indicator re-snapshots (no reactive emitter exists).
await fetchBootstrapData(() => {
if (this.state.isDestroyed) return;
this.bootstrapHydrationState = getBootstrapHydrationState();
this.updateConnectivityUi();
});
markLcpDebug('wm:boot:fast-bootstrap-ready');
this.bootstrapHydrationState = getBootstrapHydrationState();
// Verify OAuth OTT and hydrate auth session BEFORE any UI subscribes to auth state
await initAuthState();
initAuthAnalytics();
installCloudPrefsSync(SITE_VARIANT);
window.addEventListener(CLOUD_PREFS_APPLIED_EVENT, this.handleCloudPrefsApplied);
// Install the followed-countries auth listener once. Drives the
// anon→signed-in handoff (mergeAnonymousLocal mutation) and sign-out
// cleanup. Idempotent.
installFollowedCountriesAuthListener();
window.addEventListener(WM_FOLLOWED_COUNTRIES_CAP_DROP, this.handleFollowedCountriesCapDrop);
this.enforceFreeTierLimits();
let _prevUserId: string | null = null;
let _convexWatchHandoffGeneration = 0;
// Track the last-seen PRO entitlement so we can re-fire PRO-gated loaders
// ONCE on a false→true transition (user signs in / purchase lands mid-session).
// Without this, loaders gated behind hasPremiumAccess() at init time (e.g.
// loadTradePolicy) would sit empty until the next scheduled refresh — for
// trade-policy that's a 10-minute wait post-sign-in. See PR #3295 review.
let _prevHadPremium = hasPremiumAccess();
// Pro-loader fan-out runs on EITHER Clerk auth changes OR Convex
// entitlement changes — Pro can come from either signal (Clerk
// user.role === 'pro' OR Convex tier >= 1 via Dodo). User-reported
// on commodity.worldmonitor.app: Trade Policy panel stuck at "Loading…"
// for a Pro Monthly subscriber because the original listener only
// watched subscribeAuthState (Clerk-only); Convex Free→Pro transitions
// never re-fired loadTradePolicy. Same root cause as PR #3409 layer-unlock.
const firePremiumLoaders = (): void => {
this.enforceFreeTierLimits();
const hadPremium = _prevHadPremium;
const nowPremium = hasPremiumAccess();
if (nowPremium && !hadPremium) {
// Entitlement just resolved → fire PRO-gated initial loads that were
// skipped at boot. Each loader early-returns if the panel isn't
// mounted and re-checks hasPremiumAccess() internally, so these
// calls are safe and idempotent. Without this, panels would sit empty
// until the next scheduled refresh (10+ min for trade-policy; FOREVER
// on the full variant for stock-analysis / stock-backtest / daily-
// market-brief / market-implications because their schedulers are
// gated to SITE_VARIANT === 'finance'). The audit-locking regression
// test in tests/premium-loaders-fan-out-coverage.test.mts asserts
// every `hasPremiumAccess() && shouldLoad('X')` gate in data-loader.ts
// has a matching call here.
void this.dataLoader.loadTradePolicy();
void this.dataLoader.loadStockAnalysis();
void this.dataLoader.loadStockBacktest();
void this.dataLoader.loadDailyMarketBrief();
void this.dataLoader.loadMarketImplications();
void this.dataLoader.loadWsbTickers();
void this.dataLoader.loadResilienceRanking();
void this.dataLoader.loadGlobalTenders();
} else if (!nowPremium && hadPremium) {
// Pro data must not remain visible or available from the client cache
// after sign-out, expiry, or downgrade.
void this.dataLoader.clearGlobalTenders();
}
_prevHadPremium = nowPremium;
};
this.unsubEntitlementPremiumLoaders = onEntitlementChange(() => firePremiumLoaders());
this.unsubFreeTier = subscribeAuthState((session) => {
firePremiumLoaders();
const userId = session.user?.id ?? null;
if (userId !== null && userId !== _prevUserId) {
const handoffGeneration = ++_convexWatchHandoffGeneration;
// Rebind Convex watches to the real Clerk userId (was bound to anon UUID at init)
// destroyEntitlementSubscription deliberately PRESERVES the last
// snapshot so a WebSocket reconnect doesn't flash paying users back to
// locked. That preservation is wrong across an account change: until
// the new user's first snapshot lands, getEntitlementState() still
// describes the previous one. Anything reading it then attributes A's
// plan to B — e.g. premium-denial's clientBelievesPro would read B's
// legitimate 403 as A's entitlement desync and retry instead of
// showing the upgrade CTA. Sign-out already resets for this reason;
// an account switch carries the same hazard.
void startAccountAuthHandoff({
userId,
isCurrent: () => (
handoffGeneration === _convexWatchHandoffGeneration &&
getAuthState().user?.id === userId
),
effects: {
destroyEntitlementSubscription,
resetEntitlementState,
destroySubscriptionWatch,
rebindConvexAuthForWatchHandoff,
initEntitlementSubscription,
initSubscriptionWatch,
cloudPrefsSignIn: (nextUserId) => cloudPrefsSignIn(nextUserId, SITE_VARIANT),
},
});
// Claim any anonymous purchase made before sign-in (anon → real user migration)
const anonId = getStoredAnonId();
if (anonId) {
void (async () => {
const [client, api] = await Promise.all([getConvexClient(), getConvexApi()]);
if (!client || !api) return;
// Wait for ConvexClient WebSocket auth handshake to complete.
// Without this, mutations arrive at Convex before the server
// has the JWT → "Authentication required" errors.
const ready = await waitForConvexAuthForUser(userId, 10_000);
if (!ready) {
console.warn('[billing] claimSubscription skipped — Convex auth not ready');
return;
}
const claimToken = getFreshStoredAnonClaimToken() ?? undefined;
const result = await settleAccountOperation(
userId,
'claiming the anonymous subscription',
() => client.mutation(api.payments.billing.claimSubscription, {
anonId,
...(claimToken ? { claimToken } : {}),
}),
);
assertAccountStillCurrent(userId, 'claiming the anonymous subscription');
const claimed = result.claimed;
const totalClaimed = claimed.subscriptions + claimed.entitlements +
claimed.customers + claimed.payments;
if (totalClaimed > 0) {
console.log('[billing] Claimed anon subscription on sign-in:', claimed);
}
// Always remove after non-throwing completion — mutation is idempotent.
// Prevents cold Convex init + mutation on every sign-in for non-purchasers.
clearStoredAnonIdentity();
})().catch((err: unknown) => {
if (!isAccountStillCurrent(userId)) return;
console.warn('[billing] claimSubscription failed:', err);
// Non-fatal — anon ID preserved for retry on next page load
});
}
// Accept a Business Pro seat invite carried in the URL (mirror of the
// anon-claim hook). The invite link is /settings?accept-business-invite=<id>&token=<t>.
// Runs after sign-in so the invitee's Clerk email is available server-side.
const businessInviteGrantId = new URLSearchParams(window.location.search).get('accept-business-invite');
const businessInviteToken = new URLSearchParams(window.location.search).get('token');
if (businessInviteGrantId && businessInviteToken) {
void (async () => {
const [client, api] = await Promise.all([getConvexClient(), getConvexApi()]);
if (!client || !api) return;
const ready = await waitForConvexAuthForUser(userId, 10_000);
if (!ready) {
console.warn('[business-seats] acceptBusinessInvite skipped — Convex auth not ready');
return;
}
try {
await settleAccountOperation(
userId,
'accepting the Business Pro seat invite',
() => client.mutation(api.payments.businessSeats.acceptBusinessInvite, {
grantId: businessInviteGrantId as Id<'businessProGrants'>,
token: businessInviteToken,
}),
);
assertAccountStillCurrent(userId, 'accepting the Business Pro seat invite');
showToast('Pro seat activated');
} catch (err) {
if (!isAccountStillCurrent(userId)) return;
const msg = err instanceof Error ? err.message : 'Failed to accept invite';
if (msg.includes('INVITE_EMAIL_MISMATCH')) {
showToast('This invite is for a different email address');
} else if (msg.includes('INVITE_EXPIRED')) {
showToast('This invite has expired');
} else if (msg.includes('BUSINESS_NOT_ACTIVE')) {
showToast('The Business plan that sent this invite is no longer active');
} else if (msg.includes('INVITE_ALREADY_USED')) {
showToast('This invite has already been used');
} else {
showToast('Could not accept invite');
}
console.warn('[business-seats] acceptBusinessInvite failed:', err);
} finally {
// Clear the invite params from the URL so a refresh does not retry.
const url = new URL(window.location.href);
url.searchParams.delete('accept-business-invite');
url.searchParams.delete('token');
window.history.replaceState({}, '', url.toString());
}
})();
}
void resumePendingCheckout({
openAuth: () => this.state.authModal?.open(),
});
} else if (userId === null && _prevUserId !== null) {
// Clerk's mounted UserButton signs out through the SDK directly, so
// this observed transition is the authoritative place to invalidate
// cached/in-flight HTTP tokens and the authenticated Convex socket.
invalidateConvexAuthForSignOut();
// Supersede any server-auth wait that was started for the account
// being signed out before it gets a chance to attach user watches.
_convexWatchHandoffGeneration++;
destroyEntitlementSubscription();
destroySubscriptionWatch();
cloudPrefsSignOut();
resetEntitlementState();
}
_prevUserId = userId;
});
const geoCoordsPromise: Promise<PreciseCoordinates | null> =
this.state.isMobile && this.state.initialUrlState?.lat === undefined && this.state.initialUrlState?.lon === undefined
? resolvePreciseUserCoordinates(5000)
: Promise.resolve(null);
const resolvedRegion = await resolveUserRegion();
this.state.resolvedLocation = resolvedRegion;
// Phase 1: Layout (creates map + panels — they'll find hydrated data).
// init() is async so the dynamic MapContainer import can resolve before
// downstream code (e.g. mobileGeoCoords→state.map.setCenter) reads ctx.map.
markLcpDebug('wm:layout:init-start');
await this.panelLayout.init();
markLcpDebug('wm:layout:init-complete');
this.eventHandlers.setupSearchControls();
showProBanner(this.state.container);
this.updateConnectivityUi();
window.addEventListener('online', this.handleConnectivityChange);
window.addEventListener('offline', this.handleConnectivityChange);
const mobileGeoCoords = await geoCoordsPromise;
if (mobileGeoCoords && this.state.map) {
this.state.map.setCenter(mobileGeoCoords.lat, mobileGeoCoords.lon, 6);
}
// Happy variant: pre-populate panels from persistent cache for instant render
if (SITE_VARIANT === 'happy') {
await this.dataLoader.hydrateHappyPanelsFromCache();
}
// Phase 2: Shared UI components
if (!this.state.isMobile) {
void this.initFindingsBadge();
}
initBreakingNewsAlerts();
this.state.breakingBanner = new BreakingNewsBanner();
// Phase 3: UI setup methods
this.eventHandlers.startHeaderClock();
this.eventHandlers.setupPlaybackControl();
this.eventHandlers.setupStatusPanel();
this.eventHandlers.setupPizzIntIndicator();
this.eventHandlers.setupLlmStatusIndicator();
this.eventHandlers.setupExportPanel();
this.eventHandlers.setupSearchControls();
// Correlation engine is constructed lazily at its post-loadAllData run site
// (Phase 6 below) so its bytes + adapters stay off the eager boot graph (#4486).
this.eventHandlers.setupUnifiedSettings();
this.eventHandlers.setupAuthWidget();
// Capture any ?ref= / ?wm_referral= from the URL into localStorage
// and strip from the visible URL. Runs BEFORE the pending-checkout
// capture so a /dashboard?ref=X&checkoutProduct=Y landing preserves both
// signals. Pure read of current URL — no-op when neither param is
// present.
captureReferralFromUrl();
// Wire checkout-attempt lifecycle watchers (sign-out clear) before
// any capture/resume path runs, so a stale session from a prior
// user can't bleed into the current one.
initCheckoutWatchers();
// Stale attempt records are ignored by loadCheckoutAttempt() via
// the 24h TTL — no separate sweep needed. The attempt record's
// only consumer (the failure-retry banner) runs handleCheckoutReturn
// synchronously during panel-layout mount, which is after the
// captureePendingCheckoutIntentFromUrl repopulates it for any /pro
// handoff — so no race exists that would want to sweep pre-capture.
const pendingCheckout = capturePendingCheckoutIntentFromUrl();
if (pendingCheckout) {
// Checkout intent from /pro page redirect. Resume immediately if
// already authenticated, otherwise the auth callback handles it.
void resumePendingCheckout({
openAuth: () => this.state.authModal?.open(),
});
}
// Phase 4: MapLayerHandlers, CountryIntel. SearchManager is lazy-loaded
// on first CMD+K/search-button open so its modal catalog stays off startup.
this.eventHandlers.setupMapLayerHandlers();
await this.countryIntel.init();
// Unblock any WebMCP tool invocations that arrived during startup.
this.resolveUiReady();
// Phase 5: Event listeners + URL sync
this.eventHandlers.init();
// Capture deep link params BEFORE URL sync overwrites them
const initState = parseMapUrlState(window.location.search, this.state.mapLayers);
this.pendingDeepLinkCountry = initState.country ?? null;
this.pendingDeepLinkExpanded = initState.expanded === true;
this.pendingDeepLinkChokepoint = initState.chokepoint ?? null;
const earlyParams = new URLSearchParams(window.location.search);
this.pendingDeepLinkStoryCode = earlyParams.get('c') ?? null;
this.eventHandlers.setupUrlStateSync();
if (import.meta.env.VITE_E2E === '1') {
document.documentElement.dataset.wmEventHandlersReady = 'true';
}
this.state.countryBriefPage?.onStateChange?.(() => {
this.eventHandlers.syncUrlState();
});
// Start deep link handling early — its retry loop polls hasSufficientData()
// independently, so it must not be gated behind loadAllData() which can hang.
this.handleDeepLinks();
// Phase 6: Data loading
this.dataLoader.syncDataFreshnessWithLayers();
const slowTierReady = this.waitForSlowBootstrapCheckpoint();
if (this.state.isDestroyed) return;
// Prime panel-specific data concurrently with bulk loading.
// primeVisiblePanelData owns ETF, Stablecoins, Gulf Economies, etc. that
// are NOT part of loadAllData. Running them in parallel prevents those
// panels from being blocked when a loadAllData batch is slow.
window.addEventListener('scroll', this.handleViewportPrime, { passive: true });
window.addEventListener('resize', this.handleViewportPrime);
// forceAll=false at bootstrap: data-loader's existing per-panel
// viewport gate (shouldLoad(id) = forceAll || isPanelNearViewport(id))
// now actually fires, cutting the ~80-request fan-out down to the
// panels currently above the fold. IntersectionObserver wiring in
// panel-layout.ts plus handleViewportPrime above re-trigger
// loadAllData() as below-fold panels enter the viewport. (#3990)
// Slow-tier hydration keys are consume-once (getHydratedData deletes on
// read) and the visible-data consumers in loadAllData read them at task
// start. If the fan-out runs before the slow tier settles, those reads miss
// and fall back to per-panel RPCs that never re-read the late payload —
// wasting the ~500 KB slow-tier bootstrap. The shell LCP element already
// painted back in panelLayout.init() (Phase 1), so awaiting here is OFF the
// LCP critical path; it stays bounded by waitForBootstrapSlowTier's timeout
// (3.5 s browser / 8.5 s desktop). (#4512)
await slowTierReady;
if (this.state.isDestroyed) return;
// Snapshot whether precision geometry was already loaded BEFORE the fan-out
// (the map renderer triggers the memoized fetch early). If so, the fan-out's
// geometry-dependent CII ingests already attributed correctly and the
// post-LCP replay would just be a redundant second CII compute + choropleth
// repaint, so we skip it below. (#4512)
const geometryReadyBeforeFanout = isCountryGeometryLoaded();
markLcpDebug('wm:data:initial-fanout-start');
await Promise.all([
this.dataLoader.loadAllData(),
this.primeVisiblePanelData(),
]);
markLcpDebug('wm:data:initial-fanout-complete');
const countryGeometryReady = this.preloadCountryGeometryForPostLcpWork();
// If bootstrap was served from cache but live data just loaded, promote the status indicator
markBootstrapAsLive();
this.bootstrapHydrationState = getBootstrapHydrationState();
this.updateConnectivityUi();
// Initial correlation engine run is post-LCP background work. Wait for
// precision country geometry there instead of before visible data fan-out.
this.startPostLcpIntelligence(countryGeometryReady, geometryReadyBeforeFanout);
// Hide unconfigured layers after first data load
if (!isAisConfigured()) {
this.state.map?.hideLayerToggle('ais');
}
if (isOutagesConfigured() === false) {
this.state.map?.hideLayerToggle('outages');
}
if (!CYBER_LAYER_ENABLED) {
this.state.map?.hideLayerToggle('cyberThreats');
}
// Phase 7: Refresh scheduling
this.setupRefreshIntervals();
this.eventHandlers.setupSnapshotSaving();
cleanOldSnapshots().catch((e) => console.warn('[Storage] Snapshot cleanup failed:', e));
// Phase 8: Update checks
this.desktopUpdater.init();
// Analytics
trackEvent('wm_app_loaded', {
load_time_ms: Math.round(performance.now() - initStart),
panel_count: Object.keys(this.state.panels).length,
});
this.eventHandlers.setupPanelViewTracking();
}
/**
* Enforce free-tier panel and source limits.
* Reads current values from storage, trims if necessary, and saves back.
* Safe to call multiple times (idempotent) — e.g. on auth state changes.
*/
private enforceFreeTierLimits(): void {
// ── One-time v1 cap-bug recovery ──────────────────────────────────
// Pre-2026-05-01 the source cap was enforced by Array.sort().slice(),
// which silently auto-disabled every source past alphabetical position
// FREE_MAX_SOURCES — catastrophically erasing late-alphabet categories
// (Layoffs, Semiconductors, IPO, Funding, Product Hunt, …). Storage
// didn't track auto-disabled vs user-disabled, so a heuristic that runs
// on every load would silently undo a user who legitimately disabled
// every source in a category — and re-undo it on every refresh forever.
//
// Migration approach: run findFullyDisabledCategories ONCE, gated by
// disabledFeedsSchema version. After the migration completes, bump
// schema → 1 so subsequent loads skip recovery entirely. Users who
// explicitly toggle off every source in a category post-migration
// keep that preference permanently. Trade-off: a user who BEFORE the
// migration legitimately disabled every source in a category will lose
// those preferences once. That's acceptable since v1 victims have been
// suffering silent breakage and the explicit-full-category-disable
// pattern is rare (users typically hide the whole panel instead).
const schemaVersion = loadFromStorage<number>(STORAGE_KEYS.disabledFeedsSchema, 0);
if (schemaVersion < 1) {
const disabled = new Set(loadFromStorage<string[]>(STORAGE_KEYS.disabledFeeds, []));
const recoverable = findFullyDisabledCategories(FEEDS, disabled);
if (recoverable.length > 0) {
for (const name of recoverable) disabled.delete(name);
saveToStorage(STORAGE_KEYS.disabledFeeds, Array.from(disabled));
console.log(`[App] One-time v1-cap-bug migration: re-enabled ${recoverable.length} source(s) from fully-disabled categories. This will not run again.`);
}
saveToStorage(STORAGE_KEYS.disabledFeedsSchema, 1);
}
if (isProUser()) return;
// --- Panel limit ---
// Delegate to the shared enforceFreePanelLimit helper so this boot path and
// the dashboard-tab add/switch/load paths stay in lockstep (same cw-* and
// count rules). isPro is false here — the isProUser() early-return above
// already short-circuited pro users.
let panelSettings = loadFromStorage<Record<string, PanelConfig>>(STORAGE_KEYS.panels, {});
let panelsChanged = false;
try {
if (!localStorage.getItem(FREE_MAP_PANEL_ACCESS_KEY)) {
const restoredPanels = restoreFreeMapPanelAccess(panelSettings);
if (panelSettings.map?.enabled !== restoredPanels.map?.enabled) {
panelSettings = restoredPanels;
panelsChanged = true;
}
localStorage.setItem(FREE_MAP_PANEL_ACCESS_KEY, 'done');
}
} catch {
// Persistence-only migration; blocked storage already uses defaults.
}
const clampedPanels = enforceFreePanelLimit(panelSettings, false);
for (const key of Object.keys(panelSettings)) {
if (panelSettings[key]?.enabled !== clampedPanels[key]?.enabled) {
panelsChanged = true;
break;
}
}
if (panelsChanged) {
saveToStorage(STORAGE_KEYS.panels, clampedPanels);
this.state.panelSettings = clampedPanels;
console.log(`[App] Free tier: enforced ${FREE_MAX_PANELS}-panel limit (disabled over-cap / cw-* panels)`);
}
// --- Source limit ---
// Free-tier 80-source cap. Pre-2026-05-01 this used `Array.sort().slice()`
// which silently auto-disabled every source past alphabetical position 80,
// catastrophically erasing late-alphabet categories (Layoffs, Semiconductors,
// IPO & SPAC, Funding & VC, Product Hunt, …) and producing the "All sources
// disabled" red panel state on the homepage with no user explanation.
// Replaced with round-robin per-category distribution from `selectSourcesUnderCap`.
// (v1-bug recovery for stuck localStorage state is handled once at the top
// of this function via the schema-version migration.)
const disabledSources = new Set(loadFromStorage<string[]>(STORAGE_KEYS.disabledFeeds, []));
const totalEligible = (() => {
const s = new Set<string>();
Object.values(FEEDS).forEach((feeds) => feeds?.forEach((f) => s.add(f.name)));
INTEL_SOURCES.forEach((f) => s.add(f.name));
let count = 0;
for (const name of s) if (!disabledSources.has(name)) count++;
return count;
})();
if (totalEligible > FREE_MAX_SOURCES) {
// Protect locale-boosted sources from the cap. Without this, locale-
// tagged feeds that sit late in their category bucket (e.g. Hungarian
// entries in the Europe bucket, declared AFTER the existing en/de/it/
// nl/sv defaults) get round-robin'd out — the locale boost re-enables
// them, then the cap immediately auto-disables them again. Free-tier
// users on the boosted locale lose their locale's defaults entirely.
// userLang derivation mirrors the locale-boost migration (earlier in
// the App constructor) and the i18n.ts:99 `wmExplicit` detector:
// explicit Settings choice wins, navigator is the fallback. Direct
// localStorage read because i18next isn't initialized yet at the
// constructor stage where enforceFreeTierLimits also runs.
let explicitLocale = '';
try { explicitLocale = localStorage.getItem('wm-locale-explicit') || ''; } catch { /* private mode */ }
const userLang = ((explicitLocale || navigator.language || 'en').split('-')[0] ?? 'en').toLowerCase();
const protectedNames = userLang === 'en' ? new Set<string>() : getLocaleBoostedSources(userLang);
const { keep, autoDisabled } = selectSourcesUnderCap(FEEDS, INTEL_SOURCES, disabledSources, FREE_MAX_SOURCES, protectedNames);
// Defense in depth: feeds.ts has 35+ source names that appear in
// multiple category buckets. The helper guarantees keep ∩ autoDisabled
// = ∅, but a regression there would silently re-disable a kept source
// here. The keep.has() guard makes the cross-set invariant explicit
// at the caller too — if it ever fires it's a helper-bug signal.
for (const name of autoDisabled) {
if (!keep.has(name)) disabledSources.add(name);
}
saveToStorage(STORAGE_KEYS.disabledFeeds, Array.from(disabledSources));
console.log(`[App] Free tier: round-robin disabled ${autoDisabled.size} source(s) to enforce ${FREE_MAX_SOURCES}-source limit (per-category fairness)`);
}
}
public destroy(): void {
this.state.isDestroyed = true;
cancelBootstrapSlowTier();
window.removeEventListener('scroll', this.handleViewportPrime);
window.removeEventListener('resize', this.handleViewportPrime);
window.removeEventListener('online', this.handleConnectivityChange);
window.removeEventListener('offline', this.handleConnectivityChange);
window.removeEventListener(I18N_RESOURCES_LOADED_EVENT, this.handleI18nResourcesLoaded);
window.removeEventListener(WM_FOLLOWED_COUNTRIES_CAP_DROP, this.handleFollowedCountriesCapDrop);
window.removeEventListener(CLOUD_PREFS_APPLIED_EVENT, this.handleCloudPrefsApplied);
if (this.visiblePanelPrimeRaf !== null) {
window.cancelAnimationFrame(this.visiblePanelPrimeRaf);
this.visiblePanelPrimeRaf = null;
}
if (this.chokepointDeepLinkTimer !== null) {
window.clearTimeout(this.chokepointDeepLinkTimer);
this.chokepointDeepLinkTimer = null;
}
// Destroy all modules in reverse order
for (let i = this.modules.length - 1; i >= 0; i--) {
this.modules[i]!.destroy();
}
// Clean up subscriptions, map, AIS, and breaking news
this.unsubAiFlow?.();
this.unsubFreeTier?.();
this.unsubEntitlementPremiumLoaders?.();
mlWorker.terminate();
this.state.findingsBadge?.destroy();
this.state.findingsBadge = null;
this.state.breakingBanner?.destroy();
destroyBreakingNewsAlerts();
this.cachedModeBannerEl?.remove();
this.cachedModeBannerEl = null;
window.removeEventListener(WM_SESSION_DEGRADED_EVENT, this.handleWmSessionDegraded);
if (this.followedCountriesCapDropToastTimer !== null) {
window.clearTimeout(this.followedCountriesCapDropToastTimer);
this.followedCountriesCapDropToastTimer = null;
}
this.state.map?.destroy();
disconnectAisStream();
stopFlightHistoryCleanup();
stopLoadedVesselHistoryCleanup();
// Unregister every WebMCP tool so a same-document re-init (tests,
// HMR, SPA harness) doesn't leave the browser with stale bindings
// pointing at a disposed App.
this.webMcpController?.abort();
this.webMcpController = null;
}
private async initFindingsBadge(): Promise<void> {
try {
const { IntelligenceGapBadge } = await import('@/components/IntelligenceGapBadge');
if (this.state.isDestroyed) return;
this.state.findingsBadge = new IntelligenceGapBadge();
this.state.findingsBadge.setOnSignalClick((signal) => {
if (this.state.countryBriefPage?.isVisible()) return;
if (localStorage.getItem('wm-settings-open') === '1') return;
void this.state.ensureSignalModal()
.then((signalModal) => {
if (!this.state.isDestroyed) signalModal.showSignal(signal);
})
.catch((err) => {
console.warn('[SignalModal] Failed to show signal:', err);
});
});
this.state.findingsBadge.setOnAlertClick((alert) => {
if (this.state.countryBriefPage?.isVisible()) return;
if (localStorage.getItem('wm-settings-open') === '1') return;
void this.state.ensureSignalModal()
.then((signalModal) => {
if (!this.state.isDestroyed) signalModal.showAlert(alert);
})
.catch((err) => {
console.warn('[SignalModal] Failed to show alert:', err);
});
});
} catch (error) {
console.warn('[IntelligenceGapBadge] Lazy init failed:', error);
}
}
private showFollowedCountriesCapDropToast(kept: number, dropped: number): void {
if (this.followedCountriesCapDropToastTimer !== null) {
window.clearTimeout(this.followedCountriesCapDropToastTimer);
this.followedCountriesCapDropToastTimer = null;
}
document.querySelector('.wm-followed-cap-drop-toast')?.remove();
const toast = document.createElement('div');
toast.className = 'wm-followed-cap-drop-toast update-toast';
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
const body = document.createElement('div');
body.className = 'update-toast-body';
const title = document.createElement('div');
title.className = 'update-toast-title';
title.textContent = 'Follow limit reached';
const detail = document.createElement('div');
detail.className = 'update-toast-detail';
const countryWord = dropped === 1 ? 'country was' : 'countries were';
detail.textContent = `${kept} kept. ${dropped} ${countryWord} not added because the free plan supports ${FREE_TIER_FOLLOW_LIMIT} followed countries.`;
body.append(title, detail);
const action = document.createElement('button');
action.type = 'button';
action.className = 'update-toast-action';
action.dataset.action = 'upgrade';
action.textContent = 'Upgrade';
const dismiss = document.createElement('button');
dismiss.type = 'button';
dismiss.className = 'update-toast-dismiss';
dismiss.dataset.action = 'dismiss';
dismiss.setAttribute('aria-label', 'Dismiss');
dismiss.textContent = '\u00d7';
toast.append(body, action, dismiss);
this.followedCountriesCapDropToastTimer = window.setTimeout(() => {
toast.remove();
this.followedCountriesCapDropToastTimer = null;
}, 8000);
toast.addEventListener('click', (e) => {
const clickedAction = (e.target as HTMLElement)
.closest<HTMLElement>('[data-action]')
?.dataset.action;
if (clickedAction === 'upgrade') {
window.open('/pro#pricing', '_blank', 'noopener,noreferrer');
if (this.followedCountriesCapDropToastTimer !== null) {
window.clearTimeout(this.followedCountriesCapDropToastTimer);
this.followedCountriesCapDropToastTimer = null;
}
toast.remove();
} else if (clickedAction === 'dismiss') {
if (this.followedCountriesCapDropToastTimer !== null) {
window.clearTimeout(this.followedCountriesCapDropToastTimer);
this.followedCountriesCapDropToastTimer = null;
}
toast.remove();
}
});
document.body.appendChild(toast);
window.requestAnimationFrame(() => toast.classList.add('visible'));
}
// Waits for Phase-4 UI modules to finish initialising. WebMCP bindings call
// this before touching nullable UI
// state so a tool invoked during startup waits rather than throwing;
// the timeout guards against a genuinely broken init path hanging the
// agent forever.
private async waitForUiReady(timeoutMs = 10_000): Promise<void> {
let timer: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`UI did not initialise within ${timeoutMs}ms`)),
timeoutMs,
);
});
try {
await Promise.race([this.uiReady, timeout]);
} finally {
if (timer !== null) clearTimeout(timer);
}
}
private handleDeepLinks(): void {
const url = new URL(window.location.href);
const DEEP_LINK_INITIAL_DELAY_MS = 1500;
// Check for country brief deep link: ?c=IR (captured early before URL sync)
const storyCode = this.pendingDeepLinkStoryCode ?? url.searchParams.get('c');
this.pendingDeepLinkStoryCode = null;
if (url.pathname === '/story' || storyCode) {
const countryCode = storyCode;
if (countryCode) {
trackDeeplinkOpened('country', countryCode);
const countryName = getCountryNameByCode(countryCode.toUpperCase()) || countryCode;
setTimeout(() => {
void this.countryIntel.openCountryBriefByCode(countryCode.toUpperCase(), countryName, {
maximize: true,
}).catch((err) => {
console.error('[CountryBrief] Failed to open country brief:', err);
this.state.map?.setRenderPaused(false);
showToast('Country brief failed to open. Please try again.');
});
this.eventHandlers.syncUrlState();
}, DEEP_LINK_INITIAL_DELAY_MS);
return;
}
}
// Check for country brief deep link: ?country=UA or ?country=UA&expanded=1
const deepLinkCountry = this.pendingDeepLinkCountry;
const deepLinkExpanded = this.pendingDeepLinkExpanded;
this.pendingDeepLinkCountry = null;
this.pendingDeepLinkExpanded = false;
if (deepLinkCountry) {
trackDeeplinkOpened('country', deepLinkCountry);
const cName = CountryIntelManager.resolveCountryName(deepLinkCountry);
setTimeout(() => {
void this.countryIntel.openCountryBriefByCode(deepLinkCountry, cName, {
maximize: deepLinkExpanded,
}).catch((err) => {
console.error('[CountryBrief] Failed to open country brief:', err);
this.state.map?.setRenderPaused(false);
showToast('Country brief failed to open. Please try again.');
});
this.eventHandlers.syncUrlState();
}, DEEP_LINK_INITIAL_DELAY_MS);
}
// Check for chokepoint deep link: ?chokepoint=bab_el_mandeb — pans the map to
// the waterway and opens its popup (the chokepoint equivalent of the country
// brief deep link). openChokepoint no-ops on an unknown id.
const deepLinkChokepoint = this.pendingDeepLinkChokepoint;
this.pendingDeepLinkChokepoint = null;
if (deepLinkChokepoint) {
trackDeeplinkOpened('chokepoint', deepLinkChokepoint);
this.state.activeChokepoint = deepLinkChokepoint;
this.chokepointDeepLinkTimer = window.setTimeout(() => {
this.chokepointDeepLinkTimer = null;
if (this.state.isDestroyed) return;
this.state.mapLayers.waterways = true;
this.state.map?.enableLayer('waterways');
this.state.map?.openChokepoint(deepLinkChokepoint);
this.eventHandlers.syncUrlState();
}, DEEP_LINK_INITIAL_DELAY_MS);
}
}
private setupRefreshIntervals(): void {
// Always refresh news for all variants
this.refreshScheduler.scheduleRefresh('news', () => this.dataLoader.loadNews(), REFRESH_INTERVALS.feeds);
// Registration (and its immediate first hydration) is deferred to
// post-paint idle: freshness badges are below-the-fold decoration, so the
// fetch must not compete with the LCP-window requests (#4907, #4890).
scheduleAfterFirstPaint(() => {
this.refreshScheduler.scheduleRefresh(
'health-freshness',
async () => { await refreshDataFreshnessFromHealth(); },
REFRESH_INTERVALS.healthFreshness,
undefined,
{ runImmediately: true },
);
});
// Happy variant only refreshes news -- skip all geopolitical/financial/military refreshes
if (SITE_VARIANT !== 'happy') {
this.refreshScheduler.registerAll([
{
name: 'markets',
fn: () => this.dataLoader.loadMarkets(),
intervalMs: REFRESH_INTERVALS.markets,
condition: () => this.isAnyPanelNearViewport(['markets', 'heatmap', 'commodities', 'crypto', 'crypto-heatmap', 'defi-tokens', 'ai-tokens', 'other-tokens']),
},
{
name: 'predictions',
fn: () => this.dataLoader.loadPredictions(),
intervalMs: REFRESH_INTERVALS.predictions,
condition: () => this.isPanelNearViewport('polymarket'),
},
{
name: 'forecasts',
fn: () => this.dataLoader.loadForecasts(),
intervalMs: REFRESH_INTERVALS.forecasts,
condition: () => this.isPanelNearViewport('forecast'),
},
{ name: 'pizzint', fn: () => this.dataLoader.loadPizzInt(), intervalMs: REFRESH_INTERVALS.pizzint, condition: () => SITE_VARIANT === 'full' },
{ name: 'natural', fn: () => this.dataLoader.loadNatural(), intervalMs: REFRESH_INTERVALS.natural, condition: () => this.state.mapLayers.natural },
{ name: 'weather', fn: () => this.dataLoader.loadWeatherAlerts(), intervalMs: REFRESH_INTERVALS.weather, condition: () => this.state.mapLayers.weather },
{ name: 'fred', fn: () => this.dataLoader.loadFredData(), intervalMs: REFRESH_INTERVALS.fred, condition: () => this.isPanelNearViewport('economic') },
{ name: 'spending', fn: () => this.dataLoader.loadGovernmentSpending(), intervalMs: REFRESH_INTERVALS.spending, condition: () => this.isPanelNearViewport('economic') },
{ name: 'global-tenders', fn: () => this.dataLoader.loadGlobalTenders(), intervalMs: REFRESH_INTERVALS.spending, condition: () => hasPremiumAccess() && this.isPanelNearViewport('global-procurement') },
{ name: 'bis', fn: () => this.dataLoader.loadBisData(), intervalMs: REFRESH_INTERVALS.bis, condition: () => this.isPanelNearViewport('economic') },
{ name: 'oil', fn: () => this.dataLoader.loadOilAnalytics(), intervalMs: REFRESH_INTERVALS.oil, condition: () => this.isPanelNearViewport('energy-complex') },
{ name: 'firms', fn: () => this.dataLoader.loadFirmsData(), intervalMs: REFRESH_INTERVALS.firms, condition: () => this.shouldRefreshFirms() },
{ name: 'ais', fn: () => this.dataLoader.loadAisSignals(), intervalMs: REFRESH_INTERVALS.ais, condition: () => this.state.mapLayers.ais },
{ name: 'cables', fn: () => this.dataLoader.loadCableActivity(), intervalMs: REFRESH_INTERVALS.cables, condition: () => this.state.mapLayers.cables },
{ name: 'cableHealth', fn: () => this.dataLoader.loadCableHealth(), intervalMs: REFRESH_INTERVALS.cableHealth, condition: () => this.state.mapLayers.cables },
{ name: 'flights', fn: () => this.dataLoader.loadFlightDelays(), intervalMs: REFRESH_INTERVALS.flights, condition: () => this.state.mapLayers.flights },
{
name: 'cyberThreats', fn: () => {
this.state.cyberThreatsCache = null;
return this.dataLoader.loadCyberThreats();
}, intervalMs: REFRESH_INTERVALS.cyberThreats, condition: () => CYBER_LAYER_ENABLED && this.state.mapLayers.cyberThreats
},
]);
}
if (SITE_VARIANT === 'finance') {
this.refreshScheduler.scheduleRefresh(
'stock-analysis',
() => this.dataLoader.loadStockAnalysis(),
REFRESH_INTERVALS.stockAnalysis,
() => hasPremiumAccess() && this.isPanelNearViewport('stock-analysis'),
);
this.refreshScheduler.scheduleRefresh(
'daily-market-brief',
() => this.dataLoader.loadDailyMarketBrief(),
REFRESH_INTERVALS.dailyMarketBrief,
() => hasPremiumAccess() && this.isPanelNearViewport('daily-market-brief'),
);
this.refreshScheduler.scheduleRefresh(
'stock-backtest',
() => this.dataLoader.loadStockBacktest(),
REFRESH_INTERVALS.stockBacktest,
() => hasPremiumAccess() && this.isPanelNearViewport('stock-backtest'),
);
this.refreshScheduler.scheduleRefresh(
'market-implications',
() => this.dataLoader.loadMarketImplications(),
REFRESH_INTERVALS.marketImplications,
() => hasPremiumAccess() && this.isPanelNearViewport('market-implications'),
);
}
// Panel-level refreshes (moved from panel constructors into scheduler for hidden-tab awareness + jitter)
this.refreshScheduler.scheduleRefresh(
'service-status',
() => (this.state.panels['service-status'] as ServiceStatusPanel).fetchStatus(),
REFRESH_INTERVALS.serviceStatus,
() => this.isPanelNearViewport('service-status')
);
this.refreshScheduler.scheduleRefresh(
'stablecoins',
() => (this.state.panels.stablecoins as StablecoinPanel).fetchData(),
REFRESH_INTERVALS.stablecoins,
() => this.isPanelNearViewport('stablecoins')
);
this.refreshScheduler.scheduleRefresh(
'energy-crisis',
() => (this.state.panels['energy-crisis'] as EnergyCrisisPanel).fetchData(),
REFRESH_INTERVALS.energyCrisis,
() => this.isPanelNearViewport('energy-crisis')
);
this.refreshScheduler.scheduleRefresh(
'etf-flows',
() => (this.state.panels['etf-flows'] as ETFFlowsPanel).fetchData(),
REFRESH_INTERVALS.etfFlows,
() => this.isPanelNearViewport('etf-flows')
);
this.refreshScheduler.scheduleRefresh(
'macro-signals',
() => (this.state.panels['macro-signals'] as MacroSignalsPanel).fetchData(),
REFRESH_INTERVALS.macroSignals,
() => this.isPanelNearViewport('macro-signals')
);
this.refreshScheduler.scheduleRefresh(
'defense-patents',
() => { (this.state.panels['defense-patents'] as DefensePatentsPanel).refresh(); return Promise.resolve(); },
REFRESH_INTERVALS.defensePatents,
() => this.isPanelNearViewport('defense-patents')
);
this.refreshScheduler.scheduleRefresh(
'fear-greed',
() => (this.state.panels['fear-greed'] as FearGreedPanel).fetchData(),
REFRESH_INTERVALS.fearGreed,
() => this.isPanelNearViewport('fear-greed')
);
this.refreshScheduler.scheduleRefresh(
'hormuz-tracker',
() => (this.state.panels['hormuz-tracker'] as HormuzPanel).fetchData(),
REFRESH_INTERVALS.hormuzTracker,
() => this.isPanelNearViewport('hormuz-tracker')
);
this.refreshScheduler.scheduleRefresh(
'positioning-247',
() => (this.state.panels['positioning-247'] as PositioningPanel).fetchData(),
REFRESH_INTERVALS.hyperliquidFlow,
() => this.isPanelNearViewport('positioning-247')
);
this.refreshScheduler.scheduleRefresh(
'strategic-posture',
() => (this.state.panels['strategic-posture'] as StrategicPosturePanel).refresh(),
REFRESH_INTERVALS.strategicPosture,
() => this.isPanelNearViewport('strategic-posture')
);
this.refreshScheduler.scheduleRefresh(
'strategic-risk',
() => (this.state.panels['strategic-risk'] as StrategicRiskPanel).refresh(),
REFRESH_INTERVALS.strategicRisk,
() => this.isPanelNearViewport('strategic-risk')
);
this.refreshScheduler.scheduleRefresh(
'wsb-tickers',
() => this.dataLoader.loadWsbTickers(),
REFRESH_INTERVALS.wsbTickers,
() => hasPremiumAccess() && this.isPanelNearViewport('wsb-ticker-scanner'),
);
// Server-side temporal anomalies (news + satellite_fires)
if (SITE_VARIANT !== 'happy') {
this.refreshScheduler.scheduleRefresh('temporalBaseline', () => this.dataLoader.refreshTemporalBaseline(), REFRESH_INTERVALS.temporalBaseline, () => this.shouldRefreshIntelligence());
}
// WTO trade policy data — annual data, poll every 10 min to avoid hammering upstream.
// PRO-gated: the isNearViewport check is a visibility gate, not an entitlement gate,
// so without hasPremiumAccess() here we'd still hit the 6 WTO RPCs every poll for
// free users once the panel scrolled into view.
if (SITE_VARIANT === 'full' || SITE_VARIANT === 'finance' || SITE_VARIANT === 'commodity' || SITE_VARIANT === 'energy') {
this.refreshScheduler.scheduleRefresh('tradePolicy', () => this.dataLoader.loadTradePolicy(), REFRESH_INTERVALS.tradePolicy, () => hasPremiumAccess() && this.isPanelNearViewport('trade-policy'));
this.refreshScheduler.scheduleRefresh('supplyChain', () => this.dataLoader.loadSupplyChain(), REFRESH_INTERVALS.supplyChain, () => this.isPanelNearViewport('supply-chain'));
this.refreshScheduler.scheduleRefresh('chinaCorridors', () => this.dataLoader.loadChinaCorridors(), REFRESH_INTERVALS.chinaCorridors, () => this.isPanelNearViewport('china-corridors'));
this.refreshScheduler.scheduleRefresh('chinaActivityNowcast', () => this.dataLoader.loadChinaActivityNowcast(), REFRESH_INTERVALS.chinaActivityNowcast, () => this.isPanelNearViewport('china-activity-nowcast'));
}
this.refreshScheduler.scheduleRefresh(
'cross-source-signals',
() => this.dataLoader.loadCrossSourceSignals(),
REFRESH_INTERVALS.crossSourceSignals,
() => this.isPanelNearViewport('cross-source-signals'),
);
// Telegram Intel (near real-time, 60s refresh)
this.refreshScheduler.scheduleRefresh(
'telegram-intel',
() => this.dataLoader.loadTelegramIntel(),
REFRESH_INTERVALS.telegramIntel,
() => this.isPanelNearViewport('telegram-intel')
);
this.refreshScheduler.scheduleRefresh(
'gulf-economies',
() => (this.state.panels['gulf-economies'] as GulfEconomiesPanel).fetchData(),
REFRESH_INTERVALS.gulfEconomies,
() => this.isPanelNearViewport('gulf-economies')
);
this.refreshScheduler.scheduleRefresh(
'grocery-basket',
() => (this.state.panels['grocery-basket'] as GroceryBasketPanel).fetchData(),
REFRESH_INTERVALS.groceryBasket,
() => this.isPanelNearViewport('grocery-basket')
);
this.refreshScheduler.scheduleRefresh(
'bigmac',
() => (this.state.panels['bigmac'] as BigMacPanel).fetchData(),
REFRESH_INTERVALS.groceryBasket,
() => this.isPanelNearViewport('bigmac')
);
this.refreshScheduler.scheduleRefresh(
'fuel-prices',
() => (this.state.panels['fuel-prices'] as FuelPricesPanel).fetchData(),
REFRESH_INTERVALS.fuelPrices,
() => this.isPanelNearViewport('fuel-prices')
);
this.refreshScheduler.scheduleRefresh(
'fao-food-price-index',
() => (this.state.panels['fao-food-price-index'] as FaoFoodPriceIndexPanel).fetchData(),
REFRESH_INTERVALS.faoFoodPriceIndex,
() => this.isPanelNearViewport('fao-food-price-index')
);
this.refreshScheduler.scheduleRefresh(
'oil-inventories',
() => (this.state.panels['oil-inventories'] as OilInventoriesPanel).fetchData(),
REFRESH_INTERVALS.oilInventories,
() => this.isPanelNearViewport('oil-inventories')
);
this.refreshScheduler.scheduleRefresh(
'pipeline-status',
() => (this.state.panels['pipeline-status'] as PipelineStatusPanel).fetchData(),
REFRESH_INTERVALS.pipelineStatus,
() => this.isPanelNearViewport('pipeline-status')
);
this.refreshScheduler.scheduleRefresh(
'storage-facility-map',
() => (this.state.panels['storage-facility-map'] as StorageFacilityMapPanel).fetchData(),
REFRESH_INTERVALS.storageFacilityMap,
() => this.isPanelNearViewport('storage-facility-map')
);
this.refreshScheduler.scheduleRefresh(
'fuel-shortages',
() => (this.state.panels['fuel-shortages'] as FuelShortagePanel).fetchData(),
REFRESH_INTERVALS.fuelShortages,
() => this.isPanelNearViewport('fuel-shortages')
);
this.refreshScheduler.scheduleRefresh(
'energy-disruptions',
() => (this.state.panels['energy-disruptions'] as EnergyDisruptionsPanel).fetchData(),
REFRESH_INTERVALS.energyDisruptions,
() => this.isPanelNearViewport('energy-disruptions')
);
this.refreshScheduler.scheduleRefresh(
'energy-risk-overview',
() => (this.state.panels['energy-risk-overview'] as EnergyRiskOverviewPanel).fetchData(),
REFRESH_INTERVALS.energyRiskOverview,
() => this.isPanelNearViewport('energy-risk-overview')
);
this.refreshScheduler.scheduleRefresh(
'chokepoint-strip',
() => (this.state.panels['chokepoint-strip'] as ChokepointStripPanel).fetchData(),
REFRESH_INTERVALS.chokepointStrip,
() => this.isPanelNearViewport('chokepoint-strip')
);
this.refreshScheduler.scheduleRefresh(
'climate-news',
() => (this.state.panels['climate-news'] as ClimateNewsPanel).fetchData(),
REFRESH_INTERVALS.climateNews,
() => this.isPanelNearViewport('climate-news')
);
this.refreshScheduler.scheduleRefresh(
'macro-tiles',
() => (this.state.panels['macro-tiles'] as MacroTilesPanel).fetchData(),
REFRESH_INTERVALS.macroTiles,
() => this.isPanelNearViewport('macro-tiles')
);
this.refreshScheduler.scheduleRefresh(
'fsi',
() => (this.state.panels['fsi'] as FSIPanel).fetchData(),
REFRESH_INTERVALS.fsi,
() => this.isPanelNearViewport('fsi')
);
this.refreshScheduler.scheduleRefresh(
'yield-curve',
() => (this.state.panels['yield-curve'] as YieldCurvePanel).fetchData(),
REFRESH_INTERVALS.yieldCurve,
() => this.isPanelNearViewport('yield-curve')
);
this.refreshScheduler.scheduleRefresh(
'earnings-calendar',
() => (this.state.panels['earnings-calendar'] as EarningsCalendarPanel).fetchData(),
REFRESH_INTERVALS.earningsCalendar,
() => this.isPanelNearViewport('earnings-calendar')
);
this.refreshScheduler.scheduleRefresh(
'economic-calendar',
() => (this.state.panels['economic-calendar'] as EconomicCalendarPanel).fetchData(),
REFRESH_INTERVALS.economicCalendar,
() => this.isPanelNearViewport('economic-calendar')
);
this.refreshScheduler.scheduleRefresh(
'cot-positioning',
() => (this.state.panels['cot-positioning'] as CotPositioningPanel).fetchData(),
REFRESH_INTERVALS.cotPositioning,
() => this.isPanelNearViewport('cot-positioning')
);
this.refreshScheduler.scheduleRefresh(
'gold-intelligence',
() => (this.state.panels['gold-intelligence'] as GoldIntelligencePanel).fetchData(),
REFRESH_INTERVALS.goldIntelligence,
() => this.isPanelNearViewport('gold-intelligence')
);
this.refreshScheduler.scheduleRefresh(
'aaii-sentiment',
() => this.dataLoader.loadAaiiSentiment(),
REFRESH_INTERVALS.aaiiSentiment,
() => this.isPanelNearViewport('aaii-sentiment')
);
this.refreshScheduler.scheduleRefresh(
'market-breadth',
() => this.dataLoader.loadMarketBreadth(),
REFRESH_INTERVALS.marketBreadth,
() => this.isPanelNearViewport('market-breadth')
);
// Refresh intelligence signals for CII (geopolitical variant only)
if (SITE_VARIANT === 'full') {
this.refreshScheduler.scheduleRefresh('intelligence', () => {
const { military, iranEvents } = this.state.intelligenceCache;
this.state.intelligenceCache = {};
if (military) this.state.intelligenceCache.military = military;
if (iranEvents) this.state.intelligenceCache.iranEvents = iranEvents;
return this.dataLoader.loadIntelligenceSignals();
}, REFRESH_INTERVALS.intelligence, () => this.shouldRefreshIntelligence());
}
// Correlation engine refresh
this.refreshScheduler.scheduleRefresh(
'correlation-engine',
async () => {
const engine = this.state.correlationEngine;
if (!engine) return;
await engine.run(this.state);
for (const domain of ['military', 'escalation', 'economic', 'disaster'] as const) {
const panel = this.state.panels[`${domain}-correlation`] as CorrelationPanel | undefined;
panel?.updateCards(engine.getCards(domain));
}
},
REFRESH_INTERVALS.correlationEngine,
() => this.shouldRefreshCorrelation(),
);
}
}