Skip to content

Commit ae26186

Browse files
chore(runway): cherry-pick fix(predict): add extended sports market support for more leagues cp-7.79.0 (#30566)
- fix(predict): add extended sports market support for more leagues cp-7.79.0 (#30559) ## **Description** Adds Predict live sports and extended-market support for additional basketball, baseball, hockey, soccer, and tennis leagues. This expands league parsing and supported flag filtering so newly enabled Polymarket game events can render as game detail experiences instead of generic markets. This also fixes several extended sports details issues found while validating the new leagues: - Parses WNBA, MLB, NHL, ATP, WTA, and ITF game slugs and tennis provider metadata. - Uses tennis `series` and team metadata when ATP/WTA/ITF events only include generic tennis tags. - Keeps extended game charts on the primary moneyline outcome so World Cup and other draw-capable markets load correctly. - Opens extended market cards through the bottom-sheet buy flow instead of the legacy full-screen buy preview. - Adds loading-only chart height reservation to avoid the game details footer jumping while the chart loads. - Adds tennis market labels and separates tennis cards into `Game Lines` and `1st Set` groups. - Aligns footer and card outcome labels, order, and team colors for tennis moneyline and first-set winner markets. ## **Changelog** CHANGELOG entry: Added support for additional Predict sports leagues and extended sports market details. ## **Related issues** Fixes: PRED-925 https://consensyssoftware.atlassian.net/browse/PRED-925 ## **Manual testing steps** ```gherkin Feature: Predict extended sports markets for newly supported leagues Scenario: user opens supported game detail markets Given Predict live sports is enabled for WNBA, MLB, NHL, ATP, WTA, and ITF And Predict extended sports markets is enabled for NBA, WNBA, MLB, NHL, World Cup, UCL, EPL, La Liga, Serie A, Bundesliga, MLS, FIFA Friendlies, ATP, WTA, and ITF When the user opens a supported game market Then the market renders as a game details view And the chart loads from the primary moneyline market And the footer prices match the primary moneyline outcomes Scenario: user opens an ATP, WTA, or ITF tennis game Given the event has generic Tennis and Games tags And the event has ATP, WTA, or ITF league metadata in series or teams When the user opens the game details view Then the event is parsed into the correct tennis league And the tabs show Game Lines and 1st Set And tennis market cards show translated labels And the 1st Set Winner buttons use the same team colors as the footer Scenario: user selects an extended sports market card Given the extended sports market cards are visible When the user taps a card outcome Then the bottom-sheet buy flow opens for that outcome And the app does not navigate to the legacy full-screen buy preview ``` ## **Automated testing** - `node .yarn/releases/yarn-4.14.1.cjs jest app/components/UI/Predict/utils/gameParser.test.ts app/components/UI/Predict/constants/sports.test.ts app/components/UI/Predict/providers/polymarket/utils.test.ts app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.test.tsx app/components/UI/Predict/components/PredictGameChart/PredictGameChart.test.tsx app/components/UI/Predict/components/PredictGameChart/PredictGameChart.wrapper.test.tsx app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsTabsContent.test.tsx app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.test.tsx app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx` - 9 test suites passed - 288 tests passed - `node .yarn/releases/yarn-4.14.1.cjs lint:tsc` ## **Screenshots/Recordings** ### **Before** N/A - no recordings attached in this local PR draft. ### **After** N/A - no recordings attached in this local PR draft. ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - N/A - no Android-specific or performance-sensitive native path changed. - [x] I've tested with a power user scenario - N/A - Predict sports details rendering does not depend on imported wallet size. - [x] I've instrumented key operations with Sentry traces for production performance metrics - N/A - no new production performance operation was added. For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Medium risk because it changes how sports leagues are detected/whitelisted and how UI components select outcome tokens (primary moneyline vs extended markets), which can affect navigation, pricing subscriptions, and displayed teams across multiple sport experiences. > > **Overview** > Extends Predict live/extended sports support to additional leagues (WNBA/MLB/NHL and tennis `atp`/`wta`/`itf`), including updated league whitelisting/types and more robust league detection from event `series`/team metadata when tags are missing. > > Standardizes **"primary" moneyline selection** via `getPrimaryMoneylineOutcomes`, and updates the footer buttons, market sport cards, and game charts to ignore non-moneyline extended outcomes (especially for draw-capable leagues) and to map tennis/home-away tokens to the correct team labels/colors. > > Improves game details UX by routing outcomes-tab buys through the shared `onBetPress` bottom-sheet flow (instead of navigation) and reserving chart height only while loading to avoid layout jump; adds tennis group ordering/labels (e.g., `first_set`) and corresponding i18n strings. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c2a0122. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> [788641c](788641c) Co-authored-by: Caainã Jeronimo <caainaje@gmail.com>
1 parent 06135e3 commit ae26186

22 files changed

Lines changed: 1118 additions & 87 deletions

app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.test.tsx

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,80 @@ describe('PredictActionButtons', () => {
316316
expect(screen.getAllByText('35¢')).toHaveLength(1);
317317
});
318318

319+
it('uses token-matched teams when a game moneyline returns home-away tokens', () => {
320+
const outcome = createMockOutcome({
321+
sportsMarketType: 'moneyline',
322+
tokens: [
323+
{
324+
id: 'token-ivashka',
325+
title: 'Ilya Ivashka',
326+
shortTitle: 'IVASHKA',
327+
price: 0.63,
328+
},
329+
{
330+
id: 'token-stewart',
331+
title: 'Hamish Stewart',
332+
shortTitle: 'STEWART',
333+
price: 0.38,
334+
},
335+
],
336+
});
337+
const market = createMockMarket({
338+
outcomes: [outcome],
339+
game: {
340+
id: 'game-atp-1',
341+
startTime: '2026-05-22T07:30:00Z',
342+
status: 'scheduled',
343+
league: 'atp',
344+
elapsed: null,
345+
period: null,
346+
score: null,
347+
awayTeam: {
348+
id: 'stewart',
349+
name: 'Hamish Stewart',
350+
logo: 'https://example.com/stewart.png',
351+
abbreviation: 'STEWART',
352+
color: TEST_HEX_COLORS.TEAM_SEA,
353+
alias: 'H. Stewart',
354+
},
355+
homeTeam: {
356+
id: 'ivashka',
357+
name: 'Ilya Ivashka',
358+
logo: 'https://example.com/ivashka.png',
359+
abbreviation: 'IVASHKA',
360+
color: TEST_HEX_COLORS.TEAM_DEN,
361+
alias: 'I. Ivashka',
362+
},
363+
},
364+
});
365+
366+
const mockOnBetPress = jest.fn();
367+
const props = createDefaultProps({
368+
market,
369+
outcome,
370+
onBetPress: mockOnBetPress,
371+
});
372+
373+
renderWithProvider(<PredictActionButtons {...props} />);
374+
375+
expect(screen.getByText('IVASHKA')).toBeOnTheScreen();
376+
expect(screen.getByText('STEWART')).toBeOnTheScreen();
377+
expect(screen.getAllByText('63¢')).toHaveLength(1);
378+
expect(screen.getAllByText('38¢')).toHaveLength(1);
379+
380+
fireEvent.press(screen.getByTestId('action-buttons-bet-yes'));
381+
fireEvent.press(screen.getByTestId('action-buttons-bet-no'));
382+
383+
expect(mockOnBetPress).toHaveBeenNthCalledWith(
384+
1,
385+
expect.objectContaining({ id: 'token-ivashka' }),
386+
);
387+
expect(mockOnBetPress).toHaveBeenNthCalledWith(
388+
2,
389+
expect.objectContaining({ id: 'token-stewart' }),
390+
);
391+
});
392+
319393
it('calls onBetPress with correct token for away team', () => {
320394
const mockOnBetPress = jest.fn();
321395
const outcome = createMockOutcome();
@@ -379,6 +453,49 @@ describe('PredictActionButtons', () => {
379453
expect.objectContaining({ id: 'token-draw' }),
380454
);
381455
});
456+
457+
it('ignores extended non-moneyline outcomes for draw-capable leagues', () => {
458+
const market = createMockDrawCapableGameMarket();
459+
const [awayOutcome, drawOutcome, homeOutcome] = market.outcomes;
460+
const extendedMarket = {
461+
...market,
462+
outcomes: [
463+
createMockOutcome({
464+
id: 'outcome-spread',
465+
sportsMarketType: 'spreads',
466+
groupItemThreshold: -2.5,
467+
tokens: [{ id: 'token-spread', title: 'Spread', price: 0.16 }],
468+
}),
469+
{ ...awayOutcome, sportsMarketType: 'moneyline' },
470+
createMockOutcome({
471+
id: 'outcome-halftime',
472+
sportsMarketType: 'soccer_halftime_result',
473+
groupItemThreshold: 1,
474+
tokens: [{ id: 'token-halftime', title: 'Draw', price: 0.2 }],
475+
}),
476+
{ ...drawOutcome, sportsMarketType: 'moneyline' },
477+
{ ...homeOutcome, sportsMarketType: 'moneyline' },
478+
],
479+
};
480+
481+
const props = createDefaultProps({
482+
market: extendedMarket,
483+
outcome: extendedMarket.outcomes[0],
484+
});
485+
486+
renderWithProvider(<PredictActionButtons {...props} />);
487+
488+
expect(screen.getByText('ARS')).toBeOnTheScreen();
489+
expect(screen.getByText('DRAW')).toBeOnTheScreen();
490+
expect(screen.getByText('PSG')).toBeOnTheScreen();
491+
expect(screen.getAllByText('42¢')).toHaveLength(1);
492+
expect(screen.getAllByText('30¢')).toHaveLength(1);
493+
expect(screen.getAllByText('28¢')).toHaveLength(1);
494+
expect(mockUseLiveMarketPrices).toHaveBeenCalledWith(
495+
['token-home', 'token-draw', 'token-away'],
496+
{ enabled: true },
497+
);
498+
});
382499
});
383500

384501
describe('priority order', () => {

app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.tsx

Lines changed: 73 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,16 @@ import {
77
PredictActionButtonsProps,
88
PredictBetButtonLayout,
99
} from './PredictActionButtons.types';
10-
import { PredictMarketStatus, PredictOutcomeToken } from '../../types';
10+
import {
11+
PredictMarketGame,
12+
PredictMarketStatus,
13+
PredictOutcomeToken,
14+
} from '../../types';
1115
import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices';
12-
import { isDrawCapableLeague } from '../../constants/sports';
16+
import {
17+
getPrimaryMoneylineOutcomes,
18+
isDrawCapableLeague,
19+
} from '../../constants/sports';
1320
import {
1421
BASE_PREDICT_ACTION_BUTTONS_TEST_IDS,
1522
PREDICT_ACTION_BUTTONS_TEST_IDS,
@@ -29,6 +36,38 @@ interface ButtonConfig {
2936
drawToken?: PredictOutcomeToken;
3037
}
3138

39+
type GameTeam = PredictMarketGame['homeTeam'];
40+
41+
const normalizeLabel = (value?: string): string | undefined =>
42+
value?.trim().toLowerCase();
43+
44+
const teamMatchesToken = (
45+
team: GameTeam,
46+
token: PredictOutcomeToken,
47+
): boolean => {
48+
const tokenLabels = [token.shortTitle, token.title]
49+
.map(normalizeLabel)
50+
.filter((label): label is string => Boolean(label));
51+
const teamLabels = [team.abbreviation, team.name, team.alias]
52+
.map(normalizeLabel)
53+
.filter((label): label is string => Boolean(label));
54+
55+
return tokenLabels.some((tokenLabel) => teamLabels.includes(tokenLabel));
56+
};
57+
58+
const getTokenTeam = (
59+
token: PredictOutcomeToken,
60+
game: PredictMarketGame,
61+
): GameTeam | undefined => {
62+
if (teamMatchesToken(game.homeTeam, token)) {
63+
return game.homeTeam;
64+
}
65+
if (teamMatchesToken(game.awayTeam, token)) {
66+
return game.awayTeam;
67+
}
68+
return undefined;
69+
};
70+
3271
const PredictActionButtons: React.FC<PredictActionButtonsProps> = ({
3372
market,
3473
outcome,
@@ -45,21 +84,33 @@ const PredictActionButtons: React.FC<PredictActionButtonsProps> = ({
4584
}) => {
4685
const isGameMarket = Boolean(market.game);
4786
const isMarketOpen = market.status === PredictMarketStatus.OPEN;
87+
const moneylineOutcomes = useMemo(
88+
() => getPrimaryMoneylineOutcomes(market.outcomes),
89+
[market.outcomes],
90+
);
91+
const hasMainMoneylineOutcomes = moneylineOutcomes.some(
92+
(marketOutcome) =>
93+
marketOutcome.sportsMarketType?.toLowerCase() === 'moneyline',
94+
);
95+
const primaryOutcome =
96+
hasMainMoneylineOutcomes && !moneylineOutcomes.includes(outcome)
97+
? (moneylineOutcomes[0] ?? outcome)
98+
: outcome;
4899

49100
const isDrawCapable =
50101
isGameMarket &&
51102
market.game &&
52103
isDrawCapableLeague(market.game.league) &&
53-
market.outcomes.length >= 3;
104+
moneylineOutcomes.length >= 3;
54105

55106
const sortedOutcomes = useMemo(() => {
56107
if (!isDrawCapable) {
57108
return null;
58109
}
59-
return [...market.outcomes].sort(
110+
return [...moneylineOutcomes].sort(
60111
(a, b) => (a.groupItemThreshold ?? 0) - (b.groupItemThreshold ?? 0),
61112
);
62-
}, [isDrawCapable, market.outcomes]);
113+
}, [isDrawCapable, moneylineOutcomes]);
63114

64115
const tokenIds = useMemo(() => {
65116
if (sortedOutcomes) {
@@ -68,8 +119,8 @@ const PredictActionButtons: React.FC<PredictActionButtonsProps> = ({
68119
.filter((tokenId): tokenId is string => Boolean(tokenId));
69120
}
70121

71-
return outcome.tokens.map((token) => token.id);
72-
}, [sortedOutcomes, outcome.tokens]);
122+
return primaryOutcome.tokens.map((token) => token.id);
123+
}, [sortedOutcomes, primaryOutcome.tokens]);
73124

74125
const { getPrice } = useLiveMarketPrices(tokenIds, {
75126
enabled: isMarketOpen && !isLoading,
@@ -110,7 +161,7 @@ const PredictActionButtons: React.FC<PredictActionButtonsProps> = ({
110161
};
111162
}
112163

113-
const tokens = outcome.tokens;
164+
const tokens = primaryOutcome.tokens;
114165
if (tokens.length < 2) {
115166
return null;
116167
}
@@ -126,14 +177,17 @@ const PredictActionButtons: React.FC<PredictActionButtonsProps> = ({
126177

127178
if (isGameMarket && market.game) {
128179
const { awayTeam, homeTeam } = market.game;
180+
const yesTeam = getTokenTeam(yesToken, market.game) ?? awayTeam;
181+
const noTeam = getTokenTeam(noToken, market.game) ?? homeTeam;
182+
129183
return {
130-
yesLabel: awayTeam.abbreviation,
184+
yesLabel: yesTeam.abbreviation,
131185
yesPrice: Math.round(yesPrice * 100),
132-
yesTeamColor: awayTeam.color,
186+
yesTeamColor: yesTeam.color,
133187
yesToken,
134-
noLabel: homeTeam.abbreviation,
188+
noLabel: noTeam.abbreviation,
135189
noPrice: Math.round(noPrice * 100),
136-
noTeamColor: homeTeam.color,
190+
noTeamColor: noTeam.color,
137191
noToken,
138192
};
139193
}
@@ -148,7 +202,13 @@ const PredictActionButtons: React.FC<PredictActionButtonsProps> = ({
148202
noTeamColor: undefined,
149203
noToken,
150204
};
151-
}, [outcome.tokens, isGameMarket, market.game, sortedOutcomes, getPrice]);
205+
}, [
206+
primaryOutcome.tokens,
207+
isGameMarket,
208+
market.game,
209+
sortedOutcomes,
210+
getPrice,
211+
]);
152212

153213
if (isLoading) {
154214
return (

app/components/UI/Predict/components/PredictGameChart/PredictGameChart.constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
export const CHART_HEIGHT = 200;
2+
export const TIMEFRAME_SELECTOR_RESERVED_HEIGHT = 56;
3+
export const CHART_WITH_TIMEFRAME_SELECTOR_HEIGHT =
4+
CHART_HEIGHT + TIMEFRAME_SELECTOR_RESERVED_HEIGHT;
25
export const FONT_SIZE_LABEL = 14;
36
export const FONT_SIZE_VALUE = 24;
47
export const LABEL_HEIGHT = 40;

app/components/UI/Predict/components/PredictGameChart/PredictGameChart.test.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import renderWithProvider from '../../../../../util/test/renderWithProvider';
44
import PredictGameChartContent from './PredictGameChartContent';
55
import { GameChartSeries } from './PredictGameChart.types';
66
import { TEST_HEX_COLORS } from '../../testUtils/mockColors';
7+
import { CHART_WITH_TIMEFRAME_SELECTOR_HEIGHT } from './PredictGameChart.constants';
78

89
jest.mock('react-native-svg-charts', () => {
910
const { View, Text } = jest.requireActual('react-native');
@@ -313,6 +314,35 @@ describe('PredictGameChartContent (Chart UI)', () => {
313314

314315
expect(getByText('Live')).toBeOnTheScreen();
315316
});
317+
318+
it('reserves chart height only while loading', () => {
319+
const onTimeframeChange = jest.fn();
320+
321+
const { getByTestId, rerender } = renderWithProvider(
322+
<PredictGameChartContent
323+
data={[]}
324+
isLoading
325+
onTimeframeChange={onTimeframeChange}
326+
testID="chart"
327+
/>,
328+
);
329+
330+
expect(getByTestId('chart')).toHaveStyle({
331+
minHeight: CHART_WITH_TIMEFRAME_SELECTOR_HEIGHT,
332+
});
333+
334+
rerender(
335+
<PredictGameChartContent
336+
data={mockDualSeriesData}
337+
onTimeframeChange={onTimeframeChange}
338+
testID="chart"
339+
/>,
340+
);
341+
342+
expect(getByTestId('chart')).not.toHaveStyle({
343+
minHeight: CHART_WITH_TIMEFRAME_SELECTOR_HEIGHT,
344+
});
345+
});
316346
});
317347

318348
describe('Data Processing', () => {

app/components/UI/Predict/components/PredictGameChart/PredictGameChart.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import React, {
88
import { PredictGameStatus, PredictPriceHistoryInterval } from '../../types';
99
import { usePredictPriceHistory } from '../../hooks/usePredictPriceHistory';
1010
import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices';
11-
import { isDrawCapableLeague } from '../../constants/sports';
11+
import {
12+
getPrimaryMoneylineOutcomes,
13+
isDrawCapableLeague,
14+
} from '../../constants/sports';
1215
import { useTheme } from '../../../../../util/theme';
1316
import PredictGameChartContent from './PredictGameChartContent';
1417
import {
@@ -72,27 +75,31 @@ const PredictGameChart: React.FC<PredictGameChartProps> = ({
7275
const gameStatus = game?.status;
7376
const isGameEnded = gameStatus === 'ended';
7477
const isGameOngoing = gameStatus === 'ongoing';
78+
const moneylineOutcomes = useMemo(
79+
() => getPrimaryMoneylineOutcomes(market.outcomes),
80+
[market.outcomes],
81+
);
7582

7683
const tokenIds = useMemo(() => {
7784
if (
7885
game?.league &&
7986
isDrawCapableLeague(game.league) &&
80-
market.outcomes.length >= 3
87+
moneylineOutcomes.length >= 3
8188
) {
82-
return [...market.outcomes]
89+
return [...moneylineOutcomes]
8390
.sort(
8491
(a, b) => (a.groupItemThreshold ?? 0) - (b.groupItemThreshold ?? 0),
8592
)
8693
.map((o) => o.tokens[0]?.id)
8794
.filter((id): id is string => Boolean(id));
8895
}
89-
const tokens = market.outcomes[0]?.tokens ?? [];
96+
const tokens = moneylineOutcomes[0]?.tokens ?? [];
9097
return tokens.map((t) => t.id);
91-
}, [market.outcomes, game?.league]);
98+
}, [moneylineOutcomes, game?.league]);
9299

93100
const seriesConfig: GameChartSeriesConfig[] | null = useMemo(() => {
94101
if (!game) return null;
95-
if (isDrawCapableLeague(game.league) && market.outcomes.length >= 3) {
102+
if (isDrawCapableLeague(game.league) && moneylineOutcomes.length >= 3) {
96103
return [
97104
{ label: game.homeTeam.abbreviation, color: game.homeTeam.color },
98105
{ label: 'DRAW', color: colors.icon.muted },
@@ -103,7 +110,7 @@ const PredictGameChart: React.FC<PredictGameChartProps> = ({
103110
{ label: game.awayTeam.abbreviation, color: game.awayTeam.color },
104111
{ label: game.homeTeam.abbreviation, color: game.homeTeam.color },
105112
];
106-
}, [game, market.outcomes.length, colors.icon.muted]);
113+
}, [game, moneylineOutcomes.length, colors.icon.muted]);
107114

108115
const [timeframe, setTimeframe] = useState<ChartTimeframe>(() =>
109116
getDefaultTimeframe(gameStatus),

0 commit comments

Comments
 (0)