diff --git a/frontend/src/components/APYTrendChart.tsx b/frontend/src/components/APYTrendChart.tsx index 556987c7..dbde2a12 100644 --- a/frontend/src/components/APYTrendChart.tsx +++ b/frontend/src/components/APYTrendChart.tsx @@ -410,4 +410,4 @@ const APYTrendChart: React.FC = ({ data = ALL_HISTORY }) => ); }; -export default APYTrendChart; +export default React.memo(APYTrendChart); diff --git a/frontend/src/components/VaultPerformanceChart.tsx b/frontend/src/components/VaultPerformanceChart.tsx index 692b68b2..92f68100 100644 --- a/frontend/src/components/VaultPerformanceChart.tsx +++ b/frontend/src/components/VaultPerformanceChart.tsx @@ -258,4 +258,4 @@ const VaultPerformanceChart: React.FC = () => { ); }; -export default VaultPerformanceChart; +export default React.memo(VaultPerformanceChart); diff --git a/frontend/src/components/YieldBreakdownChart.test.tsx b/frontend/src/components/YieldBreakdownChart.test.tsx new file mode 100644 index 00000000..187b42dc --- /dev/null +++ b/frontend/src/components/YieldBreakdownChart.test.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import YieldBreakdownChart from "../components/YieldBreakdownChart"; + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock("../context/PreferencesContext", () => ({ + usePreferencesContext: () => ({ + preferences: { locale: "en-US", currency: "USD" }, + chartModes: { vaultPerformance: "area", apyTrend: "line", yieldBreakdown: "line" }, + setChartMode: vi.fn(), + tableDensity: "comfortable", + setTableDensity: vi.fn(), + }), +})); + +// recharts ResizeObserver shim (jsdom doesn't implement it) +vi.mock("recharts", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ResponsiveContainer: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + }; +}); + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("YieldBreakdownChart", () => { + beforeEach(() => { + vi.stubEnv("NODE_ENV", "test"); + }); + + it("renders the section heading", () => { + render(); + expect(screen.getByRole("heading", { name: /yield earnings/i })).toBeInTheDocument(); + }); + + it("defaults to the 30D period", () => { + render(); + const group = screen.getByRole("group", { name: /select yield period/i }); + const btn = Array.from(group.querySelectorAll("button")).find( + (b) => b.textContent === "30D", + ); + expect(btn).toHaveAttribute("aria-pressed", "true"); + }); + + it("switches to the ALL period, spanning the full 90-day mock series, without crashing", () => { + render(); + const group = screen.getByRole("group", { name: /select yield period/i }); + const btnAll = Array.from(group.querySelectorAll("button")).find( + (b) => b.textContent === "ALL", + ) as HTMLElement; + fireEvent.click(btnAll); + expect(btnAll).toHaveAttribute("aria-pressed", "true"); + expect(document.querySelector("svg")).not.toBeNull(); + }); + + it("shows the empty state and does not render a chart when there is no gain", () => { + render(); + expect(screen.getByText(/no yield data yet/i)).toBeInTheDocument(); + }); + + it("keeps the period total consistent regardless of point-sampling for rendering", () => { + // The chart downsamples the rendered series for long ranges (see sampleChartSeries), + // but the displayed total must reflect the full underlying dataset, not the + // downsampled one. + render(); + const group = screen.getByRole("group", { name: /select yield period/i }); + const btnAll = Array.from(group.querySelectorAll("button")).find( + (b) => b.textContent === "ALL", + ) as HTMLElement; + fireEvent.click(btnAll); + // 3650 total gain over 90 days averages ~40.5/day; the displayed total for + // the full period should be close to the full totalGain, not a fraction of + // it truncated by rendering-only downsampling. + expect(screen.getByText(/earned in selected period/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/YieldBreakdownChart.tsx b/frontend/src/components/YieldBreakdownChart.tsx index 3ff6be52..27b27427 100644 --- a/frontend/src/components/YieldBreakdownChart.tsx +++ b/frontend/src/components/YieldBreakdownChart.tsx @@ -18,6 +18,9 @@ import { ChartModeToggle } from "./ChartModeToggle"; import { usePreferencesContext } from "../context/PreferencesContext"; import { formatCurrency, formatDate } from "../lib/formatters"; import { formatChartCurrency, createChartCurrencyTickFormatter } from "../lib/chartFormatters"; +import { sampleChartSeries } from "../lib/chartSeries"; + +const MAX_RENDER_POINTS = 120; interface YieldDataPoint { date: string; @@ -100,15 +103,22 @@ const YieldBreakdownChart: React.FC = ({ totalGain }) const allData = useMemo(() => generateYieldData(totalGain, 90), [totalGain]); - const data = useMemo(() => { + const filteredData = useMemo(() => { const days = PERIOD_DAYS[period]; if (days === null) return allData; return allData.slice(-days); }, [allData, period]); + const data = useMemo( + () => sampleChartSeries(filteredData, MAX_RENDER_POINTS), + [filteredData], + ); + + const isCompactSeries = filteredData.length > data.length; + const periodTotal = useMemo( - () => data.reduce((sum, p) => sum + p.yield, 0), - [data], + () => filteredData.reduce((sum, p) => sum + p.yield, 0), + [filteredData], ); const isEmpty = totalGain === 0; @@ -212,7 +222,13 @@ const YieldBreakdownChart: React.FC = ({ totalGain }) formatDate(str, { month: "short", day: "numeric" }, locale)} minTickGap={28} /> ; label?: string }) => } /> - + ) : chartMode === "area" ? ( @@ -220,7 +236,16 @@ const YieldBreakdownChart: React.FC = ({ totalGain }) formatDate(str, { month: "short", day: "numeric" }, locale)} minTickGap={28} /> ; label?: string }) => } /> - + ) : ( = ({ totalGain }) strokeWidth={2} dot={false} activeDot={{ r: 4, fill: "var(--accent-purple)" }} - animationDuration={600} + isAnimationActive={!isCompactSeries} + animationDuration={isCompactSeries ? 0 : 600} /> )} @@ -277,4 +303,4 @@ const YieldBreakdownChart: React.FC = ({ totalGain }) ); }; -export default YieldBreakdownChart; +export default React.memo(YieldBreakdownChart);