Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions e2e/tests/shared/mocked/gas-tendency-chart.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { test } from "../../../fixtures/test";

/**
* Hermetic regression test for the Gas Tendency chart on the Network and
* Blocks pages. The chart fetches `eth_feeHistory(0x32, "latest")` via
* `useFeeHistory` and renders an SVG sparkline gated on
* `dataService.isEVM()`.
*
* Placeholder in phase 1 — the hermetic version requires:
* 1. Mocking `eth_blockNumber`, `eth_getBlockByNumber`, `eth_gasPrice`,
* `eth_feeHistory`, and `eth_getTransactionByHash` for the full page
* load, and asserting `eth_feeHistory` is called with `0x32` (50).
* 2. Asserting `[data-testid="gas-tendency-chart"]` is visible after the
* mocked response resolves, on both `/<chainId>` and `/<chainId>/blocks`.
* 3. Asserting the chart is NOT rendered on Bitcoin / Solana routes.
* 4. Hovering an SVG point and asserting the tooltip surfaces the
* expected block number, base fee (gwei), and gas-used %.
*
* Phase 4 wires these together using the `rpcMock` helpers shared with
* the other hermetic specs in this folder.
*/

test.describe("Gas Tendency chart — TODO phase 4", () => {
test.skip("renders on the Network page with mocked eth_feeHistory", async () => {});
test.skip("renders on the Blocks page with mocked eth_feeHistory", async () => {});
test.skip("is absent on Bitcoin and Solana routes", async () => {});
test.skip("hover tooltip shows block number, base fee, and gas used", async () => {});
});
3 changes: 3 additions & 0 deletions src/components/pages/evm/blocks/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { RPCIndicator } from "../../../common/RPCIndicator";
import Breadcrumb from "../../../common/Breadcrumb";
import GasTendencyChartSection from "../shared/GasTendencyChartSection";
import { getNetworkById } from "../../../../config/networks";
import { useDataService } from "../../../../hooks/useDataService";
import { useProviderSelection } from "../../../../hooks/useProviderSelection";
Expand Down Expand Up @@ -281,6 +282,8 @@ export default function Blocks() {
)}
</div>

<GasTendencyChartSection network={numericNetworkId} namespace="block" />

<div className="table-wrapper">
<table className="dash-table blocks-table">
<thead>
Expand Down
3 changes: 3 additions & 0 deletions src/components/pages/evm/network/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { resolveNetwork, getChainIdFromNetwork } from "../../../../utils/network
import { getAllNetworks } from "../../../../config/networks";
import SearchBox from "../../../common/SearchBox";
import DashboardStats from "./DashboardStats";
import GasTendencyChartSection from "../shared/GasTendencyChartSection";
import LatestBlocksTable from "./LatestBlocksTable";
import LatestTransactionsTable from "./LatestTransactionsTable";
import ProfileDisplay from "./NetworkProfileDisplay";
Expand Down Expand Up @@ -68,6 +69,8 @@ export default function Network() {
networkId={chainId}
/>

{network && <GasTendencyChartSection network={network} namespace="network" />}

<div className="dashboard-tables-row">
<LatestBlocksTable
blocks={dashboard.latestBlocks}
Expand Down
196 changes: 196 additions & 0 deletions src/components/pages/evm/shared/GasTendencyChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { FeeHistory } from "../../../../types";

interface Props {
feeHistory: FeeHistory | null;
isLoading: boolean;
}

const WEI_PER_GWEI = 1_000_000_000n;
const VIEW_WIDTH = 1000;
const VIEW_HEIGHT = 140;

const weiToGwei = (wei: bigint): number => {
const whole = wei / WEI_PER_GWEI;
const remainder = wei % WEI_PER_GWEI;
return Number(whole) + Number(remainder) / Number(WEI_PER_GWEI);
};

const formatGwei = (gwei: number): string => {
if (gwei >= 100) return gwei.toFixed(0);
if (gwei >= 10) return gwei.toFixed(1);
if (gwei >= 1) return gwei.toFixed(2);
return gwei.toFixed(3);
};

const GasTendencyChart: React.FC<Props> = ({ feeHistory, isLoading }) => {
const { t } = useTranslation("network");
const [hoverIdx, setHoverIdx] = useState<number | null>(null);

const chartData = useMemo(() => {
if (!feeHistory || feeHistory.gasUsedRatio.length === 0) return null;
const ratios = feeHistory.gasUsedRatio;
const baseFees = feeHistory.baseFeePerGas.slice(0, ratios.length); // align lengths
const gweis = baseFees.map(weiToGwei);
const minGwei = Math.min(...gweis);
const maxGwei = Math.max(...gweis);
const range = Math.max(maxGwei - minGwei, 1e-9);
return {
ratios,
gweis,
minGwei,
maxGwei,
range,
oldestBlock: feeHistory.oldestBlock,
newestBlock: feeHistory.oldestBlock + ratios.length - 1,
};
}, [feeHistory]);

if (isLoading && !chartData) {
return (
<div
className="gas-tendency-chart gas-tendency-chart-loading"
data-testid="gas-tendency-chart-loading"
>
<div className="gas-tendency-chart-skeleton" />
</div>
);
}

if (!chartData) {
return null;
}

const { ratios, gweis, minGwei, maxGwei, range, oldestBlock, newestBlock } = chartData;
const n = ratios.length;
const barWidth = VIEW_WIDTH / n;

const pointX = (i: number) => (i + 0.5) * barWidth;
const pointY = (gwei: number) => {
// 8% top/bottom padding so the line never hits the edges
const t = (gwei - minGwei) / range;
return VIEW_HEIGHT * (0.9 - 0.8 * t);
};

const linePath = gweis
.map((g, i) => `${i === 0 ? "M" : "L"} ${pointX(i).toFixed(2)} ${pointY(g).toFixed(2)}`)
.join(" ");

// Area under the line for soft fill
const areaPath = `${linePath} L ${pointX(n - 1).toFixed(2)} ${VIEW_HEIGHT} L ${pointX(0).toFixed(2)} ${VIEW_HEIGHT} Z`;

const latestGwei = gweis[n - 1];

return (
<div className="gas-tendency-chart" data-testid="gas-tendency-chart">
<div className="gas-tendency-chart-meta">
<span className="gas-tendency-chart-meta-value">
{formatGwei(latestGwei ?? 0)} <span className="gas-tendency-chart-meta-unit">gwei</span>
</span>
<span className="gas-tendency-chart-meta-range">
{formatGwei(minGwei)} – {formatGwei(maxGwei)} gwei
</span>
</div>

<div className="gas-tendency-chart-plot">
<span className="gas-tendency-chart-reference-label" aria-hidden="true">
50%
</span>
<svg
className="gas-tendency-chart-svg"
viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}
preserveAspectRatio="none"
aria-label={t("gasTendency.title")}
role="img"
onMouseMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const xRatio = (e.clientX - rect.left) / rect.width;
const idx = Math.min(n - 1, Math.max(0, Math.floor(xRatio * n)));
setHoverIdx(idx);
}}
onMouseLeave={() => setHoverIdx(null)}
>
<title>{t("gasTendency.title")}</title>
{/* Gas-used % bars behind the line */}
{ratios.map((r, i) => {
const h = Math.max(r * VIEW_HEIGHT, 1);
return (
<rect
key={`bar-${oldestBlock + i}`}
x={i * barWidth}
y={VIEW_HEIGHT - h}
width={Math.max(barWidth - 1, 1)}
height={h}
className="gas-tendency-chart-bar"
/>
);
})}

{/* 50% gas-used reference line */}
<line
x1={0}
x2={VIEW_WIDTH}
y1={VIEW_HEIGHT / 2}
y2={VIEW_HEIGHT / 2}
className="gas-tendency-chart-reference-line"
/>

{/* Base fee area + line */}
<path d={areaPath} className="gas-tendency-chart-area" />
<path d={linePath} className="gas-tendency-chart-line" />

{/* Hover guide */}
{hoverIdx !== null && gweis[hoverIdx] !== undefined && (
<>
<line
x1={pointX(hoverIdx)}
x2={pointX(hoverIdx)}
y1={0}
y2={VIEW_HEIGHT}
className="gas-tendency-chart-guide"
/>
<circle
cx={pointX(hoverIdx)}
cy={pointY(gweis[hoverIdx] as number)}
r={4}
className="gas-tendency-chart-dot"
/>
</>
)}
</svg>

{hoverIdx !== null && gweis[hoverIdx] !== undefined && (
<div
className="gas-tendency-chart-tooltip"
style={{ left: `${((hoverIdx + 0.5) / n) * 100}%` }}
data-testid="gas-tendency-chart-tooltip"
>
<div className="gas-tendency-chart-tooltip-block">
{t("gasTendency.tooltipBlock", { number: oldestBlock + hoverIdx })}
</div>
<div className="gas-tendency-chart-tooltip-row">
{t("gasTendency.tooltipBaseFee", { value: formatGwei(gweis[hoverIdx] as number) })}
</div>
<div className="gas-tendency-chart-tooltip-row">
{t("gasTendency.tooltipGasUsed", {
value: ((ratios[hoverIdx] ?? 0) * 100).toFixed(1),
})}
</div>
</div>
)}
</div>

<div className="gas-tendency-chart-axis">
<span>{t("gasTendency.blockShort", { number: oldestBlock })}</span>
<span className="gas-tendency-chart-axis-legend">
<span className="gas-tendency-chart-legend-line" /> {t("gasTendency.baseFee")}
<span className="gas-tendency-chart-legend-bar" /> {t("gasTendency.gasUsed")}
</span>
<span>{t("gasTendency.blockShort", { number: newestBlock })}</span>
</div>
</div>
);
};

export default GasTendencyChart;
40 changes: 40 additions & 0 deletions src/components/pages/evm/shared/GasTendencyChartSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useTranslation } from "react-i18next";
import type { NetworkConfig } from "../../../../types";
import { useFeeHistory } from "../../../../hooks/useFeeHistory";
import GasTendencyChart from "./GasTendencyChart";

interface Props {
network: NetworkConfig | number;
namespace?: "network" | "block";
blockCount?: number;
}

const DEFAULT_BLOCK_COUNT = 50;

const GasTendencyChartSection: React.FC<Props> = ({
network,
namespace = "network",
blockCount = DEFAULT_BLOCK_COUNT,
}) => {
const { t } = useTranslation(namespace);
const { feeHistory, isLoading, error } = useFeeHistory(network, blockCount);

// Hide the section entirely on error or when there's no usable data after loading.
if (error || (!isLoading && (!feeHistory || feeHistory.gasUsedRatio.length === 0))) {
return null;
}

return (
<section
className="dashboard-table-section gas-tendency-section"
data-testid="gas-tendency-section"
>
<header className="dashboard-table-header">
<h2 className="dashboard-table-title">{t("gasTendency.title")}</h2>
</header>
<GasTendencyChart feeHistory={feeHistory} isLoading={isLoading} />
</section>
);
};

export default GasTendencyChartSection;
Loading