Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add dashboard portfolio charts #912

Merged
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 214 additions & 2 deletions app/[addressOrDomain]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ import { useAccount } from "@starknet-react/core";
import Blur from "@components/shapes/blur";
import { utils } from "starknetid.js";
import { StarknetIdJsContext } from "@context/StarknetIdJsProvider";
import { hexToDecimal } from "@utils/feltService";
import { hexToDecimal, tokenToDecimal } from "@utils/feltService";
import { isHexString, minifyAddress } from "@utils/stringService";
import ProfileCardSkeleton from "@components/skeletons/profileCardSkeleton";
import { getDataFromId } from "@services/starknetIdService";
import { usePathname, useRouter } from "next/navigation";
import ErrorScreen from "@components/UI/screens/errorScreen";
import { CompletedQuests } from "../../types/backTypes";
import { ArgentDappMap, ArgentTokenMap, ArgentUserDapp, ArgentUserToken, CompletedQuests } from "../../types/backTypes";
import QuestSkeleton from "@components/skeletons/questsSkeleton";
import QuestCardCustomised from "@components/dashboard/CustomisedQuestCard";
import QuestStyles from "@styles/Home.module.css";
Expand All @@ -31,16 +31,34 @@ import { TEXT_TYPE } from "@constants/typography";
import { a11yProps } from "@components/UI/tabs/a11y";
import { CustomTabPanel } from "@components/UI/tabs/customTab";
import SuggestedQuests from "@components/dashboard/SuggestedQuests";
import PortfolioSummary from "@components/dashboard/PortfolioSummary";
import { useNotification } from "@context/NotificationProvider";
import { calculateTokenPrice, fetchDapps, fetchTokens, fetchUserDapps, fetchUserTokens } from "@services/argentPortfolioService";
import PortfolioSummarySkeleton from "@components/skeletons/portfolioSummarySkeleton";

type AddressOrDomainProps = {
params: {
addressOrDomain: string;
};
};

type ChartItemMap = {
[dappId: string]: ChartItem
};

type DebtStatus = {
hasDebt: boolean;
tokens: {
dappId: string,
tokenAddress: string,
tokenBalance: number
}[];
};

export default function Page({ params }: AddressOrDomainProps) {
const router = useRouter();
const addressOrDomain = params.addressOrDomain;
const { showNotification } = useNotification();
const { address } = useAccount();
const { starknetIdNavigator } = useContext(StarknetIdJsContext);
const [initProfile, setInitProfile] = useState(false);
Expand All @@ -62,6 +80,9 @@ export default function Page({ params }: AddressOrDomainProps) {
const [questsLoading, setQuestsLoading] = useState(true);
const [tabIndex, setTabIndex] = React.useState(0);
const [claimableQuests, setClaimableQuests] = useState<Boost[]>([]);
const [portfolioAssets, setPortfolioAssets] = useState<ChartItem[]>([]);
const [portfolioProtocols, setPortfolioProtocols] = useState<ChartItem[]>([]);
const [loadingProtocols, setLoadingProtocols] = useState(true);
joeperpetua marked this conversation as resolved.
Show resolved Hide resolved

const handleChangeTab = useCallback(
(event: React.SyntheticEvent, newValue: number) => {
Expand Down Expand Up @@ -168,10 +189,177 @@ export default function Page({ params }: AddressOrDomainProps) {
setQuestsLoading(false);
}, []);

const fetchPortfolioAssets = useCallback(async (addr: string) => {

// TODO: Implement fetch from Argent API
const assets = [
{ color: "#1E2097", itemLabel: "USDC", itemValue: "46.68", itemValueSymbol: "%" },
{ color: "#637DEB", itemLabel: "USDT", itemValue: "27.94", itemValueSymbol: "%" },
{ color: "#2775CA", itemLabel: "STRK", itemValue: "22.78", itemValueSymbol: "%" },
{ color: "#5CE3FE", itemLabel: "ETH", itemValue: "0.36", itemValueSymbol: "%" },
{ color: "#F4FAFF", itemLabel: "Others", itemValue: "2.36", itemValueSymbol: "%" },
];
setPortfolioAssets(assets);

}, []);

const userHasDebt = (userDapps: ArgentUserDapp[]) => {
let debt: DebtStatus = { hasDebt: false, tokens: [] };

for (const dapp of userDapps) {
if (!dapp.products[0]) { return; }
for (const position of dapp.products[0].positions) {
for (const tokenAddress of Object.keys(position.totalBalances)) {
const tokenBalance = Number(position.totalBalances[tokenAddress]);
if (tokenBalance < 0) {
debt.hasDebt = true;
debt.tokens.push({dappId: dapp.dappId, tokenAddress, tokenBalance});
}
}
}
}
return debt;
};
joeperpetua marked this conversation as resolved.
Show resolved Hide resolved

const handleDebt = async (protocolsMap: ChartItemMap, userDapps: ArgentUserDapp[], tokens: ArgentTokenMap) => {
const debtStatus = userHasDebt(userDapps);
if (!debtStatus || !debtStatus.hasDebt) { return; }

for await (const debt of debtStatus.tokens) {
let value = Number(protocolsMap[debt.dappId].itemValue);
value += await calculateTokenPrice(
debt.tokenAddress,
tokenToDecimal(debt.tokenBalance.toString(),
tokens[debt.tokenAddress].decimals),
"USD"
);

protocolsMap[debt.dappId].itemValue = value.toFixed(2);
}
};

const getProtocolsFromTokens = async (protocolsMap: ChartItemMap, userTokens: ArgentUserToken[], tokens: ArgentTokenMap, dapps: ArgentDappMap) => {
for await (const token of userTokens) {
const tokenInfo = tokens[token.tokenAddress];
if (tokenInfo.dappId && token.tokenBalance != "0") {
let itemValue = 0;
const currentTokenBalance = await calculateTokenPrice(token.tokenAddress, tokenToDecimal(token.tokenBalance, tokenInfo.decimals), "USD");

if (protocolsMap[tokenInfo.dappId]?.itemValue) {
itemValue = Number(protocolsMap[tokenInfo.dappId].itemValue) + currentTokenBalance;
} else {
itemValue = currentTokenBalance;
}

protocolsMap[tokenInfo.dappId] = {
color: "",
itemLabel: dapps[tokenInfo.dappId].name,
itemValueSymbol: "$",
itemValue: itemValue.toFixed(2)
}
}
}
}
joeperpetua marked this conversation as resolved.
Show resolved Hide resolved

const getProtocolsFromDapps = async (protocolsMap: ChartItemMap, userDapps: ArgentUserDapp[], tokens: ArgentTokenMap, dapps: ArgentDappMap) => {
for await (const userDapp of userDapps) {
if (protocolsMap[userDapp.dappId]) { continue; } // Ignore entry if already present in the map

let protocolBalance = 0;
if (!userDapp.products[0]) { return; }
for await (const position of userDapp.products[0].positions) {
for await (const tokenAddress of Object.keys(position.totalBalances)) {
protocolBalance += await calculateTokenPrice(
tokenAddress,
tokenToDecimal(position.totalBalances[tokenAddress], tokens[tokenAddress].decimals),
"USD"
);
}
}

protocolsMap[userDapp.dappId] = {
color: "",
itemLabel: dapps[userDapp.dappId].name,
itemValueSymbol: "$",
itemValue: protocolBalance.toFixed(2)
}
}
}

const sortProtocols = (protocolsMap: ChartItemMap) => {
return Object.values(protocolsMap).sort((a, b) => parseFloat(b.itemValue) - parseFloat(a.itemValue));
}

const handleExtraProtocols = (sortedProtocols: ChartItem[]) => {
let otherProtocols = sortedProtocols.length > 5 ? sortedProtocols.splice(4) : [];
if (otherProtocols.length === 0) { return;}
sortedProtocols.push({
itemLabel: "Others",
itemValue: otherProtocols.reduce((valueSum, protocol) => valueSum + Number(protocol.itemValue), 0).toFixed(2),
itemValueSymbol: "$",
color: ""
});
}
joeperpetua marked this conversation as resolved.
Show resolved Hide resolved

const assignProtocolColors = (sortedProtocols: ChartItem[]) => {
const portfolioProtocolColors = [
"#278015",
"#23F51F",
"#DEFE5C",
"#9EFABB",
"#F4FAFF"
];
sortedProtocols.forEach((protocol, index) => {
protocol.color = portfolioProtocolColors[index];
});
}

const fetchPortfolioProtocols = useCallback(async (addr: string) => {
let dapps: ArgentDappMap = {};
let tokens: ArgentTokenMap = {};
let userTokens: ArgentUserToken[] = [];
let userDapps: ArgentUserDapp[] = [];

setLoadingProtocols(true);
try {
[dapps, tokens, userTokens, userDapps] = await Promise.all([
fetchDapps(),
fetchTokens(),
fetchUserTokens(addr),
fetchUserDapps(addr)
]);
} catch (error) {
showNotification("Error while fetching address portfolio", "error");
console.log("Error while fetching address portfolio", error);
}

if (!dapps || !tokens || (!userTokens && !userDapps)) return;
joeperpetua marked this conversation as resolved.
Show resolved Hide resolved
let protocolsMap: ChartItemMap = {};

try {
await getProtocolsFromTokens(protocolsMap, userTokens, tokens, dapps);
await handleDebt(protocolsMap, userDapps, tokens); // Tokens show debt as balance 0, so need to handle it manually
await getProtocolsFromDapps(protocolsMap, userDapps, tokens, dapps);

let sortedProtocols = sortProtocols(protocolsMap);
handleExtraProtocols(sortedProtocols);
assignProtocolColors(sortedProtocols);

setPortfolioProtocols(sortedProtocols);
} catch (error) {
showNotification("Error while calculating address portfolio stats", "error");
console.log("Error while calculating address portfolio stats", error);
}

setLoadingProtocols(false);
}, []);
joeperpetua marked this conversation as resolved.
Show resolved Hide resolved

useEffect(() => {
if (!identity) return;
fetchQuestData(identity.owner);
fetchPageData(identity.owner);
fetchPortfolioAssets(identity.owner);
fetchPortfolioProtocols(identity.owner);
}, [identity]);

useEffect(() => setNotFound(false), [dynamicRoute]);
Expand Down Expand Up @@ -325,6 +513,30 @@ export default function Page({ params }: AddressOrDomainProps) {
)}
</div>

{/* Portfolio charts */}
<div className={styles.dashboard_portfolio_summary_container}>
{loadingProtocols ? ( // Change for corresponding state
<PortfolioSummarySkeleton />
) : (
<PortfolioSummary
title="Portfolio by assets type"
data={portfolioAssets}
totalBalance={portfolioAssets.reduce((sum, item) => sum + Number(item.itemValue), 0)}
joeperpetua marked this conversation as resolved.
Show resolved Hide resolved
isProtocol={false}
/>
)}
{loadingProtocols ? (
<PortfolioSummarySkeleton />
) : (
<PortfolioSummary
title="Portfolio by protocol usage"
data={portfolioProtocols}
totalBalance={portfolioProtocols.reduce((sum, item) => sum + Number(item.itemValue), 0)}
isProtocol={true}
/>
)}
</div>
joeperpetua marked this conversation as resolved.
Show resolved Hide resolved

{/* Completed Quests */}
<div className={styles.dashboard_completed_tasks_container}>
<div>
Expand Down
126 changes: 126 additions & 0 deletions components/dashboard/PortfolioSummary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import React, { FunctionComponent, useMemo } from "react";
import { TEXT_TYPE } from "@constants/typography";
import Typography from "@components/UI/typography/typography";
import { Doughnut } from "react-chartjs-2";
import styles from "@styles/dashboard.module.css";
import { Chart, ArcElement, DoughnutController, Tooltip, ChartEvent, ActiveElement, TooltipItem, ChartOptions } from 'chart.js';
import { CDNImg } from "@components/cdn/image";
import starknetIcon from "../../public/starknet/favicon.ico";
import cursor from '../../public/icons/cursor.png';
import cursorPointer from '../../public/icons/pointer-cursor.png';

Chart.register(ArcElement, DoughnutController, Tooltip);

type PortfolioSummaryProps = {
title: string,
data: ChartItem[],
totalBalance: number,
isProtocol: boolean
}

const ChartEntry: FunctionComponent<ChartItem> = ({
color,
itemLabel,
itemValue,
itemValueSymbol
}) => {
const value = itemValueSymbol === '%' ? itemValue + itemValueSymbol : itemValueSymbol + itemValue;
return (
<div className="flex w-full justify-between my-1">
<div className="flex flex-row w-full items-center gap-2">
<svg width={16} height={16}>
<circle cx="50%" cy="50%" r="8" fill={color} />
</svg>
<Typography type={TEXT_TYPE.BODY_MIDDLE}>{itemLabel}</Typography>
</div>
<Typography type={TEXT_TYPE.BODY_MIDDLE}>{value}</Typography>
</div>
);
};

const PortfolioSummary: FunctionComponent<PortfolioSummaryProps> = ({ title, data, totalBalance, isProtocol }) => {
joeperpetua marked this conversation as resolved.
Show resolved Hide resolved

const normalizeMinValue = (data: ChartItem[], minPercentage: number) => {
return data.map(entry =>
Number(entry.itemValue) < totalBalance * minPercentage ?
(totalBalance * minPercentage).toFixed(2) :
entry.itemValue
);
}
joeperpetua marked this conversation as resolved.
Show resolved Hide resolved

const chartOptions: ChartOptions<"doughnut"> = useMemo(() => ({
elements: {
arc: {
borderRadius: 3,
spacing: 1,
hoverBorderColor: "white",
hoverBorderWidth: 1,
hoverOffset: 2
}
},
layout: {
padding: 5
},
responsive: true,
maintainAspectRatio: false,
cutout: '65%',
plugins: {
tooltip: {
callbacks: {
label: function (tooltipItem: TooltipItem<"doughnut">) {
return ` ${data[tooltipItem.dataIndex].itemValueSymbol}${data[tooltipItem.dataIndex].itemValue}`;
}
}
}
},
onHover: (event: ChartEvent, element: ActiveElement[]) => {
let canvas = event.native?.target as HTMLCanvasElement;
canvas.style.cursor = element[0] ? `url(${cursorPointer.src}), pointer` : `url(${cursor.src}), auto`;
}
joeperpetua marked this conversation as resolved.
Show resolved Hide resolved
}), [data]);
joeperpetua marked this conversation as resolved.
Show resolved Hide resolved

return data.length > 0 ? (
<div className={styles.dashboard_portfolio_summary}>
<div className="flex flex-col md:flex-row w-full justify-between items-center mb-4">
<div className="mb-4 md:mb-1">
<Typography type={TEXT_TYPE.BUTTON_LARGE} style={{ textAlign: "left", width: "fit-content"}}>{title}</Typography>
</div>
{isProtocol && (
<button
onClick={() => { }}
className="flex items-center justify-evenly gap-1.5 lg:gap-4 bg-white rounded-xl modified-cursor-pointer h-min px-6 py-2 mb-4 md:mb-0"
joeperpetua marked this conversation as resolved.
Show resolved Hide resolved
>
<CDNImg width={20} src={starknetIcon.src} loading="lazy" />
<Typography type={TEXT_TYPE.BUTTON_SMALL} color="background" style={{ lineHeight: "1rem" }}>
Claim your reward
</Typography>
</button>
joeperpetua marked this conversation as resolved.
Show resolved Hide resolved
)}
</div>
<div className={styles.dashboard_portfolio_summary_info}>
<div className="flex flex-col justify-between w-10/12 md:w-8/12 h-fit">
{data.map((item, id) => (
<ChartEntry key={id} {...item} />
))}
</div>
<div className="w-full mb-4 md:w-3/12 md:mb-0">
<Doughnut
data={{
labels: data.map(entry => entry.itemLabel),
datasets: [{
label: '',
data: normalizeMinValue(data, .05),
joeperpetua marked this conversation as resolved.
Show resolved Hide resolved
backgroundColor: data.map(entry => entry.color),
borderColor: data.map(entry => entry.color),
borderWidth: 1,
}],
}}
options={chartOptions}
/>
</div>
</div>
</div>
) : null;
}

export default PortfolioSummary;
Loading
Loading