diff --git a/apps/storybook/src/stories/defi/ChainTvlHistoricalChart.stories.tsx b/apps/storybook/src/stories/defi/ChainTvlHistoricalChart.stories.tsx new file mode 100644 index 00000000..3abfd0a8 --- /dev/null +++ b/apps/storybook/src/stories/defi/ChainTvlHistoricalChart.stories.tsx @@ -0,0 +1,54 @@ +import { ChainTvlHistoricalChart } from "@geist/ui-react/components/defi/chain-tvl-historical-chart"; +import type { Meta, StoryObj } from "@storybook/react"; +import { expect, userEvent } from "@storybook/test"; +import { withQueryClientProvider } from "#stories/decorators/wagmi.tsx"; +import { setupCanvas } from "../utils/test-utils"; + +const meta = { + title: "DeFi/ChainTvlHistoricalChart", + component: ChainTvlHistoricalChart, + parameters: { + layout: "centered", + }, + decorators: [withQueryClientProvider()], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const EthereumMainnet: Story = { + args: { + chainId: "ethereum", + title: "Ethereum TVL", + color: "#5470FF", + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const { canvas } = await setupCanvas(canvasElement, 3000); + + const chart = await canvas.findByTestId("chain-tvl-historical-chart"); + await expect(chart).toBeInTheDocument(); + + const title = await canvas.findByText("Ethereum TVL"); + await expect(title).toBeInTheDocument(); + + await userEvent.keyboard("{Tab}"); + + const tvlText = await canvas.findByText("TVL", undefined, { + timeout: 3000, + }); + await expect(tvlText).toBeInTheDocument(); + + const dateText = await canvas.findByText("Date", undefined, { + timeout: 3000, + }); + await expect(dateText).toBeInTheDocument(); + }, +}; + +export const ArbitrumOne: Story = { + args: { + chainId: "arbitrum", + title: "Arbitrum TVL", + color: "#28A0F0", + }, +}; diff --git a/apps/storybook/src/stories/defi/ProtocolTvlHistoricalChart.stories.tsx b/apps/storybook/src/stories/defi/ProtocolTvlHistoricalChart.stories.tsx new file mode 100644 index 00000000..677362c6 --- /dev/null +++ b/apps/storybook/src/stories/defi/ProtocolTvlHistoricalChart.stories.tsx @@ -0,0 +1,62 @@ +import { ProtocolTvlHistoricalChart } from "@geist/ui-react/components/defi/protocol-tvl-historical-chart"; +import type { Meta, StoryObj } from "@storybook/react"; +import { expect, userEvent } from "@storybook/test"; +import { withQueryClientProvider } from "#stories/decorators/wagmi.tsx"; +import { setupCanvas } from "../utils/test-utils"; + +const meta = { + title: "DeFi/ProtocolTvlHistoricalChart", + component: ProtocolTvlHistoricalChart, + parameters: { + layout: "centered", + }, + decorators: [withQueryClientProvider()], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const AaveProtocol: Story = { + args: { + protocol: "aave", + title: "Aave v2 TVL", + color: "#B6509E", + }, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + const { canvas } = await setupCanvas(canvasElement, 3000); + + const chart = await canvas.findByTestId("protocol-tvl-historical-chart"); + await expect(chart).toBeInTheDocument(); + + const title = await canvas.findByText("Aave v2 TVL"); + await expect(title).toBeInTheDocument(); + + await userEvent.keyboard("{Tab}"); + + const tvlText = await canvas.findByText("TVL", undefined, { + timeout: 3000, + }); + await expect(tvlText).toBeInTheDocument(); + + const dateText = await canvas.findByText("Date", undefined, { + timeout: 3000, + }); + await expect(dateText).toBeInTheDocument(); + }, +}; + +export const UniswapProtocol: Story = { + args: { + protocol: "uniswap", + title: "Uniswap v3 TVL", + color: "#FF007A", + }, +}; + +export const MorphoProtocol: Story = { + args: { + protocol: "morpho", + title: "Morpho TVL", + color: "#00D395", + }, +}; diff --git a/packages/ui-react/src/components/defi/chain-tvl-historical-chart.tsx b/packages/ui-react/src/components/defi/chain-tvl-historical-chart.tsx new file mode 100644 index 00000000..73523ad9 --- /dev/null +++ b/packages/ui-react/src/components/defi/chain-tvl-historical-chart.tsx @@ -0,0 +1,133 @@ +import { formatNumberWithLocale } from "@geist/domain/amount"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "#components/shadcn/chart"; +import { Skeleton } from "#components/shadcn/skeleton"; + +type ChainTvlHistoricalChartProps = { + chainId: string; + title?: string; + color?: string; +}; + +export type DefiLlamaTvlDataPoint = { + date: number; + tvl: number; +}; + +const chartConfig = {} satisfies ChartConfig; + +async function getChainTvlHistoricalChartData( + chainId: string, +): Promise { + const response = await fetch( + `https://api.llama.fi/v2/historicalChainTvl/${chainId}`, + ); + + if (!response.ok) { + throw new Error(`Failed to fetch TVL data: ${response.statusText}`); + } + + const data = await response.json(); + return data as DefiLlamaTvlDataPoint[]; +} + +export function ChainTvlHistoricalChart({ + chainId, + title, + color, +}: ChainTvlHistoricalChartProps) { + const { data, isLoading } = useQuery({ + queryKey: ["chain-tvl-historical-chart", chainId], + queryFn: () => getChainTvlHistoricalChartData(chainId), + }); + + const formatDate = (timestamp: number) => { + return new Date(timestamp * 1000).toLocaleDateString(); + }; + + const formatTvl = (value: number) => { + return formatNumberWithLocale({ + value, + locale: new Intl.Locale("en-US"), + formatOptions: { + style: "currency", + currency: "USD", + notation: "compact", + compactDisplay: "short", + }, + }); + }; + + const formattedTvlData = useMemo(() => { + if (!data) return []; + + const mapTvlDataPoint = (dataPoint: DefiLlamaTvlDataPoint) => ({ + date: dataPoint.date, + tvl: dataPoint.tvl, + }); + + const sortByDate = (a: DefiLlamaTvlDataPoint, b: DefiLlamaTvlDataPoint) => + a.date - b.date; + + return data.map(mapTvlDataPoint).sort(sortByDate); + }, [data]); + + if (isLoading) return ; + + return ( +
+ {title &&
{title}
} + + + + + + { + return ( +
+
TVL: {formatTvl(value as number)}
+
Date: {formatDate(props.payload.date)}
+
+ ); + }} + /> + } + /> + +
+
+
+ ); +} diff --git a/packages/ui-react/src/components/defi/protocol-tvl-historical-chart.tsx b/packages/ui-react/src/components/defi/protocol-tvl-historical-chart.tsx new file mode 100644 index 00000000..1eba3039 --- /dev/null +++ b/packages/ui-react/src/components/defi/protocol-tvl-historical-chart.tsx @@ -0,0 +1,178 @@ +import { formatNumberWithLocale } from "@geist/domain/amount"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "#components/shadcn/chart"; +import { Skeleton } from "#components/shadcn/skeleton"; + +type ProtocolTvlHistoricalChartProps = { + protocol: string; + title?: string; + color?: string; +}; + +interface ProtocolTvlDataPoint { + date: number; + totalLiquidityUSD: number; +} + +interface ChainTvlData { + [chainId: string]: ProtocolTvlDataPoint[]; +} + +interface ProtocolData { + chainTvls: ChainTvlData; + tvl: ProtocolTvlDataPoint[]; + [key: string]: unknown; +} + +const chartConfig = {} satisfies ChartConfig; + +async function getProtocolTvlHistoricalChartData( + protocolId: string, +): Promise { + const response = await fetch(`https://api.llama.fi/protocol/${protocolId}`); + + if (!response.ok) { + throw new Error( + `Failed to fetch protocol TVL data: ${response.statusText}`, + ); + } + + const data = (await response.json()) as ProtocolData; + + // If chainName is specified, only include data for that chain + if (data.tvl) { + const chainData = data.tvl; + const validPoints = chainData.filter( + (point) => + typeof point === "object" && + "date" in point && + "totalLiquidityUSD" in point, + ); + return validPoints.sort((a, b) => a.date - b.date); + } + + // Otherwise combine all valid chain data into a single dataset + const tvlData: ProtocolTvlDataPoint[] = []; + + Object.values(data.chainTvls).forEach((chainData) => { + // Only include data where the required format is present + if (Array.isArray(chainData) && chainData.length > 0) { + const validPoints = chainData.filter( + (point) => + typeof point === "object" && + "date" in point && + "totalLiquidityUSD" in point, + ); + tvlData.push(...validPoints); + } + }); + + const sortByDate = (a: ProtocolTvlDataPoint, b: ProtocolTvlDataPoint) => + a.date - b.date; + + return tvlData.sort(sortByDate); +} + +export function ProtocolTvlHistoricalChart({ + protocol: protocolId, + title, + color, +}: ProtocolTvlHistoricalChartProps) { + const { data, isLoading } = useQuery({ + queryKey: ["protocol-tvl-historical-chart", protocolId], + queryFn: () => getProtocolTvlHistoricalChartData(protocolId), + }); + + const formatDate = (timestamp: number) => { + return new Date(timestamp * 1000).toLocaleDateString(); + }; + + const formatTvl = (value: number) => { + return formatNumberWithLocale({ + value, + locale: new Intl.Locale("en-US"), + formatOptions: { + style: "currency", + currency: "USD", + notation: "compact", + compactDisplay: "short", + }, + }); + }; + + const formattedTvlData = useMemo(() => { + if (!data) return []; + + return data.map((dataPoint) => ({ + date: dataPoint.date, + tvl: dataPoint.totalLiquidityUSD, + })); + }, [data]); + + const displayedTitle = useMemo(() => { + if (title) return title; + return protocolId; + }, [title, protocolId]); + + if (isLoading) return ; + + return ( +
+ {displayedTitle && ( +
{displayedTitle}
+ )} + + + + + + { + return ( +
+
TVL: {formatTvl(value as number)}
+
Date: {formatDate(props.payload.date)}
+
+ ); + }} + /> + } + /> + +
+
+
+ ); +} diff --git a/packages/ui-react/src/components/token/yield-historical-chart.tsx b/packages/ui-react/src/components/token/yield-historical-chart.tsx index 73ed46f8..37a20788 100644 --- a/packages/ui-react/src/components/token/yield-historical-chart.tsx +++ b/packages/ui-react/src/components/token/yield-historical-chart.tsx @@ -109,7 +109,7 @@ export function YieldHistoricalChart({ type="monotone" dataKey="apy" strokeWidth={2} - stroke={color ?? "#2563eb"} + stroke={color} dot={false} />