Skip to content

Commit 0c60aeb

Browse files
authored
feat(predict): Add World Cup tab data hooks and content (#30162)
## **Description** Adds the World Cup Predict data layer and wires it into the dedicated World Cup screen so the tab row and tab content are driven by Polymarket data instead of placeholders. This includes: - World Cup-specific query builders/options for All, Live, Props, and configured stage tabs. - Lightweight availability checks for tabs that should be hidden when empty. - A unified cached `usePredictWorldCupMarkets` hook using React Query for tab content. - Prefetching visible World Cup tab content after availability resolves to reduce skeletons during tab switches. - World Cup screen tab rendering and market list content using existing Predict market cards. - Unit tests for query shapes, availability behavior, caching, hidden tabs, and World Cup game parsing. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: PRED-879 ## **Manual testing steps** ```gherkin Feature: Predict World Cup tab data Scenario: user views and switches World Cup tabs Given the Predict World Cup feature flag is enabled with World Cup identifiers and stage event IDs And the user opens the World Cup Predict screen When tab availability finishes loading Then tabs with active content are visible And tabs without active content are hidden When user switches between All, Props, Live, and configured stage tabs Then market content loads for the selected tab And previously loaded tabs reuse cached data without showing the full loading skeleton again ``` ## **Screenshots/Recordings** N/A — data/query wiring and existing UI composition. Manually validated in simulator during development. ### **Before** N/A ### **After** https://github.com/user-attachments/assets/aadd8ec0-d5a6-4896-b828-af1e0662d908 ## **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 - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example 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 adds new React Query data-fetching/prefetching paths and changes the World Cup screen to render paginated market lists and conditional tabs based on API-driven availability. > > **Overview** > **World Cup Predict is now data-driven instead of placeholder-driven.** The PR introduces a World Cup-specific data layer (query keys/options, services, and hooks) to fetch Polymarket markets for `All`, `Live`, `Props`, and configured stage tabs, including pagination where appropriate and deterministic start-time sorting for stage/game markets. > > Tabs are now conditionally shown/hidden based on lightweight availability checks, and `resolvePredictWorldCupInitialTab`/`getPredictWorldCupAvailableTabKeys` take this availability into account. The World Cup screen is rewired to use `usePredictWorldCupAvailableTabs` + `usePredictWorldCupMarkets`, rendering a `FlashList` of `PredictMarket` cards with skeleton/loading, pull-to-refresh, error state, and prefetching of visible tabs to reduce reloads on tab switches. > > Adds/updates unit tests covering query shapes, tab availability + initial-tab fallback, market hook caching/pagination behavior, stage edge cases (no event IDs), and World Cup game parsing in Polymarket utils. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 240a4a1. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent f9d1e62 commit 0c60aeb

13 files changed

Lines changed: 1595 additions & 48 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import {
2+
getPredictWorldCupAvailableTabKeys,
3+
PREDICT_WORLD_CUP_TAB_KEYS,
4+
resolvePredictWorldCupInitialTab,
5+
} from './worldCupTabs';
6+
7+
const config = {
8+
stages: [
9+
{ key: 'group-stage', eventIds: ['123'] },
10+
{ key: 'final', eventIds: ['456'] },
11+
],
12+
};
13+
14+
describe('worldCupTabs', () => {
15+
describe('getPredictWorldCupAvailableTabKeys', () => {
16+
it('returns all fixed and configured stage tabs when availability is not supplied', () => {
17+
expect(getPredictWorldCupAvailableTabKeys(config)).toEqual([
18+
PREDICT_WORLD_CUP_TAB_KEYS.ALL,
19+
PREDICT_WORLD_CUP_TAB_KEYS.LIVE,
20+
PREDICT_WORLD_CUP_TAB_KEYS.PROPS,
21+
'group-stage',
22+
'final',
23+
]);
24+
});
25+
26+
it('hides Live, Props, and stage tabs when availability is false', () => {
27+
expect(
28+
getPredictWorldCupAvailableTabKeys(config, {
29+
live: false,
30+
props: true,
31+
stages: {
32+
'group-stage': false,
33+
final: true,
34+
},
35+
}),
36+
).toEqual([PREDICT_WORLD_CUP_TAB_KEYS.ALL, 'props', 'final']);
37+
});
38+
});
39+
40+
describe('resolvePredictWorldCupInitialTab', () => {
41+
it('falls back to All when requested tab is hidden by availability', () => {
42+
expect(
43+
resolvePredictWorldCupInitialTab('group-stage', config, {
44+
live: true,
45+
props: true,
46+
stages: {
47+
'group-stage': false,
48+
final: true,
49+
},
50+
}),
51+
).toBe(PREDICT_WORLD_CUP_TAB_KEYS.ALL);
52+
});
53+
});
54+
});

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,43 @@ export const PREDICT_WORLD_CUP_TAB_KEYS = {
1010

1111
export type PredictWorldCupTabKey = string;
1212

13+
export interface PredictWorldCupTabAvailability {
14+
live: boolean;
15+
props: boolean;
16+
stages: Record<string, boolean>;
17+
}
18+
1319
export const getPredictWorldCupAvailableTabKeys = (
1420
config?: Pick<PredictWorldCupConfig, 'stages'>,
21+
availability?: PredictWorldCupTabAvailability,
1522
): string[] => [
16-
...Object.values(PREDICT_WORLD_CUP_TAB_KEYS),
17-
...(config?.stages ?? []).map((stage) => stage.key),
23+
PREDICT_WORLD_CUP_TAB_KEYS.ALL,
24+
...(!availability || availability.live
25+
? [PREDICT_WORLD_CUP_TAB_KEYS.LIVE]
26+
: []),
27+
...(!availability || availability.props
28+
? [PREDICT_WORLD_CUP_TAB_KEYS.PROPS]
29+
: []),
30+
...(config?.stages ?? [])
31+
.filter((stage) => !availability || availability.stages[stage.key])
32+
.map((stage) => stage.key),
1833
];
1934

2035
export const resolvePredictWorldCupInitialTab = (
2136
requestedTab?: string | null,
2237
config?: Pick<PredictWorldCupConfig, 'stages'>,
38+
availability?: PredictWorldCupTabAvailability,
2339
): PredictWorldCupTabKey => {
2440
const normalizedTab = requestedTab?.toLowerCase();
2541

2642
if (!normalizedTab) {
2743
return PREDICT_WORLD_CUP_TAB_KEYS.ALL;
2844
}
2945

30-
const availableTabKeys = getPredictWorldCupAvailableTabKeys(config);
46+
const availableTabKeys = getPredictWorldCupAvailableTabKeys(
47+
config,
48+
availability,
49+
);
3150

3251
return availableTabKeys.includes(normalizedTab)
3352
? normalizedTab

app/components/UI/Predict/hooks/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,12 @@ export {
2525
} from './usePredictSearch';
2626

2727
export { usePredictCashOut } from './usePredictCashOut';
28+
29+
export {
30+
usePredictWorldCupMarkets,
31+
usePredictWorldCupAvailability,
32+
usePredictWorldCupAvailableTabs,
33+
type UsePredictWorldCupMarketsOptions,
34+
type UsePredictWorldCupAvailableTabsOptions,
35+
type PredictWorldCupAvailableTab,
36+
} from './usePredictWorldCup';
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import React from 'react';
2+
import { renderHook, waitFor } from '@testing-library/react-native';
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4+
import Engine from '../../../../core/Engine';
5+
import { DEFAULT_PREDICT_WORLD_CUP_FLAG } from '../constants/flags';
6+
import { Recurrence, type PredictMarket } from '../types';
7+
import { usePredictWorldCupMarkets } from './usePredictWorldCup';
8+
9+
jest.mock('../../../../core/Engine', () => ({
10+
context: {
11+
PredictController: {
12+
getMarkets: jest.fn(),
13+
},
14+
},
15+
}));
16+
17+
const mockGetMarkets = jest.mocked(Engine.context.PredictController.getMarkets);
18+
19+
const createWrapper = () => {
20+
const queryClient = new QueryClient({
21+
defaultOptions: { queries: { cacheTime: Infinity, retry: false } },
22+
});
23+
const Wrapper = ({ children }: { children: React.ReactNode }) =>
24+
React.createElement(QueryClientProvider, { client: queryClient }, children);
25+
return { Wrapper, queryClient };
26+
};
27+
28+
const createMarket = (
29+
overrides: Partial<PredictMarket> = {},
30+
): PredictMarket => ({
31+
id: 'market-1',
32+
providerId: 'polymarket',
33+
slug: 'market-1',
34+
title: 'Market 1',
35+
description: 'Description',
36+
image: 'image.png',
37+
status: 'open',
38+
recurrence: Recurrence.NONE,
39+
category: 'hot',
40+
tags: [],
41+
outcomes: [],
42+
liquidity: 0,
43+
volume: 0,
44+
...overrides,
45+
});
46+
47+
describe('usePredictWorldCupMarkets', () => {
48+
beforeEach(() => {
49+
jest.clearAllMocks();
50+
});
51+
52+
it('requests All markets with a cached paginated query', async () => {
53+
const { Wrapper } = createWrapper();
54+
const allMarket = createMarket({ id: 'all-market' });
55+
mockGetMarkets.mockResolvedValue([allMarket]);
56+
57+
const { result } = renderHook(
58+
() =>
59+
usePredictWorldCupMarkets({
60+
tabKey: 'all',
61+
config: DEFAULT_PREDICT_WORLD_CUP_FLAG,
62+
pageSize: 30,
63+
}),
64+
{ wrapper: Wrapper },
65+
);
66+
67+
await waitFor(() => expect(result.current.marketData).toEqual([allMarket]));
68+
69+
expect(mockGetMarkets).toHaveBeenCalledWith({
70+
category: 'hot',
71+
customQueryParams:
72+
'active=true&archived=false&closed=false&tag_slug=fifa-world-cup&order=volume24hr&ascending=false',
73+
limit: 30,
74+
offset: 0,
75+
});
76+
expect(result.current.hasMore).toBe(false);
77+
});
78+
79+
it('requests Props markets with a cached paginated query', async () => {
80+
const { Wrapper } = createWrapper();
81+
const propsMarket = createMarket({ id: 'props-market' });
82+
mockGetMarkets.mockResolvedValue([propsMarket]);
83+
84+
const { result } = renderHook(
85+
() =>
86+
usePredictWorldCupMarkets({
87+
tabKey: 'props',
88+
config: DEFAULT_PREDICT_WORLD_CUP_FLAG,
89+
}),
90+
{ wrapper: Wrapper },
91+
);
92+
93+
await waitFor(() =>
94+
expect(result.current.marketData).toEqual([propsMarket]),
95+
);
96+
97+
expect(mockGetMarkets).toHaveBeenCalledWith(
98+
expect.objectContaining({
99+
customQueryParams:
100+
'active=true&archived=false&closed=false&tag_slug=fifa-world-cup&exclude_tag_id=100639&order=volume&ascending=false',
101+
}),
102+
);
103+
});
104+
105+
it('requests Live markets without pagination', async () => {
106+
const { Wrapper } = createWrapper();
107+
const liveMarket = createMarket({ id: 'live-market' });
108+
mockGetMarkets.mockResolvedValue([liveMarket]);
109+
110+
const { result } = renderHook(
111+
() =>
112+
usePredictWorldCupMarkets({
113+
tabKey: 'live',
114+
config: DEFAULT_PREDICT_WORLD_CUP_FLAG,
115+
}),
116+
{ wrapper: Wrapper },
117+
);
118+
119+
await waitFor(() =>
120+
expect(result.current.marketData).toEqual([liveMarket]),
121+
);
122+
123+
expect(mockGetMarkets).toHaveBeenCalledWith(
124+
expect.objectContaining({
125+
customQueryParams:
126+
'active=true&archived=false&closed=false&series_id=11433&tag_id=100639&live=true&order=startDate',
127+
}),
128+
);
129+
expect(result.current.hasMore).toBe(false);
130+
expect(result.current.isFetchingMore).toBe(false);
131+
});
132+
133+
it('requests stage markets with all configured event IDs and no pagination', async () => {
134+
const { Wrapper } = createWrapper();
135+
const stageMarket = createMarket({ id: 'stage-market' });
136+
const config = {
137+
...DEFAULT_PREDICT_WORLD_CUP_FLAG,
138+
stages: [{ key: 'group-stage', eventIds: ['123', '456'] }],
139+
};
140+
mockGetMarkets.mockResolvedValue([stageMarket]);
141+
142+
const { result } = renderHook(
143+
() =>
144+
usePredictWorldCupMarkets({
145+
tabKey: 'group-stage',
146+
config,
147+
}),
148+
{ wrapper: Wrapper },
149+
);
150+
151+
await waitFor(() =>
152+
expect(result.current.marketData).toEqual([stageMarket]),
153+
);
154+
155+
expect(mockGetMarkets).toHaveBeenCalledWith(
156+
expect.objectContaining({
157+
customQueryParams:
158+
'active=true&archived=false&closed=false&id=123&id=456',
159+
limit: 2,
160+
offset: 0,
161+
}),
162+
);
163+
expect(result.current.hasMore).toBe(false);
164+
});
165+
166+
it('skips fetching for a stage without configured event IDs', () => {
167+
const { Wrapper } = createWrapper();
168+
const config = {
169+
...DEFAULT_PREDICT_WORLD_CUP_FLAG,
170+
stages: [{ key: 'empty-stage', eventIds: [] }],
171+
};
172+
173+
const { result } = renderHook(
174+
() =>
175+
usePredictWorldCupMarkets({
176+
tabKey: 'empty-stage',
177+
config,
178+
}),
179+
{ wrapper: Wrapper },
180+
);
181+
182+
expect(result.current.marketData).toEqual([]);
183+
expect(mockGetMarkets).not.toHaveBeenCalled();
184+
});
185+
186+
it('returns cached data when switching back to a previously loaded tab', async () => {
187+
const { Wrapper } = createWrapper();
188+
const allMarket = createMarket({ id: 'all-market' });
189+
const liveMarket = createMarket({ id: 'live-market' });
190+
mockGetMarkets
191+
.mockResolvedValueOnce([allMarket])
192+
.mockResolvedValueOnce([liveMarket]);
193+
194+
const { result, rerender } = renderHook(
195+
({ tabKey }: { tabKey: 'all' | 'live' }) =>
196+
usePredictWorldCupMarkets({
197+
tabKey,
198+
config: DEFAULT_PREDICT_WORLD_CUP_FLAG,
199+
}),
200+
{ initialProps: { tabKey: 'all' }, wrapper: Wrapper },
201+
);
202+
203+
await waitFor(() => expect(result.current.marketData).toEqual([allMarket]));
204+
205+
rerender({ tabKey: 'live' });
206+
207+
await waitFor(() =>
208+
expect(result.current.marketData).toEqual([liveMarket]),
209+
);
210+
211+
mockGetMarkets.mockClear();
212+
rerender({ tabKey: 'all' });
213+
214+
expect(result.current.marketData).toEqual([allMarket]);
215+
expect(mockGetMarkets).not.toHaveBeenCalled();
216+
});
217+
});

0 commit comments

Comments
 (0)