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 @@ + + + + + + ALP + 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 @@ + + + + + + AMR + 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 @@ + + + + + FER + 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 @@ + + + + + + HAA + 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 @@ + + + + + MCL + 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 @@ + + + + + MER + 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 @@ + + + + + + RB + 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 @@ + + + + + + + RBR + 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 @@ + + + + + + SAU + 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 @@ + + + + + WIL + 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')} `; })()} World 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 => ` + + + `; + } + + const teams = fixture.homeTeam && fixture.awayTeam + ? `${fixture.homeTeam} vs ${fixture.awayTeam}` + : fixture.title; + + return ` + + + `; + } + private renderTechEventPopup(event: TechEventPopupData): string { const startDate = new Date(event.startDate); const endDate = new Date(event.endDate); diff --git a/src/components/Panel.ts b/src/components/Panel.ts index d9297d0724..782ab60866 100644 --- a/src/components/Panel.ts +++ b/src/components/Panel.ts @@ -177,6 +177,7 @@ function setColSpanClass(element: HTMLElement, span: number): void { } function getRowSpan(element: HTMLElement): number { + if (element.classList.contains('span-5')) return 5; if (element.classList.contains('span-4')) return 4; if (element.classList.contains('span-3')) return 3; if (element.classList.contains('span-2')) return 2; @@ -187,11 +188,11 @@ function deltaToRowSpan(startSpan: number, deltaY: number): number { const spanDelta = deltaY > 0 ? Math.floor(deltaY / ROW_RESIZE_STEP_PX) : Math.ceil(deltaY / ROW_RESIZE_STEP_PX); - return Math.max(1, Math.min(4, startSpan + spanDelta)); + return Math.max(1, Math.min(5, startSpan + spanDelta)); } function setSpanClass(element: HTMLElement, span: number): void { - element.classList.remove('span-1', 'span-2', 'span-3', 'span-4'); + element.classList.remove('span-1', 'span-2', 'span-3', 'span-4', 'span-5'); element.classList.add(`span-${span}`); element.classList.add('resized'); } @@ -1112,7 +1113,7 @@ export class Panel { * Reset panel height to default */ public resetHeight(): void { - this.element.classList.remove('resized', 'span-1', 'span-2', 'span-3', 'span-4'); + this.element.classList.remove('resized', 'span-1', 'span-2', 'span-3', 'span-4', 'span-5'); const spans = loadPanelSpans(); delete spans[this.panelId]; localStorage.setItem(PANEL_SPANS_KEY, JSON.stringify(spans)); diff --git a/src/components/SportsEuropeanFootballAnalysisPanel.ts b/src/components/SportsEuropeanFootballAnalysisPanel.ts new file mode 100644 index 0000000000..b0cc6c4309 --- /dev/null +++ b/src/components/SportsEuropeanFootballAnalysisPanel.ts @@ -0,0 +1,364 @@ +import type { Feed, NewsItem } from '@/types'; +import { fetchCategoryFeeds } from '@/services'; +import { fetchEuropeanFootballTopLeagueTables, type SportsStandingRow, type SportsTableGroup } from '@/services/sports'; +import { rssProxyUrl } from '@/utils'; +import { escapeHtml } from '@/utils/sanitize'; +import { formatSportsForm, renderSportsTeamIdentity } from './sportsPanelShared'; +import { + SportsAnalysisPanelBase, + countFreshAnalysisStories, + dedupeNewsItems, + normalizeLookup, + renderAiBrief, + renderAnalysisCards, + renderAnalysisPoints, + renderAnalysisStories, + renderDistributionChips, + formatUpdatedAt, + type SportsAnalysisCard, + type SportsAnalysisPoint, + type SportsAnalysisStory, +} from './sportsAnalysisShared'; + +const EURO_FOOTBALL_ANALYSIS_FEEDS: Feed[] = [ + { name: 'BBC Football', url: rssProxyUrl('https://feeds.bbci.co.uk/sport/football/rss.xml?edition=uk') }, + { name: 'ESPN Soccer', url: rssProxyUrl('https://www.espn.com/espn/rss/soccer/news') }, + { name: 'Guardian Football', url: rssProxyUrl('https://www.theguardian.com/football/rss') }, + { name: 'European Leagues', url: rssProxyUrl('https://news.google.com/rss/search?q=(\"Premier League\" OR \"La Liga\" OR Bundesliga OR \"Serie A\" OR \"Ligue 1\" OR Eredivisie OR \"Primeira Liga\")+when:2d&hl=en-US&gl=US&ceid=US:en') }, +]; + +type LeagueSnapshot = { + table: SportsTableGroup; + leader: SportsStandingRow; + runnerUp: SportsStandingRow | null; + gap: number; + bestForm: SportsStandingRow | null; +}; + +type TaggedFootballStory = SportsAnalysisStory & { + tag: string; + league?: string; +}; + +type FootballAnalysisState = { + snapshots: LeagueSnapshot[]; + stories: TaggedFootballStory[]; + cards: SportsAnalysisCard[]; + points: SportsAnalysisPoint[]; + leagueMix: Array<{ label: string; count: number }>; + updatedAt: string; +}; + +function buildFormScore(form?: string): number { + return (form || '') + .toUpperCase() + .split('') + .reduce((score, result) => { + if (result === 'W') return score + 3; + if (result === 'D') return score + 1; + return score; + }, 0); +} + +function teamAliases(row: SportsStandingRow): string[] { + const parts = row.team.split(/\s+/).filter(Boolean); + const acronym = parts.length >= 2 ? parts.map((part) => part[0]).join('') : ''; + const aliases = [ + row.team, + acronym, + parts.slice(-1).join(' '), + parts.slice(-2).join(' '), + ]; + + return [...new Set(aliases.map(normalizeLookup).filter((alias) => alias.length >= 2))]; +} + +function buildLeagueSnapshots(tables: SportsTableGroup[]): LeagueSnapshot[] { + return tables + .map((table) => { + const leader = table.rows[0]; + if (!leader) return null; + const runnerUp = table.rows[1] || null; + const bestForm = [...table.rows] + .filter((row) => !!row.form) + .sort((a, b) => buildFormScore(b.form) - buildFormScore(a.form) || b.points - a.points)[0] || null; + + return { + table, + leader, + runnerUp, + gap: runnerUp ? Math.max(leader.points - runnerUp.points, 0) : 0, + bestForm, + } satisfies LeagueSnapshot; + }) + .filter((snapshot): snapshot is LeagueSnapshot => !!snapshot); +} + +function buildLeagueAliases(snapshot: LeagueSnapshot): string[] { + const leagueNames = [ + snapshot.table.league.name, + snapshot.table.league.shortName, + ]; + const teamNames = snapshot.table.rows.slice(0, 8).flatMap((row) => teamAliases(row)); + return [...new Set([ + ...leagueNames.map(normalizeLookup), + ...teamNames, + ].filter((alias) => alias.length >= 2))]; +} + +function matchStoryToLeague(title: string, snapshots: LeagueSnapshot[]): LeagueSnapshot | null { + const normalized = normalizeLookup(title); + let best: { snapshot: LeagueSnapshot; score: number } | null = null; + + for (const snapshot of snapshots) { + const aliases = buildLeagueAliases(snapshot); + let score = 0; + for (const alias of aliases) { + if (!normalized.includes(alias)) continue; + score = Math.max(score, alias === normalizeLookup(snapshot.table.league.name) ? 120 : alias === normalizeLookup(snapshot.table.league.shortName) ? 95 : 65); + } + if (score > 0 && (!best || score > best.score)) { + best = { snapshot, score }; + } + } + + return best?.snapshot || null; +} + +function buildTaggedStories(items: NewsItem[], snapshots: LeagueSnapshot[]): TaggedFootballStory[] { + const deduped = dedupeNewsItems(items); + const tagged = deduped.map((item) => { + const match = matchStoryToLeague(item.title, snapshots); + return { + title: item.title, + link: item.link, + source: item.source, + publishedAt: item.pubDate, + league: match?.table.league.shortName || match?.table.league.name, + tag: match?.table.league.shortName || 'Europe', + }; + }); + + const matched = tagged.filter((story) => story.league); + return (matched.length >= 6 ? matched : tagged).slice(0, 10); +} + +function buildLeagueMix(stories: TaggedFootballStory[]): Array<{ label: string; count: number }> { + const counts = new Map(); + for (const story of stories) { + counts.set(story.tag, (counts.get(story.tag) || 0) + 1); + } + return [...counts.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, 5) + .map(([label, count]) => ({ label, count })); +} + +function buildCards(snapshots: LeagueSnapshot[], stories: TaggedFootballStory[]): SportsAnalysisCard[] { + const tightest = [...snapshots].sort((a, b) => a.gap - b.gap)[0]; + const biggestCushion = [...snapshots].sort((a, b) => b.gap - a.gap)[0]; + const hottest = [...snapshots] + .filter((snapshot) => snapshot.bestForm) + .sort((a, b) => buildFormScore(b.bestForm?.form) - buildFormScore(a.bestForm?.form))[0]; + const storyFocus = buildLeagueMix(stories)[0]; + + const cards: SportsAnalysisCard[] = []; + if (tightest) cards.push({ label: 'Tightest Race', value: `${tightest.gap} pts`, detail: tightest.table.league.shortName, tone: 'amber' }); + if (biggestCushion) cards.push({ label: 'Biggest Cushion', value: `${biggestCushion.gap} pts`, detail: biggestCushion.table.league.shortName, tone: 'sky' }); + if (hottest?.bestForm) cards.push({ label: 'Hottest Club', value: hottest.bestForm.team, detail: `${formatSportsForm(hottest.bestForm.form)} · ${hottest.table.league.shortName}`, tone: 'emerald' }); + if (storyFocus) cards.push({ label: 'Story Focus', value: storyFocus.label, detail: `${storyFocus.count} recent headlines`, tone: 'rose' }); + return cards; +} + +function buildPoints(snapshots: LeagueSnapshot[], stories: TaggedFootballStory[]): SportsAnalysisPoint[] { + const tightest = [...snapshots].sort((a, b) => a.gap - b.gap)[0]; + const biggestCushion = [...snapshots].sort((a, b) => b.gap - a.gap)[0]; + const hottest = [...snapshots] + .filter((snapshot) => snapshot.bestForm) + .sort((a, b) => buildFormScore(b.bestForm?.form) - buildFormScore(a.bestForm?.form))[0]; + const focus = buildLeagueMix(stories)[0]; + + const points: SportsAnalysisPoint[] = []; + if (tightest && tightest.runnerUp) { + points.push({ + label: 'Title Pressure', + text: `${tightest.table.league.name} is the sharpest title race in the pack, with ${tightest.leader.team} only ${tightest.gap} points clear of ${tightest.runnerUp.team}.`, + }); + } + if (biggestCushion && biggestCushion.runnerUp) { + points.push({ + label: 'Control', + text: `${biggestCushion.leader.team} has the most breathing room of the seven-league set, sitting ${biggestCushion.gap} points ahead in ${biggestCushion.table.league.name}.`, + }); + } + if (hottest?.bestForm) { + points.push({ + label: 'Form', + text: `${hottest.bestForm.team} owns the strongest recent form line at ${formatSportsForm(hottest.bestForm.form)}, which is the cleanest short-term signal in the title and Champions League races.`, + }); + } + if (focus) { + points.push({ + label: 'Story Tape', + text: `${focus.label} is absorbing the most recent headline flow, so the media narrative is leaning there even though the full top-seven picture remains spread across the table board.`, + }); + } + + return points.slice(0, 4); +} + +function buildFallbackBrief(snapshots: LeagueSnapshot[], stories: TaggedFootballStory[]): string { + const tightest = [...snapshots].sort((a, b) => a.gap - b.gap)[0]; + const hottest = [...snapshots] + .filter((snapshot) => snapshot.bestForm) + .sort((a, b) => buildFormScore(b.bestForm?.form) - buildFormScore(a.bestForm?.form))[0]; + const focus = buildLeagueMix(stories)[0]; + return `${tightest?.table.league.shortName || 'The top-seven set'} has the tightest title pressure, ${hottest?.bestForm?.team || 'the hottest club'} is carrying the best recent form, and story flow is leaning toward ${focus?.label || 'Europe-wide themes'}.`; +} + +function buildSummaryInputs(snapshots: LeagueSnapshot[], stories: TaggedFootballStory[]): string[] { + const tightest = [...snapshots].sort((a, b) => a.gap - b.gap)[0]; + const biggestCushion = [...snapshots].sort((a, b) => b.gap - a.gap)[0]; + const hottest = [...snapshots] + .filter((snapshot) => snapshot.bestForm) + .sort((a, b) => buildFormScore(b.bestForm?.form) - buildFormScore(a.bestForm?.form))[0]; + const focus = buildLeagueMix(stories)[0]; + + return [ + ...stories.slice(0, 5).map((story) => story.title), + tightest ? `${tightest.table.league.name} is the tightest race at ${tightest.gap} points` : '', + biggestCushion ? `${biggestCushion.leader.team} has the biggest cushion at ${biggestCushion.gap} points in ${biggestCushion.table.league.name}` : '', + hottest?.bestForm ? `${hottest.bestForm.team} has the best recent form at ${formatSportsForm(hottest.bestForm.form)}` : '', + focus ? `${focus.label} is driving the most football headlines` : '', + ].filter(Boolean); +} + +function renderLeagueBoard(snapshots: LeagueSnapshot[]): string { + return ` +
+
+
Top 7 League Board
+
Leader, gap, and hottest form
+
+
+ + + + + + + + + + + ${snapshots.map((snapshot) => ` + + + + + + + `).join('')} + +
LeagueLeaderGapBest Form
+
+
${escapeHtml(snapshot.table.league.name)}
+
${escapeHtml(snapshot.table.season || 'Current season')}
+
+
+ ${renderSportsTeamIdentity(snapshot.leader.team, snapshot.leader.badge, { secondary: `${snapshot.leader.points} pts`, size: 24 })} + ${snapshot.runnerUp ? `${snapshot.gap} pts` : '—'} + ${snapshot.bestForm ? ` +
+
${escapeHtml(snapshot.bestForm.team)}
+
${escapeHtml(formatSportsForm(snapshot.bestForm.form))}
+
+ ` : ''} +
+
+
+ `; +} + +export class SportsEuropeanFootballAnalysisPanel extends SportsAnalysisPanelBase { + constructor() { + super({ + id: 'sports-football-analysis', + title: 'European Football AI', + showCount: true, + className: 'panel-wide', + defaultRowSpan: 5, + infoTooltip: 'European football storylines across the top seven leagues, summarized with AI and anchored to live title-race tables.', + }); + } + + public async fetchData(): Promise { + this.showLoading('Loading European football analysis...'); + try { + const [tables, rawStories] = await Promise.all([ + fetchEuropeanFootballTopLeagueTables(), + fetchCategoryFeeds(EURO_FOOTBALL_ANALYSIS_FEEDS, { batchSize: 2 }), + ]); + + const snapshots = buildLeagueSnapshots(tables); + if (!snapshots.length) { + this.setCount(0); + this.showError('European football analysis is unavailable right now.', () => void this.fetchData()); + return false; + } + + const stories = buildTaggedStories(rawStories, snapshots); + const freshCount = countFreshAnalysisStories(stories); + const updatedAt = snapshots + .map((snapshot) => snapshot.table.updatedAt) + .filter((value): value is string => !!value) + .sort() + .reverse()[0] || new Date().toISOString(); + + this.data = { + snapshots, + stories, + cards: buildCards(snapshots, stories), + points: buildPoints(snapshots, stories), + leagueMix: buildLeagueMix(stories), + updatedAt, + }; + this.fallbackBrief = buildFallbackBrief(snapshots, stories); + this.setCount(stories.length); + this.setNewBadge(freshCount, freshCount > 0); + this.renderPanel(); + this.requestAiBrief(buildSummaryInputs(snapshots, stories)); + return true; + } catch (error) { + if (this.isAbortError(error)) return false; + this.setCount(0); + this.showError('Failed to load European football analysis.', () => void this.fetchData()); + return false; + } + } + + protected renderPanel(): void { + if (!this.data) { + this.setContent('
Loading European football analysis.
'); + return; + } + + this.setContent(` +
+
+
Top 7 League Desk
+
European football title-race pressure and story flow
+
AI summary stacked on top of live league-table context for the Premier League, La Liga, Bundesliga, Serie A, Ligue 1, Eredivisie, and Primeira Liga.
+
Updated ${escapeHtml(formatUpdatedAt(this.data.updatedAt))}
+
+ + ${renderAiBrief(this.aiBrief, this.fallbackBrief, this.aiPending)} + ${renderAnalysisCards(this.data.cards)} + ${renderDistributionChips('League Focus', this.data.leagueMix.map((entry) => ({ label: entry.label, value: `${entry.count} stories` })))} + ${renderLeagueBoard(this.data.snapshots)} + ${renderAnalysisPoints('What Stands Out', this.data.points)} + ${renderAnalysisStories('Key Storylines', this.data.stories, 'No European football storylines are available right now.')} +
+ `); + } +} diff --git a/src/components/SportsFixturesPanel.ts b/src/components/SportsFixturesPanel.ts new file mode 100644 index 0000000000..72cee7b4f1 --- /dev/null +++ b/src/components/SportsFixturesPanel.ts @@ -0,0 +1,100 @@ +import { Panel } from './Panel'; +import { escapeHtml } from '@/utils/sanitize'; +import { fetchFeaturedSportsFixtures, parseEventTimestamp, type SportsEvent, type SportsFixtureGroup } from '@/services/sports'; +import { renderSportsTeamIdentity } from './sportsPanelShared'; + +function formatFixtureDayLabel(date = new Date()): string { + return date.toLocaleDateString(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + }); +} + +function formatEventClock(event: SportsEvent): string { + const timestamp = parseEventTimestamp(event); + if (timestamp !== Number.MAX_SAFE_INTEGER) { + return new Date(timestamp).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + } + if (event.dateEvent && event.strTime) return `${event.dateEvent} ${event.strTime}`; + return event.dateEvent || event.strTime || 'TBD'; +} + +export class SportsFixturesPanel extends Panel { + private groups: SportsFixtureGroup[] = []; + + constructor() { + super({ + id: 'sports-fixtures', + title: 'Daily Fixtures', + showCount: true, + infoTooltip: 'Today\'s fixtures across top football leagues, the NBA, and motorsport calendars from open sports schedule feeds.', + }); + } + + public async fetchData(): Promise { + this.showLoading('Loading daily fixtures...'); + try { + this.groups = await fetchFeaturedSportsFixtures(); + if (this.groups.length === 0) { + this.setCount(0); + this.showError('No daily fixture data available right now.', () => void this.fetchData()); + return false; + } + this.setCount(this.groups.reduce((sum, group) => sum + group.events.length, 0)); + this.renderPanel(); + return true; + } catch (error) { + if (this.isAbortError(error)) return false; + this.setCount(0); + this.showError('Failed to load fixtures.', () => void this.fetchData()); + return false; + } + } + + private renderPanel(): void { + const dayLabel = formatFixtureDayLabel(); + const html = ` +
+
+
Daily Schedule
+
${escapeHtml(dayLabel)}
+
+ ${this.groups.map((group) => ` +
+
+
+
${escapeHtml(group.league.sport)}
+
${escapeHtml(group.league.name)}
+
+
${escapeHtml(group.league.country || group.league.shortName)}
+
+
+ ${group.events.map((event) => ` +
+
+ ${renderSportsTeamIdentity(event.strHomeTeam || 'Home', event.strHomeBadge)} +
${escapeHtml(formatEventClock(event))}
+ ${renderSportsTeamIdentity(event.strAwayTeam || 'Away', event.strAwayBadge, { align: 'right' })} +
+
${escapeHtml(event.strEvent || `${event.strHomeTeam || 'Home'} vs ${event.strAwayTeam || 'Away'}`)}
+
+ ${escapeHtml(event.strVenue || 'Venue TBD')} + ${escapeHtml(event.strRound ? `Round ${event.strRound}` : event.strSeason || '')} +
+
+ `).join('')} +
+
+ `).join('')} +
+ `; + + this.setContent(html); + } +} diff --git a/src/components/SportsLiveTrackerPanel.ts b/src/components/SportsLiveTrackerPanel.ts new file mode 100644 index 0000000000..f477a7deed --- /dev/null +++ b/src/components/SportsLiveTrackerPanel.ts @@ -0,0 +1,495 @@ +import { Panel } from './Panel'; +import { escapeHtml } from '@/utils/sanitize'; +import { + fetchSportsFixtureSnapshot, + parseEventTimestamp, + searchFeaturedSportsFixtures, + type SportsEvent, + type SportsFixtureSearchMatch, + type SportsStatSnapshot, +} from '@/services/sports'; +import { renderSportsTeamIdentity } from './sportsPanelShared'; + +const TRACKER_QUERY_KEY = 'wm-sports-live-tracker-query'; +const TRACKER_SELECTION_KEY = 'wm-sports-live-tracker-selection'; +const MAX_TRACKED_FIXTURES = 8; + +type TrackedFixture = { + eventId: string; + leagueId?: string; + leagueName?: string; + sport?: string; + label?: string; + homeTeam?: string; + awayTeam?: string; + homeBadge?: string; + awayBadge?: string; +}; + +const LIVE_STATUS_MARKERS = ['live', 'in progress', 'halftime', 'quarter', 'period', 'overtime', 'extra time']; + +function toOptionalString(value: unknown): string | undefined { + if (value == null) return undefined; + const trimmed = String(value).trim(); + return trimmed ? trimmed : undefined; +} + +function loadStoredString(key: string): string { + try { + return localStorage.getItem(key) || ''; + } catch { + return ''; + } +} + +function saveStoredString(key: string, value: string): void { + try { + if (value) localStorage.setItem(key, value); + else localStorage.removeItem(key); + } catch { + // Ignore storage failures. + } +} + +function sanitizeTrackedFixture(raw: unknown): TrackedFixture | null { + if (!raw || typeof raw !== 'object') return null; + const source = raw as Record; + const eventId = toOptionalString(source.eventId); + if (!eventId) return null; + + return { + eventId, + leagueId: toOptionalString(source.leagueId), + leagueName: toOptionalString(source.leagueName), + sport: toOptionalString(source.sport), + label: toOptionalString(source.label), + homeTeam: toOptionalString(source.homeTeam), + awayTeam: toOptionalString(source.awayTeam), + homeBadge: toOptionalString(source.homeBadge), + awayBadge: toOptionalString(source.awayBadge), + }; +} + +function loadTrackedFixtures(): TrackedFixture[] { + try { + const raw = localStorage.getItem(TRACKER_SELECTION_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + const seen = new Set(); + const fixtures: TrackedFixture[] = []; + for (const entry of parsed) { + const fixture = sanitizeTrackedFixture(entry); + if (!fixture || seen.has(fixture.eventId)) continue; + seen.add(fixture.eventId); + fixtures.push(fixture); + if (fixtures.length >= MAX_TRACKED_FIXTURES) break; + } + return fixtures; + } catch { + return []; + } +} + +function saveTrackedFixtures(fixtures: TrackedFixture[]): void { + try { + if (!fixtures.length) { + localStorage.removeItem(TRACKER_SELECTION_KEY); + return; + } + localStorage.setItem(TRACKER_SELECTION_KEY, JSON.stringify(fixtures.slice(0, MAX_TRACKED_FIXTURES))); + } catch { + // Ignore storage failures. + } +} + +function toTrackedFixture(match: SportsFixtureSearchMatch): TrackedFixture { + const { league, event } = match; + const title = event.strEvent || [event.strHomeTeam, event.strAwayTeam].filter(Boolean).join(' vs ') || league.name; + return { + eventId: event.idEvent, + leagueId: event.idLeague || league.id, + leagueName: event.strLeague || league.name, + sport: event.strSport || league.sport, + label: title, + homeTeam: event.strHomeTeam, + awayTeam: event.strAwayTeam, + homeBadge: event.strHomeBadge, + awayBadge: event.strAwayBadge, + }; +} + +function formatFixtureTime(event: Pick): string { + const ts = parseEventTimestamp(event); + if (ts !== Number.MAX_SAFE_INTEGER) { + return new Date(ts).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + } + if (event.dateEvent && event.strTime) return `${event.dateEvent} ${event.strTime}`; + return event.dateEvent || event.strTime || 'TBD'; +} + +function getFixtureLabel(event: Pick, fallback = 'Fixture'): string { + return event.strEvent || [event.strHomeTeam, event.strAwayTeam].filter(Boolean).join(' vs ') || fallback; +} + +function isLiveFixture(event: Pick): boolean { + const status = `${event.strStatus || ''} ${event.strProgress || ''}`.toLowerCase(); + if (LIVE_STATUS_MARKERS.some((marker) => status.includes(marker))) return true; + if ((event.intHomeScore || event.intAwayScore) && status && !status.includes('final')) return true; + return false; +} + +function getFixtureStatusLabel(event: Pick): string { + if (event.strProgress && event.strProgress !== event.strStatus) return event.strProgress; + if (event.strStatus) return event.strStatus; + return formatFixtureTime(event); +} + +function renderSearchResults(results: SportsFixtureSearchMatch[], trackedEventIds: Set): string { + if (!results.length) return ''; + + return ` +
+
+
Fixture Matches
+
${results.length} result${results.length === 1 ? '' : 's'}
+
+
+ ${results.map((match) => { + const { event, league } = match; + const tracked = trackedEventIds.has(event.idEvent); + const live = isLiveFixture(event); + return ` +
+
+
${escapeHtml(event.strLeague || league.shortName || league.name)}
+
+ ${escapeHtml(getFixtureStatusLabel(event))} +
+
+
+ ${renderSportsTeamIdentity(event.strHomeTeam || 'Home', event.strHomeBadge)} +
${event.intHomeScore || event.intAwayScore ? `${escapeHtml(event.intHomeScore || '-')}-${escapeHtml(event.intAwayScore || '-')}` : 'vs'}
+ ${renderSportsTeamIdentity(event.strAwayTeam || 'Away', event.strAwayBadge, { align: 'right' })} +
+
+
${escapeHtml(formatFixtureTime(event))}
+ +
+
+ `; + }).join('')} +
+
+ `; +} + +function renderTrackedFixtureCard(fixture: TrackedFixture, snapshot: SportsStatSnapshot | undefined): string { + const event = snapshot?.event || { + idEvent: fixture.eventId, + strLeague: fixture.leagueName, + strSport: fixture.sport, + strEvent: fixture.label, + strHomeTeam: fixture.homeTeam, + strAwayTeam: fixture.awayTeam, + strHomeBadge: fixture.homeBadge, + strAwayBadge: fixture.awayBadge, + } satisfies SportsEvent; + + const leagueLabel = snapshot?.league.shortName || event.strLeague || fixture.sport || 'Fixture'; + const live = isLiveFixture(event); + const stats = snapshot?.stats || []; + const score = event.intHomeScore || event.intAwayScore + ? `${event.intHomeScore || '-'} - ${event.intAwayScore || '-'}` + : 'vs'; + + return ` +
+
+
+
${escapeHtml(leagueLabel)}
+
${escapeHtml(getFixtureLabel(event, fixture.label || 'Selected fixture'))}
+
${escapeHtml(formatFixtureTime(event))}
+
+
+ + ${escapeHtml(getFixtureStatusLabel(event))} + + +
+
+
+ ${renderSportsTeamIdentity(event.strHomeTeam || fixture.homeTeam || 'Home', event.strHomeBadge || fixture.homeBadge)} +
${escapeHtml(score)}
+ ${renderSportsTeamIdentity(event.strAwayTeam || fixture.awayTeam || 'Away', event.strAwayBadge || fixture.awayBadge, { align: 'right' })} +
+
+ ${stats.length > 0 + ? stats.map((stat) => ` +
+ ${escapeHtml(stat.homeValue || '-')} + ${escapeHtml(stat.label)} + ${escapeHtml(stat.awayValue || '-')} +
+ `).join('') + : '
Waiting for live stats feed.
'} +
+
+ `; +} + +export class SportsLiveTrackerPanel extends Panel { + private query = loadStoredString(TRACKER_QUERY_KEY); + private searchResults: SportsFixtureSearchMatch[] = []; + private trackedFixtures: TrackedFixture[] = loadTrackedFixtures(); + private snapshots = new Map(); + private isSearching = false; + private isRefreshingTracked = false; + private statusMessage = ''; + private errorMessage = ''; + private loadToken = 0; + + constructor() { + super({ + id: 'sports-live-tracker', + title: 'Live Fixture Tracker', + showCount: true, + className: 'panel-wide', + defaultRowSpan: 4, + infoTooltip: 'Search fixtures, add multiple matches to your watchlist, and track score plus live stat updates in one panel.', + }); + + this.content.addEventListener('submit', (event) => { + const form = (event.target as HTMLElement).closest('form[data-action="fixture-search"]') as HTMLFormElement | null; + if (!form) return; + event.preventDefault(); + const input = form.querySelector('input[name="fixture-query"]') as HTMLInputElement | null; + void this.searchFixtures(input?.value || ''); + }); + + this.content.addEventListener('click', (event) => { + const target = (event.target as HTMLElement).closest('[data-action]'); + const action = target?.dataset.action; + if (!action) return; + + if (action === 'fixture-refresh') { + void this.refreshTrackedFixtures(); + return; + } + + const eventId = target?.dataset.eventId; + if (!eventId) return; + + if (action === 'fixture-track') { + const match = this.searchResults.find((entry) => entry.event.idEvent === eventId); + if (match) this.addTrackedFixture(match); + return; + } + + if (action === 'fixture-remove') { + this.removeTrackedFixture(eventId); + } + }); + } + + public async fetchData(): Promise { + this.setCount(this.trackedFixtures.length); + + if (this.trackedFixtures.length > 0) { + return this.refreshTrackedFixtures(); + } + + if (this.query && this.searchResults.length === 0) { + return this.searchFixtures(this.query); + } + + this.renderPanel(); + return true; + } + + private async searchFixtures(rawQuery: string): Promise { + const trimmed = rawQuery.trim(); + this.query = trimmed; + saveStoredString(TRACKER_QUERY_KEY, trimmed); + this.errorMessage = ''; + this.statusMessage = ''; + + if (!trimmed) { + this.searchResults = []; + this.renderPanel(); + return true; + } + + const token = ++this.loadToken; + this.isSearching = true; + this.renderPanel(); + + try { + const results = await searchFeaturedSportsFixtures(trimmed, 24); + if (token !== this.loadToken) return false; + this.searchResults = results; + this.isSearching = false; + if (!results.length) { + this.statusMessage = `No fixtures found for "${trimmed}".`; + } + this.renderPanel(); + return results.length > 0; + } catch (error) { + if (this.isAbortError(error)) return false; + this.isSearching = false; + this.errorMessage = 'Failed to search fixtures.'; + this.renderPanel(); + return false; + } + } + + private addTrackedFixture(match: SportsFixtureSearchMatch): void { + if (this.trackedFixtures.some((fixture) => fixture.eventId === match.event.idEvent)) { + this.statusMessage = 'This fixture is already being tracked.'; + this.renderPanel(); + return; + } + if (this.trackedFixtures.length >= MAX_TRACKED_FIXTURES) { + this.statusMessage = `Tracker limit reached (${MAX_TRACKED_FIXTURES} fixtures). Remove one first.`; + this.renderPanel(); + return; + } + + this.trackedFixtures = [...this.trackedFixtures, toTrackedFixture(match)]; + saveTrackedFixtures(this.trackedFixtures); + this.setCount(this.trackedFixtures.length); + this.statusMessage = `Tracking ${getFixtureLabel(match.event, 'fixture')}.`; + this.renderPanel(); + void this.refreshTrackedFixtures(); + } + + private removeTrackedFixture(eventId: string): void { + const next = this.trackedFixtures.filter((fixture) => fixture.eventId !== eventId); + if (next.length === this.trackedFixtures.length) return; + this.trackedFixtures = next; + saveTrackedFixtures(next); + this.snapshots.delete(eventId); + this.setCount(this.trackedFixtures.length); + this.statusMessage = ''; + this.errorMessage = ''; + this.renderPanel(); + } + + private async refreshTrackedFixtures(): Promise { + this.setCount(this.trackedFixtures.length); + if (!this.trackedFixtures.length) { + this.renderPanel(); + return true; + } + + const token = ++this.loadToken; + this.isRefreshingTracked = true; + this.errorMessage = ''; + this.renderPanel(); + + try { + const responses = await Promise.all( + this.trackedFixtures.map(async (fixture) => ({ + fixture, + snapshot: await fetchSportsFixtureSnapshot(fixture.eventId, fixture.leagueId, fixture.leagueName).catch(() => null), + })), + ); + if (token !== this.loadToken) return false; + + const nextSnapshots = new Map(); + let resolvedCount = 0; + for (const { fixture, snapshot } of responses) { + if (snapshot) { + nextSnapshots.set(fixture.eventId, snapshot); + resolvedCount += 1; + continue; + } + const previous = this.snapshots.get(fixture.eventId); + if (previous) nextSnapshots.set(fixture.eventId, previous); + } + + this.snapshots = nextSnapshots; + this.isRefreshingTracked = false; + this.statusMessage = ''; + if (resolvedCount === 0) { + this.errorMessage = 'Unable to refresh selected fixture stats right now.'; + } + this.renderPanel(); + return resolvedCount > 0; + } catch (error) { + if (this.isAbortError(error)) return false; + this.isRefreshingTracked = false; + this.errorMessage = 'Failed to refresh tracked fixtures.'; + this.renderPanel(); + return false; + } + } + + private renderPanel(): void { + const trackedEventIds = new Set(this.trackedFixtures.map((fixture) => fixture.eventId)); + const trackedCards = this.trackedFixtures + .map((fixture) => renderTrackedFixtureCard(fixture, this.snapshots.get(fixture.eventId))) + .join(''); + + const intro = this.trackedFixtures.length > 0 + ? 'Track multiple selected fixtures and monitor live score/stat updates in one place.' + : 'Search for any match, add it to the tracker, and keep its live score and team stats visible.'; + + this.setCount(this.trackedFixtures.length); + this.setContent(` +
+
+
+
+
Fixture Search
+
${escapeHtml(intro)}
+
+ +
+
+ + +
+ ${this.errorMessage ? `
${escapeHtml(this.errorMessage)}
` : ''} + ${this.statusMessage ? `
${escapeHtml(this.statusMessage)}
` : ''} + ${this.searchResults.length > 0 ? renderSearchResults(this.searchResults, trackedEventIds) : ''} +
+ +
+
+
Tracked Fixtures
+
${this.trackedFixtures.length}/${MAX_TRACKED_FIXTURES}
+
+ ${this.isRefreshingTracked ? '
Refreshing live fixture stats...
' : ''} + ${trackedCards || '
No fixtures tracked yet. Search a match above and click Track.
'} +
+
+ `); + } +} diff --git a/src/components/SportsMajorTournamentsPanel.ts b/src/components/SportsMajorTournamentsPanel.ts new file mode 100644 index 0000000000..17bc414cc2 --- /dev/null +++ b/src/components/SportsMajorTournamentsPanel.ts @@ -0,0 +1,183 @@ +import { Panel } from './Panel'; +import { + fetchMajorTournamentCenterData, + fetchMajorTournamentLeagueOptions, + type SportsLeagueCenterData, + type SportsLeagueOption, +} from '@/services/sports'; +import { escapeHtml } from '@/utils/sanitize'; +import { + buildSportsLeagueStatCards, + buildSportsSeasonLabel, + formatSportsUpdatedAt, + renderSportsEventSection, + renderSportsStandingsBlock, + renderSportsStatSnapshotBlock, +} from './sportsPanelShared'; + +const TOURNAMENT_KEY = 'wm-sports-tournament-id'; + +function loadStored(key: string): string { + try { + return localStorage.getItem(key) || ''; + } catch { + return ''; + } +} + +function saveStored(key: string, value: string): void { + try { + if (value) localStorage.setItem(key, value); + else localStorage.removeItem(key); + } catch { + // Ignore persistence failures. + } +} + +export class SportsMajorTournamentsPanel extends Panel { + private options: SportsLeagueOption[] = []; + private data: SportsLeagueCenterData | null = null; + private selectedLeagueId = loadStored(TOURNAMENT_KEY); + private loadToken = 0; + + constructor() { + super({ + id: 'sports-tournaments', + title: 'Major Tournaments', + showCount: true, + className: 'panel-wide', + defaultRowSpan: 4, + infoTooltip: 'Curated ESPN tournament selector for major competitions such as the UEFA Champions League and FIFA World Cup.', + }); + + this.content.addEventListener('change', (event) => { + const target = event.target as HTMLElement; + const select = target.closest('select[data-action="tournament-select"]') as HTMLSelectElement | null; + if (!select || !select.value || select.value === this.selectedLeagueId) return; + + this.selectedLeagueId = select.value; + saveStored(TOURNAMENT_KEY, this.selectedLeagueId); + void this.fetchData(); + }); + } + + public async fetchData(): Promise { + const token = ++this.loadToken; + try { + if (!this.options.length) { + this.showLoading('Loading tournaments...'); + this.options = await fetchMajorTournamentLeagueOptions(); + } + + const selected = this.resolveSelectedLeague(); + if (!selected) { + this.setCount(0); + this.showError('No tournament leagues available right now.', () => void this.fetchData()); + return false; + } + + this.showLoading('Loading tournament data...'); + const data = await fetchMajorTournamentCenterData(selected.id); + if (token !== this.loadToken) return false; + if (!data) { + this.setCount(0); + this.showError('Tournament data is unavailable right now.', () => void this.fetchData()); + return false; + } + + this.data = data; + this.selectedLeagueId = data.league.id; + saveStored(TOURNAMENT_KEY, this.selectedLeagueId); + this.setCount(data.table?.rows.length ?? (data.recentEvents.length + data.upcomingEvents.length)); + this.renderPanel(); + return true; + } catch (error) { + if (this.isAbortError(error)) return false; + this.setCount(0); + this.showError('Failed to load major tournaments.', () => void this.fetchData()); + return false; + } + } + + private resolveSelectedLeague(): SportsLeagueOption | null { + if (!this.options.length) return null; + return this.options.find((option) => option.id === this.selectedLeagueId) + || this.options[0] + || null; + } + + private renderControls(): string { + return ` +
+
+
Tournament Selector
+
Switch between major competitions like UCL, World Cup, Euros, and Copa tournaments.
+
+ +
+ `; + } + + private renderPanel(): void { + const controlsHtml = this.renderControls(); + if (!this.data) { + this.setContent(`${controlsHtml}
Select a major tournament to load standings, scores, and recent stats.
`); + return; + } + + const league = this.data.league; + const pills = [ + league.sport, + league.country, + buildSportsSeasonLabel(this.data), + this.data.table?.updatedAt ? `Updated ${formatSportsUpdatedAt(this.data.table.updatedAt)}` : 'Current tournament view', + ].filter((value): value is string => !!value) + .map((value) => ` + + ${escapeHtml(value)} + + `).join(''); + + const cards = buildSportsLeagueStatCards(this.data); + + this.setContent(` +
+ ${controlsHtml} + +
+
+
Tournament Dashboard
+
${escapeHtml(league.name)}
+
Latest result, next fixture, and the most recent stat snapshot for the selected tournament.
+
+
${pills}
+
+ + ${cards.length ? ` +
+ ${cards.map((card) => ` +
+
${escapeHtml(card.label)}
+
${escapeHtml(card.value)}
+
${escapeHtml(card.detail || '')}
+
+ `).join('')} +
+ ` : ''} + +
+ ${renderSportsEventSection('Recent Results', this.data.recentEvents, 'result', 'No completed matches available for this tournament window.')} + ${renderSportsEventSection('Upcoming Fixtures', this.data.upcomingEvents, 'fixture', 'No upcoming matches are scheduled in the current tournament window.')} +
+ + ${renderSportsStatSnapshotBlock('Latest Match Stats', this.data.statSnapshot)} + ${renderSportsStandingsBlock('Tournament Table', buildSportsSeasonLabel(this.data), this.data.table, 'The active feed does not return a current standings table for this tournament.')} +
+ `); + } +} diff --git a/src/components/SportsMotorsportAnalysisPanel.ts b/src/components/SportsMotorsportAnalysisPanel.ts new file mode 100644 index 0000000000..f5debd9a2d --- /dev/null +++ b/src/components/SportsMotorsportAnalysisPanel.ts @@ -0,0 +1,262 @@ +import type { Feed, NewsItem } from '@/types'; +import { fetchCategoryFeeds } from '@/services'; +import { fetchFormulaOneStandingsData, type FormulaOneStandingsData } from '@/services/sports'; +import { rssProxyUrl } from '@/utils'; +import { escapeHtml } from '@/utils/sanitize'; +import { + SportsAnalysisPanelBase, + countFreshAnalysisStories, + dedupeNewsItems, + normalizeLookup, + renderAiBrief, + renderAnalysisCards, + renderAnalysisPoints, + renderAnalysisStories, + renderDistributionChips, + formatUpdatedAt, + type SportsAnalysisCard, + type SportsAnalysisPoint, + type SportsAnalysisStory, +} from './sportsAnalysisShared'; + +const MOTORSPORT_ANALYSIS_FEEDS: Feed[] = [ + { name: 'Formula1.com', url: rssProxyUrl('https://news.google.com/rss/search?q=site:formula1.com+Formula+1+when:3d&hl=en-US&gl=US&ceid=US:en') }, + { name: 'NASCAR', url: rssProxyUrl('https://news.google.com/rss/search?q=site:nascar.com+NASCAR+when:3d&hl=en-US&gl=US&ceid=US:en') }, + { name: 'WRC', url: rssProxyUrl('https://news.google.com/rss/search?q=(site:wrc.com+OR+\"World Rally Championship\")+when:3d&hl=en-US&gl=US&ceid=US:en') }, + { name: 'Motorsport.com', url: rssProxyUrl('https://news.google.com/rss/search?q=site:motorsport.com+(Formula+1+OR+NASCAR+OR+Rally)+when:3d&hl=en-US&gl=US&ceid=US:en') }, + { name: 'Racer', url: rssProxyUrl('https://news.google.com/rss/search?q=site:racer.com+(Formula+1+OR+NASCAR+OR+rally)+when:3d&hl=en-US&gl=US&ceid=US:en') }, +]; + +const SERIES_KEYWORDS: Array<{ label: string; keywords: string[] }> = [ + { label: 'F1', keywords: ['formula 1', 'f1', 'grand prix', 'verstappen', 'ferrari', 'mercedes', 'mclaren'] }, + { label: 'NASCAR', keywords: ['nascar', 'cup series', 'daytona', 'talladega', 'hendrick', 'joe gibbs'] }, + { label: 'Rally', keywords: ['wrc', 'world rally championship', 'rally', 'safari rally', 'monte carlo', 'rallye'] }, +]; + +type TaggedMotorsportStory = SportsAnalysisStory & { + tag: string; + series: string; +}; + +type MotorsportAnalysisState = { + standings: FormulaOneStandingsData | null; + stories: TaggedMotorsportStory[]; + cards: SportsAnalysisCard[]; + points: SportsAnalysisPoint[]; + seriesMix: Array<{ label: string; count: number }>; + updatedAt: string; +}; + +function classifySeries(item: NewsItem): string { + const normalized = normalizeLookup(`${item.source} ${item.title}`); + const match = SERIES_KEYWORDS.find((entry) => entry.keywords.some((keyword) => normalized.includes(normalizeLookup(keyword)))); + return match?.label || 'Other'; +} + +function buildTaggedStories(items: NewsItem[]): TaggedMotorsportStory[] { + const deduped = dedupeNewsItems(items); + const tagged = deduped.map((item) => { + const series = classifySeries(item); + return { + title: item.title, + link: item.link, + source: item.source, + publishedAt: item.pubDate, + series, + tag: series, + }; + }); + + const matched = tagged.filter((story) => story.series !== 'Other'); + return (matched.length >= 6 ? matched : tagged).slice(0, 10); +} + +function buildSeriesMix(stories: TaggedMotorsportStory[]): Array<{ label: string; count: number }> { + const counts = new Map(); + for (const story of stories) { + counts.set(story.series, (counts.get(story.series) || 0) + 1); + } + return [...counts.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, 4) + .map(([label, count]) => ({ label, count })); +} + +function buildCards(standings: FormulaOneStandingsData | null, stories: TaggedMotorsportStory[]): SportsAnalysisCard[] { + const driverLeader = standings?.driverStandings[0]; + const nextRace = standings?.nextRace; + const nascarCount = stories.filter((story) => story.series === 'NASCAR').length; + const rallyCount = stories.filter((story) => story.series === 'Rally').length; + const dominant = buildSeriesMix(stories)[0]; + + const cards: SportsAnalysisCard[] = []; + if (driverLeader) cards.push({ label: 'F1 Leader', value: driverLeader.name, detail: `${driverLeader.points} pts`, tone: 'sky' }); + if (nextRace) cards.push({ label: 'Next GP', value: nextRace.raceName, detail: nextRace.date, tone: 'emerald' }); + cards.push({ label: 'NASCAR Heat', value: `${nascarCount}`, detail: 'recent headlines', tone: 'amber' }); + cards.push({ label: 'Rally Heat', value: `${rallyCount}`, detail: 'recent headlines', tone: 'rose' }); + if (dominant) cards.push({ label: 'Story Focus', value: dominant.label, detail: `${dominant.count} recent headlines` }); + return cards; +} + +function buildPoints(standings: FormulaOneStandingsData | null, stories: TaggedMotorsportStory[]): SportsAnalysisPoint[] { + const seriesMix = buildSeriesMix(stories); + const dominant = seriesMix[0]; + const driverLeader = standings?.driverStandings[0]; + const driverRunnerUp = standings?.driverStandings[1]; + const constructorLeader = standings?.constructorStandings[0]; + const nascarCount = stories.filter((story) => story.series === 'NASCAR').length; + const rallyCount = stories.filter((story) => story.series === 'Rally').length; + + const points: SportsAnalysisPoint[] = []; + if (dominant) { + points.push({ + label: 'Series Mix', + text: `${dominant.label} is carrying the biggest share of headline flow right now, so the live motorsport narrative is not evenly distributed across F1, NASCAR, and rally.`, + }); + } + if (driverLeader && driverRunnerUp) { + points.push({ + label: 'F1 Control', + text: `${driverLeader.name} leads the F1 drivers' table by ${driverLeader.points - driverRunnerUp.points} points, while ${constructorLeader?.name || 'the leading constructor'} is still setting the team benchmark.`, + }); + } + points.push({ + label: 'US Stock Car Tape', + text: nascarCount > 0 + ? `NASCAR still has fresh signal in the story tape with ${nascarCount} recent headlines, which keeps the panel anchored beyond the European single-seater cycle.` + : 'NASCAR coverage is quiet relative to F1 right now, so the current motorsport narrative is skewing away from U.S. stock-car developments.', + }); + points.push({ + label: 'Rally Watch', + text: rallyCount > 0 + ? `Rally coverage is active with ${rallyCount} recent headlines, which is enough to keep WRC storylines in the current cross-series read.` + : 'Rally coverage is light at the moment, so the panel should be read as F1-led unless new WRC stories start clustering in the next cycle.', + }); + + return points.slice(0, 4); +} + +function buildFallbackBrief(standings: FormulaOneStandingsData | null, stories: TaggedMotorsportStory[]): string { + const dominant = buildSeriesMix(stories)[0]; + const driverLeader = standings?.driverStandings[0]; + const nextRace = standings?.nextRace; + return `${driverLeader?.name || 'F1'} still anchors the live championship picture, ${nextRace?.raceName || 'the next GP'} is the next major F1 checkpoint, and headline flow is currently led by ${dominant?.label || 'a mixed motorsport slate'}.`; +} + +function buildSummaryInputs(standings: FormulaOneStandingsData | null, stories: TaggedMotorsportStory[]): string[] { + const driverLeader = standings?.driverStandings[0]; + const constructorLeader = standings?.constructorStandings[0]; + const nextRace = standings?.nextRace; + const dominant = buildSeriesMix(stories)[0]; + const nascarCount = stories.filter((story) => story.series === 'NASCAR').length; + const rallyCount = stories.filter((story) => story.series === 'Rally').length; + + return [ + ...stories.slice(0, 5).map((story) => story.title), + driverLeader ? `F1 driver leader ${driverLeader.name} with ${driverLeader.points} points` : '', + constructorLeader ? `F1 constructor leader ${constructorLeader.name} with ${constructorLeader.points} points` : '', + nextRace ? `Next grand prix is ${nextRace.raceName}` : '', + dominant ? `${dominant.label} is driving the biggest share of motorsport headlines` : '', + `NASCAR headlines ${nascarCount}`, + `Rally headlines ${rallyCount}`, + ].filter(Boolean); +} + +function renderRaceContext(standings: FormulaOneStandingsData | null): string { + const lastRace = standings?.lastRace; + const nextRace = standings?.nextRace; + + return ` +
+
+
Last F1 Race
+
${escapeHtml(lastRace?.raceName || 'No completed race yet')}
+
${escapeHtml(lastRace?.winner ? `Winner: ${lastRace.winner}` : 'Result feed unavailable')}
+
${escapeHtml(lastRace?.country || lastRace?.circuitName || 'Awaiting data')}
+
+
+
Next F1 Race
+
${escapeHtml(nextRace?.raceName || 'No scheduled GP available')}
+
${escapeHtml(nextRace?.date || 'Date TBD')}
+
${escapeHtml(nextRace?.country || nextRace?.circuitName || 'Awaiting data')}
+
+
+ `; +} + +export class SportsMotorsportAnalysisPanel extends SportsAnalysisPanelBase { + constructor() { + super({ + id: 'sports-motorsport-analysis', + title: 'Motorsport AI', + showCount: true, + className: 'panel-wide', + defaultRowSpan: 5, + infoTooltip: 'AI motorsport analysis across Formula 1, NASCAR, and rally, blending the latest headline mix with live F1 championship context.', + }); + } + + public async fetchData(): Promise { + this.showLoading('Loading motorsport analysis...'); + try { + const [standings, rawStories] = await Promise.all([ + fetchFormulaOneStandingsData().catch(() => null), + fetchCategoryFeeds(MOTORSPORT_ANALYSIS_FEEDS, { batchSize: 2 }), + ]); + + const stories = buildTaggedStories(rawStories); + if (!stories.length && !standings) { + this.setCount(0); + this.showError('Motorsport analysis is unavailable right now.', () => void this.fetchData()); + return false; + } + + const freshCount = countFreshAnalysisStories(stories); + + this.data = { + standings, + stories, + cards: buildCards(standings, stories), + points: buildPoints(standings, stories), + seriesMix: buildSeriesMix(stories), + updatedAt: standings?.updatedAt || new Date().toISOString(), + }; + this.fallbackBrief = buildFallbackBrief(standings, stories); + this.setCount(stories.length); + this.setNewBadge(freshCount, freshCount > 0); + this.renderPanel(); + this.requestAiBrief(buildSummaryInputs(standings, stories)); + return true; + } catch (error) { + if (this.isAbortError(error)) return false; + this.setCount(0); + this.showError('Failed to load motorsport analysis.', () => void this.fetchData()); + return false; + } + } + + protected renderPanel(): void { + if (!this.data) { + this.setContent('
Loading motorsport analysis.
'); + return; + } + + this.setContent(` +
+
+
Cross-Series Desk
+
Formula 1, NASCAR, and rally narrative mix
+
AI summary layered over F1 championship context and the latest multi-series motorsport story flow.
+
Updated ${escapeHtml(formatUpdatedAt(this.data.updatedAt))}
+
+ + ${renderAiBrief(this.aiBrief, this.fallbackBrief, this.aiPending)} + ${renderAnalysisCards(this.data.cards)} + ${renderDistributionChips('Series Mix', this.data.seriesMix.map((entry) => ({ label: entry.label, value: `${entry.count} stories` })))} + ${renderRaceContext(this.data.standings)} + ${renderAnalysisPoints('What Stands Out', this.data.points)} + ${renderAnalysisStories('Key Storylines', this.data.stories, 'No motorsport storylines are available right now.')} +
+ `); + } +} diff --git a/src/components/SportsMotorsportPanel.ts b/src/components/SportsMotorsportPanel.ts new file mode 100644 index 0000000000..fa6253036e --- /dev/null +++ b/src/components/SportsMotorsportPanel.ts @@ -0,0 +1,220 @@ +import { Panel } from './Panel'; +import { fetchFormulaOneStandingsData, type FormulaOneStandingsData, type MotorsportStandingRow } from '@/services/sports'; +import { escapeHtml } from '@/utils/sanitize'; +import { renderSportsTeamIdentity } from './sportsPanelShared'; + +type MotorsportCard = { + label: string; + value: string; + detail?: string; +}; + +function sanitizeHexColor(value?: string): string | null { + if (!value) return null; + const normalized = value.trim().replace(/^#/, ''); + return /^[0-9a-fA-F]{6}$/.test(normalized) ? `#${normalized}` : null; +} + +function formatUpdatedAt(value: string): string { + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) return value; + return new Date(parsed).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +function formatRaceDate(date?: string, time?: string): string { + if (!date) return 'TBD'; + const stamp = Date.parse(time ? `${date}T${time}` : `${date}T00:00:00Z`); + if (Number.isNaN(stamp)) return [date, time].filter(Boolean).join(' '); + return new Date(stamp).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: time ? 'numeric' : undefined, + minute: time ? '2-digit' : undefined, + }); +} + +function buildCards(data: FormulaOneStandingsData): MotorsportCard[] { + const driverLeader = data.driverStandings[0]; + const constructorLeader = data.constructorStandings[0]; + const cards: MotorsportCard[] = []; + + if (driverLeader) { + cards.push({ label: 'Driver Leader', value: driverLeader.name, detail: `${driverLeader.points} pts` }); + } + if (constructorLeader) { + cards.push({ label: 'Constructor Leader', value: constructorLeader.name, detail: `${constructorLeader.points} pts` }); + } + if (data.lastRace?.winner) { + cards.push({ label: 'Last Winner', value: data.lastRace.winner, detail: data.lastRace.raceName }); + } + if (data.nextRace) { + cards.push({ label: 'Next Race', value: data.nextRace.raceName, detail: formatRaceDate(data.nextRace.date, data.nextRace.time) }); + } + + return cards; +} + +function renderCards(cards: MotorsportCard[]): string { + return ` +
+ ${cards.map((card) => ` +
+
${escapeHtml(card.label)}
+
${escapeHtml(card.value)}
+
${escapeHtml(card.detail || '')}
+
+ `).join('')} +
+ `; +} + +function renderRaceCard(title: string, summary: FormulaOneStandingsData['lastRace'] | FormulaOneStandingsData['nextRace']): string { + if (!summary) { + return ` +
+
${escapeHtml(title)}
+
No race data available.
+
+ `; + } + + const meta = [summary.circuitName, summary.locality, summary.country].filter(Boolean).join(' · '); + const extra = summary.podium.length + ? `Podium: ${summary.podium.join(' · ')}` + : summary.fastestLap + ? `Fastest lap: ${summary.fastestLap}` + : ''; + + return ` +
+
${escapeHtml(title)}
+
${escapeHtml(summary.raceName)}
+
${escapeHtml(formatRaceDate(summary.date, summary.time))}
+
${escapeHtml(meta || `Round ${summary.round}`)}
+ ${extra ? `
${escapeHtml(extra)}
` : ''} +
+ `; +} + +function renderStandingsRows(rows: MotorsportStandingRow[]): string { + return rows.map((row) => { + const teamAccent = sanitizeHexColor(row.teamColor); + const rowBackground = row.rank === 1 ? 'background:rgba(16,185,129,0.05);' : ''; + const accentBar = teamAccent ? `box-shadow:inset 3px 0 0 ${teamAccent};` : ''; + const secondary = [row.code, row.driverNumber ? `#${row.driverNumber}` : row.nationality].filter(Boolean).join(' · '); + + return ` + + ${row.rank} + +
+ ${renderSportsTeamIdentity(row.name, row.badge, { secondary, size: 28 })} +
+ + + ${row.team ? renderSportsTeamIdentity(row.team, row.teamBadge, { size: 22 }) : '—'} + + ${row.points} + ${row.wins} + ${escapeHtml(row.nationality || '—')} + + `; + }).join(''); +} + +function renderStandingsTable(title: string, rows: MotorsportStandingRow[]): string { + return ` +
+
${escapeHtml(title)}
+ +
+ + + + + + + + + + + + + ${renderStandingsRows(rows)} + +
PosNameTeamPtsWinsNation
+
+
+ `; +} + +export class SportsMotorsportPanel extends Panel { + private data: FormulaOneStandingsData | null = null; + + constructor() { + super({ + id: 'sports-motorsport-standings', + title: 'Motorsport Scores', + showCount: true, + className: 'panel-wide', + defaultRowSpan: 4, + infoTooltip: 'Live Formula 1 driver and constructor standings with the latest and next race summary.', + }); + } + + public async fetchData(): Promise { + this.showLoading('Loading motorsport standings...'); + try { + const data = await fetchFormulaOneStandingsData(); + if (!data) { + this.setCount(0); + this.showError('Motorsport standings are unavailable right now.', () => void this.fetchData()); + return false; + } + + this.data = data; + this.setCount(data.driverStandings.length); + this.renderPanel(); + return true; + } catch (error) { + if (this.isAbortError(error)) return false; + this.setCount(0); + this.showError('Failed to load motorsport standings.', () => void this.fetchData()); + return false; + } + } + + private renderPanel(): void { + if (!this.data) { + this.setContent('
Loading motorsport standings.
'); + return; + } + + const cards = buildCards(this.data); + this.setContent(` +
+
+
Formula 1
+
${escapeHtml(this.data.season)} Championship Standings
+
Driver and constructor tables with the latest completed race and the next scheduled Grand Prix.
+
Round ${escapeHtml(this.data.round || '—')} · Updated ${escapeHtml(formatUpdatedAt(this.data.updatedAt))}
+
+ + ${renderCards(cards)} + +
+ ${renderRaceCard('Last Race', this.data.lastRace)} + ${renderRaceCard('Next Race', this.data.nextRace)} +
+ + ${renderStandingsTable('Driver Standings', this.data.driverStandings)} + ${renderStandingsTable('Constructor Standings', this.data.constructorStandings)} +
+ `); + } +} diff --git a/src/components/SportsNbaAnalysisPanel.ts b/src/components/SportsNbaAnalysisPanel.ts new file mode 100644 index 0000000000..8e971a3f82 --- /dev/null +++ b/src/components/SportsNbaAnalysisPanel.ts @@ -0,0 +1,341 @@ +import type { Feed, NewsItem } from '@/types'; +import { fetchCategoryFeeds } from '@/services'; +import { fetchNbaStandingsData, type NbaStandingRow, type NbaStandingsData } from '@/services/sports'; +import { rssProxyUrl } from '@/utils'; +import { escapeHtml } from '@/utils/sanitize'; +import { renderSportsTeamIdentity } from './sportsPanelShared'; +import { + SportsAnalysisPanelBase, + countFreshAnalysisStories, + dedupeNewsItems, + normalizeLookup, + renderAiBrief, + renderAnalysisCards, + renderAnalysisPoints, + renderAnalysisStories, + renderDistributionChips, + formatUpdatedAt, + type SportsAnalysisCard, + type SportsAnalysisPoint, + type SportsAnalysisStory, +} from './sportsAnalysisShared'; + +const NBA_ANALYSIS_FEEDS: Feed[] = [ + { name: 'NBA.com', url: rssProxyUrl('https://news.google.com/rss/search?q=site:nba.com+NBA+when:3d&hl=en-US&gl=US&ceid=US:en') }, + { name: 'ESPN NBA', url: rssProxyUrl('https://news.google.com/rss/search?q=site:espn.com+NBA+-college+-fantasy+-%22transfer+portal%22+when:3d&hl=en-US&gl=US&ceid=US:en') }, + { name: 'The Athletic NBA', url: rssProxyUrl('https://news.google.com/rss/search?q=site:theathletic.com+NBA+-fantasy+-college+when:3d&hl=en-US&gl=US&ceid=US:en') }, + { name: 'Reuters NBA', url: rssProxyUrl('https://news.google.com/rss/search?q=site:reuters.com+NBA+when:3d&hl=en-US&gl=US&ceid=US:en') }, +]; + +const NBA_THEME_KEYWORDS: Array<{ label: string; keywords: string[] }> = [ + { label: 'Playoffs', keywords: ['playoff', 'postseason', 'play-in', 'finals', 'conference finals'] }, + { label: 'Injuries', keywords: ['injury', 'injured', 'questionable', 'out for', 'returning', 'returns'] }, + { label: 'Trades', keywords: ['trade', 'extension', 'free agent', 'free agency', 'contract'] }, + { label: 'Awards', keywords: ['mvp', 'rookie of the year', 'all-nba', 'defensive player', 'coach of the year'] }, + { label: 'Coaching', keywords: ['coach', 'coaching', 'fired', 'hired'] }, +]; + +type TaggedNbaStory = SportsAnalysisStory & { + tag: string; + team?: string; + theme: string; +}; + +type NbaAnalysisState = { + standings: NbaStandingsData; + stories: TaggedNbaStory[]; + themeMix: Array<{ label: string; count: number }>; + cards: SportsAnalysisCard[]; + points: SportsAnalysisPoint[]; + updatedAt: string; +}; + +function parseDifferential(value: string): number { + const numeric = Number.parseFloat(value.replace(/^\+/, '')); + return Number.isFinite(numeric) ? numeric : 0; +} + +function parseStreakScore(value: string): number { + const match = value.match(/^([WL])(\d+)$/i); + if (!match) return 0; + const [, side = '', count = '0'] = match; + const magnitude = Number.parseInt(count, 10); + return side.toUpperCase() === 'W' ? magnitude : -magnitude; +} + +function parseGamesBehind(value: string): number { + const numeric = Number.parseFloat(String(value).replace(/[^\d.-]/g, '')); + return Number.isFinite(numeric) ? numeric : Number.POSITIVE_INFINITY; +} + +function buildTeamAliases(row: NbaStandingRow): string[] { + const parts = row.team.split(/\s+/).filter(Boolean); + const aliases = [ + row.team, + row.abbreviation, + parts.slice(-1).join(' '), + parts.slice(-2).join(' '), + ]; + + return [...new Set(aliases.map(normalizeLookup).filter((alias) => alias.length >= 2))]; +} + +function findTeamMention(title: string, rows: NbaStandingRow[]): NbaStandingRow | null { + const normalized = normalizeLookup(title); + let best: { row: NbaStandingRow; score: number } | null = null; + + for (const row of rows) { + for (const alias of buildTeamAliases(row)) { + if (!alias || !normalized.includes(alias)) continue; + const score = alias.length + (alias === normalizeLookup(row.team) ? 20 : 0); + if (!best || score > best.score) { + best = { row, score }; + } + } + } + + return best?.row || null; +} + +function classifyTheme(title: string): string { + const normalized = normalizeLookup(title); + const match = NBA_THEME_KEYWORDS.find((entry) => entry.keywords.some((keyword) => normalized.includes(normalizeLookup(keyword)))); + return match?.label || 'General'; +} + +function buildTaggedStories(items: NewsItem[], standings: NbaStandingsData): TaggedNbaStory[] { + const rows = standings.groups.flatMap((group) => group.rows); + return items.slice(0, 8).map((item) => { + const team = findTeamMention(item.title, rows); + const theme = classifyTheme(item.title); + return { + title: item.title, + link: item.link, + source: item.source, + publishedAt: item.pubDate, + team: team?.team, + theme, + tag: team?.abbreviation || team?.team || theme, + }; + }); +} + +function pickStoryFocus(stories: TaggedNbaStory[]): { value: string; detail: string } { + const counts = new Map(); + const detailByLabel = new Map(); + + for (const story of stories) { + const label = story.team || story.theme; + counts.set(label, (counts.get(label) || 0) + 1); + if (!detailByLabel.has(label)) { + detailByLabel.set(label, story.team ? 'team-led news flow' : 'theme-led story mix'); + } + } + + const focus = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]; + if (!focus) return { value: 'Balanced', detail: 'no dominant storyline yet' }; + return { + value: focus[0], + detail: `${focus[1]} recent headlines · ${detailByLabel.get(focus[0]) || 'story focus'}`, + }; +} + +function buildThemeMix(stories: TaggedNbaStory[]): Array<{ label: string; count: number }> { + const counts = new Map(); + for (const story of stories) { + counts.set(story.theme, (counts.get(story.theme) || 0) + 1); + } + return [...counts.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, 4) + .map(([label, count]) => ({ label, count })); +} + +function buildCards(standings: NbaStandingsData, stories: TaggedNbaStory[]): SportsAnalysisCard[] { + const eastLeader = standings.groups[0]?.rows[0]; + const westLeader = standings.groups[1]?.rows[0]; + const allRows = standings.groups.flatMap((group) => group.rows); + const bestDiff = [...allRows].sort((a, b) => parseDifferential(b.differential) - parseDifferential(a.differential))[0]; + const hottest = [...allRows].sort((a, b) => parseStreakScore(b.streak) - parseStreakScore(a.streak))[0]; + const focus = pickStoryFocus(stories); + + const cards: SportsAnalysisCard[] = []; + if (eastLeader) cards.push({ label: 'East Leader', value: eastLeader.team, detail: `${eastLeader.wins}-${eastLeader.losses}`, tone: 'sky' }); + if (westLeader) cards.push({ label: 'West Leader', value: westLeader.team, detail: `${westLeader.wins}-${westLeader.losses}`, tone: 'sky' }); + if (bestDiff) cards.push({ label: 'Best Diff', value: bestDiff.differential, detail: bestDiff.team, tone: 'emerald' }); + if (hottest) cards.push({ label: 'Hottest Streak', value: hottest.streak, detail: hottest.team, tone: 'amber' }); + cards.push({ label: 'Story Focus', value: focus.value, detail: focus.detail, tone: 'rose' }); + return cards; +} + +function buildPoints(standings: NbaStandingsData, stories: TaggedNbaStory[]): SportsAnalysisPoint[] { + const allRows = standings.groups.flatMap((group) => group.rows); + const bestDiff = [...allRows].sort((a, b) => parseDifferential(b.differential) - parseDifferential(a.differential))[0]; + const hottest = [...allRows].sort((a, b) => parseStreakScore(b.streak) - parseStreakScore(a.streak))[0]; + const conferenceRace = standings.groups + .map((group) => { + const leader = group.rows[0]; + const runnerUp = group.rows[1]; + return leader && runnerUp + ? { group: group.name, leader, runnerUp, gap: parseGamesBehind(runnerUp.gamesBehind) } + : null; + }) + .filter((entry): entry is { group: string; leader: NbaStandingRow; runnerUp: NbaStandingRow; gap: number } => !!entry) + .sort((a, b) => a.gap - b.gap)[0]; + const focus = pickStoryFocus(stories); + + const points: SportsAnalysisPoint[] = []; + if (conferenceRace) { + points.push({ + label: 'Race Pressure', + text: `${conferenceRace.group} is the tighter top-seed race right now, with ${conferenceRace.runnerUp.team} only ${conferenceRace.runnerUp.gamesBehind} games behind ${conferenceRace.leader.team}.`, + }); + } + if (bestDiff) { + points.push({ + label: 'Two-Way Signal', + text: `${bestDiff.team} owns the best point differential at ${bestDiff.differential}, which is usually the cleanest shorthand for lineup balance going into the stretch run.`, + }); + } + if (hottest) { + points.push({ + label: 'Momentum', + text: `${hottest.team} is carrying the strongest recent streak at ${hottest.streak}, so current headline flow should be read through real momentum instead of season-long priors alone.`, + }); + } + points.push({ + label: 'Story Tape', + text: `${focus.value} is driving the loudest recent conversation, which means the media narrative is concentrating there even if the standings picture is broader than one club.`, + }); + + return points.slice(0, 4); +} + +function buildFallbackBrief(standings: NbaStandingsData, stories: TaggedNbaStory[]): string { + const eastLeader = standings.groups[0]?.rows[0]; + const westLeader = standings.groups[1]?.rows[0]; + const hottest = [...standings.groups.flatMap((group) => group.rows)].sort((a, b) => parseStreakScore(b.streak) - parseStreakScore(a.streak))[0]; + const focus = pickStoryFocus(stories); + return `${eastLeader?.team || 'The East leader'} and ${westLeader?.team || 'the West leader'} still anchor the table, ${hottest?.team || 'the hottest team'} is carrying the strongest recent run, and headline flow is clustering around ${focus.value}.`; +} + +function buildSummaryInputs(standings: NbaStandingsData, stories: TaggedNbaStory[]): string[] { + const eastLeader = standings.groups[0]?.rows[0]; + const westLeader = standings.groups[1]?.rows[0]; + const bestDiff = [...standings.groups.flatMap((group) => group.rows)].sort((a, b) => parseDifferential(b.differential) - parseDifferential(a.differential))[0]; + const hottest = [...standings.groups.flatMap((group) => group.rows)].sort((a, b) => parseStreakScore(b.streak) - parseStreakScore(a.streak))[0]; + const focus = pickStoryFocus(stories); + + return [ + ...stories.slice(0, 5).map((story) => story.title), + eastLeader ? `East leader ${eastLeader.team} at ${eastLeader.wins}-${eastLeader.losses}` : '', + westLeader ? `West leader ${westLeader.team} at ${westLeader.wins}-${westLeader.losses}` : '', + bestDiff ? `Best point differential belongs to ${bestDiff.team} at ${bestDiff.differential}` : '', + hottest ? `Hottest streak belongs to ${hottest.team} at ${hottest.streak}` : '', + `Recent story focus is ${focus.value}`, + ].filter(Boolean); +} + +function renderConferenceBoard(data: NbaStandingsData): string { + return ` +
+ ${data.groups.map((group) => ` +
+
+
${escapeHtml(group.name)}
+
Top 3 seeds
+
+
+ ${group.rows.slice(0, 3).map((row) => ` +
+ ${row.seed} + ${renderSportsTeamIdentity(row.team, row.badge, { secondary: `${row.wins}-${row.losses} · ${row.streak}`, size: 24 })} +
+
${escapeHtml(row.gamesBehind)}
+
GB
+
+
+ `).join('')} +
+
+ `).join('')} +
+ `; +} + +export class SportsNbaAnalysisPanel extends SportsAnalysisPanelBase { + constructor() { + super({ + id: 'sports-nba-analysis', + title: 'NBA AI Analysis', + showCount: true, + className: 'panel-wide', + defaultRowSpan: 5, + infoTooltip: 'NBA storylines summarized with AI, plus conference pressure, momentum, and media-focus signals from live standings and recent headlines.', + }); + } + + public async fetchData(): Promise { + this.showLoading('Loading NBA analysis...'); + try { + const [standings, rawStories] = await Promise.all([ + fetchNbaStandingsData(), + fetchCategoryFeeds(NBA_ANALYSIS_FEEDS, { batchSize: 2 }), + ]); + + if (!standings) { + this.setCount(0); + this.showError('NBA analysis is unavailable right now.', () => void this.fetchData()); + return false; + } + + const stories = buildTaggedStories(dedupeNewsItems(rawStories), standings); + const freshCount = countFreshAnalysisStories(stories); + + this.data = { + standings, + stories, + themeMix: buildThemeMix(stories), + cards: buildCards(standings, stories), + points: buildPoints(standings, stories), + updatedAt: standings.updatedAt, + }; + this.fallbackBrief = buildFallbackBrief(standings, stories); + this.setCount(stories.length); + this.setNewBadge(freshCount, freshCount > 0); + this.renderPanel(); + this.requestAiBrief(buildSummaryInputs(standings, stories)); + return true; + } catch (error) { + if (this.isAbortError(error)) return false; + this.setCount(0); + this.showError('Failed to load NBA analysis.', () => void this.fetchData()); + return false; + } + } + + protected renderPanel(): void { + if (!this.data) { + this.setContent('
Loading NBA analysis.
'); + return; + } + + this.setContent(` +
+
+
NBA Story Desk
+
Conference pressure, momentum, and narrative flow
+
AI summary layered on top of live conference tables and the latest league story mix.
+
Updated ${escapeHtml(formatUpdatedAt(this.data.updatedAt))}
+
+ + ${renderAiBrief(this.aiBrief, this.fallbackBrief, this.aiPending)} + ${renderAnalysisCards(this.data.cards)} + ${renderDistributionChips('Theme Mix', this.data.themeMix.map((entry) => ({ label: entry.label, value: `${entry.count} stories` })))} + ${renderConferenceBoard(this.data.standings)} + ${renderAnalysisPoints('What Stands Out', this.data.points)} + ${renderAnalysisStories('Key Storylines', this.data.stories, 'No NBA storylines are available right now.')} +
+ `); + } +} diff --git a/src/components/SportsNbaPanel.ts b/src/components/SportsNbaPanel.ts new file mode 100644 index 0000000000..3b146bd0f4 --- /dev/null +++ b/src/components/SportsNbaPanel.ts @@ -0,0 +1,192 @@ +import { Panel } from './Panel'; +import { fetchNbaStandingsData, type NbaStandingRow, type NbaStandingsData, type NbaStandingsGroup } from '@/services/sports'; +import { escapeHtml } from '@/utils/sanitize'; +import { renderSportsTeamIdentity } from './sportsPanelShared'; + +type NbaCard = { + label: string; + value: string; + detail?: string; +}; + +function parseDifferential(value: string): number { + const numeric = Number.parseFloat(value.replace(/^\+/, '')); + return Number.isFinite(numeric) ? numeric : 0; +} + +function parseStreakScore(value: string): number { + const match = value.match(/^([WL])(\d+)$/i); + if (!match) return 0; + const [, side = '', count = '0'] = match; + const magnitude = Number.parseInt(count, 10); + return side.toUpperCase() === 'W' ? magnitude : -magnitude; +} + +function formatUpdatedAt(value: string): string { + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) return value; + return new Date(parsed).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +function buildCards(data: NbaStandingsData): NbaCard[] { + const eastLeader = data.groups[0]?.rows[0]; + const westLeader = data.groups[1]?.rows[0]; + const allRows = data.groups.flatMap((group) => group.rows); + const bestDiff = [...allRows].sort((a, b) => parseDifferential(b.differential) - parseDifferential(a.differential))[0]; + const hottest = [...allRows].sort((a, b) => parseStreakScore(b.streak) - parseStreakScore(a.streak))[0]; + const cards: NbaCard[] = []; + + if (eastLeader) { + cards.push({ label: 'East Leader', value: eastLeader.team, detail: `${eastLeader.wins}-${eastLeader.losses}` }); + } + if (westLeader) { + cards.push({ label: 'West Leader', value: westLeader.team, detail: `${westLeader.wins}-${westLeader.losses}` }); + } + if (bestDiff) { + cards.push({ label: 'Best Diff', value: bestDiff.differential, detail: bestDiff.team }); + } + if (hottest) { + cards.push({ label: 'Hottest Streak', value: hottest.streak, detail: hottest.team }); + } + + return cards; +} + +function renderCards(cards: NbaCard[]): string { + return ` +
+ ${cards.map((card) => ` +
+
${escapeHtml(card.label)}
+
${escapeHtml(card.value)}
+
${escapeHtml(card.detail || '')}
+
+ `).join('')} +
+ `; +} + +function renderStandingsRows(rows: NbaStandingRow[]): string { + return rows.map((row) => ` + + ${row.seed} + +
+ ${renderSportsTeamIdentity(row.team, row.badge)} +
+ ${escapeHtml(row.abbreviation)}${row.clincher ? ` · ${escapeHtml(row.clincher.toUpperCase())}` : ''} +
+
+ + ${row.wins} + ${row.losses} + ${escapeHtml(row.winPercent)} + ${escapeHtml(row.gamesBehind)} + ${escapeHtml(row.pointsFor)} + ${escapeHtml(row.pointsAgainst)} + ${escapeHtml(row.differential)} + ${escapeHtml(row.streak)} + ${escapeHtml(row.lastTen)} + + `).join(''); +} + +function renderStandingsGroup(group: NbaStandingsGroup): string { + return ` +
+
+
+
${escapeHtml(group.name)}
+
${group.rows.length} teams
+
+
+ +
+ + + + + + + + + + + + + + + + + + ${renderStandingsRows(group.rows)} + +
SeedTeamWLPCTGBPPGOPPDIFFSTRKL10
+
+
+ `; +} + +export class SportsNbaPanel extends Panel { + private data: NbaStandingsData | null = null; + + constructor() { + super({ + id: 'sports-nba', + title: 'NBA Standings', + showCount: true, + className: 'panel-wide', + defaultRowSpan: 4, + infoTooltip: 'Live NBA conference standings and league metrics sourced from ESPN standings data.', + }); + } + + public async fetchData(): Promise { + this.showLoading('Loading NBA standings...'); + try { + const data = await fetchNbaStandingsData(); + if (!data) { + this.setCount(0); + this.showError('NBA standings are unavailable right now.', () => void this.fetchData()); + return false; + } + + this.data = data; + this.setCount(data.groups.reduce((sum, group) => sum + group.rows.length, 0)); + this.renderPanel(); + return true; + } catch (error) { + if (this.isAbortError(error)) return false; + this.setCount(0); + this.showError('Failed to load NBA standings.', () => void this.fetchData()); + return false; + } + } + + private renderPanel(): void { + if (!this.data) { + this.setContent('
Loading NBA standings.
'); + return; + } + + const cards = buildCards(this.data); + this.setContent(` +
+
+
NBA
+
${escapeHtml(this.data.seasonDisplay || this.data.leagueName)} Standings
+
Full conference tables and league-level performance stats.
+
Updated ${escapeHtml(formatUpdatedAt(this.data.updatedAt))}
+
+ + ${renderCards(cards)} + ${this.data.groups.map((group) => renderStandingsGroup(group)).join('')} +
+ `); + } +} diff --git a/src/components/SportsPlayerSearchPanel.ts b/src/components/SportsPlayerSearchPanel.ts new file mode 100644 index 0000000000..97c6323c1f --- /dev/null +++ b/src/components/SportsPlayerSearchPanel.ts @@ -0,0 +1,382 @@ +import { Panel } from './Panel'; +import { fetchSportsPlayerDetails, fetchSportsPlayerSearch, type SportsPlayerDetails, type SportsPlayerSearchResult } from '@/services/sports'; +import { escapeHtml, sanitizeUrl } from '@/utils/sanitize'; +import { renderSportsBadge } from './sportsPanelShared'; + +const PLAYER_QUERY_KEY = 'wm-sports-player-query'; +const PLAYER_ID_KEY = 'wm-sports-player-id'; + +type PlayerCard = { + label: string; + value: string; + detail?: string; +}; + +function loadStored(key: string): string { + try { + return localStorage.getItem(key) || ''; + } catch { + return ''; + } +} + +function saveStored(key: string, value: string): void { + try { + if (value) localStorage.setItem(key, value); + else localStorage.removeItem(key); + } catch { + // Ignore persistence failures. + } +} + +function formatDate(value?: string): string | undefined { + if (!value) return undefined; + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) return value; + return new Date(parsed).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +function computeAge(value?: string): string | undefined { + if (!value) return undefined; + const birthDate = new Date(value); + if (Number.isNaN(birthDate.getTime())) return undefined; + const now = new Date(); + let age = now.getFullYear() - birthDate.getFullYear(); + const beforeBirthday = now.getMonth() < birthDate.getMonth() + || (now.getMonth() === birthDate.getMonth() && now.getDate() < birthDate.getDate()); + if (beforeBirthday) age -= 1; + return age > 0 ? `${age}` : undefined; +} + +function buildPlayerCards(player: SportsPlayerDetails): PlayerCard[] { + const age = computeAge(player.birthDate); + const born = [formatDate(player.birthDate), age ? `${age} yrs` : ''].filter(Boolean).join(' · '); + + return [ + { label: 'Sport', value: player.sport || '—', detail: player.position || 'Position' }, + { label: 'Team', value: player.team || '—', detail: player.secondaryTeam || 'Current club/team' }, + { label: 'Nationality', value: player.nationality || '—', detail: player.birthLocation || 'Birthplace' }, + { label: 'Status', value: player.status || '—', detail: player.number ? `No. ${player.number}` : 'Roster status' }, + { label: 'Born', value: born || '—', detail: player.gender || 'Player profile' }, + { label: 'Physical', value: [player.height, player.weight].filter(Boolean).join(' · ') || '—', detail: player.handedness || 'Height / weight' }, + { label: 'Signed', value: formatDate(player.signedDate) || '—', detail: player.signing || player.agent || 'Contract / agent' }, + { label: 'Outfitter', value: player.outfitter || '—', detail: player.kit || 'Equipment' }, + ].filter((card) => card.value !== '—' || card.detail); +} + +function normalizeExternalUrl(value?: string): string | null { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed.replace(/^@/, '')}`; + const sanitized = sanitizeUrl(withProtocol); + return sanitized === '#' ? null : sanitized; +} + +function buildPlayerLinks(player: SportsPlayerDetails): Array<{ label: string; url: string }> { + const entries = [ + ['Website', normalizeExternalUrl(player.website)], + ['Facebook', normalizeExternalUrl(player.facebook)], + ['X', normalizeExternalUrl(player.twitter)], + ['Instagram', normalizeExternalUrl(player.instagram)], + ['YouTube', normalizeExternalUrl(player.youtube)], + ] as const; + + const links: Array<{ label: string; url: string }> = []; + for (const [label, url] of entries) { + if (!url) continue; + links.push({ label, url }); + } + return links; +} + +function buildPlayerDescription(player: SportsPlayerDetails): string { + const description = (player.description || '').trim(); + if (!description) return ''; + return description.length > 520 ? `${description.slice(0, 517)}...` : description; +} + +function renderPlayerImage(player: SportsPlayerDetails): string { + const imageUrl = sanitizeUrl(player.cutout || player.thumb || player.banner || player.fanart || ''); + if (imageUrl && imageUrl !== '#') { + return ` +
+ ${escapeHtml(player.name)} +
+ `; + } + + return renderSportsBadge(player.name, undefined, 88); +} + +function renderSearchResults(results: SportsPlayerSearchResult[], selectedPlayerId: string): string { + if (!results.length) return ''; + + return ` +
+
+
Matches
+
${results.length} result${results.length === 1 ? '' : 's'}
+
+
+ ${results.map((player) => ` + + `).join('')} +
+
+ `; +} + +function renderPlayerProfile(player: SportsPlayerDetails): string { + const cards = buildPlayerCards(player); + const links = buildPlayerLinks(player); + const description = buildPlayerDescription(player); + const pills = [player.sport, player.team, player.secondaryTeam, player.position, player.nationality] + .filter((value): value is string => !!value) + .slice(0, 5); + + return ` +
+
+ ${renderPlayerImage(player)} +
+
Player Profile
+
${escapeHtml(player.name)}
+ ${player.alternateName ? `
${escapeHtml(player.alternateName)}
` : ''} +
+ ${pills.map((pill) => ` + + ${escapeHtml(pill)} + + `).join('')} +
+
+
+ +
+ ${cards.map((card) => ` +
+
${escapeHtml(card.label)}
+
${escapeHtml(card.value)}
+
${escapeHtml(card.detail || '')}
+
+ `).join('')} +
+ + ${description ? ` +
+
Bio
+
${escapeHtml(description)}
+
+ ` : ''} + + ${links.length ? ` +
+ ${links.map((link) => ` + + ${escapeHtml(link.label)} + + `).join('')} +
+ ` : ''} +
+ `; +} + +export class SportsPlayerSearchPanel extends Panel { + private query = loadStored(PLAYER_QUERY_KEY); + private selectedPlayerId = loadStored(PLAYER_ID_KEY); + private searchResults: SportsPlayerSearchResult[] = []; + private selectedPlayer: SportsPlayerDetails | null = null; + private isLoading = false; + private statusMessage = ''; + private errorMessage = ''; + private loadToken = 0; + + constructor() { + super({ + id: 'sports-player-search', + title: 'Player Search', + showCount: true, + className: 'panel-wide', + defaultRowSpan: 4, + infoTooltip: 'Search any player across football, basketball, motorsport, baseball, and more to load profile stats and bio data.', + }); + + this.content.addEventListener('submit', (event) => { + const form = (event.target as HTMLElement).closest('form[data-action="player-search"]') as HTMLFormElement | null; + if (!form) return; + event.preventDefault(); + const input = form.querySelector('input[name="player-query"]') as HTMLInputElement | null; + void this.searchPlayers(input?.value || ''); + }); + + this.content.addEventListener('click', (event) => { + const target = event.target as HTMLElement; + const button = target.closest('button[data-action="player-select"]') as HTMLButtonElement | null; + const playerId = button?.dataset.playerId; + if (!playerId || playerId === this.selectedPlayerId) return; + void this.loadPlayer(playerId); + }); + } + + public async fetchData(): Promise { + if (this.query) { + return this.searchPlayers(this.query, this.selectedPlayerId || undefined); + } + if (this.selectedPlayerId) { + return this.loadPlayer(this.selectedPlayerId); + } + this.setCount(0); + this.renderPanel(); + return true; + } + + private async searchPlayers(rawQuery: string, preferredPlayerId?: string): Promise { + const trimmedQuery = rawQuery.trim(); + this.query = trimmedQuery; + saveStored(PLAYER_QUERY_KEY, trimmedQuery); + this.errorMessage = ''; + this.statusMessage = ''; + + if (!trimmedQuery) { + this.searchResults = []; + this.selectedPlayer = null; + this.selectedPlayerId = ''; + saveStored(PLAYER_ID_KEY, ''); + this.setCount(0); + this.renderPanel(); + return true; + } + + const token = ++this.loadToken; + this.isLoading = true; + this.renderPanel(); + + try { + const results = await fetchSportsPlayerSearch(trimmedQuery); + if (token !== this.loadToken) return false; + + this.searchResults = results; + this.setCount(results.length); + + if (!results.length) { + this.selectedPlayer = null; + this.selectedPlayerId = ''; + saveStored(PLAYER_ID_KEY, ''); + this.isLoading = false; + this.statusMessage = `No players found for "${trimmedQuery}".`; + this.renderPanel(); + return false; + } + + const fallbackPlayer = results[0]; + if (!fallbackPlayer) { + this.selectedPlayer = null; + this.selectedPlayerId = ''; + saveStored(PLAYER_ID_KEY, ''); + this.isLoading = false; + this.statusMessage = `No players found for "${trimmedQuery}".`; + this.renderPanel(); + return false; + } + + const nextPlayerId = preferredPlayerId && results.some((player) => player.id === preferredPlayerId) + ? preferredPlayerId + : fallbackPlayer.id; + + return this.loadPlayer(nextPlayerId, token); + } catch (error) { + if (this.isAbortError(error)) return false; + this.isLoading = false; + this.selectedPlayer = null; + this.errorMessage = 'Failed to search players.'; + this.renderPanel(); + return false; + } + } + + private async loadPlayer(playerId: string, existingToken?: number): Promise { + const token = existingToken ?? ++this.loadToken; + this.selectedPlayerId = playerId; + saveStored(PLAYER_ID_KEY, playerId); + this.errorMessage = ''; + this.statusMessage = ''; + this.isLoading = true; + this.renderPanel(); + + try { + const player = await fetchSportsPlayerDetails(playerId); + if (token !== this.loadToken) return false; + + this.isLoading = false; + if (!player) { + this.selectedPlayer = null; + this.errorMessage = 'Player details are unavailable right now.'; + this.renderPanel(); + return false; + } + + this.selectedPlayer = player; + this.renderPanel(); + return true; + } catch (error) { + if (this.isAbortError(error)) return false; + this.isLoading = false; + this.selectedPlayer = null; + this.errorMessage = 'Failed to load player details.'; + this.renderPanel(); + return false; + } + } + + private renderPanel(): void { + const intro = this.query + ? `Search results for ${this.query}. Pick any player to load the latest open profile data.` + : 'Search any player in the world across major sports. The panel loads profile stats, team info, and biography data from the open sports directory.'; + + this.setContent(` +
+
+
+
Player Finder
+
${escapeHtml(intro)}
+
+
+ + +
+ ${this.isLoading ? '
Loading player data...
' : ''} + ${this.errorMessage ? `
${escapeHtml(this.errorMessage)}
` : ''} + ${this.statusMessage ? `
${escapeHtml(this.statusMessage)}
` : ''} +
+ + ${renderSearchResults(this.searchResults, this.selectedPlayerId)} + ${this.selectedPlayer ? renderPlayerProfile(this.selectedPlayer) : ''} +
+ `); + } +} diff --git a/src/components/SportsStatsPanel.ts b/src/components/SportsStatsPanel.ts new file mode 100644 index 0000000000..29e8415ea7 --- /dev/null +++ b/src/components/SportsStatsPanel.ts @@ -0,0 +1,80 @@ +import { Panel } from './Panel'; +import { escapeHtml } from '@/utils/sanitize'; +import { fetchFeaturedSportsStats, parseEventTimestamp, type SportsEvent, type SportsStatSnapshot } from '@/services/sports'; +import { renderSportsTeamIdentity } from './sportsPanelShared'; + +function formatEventMeta(event: SportsEvent): string { + const timestamp = parseEventTimestamp(event); + const parts: string[] = []; + if (timestamp !== Number.MAX_SAFE_INTEGER) { + parts.push(new Date(timestamp).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })); + } else if (event.dateEvent) { + parts.push(event.dateEvent); + } + if (event.strVenue) parts.push(event.strVenue); + return parts.join(' | '); +} + +export class SportsStatsPanel extends Panel { + private snapshots: SportsStatSnapshot[] = []; + + constructor() { + super({ + id: 'sports-stats', + title: 'Match Stats', + showCount: false, + infoTooltip: 'Recent match stat snapshots powered by ESPN match summaries across featured competitions.', + }); + } + + public async fetchData(): Promise { + this.showLoading('Loading match stats...'); + try { + this.snapshots = await fetchFeaturedSportsStats(); + if (this.snapshots.length === 0) { + this.showError('No match stats available right now.', () => void this.fetchData()); + return false; + } + this.renderPanel(); + return true; + } catch (error) { + if (this.isAbortError(error)) return false; + this.showError('Failed to load match stats.', () => void this.fetchData()); + return false; + } + } + + private renderPanel(): void { + const html = ` +
+ ${this.snapshots.map((snapshot) => ` +
+
+
+
${escapeHtml(snapshot.league.shortName)}
+
${escapeHtml(snapshot.event.strEvent || `${snapshot.event.strHomeTeam || ''} vs ${snapshot.event.strAwayTeam || ''}`)}
+
+
${escapeHtml(formatEventMeta(snapshot.event))}
+
+
+ ${renderSportsTeamIdentity(snapshot.event.strHomeTeam || 'Home', snapshot.event.strHomeBadge)} +
${escapeHtml((snapshot.event.intHomeScore || snapshot.event.intAwayScore) ? `${snapshot.event.intHomeScore || '-'} - ${snapshot.event.intAwayScore || '-'}` : 'vs')}
+ ${renderSportsTeamIdentity(snapshot.event.strAwayTeam || 'Away', snapshot.event.strAwayBadge, { align: 'right' })} +
+
+ ${snapshot.stats.map((stat) => ` +
+ ${escapeHtml(stat.homeValue || '-')} + ${escapeHtml(stat.label)} + ${escapeHtml(stat.awayValue || '-')} +
+ `).join('')} +
+
+ `).join('')} +
+ `; + + this.setContent(html); + } +} diff --git a/src/components/SportsTablesPanel.ts b/src/components/SportsTablesPanel.ts new file mode 100644 index 0000000000..ea024c3ff4 --- /dev/null +++ b/src/components/SportsTablesPanel.ts @@ -0,0 +1,417 @@ +import { Panel } from './Panel'; +import { + fetchAllSportsLeagues, + fetchLeagueCenterData, + type SportsLeagueCenterData, + type SportsLeagueOption, + type SportsStandingRow, + type SportsTableGroup, +} from '@/services/sports'; +import { escapeHtml } from '@/utils/sanitize'; +import { renderSportsTeamIdentity } from './sportsPanelShared'; + +const SPORTS_LEAGUE_ID_KEY = 'wm-sports-league-id'; +const LEGACY_SPORTS_SEASON_KEY = 'wm-sports-league-season'; + +type LeagueStatCard = { + label: string; + value: string; + detail?: string; +}; + +function loadStored(key: string): string { + try { + return localStorage.getItem(key) || ''; + } catch { + return ''; + } +} + +function saveStored(key: string, value: string): void { + try { + if (value) { + localStorage.setItem(key, value); + } else { + localStorage.removeItem(key); + } + } catch { + // Ignore persistence failures. + } +} + +function buildLeagueGroups(leagues: SportsLeagueOption[]): Array<[string, SportsLeagueOption[]]> { + const grouped = new Map(); + for (const league of leagues) { + const sport = league.sport || 'Other'; + const bucket = grouped.get(sport) || []; + bucket.push(league); + grouped.set(sport, bucket); + } + + return [...grouped.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([sport, options]) => [sport, options.sort((a, b) => a.name.localeCompare(b.name))]); +} + +function buildFormScore(form?: string): number { + return (form || '') + .toUpperCase() + .split('') + .reduce((score, result) => { + if (result === 'W') return score + 3; + if (result === 'D') return score + 1; + return score; + }, 0); +} + +function formatForm(form?: string): string { + const cleaned = (form || '').replace(/[^A-Za-z]/g, '').toUpperCase().slice(0, 5); + return cleaned || '—'; +} + +function formatUpdatedAt(value?: string): string { + if (!value) return 'Live feed'; + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) return value; + return new Date(parsed).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +function buildSeasonLabel(data: SportsLeagueCenterData): string { + return data.table?.season || data.league.currentSeason || data.selectedSeason || 'Current season'; +} + +function buildStatCards(data: SportsLeagueCenterData): LeagueStatCard[] { + const rows = data.table?.rows || []; + const leader = rows[0]; + if (!leader) return []; + + const runnerUp = rows[1]; + const totalPlayed = rows.reduce((sum, row) => sum + row.played, 0); + const totalPoints = rows.reduce((sum, row) => sum + row.points, 0); + const matchesLogged = Math.round(totalPlayed / 2); + const leaderPace = leader.played > 0 ? (leader.points / leader.played).toFixed(2) : '0.00'; + const averagePoints = (totalPoints / rows.length).toFixed(1); + const bestForm = rows + .filter((row) => !!row.form) + .sort((a, b) => buildFormScore(b.form) - buildFormScore(a.form) || b.points - a.points)[0]; + + const cards: LeagueStatCard[] = [ + { + label: 'Leader', + value: leader.team, + detail: `${leader.points} pts`, + }, + { + label: 'Gap', + value: runnerUp ? `${Math.max(leader.points - runnerUp.points, 0)} pts` : '—', + detail: runnerUp ? `over ${runnerUp.team}` : 'No runner-up yet', + }, + { + label: 'Clubs', + value: String(rows.length), + detail: 'in table', + }, + { + label: 'Matches', + value: String(matchesLogged), + detail: 'logged', + }, + { + label: 'Leader pace', + value: leaderPace, + detail: 'pts per match', + }, + { + label: 'Avg points', + value: averagePoints, + detail: 'per club', + }, + ]; + + if (bestForm) { + cards.push({ + label: 'Best form', + value: formatForm(bestForm.form), + detail: bestForm.team, + }); + } + + return cards; +} + +function renderFormBadges(form?: string): string { + const values = formatForm(form); + if (values === '—') { + return ''; + } + + return ` + + ${values.split('').map((value) => { + const tone = value === 'W' + ? 'background:rgba(16,185,129,0.18);color:#a7f3d0;' + : value === 'D' + ? 'background:rgba(245,158,11,0.16);color:#fde68a;' + : 'background:rgba(239,68,68,0.16);color:#fca5a5;'; + return `${value}`; + }).join('')} + + `; +} + +function renderTableRows(table: SportsTableGroup): string { + return table.rows.map((row) => { + const isLeader = row.rank === 1; + const rowAccent = isLeader ? 'background:rgba(16,185,129,0.05);' : ''; + const rankAccent = isLeader + ? 'background:rgba(16,185,129,0.18);color:#a7f3d0;' + : 'background:rgba(255,255,255,0.05);color:rgba(255,255,255,0.76);'; + + return ` + + + + ${row.rank} + + + +
+ ${renderSportsTeamIdentity(row.team, row.badge)} + ${row.note ? `
${escapeHtml(row.note)}
` : ''} +
+ + ${row.played} + ${row.wins} + ${row.draws} + ${row.losses} + ${row.goalDifference >= 0 ? '+' : ''}${row.goalDifference} + ${row.points} + ${renderFormBadges(row.form)} + + `; + }).join(''); +} + +function renderTableNotes(rows: SportsStandingRow[]): string { + const notes = [...new Set(rows.map((row) => row.note).filter(Boolean))]; + if (!notes.length) return ''; + + return ` +
+ ${notes.map((note) => ` + + ${escapeHtml(note || '')} + + `).join('')} +
+ `; +} + +export class SportsTablesPanel extends Panel { + private leagues: SportsLeagueOption[] = []; + private data: SportsLeagueCenterData | null = null; + private selectedLeagueId = loadStored(SPORTS_LEAGUE_ID_KEY) || '4328'; + private loadToken = 0; + + constructor() { + super({ + id: 'sports-tables', + title: 'League Table', + showCount: true, + className: 'panel-wide', + defaultRowSpan: 5, + infoTooltip: 'Select a football league to view its current full standings table and live league stats.', + }); + + saveStored(LEGACY_SPORTS_SEASON_KEY, ''); + + this.content.addEventListener('change', (event) => { + const target = event.target as HTMLElement; + const select = target.closest('select[data-action="league-select"]') as HTMLSelectElement | null; + if (!select || !select.value || select.value === this.selectedLeagueId) return; + + this.selectedLeagueId = select.value; + saveStored(SPORTS_LEAGUE_ID_KEY, this.selectedLeagueId); + void this.fetchData(); + }); + } + + public async fetchData(): Promise { + const token = ++this.loadToken; + try { + if (!this.leagues.length) { + this.showLoading('Loading league directory...'); + this.leagues = (await fetchAllSportsLeagues()).filter((league) => league.sport === 'Soccer'); + } + + const selectedLeague = this.resolveSelectedLeague(); + if (!selectedLeague) { + this.setCount(0); + this.showError('No league directory available right now.', () => void this.fetchData()); + return false; + } + + this.showLoading('Loading current table...'); + const data = await fetchLeagueCenterData(selectedLeague.id); + if (token !== this.loadToken) return false; + if (!data) { + this.setCount(0); + this.showError('League table is unavailable right now.', () => void this.fetchData()); + return false; + } + + this.data = data; + this.selectedLeagueId = data.league.id; + saveStored(SPORTS_LEAGUE_ID_KEY, this.selectedLeagueId); + saveStored(LEGACY_SPORTS_SEASON_KEY, ''); + this.setCount(data.table?.rows.length ?? 0); + this.clearNewBadge(); + this.renderPanel(); + return true; + } catch (error) { + if (this.isAbortError(error)) return false; + this.setCount(0); + this.showError('Failed to load league table.', () => void this.fetchData()); + return false; + } + } + + private resolveSelectedLeague(): SportsLeagueOption | null { + if (!this.leagues.length) return null; + return this.leagues.find((league) => league.id === this.selectedLeagueId) + || this.leagues.find((league) => league.id === '4328') + || this.leagues[0] + || null; + } + + private renderControls(): string { + const groupedLeagues = buildLeagueGroups(this.leagues); + + return ` +
+
+
Competition
+
Choose a football league. The panel loads the latest live table the feed has for that competition.
+
+ +
+ `; + } + + private renderStats(): string { + if (!this.data?.table?.rows.length) return ''; + + const cards = buildStatCards(this.data); + const league = this.data.league; + const meta = [ + league.country, + buildSeasonLabel(this.data), + this.data.table?.updatedAt ? `Updated ${formatUpdatedAt(this.data.table.updatedAt)}` : 'Live table', + ].filter((value): value is string => !!value).join(' · '); + + return ` +
+
+
${escapeHtml(league.name)}
+
${escapeHtml(meta)}
+
+
+ ${cards.map((card) => ` +
+
${escapeHtml(card.label)}
+
${escapeHtml(card.value)}
+
${escapeHtml(card.detail || '')}
+
+ `).join('')} +
+
+ `; + } + + private renderStandings(): string { + if (!this.data) return ''; + + if (!this.data.table?.rows.length) { + return ` +
+
Standings
+
+ The active feed does not return a current standings table for this league. Select another competition to load a live table. +
+
+ `; + } + + return ` +
+
+
+
Live Table
+
${escapeHtml(`${buildSeasonLabel(this.data)} · ${this.data.table.rows.length} clubs`)}
+
+
+ ${escapeHtml(this.data.table.updatedAt ? `Updated ${formatUpdatedAt(this.data.table.updatedAt)}` : 'Live feed')} +
+
+ +
+ + + + + + + + + + + + + + + + ${renderTableRows(this.data.table)} + +
PosTeamPWDLGDPtsForm
+
+ + ${renderTableNotes(this.data.table.rows)} +
+ `; + } + + private renderPanel(): void { + if (!this.leagues.length) { + this.showLoading('Loading league directory...'); + return; + } + + const controlsHtml = this.renderControls(); + if (!this.data) { + this.setContent(`${controlsHtml}
Select a league to load its current standings and league stats.
`); + return; + } + + this.setContent(` +
+ ${controlsHtml} + ${this.renderStats()} + ${this.renderStandings()} +
+ `); + } +} diff --git a/src/components/SportsTransferNewsPanel.ts b/src/components/SportsTransferNewsPanel.ts new file mode 100644 index 0000000000..da412fa8c5 --- /dev/null +++ b/src/components/SportsTransferNewsPanel.ts @@ -0,0 +1,128 @@ +import { Panel } from './Panel'; +import type { Feed, NewsItem } from '@/types'; +import { fetchCategoryFeeds } from '@/services'; +import { rssProxyUrl } from '@/utils'; +import { escapeHtml, sanitizeUrl } from '@/utils/sanitize'; + +const TRANSFER_FEEDS: Feed[] = [ + { name: 'Football Transfers', url: rssProxyUrl('https://news.google.com/rss/search?q=((football+OR+soccer)+transfer+OR+loan+OR+signing)+-%22transfer+portal%22+-college+-NCAA+when:7d&hl=en-US&gl=US&ceid=US:en') }, + { name: 'BBC Football', url: rssProxyUrl('https://feeds.bbci.co.uk/sport/football/rss.xml?edition=uk') }, + { name: 'ESPN Soccer', url: rssProxyUrl('https://www.espn.com/espn/rss/soccer/news') }, + { name: 'Guardian Football', url: rssProxyUrl('https://www.theguardian.com/football/rss') }, +]; + +const TRANSFER_KEYWORDS = [ + 'transfer', + 'loan', + 'signing', + 'signs', + 'signed', + 'joins', + 'joined', + 'bid', + 'bids', + 'deal', + 'move', + 'moves', + 'medical', + 'clause', + 'window', + 'rumor', + 'rumour', +]; + +function normalize(text: string): string { + return text.toLowerCase().replace(/\s+/g, ' ').trim(); +} + +function dedupeNews(items: NewsItem[]): NewsItem[] { + const seen = new Set(); + const result: NewsItem[] = []; + for (const item of items) { + const key = `${item.link}|${item.title}`; + if (seen.has(key)) continue; + seen.add(key); + result.push(item); + } + return result; +} + +function isTransferHeadline(item: NewsItem): boolean { + const title = normalize(item.title); + return TRANSFER_KEYWORDS.some((keyword) => title.includes(keyword)); +} + +function countFreshNews(items: NewsItem[]): number { + const cutoff = Date.now() - (36 * 60 * 60 * 1000); + return items.filter((item) => item.pubDate.getTime() >= cutoff).length; +} + +function isFreshNews(item: NewsItem): boolean { + return item.pubDate.getTime() >= Date.now() - (36 * 60 * 60 * 1000); +} + +export class SportsTransferNewsPanel extends Panel { + private items: NewsItem[] = []; + + constructor() { + super({ + id: 'sports-transfers', + title: 'Transfer News', + showCount: true, + infoTooltip: 'Football transfer headlines aggregated from BBC, ESPN, and Google News football transfer feeds.', + }); + } + + public async fetchData(): Promise { + this.showLoading('Loading transfer news...'); + try { + const fetched = await fetchCategoryFeeds(TRANSFER_FEEDS, { batchSize: 2 }); + const deduped = dedupeNews(fetched); + const filtered = deduped.filter(isTransferHeadline); + this.items = (filtered.length ? filtered : deduped).slice(0, 10); + + if (this.items.length === 0) { + this.showError('No transfer headlines available right now.', () => void this.fetchData()); + return false; + } + + this.setCount(this.items.length); + const freshCount = countFreshNews(this.items); + this.setNewBadge(freshCount, freshCount > 0); + this.renderPanel(); + return true; + } catch (error) { + if (this.isAbortError(error)) return false; + this.showError('Failed to load transfer news.', () => void this.fetchData()); + return false; + } + } + + private renderPanel(): void { + const html = ` +
+
+
+ Latest football moves, bids, loans, and transfer-window headlines aggregated from BBC, ESPN, and Google News transfer searches. +
+
+
+ ${this.items.map((item) => ` + +
+ ${escapeHtml(item.title)} + ${isFreshNews(item) ? 'New' : ''} +
+
+ ${escapeHtml(item.source)} + ${escapeHtml(item.pubDate.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }))} +
+
+ `).join('')} +
+
+ `; + + this.setContent(html); + } +} diff --git a/src/components/index.ts b/src/components/index.ts index 76cfd1b508..b111e84d45 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -89,4 +89,15 @@ export * from './DiseaseOutbreaksPanel'; export * from './SocialVelocityPanel'; export * from './WsbTickerScannerPanel'; export * from './ResilienceWidget'; +export * from './SportsTablesPanel'; +export * from './SportsStatsPanel'; +export * from './SportsLiveTrackerPanel'; +export * from './SportsMajorTournamentsPanel'; +export * from './SportsNbaPanel'; +export * from './SportsMotorsportPanel'; +export * from './SportsTransferNewsPanel'; +export * from './SportsPlayerSearchPanel'; export * from './EnergyCrisisPanel'; +export * from './SportsNbaAnalysisPanel'; +export * from './SportsEuropeanFootballAnalysisPanel'; +export * from './SportsMotorsportAnalysisPanel'; diff --git a/src/components/sportsAnalysisShared.ts b/src/components/sportsAnalysisShared.ts new file mode 100644 index 0000000000..70ca2c90bc --- /dev/null +++ b/src/components/sportsAnalysisShared.ts @@ -0,0 +1,235 @@ +import type { NewsItem } from '@/types'; +import { generateSummary, type SummarizationResult } from '@/services/summarization'; +import { escapeHtml, sanitizeUrl } from '@/utils/sanitize'; +import { Panel, type PanelOptions } from './Panel'; + +export type SportsAnalysisCard = { + label: string; + value: string; + detail?: string; + tone?: 'sky' | 'emerald' | 'amber' | 'rose'; +}; + +export type SportsAnalysisPoint = { + label: string; + text: string; +}; + +export type SportsAnalysisStory = { + title: string; + link: string; + source: string; + publishedAt: Date; + tag?: string; +}; + +const TONE_STYLES: Record, { border: string; glow: string; text: string }> = { + sky: { border: 'rgba(96,165,250,0.30)', glow: 'rgba(59,130,246,0.10)', text: '#bfdbfe' }, + emerald: { border: 'rgba(52,211,153,0.30)', glow: 'rgba(16,185,129,0.10)', text: '#a7f3d0' }, + amber: { border: 'rgba(251,191,36,0.30)', glow: 'rgba(245,158,11,0.10)', text: '#fde68a' }, + rose: { border: 'rgba(251,113,133,0.30)', glow: 'rgba(244,63,94,0.10)', text: '#fecdd3' }, +}; + +export abstract class SportsAnalysisPanelBase extends Panel { + protected data: TData | null = null; + protected aiBrief: SummarizationResult | null = null; + protected fallbackBrief = ''; + protected aiPending = false; + private summaryRequestId = 0; + private summarySignature = ''; + + protected constructor(options: PanelOptions) { + super(options); + } + + protected requestAiBrief(inputs: string[]): void { + const cleaned = [...new Set(inputs.map((input) => input.trim()).filter(Boolean))].slice(0, 8); + const signature = cleaned.join('||'); + if (signature === this.summarySignature && (this.aiPending || !!this.aiBrief?.summary)) { + return; + } + + this.summarySignature = signature; + this.aiBrief = null; + + if (cleaned.length < 2) { + this.aiPending = false; + this.renderPanel(); + return; + } + + const requestId = ++this.summaryRequestId; + this.aiPending = true; + this.renderPanel(); + + void generateSummary(cleaned, undefined, this.panelId) + .then((result) => { + if (requestId !== this.summaryRequestId || signature !== this.summarySignature) return; + this.aiBrief = result; + }) + .catch(() => { + if (requestId !== this.summaryRequestId || signature !== this.summarySignature) return; + this.aiBrief = null; + }) + .finally(() => { + if (requestId !== this.summaryRequestId || signature !== this.summarySignature) return; + this.aiPending = false; + this.renderPanel(); + }); + } + + public destroy(): void { + this.summaryRequestId += 1; + super.destroy(); + } + + protected abstract renderPanel(): void; +} + +export function normalizeLookup(value: string | undefined): string { + return (value || '') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +export function dedupeNewsItems(items: NewsItem[]): NewsItem[] { + const seen = new Set(); + const deduped: NewsItem[] = []; + + for (const item of items) { + const key = `${item.link}|${normalizeLookup(item.title)}`; + if (seen.has(key)) continue; + seen.add(key); + deduped.push(item); + } + + return deduped; +} + +export function countFreshStories(items: NewsItem[], windowHours = 36): number { + const cutoff = Date.now() - (windowHours * 60 * 60 * 1000); + return items.filter((item) => item.pubDate.getTime() >= cutoff).length; +} + +export function countFreshAnalysisStories(stories: SportsAnalysisStory[], windowHours = 36): number { + const cutoff = Date.now() - (windowHours * 60 * 60 * 1000); + return stories.filter((story) => story.publishedAt.getTime() >= cutoff).length; +} + +export function formatUpdatedAt(value?: string | number | Date): string { + if (!value) return 'Live feed'; + const stamp = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : Date.parse(value); + if (Number.isNaN(stamp)) return String(value); + return new Date(stamp).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +export function formatPublishedAt(value: Date): string { + return value.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +export function renderAiBrief(summary: SummarizationResult | null, fallbackBrief: string, isPending: boolean): string { + const title = summary?.summary ? 'AI Brief' : isPending ? 'AI Brief' : 'Signal Read'; + const body = summary?.summary || fallbackBrief; + const meta = summary?.summary + ? (summary.cached ? 'Cached AI brief' : `${summary.provider.toUpperCase()} AI brief`) + : isPending + ? 'Generating from the latest story mix and live table context.' + : 'Deterministic fallback while AI analysis is unavailable.'; + + return ` +
+
+
${escapeHtml(title)}
+ ${isPending && !summary?.summary ? 'Loading' : ''} +
+
${escapeHtml(body || 'Live sports context is building.')}
+
${escapeHtml(meta)}
+
+ `; +} + +export function renderAnalysisCards(cards: SportsAnalysisCard[]): string { + return ` +
+ ${cards.map((card) => { + const tone = card.tone ? TONE_STYLES[card.tone] : null; + return ` +
+
${escapeHtml(card.label)}
+
${escapeHtml(card.value)}
+
${escapeHtml(card.detail || '')}
+
+ `; + }).join('')} +
+ `; +} + +export function renderAnalysisPoints(title: string, points: SportsAnalysisPoint[]): string { + if (!points.length) return ''; + + return ` +
+
${escapeHtml(title)}
+
+ ${points.map((point) => ` +
+
${escapeHtml(point.label)}
+
${escapeHtml(point.text)}
+
+ `).join('')} +
+
+ `; +} + +export function renderDistributionChips(title: string, entries: Array<{ label: string; value: string }>): string { + if (!entries.length) return ''; + + return ` +
+
${escapeHtml(title)}
+
+ ${entries.map((entry) => ` + + ${escapeHtml(entry.label)} + ${escapeHtml(entry.value)} + + `).join('')} +
+
+ `; +} + +export function renderAnalysisStories(title: string, stories: SportsAnalysisStory[], emptyText: string): string { + return ` +
+
${escapeHtml(title)}
+ ${stories.length ? stories.map((story) => ` + +
+
${escapeHtml(story.title)}
+ ${story.tag ? `${escapeHtml(story.tag)}` : ''} +
+
+ ${escapeHtml(story.source)} + ${escapeHtml(formatPublishedAt(story.publishedAt))} +
+
+ `).join('') : `
${escapeHtml(emptyText)}
`} +
+ `; +} diff --git a/src/components/sportsPanelShared.ts b/src/components/sportsPanelShared.ts new file mode 100644 index 0000000000..e1032e0ed2 --- /dev/null +++ b/src/components/sportsPanelShared.ts @@ -0,0 +1,418 @@ +import { + parseEventTimestamp, + type SportsEvent, + type SportsLeagueCenterData, + type SportsStandingRow, + type SportsStatSnapshot, + type SportsTableGroup, +} from '@/services/sports'; +import { escapeHtml, sanitizeUrl } from '@/utils/sanitize'; + +export type SportsLeagueStatCard = { + label: string; + value: string; + detail?: string; +}; + +type SportsIdentityOptions = { + align?: 'left' | 'right'; + size?: number; + secondary?: string; +}; + +type SportsEventCardOptions = { + showTitle?: boolean; +}; + +function buildFormScore(form?: string): number { + return (form || '') + .toUpperCase() + .split('') + .reduce((score, result) => { + if (result === 'W') return score + 3; + if (result === 'D') return score + 1; + return score; + }, 0); +} + +export function formatSportsForm(form?: string): string { + const cleaned = (form || '').replace(/[^A-Za-z]/g, '').toUpperCase().slice(0, 5); + return cleaned || '—'; +} + +export function formatSportsUpdatedAt(value?: string): string { + if (!value) return 'Live feed'; + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) return value; + return new Date(parsed).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + +export function buildSportsSeasonLabel(data: SportsLeagueCenterData): string { + return data.table?.season || data.league.currentSeason || data.selectedSeason || 'Current season'; +} + +export function buildSportsLeagueStatCards(data: SportsLeagueCenterData): SportsLeagueStatCard[] { + const rows = data.table?.rows || []; + const leader = rows[0]; + if (!leader) return []; + + const runnerUp = rows[1]; + const totalPlayed = rows.reduce((sum, row) => sum + row.played, 0); + const totalPoints = rows.reduce((sum, row) => sum + row.points, 0); + const matchesLogged = Math.round(totalPlayed / 2); + const leaderPace = leader.played > 0 ? (leader.points / leader.played).toFixed(2) : '0.00'; + const averagePoints = (totalPoints / rows.length).toFixed(1); + const bestForm = rows + .filter((row) => !!row.form) + .sort((a, b) => buildFormScore(b.form) - buildFormScore(a.form) || b.points - a.points)[0]; + + const cards: SportsLeagueStatCard[] = [ + { + label: 'Leader', + value: leader.team, + detail: `${leader.points} pts`, + }, + { + label: 'Gap', + value: runnerUp ? `${Math.max(leader.points - runnerUp.points, 0)} pts` : '—', + detail: runnerUp ? `over ${runnerUp.team}` : 'No runner-up yet', + }, + { + label: 'Clubs', + value: String(rows.length), + detail: 'in table', + }, + { + label: 'Matches', + value: String(matchesLogged), + detail: 'logged', + }, + { + label: 'Leader pace', + value: leaderPace, + detail: 'pts per match', + }, + { + label: 'Avg points', + value: averagePoints, + detail: 'per club', + }, + ]; + + if (bestForm) { + cards.push({ + label: 'Best form', + value: formatSportsForm(bestForm.form), + detail: bestForm.team, + }); + } + + return cards; +} + +export function formatSportsEventTime(event: SportsEvent): string { + const timestamp = parseEventTimestamp(event); + if (timestamp !== Number.MAX_SAFE_INTEGER) { + return new Date(timestamp).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + } + if (event.dateEvent && event.strTime) return `${event.dateEvent} ${event.strTime}`; + return event.dateEvent || event.strTime || 'TBD'; +} + +export function formatSportsEventResult(event: SportsEvent): string { + if (event.intHomeScore || event.intAwayScore) { + return `${event.strHomeTeam || 'Home'} ${event.intHomeScore || '-'} - ${event.intAwayScore || '-'} ${event.strAwayTeam || 'Away'}`; + } + return event.strEvent || `${event.strHomeTeam || 'Home'} vs ${event.strAwayTeam || 'Away'}`; +} + +export function formatSportsFixture(event: SportsEvent): string { + return event.strEvent || `${event.strHomeTeam || 'Home'} vs ${event.strAwayTeam || 'Away'}`; +} + +function buildSportsMonogram(name?: string): string { + const parts = (name || '') + .split(/\s+/) + .map((part) => part.trim()) + .filter(Boolean); + if (!parts.length) return '?'; + const first = parts[0] || ''; + const second = parts[1] || ''; + if (parts.length === 1) return first.slice(0, 2).toUpperCase(); + return `${first[0] || ''}${second[0] || ''}`.toUpperCase(); +} + +export function renderSportsBadge(name?: string, badge?: string, size = 24): string { + const px = `${size}px`; + const safeBadge = badge ? sanitizeUrl(badge) : ''; + if (safeBadge) { + return ` + + ${escapeHtml(name || 'Team')} + + `; + } + + return ` + + ${escapeHtml(buildSportsMonogram(name))} + + `; +} + +export function renderSportsTeamIdentity( + name: string | undefined, + badge?: string, + options: SportsIdentityOptions = {}, +): string { + const align = options.align === 'right' ? 'right' : 'left'; + const secondary = options.secondary + ? `
${escapeHtml(options.secondary)}
` + : ''; + + return ` +
+ ${renderSportsBadge(name, badge, options.size || 24)} +
+
${escapeHtml(name || 'TBD')}
+ ${secondary} +
+
+ `; +} + +export function renderSportsMatchup(event: SportsEvent, mode: 'result' | 'fixture'): string { + const statusDetail = event.strProgress && event.strProgress !== event.strStatus + ? event.strProgress + : event.strStatus; + const centerLabel = mode === 'result' && (event.intHomeScore || event.intAwayScore) + ? `${event.intHomeScore || '-'} - ${event.intAwayScore || '-'}` + : 'vs'; + + return ` +
+ ${renderSportsTeamIdentity(event.strHomeTeam, event.strHomeBadge)} +
+
${escapeHtml(centerLabel)}
+ ${statusDetail ? `
${escapeHtml(statusDetail)}
` : ''} +
+ ${renderSportsTeamIdentity(event.strAwayTeam, event.strAwayBadge, { align: 'right' })} +
+ `; +} + +export function renderSportsFormBadges(form?: string): string { + const values = formatSportsForm(form); + if (values === '—') { + return ''; + } + + return ` + + ${values.split('').map((value) => { + const tone = value === 'W' + ? 'background:rgba(16,185,129,0.18);color:#a7f3d0;' + : value === 'D' + ? 'background:rgba(245,158,11,0.16);color:#fde68a;' + : 'background:rgba(239,68,68,0.16);color:#fca5a5;'; + return `${value}`; + }).join('')} + + `; +} + +export function renderSportsTableRows(table: SportsTableGroup): string { + return table.rows.map((row) => { + const isLeader = row.rank === 1; + const rowAccent = isLeader ? 'background:rgba(16,185,129,0.05);' : ''; + const rankAccent = isLeader + ? 'background:rgba(16,185,129,0.18);color:#a7f3d0;' + : 'background:rgba(255,255,255,0.05);color:rgba(255,255,255,0.76);'; + + return ` + + + + ${row.rank} + + + +
+ ${renderSportsTeamIdentity(row.team, row.badge)} + ${row.note ? `
${escapeHtml(row.note)}
` : ''} +
+ + ${row.played} + ${row.wins} + ${row.draws} + ${row.losses} + ${row.goalDifference >= 0 ? '+' : ''}${row.goalDifference} + ${row.points} + ${renderSportsFormBadges(row.form)} + + `; + }).join(''); +} + +export function renderSportsTableNotes(rows: SportsStandingRow[]): string { + const notes = [...new Set(rows.map((row) => row.note).filter(Boolean))]; + if (!notes.length) return ''; + + return ` +
+ ${notes.map((note) => ` + + ${escapeHtml(note || '')} + + `).join('')} +
+ `; +} + +export function renderSportsStandingsBlock( + title: string, + seasonLabel: string, + table: SportsTableGroup | null, + emptyMessage: string, +): string { + if (!table?.rows.length) { + return ` +
+
${escapeHtml(title)}
+
${escapeHtml(emptyMessage)}
+
+ `; + } + + return ` +
+
+
+
${escapeHtml(title)}
+
${escapeHtml(seasonLabel)}
+
+
+ ${escapeHtml(table.updatedAt ? `Updated ${formatSportsUpdatedAt(table.updatedAt)}` : 'Live feed')} +
+
+ +
+ + + + + + + + + + + + + + + + ${renderSportsTableRows(table)} + +
PosTeamPWDLGDPtsForm
+
+ + ${renderSportsTableNotes(table.rows)} +
+ `; +} + +export function renderSportsEventCard( + title: string, + event: SportsEvent | undefined, + mode: 'result' | 'fixture', + options: SportsEventCardOptions = {}, +): string { + const showTitle = options.showTitle !== false; + if (!event) { + return ` +
+ ${showTitle ? `
${escapeHtml(title)}
` : ''} +
No ${mode === 'result' ? 'recent result' : 'upcoming event'} available.
+
+ `; + } + + const headline = mode === 'result' ? formatSportsEventResult(event) : formatSportsFixture(event); + const meta = [ + event.strVenue, + event.strRound ? `Round ${event.strRound}` : '', + event.strSeason, + ].filter(Boolean).join(' · '); + + return ` +
+ ${showTitle ? `
${escapeHtml(title)}
` : ''} + ${(event.strHomeTeam || event.strAwayTeam) ? renderSportsMatchup(event, mode) : `
${escapeHtml(headline)}
`} +
${escapeHtml(formatSportsEventTime(event))}
+
${escapeHtml(meta || 'Live sports feed')}
+
+ `; +} + +export function renderSportsEventSection( + title: string, + events: SportsEvent[], + mode: 'result' | 'fixture', + emptyMessage: string, +): string { + return ` +
+
+
${escapeHtml(title)}
+
${events.length ? `${events.length} match${events.length === 1 ? '' : 'es'}` : 'No events'}
+
+ ${events.length + ? `
${events.map((event) => renderSportsEventCard('', event, mode, { showTitle: false })).join('')}
` + : `
${escapeHtml(emptyMessage)}
`} +
+ `; +} + +export function renderSportsStatSnapshotBlock(title: string, snapshot: SportsStatSnapshot | null): string { + if (!snapshot) { + return ` +
+
${escapeHtml(title)}
+
No recent stat snapshot available.
+
+ `; + } + + return ` +
+
+
+
${escapeHtml(title)}
+
${escapeHtml(snapshot.event.strEvent || formatSportsEventResult(snapshot.event))}
+
+
${escapeHtml(formatSportsEventTime(snapshot.event))}
+
+ ${renderSportsMatchup(snapshot.event, 'result')} +
+ ${snapshot.stats.map((stat) => ` +
+ ${escapeHtml(stat.homeValue || '-')} + ${escapeHtml(stat.label)} + ${escapeHtml(stat.awayValue || '-')} +
+ `).join('')} +
+
+ `; +} diff --git a/src/config/commands.ts b/src/config/commands.ts index 474b7f154f..316d10e6cd 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -82,6 +82,7 @@ export const COMMANDS: Command[] = [ { id: 'layer:economic', keywords: ['economic centers', 'gdp'], label: 'Toggle economic centers', icon: '\u{1F4B0}', category: 'layers' }, { id: 'layer:minerals', keywords: ['minerals', 'rare earth', 'critical minerals', 'lithium'], label: 'Toggle critical minerals', icon: '\u{1F48E}', category: 'layers' }, { id: 'layer:cii', keywords: ['cii', 'instability index', 'country instability'], label: 'Toggle CII instability', icon: '\u{1F30E}', category: 'layers' }, + { id: 'layer:sportsFixtures', keywords: ['sports fixtures', 'fixtures', 'matches', 'games', 'sports map'], label: 'Toggle sports fixtures', icon: '\u{26BD}', category: 'layers' }, { id: 'layer:dayNight', keywords: ['day night', 'terminator', 'shadow', 'day/night'], label: 'Toggle day/night overlay', icon: '\u{1F31C}', category: 'layers' }, { id: 'layer:sanctions', keywords: ['sanctions', 'embargoes'], label: 'Toggle sanctions', icon: '\u{1F6AB}', category: 'layers' }, diff --git a/src/config/feeds.ts b/src/config/feeds.ts index 4876998744..635c7bcf60 100644 --- a/src/config/feeds.ts +++ b/src/config/feeds.ts @@ -882,6 +882,47 @@ const COMMODITY_FEEDS: Record = { ], }; +const SPORTS_FEEDS: Record = { + sports: [ + { name: 'BBC Sport', url: rss('https://feeds.bbci.co.uk/sport/rss.xml?edition=uk') }, + { name: 'ESPN', url: rss('https://www.espn.com/espn/rss/news') }, + { name: 'Reuters Sports', url: rss('https://news.google.com/rss/search?q=site:reuters.com+sports+-politics+-election+-government+when:2d&hl=en-US&gl=US&ceid=US:en') }, + { name: 'AP Sports', url: rss('https://news.google.com/rss/search?q=site:apnews.com+sports+-politics+-election+-government+when:2d&hl=en-US&gl=US&ceid=US:en') }, + { name: 'Sky Sports', url: rss('https://www.skysports.com/rss/12040') }, + ], + soccer: [ + { name: 'BBC Sport', url: rss('https://feeds.bbci.co.uk/sport/football/rss.xml?edition=uk') }, + { name: 'Sky Sports', url: rss('https://www.skysports.com/rss/12040') }, + { name: 'ESPN', url: rss('https://www.espn.com/espn/rss/soccer/news') }, + { name: 'Guardian Football', url: rss('https://www.theguardian.com/football/rss') }, + ], + basketball: [ + { name: 'NBA.com', url: rss('https://news.google.com/rss/search?q=site:nba.com+nba+when:2d&hl=en-US&gl=US&ceid=US:en') }, + { name: 'ESPN', url: rss('https://news.google.com/rss/search?q=site:espn.com+NBA+-college+-fantasy+-%22transfer+portal%22+when:2d&hl=en-US&gl=US&ceid=US:en') }, + { name: 'The Athletic NBA', url: rss('https://news.google.com/rss/search?q=site:theathletic.com+NBA+-fantasy+-college+when:2d&hl=en-US&gl=US&ceid=US:en') }, + ], + baseball: [ + { name: 'MLB.com', url: rss('https://news.google.com/rss/search?q=site:mlb.com+MLB+when:2d&hl=en-US&gl=US&ceid=US:en') }, + { name: 'ESPN', url: rss('https://news.google.com/rss/search?q=site:espn.com+MLB+when:2d&hl=en-US&gl=US&ceid=US:en') }, + { name: 'Baseball News', url: rss('https://news.google.com/rss/search?q=MLB+(standings+OR+scores+OR+recap)+-college+-fantasy+when:2d&hl=en-US&gl=US&ceid=US:en') }, + ], + motorsport: [ + { name: 'Formula1.com', url: rss('https://news.google.com/rss/search?q=site:formula1.com+Formula+1+when:3d&hl=en-US&gl=US&ceid=US:en') }, + { name: 'Motorsport.com', url: rss('https://news.google.com/rss/search?q=site:motorsport.com+Formula+1+OR+MotoGP+when:3d&hl=en-US&gl=US&ceid=US:en') }, + { name: 'Racer', url: rss('https://news.google.com/rss/search?q=site:racer.com+motorsport+when:3d&hl=en-US&gl=US&ceid=US:en') }, + ], + tennis: [ + { name: 'ATP Tour', url: rss('https://news.google.com/rss/search?q=site:atptour.com+tennis+when:3d&hl=en-US&gl=US&ceid=US:en') }, + { name: 'WTA', url: rss('https://news.google.com/rss/search?q=site:wtatennis.com+tennis+when:3d&hl=en-US&gl=US&ceid=US:en') }, + { name: 'Tennis.com', url: rss('https://news.google.com/rss/search?q=site:tennis.com+tennis+when:3d&hl=en-US&gl=US&ceid=US:en') }, + ], + combat: [ + { name: 'UFC', url: rss('https://news.google.com/rss/search?q=site:ufc.com+UFC+when:3d&hl=en-US&gl=US&ceid=US:en') }, + { name: 'ESPN', url: rss('https://news.google.com/rss/search?q=site:espn.com+(MMA+OR+boxing)+-basketball+-football+-%22transfer+portal%22+when:3d&hl=en-US&gl=US&ceid=US:en') }, + { name: 'MMA Fighting', url: rss('https://news.google.com/rss/search?q=site:mmafighting.com+MMA+when:3d&hl=en-US&gl=US&ceid=US:en') }, + ], +}; + // Variant-aware exports export const FEEDS = SITE_VARIANT === 'tech' ? TECH_FEEDS @@ -891,6 +932,8 @@ export const FEEDS = SITE_VARIANT === 'tech' ? HAPPY_FEEDS : SITE_VARIANT === 'commodity' ? COMMODITY_FEEDS + : SITE_VARIANT === 'sports' + ? SPORTS_FEEDS : FULL_FEEDS; export const SOURCE_REGION_MAP: Record = { @@ -924,6 +967,10 @@ export const SOURCE_REGION_MAP: Record = { accelerators: def('accelerators', '⚡', 'accelerators', 'Accelerators'), cloudRegions: def('cloudRegions', '☁', 'cloudRegions', 'Cloud Regions'), techEvents: def('techEvents', '📅', 'techEvents', 'Tech Events'), + sportsFixtures: def('sportsFixtures', '⚽', 'sportsFixtures', 'Sports Fixtures'), stockExchanges: def('stockExchanges', '🏛', 'stockExchanges', 'Stock Exchanges'), financialCenters: def('financialCenters', '💰', 'financialCenters', 'Financial Centers'), centralBanks: def('centralBanks', '🏦', 'centralBanks', 'Central Banks'), @@ -117,6 +118,10 @@ const VARIANT_LAYER_ORDER: Record> = { 'ais', 'economic', 'fires', 'climate', 'resilienceScore', 'natural', 'weather', 'outages', 'sanctions', 'dayNight', ], + sports: [ + 'sportsFixtures', + 'dayNight', + ], }; const I18N_PREFIX = 'components.deckgl.layers.'; @@ -195,6 +200,7 @@ export const LAYER_SYNONYMS: Record> = { ai: ['datacenters'], startup: ['startupHubs', 'accelerators'], tech: ['techHQs', 'techEvents', 'startupHubs', 'cloudRegions', 'datacenters'], + sports: ['sportsFixtures', 'dayNight'], gps: ['gpsJamming'], jamming: ['gpsJamming'], mineral: ['minerals', 'miningSites'], diff --git a/src/config/panels.ts b/src/config/panels.ts index 179ea1e9be..68fc243a13 100644 --- a/src/config/panels.ts +++ b/src/config/panels.ts @@ -149,6 +149,7 @@ const FULL_MAP_LAYERS: MapLayers = { accelerators: false, techHQs: false, techEvents: false, + sportsFixtures: false, // Finance layers (disabled in full variant) stockExchanges: false, financialCenters: false, @@ -212,6 +213,7 @@ const FULL_MOBILE_MAP_LAYERS: MapLayers = { accelerators: false, techHQs: false, techEvents: false, + sportsFixtures: false, // Finance layers (disabled in full variant) stockExchanges: false, financialCenters: false, @@ -318,6 +320,7 @@ const TECH_MAP_LAYERS: MapLayers = { accelerators: false, techHQs: true, techEvents: true, + sportsFixtures: false, // Finance layers (disabled in tech variant) stockExchanges: false, financialCenters: false, @@ -380,6 +383,7 @@ const TECH_MOBILE_MAP_LAYERS: MapLayers = { accelerators: false, techHQs: false, techEvents: true, + sportsFixtures: false, // Finance layers (disabled in tech variant) stockExchanges: false, financialCenters: false, @@ -504,6 +508,7 @@ const FINANCE_MAP_LAYERS: MapLayers = { accelerators: false, techHQs: false, techEvents: false, + sportsFixtures: false, // Finance layers (enabled in finance variant) stockExchanges: true, financialCenters: true, @@ -566,6 +571,7 @@ const FINANCE_MOBILE_MAP_LAYERS: MapLayers = { accelerators: false, techHQs: false, techEvents: false, + sportsFixtures: false, // Finance layers (limited on mobile) stockExchanges: true, financialCenters: false, @@ -644,6 +650,7 @@ const HAPPY_MAP_LAYERS: MapLayers = { accelerators: false, techHQs: false, techEvents: false, + sportsFixtures: false, // Finance layers (disabled) stockExchanges: false, financialCenters: false, @@ -706,6 +713,7 @@ const HAPPY_MOBILE_MAP_LAYERS: MapLayers = { accelerators: false, techHQs: false, techEvents: false, + sportsFixtures: false, // Finance layers (disabled) stockExchanges: false, financialCenters: false, @@ -731,6 +739,95 @@ const HAPPY_MOBILE_MAP_LAYERS: MapLayers = { diseaseOutbreaks: false, }; +// ============================================ +// SPORTS VARIANT (Multi-Sport News + Fixtures) +// ============================================ +const SPORTS_PANELS: Record = { + map: { name: 'Sports Map', enabled: true, priority: 1 }, + sports: { name: 'Sports Headlines', enabled: true, priority: 1 }, + 'sports-nba-analysis': { name: 'NBA AI Analysis', enabled: true, priority: 1 }, + 'sports-football-analysis': { name: 'European Football AI', enabled: true, priority: 1 }, + 'sports-motorsport-analysis': { name: 'Motorsport AI', enabled: true, priority: 1 }, + 'sports-tournaments': { name: 'Major Tournaments', enabled: true, priority: 1 }, + 'sports-tables': { name: 'League Table', enabled: true, priority: 1 }, + 'sports-nba': { name: 'NBA Standings', enabled: true, priority: 1 }, + 'sports-motorsport-standings': { name: 'Motorsport Scores', enabled: true, priority: 1 }, + 'sports-stats': { name: 'Match Stats', enabled: true, priority: 1 }, + 'sports-live-tracker': { name: 'Live Fixture Tracker', enabled: true, priority: 1 }, + 'sports-transfers': { name: 'Transfer News', enabled: true, priority: 1 }, + 'sports-player-search': { name: 'Player Search', enabled: true, priority: 1 }, + soccer: { name: 'Football', enabled: true, priority: 1 }, + basketball: { name: 'Basketball', enabled: true, priority: 1 }, + baseball: { name: 'Baseball', enabled: true, priority: 2 }, + motorsport: { name: 'Motorsport', enabled: true, priority: 2 }, + tennis: { name: 'Tennis', enabled: true, priority: 2 }, + combat: { name: 'Combat Sports', enabled: true, priority: 2 }, + 'world-clock': { name: 'World Clock', enabled: true, priority: 2 }, + monitors: { name: 'My Monitors', enabled: true, priority: 2 }, +}; + +const SPORTS_MAP_LAYERS: MapLayers = { + gpsJamming: false, + satellites: false, + + + conflicts: false, + bases: false, + cables: false, + pipelines: false, + hotspots: false, + ais: false, + nuclear: false, + irradiators: false, + sanctions: false, + weather: false, + economic: false, + waterways: false, + outages: false, + cyberThreats: false, + datacenters: false, + protests: false, + flights: false, + military: false, + natural: false, + spaceports: false, + minerals: false, + fires: false, + ucdpEvents: false, + displacement: false, + climate: false, + startupHubs: false, + cloudRegions: false, + accelerators: false, + techHQs: false, + techEvents: false, + sportsFixtures: true, + stockExchanges: false, + financialCenters: false, + centralBanks: false, + commodityHubs: false, + gulfInvestments: false, + positiveEvents: false, + kindness: false, + happiness: false, + speciesRecovery: false, + renewableInstallations: false, + tradeRoutes: false, + iranAttacks: false, + ciiChoropleth: false, + resilienceScore: false, + dayNight: true, + miningSites: false, + processingPlants: false, + commodityPorts: false, + webcams: false, + diseaseOutbreaks: false, +}; + +const SPORTS_MOBILE_MAP_LAYERS: MapLayers = { + ...SPORTS_MAP_LAYERS, +}; + // ============================================ // COMMODITY VARIANT (Mining, Metals, Energy) // ============================================ @@ -803,6 +900,7 @@ const COMMODITY_MAP_LAYERS: MapLayers = { accelerators: false, techHQs: false, techEvents: false, + sportsFixtures: false, // Finance layers (enabled for commodity hubs) stockExchanges: false, financialCenters: false, @@ -865,6 +963,7 @@ const COMMODITY_MOBILE_MAP_LAYERS: MapLayers = { accelerators: false, techHQs: false, techEvents: false, + sportsFixtures: false, // Finance layers (limited on mobile) stockExchanges: false, financialCenters: false, @@ -897,6 +996,7 @@ const COMMODITY_MOBILE_MAP_LAYERS: MapLayers = { /** All panels from all variants — union with FULL taking precedence for duplicate keys. */ export const ALL_PANELS: Record = { ...HAPPY_PANELS, + ...SPORTS_PANELS, ...COMMODITY_PANELS, ...TECH_PANELS, ...FINANCE_PANELS, @@ -909,6 +1009,7 @@ export const VARIANT_DEFAULTS: Record = { tech: Object.keys(TECH_PANELS), finance: Object.keys(FINANCE_PANELS), commodity: Object.keys(COMMODITY_PANELS), + sports: Object.keys(SPORTS_PANELS), happy: Object.keys(HAPPY_PANELS), }; @@ -932,6 +1033,20 @@ export const VARIANT_PANEL_OVERRIDES: Partial { const isTauri = '__TAURI_INTERNALS__' in window || '__TAURI__' in window; if (isTauri) { const stored = localStorage.getItem('worldmonitor-variant'); - if (stored === 'tech' || stored === 'full' || stored === 'finance' || stored === 'happy' || stored === 'commodity') return stored; + if (stored === 'tech' || stored === 'full' || stored === 'finance' || stored === 'happy' || stored === 'commodity' || stored === 'sports') return stored; return buildVariant; } @@ -21,10 +21,11 @@ export const SITE_VARIANT: string = (() => { if (h.startsWith('finance.')) return 'finance'; if (h.startsWith('happy.')) return 'happy'; if (h.startsWith('commodity.')) return 'commodity'; + if (h.startsWith('sports.')) return 'sports'; if (h === 'localhost' || h === '127.0.0.1') { const stored = localStorage.getItem('worldmonitor-variant'); - if (stored === 'tech' || stored === 'full' || stored === 'finance' || stored === 'happy' || stored === 'commodity') return stored; + if (stored === 'tech' || stored === 'full' || stored === 'finance' || stored === 'happy' || stored === 'commodity' || stored === 'sports') return stored; return buildVariant; } diff --git a/src/config/variants/base.ts b/src/config/variants/base.ts index 4b72c5929a..197e460dd5 100644 --- a/src/config/variants/base.ts +++ b/src/config/variants/base.ts @@ -63,6 +63,7 @@ export const REFRESH_INTERVALS = { earningsCalendar: 60 * 60 * 1000, economicCalendar: 60 * 60 * 1000, cotPositioning: 60 * 60 * 1000, + sports: 15 * 60 * 1000, goldIntelligence: 5 * 60 * 1000, aaiiSentiment: 60 * 60 * 1000, // weekly data; hourly refresh is sufficient marketBreadth: 60 * 60 * 1000, // seeded daily; hourly refresh is sufficient diff --git a/src/config/variants/commodity.ts b/src/config/variants/commodity.ts index 5b79a73ee9..4c090ea7b8 100644 --- a/src/config/variants/commodity.ts +++ b/src/config/variants/commodity.ts @@ -97,6 +97,7 @@ export const DEFAULT_MAP_LAYERS: MapLayers = { accelerators: false, techHQs: false, techEvents: false, + sportsFixtures: false, // Finance variant layers stockExchanges: false, financialCenters: false, @@ -164,6 +165,7 @@ export const MOBILE_DEFAULT_MAP_LAYERS: MapLayers = { accelerators: false, techHQs: false, techEvents: false, + sportsFixtures: false, stockExchanges: false, financialCenters: false, centralBanks: false, diff --git a/src/config/variants/finance.ts b/src/config/variants/finance.ts index d1f337c9b2..cbcd0e45af 100644 --- a/src/config/variants/finance.ts +++ b/src/config/variants/finance.ts @@ -213,6 +213,7 @@ export const DEFAULT_MAP_LAYERS: MapLayers = { accelerators: false, techHQs: false, techEvents: false, + sportsFixtures: false, // Finance-specific layers stockExchanges: true, financialCenters: true, @@ -275,6 +276,7 @@ export const MOBILE_DEFAULT_MAP_LAYERS: MapLayers = { accelerators: false, techHQs: false, techEvents: false, + sportsFixtures: false, // Finance layers (limited on mobile) stockExchanges: true, financialCenters: false, diff --git a/src/config/variants/full.ts b/src/config/variants/full.ts index 329b4afada..64bd9ce598 100644 --- a/src/config/variants/full.ts +++ b/src/config/variants/full.ts @@ -90,6 +90,7 @@ export const DEFAULT_MAP_LAYERS: MapLayers = { accelerators: false, techHQs: false, techEvents: false, + sportsFixtures: false, // Finance layers (disabled in full variant) stockExchanges: false, financialCenters: false, @@ -152,6 +153,7 @@ export const MOBILE_DEFAULT_MAP_LAYERS: MapLayers = { accelerators: false, techHQs: false, techEvents: false, + sportsFixtures: false, // Finance layers (disabled in full variant) stockExchanges: false, financialCenters: false, diff --git a/src/config/variants/happy.ts b/src/config/variants/happy.ts index 53a80f780e..c2e3982672 100644 --- a/src/config/variants/happy.ts +++ b/src/config/variants/happy.ts @@ -56,6 +56,7 @@ export const DEFAULT_MAP_LAYERS: MapLayers = { accelerators: false, techHQs: false, techEvents: false, + sportsFixtures: false, // Finance layers (disabled) stockExchanges: false, financialCenters: false, @@ -119,6 +120,7 @@ export const MOBILE_DEFAULT_MAP_LAYERS: MapLayers = { accelerators: false, techHQs: false, techEvents: false, + sportsFixtures: false, // Finance layers (disabled) stockExchanges: false, financialCenters: false, diff --git a/src/config/variants/sports.ts b/src/config/variants/sports.ts new file mode 100644 index 0000000000..c227e978b5 --- /dev/null +++ b/src/config/variants/sports.ts @@ -0,0 +1,103 @@ +// Sports variant - sports.worldmonitor.app +import type { PanelConfig, MapLayers } from '@/types'; +import type { VariantConfig } from './base'; + +// Re-export base config +export * from './base'; + +// Panel configuration for multi-sport news and live data +export const DEFAULT_PANELS: Record = { + map: { name: 'Sports Map', enabled: true, priority: 1 }, + sports: { name: 'Sports Headlines', enabled: true, priority: 1 }, + 'sports-nba-analysis': { name: 'NBA AI Analysis', enabled: true, priority: 1 }, + 'sports-football-analysis': { name: 'European Football AI', enabled: true, priority: 1 }, + 'sports-motorsport-analysis': { name: 'Motorsport AI', enabled: true, priority: 1 }, + 'sports-tournaments': { name: 'Major Tournaments', enabled: true, priority: 1 }, + 'sports-tables': { name: 'League Table', enabled: true, priority: 1 }, + 'sports-nba': { name: 'NBA Standings', enabled: true, priority: 1 }, + 'sports-motorsport-standings': { name: 'Motorsport Scores', enabled: true, priority: 1 }, + 'sports-stats': { name: 'Match Stats', enabled: true, priority: 1 }, + 'sports-live-tracker': { name: 'Live Fixture Tracker', enabled: true, priority: 1 }, + 'sports-transfers': { name: 'Transfer News', enabled: true, priority: 1 }, + 'sports-player-search': { name: 'Player Search', enabled: true, priority: 1 }, + soccer: { name: 'Football', enabled: true, priority: 1 }, + basketball: { name: 'Basketball', enabled: true, priority: 1 }, + baseball: { name: 'Baseball', enabled: true, priority: 2 }, + motorsport: { name: 'Motorsport', enabled: true, priority: 2 }, + tennis: { name: 'Tennis', enabled: true, priority: 2 }, + combat: { name: 'Combat Sports', enabled: true, priority: 2 }, + 'world-clock': { name: 'World Clock', enabled: true, priority: 2 }, + monitors: { name: 'My Monitors', enabled: true, priority: 2 }, +}; + +// Map layers — sports keeps the map minimal with only the day/night overlay enabled. +export const DEFAULT_MAP_LAYERS: MapLayers = { + gpsJamming: false, + satellites: false, + + + conflicts: false, + bases: false, + cables: false, + pipelines: false, + hotspots: false, + ais: false, + nuclear: false, + irradiators: false, + sanctions: false, + weather: false, + economic: false, + waterways: false, + outages: false, + cyberThreats: false, + datacenters: false, + protests: false, + flights: false, + military: false, + natural: false, + spaceports: false, + minerals: false, + fires: false, + ucdpEvents: false, + displacement: false, + climate: false, + startupHubs: false, + cloudRegions: false, + accelerators: false, + techHQs: false, + techEvents: false, + sportsFixtures: true, + stockExchanges: false, + financialCenters: false, + centralBanks: false, + commodityHubs: false, + gulfInvestments: false, + positiveEvents: false, + kindness: false, + happiness: false, + speciesRecovery: false, + renewableInstallations: false, + tradeRoutes: false, + iranAttacks: false, + ciiChoropleth: false, + resilienceScore: false, + dayNight: true, + miningSites: false, + processingPlants: false, + commodityPorts: false, + webcams: false, + diseaseOutbreaks: false, +}; + +// Mobile defaults — same as desktop for the sports variant. +export const MOBILE_DEFAULT_MAP_LAYERS: MapLayers = { + ...DEFAULT_MAP_LAYERS, +}; + +export const VARIANT_CONFIG: VariantConfig = { + name: 'sports', + description: 'Multi-sport dashboard for headlines, fixtures, tables, and live stats', + panels: DEFAULT_PANELS, + mapLayers: DEFAULT_MAP_LAYERS, + mobileMapLayers: MOBILE_DEFAULT_MAP_LAYERS, +}; diff --git a/src/config/variants/tech.ts b/src/config/variants/tech.ts index fdbc268946..228b492134 100644 --- a/src/config/variants/tech.ts +++ b/src/config/variants/tech.ts @@ -248,6 +248,7 @@ export const DEFAULT_MAP_LAYERS: MapLayers = { accelerators: false, techHQs: true, techEvents: true, + sportsFixtures: false, // Finance layers (disabled in tech variant) stockExchanges: false, financialCenters: false, @@ -310,6 +311,7 @@ export const MOBILE_DEFAULT_MAP_LAYERS: MapLayers = { accelerators: false, techHQs: false, techEvents: true, + sportsFixtures: false, // Finance layers (disabled in tech variant) stockExchanges: false, financialCenters: false, diff --git a/src/e2e/map-harness.ts b/src/e2e/map-harness.ts index 028d349451..6539822107 100644 --- a/src/e2e/map-harness.ts +++ b/src/e2e/map-harness.ts @@ -170,6 +170,7 @@ const allLayersEnabled: MapLayers = { accelerators: true, techHQs: true, techEvents: true, + sportsFixtures: false, stockExchanges: true, financialCenters: true, centralBanks: true, @@ -227,6 +228,7 @@ const allLayersDisabled: MapLayers = { accelerators: false, techHQs: false, techEvents: false, + sportsFixtures: false, stockExchanges: false, financialCenters: false, centralBanks: false, diff --git a/src/e2e/mobile-map-integration-harness.ts b/src/e2e/mobile-map-integration-harness.ts index fa81610670..0dc8aec7d1 100644 --- a/src/e2e/mobile-map-integration-harness.ts +++ b/src/e2e/mobile-map-integration-harness.ts @@ -118,6 +118,7 @@ const layers = { accelerators: false, techHQs: false, techEvents: false, + sportsFixtures: false, stockExchanges: false, financialCenters: false, centralBanks: false, diff --git a/src/locales/en.json b/src/locales/en.json index 1d3006cb9d..5fff4bbfc3 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -215,6 +215,7 @@ "sourcesEnabled": "{{enabled}}/{{total}} enabled", "finance": "FINANCE", "commodity": "COMMODITY", + "sports": "SPORTS", "toggleTheme": "Toggle dark/light mode", "panelDisplayCaption": "Choose which panels to show on the dashboard", "tabGeneral": "General", @@ -248,6 +249,10 @@ "sourceRegionDeals": "Deals & Corporate", "sourceRegionFinRegulation": "Financial Regulation", "sourceRegionGulfMena": "Gulf & MENA", + "sourceRegionSportsHeadlines": "Sports Headlines", + "sourceRegionFieldCourt": "Field & Court", + "sourceRegionRacquetRacing": "Racquet & Racing", + "sourceRegionCombatArena": "Combat & Arena", "filterPanels": "Filter panels...", "resetLayout": "Reset Layout", "resetLayoutTooltip": "Restore default panel arrangement", @@ -274,7 +279,9 @@ "panelCatMining": "Mining & Supply Chain", "panelCatCommodityEcon": "Economy & Trade", "panelCatHappyNews": "Good News", - "panelCatHappyPlanet": "Planet & Giving" + "panelCatHappyPlanet": "Planet & Giving", + "panelCatSportsNews": "Sports News", + "panelCatSportsData": "Fixtures & Tables" }, "panels": { "liveNews": "Live News", @@ -1278,6 +1285,7 @@ "internetOutages": "Internet Disruptions", "cyberThreats": "Cyber Threats", "techEvents": "Tech Events", + "sportsFixtures": "Sports Fixtures", "naturalEvents": "Natural Events", "fires": "Fires", "intelHotspots": "Intel Hotspots", diff --git a/src/services/i18n.ts b/src/services/i18n.ts index 6418dd97fd..3fccd8bc25 100644 --- a/src/services/i18n.ts +++ b/src/services/i18n.ts @@ -10,12 +10,25 @@ type TranslationDictionary = Record; const SUPPORTED_LANGUAGE_SET = new Set(SUPPORTED_LANGUAGES); const loadedLanguages = new Set(); +const ENV = (() => { + try { + return import.meta.env ?? {}; + } catch { + return {} as Record; + } +})(); // Lazy-load only the locale that's actually needed — all others stay out of the bundle. -const localeModules = import.meta.glob( - ['../locales/*.json', '!../locales/en.json'], - { import: 'default' }, -); +const localeModules = (() => { + try { + return import.meta.glob( + ['../locales/*.json', '!../locales/en.json'], + { import: 'default' }, + ); + } catch { + return {} as Record Promise>; + } +})(); const RTL_LANGUAGES = new Set(['ar']); @@ -81,7 +94,7 @@ export async function initI18n(): Promise { supportedLngs: [...SUPPORTED_LANGUAGES], nonExplicitSupportedLngs: true, fallbackLng: 'en', - debug: import.meta.env.DEV, + debug: ENV.DEV === true, interpolation: { escapeValue: false, // not needed for these simple strings }, diff --git a/src/services/index.ts b/src/services/index.ts index 6213149dba..a9ffa24e80 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -47,3 +47,4 @@ export * from './daily-market-brief'; export * from './stock-analysis-history'; export * from './stock-backtest'; export * from './imagery'; +export * from './sports'; diff --git a/src/services/runtime.ts b/src/services/runtime.ts index 2e68b43d28..118c34361f 100644 --- a/src/services/runtime.ts +++ b/src/services/runtime.ts @@ -19,6 +19,7 @@ const DEFAULT_REMOTE_HOSTS: Record = { finance: WS_API_URL, world: WS_API_URL, happy: WS_API_URL, + sports: WS_API_URL, }; const DEFAULT_LOCAL_API_PORT = 46123; @@ -213,6 +214,7 @@ const APP_HOSTS = new Set([ 'worldmonitor.app', 'www.worldmonitor.app', 'tech.worldmonitor.app', + 'sports.worldmonitor.app', 'api.worldmonitor.app', 'localhost', '127.0.0.1', diff --git a/src/services/sports-headline-filter.ts b/src/services/sports-headline-filter.ts new file mode 100644 index 0000000000..fd6a4fee4e --- /dev/null +++ b/src/services/sports-headline-filter.ts @@ -0,0 +1,89 @@ +import type { NewsItem } from '@/types'; + +const SPORTS_SIGNAL_TERMS = [ + 'sport', + 'sports', + 'football', + 'soccer', + 'basketball', + 'baseball', + 'tennis', + 'golf', + 'cricket', + 'rugby', + 'hockey', + 'mma', + 'ufc', + 'boxing', + 'wrestling', + 'formula 1', + 'f1', + 'nascar', + 'motorsport', + 'nba', + 'nfl', + 'nhl', + 'mlb', + 'team', + 'player', + 'coach', + 'fixture', + 'match', + 'standings', + 'playoff', + 'playoffs', + 'tournament', + 'transfer', + 'goal', + 'score', + 'grand prix', + 'world cup', + 'champions league', + 'olympic', +]; + +const POLITICAL_NOISE_TERMS = [ + 'politics', + 'political', + 'election', + 'elections', + 'campaign', + 'vote', + 'voter', + 'government', + 'parliament', + 'congress', + 'senate', + 'white house', + 'prime minister', + 'president', + 'cabinet', + 'policy', + 'diplomatic', + 'diplomacy', + 'ceasefire', + 'tariff', + 'sanctions', +]; + +function normalizeText(value: string): string { + return value.toLowerCase().replace(/[^\p{L}\p{N}\s]+/gu, ' ').replace(/\s+/g, ' ').trim(); +} + +function containsAnyKeyword(text: string, keywords: string[]): boolean { + return keywords.some((keyword) => text.includes(keyword)); +} + +export function isOffTopicSportsPoliticalHeadline(item: Pick): boolean { + const normalizedSourceAndTitle = normalizeText(`${item.source} ${item.title}`); + const normalizedTitle = normalizeText(item.title); + const hasPoliticalSignal = containsAnyKeyword(normalizedSourceAndTitle, POLITICAL_NOISE_TERMS); + if (!hasPoliticalSignal) return false; + + const hasSportsSignal = containsAnyKeyword(normalizedTitle, SPORTS_SIGNAL_TERMS); + return !hasSportsSignal; +} + +export function filterSportsHeadlineNoise(items: NewsItem[]): NewsItem[] { + return items.filter((item) => !isOffTopicSportsPoliticalHeadline(item)); +} diff --git a/src/services/sports.ts b/src/services/sports.ts new file mode 100644 index 0000000000..e3d6c36e8f --- /dev/null +++ b/src/services/sports.ts @@ -0,0 +1,3146 @@ +import { toApiUrl } from '@/services/runtime'; +import { fetchWeatherAlerts, type WeatherAlert } from '@/services/weather'; +import { CITY_COORDS, type CityCoord } from '../../api/data/city-coords.ts'; + +export interface SportsLeague { + id: string; + sport: string; + name: string; + shortName: string; + country?: string; + tableSupported?: boolean; +} + +export interface SportsLeagueOption { + id: string; + sport: string; + name: string; + shortName: string; + country?: string; + alternateName?: string; +} + +export interface SportsLeagueDetails extends SportsLeagueOption { + country?: string; + currentSeason?: string; + formedYear?: string; + badge?: string; + description?: string; + tableSupported?: boolean; +} + +export interface SportsEvent { + idEvent: string; + idLeague?: string; + strLeague?: string; + strSeason?: string; + strSport?: string; + strEvent?: string; + strHomeTeam?: string; + strAwayTeam?: string; + strHomeBadge?: string; + strAwayBadge?: string; + strStatus?: string; + strProgress?: string; + strVenue?: string; + strCity?: string; + strCountry?: string; + strRound?: string; + strTimestamp?: string; + dateEvent?: string; + strTime?: string; + intHomeScore?: string; + intAwayScore?: string; + lat?: number; + lng?: number; +} + +export interface SportsFixtureGroup { + league: SportsLeague; + events: SportsEvent[]; +} + +export interface SportsStandingRow { + rank: number; + team: string; + badge?: string; + played: number; + wins: number; + draws: number; + losses: number; + goalDifference: number; + points: number; + form?: string; + note?: string; + season?: string; +} + +export interface SportsTableGroup { + league: SportsLeague; + season?: string; + updatedAt?: string; + rows: SportsStandingRow[]; +} + +export interface SportsEventStat { + label: string; + homeValue?: string; + awayValue?: string; +} + +export interface SportsStatSnapshot { + league: SportsLeague; + event: SportsEvent; + stats: SportsEventStat[]; +} + +export interface SportsFixtureSearchMatch { + league: SportsLeague; + event: SportsEvent; +} + +export interface SportsFixtureMapMarker { + id: string; + eventId: string; + leagueId?: string; + leagueName: string; + leagueShortName: string; + sport: string; + title: string; + homeTeam?: string; + awayTeam?: string; + homeBadge?: string; + awayBadge?: string; + venue: string; + venueCity?: string; + venueCountry?: string; + venueCapacity?: string; + venueSurface?: string; + round?: string; + season?: string; + startTime?: string; + startLabel: string; + lat: number; + lng: number; + fixtureCount?: number; + competitionCount?: number; + sports?: string[]; + fixtures?: SportsFixtureMapMarker[]; +} + +export interface SportsFixtureInsightStat { + label: string; + value: string; +} + +export interface SportsFixturePopupContext { + prediction: string; + weather: string; + story: string; + stats: SportsFixtureInsightStat[]; +} + +export interface SportsFixtureVisualMeta { + icon: string; + colorHex: string; + colorRgba: [number, number, number, number]; +} + +export interface SportsLeagueCenterData { + league: SportsLeagueDetails; + seasons: string[]; + selectedSeason?: string; + table: SportsTableGroup | null; + tableAvailable: boolean; + recentEvents: SportsEvent[]; + upcomingEvents: SportsEvent[]; + statSnapshot: SportsStatSnapshot | null; +} + +export interface NbaStandingRow { + rank: number; + seed: number; + team: string; + abbreviation: string; + badge?: string; + wins: number; + losses: number; + winPercent: string; + gamesBehind: string; + homeRecord: string; + awayRecord: string; + pointsFor: string; + pointsAgainst: string; + differential: string; + streak: string; + lastTen: string; + clincher?: string; + conference: string; +} + +export interface NbaStandingsGroup { + name: string; + rows: NbaStandingRow[]; +} + +export interface NbaStandingsData { + leagueName: string; + seasonDisplay: string; + updatedAt: string; + groups: NbaStandingsGroup[]; +} + +export interface MotorsportStandingRow { + rank: number; + name: string; + code?: string; + team?: string; + badge?: string; + teamBadge?: string; + teamColor?: string; + driverNumber?: string; + points: number; + wins: number; + nationality?: string; +} + +export interface MotorsportRaceSummary { + raceName: string; + round: string; + date: string; + time?: string; + circuitName?: string; + locality?: string; + country?: string; + lat?: number; + lng?: number; + winner?: string; + podium: string[]; + fastestLap?: string; +} + +export interface FormulaOneStandingsData { + leagueName: string; + season: string; + round: string; + updatedAt: string; + driverStandings: MotorsportStandingRow[]; + constructorStandings: MotorsportStandingRow[]; + lastRace: MotorsportRaceSummary | null; + nextRace: MotorsportRaceSummary | null; +} + +export interface SportsPlayerSearchResult { + id: string; + name: string; + alternateName?: string; + sport?: string; + team?: string; + secondaryTeam?: string; + nationality?: string; + position?: string; + status?: string; + number?: string; + thumb?: string; + cutout?: string; +} + +export interface SportsPlayerDetails extends SportsPlayerSearchResult { + banner?: string; + fanart?: string; + birthDate?: string; + birthLocation?: string; + description?: string; + height?: string; + weight?: string; + gender?: string; + handedness?: string; + signedDate?: string; + signing?: string; + agent?: string; + outfitter?: string; + kit?: string; + website?: string; + facebook?: string; + twitter?: string; + instagram?: string; + youtube?: string; +} + +const REQUEST_TIMEOUT_MS = 12_000; + +function formatLocalCalendarDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function addLocalDays(date: Date, days: number): Date { + const next = new Date(date); + next.setDate(next.getDate() + days); + return next; +} + +function buildLocalSportsDateWindow(date: Date): string[] { + return [-1, 0, 1].map((offset) => formatLocalCalendarDate(addLocalDays(date, offset))); +} + +function buildSportsEventShapeKey(event: SportsEvent): string { + const normalizedLeague = normalizeLeagueLookup(event.strLeague); + const normalizedTitle = normalizeLeagueLookup(event.strEvent || [event.strHomeTeam, event.strAwayTeam].filter(Boolean).join(' vs ')); + const normalizedVenue = normalizeLeagueLookup(event.strVenue); + const timestamp = parseEventTimestamp(event); + return [ + normalizedLeague, + normalizedTitle, + normalizedVenue, + Number.isFinite(timestamp) && timestamp !== Number.MAX_SAFE_INTEGER ? String(timestamp) : (event.dateEvent || ''), + ].join('|'); +} + +function dedupeSportsEvents(events: SportsEvent[]): SportsEvent[] { + const deduped: SportsEvent[] = []; + const seenIds = new Set(); + const seenShapes = new Set(); + + for (const event of sortEventsAscending(events)) { + const eventId = toOptionalString(event.idEvent); + const shapeKey = buildSportsEventShapeKey(event); + if (eventId && seenIds.has(eventId)) continue; + if (shapeKey && seenShapes.has(shapeKey)) continue; + if (eventId) seenIds.add(eventId); + if (shapeKey) seenShapes.add(shapeKey); + deduped.push(event); + } + + return deduped; +} + +function isEventOnLocalCalendarDate(event: SportsEvent, targetDateStr: string): boolean { + const timestamp = parseEventTimestamp(event); + if (timestamp !== Number.MAX_SAFE_INTEGER) { + return formatLocalCalendarDate(new Date(timestamp)) === targetDateStr; + } + return (event.dateEvent || '') === targetDateStr; +} + +function filterEventsToLocalCalendarDate(events: SportsEvent[], targetDateStr: string): SportsEvent[] { + return dedupeSportsEvents(events.filter((event) => isEventOnLocalCalendarDate(event, targetDateStr))); +} + +function buildSportsFixtureGroupKey(leagueName: string | undefined, sport: string | undefined, fallbackId?: string): string { + const normalizedLeague = normalizeLeagueLookup(leagueName); + const normalizedSport = normalizeLeagueLookup(sport); + if (normalizedLeague && normalizedSport) return `${normalizedSport}:${normalizedLeague}`; + if (fallbackId) return `id:${fallbackId}`; + return `${normalizedSport || 'sport'}:${normalizedLeague || 'league'}`; +} + +const FEATURED_TABLE_LEAGUES: SportsLeague[] = [ + { id: '4328', sport: 'Soccer', name: 'English Premier League', shortName: 'EPL', country: 'England', tableSupported: true }, + { id: '4335', sport: 'Soccer', name: 'Spanish La Liga', shortName: 'La Liga', country: 'Spain', tableSupported: true }, + { id: '4331', sport: 'Soccer', name: 'German Bundesliga', shortName: 'Bundesliga', country: 'Germany', tableSupported: true }, +]; + + + +const EUROPEAN_TOP_FOOTBALL_SPECS: FeaturedLeagueSpec[] = [ + { label: 'English Premier League', sport: 'Soccer', aliases: ['english premier league', 'premier league', 'epl'] }, + { label: 'Spanish La Liga', sport: 'Soccer', aliases: ['spanish la liga', 'la liga', 'laliga'] }, + { label: 'German Bundesliga', sport: 'Soccer', aliases: ['german bundesliga', 'bundesliga'] }, + { label: 'Italian Serie A', sport: 'Soccer', aliases: ['italian serie a', 'serie a'] }, + { label: 'French Ligue 1', sport: 'Soccer', aliases: ['french ligue 1', 'ligue 1'] }, + { label: 'Dutch Eredivisie', sport: 'Soccer', aliases: ['dutch eredivisie', 'eredivisie'] }, + { label: 'Portuguese Primeira Liga', sport: 'Soccer', aliases: ['portuguese primeira liga', 'primeira liga', 'liga portugal'] }, +]; + +type FeaturedLeagueSpec = { + label: string; + sport?: string; + aliases: string[]; +}; + +const MOTORSPORT_SPECS: FeaturedLeagueSpec[] = [ + { label: 'Formula 1', sport: 'Motorsport', aliases: ['formula 1', 'f1'] }, + { label: 'NASCAR Cup Series', sport: 'Motorsport', aliases: ['nascar cup series', 'nascar'] }, + { label: 'World Rally Championship', sport: 'Motorsport', aliases: ['world rally championship', 'wrc', 'rally'] }, +]; + +export const NBA_LEAGUE_ID = '4387'; + +const SPORTS_FIXTURE_SPORT_PRIORITY = new Map([ + ['Soccer', 0], + ['Basketball', 1], + ['Ice Hockey', 2], + ['Baseball', 3], + ['American Football', 4], + ['Motorsport', 5], + ['Tennis', 6], + ['Cricket', 7], + ['Mixed', 8], +]); + +const SPORTS_TEAM_LABEL_STOPWORDS = new Set([ + 'ac', + 'afc', + 'bc', + 'basketball', + 'cf', + 'club', + 'fc', + 'football', + 'sc', + 'sporting', + 'team', +]); + +const LIVE_EVENT_STATUS_MARKERS = [ + 'live', + 'in progress', + 'halftime', + 'quarter', + 'period', + 'overtime', + 'extra time', + 'started', +]; + +const ESPN_FIXTURE_COMPETITIONS: EspnCompetitionSpec[] = [ + { id: 'eng.1', sport: 'Soccer', sportPath: 'soccer', leaguePath: 'eng.1', name: 'English Premier League', shortName: 'EPL', country: 'England' }, + { id: 'esp.1', sport: 'Soccer', sportPath: 'soccer', leaguePath: 'esp.1', name: 'Spanish La Liga', shortName: 'La Liga', country: 'Spain' }, + { id: 'ger.1', sport: 'Soccer', sportPath: 'soccer', leaguePath: 'ger.1', name: 'German Bundesliga', shortName: 'Bundesliga', country: 'Germany' }, + { id: 'ita.1', sport: 'Soccer', sportPath: 'soccer', leaguePath: 'ita.1', name: 'Italian Serie A', shortName: 'Serie A', country: 'Italy' }, + { id: 'fra.1', sport: 'Soccer', sportPath: 'soccer', leaguePath: 'fra.1', name: 'French Ligue 1', shortName: 'Ligue 1', country: 'France' }, + { id: 'ned.1', sport: 'Soccer', sportPath: 'soccer', leaguePath: 'ned.1', name: 'Dutch Eredivisie', shortName: 'Eredivisie', country: 'Netherlands' }, + { id: 'por.1', sport: 'Soccer', sportPath: 'soccer', leaguePath: 'por.1', name: 'Portuguese Primeira Liga', shortName: 'Primeira Liga', country: 'Portugal' }, + { id: 'usa.1', sport: 'Soccer', sportPath: 'soccer', leaguePath: 'usa.1', name: 'Major League Soccer', shortName: 'MLS', country: 'United States' }, + { id: 'mex.1', sport: 'Soccer', sportPath: 'soccer', leaguePath: 'mex.1', name: 'Liga MX', shortName: 'Liga MX', country: 'Mexico' }, + { id: 'eng.2', sport: 'Soccer', sportPath: 'soccer', leaguePath: 'eng.2', name: 'English Championship', shortName: 'Championship', country: 'England' }, + { id: 'eng.3', sport: 'Soccer', sportPath: 'soccer', leaguePath: 'eng.3', name: 'English League One', shortName: 'League One', country: 'England' }, + { id: 'sco.1', sport: 'Soccer', sportPath: 'soccer', leaguePath: 'sco.1', name: 'Scottish Premiership', shortName: 'Premiership', country: 'Scotland' }, + { id: 'arg.1', sport: 'Soccer', sportPath: 'soccer', leaguePath: 'arg.1', name: 'Argentine Primera División', shortName: 'Primera', country: 'Argentina' }, + { id: 'uefa.champions', sport: 'Soccer', sportPath: 'soccer', leaguePath: 'uefa.champions', name: 'UEFA Champions League', shortName: 'UCL', country: 'Europe' }, + { id: 'fifa.world', sport: 'Soccer', sportPath: 'soccer', leaguePath: 'fifa.world', name: 'FIFA World Cup', shortName: 'World Cup', country: 'International' }, + { id: 'uefa.euro', sport: 'Soccer', sportPath: 'soccer', leaguePath: 'uefa.euro', name: 'UEFA European Championship', shortName: 'Euro', country: 'Europe' }, + { id: 'nba', sport: 'Basketball', sportPath: 'basketball', leaguePath: 'nba', name: 'NBA', shortName: 'NBA', country: 'United States' }, + { id: 'nhl', sport: 'Ice Hockey', sportPath: 'hockey', leaguePath: 'nhl', name: 'NHL', shortName: 'NHL', country: 'United States' }, + { id: 'mlb', sport: 'Baseball', sportPath: 'baseball', leaguePath: 'mlb', name: 'MLB', shortName: 'MLB', country: 'United States' }, + { id: 'nfl', sport: 'American Football', sportPath: 'football', leaguePath: 'nfl', name: 'NFL', shortName: 'NFL', country: 'United States' }, +]; + +type EspnCompetitionSpec = { + id: string; + sport: SportsLeague['sport']; + sportPath: 'soccer' | 'basketball' | 'hockey' | 'baseball' | 'football'; + leaguePath: string; + name: string; + shortName: string; + country?: string; +}; + +const ESPN_STATS_COMPETITIONS: EspnCompetitionSpec[] = [ + { id: 'eng.1', sport: 'Soccer', sportPath: 'soccer', leaguePath: 'eng.1', name: 'English Premier League', shortName: 'EPL', country: 'England' }, + { id: 'uefa.champions', sport: 'Soccer', sportPath: 'soccer', leaguePath: 'uefa.champions', name: 'UEFA Champions League', shortName: 'UCL', country: 'Europe' }, + { id: 'nba', sport: 'Basketball', sportPath: 'basketball', leaguePath: 'nba', name: 'NBA', shortName: 'NBA', country: 'United States' }, +]; + +const ESPN_MAJOR_TOURNAMENTS: EspnCompetitionSpec[] = [ + { id: 'uefa.champions', sport: 'Soccer', sportPath: 'soccer', leaguePath: 'uefa.champions', name: 'UEFA Champions League', shortName: 'UCL', country: 'Europe' }, + { id: 'fifa.world', sport: 'Soccer', sportPath: 'soccer', leaguePath: 'fifa.world', name: 'FIFA World Cup', shortName: 'World Cup', country: 'International' }, + { id: 'uefa.euro', sport: 'Soccer', sportPath: 'soccer', leaguePath: 'uefa.euro', name: 'UEFA European Championship', shortName: 'Euro', country: 'Europe' }, + { id: 'conmebol.america', sport: 'Soccer', sportPath: 'soccer', leaguePath: 'conmebol.america', name: 'Copa America', shortName: 'Copa America', country: 'South America' }, + { id: 'conmebol.libertadores', sport: 'Soccer', sportPath: 'soccer', leaguePath: 'conmebol.libertadores', name: 'CONMEBOL Libertadores', shortName: 'Libertadores', country: 'South America' }, +]; + +const ESPN_ALL_COMPETITIONS: EspnCompetitionSpec[] = Array.from( + new Map( + [ + ...ESPN_FIXTURE_COMPETITIONS, + ...ESPN_STATS_COMPETITIONS, + ...ESPN_MAJOR_TOURNAMENTS, + ].map((spec) => [spec.id, spec]), + ).values(), +); + +type CacheEntry = { + expiresAt: number; + value: T; +}; + +type SportsDataProvider = 'thesportsdb' | 'espn' | 'espnsite' | 'jolpica' | 'openf1'; + +const responseCache = new Map>(); +const inFlight = new Map>(); + +export function resetSportsServiceCacheForTests(): void { + responseCache.clear(); + inFlight.clear(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function asArray(value: unknown): Record[] { + return Array.isArray(value) ? value.filter(isRecord) : []; +} + +function toNumber(value: unknown): number { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : 0; +} + +function toOptionalString(value: unknown): string | undefined { + if (value == null) return undefined; + const trimmed = String(value).trim(); + return trimmed ? trimmed : undefined; +} + +function toOptionalNumber(value: unknown): number | undefined { + if (value == null || value === '') return undefined; + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : undefined; +} + +function toInteger(value: unknown): number { + const numeric = Number.parseInt(String(value ?? ''), 10); + return Number.isFinite(numeric) ? numeric : 0; +} + +function buildLeagueShortName(name: string): string { + const trimmed = name.trim(); + if (!trimmed) return 'League'; + if (trimmed.length <= 14) return trimmed; + return trimmed + .split(/\s+/) + .map((part) => part[0]) + .join('') + .slice(0, 10) || trimmed.slice(0, 10); +} + +function normalizeLeagueLookup(value: string | undefined): string { + return (value || '') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +function scoreFeaturedLeagueMatch(league: SportsLeagueOption, spec: FeaturedLeagueSpec): number { + if (spec.sport && league.sport !== spec.sport) return -1; + + const haystacks = [ + normalizeLeagueLookup(league.name), + normalizeLeagueLookup(league.shortName), + normalizeLeagueLookup(league.alternateName), + ].filter(Boolean); + + const aliases = spec.aliases.map((alias) => normalizeLeagueLookup(alias)).filter(Boolean); + let best = -1; + + for (const alias of aliases) { + for (const haystack of haystacks) { + if (!haystack) continue; + if (haystack === alias) { + best = Math.max(best, 100); + continue; + } + if (haystack.startsWith(alias)) { + best = Math.max(best, 90); + continue; + } + if (haystack.includes(alias)) { + best = Math.max(best, 80); + continue; + } + if (alias.includes(haystack) && haystack.length >= 4) { + best = Math.max(best, 60); + } + } + } + + return best; +} + +async function resolveFeaturedLeagueOptions(specs: FeaturedLeagueSpec[]): Promise { + const leagues = await fetchAllSportsLeagues(); + const seen = new Set(); + const resolved: SportsLeagueOption[] = []; + + for (const spec of specs) { + let bestMatch: SportsLeagueOption | null = null; + let bestScore = -1; + + for (const league of leagues) { + const score = scoreFeaturedLeagueMatch(league, spec); + if (score > bestScore) { + bestScore = score; + bestMatch = league; + } + } + + if (!bestMatch || bestScore < 0 || seen.has(bestMatch.id)) continue; + seen.add(bestMatch.id); + resolved.push(bestMatch); + } + + return resolved; +} + +function getCached(key: string): T | null { + const cached = responseCache.get(key); + if (!cached) return null; + if (Date.now() >= cached.expiresAt) { + responseCache.delete(key); + return null; + } + return cached.value as T; +} + +function setCached(key: string, value: T, ttlMs: number): T { + responseCache.set(key, { + value, + expiresAt: Date.now() + ttlMs, + }); + return value; +} + +async function fetchSportsApiJson(provider: SportsDataProvider, path: string, ttlMs: number): Promise { + const cacheKey = `json:${provider}:${path}`; + const cached = getCached(cacheKey); + if (cached) return cached; + + const existing = inFlight.get(cacheKey) as Promise | undefined; + if (existing) return existing; + + const request = (async () => { + const response = await fetch( + toApiUrl(`/api/sports-data?provider=${provider}&path=${encodeURIComponent(path)}`), + { + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + headers: { + Accept: 'application/json', + }, + }, + ); + if (!response.ok) { + throw new Error(`Sports data request failed (${response.status})`); + } + const json = await response.json() as T; + return setCached(cacheKey, json, ttlMs); + })(); + + inFlight.set(cacheKey, request); + try { + return await request; + } finally { + inFlight.delete(cacheKey); + } +} + +async function fetchSportsApiText(provider: SportsDataProvider, path: string, ttlMs: number): Promise { + const cacheKey = `text:${provider}:${path}`; + const cached = getCached(cacheKey); + if (cached) return cached; + + const existing = inFlight.get(cacheKey) as Promise | undefined; + if (existing) return existing; + + const request = (async () => { + const response = await fetch( + toApiUrl(`/api/sports-data?provider=${provider}&path=${encodeURIComponent(path)}`), + { + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + headers: { + Accept: 'text/html,application/xhtml+xml,text/plain;q=0.9,*/*;q=0.8', + }, + }, + ); + if (!response.ok) { + throw new Error(`Sports data request failed (${response.status})`); + } + const text = await response.text(); + return setCached(cacheKey, text, ttlMs); + })(); + + inFlight.set(cacheKey, request); + try { + return await request; + } finally { + inFlight.delete(cacheKey); + } +} + +async function fetchSportsDbJson(path: string, ttlMs: number): Promise { + return fetchSportsApiJson('thesportsdb', path, ttlMs); +} + +async function fetchEspnText(path: string, ttlMs: number): Promise { + return fetchSportsApiText('espn', path, ttlMs); +} + +async function fetchEspnSiteJson(path: string, ttlMs: number): Promise { + return fetchSportsApiJson('espnsite', path, ttlMs); +} + +async function fetchJolpicaJson(path: string, ttlMs: number): Promise { + return fetchSportsApiJson('jolpica', path, ttlMs); +} + +async function fetchOpenF1Json(path: string, ttlMs: number): Promise { + return fetchSportsApiJson('openf1', path, ttlMs); +} + +function sortEventsAscending(events: SportsEvent[]): SportsEvent[] { + return [...events].sort((a, b) => { + const aTime = parseEventTimestamp(a); + const bTime = parseEventTimestamp(b); + return aTime - bTime; + }); +} + +export function parseEventTimestamp(event: Pick): number { + if (event.strTimestamp) { + const ts = Date.parse(event.strTimestamp); + if (!Number.isNaN(ts)) return ts; + } + if (event.dateEvent && event.strTime) { + const combined = Date.parse(`${event.dateEvent}T${event.strTime}`); + if (!Number.isNaN(combined)) return combined; + } + if (event.dateEvent) { + const dateOnly = Date.parse(`${event.dateEvent}T00:00:00`); + if (!Number.isNaN(dateOnly)) return dateOnly; + } + return Number.MAX_SAFE_INTEGER; +} + +function sortEventsDescending(events: SportsEvent[]): SportsEvent[] { + return [...events].sort((a, b) => parseEventTimestamp(b) - parseEventTimestamp(a)); +} + +function mapLeagueOption(raw: Record): SportsLeagueOption | null { + const id = toOptionalString(raw.idLeague); + const name = toOptionalString(raw.strLeague); + const sport = toOptionalString(raw.strSport); + if (!id || !name || !sport) return null; + + return { + id, + name, + sport, + shortName: buildLeagueShortName(name), + country: toOptionalString(raw.strCountry), + alternateName: toOptionalString(raw.strLeagueAlternate), + }; +} + +function mapLeagueDetails(raw: Record): SportsLeagueDetails | null { + const base = mapLeagueOption(raw); + if (!base) return null; + + return { + ...base, + country: toOptionalString(raw.strCountry), + currentSeason: toOptionalString(raw.strCurrentSeason), + formedYear: toOptionalString(raw.intFormedYear), + badge: toOptionalString(raw.strBadge), + description: toOptionalString(raw.strDescriptionEN), + }; +} + +function seasonSortScore(value: string): number { + const matches = value.match(/\d{4}/g); + if (!matches?.length) return Number.MIN_SAFE_INTEGER; + const years = matches + .map((part) => Number.parseInt(part, 10)) + .filter((year) => Number.isFinite(year)); + if (!years.length) return Number.MIN_SAFE_INTEGER; + return Math.max(...years) * 10_000 + Math.min(...years); +} + +function sortSeasonsDescending(seasons: string[]): string[] { + return [...seasons].sort((a, b) => { + const scoreDiff = seasonSortScore(b) - seasonSortScore(a); + if (scoreDiff !== 0) return scoreDiff; + return b.localeCompare(a); + }); +} + +function resolveSelectedSeason(requestedSeason: string | undefined, seasons: string[], currentSeason?: string): string | undefined { + if (requestedSeason && seasons.includes(requestedSeason)) return requestedSeason; + if (requestedSeason && !seasons.length) return requestedSeason; + if (currentSeason) return currentSeason; + return seasons[0]; +} + +function uniqueStrings(values: Array): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + if (!value || seen.has(value)) continue; + seen.add(value); + result.push(value); + } + return result; +} + +function readFirstNumber(source: Record, keys: string[]): number | undefined { + for (const key of keys) { + const value = toOptionalNumber(source[key]); + if (value !== undefined) return value; + } + return undefined; +} + +function normalizeCompetitorName(value: string | undefined): string { + return normalizeLeagueLookup(value) + .replace(/\b(fc|cf|ac|sc|club|basketball|football)\b/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function normalizeCountryLookup(value: string | undefined): string { + const normalized = normalizeLeagueLookup(value); + if (!normalized) return ''; + + switch (normalized) { + case 'united states': + case 'us': + return 'usa'; + case 'united kingdom': + case 'great britain': + case 'england': + case 'scotland': + case 'wales': + return 'uk'; + case 'united arab emirates': + return 'uae'; + default: + return normalized; + } +} + +function isLikelyLiveSportsEvent(event: Pick): boolean { + const status = `${event.strStatus || ''} ${event.strProgress || ''}`.toLowerCase(); + if (!status) return false; + if (LIVE_EVENT_STATUS_MARKERS.some((marker) => status.includes(marker))) return true; + if ((event.intHomeScore || event.intAwayScore) && !status.includes('final')) return true; + return false; +} + +function scoreSportsFixtureSearchMatch(query: string, league: SportsLeague, event: SportsEvent): number { + const normalizedQuery = normalizeLeagueLookup(query); + if (!normalizedQuery) return 0; + + const fields = [ + event.strEvent, + [event.strHomeTeam, event.strAwayTeam].filter(Boolean).join(' vs '), + event.strHomeTeam, + event.strAwayTeam, + event.strLeague, + league.name, + league.shortName, + league.country, + event.strVenue, + ] + .map((value) => normalizeLeagueLookup(value)) + .filter(Boolean); + + let score = 0; + for (const field of fields) { + if (field === normalizedQuery) { + score = Math.max(score, 140); + continue; + } + if (field.startsWith(normalizedQuery)) { + score = Math.max(score, 100); + continue; + } + if (field.includes(normalizedQuery)) { + score = Math.max(score, 75); + continue; + } + if (normalizedQuery.startsWith(field) && field.length >= 5) { + score = Math.max(score, 45); + } + } + + const queryTokens = normalizedQuery.split(' ').filter((token) => token.length >= 2); + if (queryTokens.length > 1) { + const tokenHits = queryTokens.reduce((hits, token) => ( + fields.some((field) => field.includes(token)) ? hits + 1 : hits + ), 0); + score += tokenHits * 18; + } + + if (isLikelyLiveSportsEvent(event)) score += 24; + if (event.intHomeScore || event.intAwayScore) score += 8; + + return score; +} + +function formatSportsFixtureStartLabel(event: Pick): string { + const timestamp = parseEventTimestamp(event); + if (timestamp !== Number.MAX_SAFE_INTEGER) { + return new Date(timestamp).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); + } + if (event.dateEvent && event.strTime) return `${event.dateEvent} ${event.strTime}`; + return event.dateEvent || event.strTime || 'TBD'; +} + +export function getSportsFixtureVisualMeta(sport: string): SportsFixtureVisualMeta { + switch (sport) { + case 'Mixed': + return { icon: '🏟️', colorHex: '#f8fafc', colorRgba: [248, 250, 252, 220] }; + case 'Soccer': + case 'Football': + return { icon: '⚽', colorHex: '#22c55e', colorRgba: [34, 197, 94, 210] }; + case 'American Football': + return { icon: '🏈', colorHex: '#f59e0b', colorRgba: [245, 158, 11, 210] }; + case 'Basketball': + return { icon: '🏀', colorHex: '#f97316', colorRgba: [249, 115, 22, 210] }; + case 'Baseball': + return { icon: '⚾', colorHex: '#fb923c', colorRgba: [251, 146, 60, 210] }; + case 'Ice Hockey': + case 'Hockey': + return { icon: '🏒', colorHex: '#60a5fa', colorRgba: [96, 165, 250, 210] }; + case 'Tennis': + return { icon: '🎾', colorHex: '#38bdf8', colorRgba: [56, 189, 248, 210] }; + case 'Cricket': + return { icon: '🏏', colorHex: '#a78bfa', colorRgba: [167, 139, 250, 210] }; + case 'Motorsport': + return { icon: '🏁', colorHex: '#eab308', colorRgba: [234, 179, 8, 210] }; + default: + return { icon: '🎯', colorHex: '#eab308', colorRgba: [234, 179, 8, 210] }; + } +} + +export function isSportsFixtureHubMarker(marker: Pick): boolean { + return Math.max(marker.fixtureCount ?? 0, marker.fixtures?.length ?? 0) > 1; +} + +function countSportsFixtures(marker: Pick): number { + return Math.max(marker.fixtureCount ?? 0, marker.fixtures?.length ?? 0, 1); +} + +function truncateSportsLabel(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + return `${value.slice(0, Math.max(1, maxLength - 1)).trimEnd()}\u2026`; +} + +function compactSportsCompetitorLabel(name: string | undefined, maxLength: number): string { + const cleaned = (name || '').replace(/\s+/g, ' ').trim(); + if (!cleaned) return ''; + if (cleaned.length <= maxLength) return cleaned; + + const parts = cleaned.split(' ').filter(Boolean); + const meaningfulParts = parts.filter((part) => !SPORTS_TEAM_LABEL_STOPWORDS.has(part.toLowerCase())); + const preferredParts = meaningfulParts.length > 0 ? meaningfulParts : parts; + const lastPart = preferredParts[preferredParts.length - 1] || ''; + if (lastPart && lastPart.length <= maxLength) return lastPart; + + const firstPart = preferredParts[0] || ''; + const firstLastLabel = [firstPart, lastPart] + .filter(Boolean) + .filter((part, index, source) => index === 0 || part !== source[index - 1]) + .join(' '); + if (firstLastLabel && firstLastLabel.length <= maxLength + 4) return firstLastLabel; + + const initials = preferredParts + .map((part) => part[0] || '') + .join('') + .toUpperCase(); + if (initials.length >= 2 && initials.length <= maxLength) return initials; + + return truncateSportsLabel(cleaned, maxLength); +} + +function flattenSportsFixtureMarkers(markers: SportsFixtureMapMarker[]): SportsFixtureMapMarker[] { + return sortSportsFixtureMapMarkers(markers.flatMap((marker) => { + if (marker.fixtures?.length) return flattenSportsFixtureMarkers(marker.fixtures); + return [{ + ...marker, + fixtureCount: 1, + competitionCount: 1, + sports: [marker.sport], + fixtures: undefined, + }]; + })); +} + +export function getSportsFixtureDisplayLabel(marker: SportsFixtureMapMarker, compact = false): string { + const fixtureCount = countSportsFixtures(marker); + const maxLength = compact ? 26 : 38; + + if (fixtureCount > 1) { + const venueLabel = marker.venueCity || marker.venue || marker.venueCountry || ''; + const summary = venueLabel ? `${fixtureCount} fixtures \u00b7 ${venueLabel}` : `${fixtureCount} fixtures`; + return truncateSportsLabel(summary, maxLength); + } + + const home = compactSportsCompetitorLabel(marker.homeTeam, compact ? 10 : 14); + const away = compactSportsCompetitorLabel(marker.awayTeam, compact ? 10 : 14); + if (home && away) return truncateSportsLabel(`${home} vs ${away}`, maxLength); + + return truncateSportsLabel( + marker.title || marker.venueCity || marker.venue || marker.leagueShortName || marker.sport, + maxLength, + ); +} + +export function getSportsFixtureSubLabel(marker: SportsFixtureMapMarker): string { + const parts: string[] = []; + const fixtureCount = countSportsFixtures(marker); + + if (fixtureCount > 1) { + if (marker.sports && marker.sports.length > 0 && marker.sports.length <= 2) { + parts.push(marker.sports.join(' \u00b7 ')); + } else { + parts.push(marker.leagueShortName === 'MULTI' ? 'Multi-sport' : marker.leagueShortName || marker.sport); + } + } else if (marker.leagueShortName) { + parts.push(marker.leagueShortName); + } + + if (marker.venueCity) parts.push(marker.venueCity); + else if (marker.venueCountry) parts.push(marker.venueCountry); + + if (marker.startLabel) parts.push(marker.startLabel); + + return truncateSportsLabel(parts.filter(Boolean).join(' \u00b7 '), 52); +} + +export function getSportsFixtureRenderPriority(marker: SportsFixtureMapMarker): number { + const fixtureCount = countSportsFixtures(marker); + let score = fixtureCount * 6; + score += (marker.competitionCount ?? 1) * 2; + score += Math.max(0, 10 - (SPORTS_FIXTURE_SPORT_PRIORITY.get(marker.sport) ?? 8)); + if (marker.homeTeam && marker.awayTeam) score += 4; + if (marker.venueCity) score += 1; + + const timestamp = marker.startTime ? Date.parse(marker.startTime) : Number.NaN; + if (Number.isFinite(timestamp)) { + const hoursUntil = Math.abs(timestamp - Date.now()) / (60 * 60 * 1000); + if (hoursUntil <= 6) score += 3; + else if (hoursUntil <= 18) score += 2; + else if (hoursUntil <= 30) score += 1; + } + + return score; +} + +export function buildSportsFixtureAggregateMarker( + markers: SportsFixtureMapMarker[], + overrides: Partial = {}, +): SportsFixtureMapMarker { + const fixtures = flattenSportsFixtureMarkers(markers); + const [earliestFixture] = fixtures; + if (!earliestFixture) { + throw new Error('Sports fixture aggregate cannot be built from an empty marker list'); + } + + if (fixtures.length === 1 && Object.keys(overrides).length === 0) return earliestFixture; + + const sports = uniqueStrings(fixtures.map((fixture) => fixture.sport)); + const competitions = uniqueStrings(fixtures.map((fixture) => fixture.leagueName)); + const [primarySport] = sports; + const [primaryCompetition] = competitions; + const fixtureCount = fixtures.length; + const defaultTitle = fixtureCount > 1 ? `${fixtureCount} fixtures` : earliestFixture.title; + const defaultStartLabel = fixtureCount > 1 + ? `${earliestFixture.startLabel} \u2022 ${fixtureCount} fixtures` + : earliestFixture.startLabel; + const centerLat = fixtures.reduce((sum, fixture) => sum + fixture.lat, 0) / fixtureCount; + const centerLng = fixtures.reduce((sum, fixture) => sum + fixture.lng, 0) / fixtureCount; + + return { + ...earliestFixture, + ...overrides, + id: overrides.id ?? `sports-fixture-hub:${fixtures.map((fixture) => fixture.eventId).join(',')}`, + title: overrides.title ?? defaultTitle, + leagueName: overrides.leagueName ?? (competitions.length === 1 && primaryCompetition ? primaryCompetition : `${competitions.length} competitions`), + leagueShortName: overrides.leagueShortName ?? (competitions.length === 1 ? earliestFixture.leagueShortName : 'MULTI'), + sport: overrides.sport ?? (sports.length === 1 && primarySport ? primarySport : 'Mixed'), + startLabel: overrides.startLabel ?? defaultStartLabel, + lat: overrides.lat ?? centerLat, + lng: overrides.lng ?? centerLng, + fixtureCount: overrides.fixtureCount ?? fixtureCount, + competitionCount: overrides.competitionCount ?? competitions.length, + sports: overrides.sports ?? sports, + fixtures, + }; +} + +const CITY_COORDINATE_ENTRIES: CityCoordinateEntry[] = Object.entries(CITY_COORDS).map(([key, coord]) => ({ + normalizedKey: normalizeLeagueLookup(key), + normalizedCountry: normalizeCountryLookup(coord.country), + coord, +})).filter((entry) => !!entry.normalizedKey); + +const COUNTRY_COORDINATE_SUMMARIES = new Map(); +for (const entry of CITY_COORDINATE_ENTRIES) { + const existing = COUNTRY_COORDINATE_SUMMARIES.get(entry.normalizedCountry); + if (!existing) { + COUNTRY_COORDINATE_SUMMARIES.set(entry.normalizedCountry, { + lat: entry.coord.lat, + lng: entry.coord.lng, + samples: 1, + }); + continue; + } + + const samples = existing.samples + 1; + COUNTRY_COORDINATE_SUMMARIES.set(entry.normalizedCountry, { + lat: ((existing.lat * existing.samples) + entry.coord.lat) / samples, + lng: ((existing.lng * existing.samples) + entry.coord.lng) / samples, + samples, + }); +} + +type SportsVenueProfile = { + name: string; + city?: string; + country?: string; + capacity?: string; + surface?: string; + lat?: number; + lng?: number; +}; + +type SportsFixtureCandidate = { + league: SportsLeague; + event: SportsEvent; +}; + +type CityCoordinateEntry = { + normalizedKey: string; + normalizedCountry: string; + coord: CityCoord; +}; + +type CountryCoordinateSummary = { + lat: number; + lng: number; + samples: number; +}; + +const SPORT_COORDINATE_FALLBACKS: Record = { + soccer: { lat: 50.1109, lng: 8.6821 }, + basketball: { lat: 39.8283, lng: -98.5795 }, + 'ice hockey': { lat: 45.4215, lng: -75.6972 }, + baseball: { lat: 39.0119, lng: -98.4842 }, + 'american football': { lat: 39.0119, lng: -98.4842 }, + motorsport: { lat: 46.8182, lng: 8.2275 }, + tennis: { lat: 48.8566, lng: 2.3522 }, + cricket: { lat: 22.9734, lng: 78.6569 }, +}; + +function mapVenueProfile(raw: Record): SportsVenueProfile | null { + const name = toOptionalString(raw.strVenue) || toOptionalString(raw.strLocation); + if (!name) return null; + + const lat = readFirstNumber(raw, ['strLatitude', 'strLat', 'strGeoLat', 'strVenueLatitude']); + const lng = readFirstNumber(raw, ['strLongitude', 'strLon', 'strLng', 'strGeoLong', 'strVenueLongitude']); + const hasCoords = lat !== undefined && lng !== undefined && !(lat === 0 && lng === 0); + + return { + name, + city: toOptionalString(raw.strCity) || toOptionalString(raw.strLocation), + country: toOptionalString(raw.strCountry), + capacity: toOptionalString(raw.intCapacity), + surface: toOptionalString(raw.strSurface), + lat: hasCoords ? lat : undefined, + lng: hasCoords ? lng : undefined, + }; +} + +function hashFixtureSeed(value: string): number { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0; + } + return Math.abs(hash); +} + +function wrapLongitude(value: number): number { + if (value > 180) return value - 360; + if (value < -180) return value + 360; + return value; +} + +function applyFixtureCoordinateJitter(lat: number, lng: number, seed: string): { lat: number; lng: number } { + const hash = hashFixtureSeed(seed); + const latOffset = (((hash % 1000) / 999) - 0.5) * 1.2; + const lngOffset = ((((Math.floor(hash / 1000)) % 1000) / 999) - 0.5) * 1.8; + const lngScale = Math.max(0.45, Math.cos((lat * Math.PI) / 180)); + + return { + lat: Math.max(-80, Math.min(80, lat + latOffset)), + lng: wrapLongitude(lng + (lngOffset / lngScale)), + }; +} + +function resolveKnownCityCoordinate(city: string | undefined, country: string | undefined): { lat: number; lng: number } | null { + const normalizedCity = normalizeLeagueLookup(city); + if (!normalizedCity) return null; + + const normalizedCountry = normalizeCountryLookup(country); + let best: CityCoordinateEntry | null = null; + let bestScore = -1; + + for (const entry of CITY_COORDINATE_ENTRIES) { + let score = -1; + if (entry.normalizedKey === normalizedCity) score = 100; + else if (entry.normalizedKey.startsWith(normalizedCity) || normalizedCity.startsWith(entry.normalizedKey)) score = 80; + else if (entry.normalizedKey.includes(normalizedCity) || normalizedCity.includes(entry.normalizedKey)) score = 60; + if (score < 0) continue; + if (normalizedCountry && entry.normalizedCountry === normalizedCountry) score += 40; + if (score > bestScore) { + best = entry; + bestScore = score; + } + } + + return best ? { lat: best.coord.lat, lng: best.coord.lng } : null; +} + +function resolveFallbackFixtureCoordinate(event: SportsEvent, league: SportsLeague): { lat: number; lng: number } | null { + const cityCoordinate = resolveKnownCityCoordinate(event.strCity, event.strCountry || league.country); + if (cityCoordinate) return cityCoordinate; + + const normalizedCountry = normalizeCountryLookup(event.strCountry || league.country); + const countrySummary = normalizedCountry ? COUNTRY_COORDINATE_SUMMARIES.get(normalizedCountry) : null; + if (!countrySummary) return null; + + return applyFixtureCoordinateJitter( + countrySummary.lat, + countrySummary.lng, + `${event.idEvent}:${event.strEvent || event.strHomeTeam || ''}:${event.strCity || event.strCountry || league.name}`, + ); +} + +function resolveSportFallbackCoordinate(event: SportsEvent, league: SportsLeague): { lat: number; lng: number } | null { + const sportKey = normalizeLeagueLookup(event.strSport || league.sport); + const fallback = sportKey ? SPORT_COORDINATE_FALLBACKS[sportKey] : null; + if (!fallback) return null; + return applyFixtureCoordinateJitter( + fallback.lat, + fallback.lng, + `${event.idEvent}:${event.strEvent || event.strHomeTeam || ''}:${league.name}:${sportKey}`, + ); +} + +async function fetchVenueProfilesByName(venueName: string): Promise { + const trimmed = venueName.trim(); + if (!trimmed) return []; + const payload = await fetchSportsDbJson<{ venues?: unknown }>(`/searchvenues.php?v=${encodeURIComponent(trimmed)}`, 6 * 60 * 60 * 1000); + return asArray(payload.venues) + .map(mapVenueProfile) + .filter((venue): venue is SportsVenueProfile => !!venue && venue.lat !== undefined && venue.lng !== undefined); +} + +function scoreVenueProfile(venue: SportsVenueProfile, event: SportsEvent, league: SportsLeague): number { + const normalizedVenue = normalizeLeagueLookup(venue.name); + const normalizedEventVenue = normalizeLeagueLookup(event.strVenue); + const normalizedCity = normalizeLeagueLookup(event.strCity); + const normalizedCountry = normalizeLeagueLookup(event.strCountry || league.country); + let score = 0; + + if (normalizedVenue && normalizedEventVenue) { + if (normalizedVenue === normalizedEventVenue) score += 100; + else if (normalizedVenue.includes(normalizedEventVenue) || normalizedEventVenue.includes(normalizedVenue)) score += 70; + } + + if (normalizedCity && normalizeLeagueLookup(venue.city) === normalizedCity) score += 25; + if (normalizedCountry && normalizeLeagueLookup(venue.country) === normalizedCountry) score += 20; + return score; +} + +async function resolveEventVenueProfile(event: SportsEvent, league: SportsLeague): Promise { + const inlineVenue: SportsVenueProfile | null = event.strVenue + ? { + name: event.strVenue, + city: event.strCity, + country: event.strCountry || league.country, + lat: event.lat, + lng: event.lng, + } + : null; + + if (inlineVenue?.lat !== undefined && inlineVenue.lng !== undefined) { + return inlineVenue; + } + + const fallbackCoordinate = resolveFallbackFixtureCoordinate(event, league); + if (fallbackCoordinate) { + return { + name: event.strVenue || event.strCity || event.strEvent || league.name, + city: event.strCity, + country: event.strCountry || league.country, + lat: fallbackCoordinate.lat, + lng: fallbackCoordinate.lng, + }; + } + + const sportFallbackCoordinate = resolveSportFallbackCoordinate(event, league); + if (!event.strVenue) { + if (!sportFallbackCoordinate) return null; + return { + name: event.strVenue || event.strCity || event.strEvent || league.name, + city: event.strCity, + country: event.strCountry || league.country, + lat: sportFallbackCoordinate.lat, + lng: sportFallbackCoordinate.lng, + }; + } + + const venues = await fetchVenueProfilesByName(event.strVenue).catch(() => []); + if (!venues.length) { + if (inlineVenue?.lat !== undefined && inlineVenue.lng !== undefined) return inlineVenue; + if (!sportFallbackCoordinate) return inlineVenue; + return { + name: inlineVenue?.name || event.strVenue, + city: inlineVenue?.city || event.strCity, + country: inlineVenue?.country || event.strCountry || league.country, + lat: sportFallbackCoordinate.lat, + lng: sportFallbackCoordinate.lng, + }; + } + + return venues + .slice() + .sort((a, b) => scoreVenueProfile(b, event, league) - scoreVenueProfile(a, event, league))[0] || inlineVenue; +} + +function pickSportsFixtureCandidates(groups: SportsFixtureGroup[]): SportsFixtureCandidate[] { + const all = groups + .flatMap((group) => group.events.map((event) => ({ league: group.league, event } satisfies SportsFixtureCandidate))) + .sort((a, b) => { + const timeDiff = parseEventTimestamp(a.event) - parseEventTimestamp(b.event); + if (timeDiff !== 0) return timeDiff; + return a.league.name.localeCompare(b.league.name); + }); + + const selected: SportsFixtureCandidate[] = []; + const used = new Set(); + + for (const candidate of all) { + if (used.has(candidate.event.idEvent)) continue; + used.add(candidate.event.idEvent); + selected.push(candidate); + } + + return selected; +} + +function findStandingRowByTeam(rows: T[], teamName: string | undefined): T | null { + const normalized = normalizeCompetitorName(teamName); + if (!normalized) return null; + + let best: T | null = null; + let bestScore = -1; + + for (const row of rows) { + const candidates = [ + normalizeCompetitorName(row.team), + normalizeCompetitorName(row.abbreviation), + ].filter(Boolean); + + let score = -1; + for (const candidate of candidates) { + if (candidate === normalized) score = Math.max(score, 100); + else if (candidate.startsWith(normalized) || normalized.startsWith(candidate)) score = Math.max(score, 80); + else if (candidate.includes(normalized) || normalized.includes(candidate)) score = Math.max(score, 60); + } + + if (score > bestScore) { + best = row; + bestScore = score; + } + } + + return bestScore >= 60 ? best : null; +} + +function isSameFixturePair(event: SportsEvent, homeTeam: string | undefined, awayTeam: string | undefined): boolean { + const eventHome = normalizeCompetitorName(event.strHomeTeam); + const eventAway = normalizeCompetitorName(event.strAwayTeam); + const expectedHome = normalizeCompetitorName(homeTeam); + const expectedAway = normalizeCompetitorName(awayTeam); + if (!eventHome || !eventAway || !expectedHome || !expectedAway) return false; + return ( + (eventHome === expectedHome && eventAway === expectedAway) + || (eventHome === expectedAway && eventAway === expectedHome) + ); +} + +function formatLastMeeting(event: SportsEvent): string { + const home = event.strHomeTeam || 'Home'; + const away = event.strAwayTeam || 'Away'; + const score = event.intHomeScore && event.intAwayScore ? `${event.intHomeScore}-${event.intAwayScore}` : 'Result pending'; + return `${home} ${score} ${away} (${formatSportsFixtureStartLabel(event)})`; +} + +function formatRecentFixtureEvent(event: SportsEvent): string { + if (event.strHomeTeam && event.strAwayTeam) return formatLastMeeting(event); + return `${event.strEvent || event.strLeague || 'Recent event'} (${formatSportsFixtureStartLabel(event)})`; +} + +function getEventWinnerName(event: SportsEvent): string | null { + const homeScore = toOptionalNumber(event.intHomeScore); + const awayScore = toOptionalNumber(event.intAwayScore); + if (homeScore === undefined || awayScore === undefined || homeScore === awayScore) return null; + return homeScore > awayScore ? event.strHomeTeam || null : event.strAwayTeam || null; +} + +function isFormulaOneFixture(marker: SportsFixtureMapMarker): boolean { + const labels = [marker.leagueName, marker.leagueShortName].map((value) => normalizeLeagueLookup(value)); + return labels.some((label) => label === 'formula 1' || label === 'f1' || label.includes('formula 1')); +} + +function haversineDistanceKm(lat1: number, lon1: number, lat2: number, lon2: number): number { + const earthRadiusKm = 6371; + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLon = ((lon2 - lon1) * Math.PI) / 180; + const a = Math.sin(dLat / 2) ** 2 + + Math.cos((lat1 * Math.PI) / 180) + * Math.cos((lat2 * Math.PI) / 180) + * Math.sin(dLon / 2) ** 2; + return earthRadiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} + +function isUnitedStatesVenue(country: string | undefined): boolean { + const normalized = normalizeLeagueLookup(country); + return normalized === 'united states' || normalized === 'usa' || normalized === 'us'; +} + +function summarizeFixtureWeather(marker: SportsFixtureMapMarker, alerts: WeatherAlert[]): string { + if (!alerts.length) return 'Live weather context is unavailable right now.'; + if (!isUnitedStatesVenue(marker.venueCountry)) { + return 'Weather alerts are only wired for the U.S. feed in this view.'; + } + + let nearest: WeatherAlert | null = null; + let nearestDistance = Number.POSITIVE_INFINITY; + + for (const alert of alerts) { + const [lon, lat] = alert.centroid ?? alert.coordinates[0] ?? []; + const latValue = Number(lat); + const lonValue = Number(lon); + if (!Number.isFinite(latValue) || !Number.isFinite(lonValue)) continue; + const distanceKm = haversineDistanceKm(marker.lat, marker.lng, latValue, lonValue); + if (distanceKm < nearestDistance) { + nearest = alert; + nearestDistance = distanceKm; + } + } + + if (!nearest || nearestDistance > 140) return 'No nearby U.S. weather alert in the current feed.'; + return `${nearest.severity} ${nearest.event} roughly ${Math.round(nearestDistance)} km from the venue.`; +} + +function buildFootballPrediction( + marker: SportsFixtureMapMarker, + homeRow: SportsStandingRow | null, + awayRow: SportsStandingRow | null, +): string { + if (!homeRow || !awayRow) return 'Form context is thin, so this looks closer to a venue-and-momentum match than a clear mismatch.'; + const pointsDelta = homeRow.points - awayRow.points; + const rankDelta = awayRow.rank - homeRow.rank; + if (pointsDelta >= 10 || rankDelta >= 5) return `${marker.homeTeam || 'The home side'} hold the stronger table profile and should carry the sharper control into kickoff.`; + if (pointsDelta <= -10 || rankDelta <= -5) return `${marker.awayTeam || 'The away side'} arrive with the cleaner season profile and project as the more stable side.`; + if (Math.abs(pointsDelta) <= 3 && Math.abs(rankDelta) <= 2) return 'This projects as a tight swing fixture where one transition or set piece could decide it.'; + return `${pointsDelta >= 0 ? marker.homeTeam || 'The home side' : marker.awayTeam || 'The away side'} have the marginal table edge, but the gap is still live enough for an upset.`; +} + +function buildFootballStory( + homeRow: SportsStandingRow | null, + awayRow: SportsStandingRow | null, + lastMeeting: SportsEvent | null, +): string { + if (homeRow && awayRow) { + if (homeRow.rank <= 4 || awayRow.rank <= 4) return 'The table pressure is high here: European qualification stakes make every point expensive.'; + if (Math.abs(homeRow.rank - awayRow.rank) <= 2) return 'These sides sit close enough in the table for one result to move the narrative immediately.'; + if (homeRow.rank >= 15 || awayRow.rank >= 15) return 'The subtext is survival pressure, so game state and nerves matter as much as talent.'; + } + if (lastMeeting) return `The last meeting finished ${formatLastMeeting(lastMeeting)}, so there is recent reference for how the matchup can tilt.`; + return 'The story is less about raw talent and more about who controls the tempo first.'; +} + +function buildNbaPrediction( + marker: SportsFixtureMapMarker, + homeRow: NbaStandingRow | null, + awayRow: NbaStandingRow | null, +): string { + if (!homeRow || !awayRow) return 'Without a clean standings read, this projects as a rhythm and shot-variance game more than a locked result.'; + const homePct = Number.parseFloat(homeRow.winPercent); + const awayPct = Number.parseFloat(awayRow.winPercent); + const pctDelta = homePct - awayPct; + if (pctDelta >= 0.12) return `${marker.homeTeam || 'The home team'} carry the stronger season baseline and should enter as the favorite.`; + if (pctDelta <= -0.12) return `${marker.awayTeam || 'The away team'} bring the better season trend and look better positioned on paper.`; + if (Math.abs(pctDelta) <= 0.04) return 'The records say this should stay live into the fourth quarter.'; + return `${pctDelta >= 0 ? marker.homeTeam || 'The home team' : marker.awayTeam || 'The away team'} have the slight edge, but recent form still matters more than broad season record here.`; +} + +function buildNbaStory( + homeRow: NbaStandingRow | null, + awayRow: NbaStandingRow | null, + lastMeeting: SportsEvent | null, +): string { + if (homeRow && awayRow && (homeRow.clincher || awayRow.clincher)) { + return 'Playoff positioning is already part of the story, so rotation choices and late-game urgency could swing the feel of this one.'; + } + if (homeRow && awayRow && homeRow.lastTen !== awayRow.lastTen) { + return `Recent form split matters here: ${homeRow.team} are ${homeRow.lastTen} lately, while ${awayRow.team} are ${awayRow.lastTen}.`; + } + if (lastMeeting) return `Their latest direct result was ${formatLastMeeting(lastMeeting)}, which gives this rematch some built-in edge.`; + return 'Watch pace control and bench shot creation more than headline narratives here.'; +} + +function buildTennisPrediction(lastMeeting: SportsEvent | null): string { + const winner = lastMeeting ? getEventWinnerName(lastMeeting) : null; + if (winner) return `${winner} carry the most recent head-to-head edge, but serve quality and break-point conversion should still decide the match.`; + return 'Serve hold rate and return pressure should matter more here than any broad pre-match narrative.'; +} + +function buildTennisStory(marker: SportsFixtureMapMarker, lastMeeting: SportsEvent | null): string { + if (lastMeeting) return `The previous meeting was ${formatLastMeeting(lastMeeting)}, so the question is whether that matchup pattern repeats.`; + if (marker.venueSurface) return `${marker.venueSurface} conditions could reshape the balance between first-strike serving and baseline rallies.`; + return 'Watch first-serve percentage and break-point conversion more than generic form lines here.'; +} + +function buildCricketPrediction(lastMeeting: SportsEvent | null): string { + const winner = lastMeeting ? getEventWinnerName(lastMeeting) : null; + if (winner) return `${winner} have the recent result edge, but toss, powerplay control, and death-over execution should still swing the fixture.`; + return 'This looks likely to turn on the powerplay and final overs more than a clean pre-match mismatch.'; +} + +function buildCricketStory(lastMeeting: SportsEvent | null): string { + if (lastMeeting) return `The previous meeting was ${formatLastMeeting(lastMeeting)}, so batting tempo and bowling control are the obvious repeat themes.`; + return 'Toss, conditions, and who wins the middle overs are the main storylines here.'; +} + +function buildGenericFixturePrediction(marker: SportsFixtureMapMarker, lastMeeting: SportsEvent | null): string { + const winner = lastMeeting ? getEventWinnerName(lastMeeting) : null; + if (winner) return `${winner} own the most recent direct result, but this still profiles as a live matchup once venue conditions kick in.`; + return `${marker.venue} becomes a meaningful variable here, so execution on the day matters more than thin historical context.`; +} + +function buildGenericFixtureStory(marker: SportsFixtureMapMarker, lastMeeting: SportsEvent | null): string { + if (lastMeeting) return `The clearest recent signal is ${formatLastMeeting(lastMeeting)}, which gives this fixture an immediate reference point.`; + return `${marker.venue} is the main storyline variable, so local conditions and fast starts should shape the feel of this one.`; +} + +function buildMotorsportPrediction(marker: SportsFixtureMapMarker, f1: FormulaOneStandingsData | null): string { + if (!isFormulaOneFixture(marker) || !f1) { + return 'Track position, clean execution, and changing conditions should matter more than broad historical trend here.'; + } + const leader = f1.driverStandings[0]; + if (leader) return `${leader.name} still defines the championship pace, but qualifying and tyre management will shape this race weekend.`; + return 'Qualifying and race-day execution should set the edge more than pure season standings.'; +} + +function buildMotorsportStory(marker: SportsFixtureMapMarker, f1: FormulaOneStandingsData | null): string { + if (!isFormulaOneFixture(marker)) { + return `${marker.venue} puts the emphasis on setup, track position, and who adapts fastest over the weekend.`; + } + if (!f1) return `${marker.venue} puts the emphasis on track conditions, weekend setup, and who handles the pressure best.`; + if (f1.lastRace?.winner) return `The last race went to ${f1.lastRace.winner}, so the next question is whether that momentum travels into ${marker.venue}.`; + if (f1.nextRace?.circuitName) return `${f1.nextRace.circuitName} now becomes the next checkpoint in the title narrative.`; + return `${marker.venue} becomes the next control point in the championship story.`; +} + +function mapEvent(raw: Record): SportsEvent { + return { + idEvent: String(raw.idEvent ?? ''), + idLeague: raw.idLeague ? String(raw.idLeague) : undefined, + strLeague: raw.strLeague ? String(raw.strLeague) : undefined, + strSeason: raw.strSeason ? String(raw.strSeason) : undefined, + strSport: raw.strSport ? String(raw.strSport) : undefined, + strEvent: raw.strEvent ? String(raw.strEvent) : undefined, + strHomeTeam: raw.strHomeTeam ? String(raw.strHomeTeam) : undefined, + strAwayTeam: raw.strAwayTeam ? String(raw.strAwayTeam) : undefined, + strHomeBadge: toOptionalString(raw.strHomeBadge) || toOptionalString(raw.strHomeTeamBadge), + strAwayBadge: toOptionalString(raw.strAwayBadge) || toOptionalString(raw.strAwayTeamBadge), + strStatus: raw.strStatus ? String(raw.strStatus) : undefined, + strProgress: raw.strProgress ? String(raw.strProgress) : undefined, + strVenue: raw.strVenue ? String(raw.strVenue) : undefined, + strCity: toOptionalString(raw.strCity), + strCountry: toOptionalString(raw.strCountry), + strRound: raw.intRound ? String(raw.intRound) : raw.strRound ? String(raw.strRound) : undefined, + strTimestamp: raw.strTimestamp ? String(raw.strTimestamp) : undefined, + dateEvent: raw.dateEvent ? String(raw.dateEvent) : undefined, + strTime: raw.strTime ? String(raw.strTime) : undefined, + intHomeScore: raw.intHomeScore ? String(raw.intHomeScore) : undefined, + intAwayScore: raw.intAwayScore ? String(raw.intAwayScore) : undefined, + lat: readFirstNumber(raw, ['strLatitude', 'strLat', 'strGeoLat']), + lng: readFirstNumber(raw, ['strLongitude', 'strLon', 'strLng', 'strGeoLong']), + }; +} + +function mapStandingRow(row: Record): SportsStandingRow { + return { + rank: toNumber(row.intRank), + team: String(row.strTeam ?? 'Unknown'), + badge: row.strBadge ? String(row.strBadge) : undefined, + played: toNumber(row.intPlayed), + wins: toNumber(row.intWin), + draws: toNumber(row.intDraw), + losses: toNumber(row.intLoss), + goalDifference: toNumber(row.intGoalDifference), + points: toNumber(row.intPoints), + form: row.strForm ? String(row.strForm) : undefined, + note: row.strDescription ? String(row.strDescription) : undefined, + season: row.strSeason ? String(row.strSeason) : undefined, + }; +} + +function mapEspnCompetitionOption(spec: EspnCompetitionSpec): SportsLeagueOption { + return { + id: spec.id, + sport: spec.sport, + name: spec.name, + shortName: spec.shortName, + }; +} + +function mapEspnCompetitionDetails(spec: EspnCompetitionSpec, seasonLabel?: string): SportsLeagueDetails { + return { + ...mapEspnCompetitionOption(spec), + country: spec.country, + currentSeason: seasonLabel, + }; +} + +function mapEspnCompetitionLeague(spec: EspnCompetitionSpec): SportsLeague { + return { + id: spec.id, + sport: spec.sport, + name: spec.name, + shortName: spec.shortName, + country: spec.country, + }; +} + +function resolveEspnCompetitionSpec(leagueId?: string, leagueName?: string): EspnCompetitionSpec | null { + const byId = (leagueId || '').trim(); + if (byId) { + const exact = ESPN_ALL_COMPETITIONS.find((spec) => spec.id === byId); + if (exact) return exact; + } + + const normalizedLeagueId = normalizeLeagueLookup(leagueId); + if (normalizedLeagueId) { + const exact = ESPN_ALL_COMPETITIONS.find((spec) => normalizeLeagueLookup(spec.id) === normalizedLeagueId); + if (exact) return exact; + } + + const normalizedLeagueName = normalizeLeagueLookup(leagueName); + if (normalizedLeagueName) { + const byName = ESPN_ALL_COMPETITIONS.find((spec) => { + const haystacks = [ + normalizeLeagueLookup(spec.name), + normalizeLeagueLookup(spec.shortName), + ]; + return haystacks.some((haystack) => haystack === normalizedLeagueName || haystack.includes(normalizedLeagueName)); + }); + if (byName) return byName; + } + + return null; +} + +function buildEspnSiteScoreboardPath(spec: EspnCompetitionSpec, dateStr?: string): string { + const base = `/${spec.sportPath}/${spec.leaguePath}/scoreboard`; + return dateStr ? `${base}?dates=${dateStr.replace(/-/g, '')}` : base; +} + +function buildEspnSiteSummaryPath(spec: EspnCompetitionSpec, eventId: string): string { + return `/${spec.sportPath}/${spec.leaguePath}/summary?event=${encodeURIComponent(eventId)}`; +} + +function extractEspnTeamLogo(team: Record | null): string | undefined { + if (!team) return undefined; + const direct = toOptionalString(team.logo); + if (direct) return direct; + const logos = asArray(team.logos); + return toOptionalString(logos[0]?.href); +} + +function pickEspnCompetitor( + competitors: Record[], + homeAway: 'home' | 'away', + fallbackIndex: number, +): Record | null { + return competitors.find((competitor) => toOptionalString(competitor.homeAway) === homeAway) + || competitors[fallbackIndex] + || null; +} + +function mapEspnScoreboardEvent(spec: EspnCompetitionSpec, raw: Record): SportsEvent | null { + const competition = asArray(raw.competitions)[0]; + if (!competition) return null; + + const competitors = asArray(competition.competitors); + const home = pickEspnCompetitor(competitors, 'home', 0); + const away = pickEspnCompetitor(competitors, 'away', 1); + const homeTeam = home && isRecord(home.team) ? home.team : null; + const awayTeam = away && isRecord(away.team) ? away.team : null; + const status = isRecord(competition.status) ? competition.status : null; + const statusType = status && isRecord(status.type) ? status.type : null; + const venue = isRecord(competition.venue) + ? competition.venue + : asArray(competition.venues)[0]; + const venueAddress = venue && isRecord(venue.address) ? venue.address : null; + const week = isRecord(raw.week) ? raw.week : null; + const seasonType = isRecord(raw.seasonType) ? raw.seasonType : null; + const timestamp = toOptionalString(competition.date) || toOptionalString(raw.date); + const eventId = toOptionalString(raw.id); + if (!eventId || !timestamp) return null; + + return { + idEvent: eventId, + idLeague: spec.id, + strLeague: spec.name, + strSeason: toOptionalString(raw.seasonDisplay), + strSport: spec.sport, + strEvent: toOptionalString(raw.name), + strHomeTeam: toOptionalString(homeTeam?.displayName) || toOptionalString(homeTeam?.shortDisplayName), + strAwayTeam: toOptionalString(awayTeam?.displayName) || toOptionalString(awayTeam?.shortDisplayName), + strHomeBadge: extractEspnTeamLogo(homeTeam), + strAwayBadge: extractEspnTeamLogo(awayTeam), + strStatus: toOptionalString(statusType?.description), + strProgress: toOptionalString(statusType?.detail) || toOptionalString(statusType?.shortDetail), + strVenue: toOptionalString(venue?.fullName), + strCity: toOptionalString(venueAddress?.city), + strCountry: toOptionalString(venueAddress?.country) || spec.country, + strRound: toOptionalString(week?.text) || toOptionalString(seasonType?.name), + strTimestamp: timestamp, + dateEvent: timestamp.slice(0, 10), + strTime: timestamp.includes('T') ? timestamp.split('T')[1] : undefined, + intHomeScore: toOptionalString(home?.score), + intAwayScore: toOptionalString(away?.score), + lat: readFirstNumber(venue || {}, ['latitude', 'lat']), + lng: readFirstNumber(venue || {}, ['longitude', 'lng', 'lon']), + }; +} + +function pickEspnRecentOrLiveEvent(events: SportsEvent[]): SportsEvent | null { + return pickEspnRecentEvents(events, 1)[0] ?? null; +} + +function pickEspnRecentEvents(events: SportsEvent[], limit = 3): SportsEvent[] { + const active = events.filter((event) => { + const status = (event.strStatus || '').toLowerCase(); + return status.includes('final') || status.includes('live') || status.includes('extra') || status.includes('full time'); + }); + return sortEventsDescending(active).slice(0, limit); +} + +function pickEspnUpcomingEvents(events: SportsEvent[], limit?: number): SportsEvent[] { + const sorted = sortEventsAscending(events); + return limit ? sorted.slice(0, limit) : sorted; +} + +function mapEspnCompetitionEventsFromPayload(spec: EspnCompetitionSpec, payload: Record): SportsEvent[] { + const leagues = asArray(payload.leagues); + const season = isRecord(leagues[0]?.season) ? leagues[0]?.season : null; + const seasonLabel = toOptionalString(season?.displayName) || toOptionalString(season?.year); + return asArray(payload.events) + .map((event) => mapEspnScoreboardEvent(spec, { ...event, seasonDisplay: seasonLabel })) + .filter((event): event is SportsEvent => !!event); +} + +async function fetchEspnCompetitionEvents(spec: EspnCompetitionSpec, dateStr?: string): Promise { + const payload = await fetchEspnSiteJson>(buildEspnSiteScoreboardPath(spec, dateStr), 5 * 60 * 1000); + return mapEspnCompetitionEventsFromPayload(spec, payload); +} + +async function fetchEspnEventStats(spec: EspnCompetitionSpec, event: SportsEvent): Promise { + const payload = await fetchEspnSiteJson>(buildEspnSiteSummaryPath(spec, event.idEvent), 2 * 60 * 1000); + const boxscore = isRecord(payload.boxscore) ? payload.boxscore : null; + const teams = boxscore ? asArray(boxscore.teams) : []; + const home = teams.find((team) => toOptionalString(team.homeAway) === 'home') || teams[0]; + const away = teams.find((team) => toOptionalString(team.homeAway) === 'away') || teams[1]; + const homeStats = asArray(home?.statistics); + const awayStats = asArray(away?.statistics); + + if (!homeStats.length || !awayStats.length) { + return buildFallbackStats(event); + } + + const byName = new Map>(); + for (const stat of awayStats) { + const name = toOptionalString(stat.name); + if (name) byName.set(name, stat); + } + + const stats: SportsEventStat[] = []; + for (const homeStat of homeStats) { + const name = toOptionalString(homeStat.name); + if (!name) continue; + const awayStat = byName.get(name); + if (!awayStat) continue; + const homeValue = toOptionalString(homeStat.displayValue); + const awayValue = toOptionalString(awayStat.displayValue); + if (!homeValue && !awayValue) continue; + stats.push({ + label: toOptionalString(homeStat.label) || toOptionalString(homeStat.abbreviation) || name, + homeValue, + awayValue, + }); + if (stats.length >= 6) break; + } + + return stats.length ? stats : buildFallbackStats(event); +} + +export async function fetchAllSportsLeagues(): Promise { + const payload = await fetchSportsDbJson<{ leagues?: unknown }>('/all_leagues.php', 6 * 60 * 60 * 1000); + return asArray(payload.leagues) + .map(mapLeagueOption) + .filter((league): league is SportsLeagueOption => !!league) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export async function fetchMajorTournamentLeagueOptions(): Promise { + return ESPN_MAJOR_TOURNAMENTS.map(mapEspnCompetitionOption); +} + +export async function fetchMotorsportLeagueOptions(): Promise { + return resolveFeaturedLeagueOptions(MOTORSPORT_SPECS); +} + +export async function fetchSportsLeagueDetails(leagueId: string): Promise { + const payload = await fetchSportsDbJson<{ leagues?: unknown }>(`/lookupleague.php?id=${leagueId}`, 60 * 60 * 1000); + return asArray(payload.leagues) + .map(mapLeagueDetails) + .find((league): league is SportsLeagueDetails => !!league) || null; +} + +export async function fetchSportsLeagueSeasons(leagueId: string): Promise { + const payload = await fetchSportsDbJson<{ seasons?: unknown }>(`/search_all_seasons.php?id=${leagueId}`, 60 * 60 * 1000); + const seasons = asArray(payload.seasons) + .map((season) => toOptionalString(season.strSeason)) + .filter((season): season is string => !!season); + return sortSeasonsDescending(uniqueStrings(seasons)); +} + +async function fetchLeagueTableData(league: SportsLeague, season?: string): Promise { + const seasonQuery = season ? `&s=${encodeURIComponent(season)}` : ''; + const payload = await fetchSportsDbJson<{ table?: unknown }>(`/lookuptable.php?l=${league.id}${seasonQuery}`, 10 * 60 * 1000); + const rawRows = asArray(payload.table); + const rows = rawRows + .map(mapStandingRow) + .filter((row) => row.rank > 0) + .sort((a, b) => a.rank - b.rank); + + if (!rows.length) return null; + + return { + league, + season: rows[0]?.season || season, + updatedAt: toOptionalString(rawRows[0]?.dateUpdated), + rows, + }; +} + +async function fetchLeagueRecentEvents(leagueId: string, limit = 5): Promise { + const payload = await fetchSportsDbJson<{ results?: unknown }>(`/eventslast.php?id=${leagueId}`, 10 * 60 * 1000); + return sortEventsDescending(asArray(payload.results).map(mapEvent)) + .filter((event) => event.idEvent) + .slice(0, limit); +} + +async function fetchLeagueUpcomingEvents(leagueId: string, limit = 5): Promise { + const payload = await fetchSportsDbJson<{ events?: unknown; results?: unknown }>(`/eventsnext.php?id=${leagueId}`, 5 * 60 * 1000); + const rawEvents = asArray(payload.events).length ? asArray(payload.events) : asArray(payload.results); + return sortEventsAscending(rawEvents.map(mapEvent)) + .filter((event) => event.idEvent) + .slice(0, limit); +} + +async function fetchSportsDbDailyEventsForSport(sport: string, targetDate: Date): Promise { + const targetDateStr = formatLocalCalendarDate(targetDate); + const payloads = await Promise.all( + buildLocalSportsDateWindow(targetDate).map((dateStr) => + fetchSportsDbJson<{ events?: unknown }>(`/eventsday.php?d=${dateStr}&s=${encodeURIComponent(sport)}`, 5 * 60 * 1000) + .catch(() => ({ events: [] })) + ), + ); + + const events = payloads + .flatMap((payload) => asArray(payload.events)) + .map(mapEvent) + .filter((event) => event.idEvent); + + return filterEventsToLocalCalendarDate(events, targetDateStr); +} + +async function fetchEspnCompetitionDailyEvents(spec: EspnCompetitionSpec, targetDate: Date): Promise { + const targetDateStr = formatLocalCalendarDate(targetDate); + const [datedEvents, genericEvents] = await Promise.all([ + fetchEspnCompetitionEvents(spec, targetDateStr).catch(() => []), + fetchEspnCompetitionEvents(spec).catch(() => []), + ]); + + return filterEventsToLocalCalendarDate([...datedEvents, ...genericEvents], targetDateStr); +} + +async function fetchFormulaOneNextRaceSummary(): Promise { + const payload = await fetchJolpicaJson>('/ergast/f1/current/next.json', 30 * 60 * 1000).catch(() => null); + const mrData = payload && isRecord(payload.MRData) ? payload.MRData : null; + const raceTable = mrData && isRecord(mrData.RaceTable) ? mrData.RaceTable : null; + return mapMotorsportRaceSummary(asArray(raceTable?.Races)[0] || {}); +} + +function mapMotorsportRaceToSportsEvent(race: MotorsportRaceSummary | null): SportsEvent | null { + if (!race) return null; + const timestamp = race.time ? `${race.date}T${race.time}` : `${race.date}T00:00:00Z`; + return { + idEvent: `jolpica-f1-${race.round}`, + idLeague: 'formula1', + strLeague: 'Formula 1', + strSeason: undefined, + strSport: 'Motorsport', + strEvent: race.raceName, + strVenue: race.circuitName || race.raceName, + strCity: race.locality, + strCountry: race.country, + strRound: race.round, + strTimestamp: timestamp, + dateEvent: race.date, + strTime: race.time, + lat: race.lat, + lng: race.lng, + }; +} + +async function fetchDailyMotorsportSupplementGroups(targetDate: Date): Promise { + const targetDateStr = formatLocalCalendarDate(targetDate); + const nextRaceEvent = mapMotorsportRaceToSportsEvent(await fetchFormulaOneNextRaceSummary()); + if (!nextRaceEvent || !isEventOnLocalCalendarDate(nextRaceEvent, targetDateStr)) return []; + return [{ + league: { + id: 'formula1', + sport: 'Motorsport', + name: 'Formula 1', + shortName: 'F1', + country: nextRaceEvent.strCountry, + }, + events: [nextRaceEvent], + }]; +} + +function mergeSportsFixtureGroup( + groupsByLeague: Map, + league: SportsLeague, + event: SportsEvent, +): void { + const storageKey = buildSportsFixtureGroupKey(event.strLeague || league.name, event.strSport || league.sport, league.id); + let group = groupsByLeague.get(storageKey); + if (!group) { + group = { + league: { + ...league, + name: event.strLeague || league.name, + sport: event.strSport || league.sport, + country: event.strCountry || league.country, + }, + events: [], + }; + groupsByLeague.set(storageKey, group); + } + + const shapeKey = buildSportsEventShapeKey(event); + if (group.events.some((existing) => existing.idEvent === event.idEvent || buildSportsEventShapeKey(existing) === shapeKey)) return; + group.events.push(event); +} + +async function fetchEspnFixtureFallbackGroups(): Promise { + const responses = await Promise.all( + ESPN_FIXTURE_COMPETITIONS.map(async (spec) => { + const league = mapEspnCompetitionLeague(spec); + const events = pickEspnUpcomingEvents(await fetchEspnCompetitionEvents(spec).catch(() => [])); + return { league, events } satisfies SportsFixtureGroup; + }), + ); + + return responses.filter((group) => group.events.length > 0); +} + +export async function fetchFeaturedSportsFixtures(): Promise { + const targetDate = new Date(); + const theSportsDbSports = ['Soccer', 'Motorsport', 'Tennis', 'Cricket']; + const tsdbPromises = theSportsDbSports.map((sport) => fetchSportsDbDailyEventsForSport(sport, targetDate)); + + const espnPromises = ESPN_FIXTURE_COMPETITIONS.map(async (spec) => { + const league = mapEspnCompetitionLeague(spec); + const events = pickEspnUpcomingEvents(await fetchEspnCompetitionDailyEvents(spec, targetDate).catch(() => [])); + return { league, events } satisfies SportsFixtureGroup; + }); + + const [tsdbEventBuckets, espnResponses, motorsportSupplementGroups] = await Promise.all([ + Promise.all(tsdbPromises), + Promise.all(espnPromises), + fetchDailyMotorsportSupplementGroups(targetDate).catch(() => []), + ]); + + const groupsByLeague = new Map(); + + for (const group of espnResponses) { + group.events.forEach((event) => mergeSportsFixtureGroup(groupsByLeague, group.league, event)); + } + + for (const event of tsdbEventBuckets.flat()) { + if (!event.idLeague || !event.strLeague || !event.strSport) continue; + mergeSportsFixtureGroup(groupsByLeague, { + id: `tsdb-${event.idLeague}`, + sport: event.strSport, + name: event.strLeague, + shortName: buildLeagueShortName(event.strLeague), + country: event.strCountry, + }, event); + } + + for (const group of motorsportSupplementGroups) { + group.events.forEach((event) => mergeSportsFixtureGroup(groupsByLeague, group.league, event)); + } + + const groups = Array.from(groupsByLeague.values()) + .map((group) => ({ + ...group, + events: pickEspnUpcomingEvents(dedupeSportsEvents(group.events)), + })) + .filter((group) => group.events.length > 0); + + return groups.sort((a, b) => { + const sportDiff = (SPORTS_FIXTURE_SPORT_PRIORITY.get(a.league.sport) ?? 9) - (SPORTS_FIXTURE_SPORT_PRIORITY.get(b.league.sport) ?? 9); + if (sportDiff !== 0) return sportDiff; + const timeDiff = parseEventTimestamp(a.events[0] || {}) - parseEventTimestamp(b.events[0] || {}); + if (timeDiff !== 0) return timeDiff; + return a.league.name.localeCompare(b.league.name); + }); +} + +export async function searchFeaturedSportsFixtures(query: string, limit = 20): Promise { + const trimmedQuery = query.trim(); + if (!trimmedQuery) return []; + const maxResults = Math.min(Math.max(limit, 1), 30); + + const [featuredGroups, fallbackGroups] = await Promise.all([ + fetchFeaturedSportsFixtures().catch(() => []), + fetchEspnFixtureFallbackGroups().catch(() => []), + ]); + + const seen = new Set(); + const scored: Array<{ league: SportsLeague; event: SportsEvent; score: number }> = []; + const candidates = [...featuredGroups, ...fallbackGroups] + .flatMap((group) => group.events.map((event) => ({ league: group.league, event }))); + + for (const candidate of candidates) { + const key = candidate.event.idEvent || buildSportsEventShapeKey(candidate.event); + if (!key || seen.has(key)) continue; + seen.add(key); + + const score = scoreSportsFixtureSearchMatch(trimmedQuery, candidate.league, candidate.event); + if (score <= 0) continue; + scored.push({ ...candidate, score }); + } + + return scored + .sort((a, b) => { + if (a.score !== b.score) return b.score - a.score; + const liveDiff = Number(isLikelyLiveSportsEvent(b.event)) - Number(isLikelyLiveSportsEvent(a.event)); + if (liveDiff !== 0) return liveDiff; + return parseEventTimestamp(a.event) - parseEventTimestamp(b.event); + }) + .slice(0, maxResults) + .map(({ league, event }) => ({ league, event })); +} + +async function buildSportsFixtureMapMarkers(groups: SportsFixtureGroup[]): Promise { + const candidates = pickSportsFixtureCandidates(groups); + const markers: Array = await Promise.all( + candidates.map(async ({ league, event }) => { + const venue = await resolveEventVenueProfile(event, league); + if (venue?.lat === undefined || venue.lng === undefined) return null; + + return { + id: `sports-fixture:${event.idEvent}`, + eventId: event.idEvent, + leagueId: event.idLeague || league.id, + leagueName: event.strLeague || league.name, + leagueShortName: league.shortName, + sport: event.strSport || league.sport, + title: event.strEvent || [event.strHomeTeam, event.strAwayTeam].filter(Boolean).join(' vs ') || league.name, + homeTeam: event.strHomeTeam, + awayTeam: event.strAwayTeam, + homeBadge: event.strHomeBadge, + awayBadge: event.strAwayBadge, + venue: venue.name, + venueCity: venue.city || event.strCity, + venueCountry: venue.country || event.strCountry || league.country, + venueCapacity: venue.capacity, + venueSurface: venue.surface, + round: event.strRound, + season: event.strSeason, + startTime: event.strTimestamp || (event.dateEvent ? `${event.dateEvent}T${event.strTime || '00:00:00'}` : undefined), + startLabel: formatSportsFixtureStartLabel(event), + lat: venue.lat, + lng: venue.lng, + } satisfies SportsFixtureMapMarker; + }), + ); + + return markers.filter((marker): marker is SportsFixtureMapMarker => !!marker); +} + +function sortSportsFixtureMapMarkers(markers: SportsFixtureMapMarker[]): SportsFixtureMapMarker[] { + return markers + .slice() + .sort((a, b) => { + const aTime = a.startTime ? Date.parse(a.startTime) : Number.MAX_SAFE_INTEGER; + const bTime = b.startTime ? Date.parse(b.startTime) : Number.MAX_SAFE_INTEGER; + return (Number.isFinite(aTime) ? aTime : Number.MAX_SAFE_INTEGER) - (Number.isFinite(bTime) ? bTime : Number.MAX_SAFE_INTEGER); + }); +} + +function buildSportsFixtureLeagueHubs(markers: SportsFixtureMapMarker[]): SportsFixtureMapMarker[] { + const grouped = new Map(); + + for (const marker of sortSportsFixtureMapMarkers(markers)) { + const key = buildSportsFixtureGroupKey(marker.leagueName, marker.sport, marker.leagueId || marker.leagueName); + const group = grouped.get(key); + if (group) group.push(marker); + else grouped.set(key, [marker]); + } + + return sortSportsFixtureMapMarkers( + Array.from(grouped.values()) + .map((group) => { + const first = group[0]!; + const fixtureCount = group.reduce((sum, item) => sum + countSportsFixtures(item), 0); + const isMultiFixtureLeague = fixtureCount > 1; + return buildSportsFixtureAggregateMarker(group, { + id: `sports-fixture-league:${first.leagueId || normalizeLeagueLookup(first.leagueName) || first.eventId}`, + eventId: first.eventId, + leagueId: first.leagueId, + leagueName: first.leagueName, + leagueShortName: first.leagueShortName, + sport: first.sport, + title: isMultiFixtureLeague + ? `${first.leagueShortName || first.leagueName} · ${fixtureCount} fixtures` + : first.title, + fixtureCount, + competitionCount: 1, + sports: [first.sport], + }); + }) + .filter((marker) => marker.fixtureCount && marker.fixtureCount > 0), + ); +} + +export async function fetchSportsFixtureMapMarkers(): Promise { + const primaryMarkers = buildSportsFixtureLeagueHubs(await buildSportsFixtureMapMarkers(await fetchFeaturedSportsFixtures())); + if (primaryMarkers.length > 0) { + return primaryMarkers; + } + + const fallbackMarkers = buildSportsFixtureLeagueHubs(await buildSportsFixtureMapMarkers(await fetchEspnFixtureFallbackGroups())); + return fallbackMarkers; +} + +export async function fetchFeaturedSportsTables(): Promise { + const responses = await Promise.all( + FEATURED_TABLE_LEAGUES.map(async (league) => { + const table = await fetchLeagueTableData(league); + if (!table) return null; + return { + ...table, + rows: table.rows.slice(0, 5), + }; + }), + ); + + return responses.filter((group): group is SportsTableGroup => !!group && group.rows.length > 0); +} + +function mapLeagueOptionToLeague(option: SportsLeagueOption): SportsLeague { + return { + id: option.id, + sport: option.sport, + name: option.name, + shortName: option.shortName, + }; +} + +export async function fetchEuropeanFootballTopLeagueTables(): Promise { + const leagues = await resolveFeaturedLeagueOptions(EUROPEAN_TOP_FOOTBALL_SPECS); + const responses = await Promise.all( + leagues.map(async (option) => { + const league = mapLeagueOptionToLeague(option); + return fetchLeagueTableData(league).catch(() => null); + }), + ); + + return responses.filter((table): table is SportsTableGroup => !!table && table.rows.length > 0); +} + +async function fetchEventStats(eventId: string): Promise { + const payload = await fetchSportsDbJson<{ eventstats?: unknown }>(`/lookupeventstats.php?id=${eventId}`, 10 * 60 * 1000); + return asArray(payload.eventstats) + .map((stat) => ({ + label: String(stat.strStat ?? ''), + homeValue: stat.intHome ? String(stat.intHome) : undefined, + awayValue: stat.intAway ? String(stat.intAway) : undefined, + })) + .filter((stat) => stat.label && (stat.homeValue || stat.awayValue)) + .slice(0, 4); +} + +async function fetchSportsDbEventById(eventId: string): Promise { + const payload = await fetchSportsDbJson<{ events?: unknown }>(`/lookupevent.php?id=${encodeURIComponent(eventId)}`, 2 * 60 * 1000); + return asArray(payload.events) + .map(mapEvent) + .find((event) => event.idEvent === eventId) || null; +} + +function buildFallbackStats(event: SportsEvent): SportsEventStat[] { + const stats: SportsEventStat[] = []; + if (event.intHomeScore || event.intAwayScore) { + stats.push({ + label: 'Score', + homeValue: event.intHomeScore ?? '-', + awayValue: event.intAwayScore ?? '-', + }); + } + if (event.strRound) { + stats.push({ + label: 'Round', + homeValue: event.strRound, + awayValue: event.strSeason ?? '', + }); + } + if (event.strStatus || event.strProgress) { + stats.push({ + label: 'Status', + homeValue: event.strStatus ?? 'Final', + awayValue: event.strProgress ?? '', + }); + } + + if (stats.length === 0) { + stats.push({ + label: 'Status', + homeValue: event.strStatus || 'Scheduled', + awayValue: event.strProgress || formatSportsFixtureStartLabel(event), + }); + } + + return stats; +} + +export async function fetchSportsFixtureSnapshot(eventId: string, leagueId?: string, leagueName?: string): Promise { + const trimmedEventId = eventId.trim(); + if (!trimmedEventId) return null; + const cacheKey = `sports-fixture-snapshot:${leagueId || ''}:${trimmedEventId}`; + const cached = getCached(cacheKey); + if (cached) return cached; + + const spec = resolveEspnCompetitionSpec(leagueId, leagueName); + if (spec) { + const targetDate = formatLocalCalendarDate(new Date()); + const [datedEvents, genericEvents] = await Promise.all([ + fetchEspnCompetitionEvents(spec, targetDate).catch(() => []), + fetchEspnCompetitionEvents(spec).catch(() => []), + ]); + const event = dedupeSportsEvents([...datedEvents, ...genericEvents]) + .find((candidate) => candidate.idEvent === trimmedEventId); + + if (event) { + let stats = await fetchEspnEventStats(spec, event).catch(() => []); + if (stats.length === 0) stats = buildFallbackStats(event); + if (stats.length > 0) { + const snapshot: SportsStatSnapshot = { + league: mapEspnCompetitionLeague(spec), + event, + stats, + }; + return setCached(cacheKey, snapshot, 90 * 1000); + } + } + } + + const event = await fetchSportsDbEventById(trimmedEventId).catch(() => null); + if (!event) return null; + + let stats = await fetchEventStats(trimmedEventId).catch(() => []); + if (stats.length === 0) stats = buildFallbackStats(event); + if (stats.length === 0) return null; + + const resolvedLeagueName = event.strLeague || leagueName || spec?.name || 'Sports'; + const snapshot: SportsStatSnapshot = { + league: { + id: event.idLeague || leagueId || spec?.id || `event:${trimmedEventId}`, + sport: event.strSport || spec?.sport || 'Sports', + name: resolvedLeagueName, + shortName: spec?.shortName || buildLeagueShortName(resolvedLeagueName), + country: event.strCountry || spec?.country, + }, + event, + stats, + }; + return setCached(cacheKey, snapshot, 90 * 1000); +} + +export async function fetchFeaturedSportsStats(): Promise { + const snapshots = await Promise.all( + ESPN_STATS_COMPETITIONS.map(async (spec) => { + const league = mapEspnCompetitionLeague(spec); + const event = pickEspnRecentOrLiveEvent(await fetchEspnCompetitionEvents(spec).catch(() => [])); + if (!event) return null; + + const stats = await fetchEspnEventStats(spec, event).catch(() => buildFallbackStats(event)); + + return { + league, + event, + stats, + } satisfies SportsStatSnapshot; + }), + ); + + return snapshots.filter((snapshot): snapshot is SportsStatSnapshot => !!snapshot && snapshot.stats.length > 0); +} + +export async function fetchMajorTournamentCenterData(tournamentId: string): Promise { + const spec = ESPN_MAJOR_TOURNAMENTS.find((entry) => entry.id === tournamentId); + if (!spec) return null; + + const rawPayload = await fetchEspnSiteJson>(buildEspnSiteScoreboardPath(spec), 5 * 60 * 1000); + const events = mapEspnCompetitionEventsFromPayload(spec, rawPayload); + const recentEvents = pickEspnRecentEvents(events, 5); + const recentEvent = recentEvents[0] || null; + const upcomingEvents = pickEspnUpcomingEvents(events, 5); + const leagues = asArray(rawPayload.leagues); + const season = isRecord(leagues[0]?.season) ? leagues[0]?.season : null; + const seasonLabel = toOptionalString(season?.displayName) || toOptionalString(season?.year); + const statSnapshot = recentEvent + ? { + league: mapEspnCompetitionLeague(spec), + event: recentEvent, + stats: await fetchEspnEventStats(spec, recentEvent).catch(() => buildFallbackStats(recentEvent)), + } satisfies SportsStatSnapshot + : null; + + return { + league: mapEspnCompetitionDetails(spec, seasonLabel), + seasons: seasonLabel ? [seasonLabel] : [], + selectedSeason: seasonLabel, + table: null, + tableAvailable: false, + recentEvents, + upcomingEvents, + statSnapshot: statSnapshot && statSnapshot.stats.length > 0 ? statSnapshot : null, + }; +} + +function mapSportsPlayerSearchResult(raw: Record): SportsPlayerSearchResult | null { + const id = toOptionalString(raw.idPlayer); + const name = toOptionalString(raw.strPlayer); + if (!id || !name) return null; + + return { + id, + name, + alternateName: toOptionalString(raw.strPlayerAlternate), + sport: toOptionalString(raw.strSport), + team: toOptionalString(raw.strTeam), + secondaryTeam: toOptionalString(raw.strTeam2), + nationality: toOptionalString(raw.strNationality), + position: toOptionalString(raw.strPosition), + status: toOptionalString(raw.strStatus), + number: toOptionalString(raw.strNumber), + thumb: toOptionalString(raw.strThumb), + cutout: toOptionalString(raw.strCutout), + }; +} + +function mapSportsPlayerDetails(raw: Record): SportsPlayerDetails | null { + const base = mapSportsPlayerSearchResult(raw); + if (!base) return null; + + return { + ...base, + banner: toOptionalString(raw.strBanner), + fanart: uniqueStrings([ + toOptionalString(raw.strFanart1), + toOptionalString(raw.strFanart2), + toOptionalString(raw.strFanart3), + toOptionalString(raw.strFanart4), + ])[0], + birthDate: toOptionalString(raw.dateBorn), + birthLocation: toOptionalString(raw.strBirthLocation), + description: toOptionalString(raw.strDescriptionEN), + height: toOptionalString(raw.strHeight), + weight: toOptionalString(raw.strWeight), + gender: toOptionalString(raw.strGender), + handedness: toOptionalString(raw.strSide), + signedDate: toOptionalString(raw.dateSigned), + signing: toOptionalString(raw.strSigning), + agent: toOptionalString(raw.strAgent), + outfitter: toOptionalString(raw.strOutfitter), + kit: toOptionalString(raw.strKit), + website: toOptionalString(raw.strWebsite), + facebook: toOptionalString(raw.strFacebook), + twitter: toOptionalString(raw.strTwitter), + instagram: toOptionalString(raw.strInstagram), + youtube: toOptionalString(raw.strYoutube), + }; +} + +function scoreSportsPlayerSearchResult(player: SportsPlayerSearchResult, query: string): number { + const normalizedQuery = normalizeLeagueLookup(query); + const exactName = normalizeLeagueLookup(player.name); + const alternateName = normalizeLeagueLookup(player.alternateName); + const team = normalizeLeagueLookup(player.team); + let score = 0; + + if (exactName === normalizedQuery || alternateName === normalizedQuery) score += 120; + else if (exactName.startsWith(normalizedQuery) || alternateName.startsWith(normalizedQuery)) score += 80; + else if (exactName.includes(normalizedQuery) || alternateName.includes(normalizedQuery)) score += 60; + + if (team && normalizedQuery && team.includes(normalizedQuery)) score += 20; + if ((player.status || '').toLowerCase() === 'active') score += 15; + if (player.team) score += 5; + if (player.thumb || player.cutout) score += 3; + return score; +} + +export async function fetchSportsPlayerSearch(query: string): Promise { + const trimmedQuery = query.trim(); + if (!trimmedQuery) return []; + + const payload = await fetchSportsDbJson<{ player?: unknown; player_contracts?: unknown; player_honours?: unknown; players?: unknown }>( + `/searchplayers.php?p=${encodeURIComponent(trimmedQuery)}`, + 30 * 60 * 1000, + ); + const rawPlayers = asArray(payload.player).length + ? asArray(payload.player) + : asArray(payload.players); + + return rawPlayers + .map(mapSportsPlayerSearchResult) + .filter((player): player is SportsPlayerSearchResult => !!player) + .sort((a, b) => scoreSportsPlayerSearchResult(b, trimmedQuery) - scoreSportsPlayerSearchResult(a, trimmedQuery) || a.name.localeCompare(b.name)) + .slice(0, 8); +} + +export async function fetchSportsPlayerDetails(playerId: string): Promise { + const trimmedId = playerId.trim(); + if (!trimmedId) return null; + + const payload = await fetchSportsDbJson<{ players?: unknown }>(`/lookupplayer.php?id=${encodeURIComponent(trimmedId)}`, 60 * 60 * 1000); + return asArray(payload.players) + .map(mapSportsPlayerDetails) + .find((player): player is SportsPlayerDetails => !!player) || null; +} + +export async function fetchLeagueCenterData(leagueId: string, season?: string): Promise { + const details = await fetchSportsLeagueDetails(leagueId); + if (!details) return null; + + const [seasons, recentEvents, upcomingEvents] = await Promise.all([ + fetchSportsLeagueSeasons(leagueId).catch(() => []), + fetchLeagueRecentEvents(leagueId, 5).catch(() => []), + fetchLeagueUpcomingEvents(leagueId, 5).catch(() => []), + ]); + + const selectedSeason = resolveSelectedSeason(season, seasons, details.currentSeason); + + let table: SportsTableGroup | null = null; + try { + table = await fetchLeagueTableData(details, selectedSeason); + } catch { + table = null; + } + + if (!table && seasons.length > 0) { + const fallbackSeason = seasons[0]; + if (fallbackSeason && fallbackSeason !== selectedSeason) { + try { + table = await fetchLeagueTableData(details, fallbackSeason); + } catch { + table = null; + } + } + } + + if (!table && selectedSeason) { + try { + table = await fetchLeagueTableData(details); + } catch { + table = null; + } + } + + let statSnapshot: SportsStatSnapshot | null = null; + const recentEvent = recentEvents[0]; + if (recentEvent) { + let stats: SportsEventStat[] = []; + try { + stats = await fetchEventStats(recentEvent.idEvent); + } catch { + stats = []; + } + if (stats.length === 0) { + stats = buildFallbackStats(recentEvent); + } + if (stats.length > 0) { + statSnapshot = { + league: details, + event: recentEvent, + stats, + }; + } + } + + return { + league: { + ...details, + tableSupported: table ? true : details.tableSupported, + }, + seasons, + selectedSeason, + table, + tableAvailable: !!table, + recentEvents, + upcomingEvents, + statSnapshot, + }; +} + +export async function fetchSportsFixturePopupContext(marker: SportsFixtureMapMarker): Promise { + const cacheKey = `sports-fixture-popup:${marker.eventId}`; + const cached = getCached(cacheKey); + if (cached) return cached; + + const request = (async () => { + const weatherPromise = fetchWeatherAlerts().catch(() => []); + + if (marker.sport === 'Soccer' && marker.leagueId) { + const [center, alerts] = await Promise.all([ + fetchLeagueCenterData(marker.leagueId).catch(() => null), + weatherPromise, + ]); + + const rows = center?.table?.rows ?? []; + const homeRow = findStandingRowByTeam(rows, marker.homeTeam) as SportsStandingRow | null; + const awayRow = findStandingRowByTeam(rows, marker.awayTeam) as SportsStandingRow | null; + const lastMeeting = (center?.recentEvents ?? []).find((event) => event.idEvent !== marker.eventId && isSameFixturePair(event, marker.homeTeam, marker.awayTeam)) || null; + const stats: SportsFixtureInsightStat[] = []; + + if (homeRow && awayRow) { + stats.push({ + label: 'Table', + value: `${marker.homeTeam || 'Home'} #${homeRow.rank} (${homeRow.points} pts) vs ${marker.awayTeam || 'Away'} #${awayRow.rank} (${awayRow.points} pts)`, + }); + stats.push({ + label: 'Goal Diff', + value: `${homeRow.goalDifference >= 0 ? '+' : ''}${homeRow.goalDifference} vs ${awayRow.goalDifference >= 0 ? '+' : ''}${awayRow.goalDifference}`, + }); + if (homeRow.form || awayRow.form) { + stats.push({ + label: 'Form', + value: `${homeRow.form || '—'} vs ${awayRow.form || '—'}`, + }); + } + } + + if (lastMeeting) { + stats.push({ + label: 'Last Meeting', + value: formatLastMeeting(lastMeeting), + }); + } + + return setCached(cacheKey, { + prediction: buildFootballPrediction(marker, homeRow, awayRow), + weather: summarizeFixtureWeather(marker, alerts), + story: buildFootballStory(homeRow, awayRow, lastMeeting), + stats, + }, 10 * 60 * 1000); + } + + if (marker.sport === 'Basketball') { + const [standings, center, alerts] = await Promise.all([ + fetchNbaStandingsData().catch(() => null), + marker.leagueId ? fetchLeagueCenterData(marker.leagueId).catch(() => null) : Promise.resolve(null), + weatherPromise, + ]); + + const rows = standings?.groups.flatMap((group) => group.rows) ?? []; + const homeRow = findStandingRowByTeam(rows, marker.homeTeam) as NbaStandingRow | null; + const awayRow = findStandingRowByTeam(rows, marker.awayTeam) as NbaStandingRow | null; + const lastMeeting = (center?.recentEvents ?? []).find((event) => event.idEvent !== marker.eventId && isSameFixturePair(event, marker.homeTeam, marker.awayTeam)) || null; + const stats: SportsFixtureInsightStat[] = []; + + if (homeRow && awayRow) { + stats.push({ + label: 'Record', + value: `${homeRow.team} ${homeRow.wins}-${homeRow.losses} vs ${awayRow.team} ${awayRow.wins}-${awayRow.losses}`, + }); + stats.push({ + label: 'Last 10', + value: `${homeRow.lastTen} vs ${awayRow.lastTen}`, + }); + stats.push({ + label: 'Differential', + value: `${homeRow.differential} vs ${awayRow.differential}`, + }); + } + + if (lastMeeting) { + stats.push({ + label: 'Last Meeting', + value: formatLastMeeting(lastMeeting), + }); + } + + return setCached(cacheKey, { + prediction: buildNbaPrediction(marker, homeRow, awayRow), + weather: summarizeFixtureWeather(marker, alerts), + story: buildNbaStory(homeRow, awayRow, lastMeeting), + stats, + }, 10 * 60 * 1000); + } + + if (marker.sport === 'Motorsport') { + const wantsF1Context = isFormulaOneFixture(marker); + const [f1, alerts] = await Promise.all([ + wantsF1Context ? fetchFormulaOneStandingsData().catch(() => null) : Promise.resolve(null), + weatherPromise, + ]); + + const stats: SportsFixtureInsightStat[] = []; + if (marker.round) stats.push({ label: 'Round', value: marker.round }); + if (marker.season) stats.push({ label: 'Season', value: marker.season }); + if (marker.venueSurface) stats.push({ label: 'Surface', value: marker.venueSurface }); + if (f1?.driverStandings[0]) { + stats.push({ + label: 'Championship', + value: `${f1.driverStandings[0].name} leads on ${f1.driverStandings[0].points} pts`, + }); + } + if (f1?.lastRace?.winner) { + stats.push({ + label: 'Last Race', + value: `${f1.lastRace.raceName}: ${f1.lastRace.winner}`, + }); + } + + return setCached(cacheKey, { + prediction: buildMotorsportPrediction(marker, f1), + weather: summarizeFixtureWeather(marker, alerts), + story: buildMotorsportStory(marker, f1), + stats, + }, 10 * 60 * 1000); + } + + const [center, alerts] = await Promise.all([ + marker.leagueId ? fetchLeagueCenterData(marker.leagueId).catch(() => null) : Promise.resolve(null), + weatherPromise, + ]); + + const recentEvents = center?.recentEvents ?? []; + const lastMeeting = recentEvents.find((event) => event.idEvent !== marker.eventId && isSameFixturePair(event, marker.homeTeam, marker.awayTeam)) || null; + const latestResult = recentEvents.find((event) => event.idEvent !== marker.eventId) || null; + const stats: SportsFixtureInsightStat[] = []; + + if (marker.round) stats.push({ label: 'Round', value: marker.round }); + if (marker.season) stats.push({ label: 'Season', value: marker.season }); + if (marker.venueSurface) stats.push({ label: 'Surface', value: marker.venueSurface }); + if (lastMeeting) { + stats.push({ + label: 'Last Meeting', + value: formatLastMeeting(lastMeeting), + }); + } else if (latestResult) { + stats.push({ + label: 'Latest Result', + value: formatRecentFixtureEvent(latestResult), + }); + } + + let prediction = buildGenericFixturePrediction(marker, lastMeeting); + let story = buildGenericFixtureStory(marker, lastMeeting); + + if (marker.sport === 'Tennis') { + prediction = buildTennisPrediction(lastMeeting); + story = buildTennisStory(marker, lastMeeting); + } else if (marker.sport === 'Cricket') { + prediction = buildCricketPrediction(lastMeeting); + story = buildCricketStory(lastMeeting); + } + + return setCached(cacheKey, { + prediction, + weather: summarizeFixtureWeather(marker, alerts), + story, + stats, + }, 10 * 60 * 1000); + })(); + + return request; +} + +function getEspnPageStandingValue( + stats: unknown[], + headers: Record, + type: string, + fallback = '—', +): string { + const matchingHeaders = Object.values(headers) + .filter(isRecord) + .filter((header) => toOptionalString(header.t) === type) + .sort((a, b) => toInteger(a.i) - toInteger(b.i)); + + for (const header of matchingHeaders) { + const index = toInteger(header.i); + const value = toOptionalString(stats[index]); + if (value) return value; + } + + return fallback; +} + +function normalizeEspnStandingStatKey(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +function mapEspnStandingStats(stats: unknown[]): Map { + const mapped = new Map(); + + for (const rawStat of stats) { + if (!isRecord(rawStat)) continue; + const value = toOptionalString(rawStat.displayValue) + || toOptionalString(rawStat.formatted) + || toOptionalString(rawStat.summary) + || toOptionalString(rawStat.value); + if (!value) continue; + + const keys = [ + toOptionalString(rawStat.name), + toOptionalString(rawStat.abbreviation), + toOptionalString(rawStat.shortDisplayName), + toOptionalString(rawStat.displayName), + toOptionalString(rawStat.type), + ] + .filter((key): key is string => !!key) + .map(normalizeEspnStandingStatKey) + .filter((key) => key.length > 0); + + for (const key of keys) { + if (!mapped.has(key)) mapped.set(key, value); + } + } + + return mapped; +} + +function getEspnApiStandingValue( + mappedStats: Map, + aliases: string[], + fallback = '—', +): string { + for (const alias of aliases) { + const value = mappedStats.get(normalizeEspnStandingStatKey(alias)); + if (value) return value; + } + return fallback; +} + +function mapNbaStandingEntryFromEspnApi( + entry: Record, + conference: string, + rank: number, +): NbaStandingRow | null { + const team = isRecord(entry.team) ? entry.team : null; + if (!team) return null; + + const teamName = toOptionalString(team.displayName) || toOptionalString(team.shortDisplayName) || toOptionalString(team.name); + if (!teamName) return null; + + const mappedStats = mapEspnStandingStats(asArray(entry.stats)); + const parsedRank = toInteger(entry.position) || toInteger(getEspnApiStandingValue(mappedStats, ['rank'], String(rank))) || rank; + const parsedSeed = toInteger(getEspnApiStandingValue(mappedStats, ['playoffseed', 'seed'], String(parsedRank))) || parsedRank; + const clincher = getEspnApiStandingValue(mappedStats, ['clincher'], ''); + + return { + rank: parsedRank, + seed: parsedSeed, + team: teamName, + abbreviation: toOptionalString(team.abbreviation) || toOptionalString(team.abbrev) || '', + badge: extractEspnTeamLogo(team), + wins: toInteger(getEspnApiStandingValue(mappedStats, ['wins', 'win'], '0')), + losses: toInteger(getEspnApiStandingValue(mappedStats, ['losses', 'loss'], '0')), + winPercent: getEspnApiStandingValue(mappedStats, ['winpercent', 'pct', 'winpct']), + gamesBehind: getEspnApiStandingValue(mappedStats, ['gamesbehind', 'gb']), + homeRecord: getEspnApiStandingValue(mappedStats, ['home']), + awayRecord: getEspnApiStandingValue(mappedStats, ['road', 'away']), + pointsFor: getEspnApiStandingValue(mappedStats, ['avgpointsfor', 'pointsforpergame', 'ppg']), + pointsAgainst: getEspnApiStandingValue(mappedStats, ['avgpointsagainst', 'pointsagainstpergame', 'oppg']), + differential: getEspnApiStandingValue(mappedStats, ['differential', 'pointdifferential']), + streak: getEspnApiStandingValue(mappedStats, ['streak']), + lastTen: getEspnApiStandingValue(mappedStats, ['lasttengames', 'last10']), + clincher: clincher || undefined, + conference, + }; +} + +function mapNbaStandingsFromEspnSiteApi(payload: Record): NbaStandingsData | null { + const children = asArray(payload.children); + const mappedGroups = (children.length > 0 + ? children.map((group, index) => { + const standings = isRecord(group.standings) ? group.standings : group; + const rows = asArray(standings.entries) + .map((entry, entryIndex) => mapNbaStandingEntryFromEspnApi(entry, toOptionalString(group.name) || toOptionalString(group.abbreviation) || `Conference ${index + 1}`, entryIndex + 1)) + .filter((row): row is NbaStandingRow => !!row); + + return { + name: toOptionalString(group.name) || toOptionalString(group.abbreviation) || `Conference ${index + 1}`, + rows, + } satisfies NbaStandingsGroup; + }) + : (() => { + const standings = isRecord(payload.standings) ? payload.standings : payload; + const rows = asArray(standings.entries) + .map((entry, index) => mapNbaStandingEntryFromEspnApi(entry, 'NBA', index + 1)) + .filter((entry): entry is NbaStandingRow => !!entry); + return [{ + name: 'NBA', + rows, + } satisfies NbaStandingsGroup]; + })() + ).filter((group) => group.rows.length > 0); + + if (!mappedGroups.length) return null; + + const season = isRecord(payload.season) ? payload.season : null; + return { + leagueName: toOptionalString(payload.name) || 'NBA', + seasonDisplay: toOptionalString(season?.displayName) || toOptionalString(season?.year) || '', + updatedAt: new Date().toISOString(), + groups: mappedGroups, + }; +} + +function extractEspnFittState(html: string): Record | null { + const match = html.match(/window\['__espnfitt__'\]=(\{.*?\});<\/script>/s); + if (!match?.[1]) return null; + + try { + const parsed = JSON.parse(match[1]); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function mapNbaStandingEntryFromEspnPage( + entry: Record, + headers: Record, + conference: string, + rank: number, +): NbaStandingRow | null { + const team = isRecord(entry.team) ? entry.team : null; + if (!team) return null; + + const teamName = toOptionalString(team.displayName) || toOptionalString(team.shortDisplayName) || toOptionalString(team.name); + if (!teamName) return null; + + const stats = Array.isArray(entry.stats) ? entry.stats : []; + const clincher = getEspnPageStandingValue(stats, headers, 'clincher', ''); + + return { + rank, + seed: toInteger(getEspnPageStandingValue(stats, headers, 'playoffseed', String(rank))) || rank, + team: teamName, + abbreviation: toOptionalString(team.abbrev) || toOptionalString(team.abbreviation) || '', + badge: toOptionalString(team.logo), + wins: toInteger(getEspnPageStandingValue(stats, headers, 'wins', '0')), + losses: toInteger(getEspnPageStandingValue(stats, headers, 'losses', '0')), + winPercent: getEspnPageStandingValue(stats, headers, 'winpercent'), + gamesBehind: getEspnPageStandingValue(stats, headers, 'gamesbehind'), + homeRecord: getEspnPageStandingValue(stats, headers, 'home'), + awayRecord: getEspnPageStandingValue(stats, headers, 'road'), + pointsFor: getEspnPageStandingValue(stats, headers, 'avgpointsfor'), + pointsAgainst: getEspnPageStandingValue(stats, headers, 'avgpointsagainst'), + differential: getEspnPageStandingValue(stats, headers, 'differential'), + streak: getEspnPageStandingValue(stats, headers, 'streak'), + lastTen: getEspnPageStandingValue(stats, headers, 'lasttengames'), + clincher: clincher || undefined, + conference, + }; +} + +export async function fetchNbaStandingsData(): Promise { + try { + const payload = await fetchEspnSiteJson>('/basketball/nba/standings', 5 * 60 * 1000); + const parsed = mapNbaStandingsFromEspnSiteApi(payload); + if (parsed) return parsed; + } catch { + // Fall back to ESPN web page parsing if the site API is unavailable. + } + + const html = await fetchEspnText('/nba/standings', 5 * 60 * 1000); + const state = extractEspnFittState(html); + if (!state) return null; + + const page = isRecord(state.page) ? state.page : null; + const content = page && isRecord(page.content) ? page.content : null; + const standings = content && isRecord(content.standings) ? content.standings : null; + const groupedStandings = standings && isRecord(standings.groups) ? standings.groups : null; + const groups = groupedStandings ? asArray(groupedStandings.groups) : []; + const headers = groupedStandings && isRecord(groupedStandings.headers) ? groupedStandings.headers : {}; + const currentSeason = standings && isRecord(standings.currentSeason) ? standings.currentSeason : null; + const md = standings && isRecord(standings.md) ? standings.md : null; + + const mappedGroups = groups + .map((group) => { + const rows = asArray(group.standings) + .map((entry, index) => mapNbaStandingEntryFromEspnPage(entry, headers, toOptionalString(group.name) || 'Conference', index + 1)) + .filter((row): row is NbaStandingRow => !!row); + + return { + name: toOptionalString(group.name) || 'Conference', + rows, + } satisfies NbaStandingsGroup; + }) + .filter((group) => group.rows.length > 0); + + if (!mappedGroups.length) return null; + + return { + leagueName: toOptionalString(standings?.leagueNameApi) || toOptionalString(md?.nm) || 'NBA', + seasonDisplay: toOptionalString(currentSeason?.displayName) || toOptionalString(md?.ssn) || '', + updatedAt: new Date().toISOString(), + groups: mappedGroups, + }; +} + +function mapMotorsportStandingRow(raw: Record): MotorsportStandingRow | null { + const driver = isRecord(raw.Driver) ? raw.Driver : null; + const constructorEntry = isRecord(raw.Constructor) ? raw.Constructor : null; + const constructors = Array.isArray(raw.Constructors) ? raw.Constructors.filter(isRecord) : []; + + const position = toInteger(raw.position); + const name = driver + ? [toOptionalString(driver.givenName), toOptionalString(driver.familyName)].filter(Boolean).join(' ') + : toOptionalString(constructorEntry?.name); + if (!position || !name) return null; + + return { + rank: position, + name, + code: toOptionalString(driver?.code), + team: driver ? (toOptionalString(constructorEntry?.name) || toOptionalString(constructors[0]?.name)) : undefined, + driverNumber: toOptionalString(driver?.permanentNumber), + points: toInteger(raw.points), + wins: toInteger(raw.wins), + nationality: toOptionalString(driver?.nationality) || toOptionalString(constructorEntry?.nationality), + }; +} + +type OpenF1DriverRecord = { + driverNumber?: string; + fullName?: string; + headshotUrl?: string; +}; + +const F1_TEAM_ASSETS: Array<{ aliases: string[]; path: string; color: string }> = [ + { aliases: ['mclaren'], path: '/sports/f1/teams/mclaren.svg', color: 'FF8000' }, + { aliases: ['ferrari', 'scuderia ferrari'], path: '/sports/f1/teams/ferrari.svg', color: 'DC0000' }, + { aliases: ['mercedes', 'mercedes-amg', 'mercedes amg petronas'], path: '/sports/f1/teams/mercedes.svg', color: '27F4D2' }, + { aliases: ['red bull', 'red bull racing'], path: '/sports/f1/teams/red-bull.svg', color: '3671C6' }, + { aliases: ['williams', 'williams racing'], path: '/sports/f1/teams/williams.svg', color: '64C4FF' }, + { aliases: ['aston martin', 'aston martin aramco'], path: '/sports/f1/teams/aston-martin.svg', color: '229971' }, + { aliases: ['alpine', 'bwt alpine'], path: '/sports/f1/teams/alpine.svg', color: '0093CC' }, + { aliases: ['haas', 'haas f1 team'], path: '/sports/f1/teams/haas.svg', color: 'B6BABD' }, + { aliases: ['sauber', 'kick sauber', 'stake f1 team kick sauber'], path: '/sports/f1/teams/sauber.svg', color: '52E252' }, + { aliases: ['racing bulls', 'rb f1 team', 'visa cash app rb', 'visa cash app racing bulls'], path: '/sports/f1/teams/racing-bulls.svg', color: '6692FF' }, +]; + +function normalizeMotorsportLookup(value: string | undefined): string { + return (value || '') + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim(); +} + +function resolveF1TeamAsset(teamName: string | undefined): { badge?: string; color?: string } | null { + const normalized = normalizeMotorsportLookup(teamName); + if (!normalized) return null; + + for (const team of F1_TEAM_ASSETS) { + if (team.aliases.some((alias) => normalized === alias || normalized.includes(alias))) { + return { + badge: team.path, + color: team.color, + }; + } + } + + return null; +} + +function mapOpenF1DriverRecord(raw: Record): OpenF1DriverRecord | null { + const driverNumber = toOptionalString(raw.driver_number); + const fullName = toOptionalString(raw.full_name); + if (!driverNumber && !fullName) return null; + + return { + driverNumber, + fullName, + headshotUrl: toOptionalString(raw.headshot_url), + }; +} + +async function fetchOpenF1DriverAssets(): Promise { + const payload = await fetchOpenF1Json('/v1/drivers?session_key=latest', 6 * 60 * 60 * 1000); + return asArray(payload) + .map(mapOpenF1DriverRecord) + .filter((record): record is OpenF1DriverRecord => !!record); +} + +function enrichMotorsportRowsWithAssets( + rows: MotorsportStandingRow[], + driverAssets: OpenF1DriverRecord[], +): MotorsportStandingRow[] { + const byNumber = new Map(); + const byName = new Map(); + + for (const asset of driverAssets) { + if (asset.driverNumber) byNumber.set(asset.driverNumber, asset); + const normalizedName = normalizeMotorsportLookup(asset.fullName); + if (normalizedName) byName.set(normalizedName, asset); + } + + return rows.map((row) => { + const driverAsset = row.driverNumber + ? byNumber.get(row.driverNumber) + : byName.get(normalizeMotorsportLookup(row.name)); + const baseTeamName = row.team || row.name; + const teamAsset = resolveF1TeamAsset(baseTeamName); + const isDriverRow = !!row.driverNumber; + + return { + ...row, + badge: driverAsset?.headshotUrl || (!isDriverRow ? teamAsset?.badge : undefined), + team: baseTeamName, + teamBadge: teamAsset?.badge, + teamColor: teamAsset?.color, + }; + }); +} + +function formatRaceDriverName(raw: Record): string { + const driver = isRecord(raw.Driver) ? raw.Driver : null; + if (!driver) return 'Unknown'; + return [toOptionalString(driver.givenName), toOptionalString(driver.familyName)].filter(Boolean).join(' ') || 'Unknown'; +} + +function mapMotorsportRaceSummary(raw: Record): MotorsportRaceSummary | null { + const circuit = isRecord(raw.Circuit) ? raw.Circuit : null; + const location = circuit && isRecord(circuit.Location) ? circuit.Location : null; + const results = asArray(raw.Results); + const podium = results.slice(0, 3).map((result) => { + const position = toOptionalString(result.position) || ''; + return `${position}. ${formatRaceDriverName(result)}`.trim(); + }).filter(Boolean); + const fastestLapResult = results.find((result) => { + const lap = isRecord(result.FastestLap) ? result.FastestLap : null; + return toOptionalString(lap?.rank) === '1'; + }); + + const raceName = toOptionalString(raw.raceName); + const round = toOptionalString(raw.round); + const date = toOptionalString(raw.date); + if (!raceName || !round || !date) return null; + + return { + raceName, + round, + date, + time: toOptionalString(raw.time), + circuitName: toOptionalString(circuit?.circuitName), + locality: toOptionalString(location?.locality), + country: toOptionalString(location?.country), + lat: toOptionalNumber(location?.lat), + lng: toOptionalNumber(location?.long), + winner: podium[0]?.replace(/^\d+\.\s*/, ''), + podium, + fastestLap: fastestLapResult ? formatRaceDriverName(fastestLapResult) : undefined, + }; +} + +export async function fetchFormulaOneStandingsData(): Promise { + const [driverPayload, constructorPayload, lastRacePayload, nextRacePayload, openF1Drivers] = await Promise.all([ + fetchJolpicaJson>('/ergast/f1/current/driverStandings.json', 5 * 60 * 1000).catch(() => null), + fetchJolpicaJson>('/ergast/f1/current/constructorStandings.json', 5 * 60 * 1000).catch(() => null), + fetchJolpicaJson>('/ergast/f1/current/last/results.json', 5 * 60 * 1000).catch(() => null), + fetchJolpicaJson>('/ergast/f1/current/next.json', 30 * 60 * 1000).catch(() => null), + fetchOpenF1DriverAssets().catch(() => []), + ]); + + const driverMrData = driverPayload && isRecord(driverPayload.MRData) ? driverPayload.MRData : null; + const constructorMrData = constructorPayload && isRecord(constructorPayload.MRData) ? constructorPayload.MRData : null; + const lastMrData = lastRacePayload && isRecord(lastRacePayload.MRData) ? lastRacePayload.MRData : null; + const nextMrData = nextRacePayload && isRecord(nextRacePayload.MRData) ? nextRacePayload.MRData : null; + + const driverTable = driverMrData && isRecord(driverMrData.StandingsTable) ? driverMrData.StandingsTable : null; + const constructorTable = constructorMrData && isRecord(constructorMrData.StandingsTable) ? constructorMrData.StandingsTable : null; + const driverList = driverTable ? asArray(driverTable.StandingsLists) : []; + const constructorList = constructorTable ? asArray(constructorTable.StandingsLists) : []; + const driverStandings = enrichMotorsportRowsWithAssets( + asArray(driverList[0]?.DriverStandings) + .map(mapMotorsportStandingRow) + .filter((row): row is MotorsportStandingRow => !!row), + openF1Drivers, + ); + const constructorStandings = enrichMotorsportRowsWithAssets( + asArray(constructorList[0]?.ConstructorStandings) + .map(mapMotorsportStandingRow) + .filter((row): row is MotorsportStandingRow => !!row), + openF1Drivers, + ); + + if (!driverStandings.length && !constructorStandings.length) return null; + + const lastRaceTable = lastMrData && isRecord(lastMrData.RaceTable) ? lastMrData.RaceTable : null; + const nextRaceTable = nextMrData && isRecord(nextMrData.RaceTable) ? nextMrData.RaceTable : null; + const lastRace = mapMotorsportRaceSummary(asArray(lastRaceTable?.Races)[0] || {}); + const nextRace = mapMotorsportRaceSummary(asArray(nextRaceTable?.Races)[0] || {}); + + return { + leagueName: 'Formula 1', + season: toOptionalString(driverTable?.season) || toOptionalString(constructorTable?.season) || '', + round: toOptionalString(driverTable?.round) || toOptionalString(lastRaceTable?.round) || '', + updatedAt: new Date().toISOString(), + driverStandings, + constructorStandings, + lastRace, + nextRace, + }; +} diff --git a/src/services/threat-classifier.ts b/src/services/threat-classifier.ts index c5427a71eb..445c7426a5 100644 --- a/src/services/threat-classifier.ts +++ b/src/services/threat-classifier.ts @@ -327,6 +327,10 @@ function shouldEscalateToCritical(lower: string, matchCat: EventCategory): boole } export function classifyByKeyword(title: string, variant = 'full'): ThreatClassification { + if (variant === 'sports') { + return { level: 'info', category: 'general', confidence: 0.15, source: 'keyword' }; + } + const lower = title.toLowerCase(); if (EXCLUSIONS.some(ex => lower.includes(ex))) { @@ -536,6 +540,9 @@ export function classifyWithAI( title: string, variant: string, ): Promise { + if (variant === 'sports') { + return Promise.resolve(null); + } const cacheKey = title.trim().toLowerCase().replace(/\s+/g, ' '); return classifyBreaker.execute( () => classifyWithAIUncached(title, variant), diff --git a/src/styles/main.css b/src/styles/main.css index 57c40e8020..afec5c86c1 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -1243,6 +1243,11 @@ canvas, min-height: 800px !important; } +.panel.span-5 { + grid-row: span 5 !important; + min-height: 1000px !important; +} + .panel.col-span-1 { grid-column: span 1 !important; } diff --git a/src/types/index.ts b/src/types/index.ts index e3211b0fc6..4a0dda74c0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -634,6 +634,7 @@ export interface MapLayers { accelerators: boolean; techHQs: boolean; techEvents: boolean; + sportsFixtures: boolean; // Finance variant layers stockExchanges: boolean; financialCenters: boolean; diff --git a/src/utils/proxy.ts b/src/utils/proxy.ts index f745b41087..1e4c202fe1 100644 --- a/src/utils/proxy.ts +++ b/src/utils/proxy.ts @@ -1,12 +1,20 @@ import { isDesktopRuntime, toApiUrl, toRuntimeUrl } from '../services/runtime'; import { getPersistentCache, setPersistentCache } from '../services/persistent-cache'; -const isDev = import.meta.env.DEV; +const ENV = (() => { + try { + return import.meta.env ?? {}; + } catch { + return {} as Record; + } +})(); + +const isDev = ENV.DEV === true; const RESPONSE_CACHE_PREFIX = 'api-response:'; // RSS proxy: route directly to Railway relay via Cloudflare CDN when enabled. // Feature flag controls rollout; default off for safe staged deployment. -const RSS_DIRECT_TO_RELAY = import.meta.env.VITE_RSS_DIRECT_TO_RELAY === 'true'; +const RSS_DIRECT_TO_RELAY = ENV.VITE_RSS_DIRECT_TO_RELAY === 'true'; const RSS_PROXY_BASE = isDev ? '' // Dev uses Vite's rssProxyPlugin : RSS_DIRECT_TO_RELAY diff --git a/tests/edge-functions.test.mjs b/tests/edge-functions.test.mjs index 4782960cfd..d34768ce79 100644 --- a/tests/edge-functions.test.mjs +++ b/tests/edge-functions.test.mjs @@ -93,6 +93,7 @@ describe('Legacy api/*.js endpoint allowlist', () => { 'rss-proxy.js', 'satellites.js', 'seed-health.js', + 'sports-data.js', 'story.js', 'telegram-feed.js', 'sanctions-entity-search.js', diff --git a/tests/panel-config-guardrails.test.mjs b/tests/panel-config-guardrails.test.mjs index af7dcec772..f7fe1a7834 100644 --- a/tests/panel-config-guardrails.test.mjs +++ b/tests/panel-config-guardrails.test.mjs @@ -7,7 +7,11 @@ import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const panelLayoutSrc = readFileSync(resolve(__dirname, '../src/app/panel-layout.ts'), 'utf-8'); -const VARIANT_FILES = ['full', 'tech', 'finance', 'commodity', 'happy']; +const VARIANT_FILES = ['full', 'tech', 'finance', 'commodity', 'happy', 'sports']; +const ALLOWED_SIMILAR_KEYS = new Set([ + 'ai-regulation::fin-regulation', + 'baseball::basketball', +]); function parsePanelKeys(variant) { const src = readFileSync(resolve(__dirname, '../src/config/panels.ts'), 'utf-8'); @@ -97,16 +101,14 @@ describe('panel-config guardrails', () => { } const keys = [...allKeys.keys()]; - const allowedPairs = new Set([ - 'ai-regulation|fin-regulation', - 'fin-regulation|ai-regulation', - ]); const typos = []; for (let i = 0; i < keys.length; i++) { for (let j = i + 1; j < keys.length; j++) { const minLen = Math.min(keys[i].length, keys[j].length); if (minLen < 5) continue; - if (levenshtein(keys[i], keys[j]) <= 2 && keys[i] !== keys[j] && !allowedPairs.has(`${keys[i]}|${keys[j]}`)) { + const pair = [keys[i], keys[j]].sort().join('::'); + if (ALLOWED_SIMILAR_KEYS.has(pair)) continue; + if (levenshtein(keys[i], keys[j]) <= 2 && keys[i] !== keys[j]) { typos.push(`"${keys[i]}" ↔ "${keys[j]}"`); } } diff --git a/tests/smart-poll-loop.test.mjs b/tests/smart-poll-loop.test.mjs index 91e175987a..9d4e61e0c1 100644 --- a/tests/smart-poll-loop.test.mjs +++ b/tests/smart-poll-loop.test.mjs @@ -537,7 +537,7 @@ describe('startSmartPollLoop', () => { it('abort errors do not trigger backoff', async () => { let calls = 0; - startSmartPollLoop((ctx) => { + startSmartPollLoop((_ctx) => { calls++; const err = new Error('aborted'); err.name = 'AbortError'; @@ -556,7 +556,7 @@ describe('startSmartPollLoop', () => { describe('in-flight guard', () => { it('concurrent calls are deferred, not dropped', async () => { let calls = 0; - let resolvers = []; + const resolvers = []; const handle = startSmartPollLoop(() => { calls++; return new Promise(r => resolvers.push(r)); @@ -590,7 +590,7 @@ describe('startSmartPollLoop', () => { let subscribeCalls = 0; let unsubscribeCalls = 0; const fakeHub = { - subscribe(cb) { + subscribe(_cb) { subscribeCalls++; return () => { unsubscribeCalls++; }; }, diff --git a/tests/sports-data-proxy-guardrail.test.mjs b/tests/sports-data-proxy-guardrail.test.mjs new file mode 100644 index 0000000000..77ffee298b --- /dev/null +++ b/tests/sports-data-proxy-guardrail.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { describe, it } from 'node:test'; +import { createSportsDataProviders } from '../api/_sports-data-config.js'; +import sportsDataHandler from '../api/sports-data.js'; + +const viteSrc = readFileSync(new URL('../vite.config.ts', import.meta.url), 'utf8'); +const edgeSrc = readFileSync(new URL('../api/sports-data.js', import.meta.url), 'utf8'); +const providers = createSportsDataProviders(); + +describe('sports data proxy guardrail (dev parity)', () => { + it('loads provider allowlist from the shared config in both dev and edge handlers', () => { + assert.match(viteSrc, /_sports-data-config\.js/); + assert.match(edgeSrc, /_sports-data-config\.js/); + }); + + it('allows TheSportsDB fixture, event details, and venue lookup routes', () => { + const thesportsdb = providers.thesportsdb; + assert.ok(thesportsdb.endpoints.has('/eventsday.php')); + assert.deepEqual([...thesportsdb.allowedParams['/eventsday.php']], ['d', 's']); + assert.ok(thesportsdb.endpoints.has('/lookupevent.php')); + assert.deepEqual([...thesportsdb.allowedParams['/lookupevent.php']], ['id']); + assert.ok(thesportsdb.endpoints.has('/searchvenues.php')); + assert.deepEqual([...thesportsdb.allowedParams['/searchvenues.php']], ['v']); + }); + + it('allows ESPN scoreboard date filters used by daily fixture loading', () => { + const espnsite = providers.espnsite; + assert.deepEqual([...espnsite.allowedParams['/soccer/eng.1/scoreboard']], ['dates']); + assert.deepEqual([...espnsite.allowedParams['/basketball/nba/scoreboard']], ['dates']); + assert.deepEqual([...espnsite.allowedParams['/hockey/nhl/scoreboard']], ['dates']); + assert.deepEqual([...espnsite.allowedParams['/baseball/mlb/scoreboard']], ['dates']); + assert.deepEqual([...espnsite.allowedParams['/football/nfl/scoreboard']], ['dates']); + }); + + it('allows ESPN NBA standings JSON endpoint with no query params', () => { + const espnsite = providers.espnsite; + assert.ok(espnsite.endpoints.has('/basketball/nba/standings')); + assert.deepEqual([...espnsite.allowedParams['/basketball/nba/standings']], []); + }); + + it('returns 400 for invalid providers in the edge proxy', async () => { + const request = new Request('https://worldmonitor.app/api/sports-data?provider=invalid&path=/all_leagues.php'); + const response = await sportsDataHandler(request); + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: 'Invalid sports provider' }); + }); + + it('passes TheSportsDB lookupevent route through the edge proxy', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response('{"ok":true}', { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + try { + const request = new Request('https://worldmonitor.app/api/sports-data?provider=thesportsdb&path=/lookupevent.php?id=123'); + const response = await sportsDataHandler(request); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ok: true }); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/tests/sports-digest-guardrails.test.mjs b/tests/sports-digest-guardrails.test.mjs new file mode 100644 index 0000000000..853aea56ca --- /dev/null +++ b/tests/sports-digest-guardrails.test.mjs @@ -0,0 +1,53 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const digestSrc = readFileSync( + resolve(__dirname, '../server/worldmonitor/news/v1/list-feed-digest.ts'), + 'utf-8', +); +const feedsSrc = readFileSync( + resolve(__dirname, '../server/worldmonitor/news/v1/_feeds.ts'), + 'utf-8', +); +const classifierSrc = readFileSync( + resolve(__dirname, '../server/worldmonitor/news/v1/_classifier.ts'), + 'utf-8', +); +const relaySrc = readFileSync( + resolve(__dirname, '../scripts/ais-relay.cjs'), + 'utf-8', +); + +describe('sports digest guardrails', () => { + it('accepts sports as a valid digest variant', () => { + assert.match( + digestSrc, + /VALID_VARIANTS\s*=\s*new Set\(\['full', 'tech', 'finance', 'happy', 'commodity', 'sports'\]\)/, + ); + }); + + it('defines a dedicated sports feed registry on the server', () => { + assert.match(feedsSrc, /\bsports:\s*\{/); + for (const category of ['sports', 'soccer', 'basketball', 'baseball', 'motorsport', 'tennis', 'combat']) { + assert.match(feedsSrc, new RegExp(`\\b${category}:\\s*\\[`), `missing sports category ${category}`); + } + }); + + it('keeps server-side sports digest headlines on the low-signal classification path', () => { + assert.match( + classifierSrc, + /if \(variant === 'sports'\)\s*\{\s*return \{ level: 'info', category: 'general', confidence: 0\.15, source: 'keyword' \};\s*\}/, + ); + }); + + it('does not add sports to the relay threat classification seeder', () => { + assert.match( + relaySrc, + /CLASSIFY_VARIANTS = \['full', 'tech', 'finance', 'happy', 'commodity'\]/, + ); + }); +}); diff --git a/tests/sports-headline-filter.test.mts b/tests/sports-headline-filter.test.mts new file mode 100644 index 0000000000..cd3ae79379 --- /dev/null +++ b/tests/sports-headline-filter.test.mts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import type { NewsItem } from '@/types'; +import { filterSportsHeadlineNoise, isOffTopicSportsPoliticalHeadline } from '@/services/sports-headline-filter'; + +function makeItem(title: string, source = 'Reuters Sports'): NewsItem { + return { + source, + title, + link: 'https://example.com/story', + pubDate: new Date('2026-04-13T08:00:00Z'), + isAlert: false, + threat: { + level: 'low', + category: 'general', + confidence: 0.3, + source: 'keyword', + }, + }; +} + +describe('sports headline noise filter', () => { + it('flags political headlines that lack sports context', () => { + const item = makeItem('Election campaign enters final week as parliament debates coalition plan'); + assert.equal(isOffTopicSportsPoliticalHeadline(item), true); + }); + + it('keeps sports headlines even when political words appear', () => { + const item = makeItem('FIFA president confirms World Cup qualifying format'); + assert.equal(isOffTopicSportsPoliticalHeadline(item), false); + }); + + it('removes only off-topic political items', () => { + const items = [ + makeItem('NBA playoff bracket tightens after dramatic game seven'), + makeItem('Government unveils new election policy ahead of national vote'), + makeItem('Champions League quarter-final draw sets up major clash'), + ]; + + const filtered = filterSportsHeadlineNoise(items); + assert.equal(filtered.length, 2); + assert.ok(filtered.every((item) => !item.title.includes('Government unveils'))); + }); +}); diff --git a/tests/sports-layer-startup-guardrail.test.mjs b/tests/sports-layer-startup-guardrail.test.mjs new file mode 100644 index 0000000000..8797687eea --- /dev/null +++ b/tests/sports-layer-startup-guardrail.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { describe, it } from 'node:test'; + +const appSrc = readFileSync(new URL('../src/App.ts', import.meta.url), 'utf8'); +const sportsVariantSrc = readFileSync(new URL('../src/config/variants/sports.ts', import.meta.url), 'utf8'); + +describe('sports layer startup guardrail', () => { + it('sports variant keeps fixtures enabled by default in variant config', () => { + assert.match( + sportsVariantSrc, + /sportsFixtures:\s*true/, + 'sports variant defaults should enable sportsFixtures for new sessions', + ); + }); + + it('App startup does not force-enable sportsFixtures over stored or URL layer state', () => { + assert.doesNotMatch( + appSrc, + /mapLayers\.sportsFixtures\s*=\s*true/, + 'App startup should not forcibly overwrite sportsFixtures layer state', + ); + }); +}); diff --git a/tests/sports-map-fixtures.test.mts b/tests/sports-map-fixtures.test.mts new file mode 100644 index 0000000000..b91397f937 --- /dev/null +++ b/tests/sports-map-fixtures.test.mts @@ -0,0 +1,472 @@ +import assert from 'node:assert/strict'; +import { afterEach, describe, it } from 'node:test'; + +import { + buildSportsFixtureAggregateMarker, + fetchFeaturedSportsFixtures, + fetchSportsFixtureMapMarkers, + resetSportsServiceCacheForTests, +} from '../src/services/sports.ts'; + +const originalFetch = globalThis.fetch; +const originalDate = globalThis.Date; + +afterEach(() => { + globalThis.fetch = originalFetch; + globalThis.Date = originalDate; + resetSportsServiceCacheForTests(); +}); + +describe('fetchSportsFixtureMapMarkers', () => { + it('uses ESPN scoreboard data when the daily fixture feed is otherwise empty', async () => { + const requested: Array<{ provider: string; path: string }> = []; + + globalThis.fetch = (async (input: RequestInfo | URL) => { + const rawUrl = typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + const url = new URL(rawUrl, 'https://worldmonitor.test'); + const provider = url.searchParams.get('provider') || ''; + const path = decodeURIComponent(url.searchParams.get('path') || ''); + + requested.push({ provider, path }); + + if (provider === 'thesportsdb') { + return new Response(JSON.stringify({ events: [], results: [] }), { status: 200 }); + } + + if (provider === 'espnsite' && path === '/soccer/eng.1/scoreboard') { + return new Response(JSON.stringify({ + leagues: [{ season: { displayName: '2025-26' } }], + events: [ + { + id: 'espn-epl-1', + name: 'Arsenal vs Chelsea', + date: '2026-04-11T19:00:00Z', + competitions: [ + { + date: '2026-04-11T19:00:00Z', + competitors: [ + { + homeAway: 'home', + score: '0', + team: { + displayName: 'Arsenal', + shortDisplayName: 'ARS', + logos: [{ href: 'https://example.com/arsenal.png' }], + }, + }, + { + homeAway: 'away', + score: '0', + team: { + displayName: 'Chelsea', + shortDisplayName: 'CHE', + logos: [{ href: 'https://example.com/chelsea.png' }], + }, + }, + ], + venue: { + fullName: 'Emirates Stadium', + address: { + city: 'London', + country: 'England', + }, + latitude: 51.555, + longitude: -0.1086, + }, + status: { + type: { + description: 'Scheduled', + detail: 'Sat, 7:00 PM', + }, + }, + }, + ], + week: { text: 'Matchday 32' }, + seasonType: { name: 'Regular Season' }, + }, + ], + }), { status: 200 }); + } + + if (provider === 'espnsite') { + return new Response(JSON.stringify({ + leagues: [{ season: { displayName: '2025-26' } }], + events: [], + }), { status: 200 }); + } + + if (provider === 'jolpica') { + return new Response(JSON.stringify({ MRData: { RaceTable: { Races: [] } } }), { status: 200 }); + } + + throw new Error(`Unexpected request: ${provider} ${path}`); + }) as typeof fetch; + + const markers = await fetchSportsFixtureMapMarkers(); + + assert.equal(markers.length, 1); + assert.equal(markers[0]?.eventId, 'espn-epl-1'); + assert.equal(markers[0]?.venue, 'Emirates Stadium'); + assert.equal(markers[0]?.sport, 'Soccer'); + assert.equal(markers[0]?.lat, 51.555); + assert.equal(markers[0]?.lng, -0.1086); + assert.ok(requested.some((request) => request.provider === 'thesportsdb')); + assert.ok(requested.some((request) => request.provider === 'espnsite' && request.path === '/soccer/eng.1/scoreboard')); + }); + + it('uses the local calendar day when requesting daily fixtures', async () => { + const requested: Array<{ provider: string; path: string }> = []; + // Use local constructor args so the mocked "calendar day" stays stable across CI timezones. + const fixedNow = new originalDate(2026, 3, 11, 0, 30, 0, 0).valueOf(); + + class MockDate extends originalDate { + constructor(...args: any[]) { + super(...(args.length > 0 ? args : [fixedNow])); + } + + static now(): number { + return fixedNow; + } + } + + globalThis.Date = MockDate as DateConstructor; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const rawUrl = typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + const url = new URL(rawUrl, 'https://worldmonitor.test'); + const provider = url.searchParams.get('provider') || ''; + const path = decodeURIComponent(url.searchParams.get('path') || ''); + + requested.push({ provider, path }); + return new Response(JSON.stringify({ events: [], results: [], leagues: [] }), { status: 200 }); + }) as typeof fetch; + + const groups = await fetchFeaturedSportsFixtures(); + + assert.equal(groups.length, 0); + assert.ok(requested.some((request) => request.provider === 'thesportsdb' && request.path === '/eventsday.php?d=2026-04-10&s=Soccer')); + assert.ok(requested.some((request) => request.provider === 'thesportsdb' && request.path === '/eventsday.php?d=2026-04-11&s=Soccer')); + assert.ok(requested.some((request) => request.provider === 'thesportsdb' && request.path === '/eventsday.php?d=2026-04-12&s=Soccer')); + assert.ok(requested.some((request) => request.provider === 'espnsite' && request.path === '/soccer/eng.1/scoreboard?dates=20260411')); + assert.ok(requested.some((request) => request.provider === 'espnsite' && request.path === '/soccer/eng.1/scoreboard')); + }); + + it('groups same-league fixtures into a single hub marker with the full schedule', async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const rawUrl = typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + const url = new URL(rawUrl, 'https://worldmonitor.test'); + const provider = url.searchParams.get('provider') || ''; + const path = decodeURIComponent(url.searchParams.get('path') || ''); + + if (provider === 'thesportsdb') { + return new Response(JSON.stringify({ events: [], results: [] }), { status: 200 }); + } + + if (provider === 'espnsite' && path === '/soccer/eng.1/scoreboard') { + return new Response(JSON.stringify({ + leagues: [{ season: { displayName: '2025-26' } }], + events: [ + { + id: 'espn-epl-1', + name: 'Arsenal vs Chelsea', + date: '2026-04-11T12:30:00Z', + competitions: [{ + date: '2026-04-11T12:30:00Z', + competitors: [ + { homeAway: 'home', score: '0', team: { displayName: 'Arsenal' } }, + { homeAway: 'away', score: '0', team: { displayName: 'Chelsea' } }, + ], + venue: { + fullName: 'Emirates Stadium', + address: { city: 'London', country: 'England' }, + latitude: 51.555, + longitude: -0.1086, + }, + status: { type: { description: 'Scheduled', detail: 'Sat, 12:30 PM' } }, + }], + }, + { + id: 'espn-epl-2', + name: 'Tottenham vs Liverpool', + date: '2026-04-11T15:00:00Z', + competitions: [{ + date: '2026-04-11T15:00:00Z', + competitors: [ + { homeAway: 'home', score: '0', team: { displayName: 'Tottenham' } }, + { homeAway: 'away', score: '0', team: { displayName: 'Liverpool' } }, + ], + venue: { + fullName: 'Emirates Stadium', + address: { city: 'London', country: 'England' }, + latitude: 51.555, + longitude: -0.1086, + }, + status: { type: { description: 'Scheduled', detail: 'Sat, 3:00 PM' } }, + }], + }, + ], + }), { status: 200 }); + } + + if (provider === 'espnsite') { + return new Response(JSON.stringify({ + leagues: [{ season: { displayName: '2025-26' } }], + events: [], + }), { status: 200 }); + } + + if (provider === 'jolpica') { + return new Response(JSON.stringify({ MRData: { RaceTable: { Races: [] } } }), { status: 200 }); + } + + throw new Error(`Unexpected request: ${provider} ${path}`); + }) as typeof fetch; + + const markers = await fetchSportsFixtureMapMarkers(); + + assert.equal(markers.length, 1); + assert.equal(markers[0]?.fixtureCount, 2); + assert.equal(markers[0]?.venue, 'Emirates Stadium'); + assert.equal(markers[0]?.fixtures?.length, 2); + assert.equal(markers[0]?.fixtures?.[0]?.eventId, 'espn-epl-1'); + assert.equal(markers[0]?.fixtures?.[1]?.eventId, 'espn-epl-2'); + }); + + it('supplements motorsport fixtures from Jolpica when the event lands on the local day', async () => { + // Keep this anchored to local noon regardless of runner timezone. + const fixedNow = new originalDate(2026, 3, 11, 12, 0, 0, 0).valueOf(); + + class MockDate extends originalDate { + constructor(...args: any[]) { + super(...(args.length > 0 ? args : [fixedNow])); + } + + static now(): number { + return fixedNow; + } + } + + globalThis.Date = MockDate as DateConstructor; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const rawUrl = typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + const url = new URL(rawUrl, 'https://worldmonitor.test'); + const provider = url.searchParams.get('provider') || ''; + const path = decodeURIComponent(url.searchParams.get('path') || ''); + + if (provider === 'jolpica' && path === '/ergast/f1/current/next.json') { + return new Response(JSON.stringify({ + MRData: { + RaceTable: { + Races: [{ + raceName: 'Bahrain Grand Prix', + round: '4', + date: '2026-04-11', + time: '15:00:00Z', + Circuit: { + circuitName: 'Bahrain International Circuit', + Location: { + locality: 'Sakhir', + country: 'Bahrain', + lat: '26.0325', + long: '50.5106', + }, + }, + }], + }, + }, + }), { status: 200 }); + } + + return new Response(JSON.stringify({ events: [], results: [], leagues: [] }), { status: 200 }); + }) as typeof fetch; + + const markers = await fetchSportsFixtureMapMarkers(); + + assert.ok(markers.some((marker) => marker.eventId === 'jolpica-f1-4')); + const f1Marker = markers.find((marker) => marker.eventId === 'jolpica-f1-4'); + assert.equal(f1Marker?.venue, 'Bahrain International Circuit'); + assert.equal(f1Marker?.sport, 'Motorsport'); + assert.equal(f1Marker?.lat, 26.0325); + assert.equal(f1Marker?.lng, 50.5106); + }); + + it('keeps all fallback football fixtures on a single per-league marker when the generic scoreboard is used', async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const rawUrl = typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + const url = new URL(rawUrl, 'https://worldmonitor.test'); + const provider = url.searchParams.get('provider') || ''; + const path = decodeURIComponent(url.searchParams.get('path') || ''); + + if (provider === 'thesportsdb') { + return new Response(JSON.stringify({ events: [], results: [] }), { status: 200 }); + } + + if (provider === 'jolpica') { + return new Response(JSON.stringify({ MRData: { RaceTable: { Races: [] } } }), { status: 200 }); + } + + if (provider === 'espnsite' && path === '/soccer/eng.1/scoreboard?dates=20260411') { + return new Response(JSON.stringify({ + leagues: [{ season: { displayName: '2025-26' } }], + events: [], + }), { status: 200 }); + } + + if (provider === 'espnsite' && path === '/soccer/eng.1/scoreboard') { + return new Response(JSON.stringify({ + leagues: [{ season: { displayName: '2025-26' } }], + events: [ + { + id: 'fb-1', + name: 'A vs B', + date: '2026-04-12T10:00:00Z', + competitions: [{ date: '2026-04-12T10:00:00Z', competitors: [], venue: { fullName: 'Venue 1', latitude: 10, longitude: 10 } }], + }, + { + id: 'fb-2', + name: 'C vs D', + date: '2026-04-12T12:00:00Z', + competitions: [{ date: '2026-04-12T12:00:00Z', competitors: [], venue: { fullName: 'Venue 2', latitude: 11, longitude: 11 } }], + }, + { + id: 'fb-3', + name: 'E vs F', + date: '2026-04-12T14:00:00Z', + competitions: [{ date: '2026-04-12T14:00:00Z', competitors: [], venue: { fullName: 'Venue 3', latitude: 12, longitude: 12 } }], + }, + { + id: 'fb-4', + name: 'G vs H', + date: '2026-04-12T16:00:00Z', + competitions: [{ date: '2026-04-12T16:00:00Z', competitors: [], venue: { fullName: 'Venue 4', latitude: 13, longitude: 13 } }], + }, + ], + }), { status: 200 }); + } + + if (provider === 'espnsite') { + return new Response(JSON.stringify({ + leagues: [{ season: { displayName: '2025-26' } }], + events: [], + }), { status: 200 }); + } + + throw new Error(`Unexpected request: ${provider} ${path}`); + }) as typeof fetch; + + const markers = await fetchSportsFixtureMapMarkers(); + const footballMarkers = markers.filter((marker) => marker.eventId.startsWith('fb-')); + + assert.equal(footballMarkers.length, 1); + assert.equal(footballMarkers[0]?.fixtureCount, 4); + assert.equal(footballMarkers[0]?.fixtures?.length, 4); + assert.equal(footballMarkers[0]?.fixtures?.[0]?.eventId, 'fb-1'); + assert.equal(footballMarkers[0]?.fixtures?.[3]?.eventId, 'fb-4'); + }); + + it('flattens nested hub markers when building a regional sports hub', () => { + const aggregate = buildSportsFixtureAggregateMarker([ + { + id: 'hub-1', + eventId: 'hub-1', + leagueName: 'English Premier League', + leagueShortName: 'EPL', + sport: 'Soccer', + title: '2 fixtures', + venue: 'London fixture hub', + venueCity: 'London', + venueCountry: 'England', + startTime: '2026-04-12T10:00:00Z', + startLabel: 'Apr 12, 1:00 PM', + lat: 51.5, + lng: -0.1, + fixtureCount: 2, + competitionCount: 1, + sports: ['Soccer'], + fixtures: [ + { + id: 'sports-fixture:1', + eventId: 'fixture-1', + leagueName: 'English Premier League', + leagueShortName: 'EPL', + sport: 'Soccer', + title: 'Arsenal vs Chelsea', + homeTeam: 'Arsenal', + awayTeam: 'Chelsea', + venue: 'Emirates Stadium', + venueCity: 'London', + venueCountry: 'England', + startTime: '2026-04-12T10:00:00Z', + startLabel: 'Apr 12, 1:00 PM', + lat: 51.555, + lng: -0.1086, + }, + { + id: 'sports-fixture:2', + eventId: 'fixture-2', + leagueName: 'English Premier League', + leagueShortName: 'EPL', + sport: 'Soccer', + title: 'Tottenham vs Liverpool', + homeTeam: 'Tottenham', + awayTeam: 'Liverpool', + venue: 'Tottenham Hotspur Stadium', + venueCity: 'London', + venueCountry: 'England', + startTime: '2026-04-12T12:30:00Z', + startLabel: 'Apr 12, 3:30 PM', + lat: 51.6043, + lng: -0.0664, + }, + ], + }, + { + id: 'sports-fixture:3', + eventId: 'fixture-3', + leagueName: 'NBA', + leagueShortName: 'NBA', + sport: 'Basketball', + title: 'Knicks vs Celtics', + homeTeam: 'Knicks', + awayTeam: 'Celtics', + venue: 'Madison Square Garden', + venueCity: 'New York', + venueCountry: 'United States', + startTime: '2026-04-12T23:00:00Z', + startLabel: 'Apr 13, 2:00 AM', + lat: 40.7505, + lng: -73.9934, + }, + ], { + venue: 'Regional fixture hub', + }); + + assert.equal(aggregate.fixtureCount, 3); + assert.equal(aggregate.fixtures?.length, 3); + assert.equal(aggregate.fixtures?.[0]?.eventId, 'fixture-1'); + assert.equal(aggregate.fixtures?.[1]?.eventId, 'fixture-2'); + assert.equal(aggregate.fixtures?.[2]?.eventId, 'fixture-3'); + assert.deepEqual(aggregate.sports, ['Soccer', 'Basketball']); + assert.equal(aggregate.sport, 'Mixed'); + assert.equal(aggregate.venue, 'Regional fixture hub'); + }); +}); diff --git a/tests/url-sync-initial.test.mts b/tests/url-sync-initial.test.mts index c2063c379e..00d757d87e 100644 --- a/tests/url-sync-initial.test.mts +++ b/tests/url-sync-initial.test.mts @@ -100,7 +100,7 @@ class DeckGLMapStub { } /** Called by the real moveend listener. */ - simulateMoveEnd(finalLat: number, finalLon: number, finalZoom: number): void { + simulateMoveEnd(_finalLat: number, _finalLon: number, finalZoom: number): void { this.pendingCenter = null; this.state.zoom = finalZoom; // (onStateChange?.(this.getState()) would fire here) diff --git a/tests/variant-layer-guardrail.test.mjs b/tests/variant-layer-guardrail.test.mjs index ea2c15978f..bba2e9a298 100644 --- a/tests/variant-layer-guardrail.test.mjs +++ b/tests/variant-layer-guardrail.test.mjs @@ -80,6 +80,7 @@ const VARIANT_DEFAULTS = { finance: { desktop: 'FINANCE_MAP_LAYERS', mobile: 'FINANCE_MOBILE_MAP_LAYERS' }, happy: { desktop: 'HAPPY_MAP_LAYERS', mobile: 'HAPPY_MOBILE_MAP_LAYERS' }, commodity: { desktop: 'COMMODITY_MAP_LAYERS', mobile: 'COMMODITY_MOBILE_MAP_LAYERS' }, + sports: { desktop: 'SPORTS_MAP_LAYERS', mobile: 'SPORTS_MOBILE_MAP_LAYERS' }, }; describe('variant layer guardrail', () => { diff --git a/vite.config.ts b/vite.config.ts index 699409866b..940cf16caf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,7 @@ import { brotliCompress } from 'zlib'; import { promisify } from 'util'; import pkg from './package.json'; import { VARIANT_META, type VariantMeta } from './src/config/variant-meta'; +import { createSportsDataProviders, isSportsProvider } from './api/_sports-data-config.js'; // Env-dependent constants moved inside defineConfig function @@ -510,6 +511,94 @@ function rssProxyPlugin(): Plugin { }; } +function sportsDataProxyPlugin(): Plugin { + const PROVIDERS = createSportsDataProviders(); + + return { + name: 'sports-data-proxy', + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + if (!req.url?.startsWith('/api/sports-data')) { + return next(); + } + + const url = new URL(req.url, 'http://localhost'); + const providerKey = url.searchParams.get('provider') || 'thesportsdb'; + const rawPath = url.searchParams.get('path'); + if (!rawPath) { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'Missing path parameter' })); + return; + } + + if (!isSportsProvider(providerKey)) { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'Invalid sports provider' })); + return; + } + + const provider = PROVIDERS[providerKey]; + + let parsedPath: URL; + try { + parsedPath = new URL(rawPath, 'https://worldmonitor.app'); + } catch { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'Invalid sports path' })); + return; + } + + if (!provider.endpoints.has(parsedPath.pathname)) { + res.statusCode = 403; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'Sports endpoint not allowed' })); + return; + } + + const allowedParams = provider.allowedParams[parsedPath.pathname as keyof typeof provider.allowedParams]; + for (const key of parsedPath.searchParams.keys()) { + if (!allowedParams?.has(key)) { + res.statusCode = 403; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'Sports parameter not allowed' })); + return; + } + } + + const upstreamUrl = `${provider.baseUrl}${parsedPath.pathname}${parsedPath.search}`; + + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 12000); + const response = await fetch(upstreamUrl, { + signal: controller.signal, + headers: { + 'Accept': 'application/json', + 'User-Agent': 'WorldMonitor-Sports-Proxy/1.0', + }, + }); + clearTimeout(timer); + + const data = await response.text(); + res.statusCode = response.status; + res.setHeader('Content-Type', response.headers.get('content-type') || 'application/json'); + res.setHeader('Cache-Control', 'public, max-age=120'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.end(data); + } catch (error: any) { + console.error('[sports-data]', upstreamUrl, error.message); + res.statusCode = error.name === 'AbortError' ? 504 : 502; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: error.name === 'AbortError' ? 'Sports feed timeout' : 'Failed to fetch sports data' })); + } + }); + }, + }; +} + function youtubeLivePlugin(): Plugin { return { name: 'youtube-live', @@ -621,6 +710,7 @@ export default defineConfig(({ mode }) => { htmlVariantPlugin(activeMeta, activeVariant, isDesktopBuild), polymarketPlugin(), rssProxyPlugin(), + sportsDataProxyPlugin(), youtubeLivePlugin(), gpsjamDevPlugin(), sebufApiPlugin(),