import React from 'react'; import { Placeholder } from '@times-components/image'; import { PlaceholderContainer } from '../../../shared/shared-styles'; import { WPWrapper, WPTitle, WPBarContainer, WPBarTrack, WPBarSegment, WPDrawSegment, WPLabelsRow, WPLabel, WPLoadingWrapper, WPSkeletonBarTrack, WPSkeletonLabelsRow, WPStateOverlay, WPStateMessage } from './styles'; import { TeamColor } from '../shared/types'; import { useFetchFeed } from '../useFetchFeed'; import { buildWinProbabilityUrl } from '../api'; import { homeTeamColor as defaultHomeColor, awayTeamColor as defaultAwayColor } from '../shared/styles'; type PredictionEntry = { type: 'Home' | 'Away' | 'Draw'; probability: string; }; type PredictionGroup = { type: 'Win'; prediction: PredictionEntry[]; }; type Contestant = { id: string; name: string; shortName: string; officialName: string; code: string; position: 'home' | 'away'; country?: { id: string; name: string }; }; export type WinProbabilityFeed = { matchInfo: { id: string; description?: string; contestant: Contestant[]; }; liveData: { matchDetails?: { matchStatus?: string; periodId?: number; }; /** Present when the match has not yet started */ preMatchPredictions?: PredictionGroup[]; /** Present / updated during the live match */ overallLivePredictions?: PredictionGroup[]; }; }; type WPData = { homeTeam: { name: string; probability: number }; awayTeam: { name: string; probability: number }; draw: { probability: number }; }; const getProbability = ( predictions: PredictionEntry[], type: 'Home' | 'Away' | 'Draw' ): number => { const entry = predictions.find(p => p.type === type); return entry ? parseFloat(entry.probability) : 0; }; const mapFeedToWPData = (feed: WinProbabilityFeed): WPData | undefined => { const contestants = feed.matchInfo.contestant || []; const homeContestant = contestants.find(c => c.position === 'home'); const awayContestant = contestants.find(c => c.position === 'away'); if (!homeContestant || !awayContestant) { return undefined; } // Prefer live predictions when available, fall back to pre-match const liveData = feed.liveData || {}; const livePredGroup = liveData.overallLivePredictions && liveData.overallLivePredictions.find(g => g.type === 'Win'); const prePredGroup = liveData.preMatchPredictions && liveData.preMatchPredictions.find(g => g.type === 'Win'); const predGroup = livePredGroup || prePredGroup; if (!predGroup) { return undefined; } const predictions = predGroup.prediction; return { homeTeam: { name: homeContestant.shortName || homeContestant.name, probability: getProbability(predictions, 'Home') }, awayTeam: { name: awayContestant.shortName || awayContestant.name, probability: getProbability(predictions, 'Away') }, draw: { probability: getProbability(predictions, 'Draw') } }; }; const formatPct = (value: number): string => `${value.toFixed(1)}%`; export const OptaMatchStatsWinProbability: React.FC<{ /** Pass a pre-fetched feed directly (e.g. for testing / SSR). */ feed?: WinProbabilityFeed; /** Match / fixture ID used to build the API URL. */ match?: string; /** Base URL for the API (without trailing slash). */ apiBaseUrl?: string; /** Override home team colour. */ homeColor?: TeamColor; /** Override away team colour. */ awayColor?: TeamColor; /** When true, enables dark-mode media queries. */ isApp?: boolean; }> = React.memo( ({ feed, match, apiBaseUrl, homeColor = defaultHomeColor, awayColor = defaultAwayColor, isApp = false }) => { const fetchUrl = match && apiBaseUrl ? buildWinProbabilityUrl(apiBaseUrl, match) : undefined; // Initial fetch (no polling) to determine match status const { data: fetchedFeed, loading, error } = useFetchFeed< WinProbabilityFeed >(fetchUrl); const isLiveMatch = fetchedFeed && fetchedFeed.liveData && fetchedFeed.liveData.matchDetails && fetchedFeed.liveData.matchDetails.matchStatus === 'Playing'; // Poll every 60 s while the match is live (per Opta guidelines) const { data: polledFeed } = useFetchFeed( isLiveMatch ? fetchUrl : undefined, { pollingInterval: 60000 } ); const activeFeed = polledFeed || fetchedFeed || feed; let wpData: WPData | undefined; try { wpData = activeFeed ? mapFeedToWPData(activeFeed) : undefined; } catch { wpData = undefined; } const skeletonContent = ( Win Probability ); if (!wpData) { if (loading) { return ( {skeletonContent} ); } if (error) { return ( {skeletonContent} No data found. ); } return null; } const { homeTeam, awayTeam, draw } = wpData; const total = homeTeam.probability + awayTeam.probability + draw.probability; // Normalise to 100 % in case of floating-point drift const homePct = total > 0 ? (homeTeam.probability / total) * 100 : 0; const awayPct = total > 0 ? (awayTeam.probability / total) * 100 : 0; const drawPct = total > 0 ? (draw.probability / total) * 100 : 0; return ( Win Probability {formatPct(homeTeam.probability)} {formatPct(draw.probability)} {formatPct(awayTeam.probability)} ); } );