feat(predict): add crypto up/down details UI#30005
Conversation
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
|
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. |
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…to predict/crypto-updown-details-ui
Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit e0dd2d1. Configure here.
MarioAslau
left a comment
There was a problem hiding this comment.
Hey @ghgoodreau found these issues. Requestion change for the High priority ones:
High-priority issues
H1. useCryptoUpDownChartData.isLive doesn't honour the new enabled option early
The hook adds new options (enabled, liveUpdatesEnabled, historicalWindow) and a single if (!enabled) short-circuit near the end, but the intermediate state — isLiveByEndDate, wsSymbol, useLiveCryptoPrices, the historical useQuery — is wired off Date.now() < liveEndDateMs regardless of enabled:
const isLiveByEndDate =
enabled && typeof liveEndDateMs === 'number'
? Date.now() < liveEndDateMs
: false; const wsSymbol =
enabled && shouldStreamLive && symbol ? `${symbol.toLowerCase()}/usd` : '';
useLiveCryptoPrices(wsSymbol, handleLiveUpdate);In the workspace this is partially guarded (enabled is now an AND in both spots), but the historicalQuery is still gated only on enabled && !!symbol && !!historyStartDate. At the PR head commit (e0dd2d1) the guard is missing in several places. Stack item #30342 plans to reuse this hook with enabled: false; if that lands without re-auditing, the hook will fire a network request and possibly a WS subscription for a disabled caller. Either add a single if (!enabled) return /* empty */ at the top of the hook body, or make every internal effect gate on enabled.
H2. formatValue passed as a stringified function body
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('.')"LivelineChart accepts this string and presumably runs it as a Skia / Reanimated worklet via new Function('v', source). There is no test that actually executes this string — the chart test only asserts the literal string is forwarded — and any drift in quoting, escaping, or the regex (\B, (?=(\d{3})+(?!\d))) breaks the chart's tooltip/axis labels at runtime, visible only on device. The same formatter is also duplicated logic that already exists in formatCurrencyValue. Two remediation options:
- Change
LivelineChartto accept(v: number) => stringand passformatCurrencyValuedirectly. - Keep the string contract but add a
LivelineCharttest that runsnew Function('v', formatValue)(1234567.89)and asserts the output ("$1,234,567.89"), so the format string is regression-protected.
Medium-priority issues
M1. PredictCryptoUpDownDetails uses raw (non-CLOB-augmented) prices
PredictMarketDetails runs useOpenOutcomes to overlay live CLOB prices on the resolved market:
const { closedOutcomes, openOutcomes, yesPercentage } = useOpenOutcomes({
market,
isMarketFetching: isResolvedMarketFetching,
});…but the new child screen does not receive those augmented outcomes. It recomputes its own from the usePredictSeries payload via the local helpers and then drives PredictMarketDetailsActions from that:
const getOpenOutcomes = (market: PredictMarket): PredictOutcome[] =>
market.outcomes.filter(
(outcome) => outcome.status === OPEN_PREDICT_OUTCOME_STATUS,
); const selectedOpenOutcomes = useMemo(
() => getOpenOutcomes(selectedMarket),
[selectedMarket],
);
const selectedYesPercentage = useMemo(
() => getYesPercentage(selectedMarket, selectedOpenOutcomes),
[selectedMarket, selectedOpenOutcomes],
);Concrete effect: the Yes/No percentage on the buy buttons (and the payout estimate built from token?.price) is derived from whatever the series endpoint last returned, which may be several seconds stale relative to the CLOB live prices already in memory upstream. Either thread openOutcomes / yesPercentage down from PredictMarketDetails, or call useOpenOutcomes inside PredictCryptoUpDownDetails with selectedMarket. (The follow-up branch predict/up-down-resuable-hook has already done the latter — landing that consolidation here avoids the merge churn.)
M2. Analytics context still tied to outer market, not selectedMarket
PredictMarketDetails.handleBuyPress was updated to accept and forward selectedMarket into openBuySheet, but the surrounding analytics envelope still closes over the outer market:
const handleBuyPress = (
token: PredictOutcomeToken,
selectedMarket: typeof market = market,
) => {
if (!selectedMarket) {
return;
}
executeGuardedAction(
() => {
...
openBuySheet({
market: selectedMarket,
...
entryPoint:
entryPoint || PredictEventValues.ENTRY_POINT.PREDICT_MARKET_DETAILS,
...(transactionActiveAbTests?.length && { transactionActiveAbTests }),
});
},
{
attemptedAction: PredictEventValues.ATTEMPTED_ACTION.PREDICT,
},
);
};transactionActiveAbTests and entryPoint are computed once for the route market; executeGuardedAction and usePredictActionGuard track the same market. After the user picks a different time slot, the buy sheet opens for the right market but the surrounding telemetry/A-B-test buckets identify the route market. For analytics-only this is a soft-medium; for A/B tests, the variation can affect what the buy sheet itself shows. Worth either re-deriving transactionActiveAbTests from selectedMarket.id, or asserting in a test that "switching time slot + buy" tags the slot's market id.
M3. Header bar is empty after refactor (Bugbot still applies)
The PR swaps HeaderStandardAnimated (scroll-driven title) for HeaderCompactStandard, but stops passing title / subtitle:
<HeaderCompactStandard
onBack={onBack}
backButtonProps={{
testID: PredictCryptoUpDownDetailsSelectorsIDs.BACK_BUTTON,
}}
endButtonIconProps={[
{
iconName: IconName.Share,
...
},
]}
testID={PredictCryptoUpDownDetailsSelectorsIDs.HEADER}
/>HeaderCompactStandard renders its centre content from title / subtitle:
const renderContent = () => {
if (children) {
return children;
}
if (title) {
return (
<Box alignItems={BoxAlignItems.Center}>
...
{subtitle && (
...
)}
</Box>
);
}
return null;
};So today the header bar is just ← and ↗. As soon as the user scrolls past the TitleSubpage block, they lose the identifying context (which crypto? which slot?). Either pass title / subtitle (matching the old scrolled-past behaviour) or document that design wants a chrome-less header.
M4. 30s live window is now hardcoded for every recurrence
const LIVE_CHART_WINDOW_SECS = 30;
const LIVE_CHART_RETENTION_SECS = LIVE_CHART_WINDOW_SECS * 2;
const LIVE_CHART_MAX_POINTS = LIVE_CHART_RETENTION_SECS * 60;…and at the live return:
if (isLive || hasFrozenLiveData || hasExpiredLiveData) {
return {
data: chartData,
...
window: LIVE_CHART_WINDOW_SECS,
};
}The new test "uses the 30-second live window for longer recurrence markets" pins this behaviour for 4h markets. The hook also merges historical data into chartData, but the chart only renders a 30-second-wide x-axis, so historical context for hourly/4h markets is collapsed off the right edge of the chart. The PR description states this is intentional (the "live" feel), but it deserves either a follow-up ticket to revisit per-recurrence windows before hourly/4h markets are surfaced, or a brief comment in the constants block explaining the trade-off.
M5. Buy fallback can dispatch a token with a mismatched parent outcome
const selectedOpenOutcomes =
selectedMarket.id === market?.id
? openOutcomes
: selectedMarket.outcomes.filter(
(outcome) => outcome.status === OPEN_PREDICT_OUTCOME_STATUS,
);
const matchingOutcome =
selectedMarket.outcomes.find((o) =>
o.tokens.some((marketToken) => marketToken.id === token.id),
) ??
selectedOpenOutcomes[0] ??
selectedMarket.outcomes?.[0];If the lookup misses (e.g. the series payload doesn't carry tokens for the just-selected slot), the fallback selectedOpenOutcomes[0] ?? selectedMarket.outcomes?.[0] opens the buy sheet with the first outcome, regardless of which side the user actually tapped. For the crypto up/down flow (single-outcome market with two tokens) the .find always succeeds, so this never fires today — but the helper is shared with sports flows, and a future series-shape change would silently route Yes-presses to the No outcome and vice-versa. Either:
- Return early when there is no match:
if (!matchingOutcome) return; - Or assert in the fallback path that the chosen
outcome.tokenscontainstoken.id, and skip if not.
M6. OPEN_PREDICT_OUTCOME_STATUS filtering duplicated in four places
The constant is correctly hoisted into types/index.ts:
export const OPEN_PREDICT_OUTCOME_STATUS: PredictOutcome['status'] = 'open';…but the filter expression is re-implemented in:
app/components/UI/Predict/views/PredictMarketDetails/hooks/useChartData.ts(chartOpenOutcomes)app/components/UI/Predict/views/PredictMarketDetails/hooks/useOpenOutcomes.ts(openOutcomesBase)app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx(M5 above)app/components/UI/Predict/components/PredictCryptoUpDownDetails/PredictCryptoUpDownDetails.tsx(localgetOpenOutcomes)
Extract a getOpenOutcomes(market) selector next to the constant. Future additions ('live', 'paused', etc.) then only touch one file. Bugbot already flagged a slice of this; the broader pattern matters more than the constant placement.
M7. Payout estimate hard-codes a $100 stake
const LONG_OUTCOME_LABEL_THRESHOLD = 12;
const TALL_ACTION_BUTTON_MIN_HEIGHT = 48;
const DEFAULT_PAYOUT_INVESTMENT_AMOUNT = 100;
const MIN_PAYOUT_ESTIMATE_PRICE = 0.01;
const MAX_PAYOUT_ESTIMATE_PRICE = 0.99;const formatPayoutEstimate = (
price: number | undefined,
investmentAmount = DEFAULT_PAYOUT_INVESTMENT_AMOUNT,
) => {
...
return `${investmentAmountDisplay} -> ${payoutAmountDisplay}`;
};The estimate always reads "$100.00 -> $X" regardless of the user's wallet balance or any preview state. Users with a $5 or $5,000 stake will see a number that has no relationship to what they'll actually receive. Two acceptable directions:
- Make the literal string speak in those terms:
'For $100, win up to $153.85'(i18n key with{{amount}}interpolations). - Thread the actual preview/balance amount in through
PredictPreviewSheetContextor a new prop.
The tests pin the exact string '$100.00 -> $153.85', so a rename will need test updates.
M8. useGameDetailsTabs has two parallel out-of-bounds fixes
const handleTabPress = useCallback((tabIndex: number) => {
setActiveTab(tabIndex);
}, []);
const showTabBar = enabled && hasPositions;
const resolvedActiveTab = activeTab >= tabs.length ? 0 : activeTab;
useEffect(() => {
if (activeTab >= tabs.length) {
setActiveTab(0);
}
}, [activeTab, tabs.length]);Both the synchronous resolvedActiveTab and the useEffect correct the same invariant. The effect will run after the synchronous fix has already returned 0, then trigger a re-render. They will always agree, but it's two sources of truth for one rule. Pick one:
- Drop the effect and let
resolvedActiveTabbe the canonical exposed value (it already is — the return statement usesactiveTab: resolvedActiveTab). The internalactiveTabstate may temporarily store an out-of-range index, but no consumer can see it. - Drop
resolvedActiveTaband accept one render of a stale index in exchange for keeping internal state coherent.
Locking either pattern with a test ("user is on tab 1, tabs shrinks to 1 entry, exposed activeTab is 0 and handleTabPress correctly addresses the remaining tab") prevents the next refactor from re-introducing the bug.
… regression test
Extract the inline LivelineChart formatValue string into an exported CRYPTO_UP_DOWN_FORMAT_VALUE constant and add a regression test that reconstructs the formatter via new Function('v', ...) and asserts outputs across positive, negative, zero, sub-dollar, thousands and millions. Functions cannot cross the RN to WebView JSON bridge (see LivelineChart.types.ts), so the string contract has to stay; this locks its exact output so drift fails CI instead of only showing as broken tooltip/axis labels on device.
🔍 Smart E2E Test Selection
click to see 🤖 AI reasoning detailsE2E Test Selection: Performance Test Selection: |
|
fixed the relevant high priority issue, the PR stacked on top of this also expands upon this work with a lot of improvements.
MarioAslau
left a comment
There was a problem hiding this comment.
PredictCryptoUpDownDetails drives PredictMarketDetailsActions from raw (non-CLOB-augmented) outcomes
PredictMarketDetails runs useOpenOutcomes to overlay live CLOB prices on the resolved market:
const { closedOutcomes, openOutcomes, yesPercentage } = useOpenOutcomes({
market,
isMarketFetching,
});…but the new child screen recomputes its own outcomes locally from the series payload:
const getOpenOutcomes = (market: PredictMarket): PredictOutcome[] =>
market.outcomes.filter(
(outcome) => outcome.status === OPEN_PREDICT_OUTCOME_STATUS,
);
...
const getYesPercentage = (
market: PredictMarket,
openOutcomes: PredictOutcome[],
) => {
const firstOpenOutcome = openOutcomes[0];
const firstTokenPrice = firstOpenOutcome?.tokens?.[0]?.price;
if (typeof firstTokenPrice === 'number') {
return Math.round(firstTokenPrice * 100);
}
...
};…and feeds these (selectedOpenOutcomes, selectedYesPercentage) into PredictMarketDetailsActions (lines 547–549). Concrete effect: the Yes/No percentage shown on the buy buttons (and the new payout estimate built from token?.price) is whatever the usePredictSeries endpoint last returned — which may be several seconds stale relative to the CLOB live prices already in memory upstream. Either thread openOutcomes / yesPercentage down from PredictMarketDetails, or call useOpenOutcomes inside PredictCryptoUpDownDetails with selectedMarket. The follow-up branch predict/up-down-resuable-hook has already done the latter — landing that consolidation here avoids merge churn.




Description
This PR adds the crypto up/down market details UI for Predict. It introduces the crypto up/down chart and details screen, wires crypto markets into the existing market details route, adds time-slot selection, price summary, target/current price display, and buy/claim actions.
It also includes focused UI test coverage and a small Bugbot cleanup so outcome status checks use the outcome status type rather than the market status enum.
TODO
Changelog
CHANGELOG entry: Added crypto up/down market details with live charting in Predict
Related issues
Fixes: N/A - stacked Predict crypto up/down implementation split from #29436.
Manual testing steps
Screenshots/Recordings
Before
N/A
After
Pre-merge author checklist
Performance checks (if applicable)
trace()for usage andaddTokenfor an exampleFor performance guidelines and tooling, see the Performance Guide.
Pre-merge reviewer checklist
Note
Medium Risk
Adds a new crypto up/down details UI with time-window selection, live price/target handling, and claim/buy actions; correctness depends on timer-driven windowing and live price stream behavior. Also changes live chart data retention/timestamp handling, which could affect rendering and performance on device.
Overview
Adds a new
PredictCryptoUpDownDetailsflow that replaces the animated header layout, fetches and auto-advances among series time slots, shows a target vs current price summary (with signed deltas), and renders a newPredictCryptoUpDownChartwired touseCryptoUpDownChartDataanduseCryptoTargetPrice.Wires crypto up/down markets into
PredictMarketDetailswith callbacks for buy/claim actions, and updatesPredictMarketDetailsActionsto optionally show fixed-decimal payout estimates.Improves live chart data behavior by preserving millisecond sub-second precision, enforcing a fixed 30s live window with bounded retention, and handling repeated “current” timestamps; also standardizes open-outcome status checks via
OPEN_PREDICT_OUTCOME_STATUSand hardensuseGameDetailsTabsagainst out-of-bounds active tab state.Reviewed by Cursor Bugbot for commit 4bd4cfd. Bugbot is set up for automated code reviews on this repo. Configure here.