diff --git a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.test.tsx b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.test.tsx index 38a08665bb4..fa1854612e7 100644 --- a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.test.tsx +++ b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.test.tsx @@ -7,6 +7,7 @@ import { getStakingNavbar } from '../../../Navbar'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../../util/test/initial-root-state'; import { Hex } from '@metamask/utils'; + jest.mock('../../../Navbar'); jest.mock('../../hooks/useStakingEarningsHistory'); @@ -20,6 +21,11 @@ jest.mock('@react-navigation/native', () => { return { ...actualNav, useNavigation: () => mockNavigation, + useRoute: () => ({ + key: '1', + name: 'params', + params: { asset: MOCK_STAKED_ETH_ASSET }, + }), }; }); jest.mock('react-native-svg-charts', () => { @@ -79,15 +85,7 @@ const mockInitialState = { }, }; -const earningsHistoryView = ( - -); +const earningsHistoryView = ; describe('StakeEarningsHistoryView', () => { it('renders correctly and matches snapshot', () => { diff --git a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.tsx b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.tsx index a04273cf6b1..0174d1a5652 100644 --- a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.tsx +++ b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.tsx @@ -1,4 +1,4 @@ -import { useNavigation } from '@react-navigation/native'; +import { useNavigation, useRoute } from '@react-navigation/native'; import React, { useEffect } from 'react'; import { View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; @@ -7,10 +7,11 @@ import { useStyles } from '../../../../hooks/useStyles'; import { getStakingNavbar } from '../../../Navbar'; import StakingEarningsHistory from '../../components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory'; import styleSheet from './StakeEarningsHistoryView.styles'; -import { StakeEarningsHistoryViewProps } from './StakeEarningsHistoryView.types'; +import { StakeEarningsHistoryViewRouteParams } from './StakeEarningsHistoryView.types'; -const StakeEarningsHistoryView = ({ route }: StakeEarningsHistoryViewProps) => { +const StakeEarningsHistoryView = () => { const navigation = useNavigation(); + const route = useRoute(); const { styles, theme } = useStyles(styleSheet, {}); const { asset } = route.params; const ticker = asset.ticker ?? asset.symbol; diff --git a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.types.ts b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.types.ts index 5d5bee8cc39..106bb6ead4e 100644 --- a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.types.ts +++ b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.types.ts @@ -1,10 +1,9 @@ -import { RouteProp } from '@react-navigation/native'; import { TokenI } from '../../../Tokens/types'; -interface StakeEarningsHistoryViewRouteParams { - asset: TokenI -} - -export interface StakeEarningsHistoryViewProps { - route: RouteProp<{ params: StakeEarningsHistoryViewRouteParams }, 'params'>; +export interface StakeEarningsHistoryViewRouteParams { + key: string; + name: string; + params: { + asset: TokenI; + }; } diff --git a/app/components/UI/Stake/Views/StakeEarningsHistoryView/__snapshots__/StakeEarningsHistoryView.test.tsx.snap b/app/components/UI/Stake/Views/StakeEarningsHistoryView/__snapshots__/StakeEarningsHistoryView.test.tsx.snap index a3d7ae7286f..6469b6b7eda 100644 --- a/app/components/UI/Stake/Views/StakeEarningsHistoryView/__snapshots__/StakeEarningsHistoryView.test.tsx.snap +++ b/app/components/UI/Stake/Views/StakeEarningsHistoryView/__snapshots__/StakeEarningsHistoryView.test.tsx.snap @@ -28,124 +28,171 @@ exports[`StakeEarningsHistoryView renders correctly and matches snapshot 1`] = ` } } > - - - 7D - - - + + 7D + + + + + - - M - - - + + M + + + + + - - Y - - + + + Y + + + + { - {strings('stake.gas_cost_impact_warning')} + {strings('stake.gas_cost_impact_warning', { percentOverDeposit: 30 })} { - const [newYear, newMonth] = dateStr.split('-'); - const date = new Date(dateStr); - date.setUTCHours(0, 0, 0, 0); - const timePeriodInfo: TimePeriodGroupInfo = { - dateStr, - chartGroup: '', - chartGroupLabel: '', - listGroup: '', - listGroupLabel: '', - listGroupHeader: '', - }; - const dayLabel = date.toLocaleString('fullwide', { - month: 'long', - day: 'numeric', - timeZone: 'UTC', - }); - const monthLabel = date.toLocaleString('fullwide', { - month: 'long', - timeZone: 'UTC', - }); - const yearLabel = date.toLocaleString('fullwide', { - year: 'numeric', - timeZone: 'UTC', - }); - switch (selectedTimePeriod) { - case DateRange.DAILY: - timePeriodInfo.chartGroup = dateStr; - timePeriodInfo.chartGroupLabel = dayLabel; - timePeriodInfo.listGroup = dateStr; - timePeriodInfo.listGroupLabel = dayLabel; - break; - case DateRange.MONTHLY: - timePeriodInfo.chartGroup = `${newYear}-${newMonth}`; - timePeriodInfo.chartGroupLabel = monthLabel; - timePeriodInfo.listGroup = `${newYear}-${newMonth}`; - timePeriodInfo.listGroupLabel = monthLabel; - timePeriodInfo.listGroupHeader = newYear; - break; - case DateRange.YEARLY: - timePeriodInfo.chartGroup = newYear; - timePeriodInfo.chartGroupLabel = yearLabel; - timePeriodInfo.listGroup = newYear; - timePeriodInfo.listGroupLabel = yearLabel; - break; - default: - throw new Error('Unsupported time period'); - } - return timePeriodInfo; -}; +import { + EarningsHistoryData, + StakingEarningsHistoryProps, + TimePeriodGroupInfo, +} from './StakingEarningsHistory.types'; +import StakingEarningsHistoryList from './StakingEarningsHistoryList/StakingEarningsHistoryList'; +import TimePeriodButtonGroup from './StakingEarningsTimePeriod/StakingEarningsTimePeriod'; +import { + EARNINGS_HISTORY_CHART_BAR_LIMIT, + EARNINGS_HISTORY_DAYS_LIMIT, + EARNINGS_HISTORY_TIME_PERIOD_DEFAULT, +} from './StakingEarningsHistory.constants'; +import { DateRange } from './StakingEarningsTimePeriod/StakingEarningsTimePeriod.types'; +import { + fillGapsInEarningsHistory, + formatRewardsFiat, + formatRewardsNumber, + formatRewardsWei, + getEntryTimePeriodGroupInfo, +} from './StakingEarningsHistory.utils'; const StakingEarningsHistory = ({ asset }: StakingEarningsHistoryProps) => { const [selectedTimePeriod, setSelectedTimePeriod] = useState( @@ -128,14 +42,7 @@ const StakingEarningsHistory = ({ asset }: StakingEarningsHistoryProps) => { const currentCurrency: string = useSelector(selectCurrentCurrency); const multiChainMarketData = useSelector(selectTokenMarketData); const multiChainCurrencyRates = useSelector(selectCurrencyRates); - const networkConfigurations: Record = useSelector( - selectNetworkConfigurations, - ); - const nativeCurrency = - networkConfigurations?.[asset.chainId as Hex]?.nativeCurrency; - const conversionRate = - multiChainCurrencyRates?.[nativeCurrency]?.conversionRate ?? 0; - const exchangeRates = multiChainMarketData?.[asset.chainId as Hex]; + const networkConfigurations = useSelector(selectNetworkConfigurations); const { earningsHistory, isLoading: isLoadingEarningsHistory, @@ -144,77 +51,30 @@ const StakingEarningsHistory = ({ asset }: StakingEarningsHistoryProps) => { chainId: asset.chainId as ChainId, limitDays: EARNINGS_HISTORY_DAYS_LIMIT, }); - const ticker = asset.ticker ?? asset.symbol; - - const formatRewardsWei = useCallback( - (rewards: number | string | BN, isRemoveSpecialCharacters?: boolean) => { - if (!isRemoveSpecialCharacters) { - // return a string with possible special characters in display formatting - return asset.isETH - ? renderFromWei(rewards) - : renderFromTokenMinimalUnit(rewards, asset.decimals); - } - // return a string without special characters - return asset.isETH - ? fromWei(rewards) - : fromTokenMinimalUnit(rewards, asset.decimals); - }, - [asset.isETH, asset.decimals], - ); - const formatRewardsNumber = useCallback( - (rewards: number) => { - const num = new BN( - new BigNumber(rewards) - .multipliedBy(new BigNumber(10).pow(asset.decimals || 18)) - .toString(), - ); - return formatRewardsWei(num); - }, - [asset.decimals, formatRewardsWei], - ); + const ticker = asset.ticker ?? asset.symbol; + // get exchange rates for asset chainId + const exchangeRates = multiChainMarketData[asset.chainId as Hex]; + let exchangeRate = 0; + if (exchangeRates) { + exchangeRate = exchangeRates[asset.address as Hex]?.price; + } + // attempt to find native currency for asset chainId + const nativeCurrency = + networkConfigurations[asset.chainId as Hex]?.nativeCurrency; + let conversionRate = 0; + // if native currency is found, use it to get conversion rate + if (nativeCurrency) { + conversionRate = + multiChainCurrencyRates[nativeCurrency]?.conversionRate ?? conversionRate; + } - const formatRewardsFiat = useCallback( - (value: string | BN) => { - if (asset.isETH) { - const weiFiatNumber = weiToFiatNumber(new BN(value), conversionRate); - return renderFiat(weiFiatNumber, currentCurrency, 2); - } - const tokenFiatNumber = balanceToFiatNumber( - renderFromTokenMinimalUnit(value, asset.decimals), - conversionRate, - exchangeRates[asset.address as Hex].price, - ); - return renderFiat(tokenFiatNumber, currentCurrency, 2); - }, - [ - asset.isETH, - asset.decimals, - asset.address, - exchangeRates, - conversionRate, - currentCurrency, - ], + const transformedEarningsHistory = useMemo( + () => + fillGapsInEarningsHistory(earningsHistory, EARNINGS_HISTORY_DAYS_LIMIT), + [earningsHistory], ); - const transformedEarningsHistory = useMemo(() => { - if (!earningsHistory?.length) return []; - const gapFilledEarningsHistory = [...earningsHistory]; - const earliestDate = new Date(earningsHistory[0].dateStr); - const daysToFill = EARNINGS_HISTORY_DAYS_LIMIT - earningsHistory.length; - const gapDate = new Date(earliestDate); - gapDate.setUTCHours(0, 0, 0, 0); - for (let i = 0; i < daysToFill; i++) { - gapDate.setDate(gapDate.getDate() - 1); - gapFilledEarningsHistory.unshift({ - dateStr: gapDate.toISOString().split('T')[0], - dailyRewards: '0', - sumRewards: '0', - }); - } - return gapFilledEarningsHistory; - }, [earningsHistory]); - const { earningsHistoryChartData, earningsHistoryListData } = useMemo(() => { const historyData: EarningsHistoryData = { earningsHistoryChartData: { @@ -260,6 +120,7 @@ const StakingEarningsHistory = ({ asset }: StakingEarningsHistoryProps) => { const updateEarningsTotal = (entry: EarningHistory) => { historyData.earningsHistoryChartData.earningsTotal = formatRewardsWei( entry.sumRewards, + asset, ); }; @@ -274,7 +135,11 @@ const StakingEarningsHistory = ({ asset }: StakingEarningsHistoryProps) => { } else { historyData.earningsHistoryChartData.earnings.unshift({ value: parseFloat( - formatRewardsWei(rewardsTotalForChartTimePeriodBN.toString(), true), + formatRewardsWei( + rewardsTotalForChartTimePeriodBN.toString(), + asset, + true, + ), ), label: prevLastEntryTimePeriodGroupInfo.chartGroupLabel, }); @@ -302,9 +167,13 @@ const StakingEarningsHistory = ({ asset }: StakingEarningsHistoryProps) => { label: prevLastEntryTimePeriodGroupInfo.listGroupLabel, groupLabel: prevLastEntryTimePeriodGroupInfo.chartGroupLabel, groupHeader: prevLastEntryTimePeriodGroupInfo.listGroupHeader, - amount: formatRewardsWei(rewardsTotalForListTimePeriodBN), + amount: formatRewardsWei(rewardsTotalForListTimePeriodBN, asset), amountSecondaryText: formatRewardsFiat( rewardsTotalForListTimePeriodBN, + asset, + currentCurrency, + conversionRate, + exchangeRate, ), ticker, }); @@ -336,9 +205,13 @@ const StakingEarningsHistory = ({ asset }: StakingEarningsHistoryProps) => { label: lastEntryTimePeriodGroupInfo.listGroupLabel, groupLabel: lastEntryTimePeriodGroupInfo.chartGroupLabel, groupHeader: lastEntryTimePeriodGroupInfo.listGroupHeader, - amount: formatRewardsWei(rewardsTotalForListTimePeriodBN), + amount: formatRewardsWei(rewardsTotalForListTimePeriodBN, asset), amountSecondaryText: formatRewardsFiat( rewardsTotalForListTimePeriodBN, + asset, + currentCurrency, + conversionRate, + exchangeRate, ), ticker, }); @@ -351,7 +224,11 @@ const StakingEarningsHistory = ({ asset }: StakingEarningsHistoryProps) => { if (historyData.earningsHistoryChartData.earnings.length < barLimit) { historyData.earningsHistoryChartData.earnings.unshift({ value: parseFloat( - formatRewardsWei(rewardsTotalForChartTimePeriodBN.toString(), true), + formatRewardsWei( + rewardsTotalForChartTimePeriodBN.toString(), + asset, + true, + ), ), label: lastEntryTimePeriodGroupInfo.chartGroupLabel, }); @@ -399,9 +276,11 @@ const StakingEarningsHistory = ({ asset }: StakingEarningsHistoryProps) => { isLoadingEarningsHistory, errorEarningsHistory, transformedEarningsHistory, + asset, ticker, - formatRewardsWei, - formatRewardsFiat, + currentCurrency, + conversionRate, + exchangeRate, ]); const onTimePeriodChange = (newTimePeriod: DateRange) => { @@ -418,7 +297,7 @@ const StakingEarningsHistory = ({ asset }: StakingEarningsHistoryProps) => { ticker={ticker} earningsTotal={earningsHistoryChartData.earningsTotal} earnings={earningsHistoryChartData.earnings} - formatValue={(value) => formatRewardsNumber(value)} + formatValue={(value) => formatRewardsNumber(value, asset)} /> diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.types.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.types.ts new file mode 100644 index 00000000000..ac7b345c38c --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.types.ts @@ -0,0 +1,25 @@ +import { TokenI } from '../../../../Tokens/types'; +import { StakingEarningsHistoryChartData } from './StakingEarningsHistoryChart/StakingEarningsHistoryChart.types'; +import { StakingEarningsHistoryListData } from './StakingEarningsHistoryList/StakingEarningsHistoryList.types'; + +export interface StakingEarningsHistoryProps { + asset: TokenI; +} + +export interface TimePeriodGroupInfo { + dateStr: string; + chartGroup: string; + chartGroupLabel: string; + listGroup: string; + listGroupLabel: string; + listGroupHeader: string; +} + +export interface EarningsHistoryData { + earningsHistoryChartData: { + earnings: StakingEarningsHistoryChartData[]; + earningsTotal: string; + ticker: string; + }; + earningsHistoryListData: StakingEarningsHistoryListData[]; +} diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.utils.test.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.utils.test.ts new file mode 100644 index 00000000000..5bba1a3c256 --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.utils.test.ts @@ -0,0 +1,158 @@ +import { + MOCK_STAKED_ETH_ASSET, + MOCK_USDC_ASSET, +} from '../../../__mocks__/mockData'; +import { + getEntryTimePeriodGroupInfo, + fillGapsInEarningsHistory, + formatRewardsWei, + formatRewardsNumber, + formatRewardsFiat, +} from './StakingEarningsHistory.utils'; +import { DateRange } from './StakingEarningsTimePeriod/StakingEarningsTimePeriod.types'; + +const mockChartGroupDaily = { + dateStr: '2023-10-01', + chartGroup: '2023-10-01', + chartGroupLabel: 'October 1', + listGroup: '2023-10-01', + listGroupLabel: 'October 1', + listGroupHeader: '', +}; + +const mockChartGroupMonthly = { + dateStr: '2023-10-01', + chartGroup: '2023-10', + chartGroupLabel: 'October', + listGroup: '2023-10', + listGroupLabel: 'October', + listGroupHeader: '2023', +}; + +const mockChartGroupYearly = { + dateStr: '2023-10-01', + chartGroup: '2023', + chartGroupLabel: '2023', + listGroup: '2023', + listGroupLabel: '2023', + listGroupHeader: '', +}; + +describe('StakingEarningsHistory Utils', () => { + describe('getEntryTimePeriodGroupInfo', () => { + it('should return correct time period group info for daily', () => { + const result = getEntryTimePeriodGroupInfo( + mockChartGroupDaily.dateStr, + DateRange.DAILY, + ); + expect(result).toEqual(mockChartGroupDaily); + }); + + it('should return correct time period group info for monthly', () => { + const result = getEntryTimePeriodGroupInfo( + mockChartGroupMonthly.dateStr, + DateRange.MONTHLY, + ); + expect(result).toEqual(mockChartGroupMonthly); + }); + + it('should return correct time period group info for yearly', () => { + const result = getEntryTimePeriodGroupInfo( + mockChartGroupYearly.dateStr, + DateRange.YEARLY, + ); + expect(result).toEqual(mockChartGroupYearly); + }); + + it('should throw an error for unsupported time period', () => { + expect(() => + getEntryTimePeriodGroupInfo('2023-10-01', 'unsupported' as DateRange), + ).toThrow('Unsupported time period'); + }); + }); + + describe('fillGapsInEarningsHistory', () => { + it('should fill gaps in earnings history', () => { + const earningsHistory = [ + { dateStr: '2023-10-01', dailyRewards: '10', sumRewards: '10' }, + { dateStr: '2023-10-02', dailyRewards: '20', sumRewards: '30' }, + ]; + const result = fillGapsInEarningsHistory(earningsHistory, 5); + expect(result.length).toBe(5); + expect(result[0].dateStr).toBe('2023-09-28'); + expect(result[1].dateStr).toBe('2023-09-29'); + expect(result[2].dateStr).toBe('2023-09-30'); + }); + + it('should return an empty array if earnings history is null', () => { + const result = fillGapsInEarningsHistory(null, 5); + expect(result).toEqual([]); + }); + + it('should return an empty array if earnings history is empty', () => { + const result = fillGapsInEarningsHistory([], 5); + expect(result).toEqual([]); + }); + }); + + describe('formatRewardsWei', () => { + it('should format rewards value with special characters', () => { + const result = formatRewardsWei('1', MOCK_STAKED_ETH_ASSET); + expect(result).toBe('< 0.00001'); + }); + + it('should format rewards value with special characters when asset.isETH is false', () => { + const result = formatRewardsWei('1', MOCK_USDC_ASSET); + expect(result).toBe('< 0.00001'); + }); + + it('should format rewards value without special characters', () => { + const result = formatRewardsWei('1', MOCK_STAKED_ETH_ASSET, true); + expect(result).toBe('0.000000000000000001'); + }); + + it('should format rewards value without special characters when asset.isETH is false', () => { + const result = formatRewardsWei('1', MOCK_USDC_ASSET, true); + expect(result).toBe('0.000001'); + }); + }); + + describe('formatRewardsNumber', () => { + it('should format short rewards number correctly', () => { + const result = formatRewardsNumber(1.456, MOCK_STAKED_ETH_ASSET); + expect(result).toBe('1.456'); + }); + + it('should format long rewards number with 5 decimals', () => { + const result = formatRewardsNumber( + 1.456234265436536, + MOCK_STAKED_ETH_ASSET, + ); + expect(result).toBe('1.45623'); + }); + }); + + describe('formatRewardsFiat', () => { + it('should format rewards to fiat currency', () => { + const result = formatRewardsFiat( + '1000000000000000000', + MOCK_STAKED_ETH_ASSET, + 'usd', + 2000, + 1, + ); + expect(result).toBe('$2000'); + }); + + it('should format rewards to fiat currency when asset.isETH is false', () => { + const result = formatRewardsFiat( + '1000000', + MOCK_USDC_ASSET, + 'usd', + 2000, + 1, + ); + expect(result).toBe('$2000'); + }); + }); +}); diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.utils.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.utils.ts new file mode 100644 index 00000000000..0c305cca66b --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.utils.ts @@ -0,0 +1,177 @@ +import { + balanceToFiatNumber, + renderFromTokenMinimalUnit, + renderFiat, + fromTokenMinimalUnit, + weiToFiatNumber, + renderFromWei, + fromWei, +} from '../../../../../../util/number'; +import { BN } from 'ethereumjs-util'; +import { TimePeriodGroupInfo } from './StakingEarningsHistory.types'; +import { DateRange } from './StakingEarningsTimePeriod/StakingEarningsTimePeriod.types'; +import BigNumber from 'bignumber.js'; +import { EarningHistory } from '../../../hooks/useStakingEarningsHistory'; +import { TokenI } from '../../../../Tokens/types'; + +/** + * Formats the date string into a time period group info object + * + * @param {string} dateStr - The date string YYYY-MM-DD to format. + * @param {DateRange} selectedTimePeriod - The selected time period. + * @returns {TimePeriodGroupInfo} The formatted time period group info object. + */ +export const getEntryTimePeriodGroupInfo = ( + dateStr: string, + selectedTimePeriod: DateRange, +): TimePeriodGroupInfo => { + const [newYear, newMonth] = dateStr.split('-'); + const date = new Date(dateStr); + date.setUTCHours(0, 0, 0, 0); + const timePeriodInfo: TimePeriodGroupInfo = { + dateStr, + chartGroup: '', + chartGroupLabel: '', + listGroup: '', + listGroupLabel: '', + listGroupHeader: '', + }; + const dayLabel = date.toLocaleString('fullwide', { + month: 'long', + day: 'numeric', + timeZone: 'UTC', + }); + const monthLabel = date.toLocaleString('fullwide', { + month: 'long', + timeZone: 'UTC', + }); + const yearLabel = date.toLocaleString('fullwide', { + year: 'numeric', + timeZone: 'UTC', + }); + switch (selectedTimePeriod) { + case DateRange.DAILY: + timePeriodInfo.chartGroup = dateStr; + timePeriodInfo.chartGroupLabel = dayLabel; + timePeriodInfo.listGroup = dateStr; + timePeriodInfo.listGroupLabel = dayLabel; + break; + case DateRange.MONTHLY: + timePeriodInfo.chartGroup = `${newYear}-${newMonth}`; + timePeriodInfo.chartGroupLabel = monthLabel; + timePeriodInfo.listGroup = `${newYear}-${newMonth}`; + timePeriodInfo.listGroupLabel = monthLabel; + timePeriodInfo.listGroupHeader = newYear; + break; + case DateRange.YEARLY: + timePeriodInfo.chartGroup = newYear; + timePeriodInfo.chartGroupLabel = yearLabel; + timePeriodInfo.listGroup = newYear; + timePeriodInfo.listGroupLabel = yearLabel; + break; + default: + throw new Error('Unsupported time period'); + } + return timePeriodInfo; +}; + +/** + * Fills gaps in earnings history by adding zero values for days missing out of the limitDays + * + * @param {EarningHistory[] | null} earningsHistory - The earnings history to fill gaps in. + * @param {number} limitDays - The number of days to fill gaps for. + * @returns {EarningHistory[]} The filled earnings history. + */ +export const fillGapsInEarningsHistory = ( + earningsHistory: EarningHistory[] | null, + limitDays: number, +): EarningHistory[] => { + if (!earningsHistory?.length) return []; + const gapFilledEarningsHistory = [...earningsHistory]; + const earliestDate = new Date(earningsHistory[0].dateStr); + const daysToFill = limitDays - earningsHistory.length; + const gapDate = new Date(earliestDate); + gapDate.setUTCHours(0, 0, 0, 0); + for (let i = 0; i < daysToFill; i++) { + gapDate.setDate(gapDate.getDate() - 1); + gapFilledEarningsHistory.unshift({ + dateStr: gapDate.toISOString().split('T')[0], + dailyRewards: '0', + sumRewards: '0', + }); + } + return gapFilledEarningsHistory; +}; + +/** + * Formats the rewards value from minimal unit to string representation + * + * @param {number | string | BN} rewardsValue - The rewards value in minimal units. + * @param {TokenI} asset - The asset to format the rewards value for. + * @param {boolean} [isRemoveSpecialCharacters=false] - A flag indicating whether to remove special characters from the formatted output. + * @returns {string} The formatted rewards value as a string. + */ +export const formatRewardsWei = ( + rewardsValue: number | string | BN, + asset: TokenI, + isRemoveSpecialCharacters: boolean = false, +): string => { + if (!isRemoveSpecialCharacters) { + // return a string with possible special characters in display formatting + return asset.isETH + ? renderFromWei(rewardsValue) + : renderFromTokenMinimalUnit(rewardsValue, asset.decimals); + } + // return a string without special characters + return asset.isETH + ? fromWei(rewardsValue) + : fromTokenMinimalUnit(rewardsValue, asset.decimals); +}; + +/** + * Formats floating point number rewards value into a string representation + * + * @param {number} rewardsValue - The raw rewards value to format. + * @param {TokenI} asset - The asset to format the rewards value for. + * @returns {string} The formatted rewards value string. + */ +export const formatRewardsNumber = ( + rewardsValue: number, + asset: TokenI, +): string => { + const weiValue = new BN( + new BigNumber(rewardsValue) + .multipliedBy(new BigNumber(10).pow(asset.decimals)) + .toString(), + ); + return formatRewardsWei(weiValue, asset); +}; + +/** + * Formats the rewards amount into a fiat currency representation. + * + * @param {string | BN} rewardsValue - The amount of rewards to format in minimal units, which can be a string or a BigNumber (BN). + * @param {TokenI} asset - The asset to format the rewards value for. + * @param {string} currency - The currency symbol to convert to. + * @param {number} conversionRate - ETH to current currency conversion rate + * @param {number} exchangeRate - Asset to ETH conversion rate. + * @returns {string} The formatted fiat currency string. + */ +export const formatRewardsFiat = ( + rewardsValue: string | BN, + asset: TokenI, + currency: string, + conversionRate: number, + exchangeRate: number = 0, +): string => { + if (asset.isETH) { + const weiFiatNumber = weiToFiatNumber(new BN(rewardsValue), conversionRate); + return renderFiat(weiFiatNumber, currency, 2); + } + const balanceFiatNumber = balanceToFiatNumber( + renderFromTokenMinimalUnit(rewardsValue, asset.decimals), + conversionRate, + exchangeRate, + ); + return renderFiat(balanceFiatNumber, currency, 2); +}; diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.test.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.test.tsx index f216228aa7c..36cb7e84c9f 100644 --- a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.test.tsx +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.test.tsx @@ -188,9 +188,9 @@ describe('StakingEarningsHistoryChart', () => { }, ); // expect bar 3 to be unselected and not highlighted - expect(chart.data[0].svg.fill).toBe('url(#gradient)'); - expect(chart.data[1].svg.fill).toBe('url(#gradient)'); - expect(chart.data[2].svg.fill).toBe('url(#gradient)'); + expect(chart.data[0].svg.fill).toBe('url(#bar-gradient)'); + expect(chart.data[1].svg.fill).toBe('url(#bar-gradient)'); + expect(chart.data[2].svg.fill).toBe('url(#bar-gradient)'); // expect chart to be in initial state expect(chartContainer.getByText('Lifetime earnings')).toBeTruthy(); expect(chartContainer.getByText('6.00000 ETH')).toBeTruthy(); diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.tsx index 7c8cc7c7112..1bc98f34392 100644 --- a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.tsx +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.tsx @@ -9,33 +9,10 @@ import Text, { TextVariant, } from '../../../../../../../component-library/components/Texts/Text'; import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; - -export interface StakingEarningsHistoryChartData { - value: number; - label: string; -} - -interface StakingEarningsHistoryChartProps { - earnings: StakingEarningsHistoryChartData[]; - ticker: string; - earningsTotal: string; - // callback to handle selected earning - onSelectedEarning?: (earning?: { value: number; label: string }) => void; - // format the graph value from parent - formatValue?: (value: number) => string; -} - -interface HorizontalLinesProps { - // sends bandwidth to parent - onBandWidthChange?: (bandWidth: number) => void; - strokeColor: string; - // BarChart component props are passed into all children - x?: (number: number) => number; - y?: (number: number) => number; - height?: number; - bandwidth?: number; - data?: StakingEarningsHistoryChartProps['earnings']; -} +import { + HorizontalLinesProps, + StakingEarningsHistoryChartProps, +} from './StakingEarningsHistoryChart.types'; const HorizontalLines = ({ x, @@ -49,23 +26,25 @@ const HorizontalLines = ({ useEffect(() => { onBandWidthChange && onBandWidthChange(bandWidth ?? 0); }, [bandWidth, onBandWidthChange]); - if (!x || !y || !height || !data || !bandWidth) return null; - return ( - <> - {data.map((item, index) => ( - - ))} - - ); + + const renderBarTopLines = useCallback(() => { + if (!x || !y || !height || !data || !bandWidth) return null; + + return data.map((item, index) => ( + + )); + }, [data, x, y, height, bandWidth, strokeColor]); + + return <>{renderBarTopLines()}; }; export function StakingEarningsHistoryChart({ @@ -81,7 +60,17 @@ export function StakingEarningsHistoryChart({ // constants const animate = false; - const stopColorGreen = 'rgb(228, 240, 231)'; + const barGradientId = 'bar-gradient'; + const barGradientStop1 = { + offset: '0%', + stopColor: colors.success.muted, + stopOpacity: 0, + }; + const barGradientStop2 = { + offset: '100%', + stopColor: colors.success.muted, + stopOpacity: 0.1, + }; const spacingDefault = 0; //states @@ -156,7 +145,7 @@ export function StakingEarningsHistoryChart({ if (index === selectedBarIndex) { data.svg.fill = colors.success.default; } else { - data.svg.fill = 'url(#gradient)'; + data.svg.fill = `url(#${barGradientId})`; } }); return newTransformedData; @@ -174,7 +163,7 @@ export function StakingEarningsHistoryChart({ value: value.value, label: value.label, svg: { - fill: 'url(#gradient)', + fill: `url(#${barGradientId})`, testID: `earning-history-chart-bar-${index}`, }, })); @@ -263,17 +252,15 @@ export function StakingEarningsHistoryChart({ svg={{ stroke: 'transparent' }} // remove grid lines /> - - - + + + void; + // format the graph value from parent + formatValue?: (value: number) => string; +} + +export interface HorizontalLinesProps { + // sends bandwidth to parent + onBandWidthChange?: (bandWidth: number) => void; + strokeColor: string; + // BarChart component props are passed into all children + x?: (number: number) => number; + y?: (number: number) => number; + height?: number; + bandwidth?: number; + data?: StakingEarningsHistoryChartProps['earnings']; +} diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/__snapshots__/StakingEarningsHistoryChart.test.tsx.snap b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/__snapshots__/StakingEarningsHistoryChart.test.tsx.snap index cac6b4b7cbe..0f1c7f2cf19 100644 --- a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/__snapshots__/StakingEarningsHistoryChart.test.tsx.snap +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/__snapshots__/StakingEarningsHistoryChart.test.tsx.snap @@ -117,7 +117,7 @@ exports[`StakingEarningsHistoryChart renders to match snapshot 1`] = ` d="M0,133.66666666666669L155.17241379310346,133.66666666666669L155.17241379310346,200L0,200Z" fill={ { - "brushRef": "gradient", + "brushRef": "bar-gradient", "type": 1, } } @@ -132,7 +132,7 @@ exports[`StakingEarningsHistoryChart renders to match snapshot 1`] = ` d="M172.41379310344828,1L327.58620689655174,1L327.58620689655174,200L172.41379310344828,200Z" fill={ { - "brushRef": "gradient", + "brushRef": "bar-gradient", "type": 1, } } @@ -147,7 +147,7 @@ exports[`StakingEarningsHistoryChart renders to match snapshot 1`] = ` d="M344.82758620689657,67.33333333333334L500,67.33333333333334L500,200L344.82758620689657,200Z" fill={ { - "brushRef": "gradient", + "brushRef": "bar-gradient", "type": 1, } } @@ -163,14 +163,14 @@ exports[`StakingEarningsHistoryChart renders to match snapshot 1`] = ` gradient={ [ 0, - -1, + 1868340, 1, - -1773337, + 438075956, ] } gradientTransform={null} gradientUnits={0} - name="gradient" + name="bar-gradient" x1="0%" x2="0%" y1="100%" diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.tsx index facf054cd6a..14617fccf6d 100644 --- a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.tsx +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { View } from 'react-native'; import Label from '../../../../../../../component-library/components/Form/Label'; import Text from '../../../../../../../component-library/components/Texts/Text'; @@ -7,20 +7,7 @@ import { useTheme } from '../../../../../../../util/theme'; import styleSheet from './StakingEarningsHistoryList.styles'; import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; import { strings } from '../../../../../../../../locales/i18n'; - -interface StakingEarningsHistoryListProps { - earnings: StakingEarningsHistoryListData[]; - filterByGroupLabel?: string; -} - -export interface StakingEarningsHistoryListData { - label: string; - amount: string; - amountSecondaryText: string; - groupLabel: string; - groupHeader: string; - ticker: string; -} +import { StakingEarningsHistoryListProps } from './StakingEarningsHistoryList.types'; const StakingEarningsHistoryList = ({ earnings, @@ -28,7 +15,79 @@ const StakingEarningsHistoryList = ({ }: StakingEarningsHistoryListProps) => { const { colors } = useTheme(); const styles = styleSheet(); - let lastGroupHeader: string | null = null; + + const renderEarningsList = useCallback(() => { + let lastGroupHeader: string | null = null; + return earnings.map((earning, index) => { + const isFirstEarningInGroup = earning.groupHeader !== lastGroupHeader; + lastGroupHeader = earning.groupHeader; + const isGroupHeaderVisible = + earning.groupHeader.length > 0 && isFirstEarningInGroup; + if (!filterByGroupLabel || earning.groupLabel === filterByGroupLabel) { + return ( + + {isGroupHeaderVisible && ( + + + + )} + + + + + + + + + + + {earning.amountSecondaryText} + + + + + + ); + } + return null; + }); + }, [earnings, filterByGroupLabel, styles, colors]); + + const renderLoadingSkeleton = useCallback( + () => ( + + {Array.from({ length: 7 }).map((_, index) => ( + + ))} + + ), + [], + ); + return ( {earnings ? ( @@ -36,74 +95,10 @@ const StakingEarningsHistoryList = ({ - {earnings.map((earning, index) => { - const isFirstEarningInGroup = - earning.groupHeader !== lastGroupHeader; - lastGroupHeader = earning.groupHeader; - const isGroupHeaderVisible = - earning.groupHeader.length > 0 && isFirstEarningInGroup; - if ( - !filterByGroupLabel || - earning.groupLabel === filterByGroupLabel - ) { - return ( - - {isGroupHeaderVisible && ( - - - - )} - - - - - - - - - - - {earning.amountSecondaryText} - - - - - - ); - } - return null; - })} + {renderEarningsList()} ) : ( - - {Array.from({ length: 7 }).map((_, index) => ( - - ))} - + renderLoadingSkeleton() )} ); diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.types.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.types.ts new file mode 100644 index 00000000000..acea05cbbe7 --- /dev/null +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.types.ts @@ -0,0 +1,13 @@ +export interface StakingEarningsHistoryListProps { + earnings: StakingEarningsHistoryListData[]; + filterByGroupLabel?: string; +} + +export interface StakingEarningsHistoryListData { + label: string; + amount: string; + amountSecondaryText: string; + groupLabel: string; + groupHeader: string; + ticker: string; +} diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.styles.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.styles.ts index 381a82bff6f..0c46050b59c 100644 --- a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.styles.ts +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.styles.ts @@ -13,18 +13,29 @@ const styleSheet = (params: { theme: Theme }) => { paddingBottom: 32, }, unselectedButtonLabel: { - color: colors.text.muted, + color: colors.text.alternative, }, selectedButtonLabel: { - color: colors.info.inverse, + color: colors.text.alternative, }, - button: { - borderRadius: 8, + buttonContainer: { marginLeft: 8, marginRight: 8, }, - unselectedButton: { - borderWidth: 0, + button: { + backgroundColor: colors.background.default, + width: '100%', + borderRadius: 32, + paddingHorizontal: 14, + paddingVertical: 7, + }, + selectedButton: { + backgroundColor: colors.background.muted, + }, + buttonLabel: { + letterSpacing: 3, + textAlign: 'center', + color: colors.text.default, }, }); }; diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.test.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.test.tsx index 4611044de32..4c490eec6e3 100644 --- a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.test.tsx +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; -import TimePeriodButtonGroup, { DateRange } from './StakingEarningsTimePeriod'; - +import TimePeriodButtonGroup from './StakingEarningsTimePeriod'; +import { DateRange } from './StakingEarningsTimePeriod.types'; describe('TimePeriodButtonGroup', () => { const mockOnTimePeriodChange = jest.fn(); diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.tsx index 91184ca1ba4..972e11870b2 100644 --- a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.tsx +++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.tsx @@ -1,25 +1,15 @@ import React, { useState } from 'react'; -import { View } from 'react-native'; -import Button, { - ButtonVariants, -} from '../../../../../../../component-library/components/Buttons/Button'; +import { TouchableOpacity, View } from 'react-native'; +import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; import Text, { TextVariant, } from '../../../../../../../component-library/components/Texts/Text'; import { useStyles } from '../../../../../../../component-library/hooks'; import styleSheet from './StakingEarningsTimePeriod.styles'; -import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; - -export enum DateRange { - DAILY = '7D', - MONTHLY = 'M', - YEARLY = 'Y', -} - -interface TimePeriodButtonGroupProps { - onTimePeriodChange?: (timePeriod: DateRange) => void; - initialTimePeriod: DateRange; -} +import { + DateRange, + TimePeriodButtonGroupProps, +} from './StakingEarningsTimePeriod.types'; const TimePeriodButtonGroup: React.FC = ({ onTimePeriodChange = () => undefined, @@ -32,7 +22,7 @@ const TimePeriodButtonGroup: React.FC = ({ const { styles } = useStyles(styleSheet, {}); - const renderButton = (dateRange: DateRange) => { + const renderButton = (dateRange: DateRange, width: number) => { const handlePress = () => { setSelectedButton(dateRange); onTimePeriodChange(dateRange); @@ -49,27 +39,29 @@ const TimePeriodButtonGroup: React.FC = ({ const labelStyle = !isSelected && !isPressed ? styles.unselectedButtonLabel - : styles.selectedButtonLabel; // Change text color based on selection or press + : styles.selectedButtonLabel; const labelElement = ( {dateRange} ); - const buttonSelectedStyle = !isSelected ? styles.unselectedButton : {}; - const variant = isSelected - ? ButtonVariants.Primary - : ButtonVariants.Secondary; + const buttonSelectedStyle = !isSelected ? {} : styles.selectedButton; const buttonStyle = { ...styles.button, ...buttonSelectedStyle }; + return ( -