From 4a517549218c18b1cf0654b3a630fa8c83fc4f50 Mon Sep 17 00:00:00 2001 From: fayez bast Date: Wed, 8 Apr 2026 06:09:55 +0300 Subject: [PATCH 1/3] feat: add sports variant panels and feeds --- AGENTS.md | 1 + CONTRIBUTING.md | 5 +- api/sports-data.js | 183 +++ package.json | 2 + public/sports/f1/teams/alpine.svg | 7 + public/sports/f1/teams/aston-martin.svg | 7 + public/sports/f1/teams/ferrari.svg | 6 + public/sports/f1/teams/haas.svg | 7 + public/sports/f1/teams/mclaren.svg | 6 + public/sports/f1/teams/mercedes.svg | 6 + public/sports/f1/teams/racing-bulls.svg | 7 + public/sports/f1/teams/red-bull.svg | 8 + public/sports/f1/teams/sauber.svg | 7 + public/sports/f1/teams/williams.svg | 6 + scripts/ais-relay.cjs | 2 + server/_shared/source-tiers.ts | 15 + server/worldmonitor/news/v1/_classifier.ts | 4 + server/worldmonitor/news/v1/_feeds.ts | 41 + .../worldmonitor/news/v1/list-feed-digest.ts | 2 +- src/App.ts | 90 +- src/app/data-loader.ts | 162 +- src/app/panel-layout.ts | 28 +- src/app/search-manager.ts | 6 +- src/components/DeckGLMap.ts | 4 + src/components/Map.ts | 30 +- src/components/Panel.ts | 7 +- src/components/SportsFixturesPanel.ts | 84 + src/components/SportsMajorTournamentsPanel.ts | 183 +++ src/components/SportsMotorsportPanel.ts | 220 +++ src/components/SportsNbaPanel.ts | 192 +++ src/components/SportsPlayerSearchPanel.ts | 382 +++++ src/components/SportsStatsPanel.ts | 80 + src/components/SportsTablesPanel.ts | 417 +++++ src/components/SportsTransferNewsPanel.ts | 128 ++ src/components/index.ts | 8 + src/components/sportsPanelShared.ts | 418 +++++ src/config/feeds.ts | 47 + src/config/index.ts | 3 + src/config/map-layer-definitions.ts | 5 +- src/config/panels.ts | 114 ++ src/config/variant-meta.ts | 19 + src/config/variant.ts | 5 +- src/config/variants/base.ts | 1 + src/config/variants/sports.ts | 99 ++ src/locales/en.json | 9 +- src/services/index.ts | 1 + src/services/runtime.ts | 2 + src/services/sports.ts | 1451 +++++++++++++++++ src/services/threat-classifier.ts | 7 + src/styles/main.css | 5 + tests/edge-functions.test.mjs | 1 + tests/panel-config-guardrails.test.mjs | 14 +- tests/sports-digest-guardrails.test.mjs | 53 + tests/variant-layer-guardrail.test.mjs | 1 + vite.config.ts | 180 ++ 55 files changed, 4678 insertions(+), 100 deletions(-) create mode 100644 api/sports-data.js create mode 100644 public/sports/f1/teams/alpine.svg create mode 100644 public/sports/f1/teams/aston-martin.svg create mode 100644 public/sports/f1/teams/ferrari.svg create mode 100644 public/sports/f1/teams/haas.svg create mode 100644 public/sports/f1/teams/mclaren.svg create mode 100644 public/sports/f1/teams/mercedes.svg create mode 100644 public/sports/f1/teams/racing-bulls.svg create mode 100644 public/sports/f1/teams/red-bull.svg create mode 100644 public/sports/f1/teams/sauber.svg create mode 100644 public/sports/f1/teams/williams.svg create mode 100644 src/components/SportsFixturesPanel.ts create mode 100644 src/components/SportsMajorTournamentsPanel.ts create mode 100644 src/components/SportsMotorsportPanel.ts create mode 100644 src/components/SportsNbaPanel.ts create mode 100644 src/components/SportsPlayerSearchPanel.ts create mode 100644 src/components/SportsStatsPanel.ts create mode 100644 src/components/SportsTablesPanel.ts create mode 100644 src/components/SportsTransferNewsPanel.ts create mode 100644 src/components/sportsPanelShared.ts create mode 100644 src/config/variants/sports.ts create mode 100644 src/services/sports.ts create mode 100644 tests/sports-digest-guardrails.test.mjs 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.js b/api/sports-data.js new file mode 100644 index 0000000000..e652fef83c --- /dev/null +++ b/api/sports-data.js @@ -0,0 +1,183 @@ +import { getCorsHeaders, isDisallowedOrigin } from './_cors.js'; +import { jsonResponse } from './_json-response.js'; + +export const config = { runtime: 'edge' }; + +const REQUEST_TIMEOUT_MS = 12_000; + +const 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, + '/lookupeventstats.php': 10 * 60, + '/searchplayers.php': 30 * 60, + '/lookupplayer.php': 60 * 60, + }), + allowedParams: Object.freeze({ + '/all_leagues.php': new Set(), + '/lookupleague.php': new Set(['id']), + '/search_all_seasons.php': new Set(['id']), + '/lookuptable.php': new Set(['l', 's']), + '/eventslast.php': new Set(['id']), + '/eventsnext.php': new Set(['id']), + '/lookupeventstats.php': new Set(['id']), + '/searchplayers.php': new Set(['p']), + '/lookupplayer.php': new Set(['id']), + }), + }, + espn: { + baseUrl: 'https://www.espn.com', + endpointTtls: Object.freeze({ + '/nba/standings': 5 * 60, + }), + allowedParams: Object.freeze({ + '/nba/standings': new Set(), + }), + }, + 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, + '/basketball/nba/scoreboard': 2 * 60, + '/basketball/nba/summary': 90, + }), + allowedParams: Object.freeze({ + '/soccer/eng.1/scoreboard': new Set(), + '/soccer/eng.1/summary': new Set(['event']), + '/soccer/uefa.champions/scoreboard': new Set(), + '/soccer/uefa.champions/summary': new Set(['event']), + '/soccer/fifa.world/scoreboard': new Set(), + '/soccer/fifa.world/summary': new Set(['event']), + '/soccer/uefa.euro/scoreboard': new Set(), + '/soccer/uefa.euro/summary': new Set(['event']), + '/soccer/conmebol.america/scoreboard': new Set(), + '/soccer/conmebol.america/summary': new Set(['event']), + '/soccer/conmebol.libertadores/scoreboard': new Set(), + '/soccer/conmebol.libertadores/summary': new Set(['event']), + '/basketball/nba/scoreboard': new Set(), + '/basketball/nba/summary': new Set(['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': new Set(), + '/ergast/f1/current/constructorStandings.json': new Set(), + '/ergast/f1/current/last/results.json': new Set(), + '/ergast/f1/current/next.json': new Set(), + }), + }, + openf1: { + baseUrl: 'https://api.openf1.org', + endpointTtls: Object.freeze({ + '/v1/drivers': 6 * 60 * 60, + }), + allowedParams: Object.freeze({ + '/v1/drivers': new Set(['session_key']), + }), + }, +}); + +function resolveSportsRequest(providerKey, rawPath) { + if (!rawPath || typeof rawPath !== 'string') return null; + const provider = PROVIDERS[providerKey] || PROVIDERS.thesportsdb; + + 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'; + 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 e035c8a6ec..b3d0b9bb64 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/ais-relay.cjs b/scripts/ais-relay.cjs index 901293e6af..cb773488fd 100644 --- a/scripts/ais-relay.cjs +++ b/scripts/ais-relay.cjs @@ -3089,6 +3089,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/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 504eb41ecc..835307feb9 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 3ce0ea4bea..b675c05f76 100644 --- a/src/App.ts +++ b/src/App.ts @@ -47,6 +47,14 @@ 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 { SportsFixturesPanel } from '@/components/SportsFixturesPanel'; +import type { SportsTablesPanel } from '@/components/SportsTablesPanel'; +import type { SportsStatsPanel } from '@/components/SportsStatsPanel'; +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 { isDesktopRuntime, waitForSidecarReady } from '@/services/runtime'; import { hasPremiumAccess } from '@/services/panel-gating'; import { BETA_MODE } from '@/config/beta'; @@ -329,6 +337,38 @@ export class App { const panel = this.state.panels['cot-positioning'] as CotPositioningPanel | undefined; if (panel) primeTask('cot-positioning', () => panel.fetchData()); } + if (shouldPrime('sports-fixtures')) { + const panel = this.state.panels['sports-fixtures'] as SportsFixturesPanel | undefined; + if (panel) primeTask('sports-fixtures', () => panel.fetchData()); + } + 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-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-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 (shouldPrimeAny(['markets', 'heatmap', 'commodities', 'crypto', 'energy-complex'])) { primeTask('markets', () => this.dataLoader.loadMarkets()); } @@ -1155,11 +1195,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', @@ -1284,7 +1326,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()); } @@ -1387,6 +1429,48 @@ export class App { REFRESH_INTERVALS.cotPositioning, () => this.isPanelNearViewport('cot-positioning') ); + this.refreshScheduler.scheduleRefresh( + 'sports-fixtures', + () => (this.state.panels['sports-fixtures'] as SportsFixturesPanel).fetchData(), + REFRESH_INTERVALS.sports, + () => this.isPanelNearViewport('sports-fixtures') + ); + 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-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-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 b207546aac..3b3c79fb6f 100644 --- a/src/app/data-loader.ts +++ b/src/app/data-loader.ts @@ -412,6 +412,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); @@ -431,8 +433,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()) }); } @@ -510,77 +512,79 @@ 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 (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 (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 @@ -601,20 +605,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(() => {}); + } } } diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index 59c698fac0..1d768baea8 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -70,6 +70,14 @@ import { CotPositioningPanel, DiseaseOutbreaksPanel, SocialVelocityPanel, + SportsFixturesPanel, + SportsTablesPanel, + SportsStatsPanel, + SportsMajorTournamentsPanel, + SportsNbaPanel, + SportsMotorsportPanel, + SportsTransferNewsPanel, + SportsPlayerSearchPanel, } from '@/components'; import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel'; import { focusInvestmentOnMap } from '@/services/investments-focus'; @@ -353,6 +361,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' : ''} @@ -412,6 +429,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 => ` + `).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..ae0b43b200 --- /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+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://news.google.com/rss/search?q=site:theguardian.com+football+transfer+when:7d&hl=en-US&gl=US&ceid=US:en') }, +]; + +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 1ecf48d9b8..0c10f2209e 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -84,3 +84,11 @@ export * from './ClimateNewsPanel'; export * from './DiseaseOutbreaksPanel'; export * from './SocialVelocityPanel'; export * from './ResilienceWidget'; +export * from './SportsFixturesPanel'; +export * from './SportsTablesPanel'; +export * from './SportsStatsPanel'; +export * from './SportsMajorTournamentsPanel'; +export * from './SportsNbaPanel'; +export * from './SportsMotorsportPanel'; +export * from './SportsTransferNewsPanel'; +export * from './SportsPlayerSearchPanel'; 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/feeds.ts b/src/config/feeds.ts index 4876998744..f35b500a34 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+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+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://news.google.com/rss/search?q=site:theguardian.com+football+when:2d&hl=en-US&gl=US&ceid=US:en') }, + ], + 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+when:2d&hl=en-US&gl=US&ceid=US:en') }, + { name: 'The Athletic NBA', url: rss('https://news.google.com/rss/search?q=(NBA+OR+basketball)+analysis+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+OR+baseball)+trade+OR+playoffs+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+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> = { 'ais', 'economic', 'fires', 'climate', 'resilienceScore', 'natural', 'weather', 'outages', 'sanctions', 'dayNight', ], + sports: [ + 'dayNight', + ], }; const I18N_PREFIX = 'components.deckgl.layers.'; diff --git a/src/config/panels.ts b/src/config/panels.ts index c40a42157c..3eb8f460cc 100644 --- a/src/config/panels.ts +++ b/src/config/panels.ts @@ -720,6 +720,91 @@ 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-fixtures': { name: 'Upcoming Fixtures', 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-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, + 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) // ============================================ @@ -884,6 +969,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, @@ -896,6 +982,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), }; @@ -919,6 +1006,17 @@ 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 73500dd454..b2a076b2d3 100644 --- a/src/config/variants/base.ts +++ b/src/config/variants/base.ts @@ -60,6 +60,7 @@ export const REFRESH_INTERVALS = { earningsCalendar: 60 * 60 * 1000, economicCalendar: 60 * 60 * 1000, cotPositioning: 60 * 60 * 1000, + sports: 15 * 60 * 1000, }; // Monitor colors - shared diff --git a/src/config/variants/sports.ts b/src/config/variants/sports.ts new file mode 100644 index 0000000000..96ed2c4e06 --- /dev/null +++ b/src/config/variants/sports.ts @@ -0,0 +1,99 @@ +// 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-fixtures': { name: 'Upcoming Fixtures', 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-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, + 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/locales/en.json b/src/locales/en.json index 12ab16b16e..4d21cc55a6 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", 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.ts b/src/services/sports.ts new file mode 100644 index 0000000000..c907080269 --- /dev/null +++ b/src/services/sports.ts @@ -0,0 +1,1451 @@ +import { toApiUrl } from '@/services/runtime'; + +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; + 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; + strRound?: string; + strTimestamp?: string; + dateEvent?: string; + strTime?: string; + intHomeScore?: string; + intAwayScore?: string; +} + +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 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; + 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; + +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 }, +]; + +type FeaturedLeagueSpec = { + label: string; + sport?: string; + aliases: string[]; +}; + +const MOTORSPORT_SPECS: FeaturedLeagueSpec[] = [ + { label: 'Formula 1', sport: 'Motorsport', aliases: ['formula 1', 'f1'] }, + { label: 'MotoGP', sport: 'Motorsport', aliases: ['motogp'] }, + { label: 'IndyCar', sport: 'Motorsport', aliases: ['indycar'] }, + { label: 'NASCAR Cup Series', sport: 'Motorsport', aliases: ['nascar cup series', 'nascar'] }, +]; + +export const NBA_LEAGUE_ID = '4387'; + +type EspnCompetitionSpec = { + id: string; + sport: SportsLeague['sport']; + sportPath: 'soccer' | 'basketball'; + leaguePath: string; + name: string; + shortName: string; + country?: string; +}; + +const ESPN_FIXTURE_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: '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' }, +]; + +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' }, +]; + +type CacheEntry = { + expiresAt: number; + value: T; +}; + +type SportsDataProvider = 'thesportsdb' | 'espn' | 'espnsite' | 'jolpica' | 'openf1'; + +const responseCache = new Map>(); +const inFlight = new Map>(); + +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 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), + 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 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, + 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, + }; +} + +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 buildEspnSiteScoreboardPath(spec: EspnCompetitionSpec): string { + return `/${spec.sportPath}/${spec.leaguePath}/scoreboard`; +} + +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 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), + 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), + }; +} + +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 = 3): SportsEvent[] { + const upcoming = events.filter((event) => { + const status = (event.strStatus || '').toLowerCase(); + return !status || status.includes('scheduled') || status.includes('not started') || status.includes('time tbd'); + }); + return sortEventsAscending(upcoming).slice(0, limit); +} + +async function fetchEspnCompetitionEvents(spec: EspnCompetitionSpec): Promise { + const payload = await fetchEspnSiteJson>(buildEspnSiteScoreboardPath(spec), 5 * 60 * 1000); + 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 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); +} + +export async function fetchFeaturedSportsFixtures(): Promise { + const responses = await Promise.all( + ESPN_FIXTURE_COMPETITIONS.map(async (spec) => { + const league = mapEspnCompetitionLeague(spec); + const events = pickEspnUpcomingEvents(await fetchEspnCompetitionEvents(spec).catch(() => []), 3); + return { + league, + events, + }; + }), + ); + + return responses.filter((group) => group.events.length > 0); +} + +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); +} + +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); +} + +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 ?? '', + }); + } + return stats; +} + +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 = await fetchEspnCompetitionEvents(spec); + 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, + }; +} + +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 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 { + 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 constructor = 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(constructor?.name); + if (!position || !name) return null; + + return { + rank: position, + name, + code: toOptionalString(driver?.code), + team: driver ? (toOptionalString(constructor?.name) || toOptionalString(constructors[0]?.name)) : undefined, + driverNumber: toOptionalString(driver?.permanentNumber), + points: toInteger(raw.points), + wins: toInteger(raw.wins), + nationality: toOptionalString(driver?.nationality) || toOptionalString(constructor?.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), + 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 b494047674..eaf90c0fb8 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -1242,6 +1242,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/tests/edge-functions.test.mjs b/tests/edge-functions.test.mjs index 426076ccee..255c18f243 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 5b182e169e..b903477ae9 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'); @@ -96,16 +100,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/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/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..f9f9719994 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -510,6 +510,185 @@ function rssProxyPlugin(): Plugin { }; } +function sportsDataProxyPlugin(): Plugin { + const PROVIDERS = { + thesportsdb: { + baseUrl: 'https://www.thesportsdb.com/api/v1/json/123', + endpoints: new Set([ + '/all_leagues.php', + '/lookupleague.php', + '/search_all_seasons.php', + '/lookuptable.php', + '/eventslast.php', + '/eventsnext.php', + '/lookupeventstats.php', + '/searchplayers.php', + '/lookupplayer.php', + ]), + allowedParams: { + '/all_leagues.php': new Set(), + '/lookupleague.php': new Set(['id']), + '/search_all_seasons.php': new Set(['id']), + '/lookuptable.php': new Set(['l', 's']), + '/eventslast.php': new Set(['id']), + '/eventsnext.php': new Set(['id']), + '/lookupeventstats.php': new Set(['id']), + '/searchplayers.php': new Set(['p']), + '/lookupplayer.php': new Set(['id']), + }, + }, + espn: { + baseUrl: 'https://www.espn.com', + endpoints: new Set(['/nba/standings']), + allowedParams: { + '/nba/standings': new Set(), + }, + }, + espnsite: { + baseUrl: 'https://site.api.espn.com/apis/site/v2/sports', + endpoints: new Set([ + '/soccer/eng.1/scoreboard', + '/soccer/eng.1/summary', + '/soccer/uefa.champions/scoreboard', + '/soccer/uefa.champions/summary', + '/soccer/fifa.world/scoreboard', + '/soccer/fifa.world/summary', + '/soccer/uefa.euro/scoreboard', + '/soccer/uefa.euro/summary', + '/soccer/conmebol.america/scoreboard', + '/soccer/conmebol.america/summary', + '/soccer/conmebol.libertadores/scoreboard', + '/soccer/conmebol.libertadores/summary', + '/basketball/nba/scoreboard', + '/basketball/nba/summary', + ]), + allowedParams: { + '/soccer/eng.1/scoreboard': new Set(), + '/soccer/eng.1/summary': new Set(['event']), + '/soccer/uefa.champions/scoreboard': new Set(), + '/soccer/uefa.champions/summary': new Set(['event']), + '/soccer/fifa.world/scoreboard': new Set(), + '/soccer/fifa.world/summary': new Set(['event']), + '/soccer/uefa.euro/scoreboard': new Set(), + '/soccer/uefa.euro/summary': new Set(['event']), + '/soccer/conmebol.america/scoreboard': new Set(), + '/soccer/conmebol.america/summary': new Set(['event']), + '/soccer/conmebol.libertadores/scoreboard': new Set(), + '/soccer/conmebol.libertadores/summary': new Set(['event']), + '/basketball/nba/scoreboard': new Set(), + '/basketball/nba/summary': new Set(['event']), + }, + }, + jolpica: { + baseUrl: 'https://api.jolpi.ca', + endpoints: new Set([ + '/ergast/f1/current/driverStandings.json', + '/ergast/f1/current/constructorStandings.json', + '/ergast/f1/current/last/results.json', + '/ergast/f1/current/next.json', + ]), + allowedParams: { + '/ergast/f1/current/driverStandings.json': new Set(), + '/ergast/f1/current/constructorStandings.json': new Set(), + '/ergast/f1/current/last/results.json': new Set(), + '/ergast/f1/current/next.json': new Set(), + }, + }, + openf1: { + baseUrl: 'https://api.openf1.org', + endpoints: new Set([ + '/v1/drivers', + ]), + allowedParams: { + '/v1/drivers': new Set(['session_key']), + }, + }, + } as const; + + 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') as keyof typeof PROVIDERS; + 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; + } + + const provider = PROVIDERS[providerKey]; + if (!provider) { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'Invalid sports provider' })); + return; + } + + 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 +800,7 @@ export default defineConfig(({ mode }) => { htmlVariantPlugin(activeMeta, activeVariant, isDesktopBuild), polymarketPlugin(), rssProxyPlugin(), + sportsDataProxyPlugin(), youtubeLivePlugin(), gpsjamDevPlugin(), sebufApiPlugin(), From 5e1c296fd36a1e9ad294b823e504dc42f8b5afc5 Mon Sep 17 00:00:00 2001 From: fayez bast Date: Mon, 13 Apr 2026 09:00:10 +0300 Subject: [PATCH 2/3] feat(sports): add live tracker and analysis panels, update sports map wiring --- api/_sports-data-config.js | 202 ++ api/enrichment/company.js | 2 +- api/mcp-proxy.js | 27 +- api/sports-data.js | 105 +- scripts/_proxy-utils.cjs | 2 +- scripts/backfill-fuel-prices-prev.mjs | 4 +- scripts/build-country-names.cjs | 2 +- src/App.ts | 191 +- src/app/data-loader.ts | 39 +- src/app/panel-layout.ts | 10 +- src/components/DeckGLMap.ts | 419 +++- src/components/GlobeMap.ts | 52 + src/components/Map.ts | 112 +- src/components/MapContainer.ts | 14 + src/components/MapPopup.ts | 205 +- .../SportsEuropeanFootballAnalysisPanel.ts | 364 ++++ src/components/SportsFixturesPanel.ts | 26 +- src/components/SportsLiveTrackerPanel.ts | 495 +++++ .../SportsMotorsportAnalysisPanel.ts | 262 +++ src/components/SportsNbaAnalysisPanel.ts | 341 ++++ src/components/SportsTransferNewsPanel.ts | 4 +- src/components/index.ts | 5 +- src/components/sportsAnalysisShared.ts | 235 +++ src/config/commands.ts | 1 + src/config/feeds.ts | 14 +- src/config/map-layer-definitions.ts | 3 + src/config/panels.ts | 23 +- src/config/variant-meta.ts | 4 +- src/config/variants/commodity.ts | 2 + src/config/variants/finance.ts | 2 + src/config/variants/full.ts | 2 + src/config/variants/happy.ts | 2 + src/config/variants/sports.ts | 6 +- src/config/variants/tech.ts | 2 + src/e2e/map-harness.ts | 2 + src/e2e/mobile-map-integration-harness.ts | 1 + src/locales/en.json | 1 + src/services/i18n.ts | 23 +- src/services/sports-headline-filter.ts | 89 + src/services/sports.ts | 1759 ++++++++++++++++- src/types/index.ts | 1 + src/utils/proxy.ts | 12 +- tests/smart-poll-loop.test.mjs | 6 +- tests/sports-data-proxy-guardrail.test.mjs | 64 + tests/sports-headline-filter.test.mts | 44 + tests/sports-layer-startup-guardrail.test.mjs | 24 + tests/sports-map-fixtures.test.mts | 470 +++++ tests/url-sync-initial.test.mts | 2 +- vite.config.ts | 102 +- 49 files changed, 5420 insertions(+), 359 deletions(-) create mode 100644 api/_sports-data-config.js create mode 100644 src/components/SportsEuropeanFootballAnalysisPanel.ts create mode 100644 src/components/SportsLiveTrackerPanel.ts create mode 100644 src/components/SportsMotorsportAnalysisPanel.ts create mode 100644 src/components/SportsNbaAnalysisPanel.ts create mode 100644 src/components/sportsAnalysisShared.ts create mode 100644 src/services/sports-headline-filter.ts create mode 100644 tests/sports-data-proxy-guardrail.test.mjs create mode 100644 tests/sports-headline-filter.test.mts create mode 100644 tests/sports-layer-startup-guardrail.test.mjs create mode 100644 tests/sports-map-fixtures.test.mts 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 index e652fef83c..95c4a88285 100644 --- a/api/sports-data.js +++ b/api/sports-data.js @@ -1,109 +1,16 @@ 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 = 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, - '/lookupeventstats.php': 10 * 60, - '/searchplayers.php': 30 * 60, - '/lookupplayer.php': 60 * 60, - }), - allowedParams: Object.freeze({ - '/all_leagues.php': new Set(), - '/lookupleague.php': new Set(['id']), - '/search_all_seasons.php': new Set(['id']), - '/lookuptable.php': new Set(['l', 's']), - '/eventslast.php': new Set(['id']), - '/eventsnext.php': new Set(['id']), - '/lookupeventstats.php': new Set(['id']), - '/searchplayers.php': new Set(['p']), - '/lookupplayer.php': new Set(['id']), - }), - }, - espn: { - baseUrl: 'https://www.espn.com', - endpointTtls: Object.freeze({ - '/nba/standings': 5 * 60, - }), - allowedParams: Object.freeze({ - '/nba/standings': new Set(), - }), - }, - 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, - '/basketball/nba/scoreboard': 2 * 60, - '/basketball/nba/summary': 90, - }), - allowedParams: Object.freeze({ - '/soccer/eng.1/scoreboard': new Set(), - '/soccer/eng.1/summary': new Set(['event']), - '/soccer/uefa.champions/scoreboard': new Set(), - '/soccer/uefa.champions/summary': new Set(['event']), - '/soccer/fifa.world/scoreboard': new Set(), - '/soccer/fifa.world/summary': new Set(['event']), - '/soccer/uefa.euro/scoreboard': new Set(), - '/soccer/uefa.euro/summary': new Set(['event']), - '/soccer/conmebol.america/scoreboard': new Set(), - '/soccer/conmebol.america/summary': new Set(['event']), - '/soccer/conmebol.libertadores/scoreboard': new Set(), - '/soccer/conmebol.libertadores/summary': new Set(['event']), - '/basketball/nba/scoreboard': new Set(), - '/basketball/nba/summary': new Set(['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': new Set(), - '/ergast/f1/current/constructorStandings.json': new Set(), - '/ergast/f1/current/last/results.json': new Set(), - '/ergast/f1/current/next.json': new Set(), - }), - }, - openf1: { - baseUrl: 'https://api.openf1.org', - endpointTtls: Object.freeze({ - '/v1/drivers': 6 * 60 * 60, - }), - allowedParams: Object.freeze({ - '/v1/drivers': new Set(['session_key']), - }), - }, -}); +const PROVIDERS = createSportsDataProviders(); function resolveSportsRequest(providerKey, rawPath) { if (!rawPath || typeof rawPath !== 'string') return null; - const provider = PROVIDERS[providerKey] || PROVIDERS.thesportsdb; + const provider = PROVIDERS[providerKey]; + if (!provider) return null; let parsed; try { @@ -143,6 +50,10 @@ export default async function handler(req) { 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) { 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/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/src/App.ts b/src/App.ts index 2742774a47..b8673d0596 100644 --- a/src/App.ts +++ b/src/App.ts @@ -49,15 +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 { SportsFixturesPanel } from '@/components/SportsFixturesPanel'; 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'; @@ -348,37 +351,51 @@ export class App { const panel = this.state.panels['cot-positioning'] as CotPositioningPanel | undefined; if (panel) primeTask('cot-positioning', () => panel.fetchData()); } - if (shouldPrime('sports-fixtures')) { - const panel = this.state.panels['sports-fixtures'] as SportsFixturesPanel | undefined; - if (panel) primeTask('sports-fixtures', () => panel.fetchData()); - } - 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-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-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 (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; @@ -1470,48 +1487,6 @@ export class App { REFRESH_INTERVALS.cotPositioning, () => this.isPanelNearViewport('cot-positioning') ); - this.refreshScheduler.scheduleRefresh( - 'sports-fixtures', - () => (this.state.panels['sports-fixtures'] as SportsFixturesPanel).fetchData(), - REFRESH_INTERVALS.sports, - () => this.isPanelNearViewport('sports-fixtures') - ); - 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-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-transfers', - () => (this.state.panels['sports-transfers'] as SportsTransferNewsPanel).fetchData(), - REFRESH_INTERVALS.sports, - () => this.isPanelNearViewport('sports-transfers') - ); this.refreshScheduler.scheduleRefresh( 'gold-intelligence', () => (this.state.panels['gold-intelligence'] as GoldIntelligencePanel).fetchData(), @@ -1530,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 d010596833..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'; @@ -484,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')) { @@ -681,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; @@ -861,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; } @@ -1911,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 b98f9d78cc..bc23ef228e 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -73,9 +73,9 @@ import { GoldIntelligencePanel, DiseaseOutbreaksPanel, SocialVelocityPanel, - SportsFixturesPanel, SportsTablesPanel, SportsStatsPanel, + SportsLiveTrackerPanel, SportsMajorTournamentsPanel, SportsNbaPanel, SportsMotorsportPanel, @@ -84,6 +84,9 @@ import { WsbTickerScannerPanel, AAIISentimentPanel, EnergyCrisisPanel, + SportsNbaAnalysisPanel, + SportsEuropeanFootballAnalysisPanel, + SportsMotorsportAnalysisPanel, } from '@/components'; import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel'; import { focusInvestmentOnMap } from '@/services/investments-focus'; @@ -1129,12 +1132,15 @@ export class PanelLayoutManager implements AppModule { this.createPanel('earnings-calendar', () => new EarningsCalendarPanel()); this.createPanel('economic-calendar', () => new EconomicCalendarPanel()); this.createPanel('cot-positioning', () => new CotPositioningPanel()); - this.createPanel('sports-fixtures', () => new SportsFixturesPanel()); this.createPanel('sports-tables', () => new SportsTablesPanel()); this.createPanel('sports-stats', () => new SportsStatsPanel()); + this.createPanel('sports-live-tracker', () => new SportsLiveTrackerPanel()); this.createPanel('sports-tournaments', () => new SportsMajorTournamentsPanel()); this.createPanel('sports-nba', () => new SportsNbaPanel()); this.createPanel('sports-motorsport-standings', () => new SportsMotorsportPanel()); + this.createPanel('sports-nba-analysis', () => new SportsNbaAnalysisPanel()); + this.createPanel('sports-football-analysis', () => new SportsEuropeanFootballAnalysisPanel()); + this.createPanel('sports-motorsport-analysis', () => new SportsMotorsportAnalysisPanel()); this.createPanel('sports-transfers', () => new SportsTransferNewsPanel()); this.createPanel('sports-player-search', () => new SportsPlayerSearchPanel()); this.createPanel('gold-intelligence', () => new GoldIntelligencePanel()); diff --git a/src/components/DeckGLMap.ts b/src/components/DeckGLMap.ts index a3259c84f7..b4312aa29f 100644 --- a/src/components/DeckGLMap.ts +++ b/src/components/DeckGLMap.ts @@ -57,6 +57,15 @@ import { HeatmapLayer } from '@deck.gl/aggregation-layers'; import { H3HexagonLayer, TripsLayer } from '@deck.gl/geo-layers'; import { PathStyleExtension } from '@deck.gl/extensions'; import type { WeatherAlert } from '@/services/weather'; +import { + buildSportsFixtureAggregateMarker, + getSportsFixtureDisplayLabel, + getSportsFixtureRenderPriority, + getSportsFixtureSubLabel, + getSportsFixtureVisualMeta, + isSportsFixtureHubMarker, + type SportsFixtureMapMarker, +} from '@/services/sports'; import { escapeHtml } from '@/utils/sanitize'; import { tokenizeForMatch, matchKeyword, matchesAnyKeyword, findMatchingKeywords } from '@/utils/keyword-match'; import { t } from '@/services/i18n'; @@ -168,6 +177,66 @@ interface TechEventMarker { daysUntil: number; } +type MapSportsFixtureCluster = SportsFixtureMapMarker & { + lon: number; + count: number; + items: SportsFixtureMapMarker[]; + sampled: boolean; + _clusterId?: number; +}; + +const SPORTS_CLUSTER_COUNT_FIELDS = [ + ['Soccer', 'soccerCount'], + ['Basketball', 'basketballCount'], + ['Tennis', 'tennisCount'], + ['Cricket', 'cricketCount'], + ['Motorsport', 'motorsportCount'], + ['Other', 'otherCount'], +] as const; + +function getSportsClusterSummary(props: Record): { sport: string; sports: string[] } { + const sports = SPORTS_CLUSTER_COUNT_FIELDS + .filter(([, key]) => Number(props[key] ?? 0) > 0) + .map(([sport]) => sport) + .filter((sport) => sport !== 'Other'); + + if (sports.length === 0 && Number(props.otherCount ?? 0) > 0) { + return { sport: 'Mixed', sports: ['Mixed'] }; + } + if (sports.length === 1) { + return { sport: sports[0] || 'Mixed', sports }; + } + return { sport: 'Mixed', sports }; +} + +function countFixtureItems(marker: Pick): number { + return Math.max(marker.fixtureCount ?? 0, marker.fixtures?.length ?? 0, 1); +} + +function uniqueFixtureStrings(values: Array): string[] { + return Array.from(new Set(values.filter((value): value is string => !!value))); +} + +function describeSportsClusterLocation(fixtures: SportsFixtureMapMarker[]): { venue: string; venueCity?: string; venueCountry?: string } { + const cities = uniqueFixtureStrings(fixtures.map((fixture) => fixture.venueCity)); + const countries = uniqueFixtureStrings(fixtures.map((fixture) => fixture.venueCountry)); + + if (cities.length === 1) { + return { + venue: `${cities[0]} fixture hub`, + venueCity: cities[0], + venueCountry: countries[0], + }; + } + if (countries.length === 1) { + return { + venue: `${countries[0]} fixture hub`, + venueCountry: countries[0], + }; + } + return { venue: 'Regional fixture hub' }; +} + // View presets with longitude, latitude, zoom const VIEW_PRESETS: Record = { global: { longitude: 0, latitude: 20, zoom: 1.5 }, @@ -434,6 +503,7 @@ export class DeckGLMap { private naturalEvents: NaturalEvent[] = []; private firmsFireData: Array<{ lat: number; lon: number; brightness: number; frp: number; confidence: number; region: string; acq_date: string; daynight: string }> = []; private techEvents: TechEventMarker[] = []; + private sportsFixtures: SportsFixtureMapMarker[] = []; private flightDelays: AirportDelayAlert[] = []; private aircraftPositions: PositionSample[] = []; private aircraftFetchTimer: ReturnType | null = null; @@ -534,11 +604,13 @@ export class DeckGLMap { private layerCache: Map = new Map(); private lastZoomThreshold = 0; private protestSC: Supercluster | null = null; + private sportsFixtureSC: Supercluster | null = null; private techHQSC: Supercluster | null = null; private techEventSC: Supercluster | null = null; private datacenterSC: Supercluster | null = null; private datacenterSCSource: AIDataCenter[] = []; private protestClusters: MapProtestCluster[] = []; + private sportsFixtureClusters: MapSportsFixtureCluster[] = []; private techHQClusters: MapTechHQCluster[] = []; private techEventClusters: MapTechEventCluster[] = []; private datacenterClusters: MapDatacenterCluster[] = []; @@ -1172,6 +1244,98 @@ export class DeckGLMap { this.lastSCZoom = -1; } + private rebuildSportsFixtureSupercluster(): void { + if (this.sportsFixtures.length === 0) { + this.sportsFixtureSC = null; + this.sportsFixtureClusters = []; + this.lastSCZoom = -1; + return; + } + + const points = this.sportsFixtures.map((fixture, index) => { + const fixtureCount = countFixtureItems(fixture); + const startTimeMs = fixture.startTime ? Date.parse(fixture.startTime) : Number.NaN; + return { + type: 'Feature' as const, + geometry: { type: 'Point' as const, coordinates: [fixture.lng, fixture.lat] as [number, number] }, + properties: { + index, + eventId: fixture.eventId, + fixtureCount, + competitionCount: fixture.competitionCount ?? 1, + sport: fixture.sport, + title: fixture.title, + leagueName: fixture.leagueName, + leagueShortName: fixture.leagueShortName, + venue: fixture.venue, + venueCity: fixture.venueCity || '', + venueCountry: fixture.venueCountry || '', + startLabel: fixture.startLabel, + startTimeMs: Number.isFinite(startTimeMs) ? startTimeMs : Number.MAX_SAFE_INTEGER, + }, + }; + }); + + this.sportsFixtureSC = new Supercluster({ + radius: 60, + maxZoom: 14, + map: (props: Record) => { + const sport = String(props.sport ?? 'Mixed'); + const fixtureCount = Number(props.fixtureCount ?? 1) || 1; + return { + index: Number(props.index ?? 0), + eventId: String(props.eventId ?? ''), + fixtureCount, + competitionCount: Number(props.competitionCount ?? 1) || 1, + title: String(props.title ?? ''), + leagueName: String(props.leagueName ?? ''), + leagueShortName: String(props.leagueShortName ?? ''), + venue: String(props.venue ?? ''), + venueCity: String(props.venueCity ?? ''), + venueCountry: String(props.venueCountry ?? ''), + startLabel: String(props.startLabel ?? ''), + startTimeMs: Number(props.startTimeMs ?? Number.MAX_SAFE_INTEGER), + soccerCount: sport === 'Soccer' ? fixtureCount : 0, + basketballCount: sport === 'Basketball' ? fixtureCount : 0, + tennisCount: sport === 'Tennis' ? fixtureCount : 0, + cricketCount: sport === 'Cricket' ? fixtureCount : 0, + motorsportCount: sport === 'Motorsport' ? fixtureCount : 0, + otherCount: ['Soccer', 'Basketball', 'Tennis', 'Cricket', 'Motorsport'].includes(sport) ? 0 : fixtureCount, + }; + }, + reduce: (acc: Record, props: Record) => { + acc.fixtureCount = Number(acc.fixtureCount ?? 0) + Number(props.fixtureCount ?? 0); + acc.competitionCount = Number(acc.competitionCount ?? 0) + Number(props.competitionCount ?? 0); + acc.soccerCount = Number(acc.soccerCount ?? 0) + Number(props.soccerCount ?? 0); + acc.basketballCount = Number(acc.basketballCount ?? 0) + Number(props.basketballCount ?? 0); + acc.tennisCount = Number(acc.tennisCount ?? 0) + Number(props.tennisCount ?? 0); + acc.cricketCount = Number(acc.cricketCount ?? 0) + Number(props.cricketCount ?? 0); + acc.motorsportCount = Number(acc.motorsportCount ?? 0) + Number(props.motorsportCount ?? 0); + acc.otherCount = Number(acc.otherCount ?? 0) + Number(props.otherCount ?? 0); + + const nextStartTime = Number(props.startTimeMs ?? Number.MAX_SAFE_INTEGER); + const currentStartTime = Number(acc.startTimeMs ?? Number.MAX_SAFE_INTEGER); + if (nextStartTime < currentStartTime) { + acc.startTimeMs = nextStartTime; + acc.startLabel = props.startLabel; + acc.title = props.title; + acc.eventId = props.eventId; + acc.leagueName = props.leagueName; + acc.leagueShortName = props.leagueShortName; + acc.venue = props.venue; + acc.venueCity = props.venueCity; + acc.venueCountry = props.venueCountry; + } + + if (!acc.venue && props.venue) acc.venue = props.venue; + if (!acc.venueCity && props.venueCity) acc.venueCity = props.venueCity; + if (!acc.venueCountry && props.venueCountry) acc.venueCountry = props.venueCountry; + }, + }); + this.sportsFixtureSC.load(points); + this.lastSCZoom = -1; + } + private rebuildTechEventSupercluster(): void { const points = this.techEvents.map((e, i) => ({ type: 'Feature' as const, @@ -1257,15 +1421,17 @@ export class DeckGLMap { const boundsKey = `${bbox[0].toFixed(4)}:${bbox[1].toFixed(4)}:${bbox[2].toFixed(4)}:${bbox[3].toFixed(4)}`; const layers = this.state.layers; const useProtests = layers.protests && this.protestSuperclusterSource.length > 0; + const useSportsFixtures = layers.sportsFixtures && this.sportsFixtures.length > 0; const useTechHQ = SITE_VARIANT === 'tech' && layers.techHQs; const useTechEvents = SITE_VARIANT === 'tech' && layers.techEvents && this.techEvents.length > 0; const useDatacenterClusters = layers.datacenters && zoom < 5; - const layerMask = `${Number(useProtests)}${Number(useTechHQ)}${Number(useTechEvents)}${Number(useDatacenterClusters)}`; + const layerMask = `${Number(useProtests)}${Number(useSportsFixtures)}${Number(useTechHQ)}${Number(useTechEvents)}${Number(useDatacenterClusters)}`; if (zoom === this.lastSCZoom && boundsKey === this.lastSCBoundsKey && layerMask === this.lastSCMask) return; this.lastSCZoom = zoom; this.lastSCBoundsKey = boundsKey; this.lastSCMask = layerMask; + if (useSportsFixtures && !this.sportsFixtureSC) this.rebuildSportsFixtureSupercluster(); if (useTechHQ && !this.techHQSC) this.rebuildTechHQSupercluster(); if (useDatacenterClusters && !this.datacenterSC) this.rebuildDatacenterSupercluster(); @@ -1319,6 +1485,57 @@ export class DeckGLMap { this.protestClusters = []; } + if (useSportsFixtures && this.sportsFixtureSC) { + this.sportsFixtureClusters = this.sportsFixtureSC.getClusters(bbox, zoom).map((feature) => { + const coords = feature.geometry.coordinates as [number, number]; + if (feature.properties.cluster) { + const props = feature.properties as Record; + const clusterCount = Number(feature.properties.point_count ?? 0); + const { sport, sports } = getSportsClusterSummary(props); + const startTimeMs = Number(props.startTimeMs ?? Number.MAX_SAFE_INTEGER); + const startTime = Number.isFinite(startTimeMs) && startTimeMs < Number.MAX_SAFE_INTEGER + ? new Date(startTimeMs).toISOString() + : undefined; + + return { + id: `spc-${feature.properties.cluster_id}`, + eventId: String(props.eventId ?? `sports-cluster-${feature.properties.cluster_id}`), + _clusterId: feature.properties.cluster_id!, + lat: coords[1], + lng: coords[0], + lon: coords[0], + count: clusterCount, + items: [] as SportsFixtureMapMarker[], + sampled: false, + leagueId: undefined, + leagueName: sports.length === 1 ? String(props.leagueName ?? sport) : `${sports.length || 1} sports`, + leagueShortName: sports.length === 1 ? String(props.leagueShortName ?? sport) : 'MULTI', + sport, + title: `${Number(props.fixtureCount ?? clusterCount)} fixtures`, + venue: String(props.venueCity || props.venueCountry || props.venue || 'Fixture hub'), + venueCity: String(props.venueCity ?? '') || undefined, + venueCountry: String(props.venueCountry ?? '') || undefined, + startTime, + startLabel: String(props.startLabel ?? 'Today'), + fixtureCount: Number(props.fixtureCount ?? clusterCount) || clusterCount, + competitionCount: Number(props.competitionCount ?? 1) || 1, + sports, + }; + } + + const item = this.sportsFixtures[feature.properties.index]!; + return { + ...item, + lon: item.lng, + count: 1, + items: [item], + sampled: false, + }; + }); + } else { + this.sportsFixtureClusters = []; + } + if (useTechHQ && this.techHQSC) { this.techHQClusters = this.techHQSC.getClusters(bbox, zoom).map(f => { const coords = f.geometry.coordinates as [number, number]; @@ -1776,6 +1993,10 @@ export class DeckGLMap { } } + if (mapLayers.sportsFixtures && this.sportsFixtures.length > 0) { + layers.push(...this.createSportsFixturesLayers()); + } + // Gulf FDI investments layer if (mapLayers.gulfInvestments) { layers.push(this.createGulfInvestmentsLayer()); @@ -3144,6 +3365,123 @@ export class DeckGLMap { return layers; } + private createSportsFixturesLayers(): Layer[] { + this.updateClusterData(); + const layers: Layer[] = []; + const zoom = this.maplibreMap?.getZoom() || 2; + const fixtureClusters = this.sportsFixtureClusters.length > 0 + ? this.sportsFixtureClusters + : this.sportsFixtures.map((fixture) => ({ + ...fixture, + lon: fixture.lng, + count: 1, + items: [fixture], + sampled: false, + } satisfies MapSportsFixtureCluster)); + + layers.push(new ScatterplotLayer({ + id: 'sports-fixtures-halo', + data: fixtureClusters, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => 16000 + (Math.max(d.fixtureCount || 1, d.count) - 1) * 4200, + radiusMinPixels: zoom < 3 ? 7 : 9, + radiusMaxPixels: zoom < 3 ? 26 : 32, + getFillColor: (d) => { + const visuals = getSportsFixtureVisualMeta(d.sport); + return this.hexToRgba(visuals.colorHex, d.count > 1 || isSportsFixtureHubMarker(d) ? 74 : 46); + }, + pickable: false, + })); + + layers.push(new ScatterplotLayer({ + id: 'sports-fixtures-layer', + data: fixtureClusters, + getPosition: (d) => [d.lon, d.lat], + getRadius: (d) => 12000 + (Math.max(d.fixtureCount || 1, d.count) - 1) * 3000, + radiusMinPixels: 6, + radiusMaxPixels: 22, + getFillColor: (d) => { + const [red, green, blue] = getSportsFixtureVisualMeta(d.sport).colorRgba; + return [red, green, blue, d.count > 1 || isSportsFixtureHubMarker(d) ? 235 : 208] as [number, number, number, number]; + }, + getLineColor: (d) => this.hexToRgba(getSportsFixtureVisualMeta(d.sport).colorHex, 255), + getLineWidth: (d) => (d.count > 1 || isSportsFixtureHubMarker(d) ? 2 : 1), + lineWidthMinPixels: 1.25, + stroked: true, + pickable: true, + })); + + layers.push(new TextLayer({ + id: 'sports-fixtures-icon', + data: fixtureClusters, + getText: (d) => getSportsFixtureVisualMeta(d.sport).icon, + getPosition: (d) => [d.lon, d.lat], + getColor: [5, 8, 12, 255], + getSize: (d) => (d.count > 1 || isSportsFixtureHubMarker(d) ? 12 : 11), + getTextAnchor: 'middle', + getAlignmentBaseline: 'center', + billboard: true, + pickable: false, + fontFamily: '"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif', + fontWeight: 700, + })); + + const multiHubs = fixtureClusters.filter((fixture) => (fixture.fixtureCount || 1) > 1); + if (multiHubs.length > 0) { + layers.push(new TextLayer({ + id: 'sports-fixtures-badge', + data: multiHubs, + getText: (d) => String(d.fixtureCount || d.fixtures?.length || d.count || 1), + getPosition: (d) => [d.lon, d.lat], + background: true, + getBackgroundColor: [5, 8, 12, 190], + backgroundPadding: [4, 2, 4, 2], + getColor: [255, 255, 255, 255], + getSize: 12, + getPixelOffset: [0, -14], + pickable: false, + fontFamily: 'system-ui, sans-serif', + fontWeight: 700, + })); + } + + if (zoom >= 3.1) { + const maxLabels = zoom >= 5 ? 28 : zoom >= 4 ? 18 : 10; + const labelData = fixtureClusters + .slice() + .sort((a, b) => getSportsFixtureRenderPriority(b) - getSportsFixtureRenderPriority(a)) + .filter((fixture, index) => { + if (index >= maxLabels) return false; + if ((fixture.fixtureCount || 1) > 1) return true; + return zoom >= 4.1 || getSportsFixtureRenderPriority(fixture) >= 16; + }); + + if (labelData.length > 0) { + layers.push(new TextLayer({ + id: 'sports-fixtures-label', + data: labelData, + getText: (d) => getSportsFixtureDisplayLabel(d, zoom < 4.2), + getPosition: (d) => [d.lon, d.lat], + getColor: [241, 245, 249, 232], + getSize: (d) => ((d.fixtureCount || 1) > 1 ? 12 : 11), + getPixelOffset: [0, 18], + getTextAnchor: 'middle', + getAlignmentBaseline: 'top', + background: true, + getBackgroundColor: [5, 8, 12, 196], + backgroundPadding: [7, 3, 7, 3], + billboard: true, + pickable: false, + fontFamily: 'system-ui, sans-serif', + fontWeight: 700, + })); + } + } + + layers.push(this.createEmptyGhost('sports-fixtures-layer')); + return layers; + } + private createDatacenterClusterLayers(): Layer[] { this.updateClusterData(); const layers: Layer[] = []; @@ -3795,6 +4133,17 @@ export class DeckGLMap { return { html: `
${text(obj.provider)}
${text(obj.region)}
` }; case 'tech-events-layer': return { html: `
${text(obj.title)}
${text(obj.location)}
` }; + case 'sports-fixtures-layer': { + const fixture = obj as MapSportsFixtureCluster; + const title = getSportsFixtureDisplayLabel(fixture, fixture.count > 1); + const subLabel = getSportsFixtureSubLabel(fixture); + const countLine = (fixture.fixtureCount || 1) > 1 + ? `
${text(String(fixture.fixtureCount || fixture.count || 1))} fixtures` + : ''; + return { + html: `
${text(title)}
${text(subLabel || fixture.venue || '')}${countLine}
`, + }; + } case 'irradiators-layer': return { html: `
${text(obj.name)}
${text(obj.type || t('components.deckgl.layers.gammaIrradiators'))}
` }; case 'disease-outbreaks-layer': { @@ -3961,6 +4310,38 @@ export class DeckGLMap { 'resilience-choropleth-layer', ]); + private resolveSportsFixtureClusterItems(cluster: MapSportsFixtureCluster): SportsFixtureMapMarker[] { + if (cluster.count <= 1) return cluster.items; + if (cluster.items.length > 0 || cluster._clusterId == null || !this.sportsFixtureSC) return cluster.items; + + try { + const leaves = this.sportsFixtureSC.getLeaves(cluster._clusterId, cluster.count); + cluster.items = leaves + .map((leaf) => this.sportsFixtures[leaf.properties.index]) + .filter((fixture): fixture is SportsFixtureMapMarker => !!fixture); + } catch (error) { + console.warn('[DeckGLMap] stale sports fixture cluster', cluster._clusterId, error); + } + + return cluster.items; + } + + private buildSportsFixturePopupMarker(cluster: MapSportsFixtureCluster): SportsFixtureMapMarker { + const items = this.resolveSportsFixtureClusterItems(cluster); + if (items.length <= 1) return items[0] || cluster; + + const location = describeSportsClusterLocation(items); + return buildSportsFixtureAggregateMarker(items, { + id: cluster.id, + title: `${items.reduce((sum, item) => sum + countFixtureItems(item), 0)} fixtures`, + venue: location.venue, + venueCity: location.venueCity, + venueCountry: location.venueCountry, + lat: cluster.lat, + lng: cluster.lon, + }); + } + private handleClick(info: PickingInfo): void { const isChoropleth = info.layer?.id ? DeckGLMap.CHOROPLETH_LAYER_IDS.has(info.layer.id) : false; if (!info.object || isChoropleth) { @@ -4134,6 +4515,18 @@ export class DeckGLMap { } return; } + if (layerId === 'sports-fixtures-layer') { + const cluster = info.object as MapSportsFixtureCluster; + const marker = this.buildSportsFixturePopupMarker(cluster); + this.popup.show({ + type: 'sportsFixture', + data: marker, + x: info.x, + y: info.y, + }); + void this.popup.loadSportsFixtureContext(marker); + return; + } if (layerId === 'webcam-layer' && !('count' in info.object)) { this.showWebcamClickPopup(info.object as WebcamEntry, info.x, info.y); @@ -4194,6 +4587,7 @@ export class DeckGLMap { 'accelerators-layer': 'accelerator', 'cloud-regions-layer': 'cloudRegion', 'tech-events-layer': 'techEvent', + 'sports-fixtures-layer': 'sportsFixture', 'apt-groups-layer': 'apt', 'minerals-layer': 'mineral', 'ais-disruptions-layer': 'ais', @@ -4251,6 +4645,9 @@ export class DeckGLMap { const icao24 = (data as { icao24?: string }).icao24; if (icao24) this.popup.loadWingbitsLiveFlight(icao24); } + if (popupType === 'sportsFixture') { + void this.popup.loadSportsFixtureContext(data as SportsFixtureMapMarker); + } } private async showWebcamClickPopup(webcam: WebcamEntry, x: number, y: number): Promise { @@ -4615,6 +5012,17 @@ export class DeckGLMap { `; + const sportsHelpContent = ` + ${helpHeader} +
+
+
Sports Context
+
${label('sportsFixtures')} Daily football, basketball, motorsport, tennis, and cricket fixtures are grouped into one dot per league, with selectable matchup analysis in the popup.
+ ${helpItem(label('dayNight'), 'dayNight')} +
+
+ `; + const fullHelpContent = ` ${helpHeader}
@@ -4670,6 +5078,8 @@ export class DeckGLMap { ? techHelpContent : SITE_VARIANT === 'finance' ? financeHelpContent + : SITE_VARIANT === 'sports' + ? sportsHelpContent : fullHelpContent; popup.querySelector('.layer-help-close')?.addEventListener('click', () => popup.remove()); @@ -4756,6 +5166,7 @@ export class DeckGLMap { ] : SITE_VARIANT === 'sports' ? [ + { shape: shapes.circle('rgb(34, 197, 94)'), label: t('components.deckgl.layers.sportsFixtures'), layerKey: 'sportsFixtures' }, { shape: shapes.circle('rgb(96, 165, 250)'), label: t('components.deckgl.layers.dayNight'), layerKey: 'dayNight' }, ] : SITE_VARIANT === 'commodity' @@ -5637,6 +6048,12 @@ export class DeckGLMap { this.render(); } + public setSportsFixtures(fixtures: SportsFixtureMapMarker[]): void { + this.sportsFixtures = fixtures; + this.rebuildSportsFixtureSupercluster(); + this.render(); + } + public setUcdpEvents(events: UcdpGeoEvent[]): void { this.ucdpEvents = events; this.render(); diff --git a/src/components/GlobeMap.ts b/src/components/GlobeMap.ts index a99437a3af..3ed254e326 100644 --- a/src/components/GlobeMap.ts +++ b/src/components/GlobeMap.ts @@ -52,6 +52,13 @@ import type { WebcamEntry, WebcamCluster } from '@/generated/client/worldmonitor import type { TrafficAnomaly as ProtoTrafficAnomaly, DdosLocationHit } from '@/generated/client/worldmonitor/infrastructure/v1/service_client'; import type { RadiationObservation } from '@/services/radiation'; import type { ScenarioVisualState } from '@/config/scenario-templates'; +import { + getSportsFixtureDisplayLabel, + getSportsFixtureSubLabel, + getSportsFixtureVisualMeta, + isSportsFixtureHubMarker, + type SportsFixtureMapMarker, +} from '@/services/sports'; const SAT_COUNTRY_COLORS: Record = { CN: '#ff2020', RU: '#ff8800', US: '#4488ff', EU: '#44cc44', KR: '#aa66ff', IN: '#ff66aa', TR: '#ff4466', OTHER: '#ccccff' }; const SAT_TYPE_EMOJI: Record = { sar: '\u{1F4E1}', optical: '\u{1F4F7}', military: '\u{1F396}', sigint: '\u{1F4FB}' }; @@ -205,6 +212,9 @@ interface TechMarker extends BaseMarker { country: string; daysUntil: number; } +interface SportsMarker extends BaseMarker, SportsFixtureMapMarker { + _kind: 'sports'; +} interface ConflictZoneMarker extends BaseMarker { _kind: 'conflictZone'; id: string; @@ -416,6 +426,7 @@ type GlobeMarker = | WeatherMarker | NaturalMarker | IranMarker | OutageMarker | TrafficAnomalyMarker | DdosHitMarker | CyberMarker | FireMarker | ProtestMarker | UcdpMarker | DisplacementMarker | ClimateMarker | GpsJamMarker | TechMarker + | SportsMarker | ConflictZoneMarker | MilBaseMarker | NuclearSiteMarker | IrradiatorSiteMarker | SpaceportSiteMarker | EarthquakeMarker | RadiationMarker | EconomicMarker | DatacenterMarker | WaterwayMarker | MineralMarker | FlightDelayMarker | NotamRingMarker | CableAdvisoryMarker | RepairShipMarker | AisDisruptionMarker @@ -489,6 +500,7 @@ export class GlobeMap { private climateMarkers: ClimateMarker[] = []; private gpsJamMarkers: GpsJamMarker[] = []; private techMarkers: TechMarker[] = []; + private sportsMarkers: SportsMarker[] = []; private conflictZoneMarkers: ConflictZoneMarker[] = []; private milBaseMarkers: MilBaseMarker[] = []; private nuclearSiteMarkers: NuclearSiteMarker[] = []; @@ -1092,6 +1104,21 @@ export class GlobeMap { } else if (d._kind === 'tech') { el.innerHTML = GlobeMap.wrapHit(`
๐Ÿ’ป
`); el.title = d.title; + } else if (d._kind === 'sports') { + const visuals = getSportsFixtureVisualMeta(d.sport); + const label = getSportsFixtureDisplayLabel(d, true); + const subLabel = getSportsFixtureSubLabel(d); + const countBadge = isSportsFixtureHubMarker(d) + ? `${d.fixtureCount}` + : ''; + el.innerHTML = GlobeMap.wrapHit(` +
+ ${visuals.icon} + ${isSportsFixtureHubMarker(d) ? `${d.fixtureCount}` : ''} + ${countBadge} +
+ `); + el.title = `${label}${subLabel ? ` โ€ข ${subLabel}` : ''}`; } else if (d._kind === 'conflictZone') { const intColor = d.intensity === 'high' ? '#ff2020' : d.intensity === 'medium' ? '#ff8800' : '#ffcc00'; el.innerHTML = ` @@ -1312,6 +1339,16 @@ export class GlobeMap { }); return; } + if (d._kind === 'sports' && this.popup) { + const aRect = anchor.getBoundingClientRect(); + const cRect = this.container.getBoundingClientRect(); + const x = aRect.left - cRect.left + aRect.width / 2; + const y = aRect.top - cRect.top; + this.hideTooltip(); + this.popup.show({ type: 'sportsFixture', data: d, x, y }); + void this.popup.loadSportsFixtureContext(d); + return; + } this.showMarkerTooltip(d, anchor); } @@ -1456,6 +1493,11 @@ export class GlobeMap { html = `๐Ÿ’ป ${esc(d.title.slice(0, 50))}` + `
${esc(d.country)}` + (d.daysUntil >= 0 ? `
In ${d.daysUntil} days` : ''); + } else if (d._kind === 'sports') { + const visuals = getSportsFixtureVisualMeta(d.sport); + html = `${visuals.icon} ${esc(getSportsFixtureDisplayLabel(d, true))}` + + `
${esc(getSportsFixtureSubLabel(d) || d.venue)}` + + (d.fixtureCount && d.fixtureCount > 1 ? `
${d.fixtureCount} fixtures` : ''); } else if (d._kind === 'conflictZone') { const ic = d.intensity === 'high' ? '#ff3030' : d.intensity === 'medium' ? '#ff8800' : '#ffcc00'; html = `โš” ${esc(d.name)}` + @@ -1982,6 +2024,7 @@ export class GlobeMap { markers.push(...this.imagerySceneMarkers); } if (this.layers.techEvents) markers.push(...this.techMarkers); + if (this.layers.sportsFixtures) markers.push(...this.sportsMarkers); if (this.layers.cables) { markers.push(...this.cableAdvisoryMarkers); markers.push(...this.repairShipMarkers); @@ -3291,6 +3334,15 @@ export class GlobeMap { })); this.flushMarkers(); } + public setSportsFixtures(fixtures: SportsFixtureMapMarker[]): void { + this.sportsMarkers = (fixtures ?? []).filter((fixture) => fixture.lat != null && fixture.lng != null).map((fixture) => ({ + _kind: 'sports' as const, + _lat: fixture.lat, + _lng: fixture.lng, + ...fixture, + })); + this.flushMarkers(); + } public onHotspotClicked(cb: (h: Hotspot) => void): void { this.onHotspotClickCb = cb; } public onTimeRangeChanged(_cb: (r: TimeRange) => void): void {} public onStateChanged(_cb: (s: MapContainerState) => void): void {} diff --git a/src/components/Map.ts b/src/components/Map.ts index a94e3b117f..1cd1de5ac8 100644 --- a/src/components/Map.ts +++ b/src/components/Map.ts @@ -10,6 +10,15 @@ import type { Earthquake } from '@/services/earthquakes'; import { type IranEvent, getIranEventCssColor, getIranEventSize } from '@/services/conflict'; import type { TechHubActivity } from '@/services/tech-activity'; import type { GeoHubActivity } from '@/services/geo-activity'; +import { + buildSportsFixtureAggregateMarker, + getSportsFixtureDisplayLabel, + getSportsFixtureRenderPriority, + getSportsFixtureSubLabel, + getSportsFixtureVisualMeta, + isSportsFixtureHubMarker, + type SportsFixtureMapMarker, +} from '@/services/sports'; import { getNaturalEventIcon } from '@/services/eonet'; import type { WeatherAlert } from '@/services/weather'; import type { RadiationObservation } from '@/services/radiation'; @@ -91,6 +100,34 @@ interface TechEventMarker { daysUntil: number; } +function countFixtureItems(marker: Pick): number { + return Math.max(marker.fixtureCount ?? 0, marker.fixtures?.length ?? 0, 1); +} + +function uniqueFixtureStrings(values: Array): string[] { + return Array.from(new Set(values.filter((value): value is string => !!value))); +} + +function describeSportsClusterLocation(fixtures: SportsFixtureMapMarker[]): { venue: string; venueCity?: string; venueCountry?: string } { + const cities = uniqueFixtureStrings(fixtures.map((fixture) => fixture.venueCity)); + const countries = uniqueFixtureStrings(fixtures.map((fixture) => fixture.venueCountry)); + + if (cities.length === 1) { + return { + venue: `${cities[0]} fixture hub`, + venueCity: cities[0], + venueCountry: countries[0], + }; + } + if (countries.length === 1) { + return { + venue: `${countries[0]} fixture hub`, + venueCountry: countries[0], + }; + } + return { venue: 'Regional fixture hub' }; +} + interface WorldTopology extends Topology { objects: { countries: GeometryCollection; @@ -143,6 +180,7 @@ export class MapComponent { private naturalEvents: NaturalEvent[] = []; private firmsFireData: Array<{ lat: number; lon: number; brightness: number; frp: number; confidence: number; region: string; acq_date: string; daynight: string }> = []; private techEvents: TechEventMarker[] = []; + private sportsFixtures: SportsFixtureMapMarker[] = []; private techActivities: TechHubActivity[] = []; private geoActivities: GeoHubActivity[] = []; private iranEvents: IranEvent[] = []; @@ -440,6 +478,7 @@ export class MapComponent { accelerators: 'components.deckgl.layers.accelerators', techHQs: 'components.deckgl.layers.techHQs', techEvents: 'components.deckgl.layers.techEvents', + sportsFixtures: 'components.deckgl.layers.sportsFixtures', stockExchanges: 'components.deckgl.layers.stockExchanges', financialCenters: 'components.deckgl.layers.financialCenters', centralBanks: 'components.deckgl.layers.centralBanks', @@ -635,6 +674,7 @@ export class MapComponent {
Sports Context
+
${label('sportsFixtures')} Daily football, basketball, motorsport, tennis, and cricket fixtures are grouped into one dot per league, with selectable matchup analysis in the popup.
${label('dayNight')} Day/night shading helps track local kick-off, tip-off, and race windows across regions.
@@ -699,6 +739,7 @@ export class MapComponent { `; } else if (SITE_VARIANT === 'sports') { legend.innerHTML = ` +
โšฝ${escapeHtml(t('components.deckgl.layers.sportsFixtures').toUpperCase())}
โ—${escapeHtml(t('components.deckgl.layers.dayNight').toUpperCase())}
`; } else { @@ -2167,6 +2208,70 @@ export class MapComponent { }); } + // Sports fixtures layer + if (this.state.layers.sportsFixtures && this.sportsFixtures.length > 0) { + const clusterRadius = this.state.zoom >= 5 ? 14 : this.state.zoom >= 4 ? 20 : this.state.zoom >= 3 ? 28 : 40; + const fixtureClusters = this.clusterMarkers( + this.sportsFixtures + .slice() + .sort((a, b) => getSportsFixtureRenderPriority(b) - getSportsFixtureRenderPriority(a)) + .map((fixture) => ({ ...fixture, lon: fixture.lng })), + projection, + clusterRadius, + (fixture) => fixture.venueCountry || fixture.sport, + ); + + fixtureClusters.forEach((cluster) => { + const fixture = cluster.items.length === 1 + ? cluster.items[0]! + : buildSportsFixtureAggregateMarker(cluster.items, { + ...describeSportsClusterLocation(cluster.items), + lat: cluster.center[1], + lng: cluster.center[0], + }); + const visuals = getSportsFixtureVisualMeta(fixture.sport); + const isHub = isSportsFixtureHubMarker(fixture); + const priority = getSportsFixtureRenderPriority(fixture); + const showLabel = isHub || this.state.zoom >= 4 || (this.state.zoom >= 3 && priority >= 16); + const labelText = getSportsFixtureDisplayLabel(fixture, this.state.zoom < 4); + const subLabel = this.state.zoom >= 4.25 ? getSportsFixtureSubLabel(fixture) : ''; + const countBadge = (fixture.fixtureCount || 1) > 1 + ? `${countFixtureItems(fixture)}` + : ''; + const div = document.createElement('div'); + div.className = 'map-marker sports-fixture-marker'; + div.style.left = `${cluster.pos[0]}px`; + div.style.top = `${cluster.pos[1]}px`; + div.style.zIndex = String(48 + Math.min(priority, 24)); + div.innerHTML = ` + + ${visuals.icon}${countBadge} + ${showLabel ? ` + + ${escapeHtml(labelText)} + ${subLabel ? `${escapeHtml(subLabel)}` : ''} + + ` : ''} + + `; + div.title = `${labelText}${subLabel ? ` โ€ข ${subLabel}` : ''}`; + + div.addEventListener('click', (e) => { + e.stopPropagation(); + const rect = this.container.getBoundingClientRect(); + this.popup.show({ + type: 'sportsFixture', + data: fixture, + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + void this.popup.loadSportsFixtureContext(fixture); + }); + + this.overlays.appendChild(div); + }); + } + // Tech Events / Conferences (๐Ÿ“… icons) - with clustering if (this.state.layers.techEvents && this.techEvents.length > 0) { const mapWidth = this.container.clientWidth; @@ -3385,7 +3490,7 @@ export class MapComponent { } private static readonly ASYNC_DATA_LAYERS: Set = new Set([ - 'natural', 'weather', 'outages', 'ais', 'protests', 'flights', 'military', 'techEvents', + 'natural', 'weather', 'outages', 'ais', 'protests', 'flights', 'military', 'techEvents', 'sportsFixtures', ]); public toggleLayer(layer: keyof MapLayers, source: 'user' | 'programmatic' = 'user'): void { @@ -4010,6 +4115,11 @@ export class MapComponent { this.render(); } + public setSportsFixtures(fixtures: SportsFixtureMapMarker[]): void { + this.sportsFixtures = fixtures; + this.render(); + } + public setCyberThreats(_threats: CyberThreat[]): void { // SVG/mobile fallback intentionally does not render this layer to stay lightweight. } diff --git a/src/components/MapContainer.ts b/src/components/MapContainer.ts index 78d72b1334..f61b3b46dc 100644 --- a/src/components/MapContainer.ts +++ b/src/components/MapContainer.ts @@ -54,6 +54,7 @@ import { hasPremiumAccess } from '@/services/panel-gating'; import { trackGateHit } from '@/services/analytics'; export type { ScenarioVisualState, ScenarioResult }; +import type { SportsFixtureMapMarker } from '@/services/sports'; export type TimeRange = '1h' | '6h' | '24h' | '48h' | '7d' | 'all'; export type MapView = 'global' | 'america' | 'mena' | 'eu' | 'asia' | 'latam' | 'africa' | 'oceania'; @@ -128,6 +129,7 @@ export class MapContainer { private cachedNaturalEvents: NaturalEvent[] | null = null; private cachedFires: FireMarker[] | null = null; private cachedTechEvents: TechEventMarker[] | null = null; + private cachedSportsFixtures: SportsFixtureMapMarker[] | null = null; private cachedUcdpEvents: UcdpGeoEvent[] | null = null; private cachedDisplacementFlows: DisplacementFlow[] | null = null; private cachedClimateAnomalies: ClimateAnomaly[] | null = null; @@ -299,6 +301,7 @@ export class MapContainer { if (this.cachedNaturalEvents) this.setNaturalEvents(this.cachedNaturalEvents); if (this.cachedFires) this.setFires(this.cachedFires); if (this.cachedTechEvents) this.setTechEvents(this.cachedTechEvents); + if (this.cachedSportsFixtures) this.setSportsFixtures(this.cachedSportsFixtures); if (this.cachedUcdpEvents) this.setUcdpEvents(this.cachedUcdpEvents); if (this.cachedDisplacementFlows) this.setDisplacementFlows(this.cachedDisplacementFlows); if (this.cachedClimateAnomalies) this.setClimateAnomalies(this.cachedClimateAnomalies); @@ -564,6 +567,16 @@ export class MapContainer { } } + public setSportsFixtures(fixtures: SportsFixtureMapMarker[]): void { + this.cachedSportsFixtures = fixtures; + if (this.useGlobe) { this.globeMap?.setSportsFixtures(fixtures); return; } + if (this.useDeckGL) { + this.deckGLMap?.setSportsFixtures(fixtures); + } else { + this.svgMap?.setSportsFixtures(fixtures); + } + } + public setUcdpEvents(events: UcdpGeoEvent[]): void { this.cachedUcdpEvents = events; if (this.useGlobe) { this.globeMap?.setUcdpEvents(events); return; } @@ -1076,6 +1089,7 @@ export class MapContainer { this.cachedNaturalEvents = null; this.cachedFires = null; this.cachedTechEvents = null; + this.cachedSportsFixtures = null; this.cachedUcdpEvents = null; this.cachedDisplacementFlows = null; this.cachedClimateAnomalies = null; diff --git a/src/components/MapPopup.ts b/src/components/MapPopup.ts index a4fd40d35e..804d260ee4 100644 --- a/src/components/MapPopup.ts +++ b/src/components/MapPopup.ts @@ -4,6 +4,7 @@ import type { AirportDelayAlert, PositionSample } from '@/services/aviation'; import type { Earthquake } from '@/services/earthquakes'; import type { WeatherAlert } from '@/services/weather'; import type { RadiationObservation } from '@/services/radiation'; +import { fetchSportsFixturePopupContext, getSportsFixtureVisualMeta, isSportsFixtureHubMarker, type SportsFixtureMapMarker } from '@/services/sports'; import { UNDERSEA_CABLES } from '@/config'; import type { StartupHub, Accelerator, TechHQ, CloudRegion } from '@/config/tech-geo'; import type { TechHubActivity } from '@/services/tech-activity'; @@ -82,7 +83,7 @@ function fmtDelayMin(min: number | undefined): string { return `${min > 0 ? '+' : ''}${min}m`; } -export type PopupType = 'conflict' | 'hotspot' | 'earthquake' | 'weather' | 'base' | 'waterway' | 'apt' | 'cyberThreat' | 'nuclear' | 'economic' | 'irradiator' | 'pipeline' | 'cable' | 'cable-advisory' | 'repair-ship' | 'outage' | 'datacenter' | 'datacenterCluster' | 'ais' | 'protest' | 'protestCluster' | 'flight' | 'aircraft' | 'militaryFlight' | 'militaryVessel' | 'militaryFlightCluster' | 'militaryVesselCluster' | 'natEvent' | 'port' | 'spaceport' | 'mineral' | 'startupHub' | 'cloudRegion' | 'techHQ' | 'accelerator' | 'techEvent' | 'techHQCluster' | 'techEventCluster' | 'techActivity' | 'geoActivity' | 'stockExchange' | 'financialCenter' | 'centralBank' | 'commodityHub' | 'iranEvent' | 'gpsJamming' | 'radiation'; +export type PopupType = 'conflict' | 'hotspot' | 'earthquake' | 'weather' | 'base' | 'waterway' | 'apt' | 'cyberThreat' | 'nuclear' | 'economic' | 'irradiator' | 'pipeline' | 'cable' | 'cable-advisory' | 'repair-ship' | 'outage' | 'datacenter' | 'datacenterCluster' | 'ais' | 'protest' | 'protestCluster' | 'flight' | 'aircraft' | 'militaryFlight' | 'militaryVessel' | 'militaryFlightCluster' | 'militaryVesselCluster' | 'natEvent' | 'port' | 'spaceport' | 'mineral' | 'startupHub' | 'cloudRegion' | 'techHQ' | 'accelerator' | 'techEvent' | 'techHQCluster' | 'techEventCluster' | 'techActivity' | 'geoActivity' | 'stockExchange' | 'financialCenter' | 'centralBank' | 'commodityHub' | 'iranEvent' | 'gpsJamming' | 'radiation' | 'sportsFixture'; interface TechEventPopupData { id: string; @@ -97,6 +98,8 @@ interface TechEventPopupData { daysUntil: number; } +type SportsFixturePopupData = SportsFixtureMapMarker; + interface TechHQClusterData { items: TechHQ[]; city: string; @@ -211,7 +214,7 @@ interface DatacenterClusterData { interface PopupData { type: PopupType; - data: ConflictZone | Hotspot | Earthquake | WeatherAlert | MilitaryBase | StrategicWaterway | APTGroup | CyberThreat | NuclearFacility | EconomicCenter | GammaIrradiator | Pipeline | UnderseaCable | CableAdvisory | RepairShip | InternetOutage | AIDataCenter | AisDisruptionEvent | SocialUnrestEvent | AirportDelayAlert | PositionSample | MilitaryFlight | MilitaryVessel | MilitaryFlightCluster | MilitaryVesselCluster | NaturalEvent | Port | Spaceport | CriticalMineralProject | StartupHub | CloudRegion | TechHQ | Accelerator | TechEventPopupData | TechHQClusterData | TechEventClusterData | ProtestClusterData | DatacenterClusterData | TechHubActivity | GeoHubActivity | StockExchangePopupData | FinancialCenterPopupData | CentralBankPopupData | CommodityHubPopupData | IranEventPopupData | GpsJammingPopupData | RadiationObservation; + data: ConflictZone | Hotspot | Earthquake | WeatherAlert | MilitaryBase | StrategicWaterway | APTGroup | CyberThreat | NuclearFacility | EconomicCenter | GammaIrradiator | Pipeline | UnderseaCable | CableAdvisory | RepairShip | InternetOutage | AIDataCenter | AisDisruptionEvent | SocialUnrestEvent | AirportDelayAlert | PositionSample | MilitaryFlight | MilitaryVessel | MilitaryFlightCluster | MilitaryVesselCluster | NaturalEvent | Port | Spaceport | CriticalMineralProject | StartupHub | CloudRegion | TechHQ | Accelerator | TechEventPopupData | SportsFixturePopupData | TechHQClusterData | TechEventClusterData | ProtestClusterData | DatacenterClusterData | TechHubActivity | GeoHubActivity | StockExchangePopupData | FinancialCenterPopupData | CentralBankPopupData | CommodityHubPopupData | IranEventPopupData | GpsJammingPopupData | RadiationObservation; relatedNews?: NewsItem[]; x: number; y: number; @@ -309,6 +312,16 @@ export class MapPopup { hidden.style.display = expanded ? 'none' : ''; toggle.textContent = expanded ? (toggle.dataset.more ?? '') : (toggle.dataset.less ?? ''); } + const fixtureSelect = target.closest('[data-sports-fixture-index]') as HTMLElement | null; + if (fixtureSelect && data.type === 'sportsFixture') { + const rootFixture = data.data as SportsFixturePopupData; + const selectedIndex = Number.parseInt(fixtureSelect.dataset.sportsFixtureIndex || '', 10); + if (!Number.isFinite(selectedIndex) || selectedIndex < 0) return; + const selectedFixture = rootFixture.fixtures?.[selectedIndex]; + if (!selectedFixture) return; + this.setActiveSportsFixtureSelection(selectedFixture.eventId); + void this.loadSportsFixtureContext(rootFixture, selectedFixture); + } }); if (this.isMobileSheet) { @@ -656,6 +669,8 @@ export class MapPopup { return this.renderAcceleratorPopup(data.data as Accelerator); case 'techEvent': return this.renderTechEventPopup(data.data as TechEventPopupData); + case 'sportsFixture': + return this.renderSportsFixturePopup(data.data as SportsFixturePopupData); case 'techHQCluster': return this.renderTechHQClusterPopup(data.data as TechHQClusterData); case 'techEventCluster': @@ -1146,6 +1161,89 @@ export class MapPopup { } } + public async loadSportsFixtureContext(fixture: SportsFixturePopupData, selectedFixture?: SportsFixturePopupData): Promise { + if (!this.popup) return; + + const section = this.popup.querySelector('.sports-fixture-context'); + if (!section) return; + + const fixtureForContext = selectedFixture || (isSportsFixtureHubMarker(fixture) + ? fixture.fixtures?.[0] + : fixture); + if (!fixtureForContext) { + section.innerHTML = ` + + + `; + return; + } + + this.setActiveSportsFixtureSelection(fixtureForContext.eventId); + const requestToken = `${fixtureForContext.eventId}:${Date.now()}`; + section.setAttribute('data-request-token', requestToken); + section.innerHTML = ` + + + `; + + try { + const context = await fetchSportsFixturePopupContext(fixtureForContext); + if (!this.popup || !section.isConnected) return; + if (section.getAttribute('data-request-token') !== requestToken) return; + + const selectedTeams = fixtureForContext.homeTeam && fixtureForContext.awayTeam + ? `${fixtureForContext.homeTeam} vs ${fixtureForContext.awayTeam}` + : fixtureForContext.title; + + section.innerHTML = ` + + + + ${context.stats.length > 0 ? ` + + ` : ''} + + + `; + this.clampPopupToViewport(); + } catch { + if (section.isConnected) { + if (section.getAttribute('data-request-token') !== requestToken) return; + section.innerHTML = ` + + + `; + } + } + } + + private setActiveSportsFixtureSelection(eventId: string): void { + if (!this.popup) return; + const options = this.popup.querySelectorAll('.sports-fixture-option'); + options.forEach((option) => { + const active = option.dataset.sportsEventId === eventId; + option.setAttribute('data-selected', active ? 'true' : 'false'); + option.style.borderColor = active ? 'rgba(34,197,94,0.7)' : 'rgba(255,255,255,0.06)'; + option.style.background = active ? 'rgba(34,197,94,0.12)' : 'rgba(255,255,255,0.03)'; + }); + } + private renderGdeltArticle(article: GdeltArticle): string { const domain = article.source || extractDomain(article.url); const timeAgo = formatArticleDate(article.date); @@ -2357,6 +2455,109 @@ ${isFeatureAvailable('wingbitsEnrichment') ? '
0) + ? fixture.sports.join(' ยท ') + : fixture.sport; + + return ` + + + `; + } + + 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/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 index 32cc579f55..72cee7b4f1 100644 --- a/src/components/SportsFixturesPanel.ts +++ b/src/components/SportsFixturesPanel.ts @@ -3,6 +3,14 @@ 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) { @@ -23,32 +31,40 @@ export class SportsFixturesPanel extends Panel { constructor() { super({ id: 'sports-fixtures', - title: 'Upcoming Fixtures', - showCount: false, - infoTooltip: 'Upcoming fixtures across featured soccer and basketball competitions powered by ESPN scoreboards.', + 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 fixtures...'); + this.showLoading('Loading daily fixtures...'); try { this.groups = await fetchFeaturedSportsFixtures(); if (this.groups.length === 0) { - this.showError('No fixture data available right now.', () => void this.fetchData()); + 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) => `
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/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/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/SportsTransferNewsPanel.ts b/src/components/SportsTransferNewsPanel.ts index ae0b43b200..da412fa8c5 100644 --- a/src/components/SportsTransferNewsPanel.ts +++ b/src/components/SportsTransferNewsPanel.ts @@ -5,10 +5,10 @@ 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+when:7d&hl=en-US&gl=US&ceid=US:en') }, + { 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://news.google.com/rss/search?q=site:theguardian.com+football+transfer+when:7d&hl=en-US&gl=US&ceid=US:en') }, + { name: 'Guardian Football', url: rssProxyUrl('https://www.theguardian.com/football/rss') }, ]; const TRANSFER_KEYWORDS = [ diff --git a/src/components/index.ts b/src/components/index.ts index 5ecc41d9e7..b111e84d45 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -89,12 +89,15 @@ export * from './DiseaseOutbreaksPanel'; export * from './SocialVelocityPanel'; export * from './WsbTickerScannerPanel'; export * from './ResilienceWidget'; -export * from './SportsFixturesPanel'; 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/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 f35b500a34..635c7bcf60 100644 --- a/src/config/feeds.ts +++ b/src/config/feeds.ts @@ -886,25 +886,25 @@ 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+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+when:2d&hl=en-US&gl=US&ceid=US:en') }, + { 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://news.google.com/rss/search?q=site:theguardian.com+football+when:2d&hl=en-US&gl=US&ceid=US:en') }, + { 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+when:2d&hl=en-US&gl=US&ceid=US:en') }, - { name: 'The Athletic NBA', url: rss('https://news.google.com/rss/search?q=(NBA+OR+basketball)+analysis+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+OR+baseball)+trade+OR+playoffs+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') }, @@ -918,7 +918,7 @@ const SPORTS_FEEDS: Record = { ], 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+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') }, ], }; diff --git a/src/config/map-layer-definitions.ts b/src/config/map-layer-definitions.ts index bc8b8eca96..1b52ee8023 100644 --- a/src/config/map-layer-definitions.ts +++ b/src/config/map-layer-definitions.ts @@ -66,6 +66,7 @@ export const LAYER_REGISTRY: 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'), @@ -118,6 +119,7 @@ const VARIANT_LAYER_ORDER: Record> = { 'resilienceScore', 'natural', 'weather', 'outages', 'sanctions', 'dayNight', ], sports: [ + 'sportsFixtures', 'dayNight', ], }; @@ -198,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 5163870d27..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, @@ -737,12 +745,15 @@ const HAPPY_MOBILE_MAP_LAYERS: MapLayers = { const SPORTS_PANELS: Record = { map: { name: 'Sports Map', enabled: true, priority: 1 }, sports: { name: 'Sports Headlines', enabled: true, priority: 1 }, - 'sports-fixtures': { name: 'Upcoming Fixtures', 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 }, @@ -790,6 +801,7 @@ const SPORTS_MAP_LAYERS: MapLayers = { accelerators: false, techHQs: false, techEvents: false, + sportsFixtures: true, stockExchanges: false, financialCenters: false, centralBanks: false, @@ -888,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, @@ -950,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, @@ -1022,11 +1036,14 @@ export const VARIANT_PANEL_OVERRIDES: Partial = { map: { name: 'Sports Map', enabled: true, priority: 1 }, sports: { name: 'Sports Headlines', enabled: true, priority: 1 }, - 'sports-fixtures': { name: 'Upcoming Fixtures', 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 }, @@ -63,6 +66,7 @@ export const DEFAULT_MAP_LAYERS: MapLayers = { accelerators: false, techHQs: false, techEvents: false, + sportsFixtures: true, stockExchanges: false, financialCenters: false, centralBanks: false, 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 f99b23bb86..5fff4bbfc3 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1285,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/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 index c907080269..e3d6c36e8f 100644 --- a/src/services/sports.ts +++ b/src/services/sports.ts @@ -1,4 +1,6 @@ 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; @@ -14,6 +16,7 @@ export interface SportsLeagueOption { sport: string; name: string; shortName: string; + country?: string; alternateName?: string; } @@ -40,12 +43,16 @@ export interface SportsEvent { 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 { @@ -87,6 +94,58 @@ export interface SportsStatSnapshot { 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[]; @@ -153,6 +212,8 @@ export interface MotorsportRaceSummary { circuitName?: string; locality?: string; country?: string; + lat?: number; + lng?: number; winner?: string; podium: string[]; fastestLap?: string; @@ -208,12 +269,92 @@ export interface SportsPlayerDetails extends SportsPlayerSearchResult { 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; @@ -222,31 +363,82 @@ type FeaturedLeagueSpec = { const MOTORSPORT_SPECS: FeaturedLeagueSpec[] = [ { label: 'Formula 1', sport: 'Motorsport', aliases: ['formula 1', 'f1'] }, - { label: 'MotoGP', sport: 'Motorsport', aliases: ['motogp'] }, - { label: 'IndyCar', sport: 'Motorsport', aliases: ['indycar'] }, { 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'; -type EspnCompetitionSpec = { - id: string; - sport: SportsLeague['sport']; - sportPath: 'soccer' | 'basketball'; - leaguePath: string; - name: string; - shortName: string; - country?: string; -}; +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' }, @@ -261,6 +453,16 @@ const ESPN_MAJOR_TOURNAMENTS: EspnCompetitionSpec[] = [ { 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; @@ -271,6 +473,11 @@ type SportsDataProvider = 'thesportsdb' | 'espn' | 'espnsite' | 'jolpica' | 'ope 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; } @@ -290,6 +497,12 @@ function toOptionalString(value: unknown): string | undefined { 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; @@ -519,6 +732,7 @@ function mapLeagueOption(raw: Record): SportsLeagueOption | nul name, sport, shortName: buildLeagueShortName(name), + country: toOptionalString(raw.strCountry), alternateName: toOptionalString(raw.strLeagueAlternate), }; } @@ -573,6 +787,777 @@ function uniqueStrings(values: Array): string[] { 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 ?? ''), @@ -588,12 +1573,16 @@ function mapEvent(raw: Record): SportsEvent { 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']), }; } @@ -641,8 +1630,37 @@ function mapEspnCompetitionLeague(spec: EspnCompetitionSpec): SportsLeague { }; } -function buildEspnSiteScoreboardPath(spec: EspnCompetitionSpec): string { - return `/${spec.sportPath}/${spec.leaguePath}/scoreboard`; +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 { @@ -681,6 +1699,7 @@ function mapEspnScoreboardEvent(spec: EspnCompetitionSpec, raw: Record { - const status = (event.strStatus || '').toLowerCase(); - return !status || status.includes('scheduled') || status.includes('not started') || status.includes('time tbd'); - }); - return sortEventsAscending(upcoming).slice(0, limit); +function pickEspnUpcomingEvents(events: SportsEvent[], limit?: number): SportsEvent[] { + const sorted = sortEventsAscending(events); + return limit ? sorted.slice(0, limit) : sorted; } -async function fetchEspnCompetitionEvents(spec: EspnCompetitionSpec): Promise { - const payload = await fetchEspnSiteJson>(buildEspnSiteScoreboardPath(spec), 5 * 60 * 1000); +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); @@ -740,6 +1759,11 @@ async function fetchEspnCompetitionEvents(spec: EspnCompetitionSpec): Promise !!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; @@ -844,21 +1868,297 @@ async function fetchLeagueUpcomingEvents(leagueId: string, limit = 5): Promise { +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(() => []), 3); - return { - league, - events, - }; + 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) => { @@ -874,6 +2174,27 @@ export async function fetchFeaturedSportsTables(): Promise { 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) @@ -886,6 +2207,13 @@ async function fetchEventStats(eventId: string): Promise { .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) { @@ -909,9 +2237,71 @@ function buildFallbackStats(event: SportsEvent): SportsEventStat[] { 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) => { @@ -937,7 +2327,7 @@ export async function fetchMajorTournamentCenterData(tournamentId: string): Prom if (!spec) return null; const rawPayload = await fetchEspnSiteJson>(buildEspnSiteScoreboardPath(spec), 5 * 60 * 1000); - const events = await fetchEspnCompetitionEvents(spec); + const events = mapEspnCompetitionEventsFromPayload(spec, rawPayload); const recentEvents = pickEspnRecentEvents(events, 5); const recentEvent = recentEvents[0] || null; const upcomingEvents = pickEspnUpcomingEvents(events, 5); @@ -1139,6 +2529,180 @@ export async function fetchLeagueCenterData(leagueId: string, season?: string): }; } +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, @@ -1159,6 +2723,127 @@ function getEspnPageStandingValue( 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; @@ -1209,6 +2894,14 @@ function mapNbaStandingEntryFromEspnPage( } 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; @@ -1247,24 +2940,24 @@ export async function fetchNbaStandingsData(): Promise function mapMotorsportStandingRow(raw: Record): MotorsportStandingRow | null { const driver = isRecord(raw.Driver) ? raw.Driver : null; - const constructor = isRecord(raw.Constructor) ? raw.Constructor : 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(constructor?.name); + : toOptionalString(constructorEntry?.name); if (!position || !name) return null; return { rank: position, name, code: toOptionalString(driver?.code), - team: driver ? (toOptionalString(constructor?.name) || toOptionalString(constructors[0]?.name)) : undefined, + 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(constructor?.nationality), + nationality: toOptionalString(driver?.nationality) || toOptionalString(constructorEntry?.nationality), }; } @@ -1394,6 +3087,8 @@ function mapMotorsportRaceSummary(raw: Record): MotorsportRaceS 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, 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/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-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..65f0506029 --- /dev/null +++ b/tests/sports-map-fixtures.test.mts @@ -0,0 +1,470 @@ +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 }> = []; + const fixedNow = new originalDate('2026-04-11T00:30:00+03:00').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 () => { + const fixedNow = new originalDate('2026-04-11T12:00:00+03:00').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/vite.config.ts b/vite.config.ts index f9f9719994..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 @@ -511,99 +512,7 @@ function rssProxyPlugin(): Plugin { } function sportsDataProxyPlugin(): Plugin { - const PROVIDERS = { - thesportsdb: { - baseUrl: 'https://www.thesportsdb.com/api/v1/json/123', - endpoints: new Set([ - '/all_leagues.php', - '/lookupleague.php', - '/search_all_seasons.php', - '/lookuptable.php', - '/eventslast.php', - '/eventsnext.php', - '/lookupeventstats.php', - '/searchplayers.php', - '/lookupplayer.php', - ]), - allowedParams: { - '/all_leagues.php': new Set(), - '/lookupleague.php': new Set(['id']), - '/search_all_seasons.php': new Set(['id']), - '/lookuptable.php': new Set(['l', 's']), - '/eventslast.php': new Set(['id']), - '/eventsnext.php': new Set(['id']), - '/lookupeventstats.php': new Set(['id']), - '/searchplayers.php': new Set(['p']), - '/lookupplayer.php': new Set(['id']), - }, - }, - espn: { - baseUrl: 'https://www.espn.com', - endpoints: new Set(['/nba/standings']), - allowedParams: { - '/nba/standings': new Set(), - }, - }, - espnsite: { - baseUrl: 'https://site.api.espn.com/apis/site/v2/sports', - endpoints: new Set([ - '/soccer/eng.1/scoreboard', - '/soccer/eng.1/summary', - '/soccer/uefa.champions/scoreboard', - '/soccer/uefa.champions/summary', - '/soccer/fifa.world/scoreboard', - '/soccer/fifa.world/summary', - '/soccer/uefa.euro/scoreboard', - '/soccer/uefa.euro/summary', - '/soccer/conmebol.america/scoreboard', - '/soccer/conmebol.america/summary', - '/soccer/conmebol.libertadores/scoreboard', - '/soccer/conmebol.libertadores/summary', - '/basketball/nba/scoreboard', - '/basketball/nba/summary', - ]), - allowedParams: { - '/soccer/eng.1/scoreboard': new Set(), - '/soccer/eng.1/summary': new Set(['event']), - '/soccer/uefa.champions/scoreboard': new Set(), - '/soccer/uefa.champions/summary': new Set(['event']), - '/soccer/fifa.world/scoreboard': new Set(), - '/soccer/fifa.world/summary': new Set(['event']), - '/soccer/uefa.euro/scoreboard': new Set(), - '/soccer/uefa.euro/summary': new Set(['event']), - '/soccer/conmebol.america/scoreboard': new Set(), - '/soccer/conmebol.america/summary': new Set(['event']), - '/soccer/conmebol.libertadores/scoreboard': new Set(), - '/soccer/conmebol.libertadores/summary': new Set(['event']), - '/basketball/nba/scoreboard': new Set(), - '/basketball/nba/summary': new Set(['event']), - }, - }, - jolpica: { - baseUrl: 'https://api.jolpi.ca', - endpoints: new Set([ - '/ergast/f1/current/driverStandings.json', - '/ergast/f1/current/constructorStandings.json', - '/ergast/f1/current/last/results.json', - '/ergast/f1/current/next.json', - ]), - allowedParams: { - '/ergast/f1/current/driverStandings.json': new Set(), - '/ergast/f1/current/constructorStandings.json': new Set(), - '/ergast/f1/current/last/results.json': new Set(), - '/ergast/f1/current/next.json': new Set(), - }, - }, - openf1: { - baseUrl: 'https://api.openf1.org', - endpoints: new Set([ - '/v1/drivers', - ]), - allowedParams: { - '/v1/drivers': new Set(['session_key']), - }, - }, - } as const; + const PROVIDERS = createSportsDataProviders(); return { name: 'sports-data-proxy', @@ -614,7 +523,7 @@ function sportsDataProxyPlugin(): Plugin { } const url = new URL(req.url, 'http://localhost'); - const providerKey = (url.searchParams.get('provider') || 'thesportsdb') as keyof typeof PROVIDERS; + const providerKey = url.searchParams.get('provider') || 'thesportsdb'; const rawPath = url.searchParams.get('path'); if (!rawPath) { res.statusCode = 400; @@ -623,14 +532,15 @@ function sportsDataProxyPlugin(): Plugin { return; } - const provider = PROVIDERS[providerKey]; - if (!provider) { + 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'); From 337fcc32c37e9d31e59878dd798890b222f4a8bb Mon Sep 17 00:00:00 2001 From: fayez bast Date: Mon, 13 Apr 2026 09:18:02 +0300 Subject: [PATCH 3/3] feat(sport): make sports fixture date mocks timezone-stable in CI --- tests/sports-map-fixtures.test.mts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/sports-map-fixtures.test.mts b/tests/sports-map-fixtures.test.mts index 65f0506029..b91397f937 100644 --- a/tests/sports-map-fixtures.test.mts +++ b/tests/sports-map-fixtures.test.mts @@ -120,7 +120,8 @@ describe('fetchSportsFixtureMapMarkers', () => { it('uses the local calendar day when requesting daily fixtures', async () => { const requested: Array<{ provider: string; path: string }> = []; - const fixedNow = new originalDate('2026-04-11T00:30:00+03:00').valueOf(); + // 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[]) { @@ -243,7 +244,8 @@ describe('fetchSportsFixtureMapMarkers', () => { }); it('supplements motorsport fixtures from Jolpica when the event lands on the local day', async () => { - const fixedNow = new originalDate('2026-04-11T12:00:00+03:00').valueOf(); + // 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[]) {