Skip to content

Commit

Permalink
Merge pull request #912 from joeperpetua/feat/add-portfolio-dashboard
Browse files Browse the repository at this point in the history
Add dashboard portfolio charts
  • Loading branch information
Marchand-Nicolas authored Oct 28, 2024
2 parents 64b8bc4 + 7dff03b commit d3feb7e
Show file tree
Hide file tree
Showing 10 changed files with 623 additions and 20 deletions.
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);

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]) { continue; }
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;
};

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)
}
}
}
}

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: ""
});
}

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;
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);
}, []);

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)}
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>

{/* Completed Quests */}
<div className={styles.dashboard_completed_tasks_container}>
<div>
Expand Down
127 changes: 127 additions & 0 deletions components/dashboard/PortfolioSummary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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,
minSlicePercentage?: number
}

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, minSlicePercentage = 0.05 }) => {

const normalizeMinValue = (data: ChartItem[]) => {
return data.map(entry =>
Number(entry.itemValue) < totalBalance * minSlicePercentage ?
(totalBalance * minSlicePercentage).toFixed(2) :
entry.itemValue
);
}

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`;
}
}), [data]);

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"
>
<CDNImg width={20} src={starknetIcon.src} loading="lazy" />
<Typography type={TEXT_TYPE.BUTTON_SMALL} color="background" style={{ lineHeight: "1rem" }}>
Claim your reward
</Typography>
</button>
)}
</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),
backgroundColor: data.map(entry => entry.color),
borderColor: data.map(entry => entry.color),
borderWidth: 1,
}],
}}
options={chartOptions}
/>
</div>
</div>
</div>
) : null;
}

export default PortfolioSummary;
Loading

0 comments on commit d3feb7e

Please sign in to comment.