diff --git a/components/balanceHistory/BalanceHistoryChart.tsx b/components/balanceHistory/BalanceHistoryChart.tsx index 736456b2571dae76bbf8817029b9cdb361802219..054b319c348f87eea4a1847e464438d67dbb2fb7 100644 --- a/components/balanceHistory/BalanceHistoryChart.tsx +++ b/components/balanceHistory/BalanceHistoryChart.tsx @@ -15,7 +15,6 @@ import { cn } from "@/lib/utils"; import moment from "moment"; import { ArrowDown, ArrowUp, Minus } from "lucide-react"; - interface BalanceHistoryChartProps { hiveBalanceHistoryData?: { timestamp: string; @@ -36,6 +35,12 @@ interface BalanceHistoryChartProps { quickView?: boolean; } +export const colorMap: Record<string, string> = { + HIVE: "#8884d8", + VESTS: "#82ca9d", + HBD: "#ff7300", +}; + const BalanceHistoryChart: React.FC<BalanceHistoryChartProps> = ({ hiveBalanceHistoryData, vestsBalanceHistoryData, @@ -83,12 +88,6 @@ const BalanceHistoryChart: React.FC<BalanceHistoryChartProps> = ({ return () => window.removeEventListener("resize", handleResize); }, []); - const colorMap: Record<string, string> = { - HIVE: "#8884d8", - VESTS: "#82ca9d", - HBD: "#ff7300", - }; - const dataMap: Record< string, { timestamp: string; balance_change: number; balance: number }[] diff --git a/components/home/HeadBlockCard.tsx b/components/home/HeadBlockCard.tsx index 4c9625bf9b027e791c1b5c49b3ec8085ef8e80e5..268af3dbcc330d54be83f8b923d9feeb6210d351 100644 --- a/components/home/HeadBlockCard.tsx +++ b/components/home/HeadBlockCard.tsx @@ -17,6 +17,7 @@ import { getBlockDifference } from "./SyncInfo"; import { Toggle } from "../ui/toggle"; import { Card, CardContent, CardHeader } from "../ui/card"; import CurrentBlockCard from "./CurrentBlockCard"; +import HeadBlockHiveChartCard from "./HeadBlockHiveChartCard"; interface HeadBlockCardProps { headBlockCardData?: Explorer.HeadBlockCardData | any; @@ -61,6 +62,7 @@ const HeadBlockCard: React.FC<HeadBlockCardProps> = ({ timeCard: true, supplyCard: true, hiveParamsCard: true, + hiveChart: false, }); const { settings, setSettings } = useUserSettingsContext(); @@ -84,6 +86,13 @@ const HeadBlockCard: React.FC<HeadBlockCardProps> = ({ }); }; + const handleHideHiveChart = () => { + setHiddenPropertiesByCard({ + ...hiddenPropertiesByCard, + hiveChart: !hiddenPropertiesByCard.hiveChart, + }); + }; + const { explorerBlockNumber, hiveBlockNumber, @@ -219,7 +228,7 @@ const HeadBlockCard: React.FC<HeadBlockCardProps> = ({ return ( <Card - className="col-span-4 md:col-span-1" + className="col-span-12 md:col-span-4 lg:col-span-3" data-testid="head-block-card" > <CardHeader className="flex justify-between items-end py-2 border-b "> @@ -256,6 +265,14 @@ const HeadBlockCard: React.FC<HeadBlockCardProps> = ({ </CardHeader> <CardContent className="p-4 space-y-4"> + {/* Last Block Information */} + <CurrentBlockCard + blockDetails={blockDetails} + transactionCount={transactionCount} + opcount={opcount} + timeDifferenceInSeconds={timeDifferenceInSeconds} + liveBlockNumber={liveBlockNumber} + /> {/* Other Information*/} <div className="data-box"> <div> @@ -265,16 +282,14 @@ const HeadBlockCard: React.FC<HeadBlockCardProps> = ({ <span>Vests To Hive Ratio:</span> {liveVestsToHiveRatio} VESTS </div> </div> - {/* Last Block Information */} - <CurrentBlockCard - blockDetails={blockDetails} - transactionCount={transactionCount} - opcount={opcount} - timeDifferenceInSeconds={timeDifferenceInSeconds} - liveBlockNumber={liveBlockNumber} - /> <div> + <HeadBlockHiveChartCard + header="Hive Price Chart" + isParamsHidden={hiddenPropertiesByCard.hiveChart} + handleHideParams={handleHideHiveChart} + isLoading={isBlockCardLoading} + /> <HeadBlockPropertyCard parameters={fundAndSupplyParameters} header="Fund and Supply" diff --git a/components/home/HeadBlockHiveChartCard.tsx b/components/home/HeadBlockHiveChartCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d50d0b4135ce4fd1968ab0d9eb548a4160f3e342 --- /dev/null +++ b/components/home/HeadBlockHiveChartCard.tsx @@ -0,0 +1,67 @@ +import { ArrowDown, ArrowUp } from "lucide-react"; +import { Loader2 } from "lucide-react"; +import MarketHistoryChart from "./MarketHistoryChart"; +import moment from "moment"; +import useMarketHistory from "@/hooks/common/useMarketHistory"; + +interface HeadBlockPropertyCardProps { + header: string; + isParamsHidden: boolean; + handleHideParams: () => void; + isLoading: boolean; +} + +const MARKET_HISTORY_INTERVAL = 86400; // 1 day +const CURRENT_TIME = moment().format("YYYY-MM-DDTHH:mm:ss"); +const MARKET_HISTORY_TIME_PERIOD = moment() + .subtract(30, "days") + .format("YYYY-MM-DDTHH:mm:ss"); + +const HeadBlockHiveChartCard: React.FC<HeadBlockPropertyCardProps> = ({ + header, + isParamsHidden, + handleHideParams, + isLoading, +}) => { + const { marketHistory, isMarketHistoryLoading } = useMarketHistory( + MARKET_HISTORY_INTERVAL, + MARKET_HISTORY_TIME_PERIOD, + CURRENT_TIME + ); + + return ( + <div + className="bg-theme py-1 rounded-[6px] data-box-chart" + data-testid="expandable-list" + style={{ overflowX: "auto", width: "100%" }} + > + <div + onClick={handleHideParams} + className="h-full w-full flex items-center justify-between py-1 cursor-pointer px-1" + > + <div className="text-lg">{header}</div> + <div>{isParamsHidden ? <ArrowDown /> : <ArrowUp />}</div> + </div> + + {isLoading && !isParamsHidden ? ( + <div className="flex justify-center w-full"> + <Loader2 className="animate-spin mt-1 text-white h-8 w-8" /> + </div> + ) : ( + <div + hidden={isParamsHidden} + data-testid="content-expandable-list" + > + <div> + <MarketHistoryChart + data={marketHistory} + isLoading={isMarketHistoryLoading} + /> + </div> + </div> + )} + </div> + ); +}; + +export default HeadBlockHiveChartCard; diff --git a/components/home/MarketHistoryChart.tsx b/components/home/MarketHistoryChart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bc92498f015d879cb5460490276369cd6878d118 --- /dev/null +++ b/components/home/MarketHistoryChart.tsx @@ -0,0 +1,137 @@ +import { + LineChart, + Line, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; +import Hive from "@/types/Hive"; +import moment from "moment"; +import { useHiveChainContext } from "@/contexts/HiveChainContext"; +import { Card, CardContent, CardHeader } from "../ui/card"; +import { Loader2 } from "lucide-react"; +import { colorMap } from "../balanceHistory/BalanceHistoryChart"; +import { useTheme } from "@/contexts/ThemeContext"; + +const CustomTooltip = ({ + active, + payload, +}: { + active?: boolean; + payload?: any[]; +}) => { + if (active && payload && payload.length) { + return ( + <div className="bg-buttonHover text-text p-2 rounded-xl"> + {payload.map(({ payload: { tooltipDate, avgPrice, volume } }) => { + return ( + <div key={tooltipDate}> + <p>{`Date: ${tooltipDate}`}</p> + <p>{`Average Price: $${avgPrice}`}</p> + <p>{`Volume: ${volume.toLocaleString("en-US")} HIVE`}</p> + </div> + ); + })} + </div> + ); + } + + return null; +}; + +// Using volume to calculate the average daily price of HIVE +const calculateAvgHivePrice = ( + hive: Hive.MarketData, + nonHive: Hive.MarketData +) => { + const hiveVolume = hive.volume; + const nonHiveVolume = nonHive.volume; + + return (nonHiveVolume / hiveVolume).toFixed(4); +}; + +interface MarketChartProps { + data: Hive.MarketHistory | undefined; + isLoading: boolean; +} +interface ChartData { + date: string; + avgPrice: string; + volume: number; +} + +const MarketHistoryChart: React.FC<MarketChartProps> = ({ + data, + isLoading, +}) => { + const { hiveChain } = useHiveChainContext(); + const { theme } = useTheme(); + + if (!data || !hiveChain) return; + + if (isLoading) { + return ( + <div className="flex justify-center items-center"> + <Loader2 className="animate-spin mt-1 h-16 w-10 ml-10 dark:text-white" /> + </div> + ); + } + + const chartData = data.buckets.map((bucket) => { + const { hive, non_hive } = bucket; + const hiveAveragePrice = calculateAvgHivePrice(hive, non_hive); + + return { + date: moment(bucket.open).format("MMM D"), + tooltipDate: moment(bucket.open).format("YYYY MMM D"), + avgPrice: hiveAveragePrice, + volume: bucket.hive.volume, + }; + }); + + const lastHivePrice = chartData[chartData.length - 1].avgPrice; + + const minValue = Math.min( + ...chartData.map((d: ChartData) => parseFloat(d.avgPrice)) + ); + const maxValue = Math.max( + ...chartData.map((d: ChartData) => parseFloat(d.avgPrice)) + ); + const strokeColor = theme === "dark" ? "#FFF" : "#000"; + + return ( + <ResponsiveContainer + width="100%" + height={250} + > + <LineChart data={chartData}> + <XAxis + dataKey="date" + stroke={strokeColor} + /> + <YAxis + dataKey="avgPrice" + domain={[minValue, maxValue]} + stroke={strokeColor} + /> + <Tooltip content={<CustomTooltip />} /> + <Legend + verticalAlign="top" + height={36} + /> + <Line + name={`Hive Price: $${lastHivePrice}`} + type="monotone" + dataKey="avgPrice" + stroke={colorMap.HIVE} + dot={false} + strokeWidth={2} + /> + </LineChart> + </ResponsiveContainer> + ); +}; + +export default MarketHistoryChart; diff --git a/hooks/common/useMarketHistory.tsx b/hooks/common/useMarketHistory.tsx new file mode 100644 index 0000000000000000000000000000000000000000..00d4f2b33cc7ab4b4ee3cc20930c7c8f570822b5 --- /dev/null +++ b/hooks/common/useMarketHistory.tsx @@ -0,0 +1,27 @@ +import { useQuery, UseQueryResult } from "@tanstack/react-query"; +import fetchingService from "@/services/FetchingService"; +import Hive from "@/types/Hive"; + +const useMarketHistory = ( + bucketSeconds: number, + start: string, + end: string +) => { + const { + data: marketHistory, + isLoading: isMarketHistoryLoading, + isError: isMarketHistoryError, + }: UseQueryResult<Hive.MarketHistory> = useQuery({ + queryKey: ["account_details", bucketSeconds, start, end], + queryFn: () => fetchingService.getMarketHistory(bucketSeconds, start, end), + refetchOnWindowFocus: false, + }); + + return { + marketHistory, + isMarketHistoryLoading, + isMarketHistoryError, + }; +}; + +export default useMarketHistory; diff --git a/pages/index.tsx b/pages/index.tsx index 32dab7ba1f9106d3c2d4b9966d14253d2f30a0fe..2b1c1ff2652da94ab1fce1f4ddbfc27916427953 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -14,7 +14,6 @@ import useDynamicGlobal from "@/hooks/api/homePage/useDynamicGlobal"; import { config } from "@/Config"; import useHeadBlock from "@/hooks/api/homePage/useHeadBlock"; import useBlockOperations from "@/hooks/api/common/useBlockOperations"; -import { useUserSettingsContext } from "@/contexts/UserSettingsContext"; import { Card, CardContent, @@ -64,14 +63,15 @@ export default function Home() { <Head> <title>Hive Explorer</title> </Head> - <div className=" page-container grid grid-cols-4 text-white gap-3"> + <div className="page-container grid grid-cols-12 text-white gap-3"> <HeadBlockCard headBlockCardData={dynamicGlobalQueryData} transactionCount={trxOpsLength} blockDetails={headBlockData} opcount={opcount} /> - <div className="col-span-4 md:col-span-3 lg:col-span-2"> + + <div className="col-span-12 md:col-span-8 lg:col-span-6"> <LastBlocksWidget headBlock={headBlockNum} strokeColor={strokeColor} @@ -80,7 +80,7 @@ export default function Home() { </div> <Card - className="col-span-4 md:col-span-4 lg:col-span-1 overflow-hidden" + className="col-span-12 md:col-span-12 lg:col-span-3 overflow-hidden" data-testid="top-witnesses-sidebar" > <CardHeader className="flex justify-between items-center border-b px-1 py-3"> diff --git a/services/FetchingService.ts b/services/FetchingService.ts index 5333f481150943eaa5c52e7cdcafdb9bac67a9e7..dfdd5082cae3f131c318151a1abb7d6f99682408 100644 --- a/services/FetchingService.ts +++ b/services/FetchingService.ts @@ -39,6 +39,12 @@ type ExplorerNodeApi = { Hive.HivePosts >; }; + market_history_api: { + get_market_history: TWaxApiRequest< + { bucket_seconds: number; start: string; end: string }, + Hive.MarketHistory[] + >; + }; }; class FetchingService { @@ -446,25 +452,39 @@ class FetchingService { }); } - async geAccountAggregatedtBalanceHistory( accountName: string, coinType: string, - granularity : "daily"|"monthly"|"yearly", + granularity: "daily" | "monthly" | "yearly", direction: "asc" | "desc", fromBlock?: Date | number | undefined, toBlock?: Date | number | undefined ): Promise<Hive.AccountAggregatedBalanceHistoryResponse> { - return await this.extendedHiveChain!.restApi["balance-api"].aggregatedHistory({ + return await this.extendedHiveChain!.restApi[ + "balance-api" + ].aggregatedHistory({ accountName, "coin-type": coinType, - "granularity":granularity, + granularity: granularity, direction: direction, "from-block": fromBlock, "to-block": toBlock, }); } + async getMarketHistory( + bucketSeconds: number, + start: string, + end: string + ): Promise<Hive.MarketHistory[]> { + return await this.extendedHiveChain!.api.market_history_api.get_market_history( + { + bucket_seconds: bucketSeconds, + start, + end, + } + ); + } } const fetchingService = new FetchingService(); diff --git a/styles/theme.css b/styles/theme.css index f019e4f72e4f8a6a3419090c2eba09d7787ccde1..3041b9af64a3624b191db24bf2a5cf9203ae4e0f 100644 --- a/styles/theme.css +++ b/styles/theme.css @@ -338,6 +338,19 @@ pre { flex-direction: column; /* Stack elements vertically */ } +.data-box-chart { + background-color: var(--color-extra-light-gray); + border-radius: 12px; + padding: 10px; + margin: 6px 0; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + color: var(--color-text); + font-size: 14px; + border: 1px solid var(--color-light-gray); + transition: all 0.3s ease-in-out; + flex-direction: column; +} + .data-box:hover { background-color: var(--color-row-hover); box-shadow: 0 10px 15px rgba(0, 0, 0, 0.15); /* Elevated shadow */ diff --git a/types/Hive.ts b/types/Hive.ts index 05eeda8d68f2208c13b5166b31a418327a7ed007..471211176d295339362071d5666f99dda4c572c2 100644 --- a/types/Hive.ts +++ b/types/Hive.ts @@ -789,7 +789,7 @@ namespace Hive { prev_balance!: number; min_balance!: number; max_balance!: number; - date!:Date; + date!: Date; } export class Delegation { @@ -811,6 +811,32 @@ namespace Hive { total_pages!: number; operations_result!: TwoDirectionDelegations[]; } + + export class MarketHistory { + buckets!: MarketBucket[]; + } + + export class MarketBucket { + id!: number; + open!: string; + seconds!: number; + hive!: MarketData; + symbol!: MarketSymbol; + non_hive!: MarketData; + } + + export class MarketData { + high!: number; + low!: number; + open!: number; + close!: number; + volume!: number; + } + + export class MarketSymbol { + nai!: string; + precision!: number; + } } export default Hive;