diff --git a/AGENTS.md b/AGENTS.md
index a8eba75118..e022232b58 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -108,6 +108,7 @@ The app ships multiple variants with different panel/layer configurations:
- `finance`: Financial markets focus
- `commodity`: Commodity markets focus
- `happy`: Positive news only
+- `sports`: Sports news, fixtures, tables, and match stats
Variant is set via `VITE_VARIANT` env var. Config lives in `src/config/variants/`.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index affe0e6d77..9c651fa870 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -40,13 +40,16 @@ World Monitor is a real-time OSINT dashboard built with **Vanilla TypeScript** (
### Variant System
-The codebase produces three app variants from the same source, each targeting a different audience:
+The codebase produces six app variants from the same source, each targeting a different audience:
| Variant | Command | Focus |
|---|---|---|
| `full` | `npm run dev` | Geopolitics, military, conflicts, infrastructure |
| `tech` | `npm run dev:tech` | Startups, AI/ML, cloud, cybersecurity |
| `finance` | `npm run dev:finance` | Markets, trading, central banks, commodities |
+| `commodity` | `npm run dev:commodity` | Mining, energy, metals, supply chains |
+| `happy` | `npm run dev:happy` | Positive news, progress, conservation |
+| `sports` | `npm run dev:sports` | Sports news, fixtures, tables, live stats |
Variants share all code but differ in default panels, map layers, and RSS feeds. Variant configs live in `src/config/variants/`.
diff --git a/api/_sports-data-config.js b/api/_sports-data-config.js
new file mode 100644
index 0000000000..e100d8e6e5
--- /dev/null
+++ b/api/_sports-data-config.js
@@ -0,0 +1,202 @@
+const RAW_SPORTS_DATA_PROVIDERS = Object.freeze({
+ thesportsdb: {
+ baseUrl: 'https://www.thesportsdb.com/api/v1/json/123',
+ endpointTtls: Object.freeze({
+ '/all_leagues.php': 6 * 60 * 60,
+ '/lookupleague.php': 60 * 60,
+ '/search_all_seasons.php': 60 * 60,
+ '/lookuptable.php': 10 * 60,
+ '/eventslast.php': 10 * 60,
+ '/eventsnext.php': 5 * 60,
+ '/eventsday.php': 5 * 60,
+ '/lookupevent.php': 2 * 60,
+ '/lookupeventstats.php': 10 * 60,
+ '/searchvenues.php': 6 * 60 * 60,
+ '/searchplayers.php': 30 * 60,
+ '/lookupplayer.php': 60 * 60,
+ }),
+ allowedParams: Object.freeze({
+ '/all_leagues.php': [],
+ '/lookupleague.php': ['id'],
+ '/search_all_seasons.php': ['id'],
+ '/lookuptable.php': ['l', 's'],
+ '/eventslast.php': ['id'],
+ '/eventsnext.php': ['id'],
+ '/eventsday.php': ['d', 's'],
+ '/lookupevent.php': ['id'],
+ '/lookupeventstats.php': ['id'],
+ '/searchvenues.php': ['v'],
+ '/searchplayers.php': ['p'],
+ '/lookupplayer.php': ['id'],
+ }),
+ },
+ espn: {
+ baseUrl: 'https://www.espn.com',
+ endpointTtls: Object.freeze({
+ '/nba/standings': 5 * 60,
+ }),
+ allowedParams: Object.freeze({
+ '/nba/standings': [],
+ }),
+ },
+ espnsite: {
+ baseUrl: 'https://site.api.espn.com/apis/site/v2/sports',
+ endpointTtls: Object.freeze({
+ '/soccer/eng.1/scoreboard': 5 * 60,
+ '/soccer/eng.1/summary': 2 * 60,
+ '/soccer/uefa.champions/scoreboard': 5 * 60,
+ '/soccer/uefa.champions/summary': 2 * 60,
+ '/soccer/fifa.world/scoreboard': 10 * 60,
+ '/soccer/fifa.world/summary': 2 * 60,
+ '/soccer/uefa.euro/scoreboard': 10 * 60,
+ '/soccer/uefa.euro/summary': 2 * 60,
+ '/soccer/conmebol.america/scoreboard': 10 * 60,
+ '/soccer/conmebol.america/summary': 2 * 60,
+ '/soccer/conmebol.libertadores/scoreboard': 5 * 60,
+ '/soccer/conmebol.libertadores/summary': 2 * 60,
+ '/soccer/esp.1/scoreboard': 5 * 60,
+ '/soccer/esp.1/summary': 2 * 60,
+ '/soccer/ger.1/scoreboard': 5 * 60,
+ '/soccer/ger.1/summary': 2 * 60,
+ '/soccer/ita.1/scoreboard': 5 * 60,
+ '/soccer/ita.1/summary': 2 * 60,
+ '/soccer/fra.1/scoreboard': 5 * 60,
+ '/soccer/fra.1/summary': 2 * 60,
+ '/soccer/ned.1/scoreboard': 5 * 60,
+ '/soccer/ned.1/summary': 2 * 60,
+ '/soccer/por.1/scoreboard': 5 * 60,
+ '/soccer/por.1/summary': 2 * 60,
+ '/soccer/usa.1/scoreboard': 5 * 60,
+ '/soccer/usa.1/summary': 2 * 60,
+ '/soccer/mex.1/scoreboard': 5 * 60,
+ '/soccer/mex.1/summary': 2 * 60,
+ '/soccer/eng.2/scoreboard': 5 * 60,
+ '/soccer/eng.2/summary': 2 * 60,
+ '/soccer/eng.3/scoreboard': 5 * 60,
+ '/soccer/eng.3/summary': 2 * 60,
+ '/soccer/sco.1/scoreboard': 5 * 60,
+ '/soccer/sco.1/summary': 2 * 60,
+ '/soccer/arg.1/scoreboard': 5 * 60,
+ '/soccer/arg.1/summary': 2 * 60,
+ '/basketball/nba/standings': 5 * 60,
+ '/basketball/nba/scoreboard': 2 * 60,
+ '/basketball/nba/summary': 90,
+ '/hockey/nhl/scoreboard': 2 * 60,
+ '/hockey/nhl/summary': 2 * 60,
+ '/baseball/mlb/scoreboard': 2 * 60,
+ '/baseball/mlb/summary': 2 * 60,
+ '/football/nfl/scoreboard': 2 * 60,
+ '/football/nfl/summary': 2 * 60,
+ }),
+ allowedParams: Object.freeze({
+ '/soccer/eng.1/scoreboard': ['dates'],
+ '/soccer/eng.1/summary': ['event'],
+ '/soccer/uefa.champions/scoreboard': ['dates'],
+ '/soccer/uefa.champions/summary': ['event'],
+ '/soccer/fifa.world/scoreboard': ['dates'],
+ '/soccer/fifa.world/summary': ['event'],
+ '/soccer/uefa.euro/scoreboard': ['dates'],
+ '/soccer/uefa.euro/summary': ['event'],
+ '/soccer/conmebol.america/scoreboard': ['dates'],
+ '/soccer/conmebol.america/summary': ['event'],
+ '/soccer/conmebol.libertadores/scoreboard': ['dates'],
+ '/soccer/conmebol.libertadores/summary': ['event'],
+ '/soccer/esp.1/scoreboard': ['dates'],
+ '/soccer/esp.1/summary': ['event'],
+ '/soccer/ger.1/scoreboard': ['dates'],
+ '/soccer/ger.1/summary': ['event'],
+ '/soccer/ita.1/scoreboard': ['dates'],
+ '/soccer/ita.1/summary': ['event'],
+ '/soccer/fra.1/scoreboard': ['dates'],
+ '/soccer/fra.1/summary': ['event'],
+ '/soccer/ned.1/scoreboard': ['dates'],
+ '/soccer/ned.1/summary': ['event'],
+ '/soccer/por.1/scoreboard': ['dates'],
+ '/soccer/por.1/summary': ['event'],
+ '/soccer/usa.1/scoreboard': ['dates'],
+ '/soccer/usa.1/summary': ['event'],
+ '/soccer/mex.1/scoreboard': ['dates'],
+ '/soccer/mex.1/summary': ['event'],
+ '/soccer/eng.2/scoreboard': ['dates'],
+ '/soccer/eng.2/summary': ['event'],
+ '/soccer/eng.3/scoreboard': ['dates'],
+ '/soccer/eng.3/summary': ['event'],
+ '/soccer/sco.1/scoreboard': ['dates'],
+ '/soccer/sco.1/summary': ['event'],
+ '/soccer/arg.1/scoreboard': ['dates'],
+ '/soccer/arg.1/summary': ['event'],
+ '/basketball/nba/standings': [],
+ '/basketball/nba/scoreboard': ['dates'],
+ '/basketball/nba/summary': ['event'],
+ '/hockey/nhl/scoreboard': ['dates'],
+ '/hockey/nhl/summary': ['event'],
+ '/baseball/mlb/scoreboard': ['dates'],
+ '/baseball/mlb/summary': ['event'],
+ '/football/nfl/scoreboard': ['dates'],
+ '/football/nfl/summary': ['event'],
+ }),
+ },
+ jolpica: {
+ baseUrl: 'https://api.jolpi.ca',
+ endpointTtls: Object.freeze({
+ '/ergast/f1/current/driverStandings.json': 5 * 60,
+ '/ergast/f1/current/constructorStandings.json': 5 * 60,
+ '/ergast/f1/current/last/results.json': 5 * 60,
+ '/ergast/f1/current/next.json': 30 * 60,
+ }),
+ allowedParams: Object.freeze({
+ '/ergast/f1/current/driverStandings.json': [],
+ '/ergast/f1/current/constructorStandings.json': [],
+ '/ergast/f1/current/last/results.json': [],
+ '/ergast/f1/current/next.json': [],
+ }),
+ },
+ openf1: {
+ baseUrl: 'https://api.openf1.org',
+ endpointTtls: Object.freeze({
+ '/v1/drivers': 6 * 60 * 60,
+ }),
+ allowedParams: Object.freeze({
+ '/v1/drivers': ['session_key'],
+ }),
+ },
+});
+
+const PROVIDER_KEYS = Object.freeze(Object.keys(RAW_SPORTS_DATA_PROVIDERS));
+const PROVIDER_KEY_SET = new Set(PROVIDER_KEYS);
+
+function createProviderConfig(rawProvider) {
+ const endpointTtls = Object.freeze({ ...rawProvider.endpointTtls });
+ const endpointPaths = Object.keys(endpointTtls);
+ const allowedParamsEntries = Object.entries(rawProvider.allowedParams);
+ const allowedPaths = allowedParamsEntries.map(([path]) => path);
+
+ if (allowedPaths.length !== endpointPaths.length || !endpointPaths.every((path) => allowedPaths.includes(path))) {
+ throw new Error('Sports proxy config mismatch between endpoint TTLs and allowed parameter paths');
+ }
+
+ const allowedParams = Object.freeze(Object.fromEntries(
+ allowedParamsEntries.map(([path, params]) => [path, new Set(params)]),
+ ));
+
+ return Object.freeze({
+ baseUrl: rawProvider.baseUrl,
+ endpointTtls,
+ endpoints: new Set(endpointPaths),
+ allowedParams,
+ });
+}
+
+export function createSportsDataProviders() {
+ return Object.freeze(Object.fromEntries(
+ Object.entries(RAW_SPORTS_DATA_PROVIDERS).map(([providerKey, provider]) => [providerKey, createProviderConfig(provider)]),
+ ));
+}
+
+export function isSportsProvider(providerKey) {
+ return typeof providerKey === 'string' && PROVIDER_KEY_SET.has(providerKey);
+}
+
+export function getSportsProviderKeys() {
+ return [...PROVIDER_KEYS];
+}
diff --git a/api/enrichment/company.js b/api/enrichment/company.js
index 5d1bc6236e..55ec56f29d 100644
--- a/api/enrichment/company.js
+++ b/api/enrichment/company.js
@@ -80,7 +80,7 @@ async function fetchSECData(companyName) {
);
if (!res.ok) return null;
const data = await res.json();
- if (!data.hits || !data.hits.hits || data.hits.hits.length === 0) return null;
+ if (!data.hits?.hits || data.hits.hits.length === 0) return null;
return {
totalFilings: data.hits.total?.value || 0,
recentFilings: data.hits.hits.slice(0, 5).map((h) => ({
diff --git a/api/mcp-proxy.js b/api/mcp-proxy.js
index a5af1e1dfd..0fa7375f37 100644
--- a/api/mcp-proxy.js
+++ b/api/mcp-proxy.js
@@ -182,7 +182,6 @@ class SseSession {
let buf = '';
let eventType = '';
const reader = this._reader;
- const self = this;
(async () => {
try {
@@ -190,10 +189,10 @@ class SseSession {
const { done, value } = await reader.read();
if (done) {
// Stream closed — if endpoint never arrived, reject so connect() throws
- if (!self._endpointUrl) {
- self._endpointDeferred.reject(new Error('SSE stream closed before endpoint event'));
+ if (!this._endpointUrl) {
+ this._endpointDeferred.reject(new Error('SSE stream closed before endpoint event'));
}
- for (const [, d] of self._pending) d.reject(new Error('SSE stream closed'));
+ for (const [, d] of this._pending) d.reject(new Error('SSE stream closed'));
break;
}
buf += dec.decode(value, { stream: true });
@@ -209,27 +208,27 @@ class SseSession {
// to prevent SSRF: a malicious server could emit an RFC1918 address.
let resolved;
try {
- resolved = new URL(data.startsWith('http') ? data : data, self._sseUrl);
+ resolved = new URL(data.startsWith('http') ? data : data, this._sseUrl);
} catch {
- self._endpointDeferred.reject(new Error('SSE endpoint event contains invalid URL'));
+ this._endpointDeferred.reject(new Error('SSE endpoint event contains invalid URL'));
return;
}
if (resolved.protocol !== 'https:' && resolved.protocol !== 'http:') {
- self._endpointDeferred.reject(new Error('SSE endpoint protocol not allowed'));
+ this._endpointDeferred.reject(new Error('SSE endpoint protocol not allowed'));
return;
}
if (BLOCKED_HOST_PATTERNS.some(p => p.test(resolved.hostname))) {
- self._endpointDeferred.reject(new Error('SSE endpoint host is blocked'));
+ this._endpointDeferred.reject(new Error('SSE endpoint host is blocked'));
return;
}
- self._endpointUrl = resolved.toString();
- self._endpointDeferred.resolve();
+ this._endpointUrl = resolved.toString();
+ this._endpointDeferred.resolve();
} else {
try {
const msg = JSON.parse(data);
if (msg.id !== undefined) {
- const d = self._pending.get(msg.id);
- if (d) { self._pending.delete(msg.id); d.resolve(msg); }
+ const d = this._pending.get(msg.id);
+ if (d) { this._pending.delete(msg.id); d.resolve(msg); }
}
} catch { /* skip non-JSON data lines */ }
}
@@ -238,8 +237,8 @@ class SseSession {
}
}
} catch (err) {
- self._endpointDeferred.reject(err);
- for (const [, d] of self._pending) d.reject(new Error('SSE stream closed'));
+ this._endpointDeferred.reject(err);
+ for (const [, d] of this._pending) d.reject(new Error('SSE stream closed'));
}
})();
}
diff --git a/api/sports-data.js b/api/sports-data.js
new file mode 100644
index 0000000000..95c4a88285
--- /dev/null
+++ b/api/sports-data.js
@@ -0,0 +1,94 @@
+import { getCorsHeaders, isDisallowedOrigin } from './_cors.js';
+import { jsonResponse } from './_json-response.js';
+import { createSportsDataProviders, isSportsProvider } from './_sports-data-config.js';
+
+export const config = { runtime: 'edge' };
+
+const REQUEST_TIMEOUT_MS = 12_000;
+const PROVIDERS = createSportsDataProviders();
+
+function resolveSportsRequest(providerKey, rawPath) {
+ if (!rawPath || typeof rawPath !== 'string') return null;
+ const provider = PROVIDERS[providerKey];
+ if (!provider) return null;
+
+ let parsed;
+ try {
+ parsed = new URL(rawPath, 'https://worldmonitor.app');
+ } catch {
+ return null;
+ }
+
+ const pathname = parsed.pathname;
+ if (!(pathname in provider.endpointTtls)) return null;
+
+ const allowedParams = provider.allowedParams[pathname];
+ for (const key of parsed.searchParams.keys()) {
+ if (!allowedParams.has(key)) return null;
+ }
+
+ return {
+ upstreamUrl: `${provider.baseUrl}${pathname}${parsed.search}`,
+ cacheTtl: provider.endpointTtls[pathname] || 300,
+ };
+}
+
+export default async function handler(req) {
+ const corsHeaders = getCorsHeaders(req, 'GET, OPTIONS');
+
+ if (isDisallowedOrigin(req)) {
+ return jsonResponse({ error: 'Origin not allowed' }, 403, corsHeaders);
+ }
+
+ if (req.method === 'OPTIONS') {
+ return new Response(null, { status: 204, headers: corsHeaders });
+ }
+
+ if (req.method !== 'GET') {
+ return jsonResponse({ error: 'Method not allowed' }, 405, corsHeaders);
+ }
+
+ const requestUrl = new URL(req.url);
+ const providerKey = requestUrl.searchParams.get('provider') || 'thesportsdb';
+ if (!isSportsProvider(providerKey)) {
+ return jsonResponse({ error: 'Invalid sports provider' }, 400, corsHeaders);
+ }
+
+ const requestedPath = requestUrl.searchParams.get('path');
+ const resolved = resolveSportsRequest(providerKey, requestedPath);
+ if (!resolved) {
+ return jsonResponse({ error: 'Invalid sports path' }, 400, corsHeaders);
+ }
+
+ const { upstreamUrl, cacheTtl } = resolved;
+
+ try {
+ const response = await fetch(upstreamUrl, {
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ headers: {
+ Accept: 'application/json',
+ 'User-Agent': 'WorldMonitor-Sports-Proxy/1.0',
+ },
+ });
+
+ const body = await response.text();
+
+ return new Response(body, {
+ status: response.status,
+ headers: {
+ 'Content-Type': response.headers.get('content-type') || 'application/json',
+ 'Cache-Control': response.ok
+ ? `public, max-age=120, s-maxage=${cacheTtl}, stale-while-revalidate=${cacheTtl}`
+ : 'public, max-age=15, s-maxage=60, stale-while-revalidate=120',
+ ...corsHeaders,
+ },
+ });
+ } catch (error) {
+ const isTimeout = error?.name === 'AbortError';
+ return jsonResponse(
+ { error: isTimeout ? 'Sports feed timeout' : 'Failed to fetch sports data' },
+ isTimeout ? 504 : 502,
+ corsHeaders,
+ );
+ }
+}
diff --git a/package.json b/package.json
index 7b4ff7d019..cb51814d0b 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"dev:finance": "cross-env VITE_VARIANT=finance vite",
"dev:happy": "cross-env VITE_VARIANT=happy vite",
"dev:commodity": "cross-env VITE_VARIANT=commodity vite",
+ "dev:sports": "cross-env VITE_VARIANT=sports vite",
"postinstall": "cd blog-site && npm ci --prefer-offline",
"build:blog": "cd blog-site && npm run build && rm -rf ../public/blog && mkdir -p ../public/blog && cp -r dist/* ../public/blog/",
"build:pro": "cd pro-test && npm install && npm run build",
@@ -29,6 +30,7 @@
"build:finance": "cross-env-shell VITE_VARIANT=finance \"tsc && vite build\"",
"build:happy": "cross-env-shell VITE_VARIANT=happy \"tsc && vite build\"",
"build:commodity": "cross-env-shell VITE_VARIANT=commodity \"tsc && vite build\"",
+ "build:sports": "cross-env-shell VITE_VARIANT=sports \"tsc && vite build\"",
"typecheck": "tsc --noEmit",
"typecheck:api": "tsc --noEmit -p tsconfig.api.json",
"typecheck:all": "tsc --noEmit && tsc --noEmit -p tsconfig.api.json",
diff --git a/public/sports/f1/teams/alpine.svg b/public/sports/f1/teams/alpine.svg
new file mode 100644
index 0000000000..35232a22a2
--- /dev/null
+++ b/public/sports/f1/teams/alpine.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/sports/f1/teams/aston-martin.svg b/public/sports/f1/teams/aston-martin.svg
new file mode 100644
index 0000000000..76fcabaa5d
--- /dev/null
+++ b/public/sports/f1/teams/aston-martin.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/sports/f1/teams/ferrari.svg b/public/sports/f1/teams/ferrari.svg
new file mode 100644
index 0000000000..a1651e6384
--- /dev/null
+++ b/public/sports/f1/teams/ferrari.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/sports/f1/teams/haas.svg b/public/sports/f1/teams/haas.svg
new file mode 100644
index 0000000000..7f4e195c73
--- /dev/null
+++ b/public/sports/f1/teams/haas.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/sports/f1/teams/mclaren.svg b/public/sports/f1/teams/mclaren.svg
new file mode 100644
index 0000000000..d8d1c4d07f
--- /dev/null
+++ b/public/sports/f1/teams/mclaren.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/sports/f1/teams/mercedes.svg b/public/sports/f1/teams/mercedes.svg
new file mode 100644
index 0000000000..0a57c0cef7
--- /dev/null
+++ b/public/sports/f1/teams/mercedes.svg
@@ -0,0 +1,6 @@
+
diff --git a/public/sports/f1/teams/racing-bulls.svg b/public/sports/f1/teams/racing-bulls.svg
new file mode 100644
index 0000000000..a1f09e22aa
--- /dev/null
+++ b/public/sports/f1/teams/racing-bulls.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/sports/f1/teams/red-bull.svg b/public/sports/f1/teams/red-bull.svg
new file mode 100644
index 0000000000..17d3f0dd00
--- /dev/null
+++ b/public/sports/f1/teams/red-bull.svg
@@ -0,0 +1,8 @@
+
diff --git a/public/sports/f1/teams/sauber.svg b/public/sports/f1/teams/sauber.svg
new file mode 100644
index 0000000000..5b62d44e0b
--- /dev/null
+++ b/public/sports/f1/teams/sauber.svg
@@ -0,0 +1,7 @@
+
diff --git a/public/sports/f1/teams/williams.svg b/public/sports/f1/teams/williams.svg
new file mode 100644
index 0000000000..14e67e4825
--- /dev/null
+++ b/public/sports/f1/teams/williams.svg
@@ -0,0 +1,6 @@
+
diff --git a/scripts/_proxy-utils.cjs b/scripts/_proxy-utils.cjs
index d4d08da61b..e54ddeb4df 100644
--- a/scripts/_proxy-utils.cjs
+++ b/scripts/_proxy-utils.cjs
@@ -117,7 +117,7 @@ function proxyConnectTunnel(targetHostname, proxyConfig, { timeoutMs = 20_000, t
proxySock.destroy();
return reject(
Object.assign(new Error(`Proxy CONNECT: ${statusLine}`), {
- status: parseInt(statusLine.split(' ')[1]) || 0,
+ status: parseInt(statusLine.split(' ')[1], 10) || 0,
})
);
}
diff --git a/scripts/ais-relay.cjs b/scripts/ais-relay.cjs
index c19716d90d..d95369e02e 100644
--- a/scripts/ais-relay.cjs
+++ b/scripts/ais-relay.cjs
@@ -3260,6 +3260,8 @@ const CLASSIFY_SEED_INTERVAL_MS = 15 * 60 * 1000;
const CLASSIFY_CACHE_TTL = 86400;
const CLASSIFY_SKIP_TTL = 1800;
const CLASSIFY_BATCH_SIZE = 50;
+// Sports intentionally excluded: the sports variant suppresses threat scoring
+// and does not participate in geopolitical threat summaries/notifications.
const CLASSIFY_VARIANTS = ['full', 'tech', 'finance', 'happy', 'commodity'];
const CLASSIFY_VARIANT_STAGGER_MS = 3 * 60 * 1000;
diff --git a/scripts/backfill-fuel-prices-prev.mjs b/scripts/backfill-fuel-prices-prev.mjs
index 36fc0de3d5..a18fc2b231 100644
--- a/scripts/backfill-fuel-prices-prev.mjs
+++ b/scripts/backfill-fuel-prices-prev.mjs
@@ -282,8 +282,8 @@ async function fetchMexico() {
if (!dates.length) return [];
const maxDate = dates.sort().reverse()[0];
const latest = results.filter(r => r.fecha_aplicacion === maxDate);
- const reg = latest.map(r => parseFloat(r.precio_gasolina_regular)).filter(v => !isNaN(v) && v > 0);
- const dsl = latest.map(r => parseFloat(r.precio_diesel)).filter(v => !isNaN(v) && v > 0);
+ const reg = latest.map(r => parseFloat(r.precio_gasolina_regular)).filter(v => !Number.isNaN(v) && v > 0);
+ const dsl = latest.map(r => parseFloat(r.precio_diesel)).filter(v => !Number.isNaN(v) && v > 0);
const avgR = reg.length ? +(reg.reduce((a, b) => a + b, 0) / reg.length).toFixed(4) : null;
const avgD = dsl.length ? +(dsl.reduce((a, b) => a + b, 0) / dsl.length).toFixed(4) : null;
console.log(` [MX] Regular=${avgR} MXN/L, Diesel=${avgD} MXN/L (baseline, date=${maxDate})`);
diff --git a/scripts/build-country-names.cjs b/scripts/build-country-names.cjs
index 11e54d3904..bcf24994df 100644
--- a/scripts/build-country-names.cjs
+++ b/scripts/build-country-names.cjs
@@ -21,7 +21,7 @@ function normalize(value) {
.trim();
}
-function add(key, iso2, source) {
+function add(key, iso2, _source) {
const k = normalize(key);
if (!k || !/^[A-Z]{2}$/.test(iso2)) return;
if (result[k]) return;
diff --git a/server/_shared/source-tiers.ts b/server/_shared/source-tiers.ts
index f633195ede..a87143ea6e 100644
--- a/server/_shared/source-tiers.ts
+++ b/server/_shared/source-tiers.ts
@@ -17,6 +17,8 @@ export const SOURCE_TIERS: Record = {
'AP News': 1,
'AFP': 1,
'Bloomberg': 1,
+ 'Reuters Sports': 1,
+ 'AP Sports': 1,
// Tier 1 - Official Government & International Orgs
'White House': 1,
@@ -63,6 +65,13 @@ export const SOURCE_TIERS: Record = {
'The National': 2,
'Yonhap News': 2,
'Chosun Ilbo': 2,
+ 'BBC Sport': 2,
+ 'ESPN': 2,
+ 'Sky Sports': 2,
+ 'Formula1.com': 2,
+ 'NBA.com': 2,
+ 'MLB.com': 2,
+ 'NHL.com': 2,
// Tier 2 - Spanish
'El País': 2,
@@ -221,6 +230,12 @@ export const SOURCE_TIERS: Record = {
'Layoffs.fyi': 3,
'OpenAI News': 3,
'The Hill': 3,
+ 'ATP Tour': 3,
+ 'WTA': 3,
+ 'Tennis.com': 3,
+ 'UFC': 3,
+ 'MMA Fighting': 3,
+ 'Motorsport.com': 3,
// Tier 3 - Think tanks
'Brookings Tech': 3,
diff --git a/server/worldmonitor/news/v1/_classifier.ts b/server/worldmonitor/news/v1/_classifier.ts
index ead4be75de..db536ec72a 100644
--- a/server/worldmonitor/news/v1/_classifier.ts
+++ b/server/worldmonitor/news/v1/_classifier.ts
@@ -213,6 +213,10 @@ function matchKeywords(
}
export function classifyByKeyword(title: string, variant?: string): ClassificationResult {
+ if (variant === 'sports') {
+ return { level: 'info', category: 'general', confidence: 0.15, source: 'keyword' };
+ }
+
const lower = title.toLowerCase();
if (EXCLUSIONS.some(ex => lower.includes(ex))) {
diff --git a/server/worldmonitor/news/v1/_feeds.ts b/server/worldmonitor/news/v1/_feeds.ts
index dc4fca99b3..1048d394fe 100644
--- a/server/worldmonitor/news/v1/_feeds.ts
+++ b/server/worldmonitor/news/v1/_feeds.ts
@@ -388,6 +388,47 @@ export const VARIANT_FEEDS: Record> = {
],
},
+ sports: {
+ sports: [
+ { name: 'BBC Sport', url: 'https://feeds.bbci.co.uk/sport/rss.xml?edition=uk' },
+ { name: 'ESPN', url: 'https://www.espn.com/espn/rss/news' },
+ { name: 'Reuters Sports', url: gn('site:reuters.com sports when:2d') },
+ { name: 'AP Sports', url: gn('site:apnews.com sports when:2d') },
+ { name: 'Sky Sports', url: 'https://www.skysports.com/rss/12040' },
+ ],
+ soccer: [
+ { name: 'BBC Sport', url: 'https://feeds.bbci.co.uk/sport/football/rss.xml?edition=uk' },
+ { name: 'Sky Sports', url: 'https://www.skysports.com/rss/12040' },
+ { name: 'ESPN', url: 'https://www.espn.com/espn/rss/soccer/news' },
+ { name: 'Guardian Football', url: gn('site:theguardian.com football when:2d') },
+ ],
+ basketball: [
+ { name: 'NBA.com', url: gn('site:nba.com nba when:2d') },
+ { name: 'ESPN', url: gn('site:espn.com NBA when:2d') },
+ { name: 'The Athletic NBA', url: gn('(NBA OR basketball) analysis when:2d') },
+ ],
+ baseball: [
+ { name: 'MLB.com', url: gn('site:mlb.com MLB when:2d') },
+ { name: 'ESPN', url: gn('site:espn.com MLB when:2d') },
+ { name: 'Baseball News', url: gn('(MLB OR baseball) trade OR playoffs when:2d') },
+ ],
+ motorsport: [
+ { name: 'Formula1.com', url: gn('site:formula1.com Formula 1 when:3d') },
+ { name: 'Motorsport.com', url: gn('site:motorsport.com Formula 1 OR MotoGP when:3d') },
+ { name: 'Racer', url: gn('site:racer.com motorsport when:3d') },
+ ],
+ tennis: [
+ { name: 'ATP Tour', url: gn('site:atptour.com tennis when:3d') },
+ { name: 'WTA', url: gn('site:wtatennis.com tennis when:3d') },
+ { name: 'Tennis.com', url: gn('site:tennis.com tennis when:3d') },
+ ],
+ combat: [
+ { name: 'UFC', url: gn('site:ufc.com UFC when:3d') },
+ { name: 'ESPN', url: gn('site:espn.com MMA OR boxing when:3d') },
+ { name: 'MMA Fighting', url: gn('site:mmafighting.com MMA when:3d') },
+ ],
+ },
+
happy: {
positive: [
{ name: 'Good News Network', url: 'https://www.goodnewsnetwork.org/feed/' },
diff --git a/server/worldmonitor/news/v1/list-feed-digest.ts b/server/worldmonitor/news/v1/list-feed-digest.ts
index f8f76342ba..dbd89ea2e1 100644
--- a/server/worldmonitor/news/v1/list-feed-digest.ts
+++ b/server/worldmonitor/news/v1/list-feed-digest.ts
@@ -28,7 +28,7 @@ import { getRelayBaseUrl, getRelayHeaders } from '../../../_shared/relay';
const RSS_ACCEPT = 'application/rss+xml, application/xml, text/xml, */*';
-const VALID_VARIANTS = new Set(['full', 'tech', 'finance', 'happy', 'commodity']);
+const VALID_VARIANTS = new Set(['full', 'tech', 'finance', 'happy', 'commodity', 'sports']);
const fallbackDigestCache = new Map();
const ITEMS_PER_FEED = 5;
const MAX_ITEMS_PER_CATEGORY = 20;
diff --git a/src/App.ts b/src/App.ts
index 9025782ae4..b8673d0596 100644
--- a/src/App.ts
+++ b/src/App.ts
@@ -49,7 +49,18 @@ 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 { SportsTablesPanel } from '@/components/SportsTablesPanel';
+import type { SportsStatsPanel } from '@/components/SportsStatsPanel';
+import type { SportsLiveTrackerPanel } from '@/components/SportsLiveTrackerPanel';
+import type { SportsMajorTournamentsPanel } from '@/components/SportsMajorTournamentsPanel';
+import type { SportsNbaPanel } from '@/components/SportsNbaPanel';
+import type { SportsMotorsportPanel } from '@/components/SportsMotorsportPanel';
+import type { SportsTransferNewsPanel } from '@/components/SportsTransferNewsPanel';
+import type { SportsPlayerSearchPanel } from '@/components/SportsPlayerSearchPanel';
import type { GoldIntelligencePanel } from '@/components/GoldIntelligencePanel';
+import type { SportsNbaAnalysisPanel } from '@/components/SportsNbaAnalysisPanel';
+import type { SportsEuropeanFootballAnalysisPanel } from '@/components/SportsEuropeanFootballAnalysisPanel';
+import type { SportsMotorsportAnalysisPanel } from '@/components/SportsMotorsportAnalysisPanel';
import { isDesktopRuntime, waitForSidecarReady } from '@/services/runtime';
import { hasPremiumAccess } from '@/services/panel-gating';
import { BETA_MODE } from '@/config/beta';
@@ -340,6 +351,52 @@ export class App {
const panel = this.state.panels['cot-positioning'] as CotPositioningPanel | undefined;
if (panel) primeTask('cot-positioning', () => panel.fetchData());
}
+ if (SITE_VARIANT === 'sports') {
+ if (shouldPrime('sports-tables')) {
+ const panel = this.state.panels['sports-tables'] as SportsTablesPanel | undefined;
+ if (panel) primeTask('sports-tables', () => panel.fetchData());
+ }
+ if (shouldPrime('sports-stats')) {
+ const panel = this.state.panels['sports-stats'] as SportsStatsPanel | undefined;
+ if (panel) primeTask('sports-stats', () => panel.fetchData());
+ }
+ if (shouldPrime('sports-live-tracker')) {
+ const panel = this.state.panels['sports-live-tracker'] as SportsLiveTrackerPanel | undefined;
+ if (panel) primeTask('sports-live-tracker', () => panel.fetchData());
+ }
+ if (shouldPrime('sports-tournaments')) {
+ const panel = this.state.panels['sports-tournaments'] as SportsMajorTournamentsPanel | undefined;
+ if (panel) primeTask('sports-tournaments', () => panel.fetchData());
+ }
+ if (shouldPrime('sports-nba')) {
+ const panel = this.state.panels['sports-nba'] as SportsNbaPanel | undefined;
+ if (panel) primeTask('sports-nba', () => panel.fetchData());
+ }
+ if (shouldPrime('sports-motorsport-standings')) {
+ const panel = this.state.panels['sports-motorsport-standings'] as SportsMotorsportPanel | undefined;
+ if (panel) primeTask('sports-motorsport-standings', () => panel.fetchData());
+ }
+ if (shouldPrime('sports-nba-analysis')) {
+ const panel = this.state.panels['sports-nba-analysis'] as SportsNbaAnalysisPanel | undefined;
+ if (panel) primeTask('sports-nba-analysis', () => panel.fetchData());
+ }
+ if (shouldPrime('sports-football-analysis')) {
+ const panel = this.state.panels['sports-football-analysis'] as SportsEuropeanFootballAnalysisPanel | undefined;
+ if (panel) primeTask('sports-football-analysis', () => panel.fetchData());
+ }
+ if (shouldPrime('sports-motorsport-analysis')) {
+ const panel = this.state.panels['sports-motorsport-analysis'] as SportsMotorsportAnalysisPanel | undefined;
+ if (panel) primeTask('sports-motorsport-analysis', () => panel.fetchData());
+ }
+ if (shouldPrime('sports-transfers')) {
+ const panel = this.state.panels['sports-transfers'] as SportsTransferNewsPanel | undefined;
+ if (panel) primeTask('sports-transfers', () => panel.fetchData());
+ }
+ if (shouldPrime('sports-player-search')) {
+ const panel = this.state.panels['sports-player-search'] as SportsPlayerSearchPanel | undefined;
+ if (panel) primeTask('sports-player-search', () => panel.fetchData());
+ }
+ }
if (shouldPrime('gold-intelligence')) {
const panel = this.state.panels['gold-intelligence'] as GoldIntelligencePanel | undefined;
if (panel) primeTask('gold-intelligence', () => panel.fetchData());
@@ -1176,11 +1233,13 @@ export class App {
}
private setupRefreshIntervals(): void {
+ const skipsGeneralRefreshes = SITE_VARIANT === 'happy' || SITE_VARIANT === 'sports';
+
// Always refresh news for all variants
this.refreshScheduler.scheduleRefresh('news', () => this.dataLoader.loadNews(), REFRESH_INTERVALS.feeds);
- // Happy variant only refreshes news -- skip all geopolitical/financial/military refreshes
- if (SITE_VARIANT !== 'happy') {
+ // Happy and sports variants skip the general geopolitical/financial refresh suite.
+ if (!skipsGeneralRefreshes) {
this.refreshScheduler.registerAll([
{
name: 'markets',
@@ -1318,7 +1377,7 @@ export class App {
);
// Server-side temporal anomalies (news + satellite_fires)
- if (SITE_VARIANT !== 'happy') {
+ if (!skipsGeneralRefreshes) {
this.refreshScheduler.scheduleRefresh('temporalBaseline', () => this.dataLoader.refreshTemporalBaseline(), REFRESH_INTERVALS.temporalBaseline, () => this.shouldRefreshIntelligence());
}
@@ -1446,6 +1505,74 @@ export class App {
REFRESH_INTERVALS.marketBreadth,
() => this.isPanelNearViewport('market-breadth')
);
+ if (SITE_VARIANT === 'sports') {
+ this.refreshScheduler.scheduleRefresh(
+ 'sports-fixtures-layer',
+ () => this.dataLoader.loadSportsFixturesLayer(),
+ REFRESH_INTERVALS.sports,
+ () => this.state.mapLayers.sportsFixtures
+ );
+ this.refreshScheduler.scheduleRefresh(
+ 'sports-tables',
+ () => (this.state.panels['sports-tables'] as SportsTablesPanel).fetchData(),
+ REFRESH_INTERVALS.sports,
+ () => this.isPanelNearViewport('sports-tables')
+ );
+ this.refreshScheduler.scheduleRefresh(
+ 'sports-stats',
+ () => (this.state.panels['sports-stats'] as SportsStatsPanel).fetchData(),
+ REFRESH_INTERVALS.sports,
+ () => this.isPanelNearViewport('sports-stats')
+ );
+ this.refreshScheduler.scheduleRefresh(
+ 'sports-live-tracker',
+ () => (this.state.panels['sports-live-tracker'] as SportsLiveTrackerPanel).fetchData(),
+ REFRESH_INTERVALS.sports,
+ () => this.isPanelNearViewport('sports-live-tracker')
+ );
+ this.refreshScheduler.scheduleRefresh(
+ 'sports-tournaments',
+ () => (this.state.panels['sports-tournaments'] as SportsMajorTournamentsPanel).fetchData(),
+ REFRESH_INTERVALS.sports,
+ () => this.isPanelNearViewport('sports-tournaments')
+ );
+ this.refreshScheduler.scheduleRefresh(
+ 'sports-nba',
+ () => (this.state.panels['sports-nba'] as SportsNbaPanel).fetchData(),
+ REFRESH_INTERVALS.sports,
+ () => this.isPanelNearViewport('sports-nba')
+ );
+ this.refreshScheduler.scheduleRefresh(
+ 'sports-motorsport-standings',
+ () => (this.state.panels['sports-motorsport-standings'] as SportsMotorsportPanel).fetchData(),
+ REFRESH_INTERVALS.sports,
+ () => this.isPanelNearViewport('sports-motorsport-standings')
+ );
+ this.refreshScheduler.scheduleRefresh(
+ 'sports-nba-analysis',
+ () => (this.state.panels['sports-nba-analysis'] as SportsNbaAnalysisPanel).fetchData(),
+ REFRESH_INTERVALS.sports,
+ () => this.isPanelNearViewport('sports-nba-analysis')
+ );
+ this.refreshScheduler.scheduleRefresh(
+ 'sports-football-analysis',
+ () => (this.state.panels['sports-football-analysis'] as SportsEuropeanFootballAnalysisPanel).fetchData(),
+ REFRESH_INTERVALS.sports,
+ () => this.isPanelNearViewport('sports-football-analysis')
+ );
+ this.refreshScheduler.scheduleRefresh(
+ 'sports-motorsport-analysis',
+ () => (this.state.panels['sports-motorsport-analysis'] as SportsMotorsportAnalysisPanel).fetchData(),
+ REFRESH_INTERVALS.sports,
+ () => this.isPanelNearViewport('sports-motorsport-analysis')
+ );
+ this.refreshScheduler.scheduleRefresh(
+ 'sports-transfers',
+ () => (this.state.panels['sports-transfers'] as SportsTransferNewsPanel).fetchData(),
+ REFRESH_INTERVALS.sports,
+ () => this.isPanelNearViewport('sports-transfers')
+ );
+ }
// Refresh intelligence signals for CII (geopolitical variant only)
if (SITE_VARIANT === 'full') {
diff --git a/src/app/data-loader.ts b/src/app/data-loader.ts
index b3a7c893cf..453de5f4a9 100644
--- a/src/app/data-loader.ts
+++ b/src/app/data-loader.ts
@@ -121,6 +121,7 @@ import { fetchTelegramFeed } from '@/services/telegram-intel';
import { fetchOrefAlerts, startOrefPolling, stopOrefPolling, onOrefAlertsUpdate } from '@/services/oref-alerts';
import { getResilienceRanking } from '@/services/resilience';
import { buildResilienceChoroplethMap } from '@/components/resilience-choropleth-utils';
+import { fetchSportsFixtureMapMarkers } from '@/services/sports';
import { enrichEventsWithExposure } from '@/services/population-exposure';
import { debounce, getCircuitBreakerCooldownInfo } from '@/utils';
import { isFeatureAvailable, isFeatureEnabled } from '@/services/runtime-config';
@@ -198,6 +199,7 @@ import { fetchSocialVelocity } from '@/services/social-velocity';
import { fetchShippingStress } from '@/services/supply-chain';
import { getTopActiveGeoHubs } from '@/services/geo-activity';
import { getTopActiveHubs } from '@/services/tech-activity';
+import { filterSportsHeadlineNoise } from '@/services/sports-headline-filter';
import type { GeoHubsPanel } from '@/components/GeoHubsPanel';
import type { TechHubsPanel } from '@/components/TechHubsPanel';
@@ -420,6 +422,8 @@ export class DataLoaderManager implements AppModule {
}
async loadAllData(forceAll = false): Promise {
+ const isSportsVariant = SITE_VARIANT === 'sports';
+
const runGuarded = async (name: string, fn: () => Promise): Promise => {
if (this.ctx.isDestroyed || this.ctx.inFlight.has(name)) return;
this.ctx.inFlight.add(name);
@@ -439,8 +443,8 @@ export class DataLoaderManager implements AppModule {
{ name: 'news', task: runGuarded('news', () => this.loadNews()) },
];
- // Happy variant only loads news data -- skip all geopolitical/financial/military data
- if (SITE_VARIANT !== 'happy') {
+ // Sports keeps news-only bulk loading. Happy keeps its dedicated positive-data pipeline.
+ if (SITE_VARIANT !== 'happy' && !isSportsVariant) {
if (shouldLoadAny(['markets', 'heatmap', 'commodities', 'crypto', 'energy-complex', 'crypto-heatmap', 'defi-tokens', 'ai-tokens', 'other-tokens'])) {
tasks.push({ name: 'markets', task: runGuarded('markets', () => this.loadMarkets()) });
}
@@ -482,6 +486,10 @@ export class DataLoaderManager implements AppModule {
}
}
+ if (isSportsVariant && this.ctx.mapLayers.sportsFixtures) {
+ tasks.push({ name: 'sportsFixturesLayer', task: runGuarded('sportsFixturesLayer', () => this.loadSportsFixturesLayer()) });
+ }
+
// Progress charts data (happy variant only)
if (SITE_VARIANT === 'happy') {
if (shouldLoad('progress')) {
@@ -518,78 +526,80 @@ export class DataLoaderManager implements AppModule {
});
}
- if (shouldLoad('giving')) {
- tasks.push({
- name: 'giving',
- task: runGuarded('giving', async () => {
- const givingResult = await fetchGivingSummary();
- if (!givingResult.ok) {
- dataFreshness.recordError('giving', 'Giving data unavailable (retaining prior state)');
- return;
- }
- const data = givingResult.data;
- this.callPanel('giving', 'setData', data);
- if (data.platforms.length > 0) dataFreshness.recordUpdate('giving', data.platforms.length);
- }),
- });
- }
+ if (!isSportsVariant) {
+ if (shouldLoad('giving')) {
+ tasks.push({
+ name: 'giving',
+ task: runGuarded('giving', async () => {
+ const givingResult = await fetchGivingSummary();
+ if (!givingResult.ok) {
+ dataFreshness.recordError('giving', 'Giving data unavailable (retaining prior state)');
+ return;
+ }
+ const data = givingResult.data;
+ this.callPanel('giving', 'setData', data);
+ if (data.platforms.length > 0) dataFreshness.recordUpdate('giving', data.platforms.length);
+ }),
+ });
+ }
- if (SITE_VARIANT === 'full') {
- try {
- const cached = await fetchCachedRiskScores().catch(() => null);
- if (cached && cached.cii.length > 0) {
- (this.ctx.panels['cii'] as CIIPanel)?.renderFromCached(cached);
- this.ctx.map?.setCIIScores(cached.cii.map(s => ({ code: s.code, score: s.score, level: s.level })));
- this.ctx.map?.setLayerReady('ciiChoropleth', true);
+ if (SITE_VARIANT === 'full') {
+ try {
+ const cached = await fetchCachedRiskScores().catch(() => null);
+ if (cached && cached.cii.length > 0) {
+ (this.ctx.panels['cii'] as CIIPanel)?.renderFromCached(cached);
+ this.ctx.map?.setCIIScores(cached.cii.map(s => ({ code: s.code, score: s.score, level: s.level })));
+ this.ctx.map?.setLayerReady('ciiChoropleth', true);
+ }
+ } catch { /* non-fatal */ }
+ }
+ // Intelligence signals: run for any variant that shows these panels
+ if (shouldLoadAny(['cii', 'strategic-risk', 'strategic-posture', 'climate', 'population-exposure', 'security-advisories', 'radiation-watch', 'displacement', 'ucdp-events', 'satellite-fires', 'oref-sirens'])) {
+ tasks.push({ name: 'intelligence', task: runGuarded('intelligence', () => this.loadIntelligenceSignals()) });
+ }
+
+ if (SITE_VARIANT === 'full' && (shouldLoad('satellite-fires') || this.ctx.mapLayers.natural)) {
+ tasks.push({ name: 'firms', task: runGuarded('firms', () => this.loadFirmsData()) });
+ }
+ if (this.ctx.mapLayers.natural) tasks.push({ name: 'natural', task: runGuarded('natural', () => this.loadNatural()) });
+ if (this.ctx.mapLayers.diseaseOutbreaks || shouldLoad('disease-outbreaks')) tasks.push({ name: 'diseaseOutbreaks', task: runGuarded('diseaseOutbreaks', () => this.loadDiseaseOutbreaks()) });
+ if (shouldLoad('social-velocity')) tasks.push({ name: 'socialVelocity', task: runGuarded('socialVelocity', () => this.loadSocialVelocity()) });
+ if (hasPremiumAccess() && shouldLoad('wsb-ticker-scanner')) tasks.push({ name: 'wsbTickers', task: runGuarded('wsbTickers', () => this.loadWsbTickers()) });
+ if (shouldLoad('economic')) tasks.push({ name: 'economicStress', task: runGuarded('economicStress', () => this.loadEconomicStress()) });
+ if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.weather) tasks.push({ name: 'weather', task: runGuarded('weather', () => this.loadWeatherAlerts()) });
+ if (SITE_VARIANT !== 'happy' && !isDesktopRuntime() && this.ctx.mapLayers.ais) tasks.push({ name: 'ais', task: runGuarded('ais', () => this.loadAisSignals()) });
+ if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.cables) tasks.push({ name: 'cables', task: runGuarded('cables', () => this.loadCableActivity()) });
+ if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.cables) tasks.push({ name: 'cableHealth', task: runGuarded('cableHealth', () => this.loadCableHealth()) });
+ if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.flights) tasks.push({ name: 'flights', task: runGuarded('flights', () => this.loadFlightDelays()) });
+ if (SITE_VARIANT !== 'happy' && CYBER_LAYER_ENABLED && this.ctx.mapLayers.cyberThreats) tasks.push({ name: 'cyberThreats', task: runGuarded('cyberThreats', () => this.loadCyberThreats()) });
+ if (SITE_VARIANT !== 'happy' && !isDesktopRuntime() && (this.ctx.mapLayers.iranAttacks || shouldLoadAny(['cii', 'strategic-risk', 'strategic-posture']))) tasks.push({ name: 'iranAttacks', task: runGuarded('iranAttacks', () => this.loadIranEvents()) });
+ if (SITE_VARIANT !== 'happy' && (this.ctx.mapLayers.techEvents || SITE_VARIANT === 'tech')) tasks.push({ name: 'techEvents', task: runGuarded('techEvents', () => this.loadTechEvents()) });
+ if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.satellites && this.ctx.map?.isGlobeMode?.()) tasks.push({ name: 'satellites', task: runGuarded('satellites', () => this.loadSatellites()) });
+ if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.webcams) tasks.push({ name: 'webcams', task: runGuarded('webcams', () => this.loadWebcams()) });
+ if (SITE_VARIANT !== 'happy' && (shouldLoad('sanctions-pressure') || this.ctx.mapLayers.sanctions)) {
+ tasks.push({ name: 'sanctions', task: runGuarded('sanctions', () => this.loadSanctionsPressure()) });
+ }
+ if (SITE_VARIANT !== 'happy' && (shouldLoad('radiation-watch') || this.ctx.mapLayers.radiationWatch)) {
+ tasks.push({ name: 'radiation', task: runGuarded('radiation', () => this.loadRadiationWatch()) });
+ }
+
+ if (this.ctx.mapLayers.resilienceScore) {
+ if (hasPremiumAccess()) {
+ tasks.push({ name: 'resilienceRanking', task: runGuarded('resilienceRanking', () => this.loadResilienceRanking()) });
+ } else {
+ this.ctx.map?.setResilienceRanking([]);
+ this.ctx.map?.setLayerReady('resilienceScore', false);
}
- } catch { /* non-fatal */ }
- }
- // Intelligence signals: run for any variant that shows these panels
- if (shouldLoadAny(['cii', 'strategic-risk', 'strategic-posture', 'climate', 'population-exposure', 'security-advisories', 'radiation-watch', 'displacement', 'ucdp-events', 'satellite-fires', 'oref-sirens'])) {
- tasks.push({ name: 'intelligence', task: runGuarded('intelligence', () => this.loadIntelligenceSignals()) });
- }
-
- if (SITE_VARIANT === 'full' && (shouldLoad('satellite-fires') || this.ctx.mapLayers.natural)) {
- tasks.push({ name: 'firms', task: runGuarded('firms', () => this.loadFirmsData()) });
- }
- if (this.ctx.mapLayers.natural) tasks.push({ name: 'natural', task: runGuarded('natural', () => this.loadNatural()) });
- if (this.ctx.mapLayers.diseaseOutbreaks || shouldLoad('disease-outbreaks')) tasks.push({ name: 'diseaseOutbreaks', task: runGuarded('diseaseOutbreaks', () => this.loadDiseaseOutbreaks()) });
- if (shouldLoad('social-velocity')) tasks.push({ name: 'socialVelocity', task: runGuarded('socialVelocity', () => this.loadSocialVelocity()) });
- if (hasPremiumAccess() && shouldLoad('wsb-ticker-scanner')) tasks.push({ name: 'wsbTickers', task: runGuarded('wsbTickers', () => this.loadWsbTickers()) });
- if (shouldLoad('economic')) tasks.push({ name: 'economicStress', task: runGuarded('economicStress', () => this.loadEconomicStress()) });
- if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.weather) tasks.push({ name: 'weather', task: runGuarded('weather', () => this.loadWeatherAlerts()) });
- if (SITE_VARIANT !== 'happy' && !isDesktopRuntime() && this.ctx.mapLayers.ais) tasks.push({ name: 'ais', task: runGuarded('ais', () => this.loadAisSignals()) });
- if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.cables) tasks.push({ name: 'cables', task: runGuarded('cables', () => this.loadCableActivity()) });
- if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.cables) tasks.push({ name: 'cableHealth', task: runGuarded('cableHealth', () => this.loadCableHealth()) });
- if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.flights) tasks.push({ name: 'flights', task: runGuarded('flights', () => this.loadFlightDelays()) });
- if (SITE_VARIANT !== 'happy' && CYBER_LAYER_ENABLED && this.ctx.mapLayers.cyberThreats) tasks.push({ name: 'cyberThreats', task: runGuarded('cyberThreats', () => this.loadCyberThreats()) });
- if (SITE_VARIANT !== 'happy' && !isDesktopRuntime() && (this.ctx.mapLayers.iranAttacks || shouldLoadAny(['cii', 'strategic-risk', 'strategic-posture']))) tasks.push({ name: 'iranAttacks', task: runGuarded('iranAttacks', () => this.loadIranEvents()) });
- if (SITE_VARIANT !== 'happy' && (this.ctx.mapLayers.techEvents || SITE_VARIANT === 'tech')) tasks.push({ name: 'techEvents', task: runGuarded('techEvents', () => this.loadTechEvents()) });
- if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.satellites && this.ctx.map?.isGlobeMode?.()) tasks.push({ name: 'satellites', task: runGuarded('satellites', () => this.loadSatellites()) });
- if (SITE_VARIANT !== 'happy' && this.ctx.mapLayers.webcams) tasks.push({ name: 'webcams', task: runGuarded('webcams', () => this.loadWebcams()) });
- if (SITE_VARIANT !== 'happy' && (shouldLoad('sanctions-pressure') || this.ctx.mapLayers.sanctions)) {
- tasks.push({ name: 'sanctions', task: runGuarded('sanctions', () => this.loadSanctionsPressure()) });
- }
- if (this.ctx.mapLayers.resilienceScore) {
- if (hasPremiumAccess()) {
- tasks.push({ name: 'resilienceRanking', task: runGuarded('resilienceRanking', () => this.loadResilienceRanking()) });
- } else {
- this.ctx.map?.setResilienceRanking([]);
- this.ctx.map?.setLayerReady('resilienceScore', false);
}
- }
- if (SITE_VARIANT !== 'happy' && (shouldLoad('radiation-watch') || this.ctx.mapLayers.radiationWatch)) {
- tasks.push({ name: 'radiation', task: runGuarded('radiation', () => this.loadRadiationWatch()) });
- }
-
- if (SITE_VARIANT !== 'happy') {
- tasks.push({ name: 'techReadiness', task: runGuarded('techReadiness', () => (this.ctx.panels['tech-readiness'] as TechReadinessPanel)?.refresh()) });
- }
- if (SITE_VARIANT !== 'happy' && shouldLoad('thermal-escalation')) {
- tasks.push({ name: 'thermalEscalation', task: runGuarded('thermalEscalation', () => this.loadThermalEscalations()) });
- }
- if (SITE_VARIANT !== 'happy' && shouldLoad('cross-source-signals')) {
- tasks.push({ name: 'crossSourceSignals', task: runGuarded('crossSourceSignals', () => this.loadCrossSourceSignals()) });
+ if (SITE_VARIANT !== 'happy') {
+ tasks.push({ name: 'techReadiness', task: runGuarded('techReadiness', () => (this.ctx.panels['tech-readiness'] as TechReadinessPanel)?.refresh()) });
+ }
+ if (SITE_VARIANT !== 'happy' && shouldLoad('thermal-escalation')) {
+ tasks.push({ name: 'thermalEscalation', task: runGuarded('thermalEscalation', () => this.loadThermalEscalations()) });
+ }
+ if (SITE_VARIANT !== 'happy' && shouldLoad('cross-source-signals')) {
+ tasks.push({ name: 'crossSourceSignals', task: runGuarded('crossSourceSignals', () => this.loadCrossSourceSignals()) });
+ }
}
// Stagger startup: run tasks in small batches to avoid hammering upstreams
@@ -610,20 +620,22 @@ export class DataLoaderManager implements AppModule {
this.updateSearchIndex();
- if (hasPremiumAccess()) {
+ if (!isSportsVariant && hasPremiumAccess()) {
await Promise.allSettled([
this.loadDailyMarketBrief(),
this.loadMarketImplications(),
]);
}
- const bootstrapTemporal = consumeServerAnomalies();
- if (bootstrapTemporal.anomalies.length > 0 || bootstrapTemporal.trackedTypes.length > 0) {
- signalAggregator.ingestTemporalAnomalies(bootstrapTemporal.anomalies, bootstrapTemporal.trackedTypes);
- ingestTemporalAnomaliesForCII(bootstrapTemporal.anomalies);
- this.refreshCiiAndBrief();
- } else {
- this.refreshTemporalBaseline().catch(() => {});
+ if (!isSportsVariant) {
+ const bootstrapTemporal = consumeServerAnomalies();
+ if (bootstrapTemporal.anomalies.length > 0 || bootstrapTemporal.trackedTypes.length > 0) {
+ signalAggregator.ingestTemporalAnomalies(bootstrapTemporal.anomalies, bootstrapTemporal.trackedTypes);
+ ingestTemporalAnomaliesForCII(bootstrapTemporal.anomalies);
+ this.refreshCiiAndBrief();
+ } else {
+ this.refreshTemporalBaseline().catch(() => {});
+ }
}
}
@@ -675,6 +687,9 @@ export class DataLoaderManager implements AppModule {
await this.loadTechEvents();
console.log('[loadDataForLayer] techEvents loaded');
break;
+ case 'sportsFixtures':
+ await this.loadSportsFixturesLayer();
+ break;
case 'positiveEvents':
await this.loadPositiveEvents();
break;
@@ -855,12 +870,20 @@ export class DataLoaderManager implements AppModule {
return labels[range];
}
+ private applyCategoryQualityFilters(category: string, items: NewsItem[]): NewsItem[] {
+ if (SITE_VARIANT === 'sports' && category === 'sports') {
+ return filterSportsHeadlineNoise(items);
+ }
+ return items;
+ }
+
renderNewsForCategory(category: string, items: NewsItem[]): void {
- this.ctx.newsByCategory[category] = items;
+ const qualityFilteredItems = this.applyCategoryQualityFilters(category, items);
+ this.ctx.newsByCategory[category] = qualityFilteredItems;
const panel = this.ctx.newsPanels[category];
if (!panel) return;
- const filteredItems = this.filterItemsByTimeRange(items);
- if (filteredItems.length === 0 && items.length > 0) {
+ const filteredItems = this.filterItemsByTimeRange(qualityFilteredItems);
+ if (filteredItems.length === 0 && qualityFilteredItems.length > 0) {
panel.renderFilteredEmpty(`No items in ${this.getTimeRangeLabel()}`);
return;
}
@@ -1905,6 +1928,22 @@ export class DataLoaderManager implements AppModule {
}
}
+ async loadSportsFixturesLayer(): Promise {
+ if (SITE_VARIANT !== 'sports' && !this.ctx.mapLayers.sportsFixtures) return;
+
+ try {
+ const markers = await fetchSportsFixtureMapMarkers();
+ this.ctx.map?.setSportsFixtures(markers);
+ this.ctx.map?.setLayerReady('sportsFixtures', markers.length > 0);
+ this.ctx.statusPanel?.updateFeed('Sports Fixtures', { status: 'ok', itemCount: markers.length });
+ } catch (error) {
+ console.error('[App] Failed to load sports fixtures layer:', error);
+ this.ctx.map?.setSportsFixtures([]);
+ this.ctx.map?.setLayerReady('sportsFixtures', false);
+ this.ctx.statusPanel?.updateFeed('Sports Fixtures', { status: 'error', errorMessage: String(error) });
+ }
+ }
+
async loadWeatherAlerts(): Promise {
try {
const alerts = await fetchWeatherAlerts();
diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts
index 0980d75473..bc23ef228e 100644
--- a/src/app/panel-layout.ts
+++ b/src/app/panel-layout.ts
@@ -73,9 +73,20 @@ import {
GoldIntelligencePanel,
DiseaseOutbreaksPanel,
SocialVelocityPanel,
+ SportsTablesPanel,
+ SportsStatsPanel,
+ SportsLiveTrackerPanel,
+ SportsMajorTournamentsPanel,
+ SportsNbaPanel,
+ SportsMotorsportPanel,
+ SportsTransferNewsPanel,
+ SportsPlayerSearchPanel,
WsbTickerScannerPanel,
AAIISentimentPanel,
EnergyCrisisPanel,
+ SportsNbaAnalysisPanel,
+ SportsEuropeanFootballAnalysisPanel,
+ SportsMotorsportAnalysisPanel,
} from '@/components';
import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel';
import { focusInvestmentOnMap } from '@/services/investments-focus';
@@ -360,6 +371,15 @@ export class PanelLayoutManager implements AppModule {
title="Good News${SITE_VARIANT === 'happy' ? ` ${t('common.currentVariant')}` : ''}">
☀️
Good News
+
+
+
+ 🏟️
+ ${t('header.sports')}
`;
})()}
MONITORWorld Monitorv${__APP_VERSION__}${BETA_MODE ? 'BETA' : ''}
@@ -419,6 +439,7 @@ export class PanelLayoutManager implements AppModule {
{ key: 'finance', icon: '📈', label: t('header.finance') },
{ key: 'commodity', icon: '⛏️', label: t('header.commodity') },
{ key: 'happy', icon: '☀️', label: 'Good News' },
+ { key: 'sports', icon: '🏟️', label: t('header.sports') },
];
return variants.map(v =>
`