Skip to content

chore: relax-price-api-condition-legacy-chart and update to time based index#33204

Merged
sahar-fehri merged 10 commits into
mainfrom
cursor/price-chart-data-conditions-a279
Jul 15, 2026
Merged

chore: relax-price-api-condition-legacy-chart and update to time based index#33204
sahar-fehri merged 10 commits into
mainfrom
cursor/price-chart-data-conditions-a279

Conversation

@sahar-fehri

@sahar-fehri sahar-fehri commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Description

Purpose

This PR improves the legacy price chart on the Token Details Page (TDP) with two changes:

  1. Time-based x-axis — switched PriceChart from index-based to time-based x-axis so partial data (e.g. 14h of data in a 24h window) renders at its correct temporal position within the full time range, with a visible gap on the left.
  2. Relaxed coverage threshold — lowered MIN_COVERAGE_RATIO from 0.95 to 0.20. With the time-based x-axis, partial data is no longer misleading, so the no-data overlay only triggers for extremely sparse responses (<20% of the requested period).

Changes

app/components/UI/AssetOverview/PriceChart/PriceChart.tsx

  • Added timePeriodMs?: number prop
  • Uses xAccessor/yAccessor/xScale with d3 scaleTime for time-based positioning
  • Sets xMin = Date.now() - timePeriodMs and xMax = Date.now()
  • Rewrote touch logic: pixel → inverse scale → timestamp → binary search nearest point
  • Fixed stale PanResponder closure by routing through refs
  • Fixed Tooltip and EndDot to use x(timestamp) instead of x(index)

app/components/UI/AssetOverview/Price/Price.legacy.tsx

  • Passes TIME_PERIOD_MS[timePeriod] as timePeriodMs to PriceChart

app/components/UI/AssetOverview/Price/tokenOverviewChart.constants.ts

  • Added TIME_PERIOD_MS mapping (TimePeriod → number | null)

app/components/hooks/useTokenHistoricalPrices.ts

  • Lowered MIN_COVERAGE_RATIO from 0.95 to 0.20

Scope

Only the Token Details Page (TDP) is affected. TraderPriceChart.tsx and other consumers are not modified.

Changelog

CHANGELOG entry: null

Related issues

Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-3618

Manual testing steps

Feature: Legacy price chart renders partial data with correct time positioning

  Scenario: Chart shows gap for partial 1D data
    Given the user navigates to Token Details for a newly listed token
    And the price API returns less than 24h of data for the 1D period
    When the chart renders
    Then there is a visible gap on the left side of the chart
    And the price line starts at the actual first data timestamp
    And the right edge of the chart represents "now"

  Scenario: Touch/scrub selects correct data point
    Given the user is viewing a chart with partial data and a gap on the left
    When the user long-presses and drags on the chart
    Then the tooltip snaps to the nearest actual data point
    And does not select phantom points in the gap area

  Scenario: ALL time range falls back to index-based
    Given the user selects the "ALL" time period
    When the chart renders
    Then data fills the full chart width (no gap, index-based)

  Scenario: No-data overlay shows for extremely sparse data
    Given the price API returns data covering less than 20% of the requested period
    When the chart renders
    Then the no-data overlay is displayed

Screenshots/Recordings

Before

Chart stretches partial data (14h) across full width, making it look like 24h of data.

After

Chart correctly shows a gap on the left, with data starting at its actual first timestamp.

Screen.Recording.2026-07-13.at.18.39.24.mov

Pre-merge author checklist

Performance checks (if applicable)

  • I've tested on iOS
  • I've tested on Android
  • I've tested with a power user scenario
  • I've instrumented key operations with Sentry traces for production performance metrics

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.

Note

Medium Risk
Chart rendering and touch-selection behavior change on token details; scope is UI-only but users may misread partial history if the new gap layout is wrong.

Overview
The legacy token overview PriceChart can use a time-based x-axis when timePeriodMs is passed from Price.legacy via new TIME_PERIOD_MS constants. Fixed windows anchor from now - period to now, so sparse history (e.g. 14h in 1D) draws at the correct time with a left gap instead of stretching across the full width. ALL still omits timePeriodMs and keeps index-based layout.

Touch/scrub maps pixels to timestamps and uses exported findNearestIndex; tooltip, end dot, and AreaChart accessors switch between timestamp and index. PanResponder handlers go through refs to avoid stale closures.

hasInsufficientTimeCoverage threshold drops from 95% to 20% (MIN_COVERAGE_RATIO), so the no-data overlay only appears for very sparse API responses; docs/comments align with the 50% boundary behavior. Tests cover pass-through, time-based touch, findNearestIndex, and updated coverage cases.

Reviewed by Cursor Bugbot for commit dd06940. Bugbot is set up for automated code reviews on this repo. Configure here.

Lower MIN_COVERAGE_RATIO from 0.95 to 0.50 so the legacy price chart
only shows the no-data overlay when less than 50% of the expected time
period is covered by the API response.

Previously, the chart would show the overlay when coverage was below
95%, which was too aggressive for newly-listed tokens or tokens with
limited price history.

Chart library audit: react-native-svg-charts AreaChart uses index-based
x-axis positioning (not timestamps), so it already begins painting at
the first data point without padding or stretching to fill a fixed time
range. No chart library changes are needed.

Co-authored-by: sahar-fehri <sahar.fehri@consensys.net>
@github-actions github-actions Bot added the pr-not-ready-for-e2e Skip E2E and block merging. Remove this label once the PR is ready to run the E2E tests. label Jul 13, 2026
@metamask-ci metamask-ci Bot added team-assets INVALID-PR-TEMPLATE PR's body doesn't match template labels Jul 13, 2026
@metamask-ci

metamask-ci Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

PR template — items to address before "Ready for review"

Warnings — informational, address before merging:

  • Pre-merge author checklist has unchecked items (e.g. "I've applied the right labels on the PR (see labeling guidelines). Not required for external contributors."). Every box must be consciously checked — see docs/readme/ready-for-review.md.

See docs/readme/ready-for-review.md for the full Definition of Ready for Review.

cursoragent and others added 2 commits July 13, 2026 14:39
Switch PriceChart from index-based to time-based x-axis so partial data
(e.g. 14h of data in a 24h window) renders at its correct temporal
position within the full time range.

Changes:
- PriceChart.tsx: add timePeriodMs prop; use xAccessor/yAccessor to
  extract timestamp and price from TokenPrice tuples; set xMin/xMax to
  span the full time window (Date.now()-timePeriodMs .. Date.now());
  rewrite updatePosition touch logic with pixel→timestamp→binary-search;
  fix Tooltip and EndDot to use timestamp-based x positioning; fix stale
  PanResponder closure by routing through refs.
- Price.legacy.tsx: pass TIME_PERIOD_MS[timePeriod] as timePeriodMs prop
  to PriceChart (null for 'all' falls back to index-based).
- tokenOverviewChart.constants.ts: add TIME_PERIOD_MS mapping
  (TimePeriod → milliseconds, null for 'all').

Co-authored-by: sahar-fehri <sahar.fehri@consensys.net>
With time-based x-axis, partial data renders correctly (gap on left).
Only show no-data overlay for extremely sparse responses (<20%).

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions

Copy link
Copy Markdown
Contributor

CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes.

@metamask-ci metamask-ci Bot removed the INVALID-PR-TEMPLATE PR's body doesn't match template label Jul 13, 2026
@sahar-fehri sahar-fehri removed the pr-not-ready-for-e2e Skip E2E and block merging. Remove this label once the PR is ready to run the E2E tests. label Jul 13, 2026
@sahar-fehri sahar-fehri marked this pull request as ready for review July 13, 2026 16:45
@sahar-fehri sahar-fehri requested a review from a team as a code owner July 13, 2026 16:45
…MetaMask/metamask-mobile into cursor/price-chart-data-conditions-a279

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	app/components/UI/AssetOverview/Price/tokenOverviewChart.constants.ts
@github-actions github-actions Bot added size-M risk:high AI analysis: high risk and removed size-S labels Jul 13, 2026
*/
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).

* Binary-search for the data point whose timestamp is closest to `target`.
* Assumes `sortedPrices` is sorted ascending by timestamp.
*/
function findNearestIndex(sortedPrices: TokenPrice[], target: number): number {

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.

Before, the chart was index-based — the x-axis was just array positions (0, 1, 2, 3...), not timestamps. So the touch mapping was trivial:

Pixel → divide by spacing → you get the exact array index. No searching needed because the data points are evenly spaced across the chart width by definition.

With time-based x-axis, data points are not evenly spaced — they sit at their real timestamps. Two points might be 5 minutes apart, then the next one 30 minutes later. So a pixel position no longer maps cleanly to an array index. You need the binary search to find which actual data point is closest to the timestamp the user is pointing at.

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions github-actions Bot added risk:medium AI analysis: medium risk and removed risk:high AI analysis: high risk labels Jul 13, 2026
@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

🧪 Flaky unit test detection

Run history flaky detection

View recent run history

Historical failure rate is a hint, not proof — review each suggestion in context. See the flaky-test-detection skill for the full pattern reference and manual audit workflow.

Failures / runs sampled per window:

File 7d 15d 30d
app/components/UI/AssetOverview/PriceChart/PriceChart.test.tsx 0/57 0/185 0/365
app/components/hooks/useTokenHistoricalPrices.test.ts 0/57 0/185 0/365

AI-detected flaky patterns

app/components/UI/AssetOverview/PriceChart/PriceChart.test.tsx

  • J7 — Non-deterministic data: Date.now() (medium)
    • The createResponderEvent helper (used by multiple touch/gesture tests) calls Date.now() on every invocation to populate timestamp fields in the mock event and touchHistory. Per the loaded mms-flaky-test-detection skill this is the canonical J7 non-deterministic data pattern (see skill example using Date.now() in test data). The test file has no fake timers, no setSystemTime, and no fixed timestamp constant, making event timestamps vary across runs/CI loads. No other J1-J10 patterns matched in either file (both already use beforeEach(jest.clearAllMocks()), have no waitFor, no fake+waitFor mix, no module-level lets, no spyOn without restore, no sleeps, and the single async test uses userEvent.press rather than a raw async prop callback). Historical data showed zero failures so the hint was not needed to surface this.
    • Suggested fix in app/components/UI/AssetOverview/PriceChart/PriceChart.test.tsx:
      -    const createResponderEvent = (locationX: number, locationY: number) => {
      -      const timestamp = Date.now();
      -      return {
      -        nativeEvent: {
      -          locationX,
      -          locationY,
      -          pageX: locationX,
      -          pageY: locationY,
      -          identifier: 1,
      -          target: 0,
      -          timestamp,
      -        },
      -        touchHistory: {
      -          indexOfSingleActiveTouch: 0,
      -          mostRecentTimeStamp: timestamp,
      -          numberActiveTouches: 1,
      -          touchBank: [
      -            {
      -              touchActive: true,
      -              startPageX: locationX,
      -              startPageY: locationY,
      -              startTimeStamp: timestamp,
      -              currentPageX: locationX,
      -              currentPageY: locationY,
      -              currentTimeStamp: timestamp,
      -              previousPageX: locationX,
      -              previousPageY: locationY,
      -              previousTimeStamp: timestamp,
      -            },
      -          ],
      -        },
      -      };
      -    };
      +    const FIXED_TIMESTAMP = 1736761237983;
      +
      +    const createResponderEvent = (locationX: number, locationY: number, timestamp = FIXED_TIMESTAMP) => {
      +      return {
      +        nativeEvent: {
      +          locationX,
      +          locationY,
      +          pageX: locationX,
      +          pageY: locationY,
      +          identifier: 1,
      +          target: 0,
      +          timestamp,
      +        },
      +        touchHistory: {
      +          indexOfSingleActiveTouch: 0,
      +          mostRecentTimeStamp: timestamp,
      +          numberActiveTouches: 1,
      +          touchBank: [
      +            {
      +              touchActive: true,
      +              startPageX: locationX,
      +              startPageY: locationY,
      +              startTimeStamp: timestamp,
      +              currentPageX: locationX,
      +              currentPageY: locationY,
      +              currentTimeStamp: timestamp,
      +              previousPageX: locationX,
      +              previousPageY: locationY,
      +              previousTimeStamp: timestamp,
      +            },
      +          ],
      +        },
      +      };
      +    };

app/components/hooks/useTokenHistoricalPrices.test.ts

  • J7 — Non-deterministic data (medium)
    • The test declares const now = Date.now(); at the describe level and uses it to generate all test time series data. This matches J7 (Non-deterministic data: Date.now()) because each test run uses a different base timestamp. While the relative calculations make the assertions pass today, it is non-deterministic and could mask or introduce timing-related flakiness if the implementation ever compares against live clock values. Historical data shows zero failures, but the pattern is present in the test file itself.
    • Suggested fix in app/components/hooks/useTokenHistoricalPrices.test.ts:
      -describe('hasInsufficientTimeCoverage', () => {
      -  const now = Date.now();
      -
      +describe('hasInsufficientTimeCoverage', () => {
      +  const now = 1000000000000;
      +

This check is informational only and does not block merging.

@sahar-fehri sahar-fehri changed the title chore: relax-price-api-condition-legacy-chart chore: relax-price-api-condition-legacy-chart and update to time based index Jul 13, 2026
sahar-fehri and others added 3 commits July 13, 2026 18:51
….legacy

Cover findNearestIndex binary search (empty, single, exact, between,
before/after), time-based rendering, time-based touch/scrub, EndDot
with time-based x, and the timePeriodMs pass-through (including the
"all" → undefined fallback) to close SonarCloud coverage gaps.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Addresses flaky test detection bot J7 pattern (non-deterministic data).

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions

Copy link
Copy Markdown
Contributor

🔍 Smart E2E Test Selection

  • Selected E2E tags: SmokeAccounts, SmokeConfirmations, SmokeNetworkAbstractions, SmokeNetworkExpansion, SmokeSwap, SmokeStake, SmokeWalletPlatform, SmokeMoney, SmokePerps, SmokeMultiChainAPI, SmokePredictions, SmokeSeedlessOnboarding, SmokeBrowser, SmokeSnaps
  • Selected Performance tags: None (no tests recommended)
  • Risk Level: high
  • AI Confidence: 100%
click to see 🤖 AI reasoning details

E2E Test Selection:
Hard rule (global-infrastructure-change): Global infrastructure changed: app/components/hooks/useTokenHistoricalPrices.test.ts, app/components/hooks/useTokenHistoricalPrices.ts. Running all tests.

Performance Test Selection:
The changes are to the price chart rendering within the asset detail view. While @PerformanceAssetLoading covers asset loading, it focuses on token list rendering and balance fetching rather than the price chart detail view. The algorithmic changes (binary search, time-based axis) are improvements that shouldn't negatively impact performance, and no performance spec files reference these components.

View GitHub Actions results

@sonarqubecloud

Copy link
Copy Markdown

Comment on lines +48 to +69
export function findNearestIndex(
sortedPrices: TokenPrice[],
target: number,
): number {
if (sortedPrices.length === 0) return -1;
let lo = 0;
let hi = sortedPrices.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (Number(sortedPrices[mid][0]) < target) {
lo = mid + 1;
} else {
hi = mid;
}
}
if (lo > 0) {
const diffLo = Math.abs(Number(sortedPrices[lo][0]) - target);
const diffPrev = Math.abs(Number(sortedPrices[lo - 1][0]) - target);
if (diffPrev < diffLo) return lo - 1;
}
return lo;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm do we need a custom binary search impl here?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claude is recommending d3-array - which we already pull in as a dep.

import { bisector } from 'd3-array';

const tokenPriceBisector = bisector((p: TokenPrice) => Number(p[0]));

...
const idx = tokenPriceBisector.center(prices, targetTs);
onActiveIndexChange(idx);

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.

Hey! d3-array isn't actually a direct dependency.. it's only pulled in transitively via react-native-svg-charts. Adding it as a direct dep just for one bisector call felt like overkill, and the custom binary search is only ~15 lines. Happy to add it as a direct dep if you feel strongly though, lmk!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

yeeeah fair enough... I see you added some tests to prove this logic. I think its okay to keep for now.

const now = Date.now();
return { chartXMin: now - timePeriodMs, chartXMax: now };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isTimeBased, timePeriodMs, prices]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

how come we need this eslint rule disable?

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.

The memo uses Date.now() to set the chart's right edge to "now." Without prices in the dep array, the memo wouldn't re-run when fresh data arrives — so if the user stays on screen and a new fetch happens, the x-domain would stay anchored to the original Date.now() and the latest data points could fall outside the visible window. Adding prices as a dep forces a recalculation of the time anchor whenever new data comes in.

Comment on lines +222 to +236
if (isTimeBased && chartXMin != null && chartXMax != null) {
const rangeMax = chartWidth - endDotInsetRight;
const clamped = Math.max(0, Math.min(pixelX, rangeMax));
const fraction = rangeMax > 0 ? clamped / rangeMax : 0;
const targetTs = chartXMin + fraction * (chartXMax - chartXMin);
const idx = findNearestIndex(prices, targetTs);
onActiveIndexChange(idx);
} else {
const xDistance = chartWidth / priceList.length;
const clamped = Math.max(0, Math.min(pixelX, chartWidth));
let value = Number((clamped / xDistance).toFixed(0));
if (value >= priceList.length - 1) {
value = priceList.length - 1;
}
onActiveIndexChange(value);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

math heavy... but looks like this is unavoidable.

@sahar-fehri sahar-fehri added this pull request to the merge queue Jul 15, 2026
Merged via the queue into main with commit f088cd1 Jul 15, 2026
276 of 278 checks passed
@sahar-fehri sahar-fehri deleted the cursor/price-chart-data-conditions-a279 branch July 15, 2026 09:42
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 15, 2026
@metamask-ci metamask-ci Bot added the release-8.4.0 Issue or pull request that will be included in release 8.4.0 label Jul 15, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

release-8.4.0 Issue or pull request that will be included in release 8.4.0 risk:medium AI analysis: medium risk size-M team-assets

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants