From 34f3eb93b273eaecc7b2c9c1171588fbbbd8a819 Mon Sep 17 00:00:00 2001
From: Dima Rifai <dima.rifai@gmail.com>
Date: Fri, 13 Dec 2024 23:29:30 +0200
Subject: [PATCH] Issue 328 - New Files for Balance History

---
 .../balanceHistory/BalanceHistoryTable.tsx    | 155 ++++++++++++++
 .../home/searches/BalanceHistorySearch.tsx    | 199 ++++++++++++++++++
 hooks/api/balanceHistory/useBalanceHistory.ts |  58 +++++
 pages/balanceHistory/[accountName].tsx        | 160 ++++++++++++++
 4 files changed, 572 insertions(+)
 create mode 100644 components/balanceHistory/BalanceHistoryTable.tsx
 create mode 100644 components/home/searches/BalanceHistorySearch.tsx
 create mode 100644 hooks/api/balanceHistory/useBalanceHistory.ts
 create mode 100644 pages/balanceHistory/[accountName].tsx

diff --git a/components/balanceHistory/BalanceHistoryTable.tsx b/components/balanceHistory/BalanceHistoryTable.tsx
new file mode 100644
index 00000000..8a0e583d
--- /dev/null
+++ b/components/balanceHistory/BalanceHistoryTable.tsx
@@ -0,0 +1,155 @@
+import Hive from "@/types/Hive";
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from "../ui/table";
+import Link from "next/link";
+import React from "react";
+import { cn } from "@/lib/utils";
+import Explorer from "@/types/Explorer";
+import { getOperationTypeForDisplay } from "@/utils/UI";
+import { categorizedOperationTypes } from "@/utils/CategorizedOperationTypes";
+import { colorByOperationCategory } from "../OperationTypesDialog";
+import { useUserSettingsContext } from "@/contexts/UserSettingsContext";
+import TimeAgo from "timeago-react";
+import { formatAndDelocalizeTime } from "@/utils/TimeUtils";
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipProvider,
+  TooltipTrigger,
+} from "../ui/tooltip";
+import { useRouter } from "next/router";
+import useOperationsTypes from "@/hooks/api/common/useOperationsTypes";
+import { formatNumber } from "@/lib/utils";
+import CustomPagination from "../CustomPagination";
+import { config } from "@/Config";
+
+interface OperationsTableProps {
+  operations: Explorer.BalanceHistoryForTable[];
+  total_operations: number;
+  total_pages: number;
+  current_page: number;
+}
+const BalanceHistoryTable: React.FC<OperationsTableProps> = ({
+  operations,
+  total_operations,
+  total_pages,
+  current_page,
+}) => {
+  const router = useRouter();
+  const operationsTypes = useOperationsTypes().operationsTypes || [];
+  
+  const formatRawCoin = (coinValue: number) =>
+    formatNumber(coinValue, false, false);
+
+  const getOperationColor = (op_type_id: number) => {
+    const operation = operationsTypes.find(
+      (op) => op.op_type_id === op_type_id
+    );
+    if (!operation) return "";
+    const category = categorizedOperationTypes.find((cat) =>
+      cat.types.includes(operation.operation_name)
+    );
+    return category ? colorByOperationCategory[category.name] : "";
+    };
+
+  const getOperationTypeForDisplayById = (op_type_id: number) =>
+    getOperationTypeForDisplay(
+      operationsTypes.find((op) => op.op_type_id === op_type_id)
+        ?.operation_name || ""
+    );
+    const updateUrl = (page: number) => {
+      router.push({
+        pathname: router.pathname,
+        query: { ...router.query, page: page.toString() },
+      });
+    };
+    
+
+  return (
+    <>
+      <CustomPagination
+        currentPage={current_page? current_page: 1}
+        onPageChange={updateUrl}
+        pageSize={config.standardPaginationSize}
+        totalCount={total_operations}
+        className="text-black dark:text-white"
+        isMirrored={false}
+      />
+      {total_operations === 0 ? (
+        <div className="flex justify-center w-full">
+          No results matching given criteria
+        </div>
+      ) : (
+        <Table className={cn("rounded-[6px] overflow-hidden max-w-[100%] text-xs mt-3")}>
+          <TableHeader>
+            <TableRow>
+              <TableHead>Operation Type</TableHead>
+              <TableHead>Date</TableHead>
+              <TableHead>Block Number</TableHead>
+              <TableHead>Balance</TableHead>
+              <TableHead>Balance Change</TableHead>
+              <TableHead>New Balance</TableHead>
+            </TableRow>
+          </TableHeader>
+          <TableBody>
+            {operations.map((operation) => {
+              const operationBgColor = getOperationColor(operation.opTypeId);
+              const coinName = router.query.coinType?router.query.coinType: 'HIVE'; //defaults to HIVE
+
+              return (
+                <TableRow key={operation.operationId}>
+                  <TableCell data-testid="operation-type">
+                    <div className="flex justify-stretch p-1 rounded">
+                      <span className={`rounded w-4 mr-2 ${operationBgColor}`}></span>
+                      <span>{getOperationTypeForDisplayById(operation.opTypeId)}</span>
+                    </div>
+                  </TableCell>
+                  <TableCell>
+                    <TooltipProvider>
+                      <Tooltip>
+                        <TooltipTrigger asChild>
+                          <div>
+                            <TimeAgo
+                              datetime={new Date(
+                                formatAndDelocalizeTime(operation.timestamp)
+                              )}
+                            />
+                          </div>
+                        </TooltipTrigger>
+                        <TooltipContent className="bg-theme text-text">
+                          {formatAndDelocalizeTime(operation.timestamp)}
+                        </TooltipContent>
+                      </Tooltip>
+                    </TooltipProvider>
+                  </TableCell>
+                  <TableCell data-testid="block-number">
+                    <Link className="text-link" href={`/block/${operation.blockNumber}`}>
+                      {operation.blockNumber?.toLocaleString()}
+                    </Link>
+                  </TableCell>
+                  <TableCell data-testid="operation-prev-balance">
+                    {formatRawCoin(operation.prev_balance)} {coinName}
+                  </TableCell>
+                  <TableCell data-testid="operation-balance-change">
+                    {formatRawCoin(operation.balanceChange)} {coinName}
+                  </TableCell>
+                  <TableCell>
+                    {formatRawCoin(operation.balance)} {coinName}
+                  </TableCell>
+                </TableRow>
+              );
+            })}
+          </TableBody>
+        </Table>
+      )}
+    </>
+  );
+};
+
+export default BalanceHistoryTable;
\ No newline at end of file
diff --git a/components/home/searches/BalanceHistorySearch.tsx b/components/home/searches/BalanceHistorySearch.tsx
new file mode 100644
index 00000000..645e62a7
--- /dev/null
+++ b/components/home/searches/BalanceHistorySearch.tsx
@@ -0,0 +1,199 @@
+import { useState, useEffect } from "react";
+import { useRouter } from "next/router";
+import SearchRanges from "@/components/searchRanges/SearchRanges";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+
+import useSearchRanges from "@/hooks/common/useSearchRanges";
+import useURLParams from "@/hooks/common/useURLParams";
+
+interface AccountSearchParams {
+  accountName?: string | undefined;
+  fromBlock: number | undefined;
+  toBlock: number | undefined;
+  fromDate: Date | undefined;
+  toDate: Date | undefined;
+  lastBlocks: number | undefined;
+  lastTime: number | undefined;
+  timeUnit: string | undefined;
+  rangeSelectKey: string | undefined;
+  page: number | undefined;
+  filters: boolean[];
+  coinType?: string; 
+}
+
+const defaultSearchParams: AccountSearchParams = {
+  accountName: undefined,
+  fromBlock: undefined,
+  toBlock: undefined,
+  fromDate: undefined,
+  toDate: undefined,
+  lastBlocks: undefined,
+  lastTime: undefined,
+  timeUnit: "days",
+  rangeSelectKey: "none",
+  page: undefined,
+  filters: [],
+  coinType: "HIVE", // Default to HIVE
+};
+
+const BalanceHistorySearch = () => {
+  const [coinType, setCoinType] = useState<string>("HIVE"); // State to store the selected coin name
+  const COIN_TYPES = ["HIVE", "VESTS", "HBD"];
+  const router = useRouter();
+  const accountNameFromRoute = (router.query.accountName as string)?.slice(1);
+
+  const { paramsState, setParams } = useURLParams(
+    {
+      ...defaultSearchParams,
+    },
+    ["accountName"]
+  );
+
+  const {
+    filters: filtersParam,
+    fromBlock: fromBlockParam,
+    toBlock: toBlockParam,
+    fromDate: fromDateParam,
+    toDate: toDateParam,
+    lastBlocks: lastBlocksParam,
+    timeUnit: timeUnitParam,
+    lastTime: lastTimeParam,
+    rangeSelectKey,
+    page,
+  } = paramsState;
+
+  const [initialSearch, setInitialSearch] = useState<boolean>(false);
+  const [filters, setFilters] = useState<boolean[]>([]);
+ 
+
+  const searchRanges = useSearchRanges();
+
+  const handleSearch = async (resetPage?: boolean) => {
+    if (
+      !initialSearch &&
+      (!!fromDateParam ||
+        !!toDateParam ||
+        !!fromBlockParam ||
+        !!toBlockParam ||
+        !!lastBlocksParam ||
+        !!lastTimeParam ||
+        !!filtersParam?.length)
+    ) {
+      fromDateParam && searchRanges.setStartDate(fromDateParam);
+      toDateParam && searchRanges.setEndDate(toDateParam);
+      fromBlockParam && searchRanges.setFromBlock(fromBlockParam);
+      toBlockParam && searchRanges.setToBlock(toBlockParam);
+      lastBlocksParam && searchRanges.setLastBlocksValue(lastBlocksParam);
+      timeUnitParam && searchRanges.setTimeUnitSelectKey(timeUnitParam);
+      rangeSelectKey && searchRanges.setRangeSelectKey(rangeSelectKey);
+      searchRanges.setLastTimeUnitValue(lastTimeParam);
+      setFilters(filtersParam);
+      setInitialSearch(true);
+    } else {
+      if (!initialSearch) {
+        setInitialSearch(true);
+      }
+
+      const {
+        payloadFromBlock,
+        payloadToBlock,
+        payloadStartDate,
+        payloadEndDate,
+      } = await searchRanges.getRangesValues();
+
+      setParams({
+        ...paramsState,
+        filters: filters,
+        fromBlock: payloadFromBlock,
+        toBlock: payloadToBlock,
+        fromDate: payloadStartDate,
+        toDate: payloadEndDate,
+        lastBlocks:
+          searchRanges.rangeSelectKey === "lastBlocks"
+            ? searchRanges.lastBlocksValue
+            : undefined,
+        lastTime:
+          searchRanges.rangeSelectKey === "lastTime"
+            ? searchRanges.lastTimeUnitValue
+            : undefined,
+        timeUnit:
+          searchRanges.rangeSelectKey === "lastTime"
+            ? searchRanges.timeUnitSelectKey
+            : undefined,
+        rangeSelectKey: searchRanges.rangeSelectKey,
+        page: resetPage ? undefined : page,
+      });
+    }
+  };
+
+  const handleCoinTypeChange = (newCoinType: string) => {
+    setCoinType(newCoinType);
+    setParams({ ...paramsState, coinType: newCoinType });
+    
+  };
+
+  const handleFilterClear = () => {
+    const newPage = rangeSelectKey !== "none" ? undefined : page;
+    setParams({
+      ...defaultSearchParams,
+      accountName: accountNameFromRoute,
+      page: newPage,
+    });
+    searchRanges.setRangeSelectKey("none");
+    setFilters([]);
+  };
+
+    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>
+        </CardHeader>
+        <CardContent>
+          <div className="flex items-center mb-3">
+            <div className="w-auto bg-theme dark:bg-theme border-0 border-b-2">
+              <select
+                value={coinType}
+                onChange={(e) => handleCoinTypeChange(e.target.value)}
+                className="w-[180px] border border-gray-300 p-2 rounded"
+              >
+                {COIN_TYPES.map((type) => (
+                  <option key={type} value={type}>
+                    {type}
+                  </option>
+                ))}
+              </select>
+            </div>
+          </div>
+          <SearchRanges rangesProps={searchRanges} />
+          <div className="flex items-center justify-between m-2">
+            <Button onClick={() => handleSearch(true)} data-testid="apply-filters">
+              <span>Apply filters</span>
+            </Button>
+            <Button onClick={() => handleFilterClear()} data-testid="clear-filters">
+              <span>Clear filters</span>
+            </Button>
+          </div>
+        </CardContent>
+      </Card>
+    </>
+  );
+};
+
+export default BalanceHistorySearch;
diff --git a/hooks/api/balanceHistory/useBalanceHistory.ts b/hooks/api/balanceHistory/useBalanceHistory.ts
new file mode 100644
index 00000000..c7fc0269
--- /dev/null
+++ b/hooks/api/balanceHistory/useBalanceHistory.ts
@@ -0,0 +1,58 @@
+import { UseQueryResult, useQuery } from "@tanstack/react-query";
+import moment from "moment";
+
+import fetchingService from "@/services/FetchingService";
+
+const useBalanceHistory = (
+  accountName: string,
+  coinType:string,
+  page: number | undefined,
+  pageSize : number | undefined,
+  direction: "asc" | "desc",
+  fromDate?: Date | number | undefined,
+  toDate?: Date| number |undefined,
+  
+) => {
+
+  /*const isDatesCorrect =
+    !moment(fromDate).isSame(toDate) && !moment(fromDate).isAfter(toDate);*/
+    const isDatesCorrect =true;
+  const fetchBalanceHist = async () =>
+    await fetchingService.geAccounttBalanceHistory(
+      accountName,
+      coinType,
+      page?page:1,
+      pageSize,
+      direction,
+      fromDate?fromDate:undefined,
+      toDate?toDate:undefined,
+    );
+
+  const {
+    data: accountBalanceHistory,
+    isLoading: isAccountBalanceHistoryLoading,
+    isError: isAccountBalanceHistoryError,
+  }: any = useQuery({
+    queryKey: [
+      "get_balance_history",
+      accountName,
+      coinType,
+      page,
+      pageSize,
+      direction,
+      fromDate,
+      toDate,
+    ],
+    queryFn: fetchBalanceHist,
+    enabled: !!accountName && isDatesCorrect,
+    refetchOnWindowFocus: false,
+  });
+  return {
+    accountBalanceHistory,
+    isAccountBalanceHistoryLoading,
+    isAccountBalanceHistoryError,
+  };
+  
+};
+
+export default useBalanceHistory;
diff --git a/pages/balanceHistory/[accountName].tsx b/pages/balanceHistory/[accountName].tsx
new file mode 100644
index 00000000..10cfdabf
--- /dev/null
+++ b/pages/balanceHistory/[accountName].tsx
@@ -0,0 +1,160 @@
+import { useRouter } from "next/router";
+import Head from "next/head";
+import Image from "next/image";
+import Link from "next/link";
+
+import { config } from "@/Config";
+
+import { Loader2 } from "lucide-react";
+
+import useBalanceHistory from "@/hooks/api/balanceHistory/useBalanceHistory";
+import useURLParams from "@/hooks/common/useURLParams";
+
+import { convertBalanceHistoryResultsToTableOperations } from "@/lib/utils";
+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";
+
+export default function BalanceHistory() {
+  const router = useRouter();
+  const accountNameFromRoute = (router.query.accountName as string)?.slice(1);
+
+  interface BalanceHistorySearchParams {
+    accountName?: string;
+    coinType: string;
+    fromBlock: Date | number | undefined;
+    toBlock: Date | number | undefined;
+    fromDate: undefined;
+    toDate: undefined;
+    lastBlocks: number | undefined;
+    lastTime: number | undefined;
+    timeUnit: string | undefined;
+    rangeSelectKey: string | undefined;
+    page: number;
+    filters: boolean[];
+  }
+
+  const defaultSearchParams: BalanceHistorySearchParams = {
+    accountName: accountNameFromRoute,
+    coinType: "HIVE",
+    fromBlock: undefined,
+    toBlock: undefined,
+    fromDate: undefined,
+    toDate: undefined,
+    lastBlocks: undefined,
+    lastTime: undefined,
+    timeUnit: "days",
+    rangeSelectKey: "none",
+    page: 1,
+    filters: [],
+  };
+
+  const { paramsState } = useURLParams(defaultSearchParams, ["accountName"]);
+
+  const {
+    filters: filtersParam,
+    fromBlock: fromBlockParam,
+    toBlock: toBlockParam,
+    fromDate: fromDateParam,
+    toDate: toDateParam,
+    lastBlocks: lastBlocksParam,
+    timeUnit: timeUnitParam,
+    lastTime: lastTimeParam,
+    rangeSelectKey,
+    page,
+  } = paramsState;
+
+  let effectiveFromBlock = paramsState.fromBlock || fromDateParam;
+  let effectiveToBlock = paramsState.toBlock || toDateParam;
+
+  if (
+    rangeSelectKey === "lastBlocks" &&
+    typeof effectiveFromBlock === "number" &&
+    paramsState.lastBlocks
+  ) {
+    effectiveToBlock = effectiveFromBlock + paramsState.lastBlocks;
+  }
+
+  const {
+    accountBalanceHistory,
+    isAccountBalanceHistoryLoading,
+    isAccountBalanceHistoryError,
+  } = useBalanceHistory(
+    accountNameFromRoute,
+    paramsState.coinType,
+    paramsState.page,
+    config.standardPaginationSize,
+    "desc",
+    effectiveFromBlock,
+    effectiveToBlock
+  );
+
+  return (
+    <>
+      <Head>
+        <title>@{accountNameFromRoute} - Hive Explorer</title>
+      </Head>
+
+      <div className="w-[95%] overflow-auto">
+        <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"
+                  src={getHiveAvatarUrl(accountNameFromRoute)}
+                  alt="avatar"
+                  width={60}
+                  height={60}
+                  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}`}
+                    >
+                      {" "}
+                      {accountNameFromRoute}
+                    </Link>
+                    / <span className="text-text">Balance History</span>
+                  </h2>
+                </div>
+              </div>
+            </div>
+          </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-black text-center">
+            No operations were found.
+          </div>
+        ) : isAccountBalanceHistoryLoading ? (
+          <div className="flex justify-center text-center items-center">
+            <Loader2 className="animate-spin mt-1 text-black 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}
+          />
+        )}
+      </div>
+    </>
+  );
+}
-- 
GitLab