Skip to content

Commit 49357e1

Browse files
authored
feat(predict): add TimeSlotPicker component for series time windows (PRED-788) (#28625)
## **Description** Add a new `TimeSlotPicker` component for the Predict feature that displays a horizontal scrollable strip of pill-shaped items representing time windows (past, live, future) for crypto Up/Down prediction market series. This is a standalone reusable component built ahead of the series detail screen (PRED-786). It includes: - Four visual pill states: default, selected, live, and selected+live - Animated pulse dot indicator using Reanimated v3 for the live market - Real-time countdown timer (MM:SS) for the live market's end time - Auto-scroll on mount to bring the selected/live pill into view - Locale-aware time formatting via `Intl.DateTimeFormat` - Uncontrolled selection with optional `selectedMarketId` override ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: [PRED-788](https://consensyssoftware.atlassian.net/browse/PRED-788) ## **Manual testing steps** ```gherkin Feature: TimeSlotPicker for crypto Up/Down series Scenario: Component renders pills for series markets Given a crypto Up/Down market with series data is available When the TimeSlotPicker is rendered with the series markets Then a horizontal scrollable strip of pill-shaped items is displayed And each pill shows the market's end time in the device's locale format Scenario: Live market is auto-selected on mount Given the series contains a market whose endDate is in the future (live) When the TimeSlotPicker mounts without a selectedMarketId prop Then the live market pill is auto-selected And the strip auto-scrolls to bring the live pill into view Scenario: Live pill displays countdown and pulse animation Given a market is currently live (endDate in the future, soonest to end) When viewing the TimeSlotPicker Then the live pill shows a pulsing red dot, "Live" label, and a MM:SS countdown And the countdown ticks down every second Scenario: User taps a non-live pill Given the live market pill is currently selected When the user taps a different (future or past) pill Then the tapped pill becomes selected with a white background and dark text And the onMarketSelected callback fires with the tapped market And the live pill remains visible with its pulse dot and countdown (unselected style) Scenario: Countdown reaches zero Given the live market's countdown is ticking When the countdown reaches 00:00 Then the live indicator moves to the next market in the series And the user's current selection does NOT change automatically ``` ## **Screenshots/Recordings** ### **Before** N/A — new component ### **After** Demo: https://www.loom.com/share/a6ef4afae3ba4b7c83375b5ebc55971e <img width="380" alt="Simulator Screenshot - mm-blue - 2026-04-09 at 16 02 23" src="https://github.com/user-attachments/assets/ef26e1f1-a288-4134-99e7-f207bbffc0c3" /> <img width="380" alt="Simulator Screenshot - mm-blue - 2026-04-09 at 16 02 35" src="https://github.com/user-attachments/assets/a5f9d685-87b4-4ab1-b566-03ac0a820d52" /> <img width="380" alt="Simulator Screenshot - mm-blue - 2026-04-09 at 16 02 43" src="https://github.com/user-attachments/assets/24fa49ce-1231-4e90-bcbc-0d77cab2e550" /> <img width="380" alt="Simulator Screenshot - mm-blue - 2026-04-09 at 16 02 55" src="https://github.com/user-attachments/assets/ed8d1d09-68c2-4cdd-acc1-34827b40826e" /> ## **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. ## **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. [PRED-788]: https://consensyssoftware.atlassian.net/browse/PRED-788?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk: this is a new, self-contained UI component with helper utilities and unit tests, with no changes to existing business logic, auth, or data flows. > > **Overview** > Adds a new `TimeSlotPicker` component that renders a horizontal list of selectable “time window” pills for a series, auto-selecting the *live* (soonest-ending future) market by default and auto-scrolling the strip to the resolved selection. > > Live pills now show an animated pulse indicator (Reanimated) plus an `MM:SS` countdown driven by a new `useCountdown` hook, while non-live pills display a locale-formatted end time via `formatTime`. > > Includes supporting constants/types, selection utilities (`findLiveMarket`, `findNearestMarket`), and comprehensive unit tests for the component, utilities, and countdown behavior. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit f4ee540. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 7a18715 commit 49357e1

9 files changed

Lines changed: 929 additions & 0 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export const PULSE_DURATION_MS = 1500;
2+
export const PULSE_DOT_SIZE = 8;
3+
export const PULSE_RING_SIZE = 16;
4+
export const COUNTDOWN_INTERVAL_MS = 1000;
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import React from 'react';
2+
import { render, fireEvent, screen } from '@testing-library/react-native';
3+
import TimeSlotPicker from './TimeSlotPicker';
4+
import { PredictMarket, Recurrence } from '../../types';
5+
6+
jest.mock('@metamask/design-system-twrnc-preset', () => ({
7+
useTailwind: () => ({
8+
style: (...classes: (string | boolean | undefined)[]) => ({
9+
testStyle: classes.filter(Boolean).join(' '),
10+
}),
11+
}),
12+
}));
13+
14+
jest.mock('@metamask/design-system-react-native', () => {
15+
const { View, Text: RNText } = jest.requireActual('react-native');
16+
return {
17+
Box: ({
18+
children,
19+
testID,
20+
onLayout,
21+
...props
22+
}: {
23+
children?: React.ReactNode;
24+
testID?: string;
25+
onLayout?: (e: unknown) => void;
26+
[key: string]: unknown;
27+
}) => (
28+
<View testID={testID} onLayout={onLayout} {...props}>
29+
{children}
30+
</View>
31+
),
32+
BoxFlexDirection: { Row: 'row' },
33+
BoxAlignItems: { Center: 'center' },
34+
BoxBackgroundColor: {
35+
ErrorMuted: 'bg-error-muted',
36+
IconDefault: 'bg-icon-default',
37+
BackgroundMuted: 'bg-muted',
38+
},
39+
BoxBorderColor: { ErrorDefault: 'border-error-default' },
40+
Text: ({
41+
children,
42+
testID,
43+
...props
44+
}: {
45+
children?: React.ReactNode;
46+
testID?: string;
47+
[key: string]: unknown;
48+
}) => (
49+
<RNText testID={testID} {...props}>
50+
{children}
51+
</RNText>
52+
),
53+
TextVariant: { BodySm: 'body-sm' },
54+
TextColor: {
55+
PrimaryInverse: 'text-primary-inverse',
56+
TextDefault: 'text-default',
57+
},
58+
FontWeight: { Medium: 'medium', Regular: 'regular' },
59+
};
60+
});
61+
62+
jest.mock('react-native-gesture-handler', () => {
63+
const { ScrollView } = jest.requireActual('react-native');
64+
return { ScrollView };
65+
});
66+
67+
jest.mock('react-native-reanimated', () => {
68+
const { View } = jest.requireActual('react-native');
69+
return {
70+
__esModule: true,
71+
default: { View },
72+
useSharedValue: (v: number) => ({ value: v }),
73+
useAnimatedStyle: (fn: () => object) => fn(),
74+
withRepeat: (v: number) => v,
75+
withTiming: (v: number) => v,
76+
Easing: { out: (fn: unknown) => fn, ease: {} },
77+
};
78+
});
79+
80+
jest.mock('./useCountdown', () => ({
81+
useCountdown: jest.fn(() => null),
82+
}));
83+
84+
const { useCountdown } = jest.requireMock('./useCountdown') as {
85+
useCountdown: jest.Mock;
86+
};
87+
88+
const createMarket = (
89+
overrides: Partial<PredictMarket> = {},
90+
): PredictMarket => ({
91+
id: 'market-1',
92+
providerId: 'polymarket',
93+
slug: 'btc-updown-5m',
94+
title: 'BTC Up/Down 5m',
95+
description: 'Will BTC go up?',
96+
endDate: '2026-04-09T12:05:00.000Z',
97+
image: '',
98+
status: 'open',
99+
recurrence: Recurrence.NONE,
100+
category: 'crypto',
101+
tags: ['up-or-down'],
102+
outcomes: [],
103+
liquidity: 1000,
104+
volume: 5000,
105+
...overrides,
106+
});
107+
108+
const createMarkets = (): PredictMarket[] => {
109+
const now = Date.now();
110+
return [
111+
createMarket({
112+
id: 'past-1',
113+
endDate: new Date(now - 60_000).toISOString(),
114+
status: 'closed',
115+
}),
116+
createMarket({
117+
id: 'live-1',
118+
endDate: new Date(now + 120_000).toISOString(),
119+
}),
120+
createMarket({
121+
id: 'future-1',
122+
endDate: new Date(now + 600_000).toISOString(),
123+
}),
124+
createMarket({
125+
id: 'future-2',
126+
endDate: new Date(now + 1_200_000).toISOString(),
127+
}),
128+
];
129+
};
130+
131+
describe('TimeSlotPicker', () => {
132+
beforeEach(() => {
133+
jest.clearAllMocks();
134+
jest.useFakeTimers();
135+
jest.setSystemTime(new Date('2026-04-09T12:00:00.000Z'));
136+
useCountdown.mockReturnValue(null);
137+
});
138+
139+
afterEach(() => {
140+
jest.useRealTimers();
141+
});
142+
143+
describe('rendering', () => {
144+
it('renders a pill for each market', () => {
145+
const markets = createMarkets();
146+
147+
render(<TimeSlotPicker markets={markets} onMarketSelected={jest.fn()} />);
148+
149+
markets.forEach((market) => {
150+
expect(
151+
screen.getByTestId(`time-slot-pill-${market.id}`),
152+
).toBeOnTheScreen();
153+
});
154+
});
155+
156+
it('renders nothing when markets array is empty', () => {
157+
render(<TimeSlotPicker markets={[]} onMarketSelected={jest.fn()} />);
158+
159+
expect(screen.queryByTestId('time-slot-picker')).not.toBeOnTheScreen();
160+
});
161+
162+
it('displays Live label and countdown for the live market when countdown is active', () => {
163+
useCountdown.mockReturnValue('02:00');
164+
const markets = createMarkets();
165+
166+
render(<TimeSlotPicker markets={markets} onMarketSelected={jest.fn()} />);
167+
168+
expect(
169+
screen.getByTestId('time-slot-live-label-live-1'),
170+
).toBeOnTheScreen();
171+
expect(
172+
screen.getByTestId('time-slot-countdown-live-1'),
173+
).toBeOnTheScreen();
174+
});
175+
});
176+
177+
describe('selection', () => {
178+
it('auto-selects the live market when no selectedMarketId is provided', () => {
179+
useCountdown.mockReturnValue('02:00');
180+
const markets = createMarkets();
181+
182+
render(<TimeSlotPicker markets={markets} onMarketSelected={jest.fn()} />);
183+
184+
expect(
185+
screen.getByTestId('time-slot-live-label-live-1'),
186+
).toBeOnTheScreen();
187+
});
188+
189+
it('renders a pill for the explicitly selected market', () => {
190+
const markets = createMarkets();
191+
192+
render(
193+
<TimeSlotPicker
194+
markets={markets}
195+
selectedMarketId="future-1"
196+
onMarketSelected={jest.fn()}
197+
/>,
198+
);
199+
200+
expect(screen.getByTestId('time-slot-pill-future-1')).toBeOnTheScreen();
201+
});
202+
});
203+
204+
describe('interactions', () => {
205+
it('calls onMarketSelected with the tapped market', () => {
206+
const markets = createMarkets();
207+
const onSelected = jest.fn();
208+
209+
render(
210+
<TimeSlotPicker markets={markets} onMarketSelected={onSelected} />,
211+
);
212+
213+
fireEvent.press(screen.getByTestId('time-slot-pill-past-1'));
214+
215+
expect(onSelected).toHaveBeenCalledTimes(1);
216+
expect(onSelected).toHaveBeenCalledWith(
217+
expect.objectContaining({ id: 'past-1' }),
218+
);
219+
});
220+
});
221+
222+
describe('edge cases', () => {
223+
it('falls back to nearest market when no live market exists', () => {
224+
const now = Date.now();
225+
const markets = [
226+
createMarket({
227+
id: 'past-1',
228+
endDate: new Date(now - 120_000).toISOString(),
229+
status: 'closed',
230+
}),
231+
createMarket({
232+
id: 'past-2',
233+
endDate: new Date(now - 60_000).toISOString(),
234+
status: 'closed',
235+
}),
236+
];
237+
238+
render(<TimeSlotPicker markets={markets} onMarketSelected={jest.fn()} />);
239+
240+
expect(screen.getByTestId('time-slot-picker')).toBeOnTheScreen();
241+
});
242+
243+
it('renders markets without endDate gracefully', () => {
244+
const markets = [createMarket({ id: 'no-end', endDate: undefined })];
245+
246+
render(<TimeSlotPicker markets={markets} onMarketSelected={jest.fn()} />);
247+
248+
expect(screen.getByTestId('time-slot-picker')).toBeOnTheScreen();
249+
});
250+
});
251+
});

0 commit comments

Comments
 (0)