Skip to content

Commit

Permalink
feat(portfolio): implement total staked calculation for projects (#6122)
Browse files Browse the repository at this point in the history
# Motivation

The `Portfolio` page features a card that displays the total assets
owned by the user. In this second iteration, Neurons are used to
calculate wealth. A final PR will provide both Tokens and Neurons from
the `Portfolio` route to the page.

The same design discrepancies mentioned in #6121 remain.

# Changes

- Exposes the `tableProjects` prop on the `Portfolio` page to receive
the list of all projects and the total amount staked.
- Adds logic to display the `NoNeuronsCard` when the neurons balance is
zero.

# Tests

- Unit test to verify the presence of the `NoNeuronsCard` based on the
neurons balance.
- Unit test to confirm that a primary action appears in the
`NoNeuronsCard` when the token balance exceeds zero.
- Unit test to ensure that the `UsdValueBanner` displays the correct
value based on the token balance.

# Todos

- [ ] Add entry to changelog (if necessary).
Not necessary

Prev. PR: #6121
  • Loading branch information
yhabib authored Jan 9, 2025
1 parent d7aa761 commit 2934fa8
Show file tree
Hide file tree
Showing 2 changed files with 130 additions and 8 deletions.
37 changes: 34 additions & 3 deletions frontend/src/lib/pages/Portfolio.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
import NoTokensCard from "$lib/components/portfolio/NoTokensCard.svelte";
import UsdValueBanner from "$lib/components/ui/UsdValueBanner.svelte";
import { authSignedInStore } from "$lib/derived/auth.derived";
import type { TableProject } from "$lib/types/staking";
import type { UserToken } from "$lib/types/tokens-page";
import { getTotalStakeInUsd } from "$lib/utils/staking.utils";
import { getTotalBalanceInUsd } from "$lib/utils/token.utils";
import { TokenAmountV2, isNullish } from "@dfinity/utils";
export let userTokensData: UserToken[] = [];
export let tableProjects: TableProject[] = [];
let totalTokensBalanceInUsd: number;
$: totalTokensBalanceInUsd = getTotalBalanceInUsd(userTokensData);
Expand All @@ -20,26 +23,54 @@
token.balance.toUlps() > 0n &&
(!("balanceInUsd" in token) || isNullish(token.balanceInUsd))
);
let totalStakedInUsd: number;
$: totalStakedInUsd = getTotalStakeInUsd(tableProjects);
let hasUnpricedStake: boolean;
$: hasUnpricedStake = tableProjects.some(
(project) =>
project.stake instanceof TokenAmountV2 &&
project.stake.toUlps() > 0n &&
(!("stakeInUsd" in project) || isNullish(project.stakeInUsd))
);
let hasUnpricedTokensOrStake: boolean;
$: hasUnpricedTokensOrStake = hasUnpricedTokens || hasUnpricedStake;
let totalUsdAmount: number | undefined;
$: totalUsdAmount = $authSignedInStore ? totalTokensBalanceInUsd : undefined;
$: totalUsdAmount = $authSignedInStore
? totalTokensBalanceInUsd + totalStakedInUsd
: undefined;
let showNoTokensCard: boolean;
$: showNoTokensCard = !$authSignedInStore || totalTokensBalanceInUsd === 0;
let showNoNeuronsCard: boolean;
$: showNoNeuronsCard = !$authSignedInStore || totalStakedInUsd === 0;
// The Card should display a Primary Action when it is the only available option.
// This occurs when there are tokens but no stake.
let hasNoNeuronsCardAPrimaryAction: boolean;
$: hasNoNeuronsCardAPrimaryAction = !showNoTokensCard;
</script>

<main data-tid="portfolio-page-component">
<div class="top" class:single-card={$authSignedInStore}>
{#if !$authSignedInStore}
<LoginCard />
{/if}
<UsdValueBanner usdAmount={totalUsdAmount} {hasUnpricedTokens} />
<UsdValueBanner
usdAmount={totalUsdAmount}
hasUnpricedTokens={hasUnpricedTokensOrStake}
/>
</div>
<div class="content">
{#if showNoTokensCard}
<NoTokensCard />
{/if}
<NoNeuronsCard />
{#if showNoNeuronsCard}
<NoNeuronsCard primaryCard={hasNoNeuronsCardAPrimaryAction} />
{/if}
</div>
</main>

Expand Down
101 changes: 96 additions & 5 deletions frontend/src/tests/lib/pages/Portfolio.spec.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
import { CKUSDC_UNIVERSE_CANISTER_ID } from "$lib/constants/ckusdc-canister-ids.constants";
import Portfolio from "$lib/pages/Portfolio.svelte";
import { icpSwapTickersStore } from "$lib/stores/icp-swap.store";
import type { TableProject } from "$lib/types/staking";
import type { UserToken } from "$lib/types/tokens-page";
import { resetIdentity, setNoIdentity } from "$tests/mocks/auth.store.mock";
import { mockIcpSwapTicker } from "$tests/mocks/icp-swap.mock";
import { principal } from "$tests/mocks/sns-projects.mock";
import { mockTableProject } from "$tests/mocks/staking.mock";
import { createUserToken } from "$tests/mocks/tokens-page.mock";
import { PortfolioPagePo } from "$tests/page-objects/PortfolioPage.page-object";
import { JestPageObjectElement } from "$tests/page-objects/jest.page-object";
import { render } from "@testing-library/svelte";

describe("Portfolio page", () => {
const renderPage = (
{ userTokensData }: { userTokensData: UserToken[] } = { userTokensData: [] }
{
userTokensData,
tableProjects,
}: { userTokensData?: UserToken[]; tableProjects?: TableProject[] } = {
userTokensData: [],
tableProjects: [],
}
) => {
const { container } = render(Portfolio, {
props: {
userTokensData: userTokensData,
userTokensData,
tableProjects,
},
});

Expand Down Expand Up @@ -87,6 +96,46 @@ describe("Portfolio page", () => {
});
});

describe("NoNeuronsCard", () => {
it("should display the card when the total balance is zero", async () => {
const po = renderPage();

expect(await po.getNoNeuronsCarPo().isPresent()).toBe(true);
expect(await po.getUsdValueBannerPo().getPrimaryAmount()).toBe("$0.00");
});

it("should not display the card when the neurons accounts balance is not zero", async () => {
const tableProject: TableProject = {
...mockTableProject,
stakeInUsd: 2,
};
const po = renderPage({ tableProjects: [tableProject] });

expect(await po.getNoNeuronsCarPo().isPresent()).toBe(false);
expect(await po.getUsdValueBannerPo().getPrimaryAmount()).toBe("$2.00");
});

it("should display a primary action when the neurons accounts balance is zero and the tokens balance is not zero", async () => {
const token = createUserToken({
universeId: principal(1),
balanceInUsd: 2,
});
const po = renderPage({ userTokensData: [token] });

expect(await po.getNoNeuronsCarPo().isPresent()).toBe(true);
expect(await po.getNoNeuronsCarPo().hasPrimaryAction()).toBe(true);
expect(await po.getUsdValueBannerPo().getPrimaryAmount()).toBe("$2.00");
});

it("should not display a primary action when the neurons accounts balance is zero and the tokens balance is also zero", async () => {
const po = renderPage();

expect(await po.getNoNeuronsCarPo().isPresent()).toBe(true);
expect(await po.getNoNeuronsCarPo().hasPrimaryAction()).toBe(false);
expect(await po.getUsdValueBannerPo().getPrimaryAmount()).toBe("$0.00");
});
});

describe("UsdValueBanner", () => {
it("should display total assets", async () => {
const token1 = createUserToken({
Expand All @@ -97,13 +146,27 @@ describe("Portfolio page", () => {
universeId: principal(1),
balanceInUsd: 7,
});
const po = renderPage({ userTokensData: [token1, token2] });

const tableProject1: TableProject = {
...mockTableProject,
stakeInUsd: 2,
};
const tableProject2: TableProject = {
...mockTableProject,
stakeInUsd: 10.5,
};
const po = renderPage({
userTokensData: [token1, token2],
tableProjects: [tableProject1, tableProject2],
});

// There are two tokens with a balance of 5$ and 7$, and two projects with a staked balance of 2$ and 10.5$ -> 24.5$
expect(await po.getUsdValueBannerPo().getPrimaryAmount()).toBe(
"$12.00"
"$24.50"
);
// 1ICP == 10$
expect(await po.getUsdValueBannerPo().getSecondaryAmount()).toBe(
"1.20 ICP"
"2.45 ICP"
);
expect(
await po.getUsdValueBannerPo().getTotalsTooltipIconPo().isPresent()
Expand Down Expand Up @@ -135,6 +198,34 @@ describe("Portfolio page", () => {
await po.getUsdValueBannerPo().getTotalsTooltipIconPo().isPresent()
).toBe(true);
});

it("should ignore neurons with unknown balance in USD and display tooltip", async () => {
const tableProject1: TableProject = {
...mockTableProject,
stakeInUsd: 2,
};
const tableProject2: TableProject = {
...mockTableProject,
stakeInUsd: 10.5,
};
const tableProject3: TableProject = {
...mockTableProject,
stakeInUsd: undefined,
};
const po = renderPage({
tableProjects: [tableProject1, tableProject2, tableProject3],
});

expect(await po.getUsdValueBannerPo().getPrimaryAmount()).toBe(
"$12.50"
);
expect(await po.getUsdValueBannerPo().getSecondaryAmount()).toBe(
"1.25 ICP"
);
expect(
await po.getUsdValueBannerPo().getTotalsTooltipIconPo().isPresent()
).toBe(true);
});
});
});
});

0 comments on commit 2934fa8

Please sign in to comment.