diff --git a/packages/widget/src/features/earn/ui/dashboard/earn-details/components/history-chart-section.tsx b/packages/widget/src/features/earn/ui/dashboard/earn-details/components/history-chart-section.tsx index afb45022..f3d54325 100644 --- a/packages/widget/src/features/earn/ui/dashboard/earn-details/components/history-chart-section.tsx +++ b/packages/widget/src/features/earn/ui/dashboard/earn-details/components/history-chart-section.tsx @@ -18,6 +18,7 @@ export const HistoryChartSection = ({ tickFormatter, title, value, + valueFormatter, view, }: { chartId: string; @@ -25,6 +26,7 @@ export const HistoryChartSection = ({ tickFormatter: (value: number) => string; title: string; value: string; + valueFormatter?: (value: number) => string; view: YieldHistoryChartView; }) => ( @@ -60,6 +62,7 @@ export const HistoryChartSection = ({ isRefreshing={view.isRefreshing} refreshKey={view.period} tickFormatter={tickFormatter} + valueFormatter={valueFormatter} /> ); diff --git a/packages/widget/src/features/earn/ui/dashboard/earn-details/reward-rate-chart.tsx b/packages/widget/src/features/earn/ui/dashboard/earn-details/reward-rate-chart.tsx index 3fe78918..5c163785 100644 --- a/packages/widget/src/features/earn/ui/dashboard/earn-details/reward-rate-chart.tsx +++ b/packages/widget/src/features/earn/ui/dashboard/earn-details/reward-rate-chart.tsx @@ -1,6 +1,7 @@ import { DateTime } from "effect"; import { useId } from "react"; -import { Area, AreaChart, XAxis, YAxis } from "recharts"; +import { useTranslation } from "react-i18next"; +import { Area, AreaChart, Tooltip, XAxis, YAxis } from "recharts"; import type { HistoryPeriod, HistoryPoint, @@ -16,6 +17,9 @@ import { chartContainer, chartLoadingOverlay, chartSurface, + chartTooltipContainer, + chartTooltipDate, + chartTooltipValue, emptyChartContainer, } from "./styles.css"; @@ -26,6 +30,7 @@ type Props = { isRefreshing: boolean; refreshKey: HistoryPeriod; tickFormatter: (value: number) => string; + valueFormatter?: (value: number) => string; }; const height = 150; @@ -38,6 +43,59 @@ type EndpointDotProps = { index?: number; }; +export type ChartTooltipProps = { + active?: boolean; + chartId?: string; + formatValue?: (value: number) => string; + locale?: string; + payload?: ReadonlyArray<{ + payload?: HistoryPoint; + value?: number; + }>; +}; + +export const ChartTooltip = ({ + active, + chartId, + formatValue, + locale = "en", + payload, +}: ChartTooltipProps) => { + if (!active || !payload?.length) { + return null; + } + + const point = payload[0]?.payload; + if ( + !point?.timestamp || + point.value == null || + !Number.isFinite(point.value) + ) { + return null; + } + + const formattedValue = formatValue + ? formatValue(point.value) + : `${point.value}`; + const formattedDate = DateTime.formatUtc(point.timestamp, { + locale, + month: "short", + day: "numeric", + year: "numeric", + }); + + return ( + + + {formattedValue} + + + {formattedDate} + + + ); +}; + export const HistoryChart = ({ chartId, data, @@ -45,7 +103,11 @@ export const HistoryChart = ({ isRefreshing, refreshKey, tickFormatter, + valueFormatter, }: Props) => { + const { i18n } = useTranslation(); + const locale = i18n.resolvedLanguage ?? i18n.language ?? "en"; + const formatValue = valueFormatter ?? tickFormatter; const gradientId = `${chartId}-gradient-${useId().replaceAll(":", "")}`; const showRefreshChrome = useDelayedBusy(isRefreshing, refreshKey); @@ -103,6 +165,26 @@ export const HistoryChart = ({ ); }; + const renderActiveDot = ({ cx, cy }: EndpointDotProps) => { + if (cx == null || cy == null) { + return ; + } + + return ( + + + + + ); + }; + return ( @@ -139,8 +221,25 @@ export const HistoryChart = ({ width={46} /> + + } + cursor={{ + stroke: vars.color.tabBorder, + strokeDasharray: "3 3", + strokeWidth: 1, + }} + isAnimationActive={false} + /> + + Schema.decodeSync(UtcDateTimeFromString)(iso); + +const samplePoints: ReadonlyArray = [ + { + timestamp: parseDate("2026-06-01T00:00:00.000Z"), + value: 6.5, + }, + { + timestamp: parseDate("2026-06-15T00:00:00.000Z"), + value: 6.85, + }, + { + timestamp: parseDate("2026-07-01T00:00:00.000Z"), + value: 7.19, + }, +]; + +describe("HistoryChart browser interactions", () => { + it("displays hover container with point details and active dot on mouse move", async () => { + const screen = await render( + +
+ `${val.toFixed(2)}%`} + /> +
+
+ ); + + const surface = screen.container.querySelector("svg.recharts-surface"); + expect(surface).toBeTruthy(); + + if (!surface) return; + // Hover over the chart surface + await userEvent.hover(surface); + const tooltip = screen.getByTestId("reward-rate-tooltip"); + await expect.element(tooltip).toBeVisible(); + await expect.element(tooltip.getByText("6.85%")).toBeVisible(); + await expect.element(tooltip.getByText("Jun 15, 2026")).toBeVisible(); + + const activeDot = screen.container.querySelector( + 'g[key*="reward-rate-active-dot"], circle[r="3.5"]' + ); + expect(activeDot).toBeTruthy(); + }); +}); diff --git a/packages/widget/tests/pages-dashboard/history-chart.dom.test.tsx b/packages/widget/tests/pages-dashboard/history-chart.dom.test.tsx new file mode 100644 index 00000000..493f14e3 --- /dev/null +++ b/packages/widget/tests/pages-dashboard/history-chart.dom.test.tsx @@ -0,0 +1,149 @@ +import { type DateTime, Schema } from "effect"; +import { I18nextProvider } from "react-i18next"; +import { describe, expect, it, vi } from "vitest"; +import { UtcDateTimeFromString } from "../../src/domain/finance/scalars"; +import type { HistoryPoint } from "../../src/domain/portfolio/models"; +import { + ChartTooltip, + HistoryChart, +} from "../../src/features/earn/ui/dashboard/earn-details/reward-rate-chart"; +import { createWidgetI18nInstance } from "../../src/services/translation/widget-translation"; +import { render } from "../utils/test-utils.dom.tsx"; + +const i18nInstance = createWidgetI18nInstance(); + +const parseDate = (iso: string): DateTime.Utc => + Schema.decodeSync(UtcDateTimeFromString)(iso); + +const samplePoints: ReadonlyArray = [ + { + timestamp: parseDate("2026-06-01T00:00:00.000Z"), + value: 6.5, + }, + { + timestamp: parseDate("2026-06-15T00:00:00.000Z"), + value: 6.85, + }, + { + timestamp: parseDate("2026-07-01T00:00:00.000Z"), + value: 7.19, + }, +]; + +describe("HistoryChart component", () => { + it("renders empty state when data has fewer than 2 points", async () => { + const { container, unmount } = await render( + + `${val}%`} + /> + + ); + + expect(container.textContent).toContain("No chart data"); + unmount(); + }); + + it("renders chart surface with SVG when points are provided", async () => { + const { container, unmount } = await render( + + `${val.toFixed(2)}%`} + /> + + ); + + expect(container.querySelector("svg.recharts-surface")).toBeTruthy(); + expect(container.textContent).not.toContain("No chart data"); + unmount(); + }); +}); + +describe("ChartTooltip component", () => { + it("returns null when not active or payload is empty", async () => { + const { container: c1, unmount: u1 } = await render( + + ); + expect(c1.children.length).toBe(0); + u1(); + + const { container: c2, unmount: u2 } = await render( + + ); + expect(c2.children.length).toBe(0); + u2(); + }); + + it("preserves the UTC snapshot date west of UTC", async () => { + vi.stubEnv("TZ", "America/Los_Angeles"); + + try { + const point = samplePoints[1]!; + const { container, unmount } = await render( + `${val.toFixed(2)}%`} + locale="en" + payload={[{ payload: point, value: point.value }]} + /> + ); + + const tooltip = container.querySelector( + '[data-testid="reward-rate-tooltip"]' + ); + expect(tooltip).toBeTruthy(); + expect(tooltip?.textContent).toContain("6.85%"); + expect(tooltip?.textContent).toContain("Jun 15, 2026"); + unmount(); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("formats dates with French locale when specified", async () => { + const point = samplePoints[1]!; + const { container, unmount } = await render( + `${val.toFixed(2)}%`} + locale="fr" + payload={[{ payload: point, value: point.value }]} + /> + ); + + const tooltip = container.querySelector( + '[data-testid="reward-rate-tooltip"]' + ); + expect(tooltip?.textContent).toContain("6.85%"); + expect(tooltip?.textContent).toContain("15 juin 2026"); + unmount(); + }); + + it("safely returns null if data point value is missing or non-finite", async () => { + const invalidPoint = { + timestamp: parseDate("2026-06-01T00:00:00.000Z"), + value: Number.NaN, + }; + const { container, unmount } = await render( + + ); + + expect(container.children.length).toBe(0); + unmount(); + }); +});