diff --git a/packages/staking/package.json b/packages/staking/package.json index 4d44c00627..bc2f8734e9 100644 --- a/packages/staking/package.json +++ b/packages/staking/package.json @@ -59,11 +59,14 @@ "i18next": "^22.5.1", "immer": "^10.0.2", "lodash": "4.17.21", + "rambda": "^8.5.0", "react-copy-to-clipboard": "^5.1.0", "react-i18next": "^12.3.1", + "recharts": "^2.9.2", "zustand": "^4.4.1" }, "devDependencies": { + "@cardano-sdk/core": "0.21.0", "@cardano-sdk/input-selection": "0.12.4", "@cardano-sdk/tx-construction": "0.14.2", "@cardano-sdk/util": "0.14.2", diff --git a/packages/staking/src/features/activity/Activity.tsx b/packages/staking/src/features/activity/Activity.tsx index f6fb246cc4..80801527d8 100644 --- a/packages/staking/src/features/activity/Activity.tsx +++ b/packages/staking/src/features/activity/Activity.tsx @@ -1,6 +1,7 @@ import { StateStatus, useOutsideHandles } from 'features/outside-handles-provider'; import { getGroupedRewardsActivities } from './helpers/getGroupedRewardsHistory'; import { NoStakingActivity } from './NoStakingActivity'; +import { PastEpochsRewards } from './PastEpochsRewards'; import { RewardsHistory } from './RewardsHistory'; export const Activity = () => { @@ -13,10 +14,13 @@ export const Activity = () => { {walletActivitiesStatus === StateStatus.LOADED && groupedRewardsActivities.length === 0 ? ( ) : ( - + <> + + + )} ); diff --git a/packages/staking/src/features/activity/EpochsSwitch.module.scss b/packages/staking/src/features/activity/EpochsSwitch.module.scss new file mode 100644 index 0000000000..de49cf4684 --- /dev/null +++ b/packages/staking/src/features/activity/EpochsSwitch.module.scss @@ -0,0 +1,4 @@ +.buttonsBackground { + background-color: var(--light-mode-light-grey, var(--dark-mode-dark-grey, #2f2f2f)); + border-radius: 1rem; +} diff --git a/packages/staking/src/features/activity/EpochsSwitch.tsx b/packages/staking/src/features/activity/EpochsSwitch.tsx new file mode 100644 index 0000000000..d72cb2b819 --- /dev/null +++ b/packages/staking/src/features/activity/EpochsSwitch.tsx @@ -0,0 +1,33 @@ +import { ControlButton, Flex, Text } from '@lace/ui'; +import { useTranslation } from 'react-i18next'; +import styles from './EpochsSwitch.module.scss'; + +// eslint-disable-next-line no-magic-numbers +const EPOCHS_OPTIONS = [5, 15]; + +type EpochsSwitchProps = { + epochsCount: number; + setEpochsCount: (epochsCount: number) => void; +}; + +export const EpochsSwitch = ({ epochsCount, setEpochsCount }: EpochsSwitchProps) => { + const { t } = useTranslation(); + return ( + + {t('activity.rewardsChart.epochs')}: + + {EPOCHS_OPTIONS.map((option, i) => { + const activeOption = epochsCount === option; + const Component = activeOption ? ControlButton.Filled : ControlButton.Outlined; + return ( + setEpochsCount(option)} + /> + ); + })} + + + ); +}; diff --git a/packages/staking/src/features/activity/PastEpochsRewards/PastEpochsRewards.tsx b/packages/staking/src/features/activity/PastEpochsRewards/PastEpochsRewards.tsx new file mode 100644 index 0000000000..2fa6547dc0 --- /dev/null +++ b/packages/staking/src/features/activity/PastEpochsRewards/PastEpochsRewards.tsx @@ -0,0 +1,23 @@ +import { Flex, Text } from '@lace/ui'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { EpochsSwitch } from '../EpochsSwitch'; +import { useRewardsByEpoch } from './hooks/useRewardsByEpoch'; +import { RewardsChart } from './RewardsChart'; + +const DEFAULT_LAST_EPOCHS = 5; + +export const PastEpochsRewards = () => { + const [epochsCount, setEpochsCount] = useState(DEFAULT_LAST_EPOCHS); + const { t } = useTranslation(); + const { rewardsByEpoch } = useRewardsByEpoch({ epochsCount }); + return ( + <> + + {t('activity.rewardsChart.title')} + + + {rewardsByEpoch && } + + ); +}; diff --git a/packages/staking/src/features/activity/PastEpochsRewards/PoolIndicator.tsx b/packages/staking/src/features/activity/PastEpochsRewards/PoolIndicator.tsx new file mode 100644 index 0000000000..00dee66204 --- /dev/null +++ b/packages/staking/src/features/activity/PastEpochsRewards/PoolIndicator.tsx @@ -0,0 +1,7 @@ +import { PIE_CHART_DEFAULT_COLOR_SET } from '@lace/ui'; + +export const PoolIndicator = ({ color = PIE_CHART_DEFAULT_COLOR_SET[0] }: { color?: string }) => ( + + + +); diff --git a/packages/staking/src/features/activity/PastEpochsRewards/RewardsChart.tsx b/packages/staking/src/features/activity/PastEpochsRewards/RewardsChart.tsx new file mode 100644 index 0000000000..341791fd89 --- /dev/null +++ b/packages/staking/src/features/activity/PastEpochsRewards/RewardsChart.tsx @@ -0,0 +1,41 @@ +import { Card } from '@lace/ui'; +import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import type { RewardsByEpoch } from './hooks/useRewardsByEpoch'; +import { useRewardsChartPoolsColorMapper } from './hooks/useRewardsChartPoolsColorMapper'; +import { RewardsChartTooltip } from './RewardsChartTooltip'; + +export const RewardsChart = ({ chartData }: { chartData: RewardsByEpoch }) => { + const poolColorMapper = useRewardsChartPoolsColorMapper(chartData); + // eslint-disable-next-line unicorn/no-array-reduce + const maxPoolsPerEpochCount = chartData.reduce((acc, epochRewards) => Math.max(acc, epochRewards.rewards.length), 0); + + return ( + + + + + `${value} ADA`} /> + } /> + {Array.from({ length: maxPoolsPerEpochCount }).map((_, i) => ( + + {chartData.map((entry, j) => { + const fill = poolColorMapper(entry.rewards[i]?.poolId); + return ; + })} + + ))} + + + + ); +}; diff --git a/packages/staking/src/features/activity/PastEpochsRewards/RewardsChartTooltip.tsx b/packages/staking/src/features/activity/PastEpochsRewards/RewardsChartTooltip.tsx new file mode 100644 index 0000000000..edb87a2c9f --- /dev/null +++ b/packages/staking/src/features/activity/PastEpochsRewards/RewardsChartTooltip.tsx @@ -0,0 +1,45 @@ +import { Card, Flex, Text } from '@lace/ui'; +import { useTranslation } from 'react-i18next'; +import { TooltipProps } from 'recharts'; +import { NameType, ValueType } from 'recharts/types/component/DefaultTooltipContent'; +import { useRewardsChartPoolsColorMapper } from './hooks/useRewardsChartPoolsColorMapper'; +import { PoolIndicator } from './PoolIndicator'; + +export const RewardsChartTooltip = ({ + active, + payload, + label, + poolColorMapper, +}: TooltipProps & { poolColorMapper: ReturnType }) => { + const { t } = useTranslation(); + + if (active && payload && payload.length > 0) { + return ( + + + + {t('activity.rewardsChart.epoch')} {label} + + + {payload.map((p, i) => { + const poolId = p.payload?.rewards?.[i]?.poolId; + return ( + + + + {p.payload?.rewards?.[i]?.metadata.name} + + {t('activity.rewardsChart.rewards')}: {payload[i]?.value} ADA + + + + ); + })} + + + + ); + } + + return null; +}; diff --git a/packages/staking/src/features/activity/PastEpochsRewards/hooks/useRewardsByEpoch.ts b/packages/staking/src/features/activity/PastEpochsRewards/hooks/useRewardsByEpoch.ts new file mode 100644 index 0000000000..d44ce1c1fe --- /dev/null +++ b/packages/staking/src/features/activity/PastEpochsRewards/hooks/useRewardsByEpoch.ts @@ -0,0 +1,82 @@ +import { Cardano, Reward } from '@cardano-sdk/core'; +import { RewardsHistory } from '@cardano-sdk/wallet'; +import { Wallet } from '@lace/cardano'; +import { useObservable } from '@lace/common'; +import { useOutsideHandles } from 'features/outside-handles-provider'; +import { groupBy, sortBy, takeLast, uniqBy } from 'rambda'; +import { useEffect, useState } from 'react'; + +type RewardWithPoolMetadata = Omit & { + metadata: Cardano.StakePoolMetadata | undefined; + spendableEpoch: Cardano.EpochNo; + rewards: string; +}; + +export type RewardsByEpoch = { spendableEpoch: Cardano.EpochNo; rewards: RewardWithPoolMetadata[] }[]; + +export type UseRewardsByEpochProps = { + epochsCount: number; +}; + +const getPoolInfos = async (poolIds: Wallet.Cardano.PoolId[], stakePoolProvider: Wallet.StakePoolProvider) => { + const filters: Wallet.QueryStakePoolsArgs = { + filters: { + identifier: { + _condition: 'or', + values: poolIds.map((poolId) => ({ id: poolId })), + }, + }, + pagination: { + limit: 100, + startAt: 0, + }, + }; + const { pageResults: pools } = await stakePoolProvider.queryStakePools(filters); + + return pools; +}; + +type GetRewardsByEpochProps = { + rewardsHistory: RewardsHistory; + stakePoolProvider: Wallet.StakePoolProvider; + epochsCount: number; +}; + +const buildRewardsByEpoch = async ({ rewardsHistory, stakePoolProvider, epochsCount }: GetRewardsByEpochProps) => { + const REWARD_SPENDABLE_DELAY_EPOCHS = 2; + const uniqPoolIds = uniqBy((rewards) => rewards.poolId, rewardsHistory.all) + .map((reward) => reward.poolId) + .filter(Boolean) as Wallet.Cardano.PoolId[]; + const stakePoolsData = await getPoolInfos(uniqPoolIds, stakePoolProvider); + const rewardsHistoryWithMetadata = rewardsHistory.all.map((reward) => ({ + ...reward, + metadata: stakePoolsData.find((poolInfo) => poolInfo.id === reward.poolId)?.metadata, + rewards: Wallet.util.lovelacesToAdaString(reward.rewards.toString()), + spendableEpoch: (reward.epoch + REWARD_SPENDABLE_DELAY_EPOCHS) as Cardano.EpochNo, + })); + const groupedRewards = groupBy(({ epoch }) => epoch.toString(), rewardsHistoryWithMetadata); + const groupedRewardsArray = Object.entries(groupedRewards).map(([epoch, rewards]) => ({ + rewards, + spendableEpoch: (Number.parseInt(epoch) + REWARD_SPENDABLE_DELAY_EPOCHS) as Cardano.EpochNo, + })); + const sortedByEpoch = sortBy((entry) => entry.spendableEpoch, groupedRewardsArray); + return takeLast(epochsCount, sortedByEpoch); +}; + +export const useRewardsByEpoch = ({ epochsCount }: UseRewardsByEpochProps) => { + const [rewardsByEpoch, setRewardsByEpoch] = useState(); + const { walletStoreInMemoryWallet: inMemoryWallet, walletStoreBlockchainProvider } = useOutsideHandles(); + const rewardsHistory = useObservable(inMemoryWallet.delegation.rewardsHistory$); + useEffect(() => { + if (!rewardsHistory) return; + (async () => { + const result = await buildRewardsByEpoch({ + epochsCount, + rewardsHistory, + stakePoolProvider: walletStoreBlockchainProvider.stakePoolProvider, + }); + setRewardsByEpoch(result); + })(); + }, [epochsCount, rewardsHistory, walletStoreBlockchainProvider]); + return { rewardsByEpoch }; +}; diff --git a/packages/staking/src/features/activity/PastEpochsRewards/hooks/useRewardsChartPoolsColorMapper.tsx b/packages/staking/src/features/activity/PastEpochsRewards/hooks/useRewardsChartPoolsColorMapper.tsx new file mode 100644 index 0000000000..29ffe3d476 --- /dev/null +++ b/packages/staking/src/features/activity/PastEpochsRewards/hooks/useRewardsChartPoolsColorMapper.tsx @@ -0,0 +1,47 @@ +/* eslint-disable unicorn/no-array-reduce */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { PIE_CHART_DEFAULT_COLOR_SET } from '@lace/ui'; +import { useDelegationPortfolioStore } from 'features/store'; +import difference from 'lodash/difference'; +import { useMemo } from 'react'; +import type { RewardsByEpoch } from './useRewardsByEpoch'; +import type { Cardano } from '@cardano-sdk/core'; + +const GRAYSCALE_PALETTE = [ + '#343434', + '#4a4a4a', + '#616161', + '#787878', + '#8e8e8e', + '#a5a5a5', + '#bbbbbb', + '#d2d2d2', + '#e8e8e8', + '#fafafa', +]; + +export const useRewardsChartPoolsColorMapper = (rewardsByEpoch: RewardsByEpoch) => { + const { currentPortfolio } = useDelegationPortfolioStore(); + + const coloring = useMemo(() => { + const poolsInPortfolio = currentPortfolio.map(({ stakePool }) => stakePool.id); + const historicalPools = rewardsByEpoch + .flatMap((rewards) => rewards.rewards.map((reward) => reward.poolId)) + .filter(Boolean) as Cardano.PoolId[]; + const poolsNotInPortfolio = difference(historicalPools, poolsInPortfolio); + + const poolsInPortfolioColoring = poolsInPortfolio.reduce((acc, poolId, index) => { + acc[poolId] = PIE_CHART_DEFAULT_COLOR_SET[index % PIE_CHART_DEFAULT_COLOR_SET.length]!; + return acc; + }, {} as Record); + + const poolsNotInPortfolioColoring = poolsNotInPortfolio.reduce((acc, poolId, index) => { + acc[poolId] = GRAYSCALE_PALETTE[index % GRAYSCALE_PALETTE.length]!; + return acc; + }, {} as Record); + + return { ...poolsInPortfolioColoring, ...poolsNotInPortfolioColoring }; + }, [currentPortfolio, rewardsByEpoch]); + + return (poolId?: Cardano.PoolId) => (poolId ? coloring[poolId] : GRAYSCALE_PALETTE[0]); +}; diff --git a/packages/staking/src/features/activity/PastEpochsRewards/index.ts b/packages/staking/src/features/activity/PastEpochsRewards/index.ts new file mode 100644 index 0000000000..3cdf22ed5a --- /dev/null +++ b/packages/staking/src/features/activity/PastEpochsRewards/index.ts @@ -0,0 +1 @@ +export { PastEpochsRewards } from './PastEpochsRewards'; diff --git a/packages/staking/src/features/activity/RewardsHistory.tsx b/packages/staking/src/features/activity/RewardsHistory.tsx index 4f2a2cc45f..d17c010722 100644 --- a/packages/staking/src/features/activity/RewardsHistory.tsx +++ b/packages/staking/src/features/activity/RewardsHistory.tsx @@ -15,7 +15,7 @@ export const RewardsHistory = ({ groupedRewardsActivities, walletActivitiesStatu return ( <> - + {t('activity.rewardsHistory.title')} diff --git a/packages/staking/src/features/i18n/translations/en.ts b/packages/staking/src/features/i18n/translations/en.ts index 7a61df7d70..51328d6be1 100644 --- a/packages/staking/src/features/i18n/translations/en.ts +++ b/packages/staking/src/features/i18n/translations/en.ts @@ -1,6 +1,12 @@ import { Translations } from '../types'; export const en: Translations = { + 'activity.rewardsChart.all': 'All', + 'activity.rewardsChart.epoch': 'Epoch', + 'activity.rewardsChart.epochs': 'Epochs', + 'activity.rewardsChart.last': 'Last', + 'activity.rewardsChart.rewards': 'Rewards', + 'activity.rewardsChart.title': 'Rewards', 'activity.rewardsHistory.noStakingActivityYet': 'No staking activity yet.', 'activity.rewardsHistory.title': 'History', 'browsePools.stakePoolTableBrowser.addPool': 'Add pool', diff --git a/packages/staking/src/features/i18n/types.ts b/packages/staking/src/features/i18n/types.ts index bec77a1cb5..c191e5af8b 100644 --- a/packages/staking/src/features/i18n/types.ts +++ b/packages/staking/src/features/i18n/types.ts @@ -20,6 +20,14 @@ type KeysStructure = { title: ''; noStakingActivityYet: ''; }; + rewardsChart: { + title: ''; + epochs: ''; + epoch: ''; + last: ''; + all: ''; + rewards: ''; + }; }; browsePools: { stakePoolTableBrowser: { diff --git a/packages/staking/src/features/store/delegationPortfolioStore/stateMachine/types.ts b/packages/staking/src/features/store/delegationPortfolioStore/stateMachine/types.ts index 41b5639f85..84a6e3ef8a 100644 --- a/packages/staking/src/features/store/delegationPortfolioStore/stateMachine/types.ts +++ b/packages/staking/src/features/store/delegationPortfolioStore/stateMachine/types.ts @@ -89,6 +89,9 @@ export type StateOverview = MakeState<{ export type StateActivity = MakeState<{ activeDelegationFlow: DelegationFlow.Activity; activeDrawerStep: undefined; + draftPortfolio: undefined; + pendingSelectedPortfolio: undefined; + viewedStakePool: undefined; }>; export type StateCurrentPoolDetails = MakeState<{ diff --git a/yarn.lock b/yarn.lock index 89c685363e..e64d139766 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8026,6 +8026,7 @@ __metadata: resolution: "@lace/staking@workspace:packages/staking" dependencies: "@ant-design/icons": ^4.7.0 + "@cardano-sdk/core": 0.21.0 "@cardano-sdk/input-selection": 0.12.4 "@cardano-sdk/tx-construction": 0.14.2 "@cardano-sdk/util": 0.14.2 @@ -8053,11 +8054,13 @@ __metadata: immer: ^10.0.2 lodash: 4.17.21 normalize.css: ^8.0.1 + rambda: ^8.5.0 react: 17.0.2 react-copy-to-clipboard: ^5.1.0 react-dom: 17.0.2 react-i18next: ^12.3.1 react-infinite-scroll-component: ^6.1.0 + recharts: ^2.9.2 tsup: ^6.7.0 typescript: ^4.9.5 vite-plugin-checker: ^0.6.0 @@ -38575,6 +38578,13 @@ __metadata: languageName: node linkType: hard +"rambda@npm:^8.5.0": + version: 8.5.0 + resolution: "rambda@npm:8.5.0" + checksum: 4f188195f9859e5b0955f5eddfa454855d3d390c8dfdf22f19b6e493ad978ba1123b09201ef0ebddc2ab608ef22b68c541582a1810cbf3f789870dd10bba5598 + languageName: node + linkType: hard + "ramda@npm:^0.21.0": version: 0.21.0 resolution: "ramda@npm:0.21.0" @@ -40065,6 +40075,20 @@ __metadata: languageName: node linkType: hard +"react-smooth@npm:^2.0.4": + version: 2.0.5 + resolution: "react-smooth@npm:2.0.5" + dependencies: + fast-equals: ^5.0.0 + react-transition-group: 2.9.0 + peerDependencies: + prop-types: ^15.6.0 + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + checksum: 914c17f741e8b533ff6e3d5e3285aea0625cdd0f98e04202d01351f9516dbdc0a0e297dc22cc2377d6916fb819da8d4ed999c0314a4c186592ca51870012e6f7 + languageName: node + linkType: hard + "react-style-singleton@npm:^2.2.1": version: 2.2.1 resolution: "react-style-singleton@npm:2.2.1" @@ -40447,6 +40471,27 @@ __metadata: languageName: node linkType: hard +"recharts@npm:^2.9.2": + version: 2.9.2 + resolution: "recharts@npm:2.9.2" + dependencies: + classnames: ^2.2.5 + eventemitter3: ^4.0.1 + lodash: ^4.17.19 + react-is: ^16.10.2 + react-resize-detector: ^8.0.4 + react-smooth: ^2.0.4 + recharts-scale: ^0.4.4 + tiny-invariant: ^1.3.1 + victory-vendor: ^36.6.8 + peerDependencies: + prop-types: ^15.6.0 + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 + checksum: 77a87e3d91229ac5400240409568e3345ded50fc117e70e43e61a135b44bbc3164048704393aa988201ea2989278e1c4e96ce350f6a2f87044d1f0a48f290e84 + languageName: node + linkType: hard + "rechoir@npm:^0.6.2": version: 0.6.2 resolution: "rechoir@npm:0.6.2" @@ -44537,7 +44582,7 @@ __metadata: languageName: node linkType: hard -"tiny-invariant@npm:^1.1.0": +"tiny-invariant@npm:^1.1.0, tiny-invariant@npm:^1.3.1": version: 1.3.1 resolution: "tiny-invariant@npm:1.3.1" checksum: 872dbd1ff20a21303a2fd20ce3a15602cfa7fcf9b228bd694a52e2938224313b5385a1078cb667ed7375d1612194feaca81c4ecbe93121ca1baebe344de4f84c