Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 26 additions & 1 deletion app/components/UI/AssetOverview/Price/Price.legacy.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import { Button, ButtonVariant } from '@metamask/design-system-react-native';
import { TokenOverviewSelectorsIDs } from '../TokenOverview.testIds';
import type { TokenPrice } from '../../../../components/hooks/useTokenHistoricalPrices';
import { selectTokenDetailsTechnicalIndicatorsEnabled } from '../../../../selectors/featureFlagController/tokenDetailsTechnicalIndicators';
import { CHART_DATA_THRESHOLD } from './tokenOverviewChart.constants';
import {
CHART_DATA_THRESHOLD,
TIME_PERIOD_MS,
} from './tokenOverviewChart.constants';

const mockSelectTechnicalIndicatorsEnabled = jest.fn(() => false);

Expand Down Expand Up @@ -195,6 +198,28 @@ describe('PriceLegacy', () => {
expect(getByTestId('price-chart-insufficient-data')).toBeOnTheScreen();
});

describe('timePeriodMs pass-through', () => {
it('passes the correct timePeriodMs for a known time period', () => {
renderWithProvider(<PriceLegacy {...baseProps} timePeriod="1d" />);
expect(PriceChart).toHaveBeenCalledWith(
expect.objectContaining({
timePeriodMs: TIME_PERIOD_MS['1d'],
}),
undefined,
);
});

it('passes undefined timePeriodMs for the "all" time period', () => {
renderWithProvider(<PriceLegacy {...baseProps} timePeriod="all" />);
expect(PriceChart).toHaveBeenCalledWith(
expect.objectContaining({
timePeriodMs: undefined,
}),
undefined,
);
});
});

describe('chart navigation buttons', () => {
it('calls onTimePeriodChange when a button is pressed with flag OFF (below chart)', () => {
mockSelectTechnicalIndicatorsEnabled.mockReturnValue(false);
Expand Down
6 changes: 5 additions & 1 deletion app/components/UI/AssetOverview/Price/Price.legacy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@
import ChartNavigationButton from '../ChartNavigationButton';
import { useSelector } from 'react-redux';
import { selectTokenDetailsTechnicalIndicatorsEnabled } from '../../../../selectors/featureFlagController/tokenDetailsTechnicalIndicators';
import { TOKEN_OVERVIEW_TIME_RANGE_ROW_HEIGHT } from './tokenOverviewChart.constants';
import {
TIME_PERIOD_MS,
TOKEN_OVERVIEW_TIME_RANGE_ROW_HEIGHT,

Check warning on line 25 in app/components/UI/AssetOverview/Price/Price.legacy.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused import of 'TOKEN_OVERVIEW_TIME_RANGE_ROW_HEIGHT'.

See more on https://sonarcloud.io/project/issues?id=MetaMask_metamask-mobile&issues=AZ9ckt9ZMU2MVIyhSY35&open=AZ9ckt9ZMU2MVIyhSY35&pullRequest=33204
} from './tokenOverviewChart.constants';

export interface PriceLegacyProps {
prices: TokenPrice[];
Expand Down Expand Up @@ -201,6 +204,7 @@
onChartIndexChange={handleChartInteraction}
chartColorOverride={initialAmbientColor}
hasInsufficientCoverage={hasInsufficientCoverage}
timePeriodMs={TIME_PERIOD_MS[timePeriod] ?? undefined}
/>
</Box>
{/* Technical indicators flag OFF: time range below chart (legacy position) */}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Dimensions } from 'react-native';
import type { OHLCVTimePeriod } from '../../Charts/AdvancedChart/TimeRangeSelector';
import { TimePeriod } from '../../../hooks/useTokenHistoricalPrices';

/**
* Token overview chart column height (AdvancedChart / TradingView + legacy line when shown).
Expand All @@ -18,6 +19,23 @@ export const CHART_DATA_THRESHOLD = 5;
*/
export const TOKEN_OVERVIEW_TIME_RANGE_ROW_HEIGHT = 34;

/**
* Duration in milliseconds for each time period.
* `null` for "all" (no fixed duration — falls back to index-based x-axis).
*/
const HOURS = 3_600_000;
const DAYS = 24 * HOURS;
export const TIME_PERIOD_MS: Record<TimePeriod, number | null> = {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIME_PERIOD_MS tells PriceChart how wide the x-axis should be in real time.

Without it, the chart only knows the data points it received — it has no idea what "1D" or "1W" means. So when the API returns 14 hours of data, it would stretch those points across the full width (the old behavior).

'1d': 1 * DAYS,
'1w': 7 * DAYS,
'7d': 7 * DAYS,
'1m': 30 * DAYS,
'3m': 90 * DAYS,
'1y': 365 * DAYS,
'3y': 3 * 365 * DAYS,
all: null,
};

/**
* Single source of truth for candle intervals in the IntervalBar.
* Maps each interval to the API timePeriod that returns enough history.
Expand Down
128 changes: 126 additions & 2 deletions app/components/UI/AssetOverview/PriceChart/PriceChart.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react-native';
import PriceChart from './PriceChart';
import PriceChart, { findNearestIndex } from './PriceChart';
import { TokenPrice } from '../../../hooks/useTokenHistoricalPrices';
import { PriceChartProvider } from './PriceChart.context';
import { useAnalytics } from '../../../hooks/useAnalytics/useAnalytics';
Expand Down Expand Up @@ -258,8 +258,10 @@ describe('PriceChart', () => {
* Synthetic responder event with the touchHistory shape
* that PanResponder's internal state machine expects.
*/
const FIXED_TIMESTAMP = 1_736_761_237_983;

const createResponderEvent = (locationX: number, locationY: number) => {
const timestamp = Date.now();
const timestamp = FIXED_TIMESTAMP;
return {
nativeEvent: {
locationX,
Expand Down Expand Up @@ -315,5 +317,127 @@ describe('PriceChart', () => {
fireEvent(chartArea, 'responderRelease', createResponderEvent(200, 55));
expect(mockOnChartIndexChange).toHaveBeenCalledTimes(3);
});

it('uses time-based updatePosition when timePeriodMs is provided', () => {
const now = Date.now();
const prices: TokenPrice[] = Array.from({ length: 5 }, (_, i) => [
String(now - 86_400_000 + i * 20_000_000),
100 + i,
]) as TokenPrice[];

const { getByTestId } = render(
<PriceChartProvider>
<PriceChart
{...defaultProps}
prices={prices}
priceDiff={5}
timePeriodMs={86_400_000}
/>
</PriceChartProvider>,
);

const chartArea = getByTestId('price-chart-area');
fireEvent(chartArea, 'responderGrant', createResponderEvent(50, 50));
expect(mockOnChartIndexChange).toHaveBeenCalled();
const idx = mockOnChartIndexChange.mock.calls[0][0];
expect(idx).toBeGreaterThanOrEqual(0);
expect(idx).toBeLessThan(prices.length);
});
});

describe('findNearestIndex', () => {
it('returns -1 for an empty array', () => {
expect(findNearestIndex([], 100)).toBe(-1);
});

it('returns 0 for a single-element array', () => {
const prices: TokenPrice[] = [['1000', 42]];
expect(findNearestIndex(prices, 999)).toBe(0);
expect(findNearestIndex(prices, 1000)).toBe(0);
expect(findNearestIndex(prices, 1001)).toBe(0);
});

it('returns exact match index', () => {
const prices: TokenPrice[] = [
['100', 1],
['200', 2],
['300', 3],
];
expect(findNearestIndex(prices, 200)).toBe(1);
});

it('returns the closer element when target is between two points', () => {
const prices: TokenPrice[] = [
['100', 1],
['200', 2],
['300', 3],
];
expect(findNearestIndex(prices, 190)).toBe(1);
expect(findNearestIndex(prices, 110)).toBe(0);
});

it('returns 0 when target is before all data', () => {
const prices: TokenPrice[] = [
['100', 1],
['200', 2],
];
expect(findNearestIndex(prices, 50)).toBe(0);
});

it('returns last index when target is after all data', () => {
const prices: TokenPrice[] = [
['100', 1],
['200', 2],
['300', 3],
];
expect(findNearestIndex(prices, 999)).toBe(2);
});
});

describe('Time-based rendering', () => {
it('renders the chart with time-based xAccessor when timePeriodMs is provided', () => {
const prices = fiveTokenPrices();
const { getByTestId } = render(
<PriceChart
{...defaultProps}
prices={prices}
priceDiff={5}
timePeriodMs={86_400_000}
/>,
);
expect(getByTestId('price-chart-area')).toBeOnTheScreen();
});

it('renders the chart with index-based xAccessor when timePeriodMs is undefined', () => {
const prices = fiveTokenPrices();
const { getByTestId } = render(
<PriceChart {...defaultProps} prices={prices} priceDiff={5} />,
);
expect(getByTestId('price-chart-area')).toBeOnTheScreen();
});

it('renders EndDot using time-based x position when timePeriodMs is set', () => {
const prices = fiveTokenPrices();
const result = render(
<PriceChart
{...defaultProps}
prices={prices}
priceDiff={5}
timePeriodMs={86_400_000}
/>,
);

const chartArea = result.getByTestId('price-chart-area');
const layoutViews = chartArea.findAll(
(node) => typeof node.props?.onLayout === 'function',
);
layoutViews.forEach((v) =>
fireEvent(v, 'layout', {
nativeEvent: { layout: { x: 0, y: 0, width: 300, height: 200 } },
}),
);

expect(result.getByTestId('price-chart-end-dot')).toBeOnTheScreen();
});
});
});
Loading
Loading