Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
3e8211f
feat(predict): add crypto up down details UI
ghgoodreau May 11, 2026
9956289
fix(predict): stabilize predictions section e2e tap
ghgoodreau May 11, 2026
1bb7d23
Merge branch 'predict/crypto-data-plumbing' into predict/crypto-updow…
ghgoodreau May 11, 2026
dfb4b71
Merge branch 'predict/crypto-data-plumbing' into predict/crypto-updow…
ghgoodreau May 11, 2026
d3be570
Merge branch 'predict/crypto-data-plumbing' into predict/crypto-updow…
ghgoodreau May 11, 2026
13e40c0
fix(predict): restore crypto details header context
ghgoodreau May 11, 2026
f86995b
Merge branch 'predict/crypto-data-plumbing' into predict/crypto-updow…
ghgoodreau May 11, 2026
3306e50
Merge branch 'predict/crypto-data-plumbing' into predict/crypto-updow…
ghgoodreau May 11, 2026
47d3167
Merge branch 'predict/crypto-data-plumbing' into predict/crypto-updow…
ghgoodreau May 11, 2026
30bcb7e
fix(predict): hide disabled details tab bar
ghgoodreau May 11, 2026
cedabc4
Merge branch 'predict/crypto-data-plumbing' into predict/crypto-updow…
ghgoodreau May 11, 2026
3d24186
Merge branch 'predict/crypto-data-plumbing' into predict/crypto-updow…
ghgoodreau May 11, 2026
eebc9e4
fix(predict): use shared theme mock in details test
ghgoodreau May 11, 2026
9810e85
Merge branch 'predict/crypto-data-plumbing' into predict/crypto-updow…
ghgoodreau May 11, 2026
631f856
Merge branch 'predict/crypto-data-plumbing' into predict/crypto-updow…
ghgoodreau May 11, 2026
54229dd
Merge branch 'predict/crypto-data-plumbing' into predict/crypto-updow…
ghgoodreau May 12, 2026
a594367
Merge branch 'predict/crypto-data-plumbing' into predict/crypto-updow…
ghgoodreau May 12, 2026
14ecec7
Merge branch 'predict/crypto-data-plumbing' into predict/crypto-updow…
ghgoodreau May 12, 2026
7cfa332
Merge branch 'predict/crypto-data-plumbing' into predict/crypto-updow…
ghgoodreau May 12, 2026
d695cce
Merge branch 'predict/crypto-data-plumbing' into predict/crypto-updow…
ghgoodreau May 13, 2026
438d155
Merge branch 'predict/crypto-data-plumbing' into predict/crypto-updow…
ghgoodreau May 13, 2026
ed3b043
Merge branch 'predict/crypto-data-plumbing' into predict/crypto-updow…
ghgoodreau May 13, 2026
7cba745
Merge branch 'predict/crypto-data-plumbing' into predict/crypto-updow…
ghgoodreau May 14, 2026
db0d9dd
fix(predict): address crypto details review findings
ghgoodreau May 14, 2026
864f0bb
fix(predict): guard crypto details series updates
ghgoodreau May 14, 2026
34db32a
Merge branch 'predict/crypto-data-plumbing' into predict/crypto-updow…
ghgoodreau May 15, 2026
4256a83
fix(e2e): allow fixture server state requests
ghgoodreau May 15, 2026
8e640c5
Merge remote-tracking branch 'origin/predict/crypto-data-plumbing' in…
ghgoodreau May 15, 2026
435696e
fix: use 30-second crypto updown live chart window
ghgoodreau May 15, 2026
c4bf19d
Merge branch 'predict/crypto-data-plumbing' into predict/crypto-updow…
ghgoodreau May 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/components/UI/Predict/Predict.testIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export const PredictCryptoUpDownDetailsSelectorsIDs = {
SHARE_BUTTON: 'predict-crypto-up-down-details-share-button',
SCROLL_VIEW: 'predict-crypto-up-down-details-scroll-view',
TITLE_SECTION: 'predict-crypto-up-down-details-title-section',
PRICE_SUMMARY: 'predict-crypto-up-down-details-price-summary',
} as const;

export const PredictMarketDetailsSelectorsText = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react-native';
import PredictCryptoUpDownChart from './PredictCryptoUpDownChart';
import { useCryptoUpDownChartData } from '../../hooks/useCryptoUpDownChartData';
import {
Recurrence,
type PredictMarket,
type PredictSeries,
} from '../../types';

jest.mock('../../hooks/useCryptoUpDownChartData', () => ({
useCryptoUpDownChartData: jest.fn(),
}));

jest.mock('../../../Charts/LivelineChart', () => {
const { View } = jest.requireActual('react-native');
const { forwardRef } = jest.requireActual('react');
const MockChart = forwardRef(
(props: Record<string, unknown>, _ref: unknown) => (
<View testID="mock-liveline-chart" {...props} />
),
);
MockChart.displayName = 'MockLivelineChart';
return {
__esModule: true,
LivelineChart: MockChart,
};
});

jest.mock('@metamask/design-system-twrnc-preset', () => ({
useTailwind: () => ({
style: jest.fn(() => ({})),
}),
}));

const createMockMarket = (): PredictMarket & { series: PredictSeries } =>
({
id: 'market-1',
providerId: 'polymarket',
slug: 'btc-up-or-down-5m',
title: 'BTC Up or Down - 5 Minutes',
description: 'Will BTC go up or down?',
image: 'https://example.com/btc.png',
status: 'open',
recurrence: Recurrence.NONE,
category: 'crypto',
tags: ['crypto', 'up-or-down'],
outcomes: [],
liquidity: 100,
volume: 200,
endDate: '2026-04-09T19:45:00Z',
series: {
id: 's1',
slug: 'btc-up-or-down-5m',
title: 'BTC Up or Down - 5 Minutes',
recurrence: '5m',
},
}) as PredictMarket & { series: PredictSeries };

describe('PredictCryptoUpDownChart', () => {
const mockUseCryptoUpDownChartData = useCryptoUpDownChartData as jest.Mock;

beforeEach(() => {
jest.clearAllMocks();
mockUseCryptoUpDownChartData.mockReturnValue({
data: [{ time: 1, value: 100 }],
value: 100,
loading: false,
isLive: true,
window: 300,
});
});

it('does not render LivelineChart when height is 0', () => {
const market = createMockMarket();

render(<PredictCryptoUpDownChart market={market} />);

expect(screen.queryByTestId('mock-liveline-chart')).not.toBeOnTheScreen();
});

it('renders LivelineChart with correct props when data is available and height is greater than 0', () => {
const market = createMockMarket();

render(<PredictCryptoUpDownChart market={market} />);

const container = screen.getByTestId(
'predict-crypto-up-down-chart-container',
);
fireEvent(container, 'layout', {
nativeEvent: { layout: { height: 300 } },
});

const chart = screen.getByTestId('mock-liveline-chart');
expect(chart).toBeOnTheScreen();
expect(chart.props.data).toEqual([{ time: 1, value: 100 }]);
expect(chart.props.value).toBe(100);
expect(chart.props.loading).toBe(false);
expect(chart.props.window).toBe(300);
expect(chart.props.height).toBe(300);
expect(chart.props.color).toBe('rgb(245, 158, 11)');
expect(chart.props.lineWidth).toBe(2);
expect(chart.props.grid).toBe(true);
expect(chart.props.hideControls).toBe(true);
expect(chart.props.badge).toBe(true);
expect(chart.props.padding).toEqual({ top: 48, bottom: 48 });
expect(chart.props.formatValue).toBe(
"const sign = v < 0 ? '-' : ''; const parts = Math.abs(v).toFixed(2).split('.'); parts[0] = parts[0].replace(/\\B(?=(\\d{3})+(?!\\d))/g, ','); return sign + '$' + parts.join('.')",
);
});

it('passes a custom chart color to LivelineChart', () => {
const market = createMockMarket();

render(
<PredictCryptoUpDownChart market={market} color="rgb(247, 147, 26)" />,
);

const container = screen.getByTestId(
'predict-crypto-up-down-chart-container',
);
fireEvent(container, 'layout', {
nativeEvent: { layout: { height: 300 } },
});

expect(screen.getByTestId('mock-liveline-chart').props.color).toBe(
'rgb(247, 147, 26)',
);
});

it('passes loading true when hook returns loading', () => {
mockUseCryptoUpDownChartData.mockReturnValue({
data: [],
value: undefined,
loading: true,
isLive: false,
window: 300,
});
const market = createMockMarket();

render(<PredictCryptoUpDownChart market={market} />);

const container = screen.getByTestId(
'predict-crypto-up-down-chart-container',
);
fireEvent(container, 'layout', {
nativeEvent: { layout: { height: 300 } },
});

const chart = screen.getByTestId('mock-liveline-chart');
expect(chart.props.loading).toBe(true);
});

it('shows reference line when targetPrice is provided', () => {
const market = createMockMarket();

render(<PredictCryptoUpDownChart market={market} targetPrice={50000} />);

const container = screen.getByTestId(
'predict-crypto-up-down-chart-container',
);
fireEvent(container, 'layout', {
nativeEvent: { layout: { height: 300 } },
});

const chart = screen.getByTestId('mock-liveline-chart');
expect(chart.props.referenceLine).toEqual({
value: 50000,
label: 'Target',
});
});

it('does not show reference line when targetPrice is undefined', () => {
const market = createMockMarket();

render(<PredictCryptoUpDownChart market={market} />);

const container = screen.getByTestId(
'predict-crypto-up-down-chart-container',
);
fireEvent(container, 'layout', {
nativeEvent: { layout: { height: 300 } },
});

const chart = screen.getByTestId('mock-liveline-chart');
expect(chart.props.referenceLine).toBeUndefined();
});

it('reports zero and negative current prices after loading completes', () => {
const market = createMockMarket();
const onCurrentPriceChange = jest.fn();

mockUseCryptoUpDownChartData.mockReturnValueOnce({
data: [{ time: 1, value: 0 }],
value: 0,
loading: false,
isLive: true,
window: 300,
});

const { rerender } = render(
<PredictCryptoUpDownChart
market={market}
onCurrentPriceChange={onCurrentPriceChange}
/>,
);

expect(onCurrentPriceChange).toHaveBeenCalledWith(0);

mockUseCryptoUpDownChartData.mockReturnValueOnce({
data: [{ time: 2, value: -1 }],
value: -1,
loading: false,
isLive: true,
window: 300,
});

rerender(
<PredictCryptoUpDownChart
market={market}
onCurrentPriceChange={onCurrentPriceChange}
/>,
);

expect(onCurrentPriceChange).toHaveBeenCalledWith(-1);
});

it('does not report placeholder current price while loading', () => {
const market = createMockMarket();
const onCurrentPriceChange = jest.fn();

mockUseCryptoUpDownChartData.mockReturnValueOnce({
data: [],
value: 0,
loading: true,
isLive: true,
window: 300,
});

render(
<PredictCryptoUpDownChart
market={market}
onCurrentPriceChange={onCurrentPriceChange}
/>,
);

expect(onCurrentPriceChange).not.toHaveBeenCalled();
});

it('does not report placeholder current price without chart data', () => {
const market = createMockMarket();
const onCurrentPriceChange = jest.fn();

mockUseCryptoUpDownChartData.mockReturnValueOnce({
data: [],
value: 0,
loading: false,
isLive: false,
window: 300,
});

render(
<PredictCryptoUpDownChart
market={market}
onCurrentPriceChange={onCurrentPriceChange}
/>,
);

expect(onCurrentPriceChange).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import React, { useEffect, useRef, useState } from 'react';
import { Box } from '@metamask/design-system-react-native';
import {
LivelineChart,
type LivelineChartRef,
} from '../../../Charts/LivelineChart';
import { useCryptoUpDownChartData } from '../../hooks/useCryptoUpDownChartData';
import type { PredictCryptoUpDownChartProps } from './PredictCryptoUpDownChart.types';

const PredictCryptoUpDownChart: React.FC<PredictCryptoUpDownChartProps> = ({
market,
targetPrice,
onCurrentPriceChange,
color = 'rgb(245, 158, 11)',
height: explicitHeight,
}) => {
const chartRef = useRef<LivelineChartRef>(null);
const [measuredHeight, setMeasuredHeight] = useState(0);
const {
data,
value,
loading,
window: chartWindow,
} = useCryptoUpDownChartData(market, chartRef, targetPrice);

const chartHeight = explicitHeight ?? measuredHeight;

useEffect(() => {
if (!loading && data.length > 0 && Number.isFinite(value)) {
onCurrentPriceChange?.(value);
}
}, [data.length, loading, onCurrentPriceChange, value]);

return (
<Box
twClassName={explicitHeight ? undefined : 'flex-1'}
onLayout={
explicitHeight
? undefined
: (e) => setMeasuredHeight(e.nativeEvent.layout.height)
}
testID="predict-crypto-up-down-chart-container"
>
{chartHeight > 0 && (
<LivelineChart
ref={chartRef}
data={data}
value={value}
loading={loading}
window={chartWindow}
height={chartHeight}
color={color}
lineWidth={2}
grid
hideControls
badge
padding={{ top: 48, bottom: 48 }}
referenceLine={
targetPrice ? { value: targetPrice, label: 'Target' } : undefined
}
formatValue="const sign = v < 0 ? '-' : ''; const parts = Math.abs(v).toFixed(2).split('.'); parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); return sign + '$' + parts.join('.')"
/>
)}
</Box>
);
};

export default PredictCryptoUpDownChart;
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { PredictMarket, PredictSeries } from '../../types';

export interface PredictCryptoUpDownChartProps {
market: PredictMarket & { series: PredictSeries };
targetPrice?: number;
onCurrentPriceChange?: (value: number) => void;
color?: string;
/** Explicit chart height in logical pixels. When provided, bypasses flex measurement. */
height?: number;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default } from './PredictCryptoUpDownChart';
export type { PredictCryptoUpDownChartProps } from './PredictCryptoUpDownChart.types';
Loading
Loading