Skip to content

Commit c11f4d7

Browse files
chore(runway): cherry-pick feat(predict): cp-7.62.0 improve chart loading state and timestamp positioning (#24818)
- feat(predict): cp-7.62.0 improve chart loading state and timestamp positioning (#24806) <!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** - Add ActivityIndicator spinner during chart loading instead of blank background - Implement dynamic timestamp positioning to prevent text overflow at chart edges - Anchor timestamp to left when near left boundary - Anchor timestamp to right when near right boundary - Center timestamp when in the middle of the chart - Fix timestamp conversion to handle both seconds and milliseconds formats - Improve chart data loading detection to check for actual data presence - Fix TimeframeSelector padding for better spacing - Fix PredictPicks cash out button background for light mode only <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** https://www.loom.com/share/d3841593361b418e81f6034dae4a2cb2 <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] 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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Enhances Predict chart reliability and UX. > > - Adds ActivityIndicator during loading instead of blank background (`PredictGameChartContent`) > - Dynamically positions tooltip timestamp with `textAnchor` and clamped `x` to prevent overflow at edges; introduces `TIMESTAMP_TEXT_HALF_WIDTH` (`ChartTooltip`, constants) > - Normalizes incoming timestamps to milliseconds via `toMilliseconds`, fixing mixed seconds/ms inputs (`PredictGameChart`) > - Tightens loading state by requiring real data presence, not just fetch state (`PredictGameChart`) > - UI polish: adjust `TimeframeSelector` padding; make `PredictPicks` cash-out button background apply only in light mode > - Tests: add coverage for timestamp edge positioning in `ChartTooltip.test.tsx` > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 00591d5. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> [8e71c07](8e71c07) Co-authored-by: Caainã Jeronimo <caainaje@gmail.com>
1 parent a38179c commit c11f4d7

7 files changed

Lines changed: 150 additions & 11 deletions

File tree

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

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,12 @@ jest.mock('react-native-svg', () => {
3232
children?: React.ReactNode;
3333
x?: number;
3434
y?: number;
35+
textAnchor?: string;
3536
}) => (
36-
<Text testID="svg-text" accessibilityLabel={`text-at-${props.x}`}>
37+
<Text
38+
testID="svg-text"
39+
accessibilityLabel={`text-at-${props.x}-anchor-${props.textAnchor}`}
40+
>
3741
{children}
3842
</Text>
3943
),
@@ -319,4 +323,109 @@ describe('ChartTooltip', () => {
319323
expect(circleElements.length).toBe(2);
320324
});
321325
});
326+
327+
describe('Timestamp Edge Positioning', () => {
328+
// chartWidth=300, contentInset.left=10, contentInset.right=80
329+
// chartDataRight = 300 - 80 = 220
330+
// TIMESTAMP_TEXT_HALF_WIDTH = 75
331+
// Near left edge: xPos < 10 + 75 = 85
332+
// Near right edge: xPos > 220 - 75 = 145
333+
// Middle: 85 <= xPos <= 145
334+
335+
it('positions timestamp at left edge with start anchor when near left boundary', () => {
336+
const leftEdgeXFunction = () => 50; // xPos < 85 (near left edge)
337+
338+
const { getAllByTestId } = render(
339+
<ChartTooltip
340+
{...defaultProps}
341+
x={leftEdgeXFunction}
342+
activeIndex={0}
343+
/>,
344+
);
345+
346+
const textElements = getAllByTestId('svg-text');
347+
const timestampElement = textElements[0];
348+
349+
// contentInset.left = 10, anchor should be 'start'
350+
expect(timestampElement.props.accessibilityLabel).toBe(
351+
'text-at-10-anchor-start',
352+
);
353+
});
354+
355+
it('positions timestamp at right edge with end anchor when near right boundary', () => {
356+
const rightEdgeXFunction = () => 180; // xPos > 145 (near right edge)
357+
358+
const { getAllByTestId } = render(
359+
<ChartTooltip
360+
{...defaultProps}
361+
x={rightEdgeXFunction}
362+
activeIndex={0}
363+
/>,
364+
);
365+
366+
const textElements = getAllByTestId('svg-text');
367+
const timestampElement = textElements[0];
368+
369+
// chartDataRight = 220, anchor should be 'end'
370+
expect(timestampElement.props.accessibilityLabel).toBe(
371+
'text-at-220-anchor-end',
372+
);
373+
});
374+
375+
it('positions timestamp at xPos with middle anchor when in center of chart', () => {
376+
const middleXFunction = () => 120; // 85 <= xPos <= 145 (middle)
377+
378+
const { getAllByTestId } = render(
379+
<ChartTooltip {...defaultProps} x={middleXFunction} activeIndex={0} />,
380+
);
381+
382+
const textElements = getAllByTestId('svg-text');
383+
const timestampElement = textElements[0];
384+
385+
// xPos = 120, anchor should be 'middle'
386+
expect(timestampElement.props.accessibilityLabel).toBe(
387+
'text-at-120-anchor-middle',
388+
);
389+
});
390+
391+
it('positions timestamp at exact left boundary threshold', () => {
392+
// xPos = 85 is exactly at the threshold (not < 85), so should be middle
393+
const boundaryXFunction = () => 85;
394+
395+
const { getAllByTestId } = render(
396+
<ChartTooltip
397+
{...defaultProps}
398+
x={boundaryXFunction}
399+
activeIndex={0}
400+
/>,
401+
);
402+
403+
const textElements = getAllByTestId('svg-text');
404+
const timestampElement = textElements[0];
405+
406+
expect(timestampElement.props.accessibilityLabel).toBe(
407+
'text-at-85-anchor-middle',
408+
);
409+
});
410+
411+
it('positions timestamp at exact right boundary threshold', () => {
412+
// xPos = 145 is exactly at the threshold (not > 145), so should be middle
413+
const boundaryXFunction = () => 145;
414+
415+
const { getAllByTestId } = render(
416+
<ChartTooltip
417+
{...defaultProps}
418+
x={boundaryXFunction}
419+
activeIndex={0}
420+
/>,
421+
);
422+
423+
const textElements = getAllByTestId('svg-text');
424+
const timestampElement = textElements[0];
425+
426+
expect(timestampElement.props.accessibilityLabel).toBe(
427+
'text-at-145-anchor-middle',
428+
);
429+
});
430+
});
322431
});

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
TIMESTAMP_Y,
1818
CROSSHAIR_START_Y,
1919
CROSSHAIR_STROKE_WIDTH,
20+
TIMESTAMP_TEXT_HALF_WIDTH,
2021
getSeparatedLabelYPositions,
2122
} from './PredictGameChart.constants';
2223

@@ -69,15 +70,30 @@ const ChartTooltip: React.FC<ChartTooltipProps> = ({
6970
const timestamp = primaryData[activeIndex].timestamp;
7071
const labelStartX = chartWidth - contentInset.right + RIGHT_LABEL_OFFSET;
7172

73+
const chartDataRight = chartWidth - contentInset.right;
74+
const isNearLeftEdge = xPos < contentInset.left + TIMESTAMP_TEXT_HALF_WIDTH;
75+
const isNearRightEdge = xPos > chartDataRight - TIMESTAMP_TEXT_HALF_WIDTH;
76+
77+
const timestampAnchor = isNearLeftEdge
78+
? 'start'
79+
: isNearRightEdge
80+
? 'end'
81+
: 'middle';
82+
const timestampX = isNearLeftEdge
83+
? contentInset.left
84+
: isNearRightEdge
85+
? chartDataRight
86+
: xPos;
87+
7288
return (
7389
<G>
7490
<SvgText
75-
x={xPos}
91+
x={timestampX}
7692
y={TIMESTAMP_Y}
7793
fill={colors.text.alternative}
7894
fontSize={FONT_SIZE_LABEL}
7995
fontWeight="400"
80-
textAnchor="middle"
96+
textAnchor={timestampAnchor}
8197
>
8298
{formatTimestamp(timestamp)}
8399
</SvgText>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const VALUE_TEXT_OFFSET_Y = 16;
1414
export const TIMESTAMP_Y = 12;
1515
export const CROSSHAIR_START_Y = 20;
1616
export const CROSSHAIR_STROKE_WIDTH = 1;
17+
export const TIMESTAMP_TEXT_HALF_WIDTH = 75;
1718

1819
export interface DotPosition {
1920
dotY: number;

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

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ const FIDELITY_BY_TIMEFRAME: Record<ChartTimeframe, number> = {
3636
const getMinuteTimestamp = (timestamp: number): number =>
3737
Math.floor(timestamp / 60000) * 60000;
3838

39+
/**
40+
* Converts a timestamp to milliseconds.
41+
* Detects if timestamp is in seconds (10 digits) or milliseconds (13 digits).
42+
* Unix timestamps in seconds are typically < 10 billion (until year 2286).
43+
*/
44+
const toMilliseconds = (timestamp: number): number =>
45+
timestamp < 10_000_000_000 ? timestamp * 1000 : timestamp;
46+
3947
const PredictGameChart: React.FC<PredictGameChartProps> = ({
4048
tokenIds,
4149
seriesConfig,
@@ -76,7 +84,7 @@ const PredictGameChart: React.FC<PredictGameChartProps> = ({
7684
data: history.map((point) => ({
7785
timestamp:
7886
typeof point.timestamp === 'number'
79-
? point.timestamp
87+
? toMilliseconds(point.timestamp)
8088
: new Date(point.timestamp).getTime(),
8189
value: Number((point.price * 100).toFixed(2)),
8290
})),
@@ -160,9 +168,12 @@ const PredictGameChart: React.FC<PredictGameChartProps> = ({
160168
}, [refetch]);
161169

162170
const chartData = isLive ? liveChartData : historicalChartData;
171+
const hasChartData =
172+
chartData.length >= 2 &&
173+
chartData[0]?.data?.length > 0 &&
174+
chartData[1]?.data?.length > 0;
163175
const isLoading =
164-
isFetching ||
165-
(isLive && !initialDataLoadedRef.current && chartData.length === 0);
176+
isFetching || !hasChartData || (isLive && !initialDataLoadedRef.current);
166177

167178
const hasErrors = errors.some((error) => error !== null);
168179
const errorMessage = hasErrors

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useMemo, useRef, useCallback, useState } from 'react';
2-
import { PanResponder, View } from 'react-native';
2+
import { ActivityIndicator, PanResponder, View } from 'react-native';
33
import { LineChart } from 'react-native-svg-charts';
44
import { useTailwind } from '@metamask/design-system-twrnc-preset';
55
import {
@@ -139,8 +139,10 @@ const PredictGameChartContent: React.FC<PredictGameChartContentProps> = ({
139139
return (
140140
<Box twClassName="flex-1" testID={testID}>
141141
<Box
142-
twClassName={`h-[${CHART_HEIGHT}px] bg-background-alternative rounded-lg`}
143-
/>
142+
twClassName={`h-[${CHART_HEIGHT}px] bg-transparent rounded-lg items-center justify-center`}
143+
>
144+
<ActivityIndicator color={colors.primary.default} />
145+
</Box>
144146
{onTimeframeChange && (
145147
<TimeframeSelector
146148
selected={timeframe}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const TimeframeSelector: React.FC<TimeframeSelectorProps> = ({
3030
return (
3131
<Box
3232
flexDirection={BoxFlexDirection.Row}
33-
twClassName="justify-center gap-2 mt-3"
33+
twClassName="justify-center gap-2 pt-2 px-4"
3434
>
3535
{TIMEFRAMES.map(({ value, label }) => {
3636
const isSelected = selected === value;

app/components/UI/Predict/components/PredictPicks/PredictPicks.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ const PredictPicks: React.FC<PredictPicksProps> = ({
101101
</Box>
102102
<Button
103103
variant={ButtonVariant.Secondary}
104-
twClassName="py-3 px-4 bg-muted/5"
104+
twClassName="py-3 px-4 light:bg-muted/5"
105105
onPress={() => onCashOut(position)}
106106
testID={`predict-picks-cash-out-button-${position.id}`}
107107
>

0 commit comments

Comments
 (0)