Skip to content
Closed
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
2 changes: 1 addition & 1 deletion frontend/src/components/APYTrendChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -410,4 +410,4 @@ const APYTrendChart: React.FC<APYTrendChartProps> = ({ data = ALL_HISTORY }) =>
);
};

export default APYTrendChart;
export default React.memo(APYTrendChart);
2 changes: 1 addition & 1 deletion frontend/src/components/VaultPerformanceChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,4 +258,4 @@ const VaultPerformanceChart: React.FC = () => {
);
};

export default VaultPerformanceChart;
export default React.memo(VaultPerformanceChart);
81 changes: 81 additions & 0 deletions frontend/src/components/YieldBreakdownChart.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import("recharts")>();
return {
...actual,
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div data-testid="responsive-container">{children}</div>
),
};
});

// ─── Tests ────────────────────────────────────────────────────────────────────

describe("YieldBreakdownChart", () => {
beforeEach(() => {
vi.stubEnv("NODE_ENV", "test");
});

it("renders the section heading", () => {
render(<YieldBreakdownChart totalGain={1000} />);
expect(screen.getByRole("heading", { name: /yield earnings/i })).toBeInTheDocument();
});

it("defaults to the 30D period", () => {
render(<YieldBreakdownChart totalGain={1000} />);
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(<YieldBreakdownChart totalGain={1000} />);
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(<YieldBreakdownChart totalGain={0} />);
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(<YieldBreakdownChart totalGain={3650} />);
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();
});
});
40 changes: 33 additions & 7 deletions frontend/src/components/YieldBreakdownChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -100,15 +103,22 @@ const YieldBreakdownChart: React.FC<YieldBreakdownChartProps> = ({ 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;
Expand Down Expand Up @@ -212,15 +222,30 @@ const YieldBreakdownChart: React.FC<YieldBreakdownChartProps> = ({ totalGain })
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fill: "var(--text-secondary)", fontSize: 11 }} tickFormatter={(str: string) => formatDate(str, { month: "short", day: "numeric" }, locale)} minTickGap={28} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: "var(--text-secondary)", fontSize: 11 }} tickFormatter={createChartCurrencyTickFormatter(currency, locale, true)} />
<Tooltip content={(props: { active?: boolean; payload?: ReadonlyArray<{ value?: number }>; label?: string }) => <YieldTooltip {...props} locale={locale} currency={currency} />} />
<Bar dataKey="yield" fill="var(--accent-purple)" radius={[4, 4, 0, 0]} animationDuration={600} />
<Bar
dataKey="yield"
fill="var(--accent-purple)"
radius={[4, 4, 0, 0]}
isAnimationActive={!isCompactSeries}
animationDuration={isCompactSeries ? 0 : 600}
/>
</BarChart>
) : chartMode === "area" ? (
<AreaChart data={data} margin={{ top: 8, right: 8, left: -20, bottom: 0 }} aria-label="Daily yield earnings area chart">
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" vertical={false} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fill: "var(--text-secondary)", fontSize: 11 }} tickFormatter={(str: string) => formatDate(str, { month: "short", day: "numeric" }, locale)} minTickGap={28} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: "var(--text-secondary)", fontSize: 11 }} tickFormatter={createChartCurrencyTickFormatter(currency, locale, true)} />
<Tooltip content={(props: { active?: boolean; payload?: ReadonlyArray<{ value?: number }>; label?: string }) => <YieldTooltip {...props} locale={locale} currency={currency} />} />
<Area type="monotone" dataKey="yield" stroke="var(--accent-purple)" strokeWidth={2} fill="var(--accent-purple)" fillOpacity={0.2} animationDuration={600} />
<Area
type="monotone"
dataKey="yield"
stroke="var(--accent-purple)"
strokeWidth={2}
fill="var(--accent-purple)"
fillOpacity={0.2}
isAnimationActive={!isCompactSeries}
animationDuration={isCompactSeries ? 0 : 600}
/>
</AreaChart>
) : (
<LineChart
Expand Down Expand Up @@ -266,7 +291,8 @@ const YieldBreakdownChart: React.FC<YieldBreakdownChartProps> = ({ totalGain })
strokeWidth={2}
dot={false}
activeDot={{ r: 4, fill: "var(--accent-purple)" }}
animationDuration={600}
isAnimationActive={!isCompactSeries}
animationDuration={isCompactSeries ? 0 : 600}
/>
</LineChart>
)}
Expand All @@ -277,4 +303,4 @@ const YieldBreakdownChart: React.FC<YieldBreakdownChartProps> = ({ totalGain })
);
};

export default YieldBreakdownChart;
export default React.memo(YieldBreakdownChart);
Loading