Skip to content

Commit 5d661e9

Browse files
authored
fix(rewards): Update mUSD calculator input max to 10M (#30202)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **Description** - Split mUSD calculator input max from slider max: -- text input now accepts up to 10,000,000 -- slider scale remains capped at 10,000 - Added compact yearly earnings formatting when the input amount is >= 100,000. - When typed input is above the slider max, the slider stays visually pinned to the right. Touching or dragging the slider brings the amount back onto the slider scale. https://github.com/user-attachments/assets/b6136ea3-f03a-4c07-b3d2-c34e636c8bb1 <!-- 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** <!-- 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** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [x] 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). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] 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. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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] > **Low Risk** > Low risk: localized UI/input/formatting changes to the Rewards mUSD calculator with updated tests; no auth, network, or persistence logic touched. > > **Overview** > The mUSD Rewards calculator now accepts typed amounts up to `10,000,000` while keeping the slider scale capped at `10,000`, with the slider thumb visually pinned at max when the typed value exceeds the slider range and returning to slider scale on the next slider interaction. > > Yearly earnings display switches to *compact USD* formatting (e.g., `$123K`) when the input amount is `>= 100,000`, enabled by extending `formatCompactUsd` to support an optional `maximumFractionDigits` setting. > > Tests are updated/added to cover the new input cap behavior, slider resync behavior, and compact earnings formatting. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit bb7f98a. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 83b886d commit 5d661e9

4 files changed

Lines changed: 92 additions & 13 deletions

File tree

app/components/UI/Rewards/components/Tabs/MusdCalculatorTab/MusdCalculatorTab.test.tsx

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,12 +234,73 @@ describe('MusdCalculatorTab', () => {
234234
});
235235
});
236236

237-
it('caps typed amounts at the slider maximum', async () => {
237+
it('allows typed amounts above the slider maximum', async () => {
238238
const { getByTestId, getByText } = render(<MusdCalculatorTab />);
239239
const amountInput = getByTestId('musd-slider-amount-display');
240240

241241
fireEvent.changeText(amountInput, '12000');
242242

243+
await waitFor(() => {
244+
expect(amountInput).toHaveProp('value', '12000');
245+
expect(getByText(/\$360/)).toBeOnTheScreen();
246+
});
247+
});
248+
249+
it('compacts yearly earnings when the input amount is at the compact threshold', async () => {
250+
const { getByTestId, getByText } = render(<MusdCalculatorTab />);
251+
const amountInput = getByTestId('musd-slider-amount-display');
252+
253+
fireEvent.changeText(amountInput, '2552222');
254+
255+
await waitFor(() => {
256+
expect(amountInput).toHaveProp('value', '2552222');
257+
expect(getByText(/\$77K/)).toBeOnTheScreen();
258+
});
259+
});
260+
261+
it('caps typed amounts at the input maximum', async () => {
262+
const { getByTestId, getByText } = render(<MusdCalculatorTab />);
263+
const amountInput = getByTestId('musd-slider-amount-display');
264+
265+
fireEvent.changeText(amountInput, '12000000');
266+
267+
await waitFor(() => {
268+
expect(amountInput).toHaveProp('value', '10000000');
269+
expect(getByText(/\$300K/)).toBeOnTheScreen();
270+
});
271+
});
272+
273+
it('formats compact yearly earnings without decimals', async () => {
274+
const { getByTestId, getByText } = render(<MusdCalculatorTab />);
275+
const amountInput = getByTestId('musd-slider-amount-display');
276+
277+
fireEvent.changeText(amountInput, '4115200');
278+
279+
await waitFor(() => {
280+
expect(amountInput).toHaveProp('value', '4115200');
281+
expect(getByText(/\$123K/)).toBeOnTheScreen();
282+
});
283+
});
284+
285+
it('returns typed amounts above the slider maximum to the slider scale after touching the slider', async () => {
286+
const { getByTestId, getByText } = render(<MusdCalculatorTab />);
287+
const amountInput = getByTestId('musd-slider-amount-display');
288+
const track = getByTestId('musd-slider-track');
289+
290+
fireEvent(track, 'layout', {
291+
nativeEvent: { layout: { width: 300, height: 32, x: 0, y: 0 } },
292+
});
293+
fireEvent.changeText(amountInput, '12000');
294+
295+
await waitFor(() => {
296+
expect(amountInput).toHaveProp('value', '12000');
297+
expect(getByText(/\$360/)).toBeOnTheScreen();
298+
});
299+
300+
await act(async () => {
301+
mockTapGestureHandlers.onEnd?.({ x: 300 });
302+
});
303+
243304
await waitFor(() => {
244305
expect(amountInput).toHaveProp('value', '10000');
245306
expect(getByText(/\$300/)).toBeOnTheScreen();

app/components/UI/Rewards/components/Tabs/MusdCalculatorTab/MusdCalculatorTab.tsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,11 @@ import {
5151
} from '../../../../Earn/constants/musd';
5252
import { useAnalytics } from '../../../../../hooks/useAnalytics/useAnalytics';
5353
import { RewardsMetricsButtons } from '../../../utils';
54-
import { formatUsd } from '../../../utils/formatUtils';
54+
import { formatCompactUsd, formatUsd } from '../../../utils/formatUtils';
5555
import {
5656
amountToPercent,
5757
clampAmount,
58-
MAX_AMOUNT,
58+
MAX_AMOUNT as MAX_SLIDER_AMOUNT,
5959
MUSD_CALCULATOR_APY,
6060
percentToAmount,
6161
SNAP_POINTS,
@@ -87,6 +87,8 @@ const SLIDER_AMOUNT_THROTTLE_MS = 48;
8787
const INITIAL_AMOUNT = SNAP_POINTS[1];
8888
const KEYBOARD_EXTRA_SCROLL_HEIGHT = 20;
8989
const MAX_DECIMAL_PLACES = 2;
90+
const MAX_INPUT_AMOUNT = 10_000_000;
91+
const COMPACT_USD_THRESHOLD = 100_000;
9092

9193
const normalizeAmountInput = (value: string) => {
9294
const numeric = value.replace(/[^0-9.]/g, '');
@@ -103,10 +105,10 @@ const getAmountInputState = (value: string) => {
103105
const inputValue = normalizeAmountInput(value);
104106
const numericAmount = Number(inputValue);
105107

106-
if (Number.isFinite(numericAmount) && numericAmount > MAX_AMOUNT) {
108+
if (Number.isFinite(numericAmount) && numericAmount > MAX_INPUT_AMOUNT) {
107109
return {
108-
amount: MAX_AMOUNT,
109-
inputValue: String(MAX_AMOUNT),
110+
amount: MAX_INPUT_AMOUNT,
111+
inputValue: String(MAX_INPUT_AMOUNT),
110112
};
111113
}
112114

@@ -126,7 +128,9 @@ const useMusdSlider = (
126128
const lastThrottledAmountSyncRef = useRef(0);
127129

128130
useEffect(() => {
129-
thumbLinearPctShared.value = amountToPercent(amount);
131+
thumbLinearPctShared.value = amountToPercent(
132+
Math.min(amount, MAX_SLIDER_AMOUNT),
133+
);
130134
}, [amount, thumbLinearPctShared]);
131135

132136
const syncAmountFromLinearPct = useCallback(
@@ -262,6 +266,10 @@ const MusdCalculatorTab: React.FC = () => {
262266

263267
const yearlyEarnings = amount * MUSD_CALCULATOR_APY;
264268
const dailyEarnings = yearlyEarnings / 365;
269+
const yearlyEarningsLabel =
270+
amount >= COMPACT_USD_THRESHOLD
271+
? formatCompactUsd(yearlyEarnings, { maximumFractionDigits: 0 })
272+
: formatUsd(yearlyEarnings);
265273

266274
const handleBuyMusd = useCallback(() => {
267275
trackEvent(
@@ -356,7 +364,7 @@ const MusdCalculatorTab: React.FC = () => {
356364
twClassName="text-[64px] leading-[72px] font-semibold"
357365
>
358366
{strings('rewards.musd.yearly_positive_prefix')}
359-
{formatUsd(yearlyEarnings)}
367+
{yearlyEarningsLabel}
360368
</Text>
361369
<Text
362370
variant={TextVariant.HeadingMd}

app/components/UI/Rewards/utils/formatUtils.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1652,5 +1652,11 @@ describe('formatUtils', () => {
16521652
it('formats negative thousands', () => {
16531653
expect(formatCompactUsd(-75_000)).toBe('-$75K');
16541654
});
1655+
1656+
it('formats compact values without decimals when requested', () => {
1657+
expect(formatCompactUsd(123_456, { maximumFractionDigits: 0 })).toBe(
1658+
'$123K',
1659+
);
1660+
});
16551661
});
16561662
});

app/components/UI/Rewards/utils/formatUtils.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -343,19 +343,23 @@ export const formatUsd = (value: string | number): string =>
343343
* @example formatCompactUsd(25000) // '$25K'
344344
* @example formatCompactUsd(500) // '$500'
345345
*/
346-
export const formatCompactUsd = (value: number): string => {
346+
export const formatCompactUsd = (
347+
value: number,
348+
options?: { maximumFractionDigits?: number },
349+
): string => {
347350
const abs = Math.abs(value);
348351
const sign = value < 0 ? '-' : '';
352+
const maximumFractionDigits = options?.maximumFractionDigits ?? 1;
353+
const formatCompactValue = (compact: number) =>
354+
`${Number(compact.toFixed(maximumFractionDigits))}`;
349355

350356
if (abs >= 1_000_000) {
351357
const compact = abs / 1_000_000;
352-
const formatted = compact % 1 === 0 ? `${compact}` : compact.toFixed(1);
353-
return `${sign}$${formatted}M`;
358+
return `${sign}$${formatCompactValue(compact)}M`;
354359
}
355360
if (abs >= 1_000) {
356361
const compact = abs / 1_000;
357-
const formatted = compact % 1 === 0 ? `${compact}` : compact.toFixed(1);
358-
return `${sign}$${formatted}K`;
362+
return `${sign}$${formatCompactValue(compact)}K`;
359363
}
360364
return `${sign}$${abs}`;
361365
};

0 commit comments

Comments
 (0)