diff --git a/.gitignore b/.gitignore index f2bf9e4df15119ae8c50017a2ceb56fa988ca6bf..8b71616ed38edee0a5d730163272635ba6309fdc 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ public/__ENV.js /blob-report/ /playwright/.cache/ .vscode/settings.json + +/.history/ \ No newline at end of file diff --git a/components/account/AccountBalanceCard.tsx b/components/account/AccountBalanceCard.tsx index 94df9801e7e6b481c3310b0d08fc99c27c9de92a..5ef17aca8fb5c8925e722228f56a0bd1c30aa485 100644 --- a/components/account/AccountBalanceCard.tsx +++ b/components/account/AccountBalanceCard.tsx @@ -171,41 +171,8 @@ const AccountBalanceCard: React.FC<AccountBalanceCardProps> = ({ className="flex justify-between items-center p-2 hover:bg-rowHover cursor-pointer px-4" > <div className="text-lg">{header}</div> - - <div className="flex"> - <TooltipProvider> - <Tooltip> - <TooltipTrigger asChild> - <Link - href={`/balanceHistory/@${userDetails.name}`} - data-testid="balance-history-link" - className="text-link text-sm mr-1 " - onClick={(e) => e.stopPropagation()} - > - <FontAwesomeIcon - icon={faHistory} - size="sm" - className="mr-1" - /> - <span>Balance History</span> - </Link> - </TooltipTrigger> - <TooltipContent - side="top" - align="start" - sideOffset={5} - alignOffset={10} - className="border-0" - > - <div className="bg-theme text-text p-2 text-sm"> - <p>Click Here for Balance History</p> - </div> - </TooltipContent> - </Tooltip> - </TooltipProvider> <span> {isBalancesHidden ? <ArrowDown /> : <ArrowUp />}</span> </div> - </div> </CardHeader> <CardContent hidden={isBalancesHidden} diff --git a/components/account/AccountBalanceHistoryCard.tsx b/components/account/AccountBalanceHistoryCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..db323f8f3f9330f1dc40e02ab20dfac766b3d1d6 --- /dev/null +++ b/components/account/AccountBalanceHistoryCard.tsx @@ -0,0 +1,220 @@ +import React, { useState, useMemo,MouseEvent } from "react"; +import { ArrowDown, ArrowUp } from "lucide-react"; +import { Card, CardContent, CardHeader } from "../ui/card"; +import Explorer from "@/types/Explorer"; +import Link from "next/link"; +import useBalanceHistory from "@/hooks/api/balanceHistory/useBalanceHistory"; +import BalanceHistoryChart from "../balanceHistory/BalanceHistoryChart"; +import moment from "moment"; +import { useRouter } from "next/router"; +import { Loader2 } from "lucide-react"; + +// Define the type for balance operation data +type AccountBalanceHistoryCardProps = { + header: string; + userDetails: Explorer.FormattedAccountDetails; +}; + +const AccountBalanceHistoryCard: React.FC<AccountBalanceHistoryCardProps> = ({ + header, + userDetails, +}) => { + const [isBalancesHidden, setIsBalancesHidden] = useState(false); + const defaultFromDate = useMemo( + () => moment().subtract(1, "month").toDate(), + [] + ); + const router = useRouter(); + const accountNameFromRoute = (router.query.accountName as string)?.slice(1); + + const { + accountBalanceHistory: hiveBalanceHistory, + isAccountBalanceHistoryLoading: hiveBalanceHistoryLoading, + isAccountBalanceHistoryError: hiveBalanceHistoryError, + } = useBalanceHistory( + accountNameFromRoute, + "HIVE", + undefined, + undefined, + "desc", + defaultFromDate + ); + + const { + accountBalanceHistory: vestsBalanceHistory, + isAccountBalanceHistoryLoading: vestsBalanceHistoryLoading, + isAccountBalanceHistoryError: vestsBalanceHistoryError, + } = useBalanceHistory( + accountNameFromRoute, + "VESTS", + undefined, + undefined, + "desc", + defaultFromDate + ); + + const { + accountBalanceHistory: hbdBalanceHistory, + isAccountBalanceHistoryLoading: hbdBalanceHistoryLoading, + isAccountBalanceHistoryError: hbdBalanceHistoryError, + } = useBalanceHistory( + accountNameFromRoute, + "HBD", + undefined, + undefined, + "desc", + defaultFromDate + ); + + const handleBalancesVisibility = () => { + setIsBalancesHidden(!isBalancesHidden); + }; + + const isLoading = + hiveBalanceHistoryLoading || + vestsBalanceHistoryLoading || + hbdBalanceHistoryLoading; + const hasData = + hiveBalanceHistory?.operations_result?.length > 0 || + vestsBalanceHistory?.operations_result?.length > 0 || + hbdBalanceHistory?.operations_result?.length > 0; + const hasError = + hiveBalanceHistoryError || + vestsBalanceHistoryError || + hbdBalanceHistoryError; + + const prepareData = ( + operations: { timestamp: string; balance: number }[] + ) => { + if (!operations || operations.length === 0) return [] + + const dailyData = new Map<string, { balance: number; balance_change: number }> + + operations.forEach((operation: any) => { + let date; + if (typeof operation.timestamp === 'string') { + date = new Date(operation.timestamp); + } else if (typeof operation.timestamp === 'number') { + date = new Date(operation.timestamp * 1000); + } else { + return; + } + + if (!isNaN(date.getTime())) { + const dateString = date.toISOString().split('T')[0]; + + let balance_change = parseInt(operation.balance_change, 10); + let balance = parseInt(operation.balance, 10); + + if (dailyData.has(dateString)) { + dailyData.get(dateString)!.balance_change += balance_change; + dailyData.get(dateString)!.balance = balance; + } else { + dailyData.set(dateString, { balance, balance_change }); + } + } + }); + + const preparedData = Array.from(dailyData.entries()).map(([date, data]) => ({ + timestamp: date, + balance: data.balance, + balance_change: data.balance_change, + })); + + return preparedData; + }; + + + + // Reverse and prepare data with useMemo + const reversedHiveBalanceHistory = useMemo( + () => + prepareData( + Array.isArray(hiveBalanceHistory?.operations_result) + ? [...hiveBalanceHistory.operations_result].reverse() + : [] + ), + [hiveBalanceHistory?.operations_result] + ); + + const reversedVestsBalanceHistory = useMemo( + () => + prepareData( + Array.isArray(vestsBalanceHistory?.operations_result) + ? [...vestsBalanceHistory.operations_result].reverse() + : [] + ), + [vestsBalanceHistory?.operations_result] + ); + + const reversedHbdBalanceHistory = useMemo( + () => + prepareData( + Array.isArray(hbdBalanceHistory?.operations_result) + ? [...hbdBalanceHistory.operations_result].reverse() + : [] + ), + [hbdBalanceHistory?.operations_result] + ); + + + const handleButtonClick = (e: MouseEvent<HTMLButtonElement>) => { + e.stopPropagation(); // Prevents the event from bubbling up + router.push(`/balanceHistory/@${userDetails.name}`); // Navigate programmatically + }; + + return ( + <Card data-testid="properties-dropdown" className="overflow-hidden pb-0"> + <CardHeader className="p-0 mb-2"> + <div + onClick={handleBalancesVisibility} + className="flex justify-between items-center p-2 hover:bg-rowHover cursor-pointer px-4" + > + <div className="text-lg">{header}</div> + + + <span>{isBalancesHidden ? <ArrowDown /> : <ArrowUp />}</span> + </div> + + <div className="flex justify-end items-end w-full"> + <button + onClick={handleButtonClick} + className="bg-explorer-orange text-explorer-gray-light dark:explorer-gray-dark rounded p-2 mr-4" + > + Full Chart + </button> + </div> + + </CardHeader> + <CardContent + hidden={isBalancesHidden} + data-testid="balance-history-content" + > + {isLoading && ( + <div className="flex justify-center items-center"> + <Loader2 className="animate-spin mt-1 h-16 w-10 ml-10 dark:text-white" /> + </div> + )} + {!isLoading && hasError && ( + <p className="text-sm text-center"> + Error loading balance information. + </p> + )} + {!isLoading && !hasData && ( + <p className="text-sm text-center">No balance information found.</p> + )} + {!isLoading && hasData && ( + <BalanceHistoryChart + hiveBalanceHistoryData={reversedHiveBalanceHistory} + vestsBalanceHistoryData={reversedVestsBalanceHistory} + hbdBalanceHistoryData={reversedHbdBalanceHistory} + quickView={true} + className="h-[320px]" + /> + )} + </CardContent> + </Card> + ); +}; + +export default AccountBalanceHistoryCard; diff --git a/components/account/AccountDetailsSection.tsx b/components/account/AccountDetailsSection.tsx index 719fccb254d00dec3d53c2bc8b063b70f01f9475..76749644061f740201de650d7fbb387079197057 100644 --- a/components/account/AccountDetailsSection.tsx +++ b/components/account/AccountDetailsSection.tsx @@ -15,7 +15,7 @@ import AccountVestingDelegationsCard from "./AccountVestingDelegationsCard"; import AccountRcDelegationsCard from "./AccountRcDelegationsCard"; import AccountBalanceCard from "./AccountBalanceCard"; import Explorer from "@/types/Explorer"; - +import AccountBalanceHistoryCard from "./AccountBalanceHistoryCard"; interface AccountDetailsSectionProps { accountName: string; refetchAccountOperations: QueryObserverResult<Hive.AccountOperationsResponse>["refetch"]; @@ -66,6 +66,11 @@ const AccountDetailsSection: React.FC<AccountDetailsSectionProps> = ({ header="Wallet" userDetails={accountDetails} /> + <AccountBalanceHistoryCard + header="Balance History" + userDetails={accountDetails} + /> + <AccountDetailsCard header="Properties" userDetails={accountDetails} diff --git a/components/balanceHistory/BalanceHistoryChart.tsx b/components/balanceHistory/BalanceHistoryChart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6f4194fc239bbdfe6069f932c25ecb8478c5a515 --- /dev/null +++ b/components/balanceHistory/BalanceHistoryChart.tsx @@ -0,0 +1,270 @@ +import React, { useState, useEffect } from "react"; +import { + Line, + LineChart, + CartesianGrid, + XAxis, + YAxis, + Tooltip, + Legend, + ResponsiveContainer, + Brush, +} from "recharts"; +import { formatNumber } from "@/lib/utils"; +import { cn } from "@/lib/utils"; +import moment from "moment"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faArrowDown, faArrowUp } from "@fortawesome/free-solid-svg-icons"; + +interface BalanceHistoryChartProps { + hiveBalanceHistoryData?: { + timestamp: string; + balance_change: number; + balance: number; + }[]; + vestsBalanceHistoryData?: { + timestamp: string; + balance_change: number; + balance: number; + }[]; + hbdBalanceHistoryData?: { + timestamp: string; + balance_change: number; + balance: number; + }[]; + className?: string; + quickView?: boolean; +} + +const BalanceHistoryChart: React.FC<BalanceHistoryChartProps> = ({ + hiveBalanceHistoryData, + vestsBalanceHistoryData, + hbdBalanceHistoryData, + className = "", + quickView = false, +}) => { + const [selectedCoinType, setSelectedCoinType] = useState<string>("HIVE"); + const [isMobile, setIsMobile] = useState<boolean>(window.innerWidth < 480); + const [hiddenDataKeys, setHiddenDataKeys] = useState<string[]>([]); + + // State to store available coins + const [availableCoins, setAvailableCoins] = useState<string[]>([]); + + useEffect(() => { + const newAvailableCoins: string[] = []; + if (hiveBalanceHistoryData && hiveBalanceHistoryData.length > 0) + newAvailableCoins.push("HIVE"); + if (vestsBalanceHistoryData && vestsBalanceHistoryData.length > 0) + newAvailableCoins.push("VESTS"); + if (hbdBalanceHistoryData && hbdBalanceHistoryData.length > 0) + newAvailableCoins.push("HBD"); + + setAvailableCoins(newAvailableCoins); + }, [hiveBalanceHistoryData, vestsBalanceHistoryData, hbdBalanceHistoryData]); + + useEffect(() => { + if (availableCoins.length === 1) { + setSelectedCoinType(availableCoins[0]); + } else if ( + availableCoins.length > 1 && + !availableCoins.includes(selectedCoinType) + ) { + setSelectedCoinType(availableCoins[0]); + } + }, [availableCoins, selectedCoinType]); + + useEffect(() => { + const handleResize = () => { + setIsMobile(window.innerWidth < 480); + }; + + window.addEventListener("resize", handleResize); + 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 }[] + > = { + HIVE: hiveBalanceHistoryData || [], + VESTS: vestsBalanceHistoryData || [], + HBD: hbdBalanceHistoryData || [], + }; + + const handleCoinTypeChange = (coinType: string) => { + setSelectedCoinType(coinType); + }; + + const CustomTooltip = ({ + active, + payload, + label, + }: { + active?: boolean; + payload?: any[]; + label?: string; + }) => { + if (quickView || !active || !payload || payload.length === 0) return null; + + const actualBalance = + dataMap[selectedCoinType]?.find((item) => item.timestamp === label) + ?.balance ?? 0; + const balanceChange = payload[0]?.payload.balance_change ?? 0; + + const isPositiveChange = balanceChange > 0; + + return ( + <div className="bg-theme dark:bg-theme p-2 rounded border border-explorer-light-gray"> + <p className="font-bold">{`Date: ${label}`}</p> + {payload.map((pld, index) => ( + <div key={index} style={{ color: pld.stroke }}> + <div> + {isPositiveChange ? ( + <FontAwesomeIcon + icon={faArrowUp} + size="sm" + className="bg-green-400 p-[1.2px]" + /> + ) : ( + <FontAwesomeIcon + icon={faArrowDown} + size="sm" + color="red" + className="bg-red-400 p-[1.2px]" + /> + )} + {` ${formatNumber( + balanceChange, + selectedCoinType === "VESTS", + false + )}`} + </div> + <div>{`Balance: ${formatNumber( + actualBalance, + selectedCoinType === "VESTS", + false + )}`}</div> + </div> + ))} + </div> + ); + }; + + const renderCoinButtons = () => { + return availableCoins.map((coinType) => ( + <button + key={coinType} + onClick={() => handleCoinTypeChange(coinType)} + className={cn( + "px-2 py-1 text-sm rounded m-[1px]", + selectedCoinType === coinType + ? "bg-blue-500 text-white" + : "bg-gray-200 text-black hover:bg-gray-300 dark:bg-gray-600 dark:text-white hover:dark:bg-gray-500" + )} + > + {coinType} + </button> + )); + }; + + const getMinMax = (data: { balance: number }[]) => { + const balance = data.map((item) => item.balance); + const minValue = Math.min(...balance); + const maxValue = Math.max(...balance); + return [minValue, maxValue]; + }; + + const [minValue, maxValue] = getMinMax(dataMap[selectedCoinType]); + + return ( + <div className={cn("w-full", className)}> + {availableCoins.length > 1 && ( + <div className="flex justify-end mb-4">{renderCoinButtons()}</div> + )} + + <ResponsiveContainer + width="100%" + height="100%" + className="mb-5 items-start" + > + <LineChart + data={dataMap[selectedCoinType] || []} + margin={{ + top: 20, + right: isMobile ? 0 : 20, + left: isMobile ? 0 : 10, + bottom: isMobile ? 100 : 60, + }} + > + <CartesianGrid strokeDasharray="3 3" /> + <XAxis + dataKey="timestamp" + tickCount={quickView ? 5 : 14} + tickFormatter={(value) => moment(value).format("MMM D")} + style={{ fontSize: "10px" }} + angle={isMobile ? -90 : 0} + dx={isMobile ? -8 : 0} + dy={isMobile ? 20 : 10} + /> + <YAxis + domain={[minValue, maxValue]} + tickFormatter={(tick) => { + if (selectedCoinType === "VESTS") { + const valueInK = tick / 1000; + let formattedValue = formatNumber(valueInK, true, false); + formattedValue = formattedValue.split(".")[0]; + return `${formattedValue} K`; + } + return formatNumber(tick, selectedCoinType === "VESTS", false); + }} + style={{ fontSize: "10px" }} + tickCount={6} + /> + <Tooltip content={<CustomTooltip />} /> + <Line + type="monotone" + dataKey="balance" + stroke={colorMap[selectedCoinType]} + activeDot={{ r: 6 }} + name={selectedCoinType} + dot={false} + hide={hiddenDataKeys.includes("balance")} + /> + + {!quickView && ( + <Brush + dataKey="timestamp" + height={30} + stroke="var(--color-switch-off)" + fill="var(--color-background)" + travellerWidth={10} + tickFormatter={(value) => moment(value).format("MMM D")} + y={380} + x={50} + className="text-xs" + /> + )} + <Legend + onClick={(event) => { + const { dataKey } = event; + const isHidden = hiddenDataKeys.includes(dataKey); + if (isHidden) { + setHiddenDataKeys(hiddenDataKeys.filter((key) => key !== dataKey)); + } else { + setHiddenDataKeys([...hiddenDataKeys, dataKey]); + } + }} + /> + </LineChart> + </ResponsiveContainer> + </div> + ); +}; + +export default BalanceHistoryChart; diff --git a/components/balanceHistory/BalanceHistoryTable.tsx b/components/balanceHistory/BalanceHistoryTable.tsx index 4a2d1891e575470e9d5a55032aec96b650e05ede..15eeeb0e1320b0ff3816af0c8150b5a7f17a337a 100644 --- a/components/balanceHistory/BalanceHistoryTable.tsx +++ b/components/balanceHistory/BalanceHistoryTable.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useRef, useEffect } from "react"; import Hive from "@/types/Hive"; import { Table, @@ -33,15 +33,14 @@ import { config } from "@/Config"; import { faChevronUp, faChevronDown } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import useOperationsFormatter from "@/hooks/common/useOperationsFormatter"; -import { convertOperationResultsToTableOperations } from "@/lib/utils"; -interface OperationsTableProps { +interface BalanceHistoryTableProps { operations: Explorer.BalanceHistoryForTable[]; total_operations: number; total_pages: number; current_page: number; } -const BalanceHistoryTable: React.FC<OperationsTableProps> = ({ +const BalanceHistoryTable: React.FC<BalanceHistoryTableProps> = ({ operations, total_operations, total_pages, @@ -55,8 +54,28 @@ const BalanceHistoryTable: React.FC<OperationsTableProps> = ({ const [expandedRow, setExpandedRow] = useState<number | null>(null); const operationsTypes = useOperationsTypes().operationsTypes || []; + // Create refs to store row and detail div elements + const rowRefs = useRef<Map<number, HTMLTableRowElement>>(new Map()); + const detailRefs = useRef<Map<number, HTMLDivElement>>(new Map()); + + // Track screen size for mobile responsiveness + const [isMobile, setIsMobile] = useState(false); + + // Set screen size check for mobile view + useEffect(() => { + const checkMobile = () => { + setIsMobile(window.innerWidth <= 768); // Adjust breakpoint as needed + }; + + checkMobile(); + window.addEventListener("resize", checkMobile); + return () => { + window.removeEventListener("resize", checkMobile); + }; + }, []); + const formatRawCoin = (coinValue: number) => - formatNumber(coinValue, false, false); + router.query.coinType === 'VESTS' ? formatNumber(coinValue, true, false) : formatNumber(coinValue, false, false); const getOperationColor = (op_type_id: number) => { const operation = operationsTypes.find( @@ -109,6 +128,7 @@ const BalanceHistoryTable: React.FC<OperationsTableProps> = ({ return <p>Loading operation details...</p>; }; + const getOneLineDescription = (operation: any) => { const value = operation.op.value; // Check if 'value' is a string or a valid React element @@ -136,6 +156,19 @@ const BalanceHistoryTable: React.FC<OperationsTableProps> = ({ return null; }; + const handleRowClick = (operationId: number) => { + setExpandedRow((prev) => (prev === operationId ? null : operationId)); + + // Scroll the corresponding row and details into view with additional margin for visibility + const rowElement = rowRefs.current.get(operationId); + const detailElement = detailRefs.current.get(operationId); + + if (rowElement && detailElement) { + // Ensure smooth scrolling for both row and details + rowElement.scrollIntoView({ behavior: "smooth", block: "start" }); + } + }; + return ( <> <CustomPagination @@ -177,7 +210,10 @@ const BalanceHistoryTable: React.FC<OperationsTableProps> = ({ return ( <React.Fragment key={index}> - <TableRow key={operation.operationId}> + <TableRow + ref={(el) => el && rowRefs.current.set(operation.operationId, el)} + className={isExpanded ? "bg-rowOdd" : ""} + > <TableCell data-testid="operation-type"> <div className="flex justify-stretch p-1 rounded"> <span @@ -194,11 +230,9 @@ const BalanceHistoryTable: React.FC<OperationsTableProps> = ({ <TooltipTrigger asChild> <div> <TimeAgo - datetime={ - new Date( - formatAndDelocalizeTime(operation.timestamp) - ) - } + datetime={new Date( + formatAndDelocalizeTime(operation.timestamp) + )} /> </div> </TooltipTrigger> @@ -227,11 +261,7 @@ const BalanceHistoryTable: React.FC<OperationsTableProps> = ({ </TableCell> <TableCell> <button - onClick={() => - setExpandedRow( - isExpanded ? null : operation.operationId - ) - } + onClick={() => handleRowClick(operation.operationId)} className="text-link" > <FontAwesomeIcon @@ -243,7 +273,9 @@ const BalanceHistoryTable: React.FC<OperationsTableProps> = ({ </TableCell> </TableRow> {isExpanded && ( - <TableRow> + <TableRow + ref={(el) => el && detailRefs.current.set(operation.operationId, el)} + > <TableCell colSpan={7} className="p-4"> <div className="border rounded-2xl p-4"> <h3 className="text-lg font-bold"> diff --git a/components/home/searches/BalanceHistorySearch.tsx b/components/home/searches/BalanceHistorySearch.tsx index a3afb6e833d126302d0443585643b1ff42b1589b..de75d00315748bb8dd4208ad86565c53fe175f96 100644 --- a/components/home/searches/BalanceHistorySearch.tsx +++ b/components/home/searches/BalanceHistorySearch.tsx @@ -6,6 +6,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import useSearchRanges from "@/hooks/common/useSearchRanges"; import useURLParams from "@/hooks/common/useURLParams"; +import OperationTypesDialog from "@/components/OperationTypesDialog"; +import useAccountOperationTypes from "@/hooks/api/accountPage/useAccountOperationTypes"; interface AccountSearchParams { accountName?: string | undefined; @@ -19,7 +21,7 @@ interface AccountSearchParams { rangeSelectKey: string | undefined; page: number | undefined; filters: boolean[]; - coinType?: string; + coinType?: string; } const defaultSearchParams: AccountSearchParams = { @@ -42,6 +44,30 @@ const BalanceHistorySearch = () => { const COIN_TYPES = ["HIVE", "VESTS", "HBD"]; const router = useRouter(); const accountNameFromRoute = (router.query.accountName as string)?.slice(1); + const { accountOperationTypes } = + useAccountOperationTypes(accountNameFromRoute); + const [selectedOperationTypes, setSelectedOperationTypes] = useState< + number[] + >([]); + const [singleOperationTypeId, setSingleOperationTypeId] = useState< + number | undefined + >(undefined); + const [fieldContent, setFieldContent] = useState<string>(""); + const [selectedKeys, setSelectedKeys] = useState<string[] | undefined>( + undefined + ); + const [selectedIndex, setSelectedIndex] = useState<string>(""); + + const changeSelectedOperationTypes = (operationTypesIds: number[]) => { + if (operationTypesIds.length === 1) { + setSingleOperationTypeId(operationTypesIds[0]); + } else { + setSingleOperationTypeId(undefined); + } + setSelectedKeys(undefined); + setFieldContent(""); + setSelectedOperationTypes(operationTypesIds); + }; const { paramsState, setParams } = useURLParams( { @@ -65,7 +91,6 @@ const BalanceHistorySearch = () => { const [initialSearch, setInitialSearch] = useState<boolean>(false); const [filters, setFilters] = useState<boolean[]>([]); - const searchRanges = useSearchRanges(); @@ -129,8 +154,11 @@ const BalanceHistorySearch = () => { const handleCoinTypeChange = (newCoinType: string) => { setCoinType(newCoinType); - setParams({ ...paramsState, coinType: newCoinType }); - + setParams({ + ...paramsState, + coinType: newCoinType, + page: undefined, // Reset the page when the coin type changes + }); }; const handleFilterClear = () => { @@ -144,48 +172,66 @@ const BalanceHistorySearch = () => { setFilters([]); }; - useEffect(() => { + useEffect(() => { if (paramsState.coinType) { setCoinType(paramsState.coinType); - } + } if (paramsState && !initialSearch) { handleSearch(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [paramsState]); - return ( <> <p className="m-2 mb-6 mt-6"> Find balance history of given account by coin and range. </p> - + <Card className="mb-4"> <CardHeader> - <CardTitle>Filters</CardTitle> + <CardTitle className="">Filters</CardTitle> </CardHeader> <CardContent> <div className="flex items-center mb-3"> - <select - value={coinType} - onChange={(e) => handleCoinTypeChange(e.target.value)} - className="w-[180px] border border-gray-300 p-2 rounded bg-theme dark:bg-theme" - > - {COIN_TYPES.map((type) => ( - <option key={type} value={type}> - {type} - </option> - ))} - </select> - + <select + value={coinType} + onChange={(e) => handleCoinTypeChange(e.target.value)} + className="w-[180px] border border-gray-300 p-2 rounded bg-theme dark:bg-theme" + > + {COIN_TYPES.map((type) => ( + <option key={type} value={type}> + {type} + </option> + ))} + </select> </div> <SearchRanges rangesProps={searchRanges} /> - <div className="flex items-center justify-between m-2"> - <Button onClick={() => handleSearch(true)} data-testid="apply-filters"> + {/* Operations Types commented for now + <div className="flex items-center mb-10 mt-2"> + <OperationTypesDialog + operationTypes={accountOperationTypes} + selectedOperations={selectedOperationTypes} + setSelectedOperations={/*changeSelectedOperationTypes} + buttonClassName="bg-gray-500" + triggerTitle={/*getOperationButtonTitle( + selectedOperationTypes, + accountOperationTypes + )} + /> + </div> */} + <div> + <Button + onClick={() => handleSearch(true)} + data-testid="apply-filters" + > <span>Apply filters</span> </Button> - <Button onClick={() => handleFilterClear()} data-testid="clear-filters"> + <Button + onClick={() => handleFilterClear()} + data-testid="clear-filters" + className="ml-2" + > <span>Clear filters</span> </Button> </div> diff --git a/hooks/api/balanceHistory/useBalanceHistory.ts b/hooks/api/balanceHistory/useBalanceHistory.ts index 011554b9621c290b4f63b85fe2389ce1e0307086..d4bacfa306d3e54affb20cac828426fbfca5c463 100644 --- a/hooks/api/balanceHistory/useBalanceHistory.ts +++ b/hooks/api/balanceHistory/useBalanceHistory.ts @@ -20,7 +20,7 @@ const useBalanceHistory = ( return await fetchingService.geAccounttBalanceHistory( accountName, coinType, - page ? page : 1, + page, pageSize, direction, fromDate ? fromDate : undefined, diff --git a/pages/balanceHistory/[accountName].tsx b/pages/balanceHistory/[accountName].tsx index 6cca43e345613da7fbe546bc7344fc12ed844fb5..d7f9ba591833ea0fdb20609d11757c51dda4e985 100644 --- a/pages/balanceHistory/[accountName].tsx +++ b/pages/balanceHistory/[accountName].tsx @@ -2,7 +2,8 @@ import { useRouter } from "next/router"; import Head from "next/head"; import Image from "next/image"; import Link from "next/link"; - +import React, { useMemo } from "react"; +import moment from "moment"; import { config } from "@/Config"; import { Loader2 } from "lucide-react"; @@ -17,13 +18,65 @@ import { getHiveAvatarUrl } from "@/utils/HiveBlogUtils"; import BalanceHistoryTable from "@/components/balanceHistory/BalanceHistoryTable"; import BalanceHistorySearch from "@/components/home/searches/BalanceHistorySearch"; import { Card, CardHeader } from "@/components/ui/card"; +import BalanceHistoryChart from "@/components/balanceHistory/BalanceHistoryChart"; + +// Memoizing the BalanceHistoryChart component to avoid unnecessary re-renders +const MemoizedBalanceHistoryChart = React.memo(BalanceHistoryChart); + +interface Operation { + timestamp: number; // Timestamp in seconds + balance: number; // Balance associated with the operation +} + +const prepareData = (operations: Operation[]) => { + if (!operations || operations.length === 0) return []; + + const dailyData = new Map<string, { balance: number; balance_change: number }>(); + + operations.forEach((operation: any) => { + let date; + if (typeof operation.timestamp === 'string') { + date = new Date(operation.timestamp); + } else if (typeof operation.timestamp === 'number') { + date = new Date(operation.timestamp * 1000); + } else { + return; + } + + if (!isNaN(date.getTime())) { + const dateString = date.toISOString().split('T')[0]; + + let balance_change = parseInt(operation.balance_change, 10); + let balance = parseInt(operation.balance, 10); + + if (dailyData.has(dateString)) { + dailyData.get(dateString)!.balance_change += balance_change; + dailyData.get(dateString)!.balance = balance; + } else { + dailyData.set(dateString, { balance, balance_change }); + } + } + }); + + const preparedData = Array.from(dailyData.entries()).map(([date, data]) => ({ + timestamp: date, + balance: data.balance, + balance_change: data.balance_change, + })); + + return preparedData; +}; export default function BalanceHistory() { const router = useRouter(); const accountNameFromRoute = (router.query.accountName as string)?.slice(1); - // Fetch account details - const { accountDetails, isAccountDetailsLoading, isAccountDetailsError ,notFound } = useAccountDetails(accountNameFromRoute, false); + const { + accountDetails, + isAccountDetailsLoading, + isAccountDetailsError, + notFound, + } = useAccountDetails(accountNameFromRoute, false); interface BalanceHistorySearchParams { accountName?: string; @@ -70,14 +123,11 @@ export default function BalanceHistory() { page, } = paramsState; - let effectiveFromBlock = paramsState.fromBlock || fromDateParam; + const defaultFromDate = React.useMemo(() => moment().subtract(1, "month").toDate(), []); + let effectiveFromBlock = paramsState.fromBlock || fromDateParam || defaultFromDate; let effectiveToBlock = paramsState.toBlock || toDateParam; - if ( - rangeSelectKey === "lastBlocks" && - typeof effectiveFromBlock === "number" && - paramsState.lastBlocks - ) { + if (rangeSelectKey === "lastBlocks" && typeof effectiveFromBlock === "number" && paramsState.lastBlocks) { effectiveToBlock = effectiveFromBlock + paramsState.lastBlocks; } @@ -95,13 +145,39 @@ export default function BalanceHistory() { effectiveToBlock ); + // Update chartData to return loading, error, and data + const { + accountBalanceHistory: chartData, + isAccountBalanceHistoryLoading: isChartDataLoading, + isAccountBalanceHistoryError: isChartDataError, + } = useBalanceHistory( + accountNameFromRoute, + paramsState.coinType, + undefined, + 5000, // Default size for chart data + "desc", + effectiveFromBlock, + effectiveToBlock + ); + + // Use useMemo to memoize the prepared data so it only recalculates when chartData changes + const preparedData = useMemo(() => { + return chartData ? prepareData(chartData.operations_result?.slice().reverse()) : []; + }, [chartData]); // This will only recompute when chartData changes + + let message = ""; + if (effectiveFromBlock === defaultFromDate && !fromBlockParam && !toBlockParam) { + message = "Showing Results for the last month."; + } else { + message = "Showing Results with applied filters."; + } + return ( <> <Head> <title>@{accountNameFromRoute} - Hive Explorer</title> </Head> - {/* Loading state for account details */} {isAccountDetailsLoading ? ( <div className="flex justify-center text-center items-center"> <Loader2 className="animate-spin mt-1 text-black h-12 w-12 ml-3" /> @@ -114,7 +190,6 @@ export default function BalanceHistory() { <Card data-testid="account-details"> <CardHeader> <div className="flex flex-wrap items-center justify-between gap-4 bg-theme dark:bg-theme"> - {/* Avatar and Name */} <div className="flex items-center gap-4"> <Image className="rounded-full border-2 border-explorer-orange" @@ -125,15 +200,8 @@ export default function BalanceHistory() { data-testid="user-avatar" /> <div> - <h2 - className="text-lg font-semibold text-gray-800 dark:text-white" - data-testid="account-name" - > - <Link - className="text-link" - href={`/@${accountNameFromRoute}`} - > - {" "} + <h2 className="text-lg font-semibold text-gray-800 dark:text-white" data-testid="account-name"> + <Link className="text-link" href={`/@${accountNameFromRoute}`}> {accountNameFromRoute} </Link> / <span className="text-text">Balance History</span> @@ -144,10 +212,8 @@ export default function BalanceHistory() { </CardHeader> </Card> - {/* Filter Options (Always visible) */} <BalanceHistorySearch /> - {/* Show Error Message if No Balance History and No Loading State */} {!isAccountBalanceHistoryLoading && !accountBalanceHistory?.total_operations ? ( <div className="w-full my-4 text-center"> No operations were found. @@ -157,15 +223,39 @@ export default function BalanceHistory() { <Loader2 className="animate-spin mt-1 h-12 w-12 ml-3" /> </div> ) : ( - // Show the table when balance history exists - <BalanceHistoryTable - operations={convertBalanceHistoryResultsToTableOperations( - accountBalanceHistory - )} - total_operations={accountBalanceHistory.total_operations} - total_pages={accountBalanceHistory.total_pages} - current_page={paramsState.page} - /> + <> + <Card data-testid="account-details"> + {message && ( + <div className="p-4 bg-gray-100 dark:bg-gray-700 rounded-lg mb-4 text-center text-sm text-gray-500"> + {message}<br /> + Results are limited to 5000 records and grouped by day.<br /> + </div> + )} + + {isChartDataLoading ? ( + <div className="flex justify-center text-center items-center"> + <Loader2 className="animate-spin mt-1 h-16 w-10 ml-10 dark:text-white" /> + </div> + ) : !isChartDataError ? ( + <MemoizedBalanceHistoryChart + hiveBalanceHistoryData={(!paramsState.coinType || paramsState.coinType === "HIVE") ? preparedData : undefined} + vestsBalanceHistoryData={paramsState.coinType === "VESTS" ? preparedData : undefined} + hbdBalanceHistoryData={paramsState.coinType === "HBD" ? preparedData : undefined} + quickView={false} + className="h-[450px] mb-10 mr-0 pr-1 pb-6" + /> + ) : ( + <div>Error loading chart data</div> + )} + </Card> + + <BalanceHistoryTable + operations={convertBalanceHistoryResultsToTableOperations(accountBalanceHistory)} + total_operations={accountBalanceHistory.total_operations} + total_pages={accountBalanceHistory.total_pages} + current_page={paramsState.page} + /> + </> )} </div> )