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